From deefabd8f8e0a388c47532ceb920463ecdd99574 Mon Sep 17 00:00:00 2001 From: Reactor Scram Date: Mon, 3 Jun 2024 09:32:08 -0500 Subject: [PATCH] refactor(firezone-tunnel): move routes and DNS control out of connlib and up to the Client (#5111) Refs #3636 (This pays down some of the technical debt from Linux DNS) Refs #4473 (This partially fulfills it) Refs #5068 (This is needed to make `FIREZONE_DNS_CONTROL` mandatory) As of dd6421: - On both Linux and Windows, DNS control and IP setting (i.e. `on_set_interface_config`) both move to the Client - On Windows, route setting stays in `tun_windows.rs`. Route setting in Windows requires us to know the interface index, which we don't know in the Client code. If we could pass opaque platform-specific data between the tunnel and the Client it would be easy. - On Linux, route setting moves to the Client and Gateway, which completely removes the `worker` task in `tun_linux.rs` - Notifying systemd that we're ready moves up to the headless Client / IPC service ```[tasklist] ### Before merging / notes - [x] Does DNS roaming work on Linux on `main`? I don't see where it hooks up. I think I only set up DNS in `Tun::new` (Yes, the `Tun` gets recreated every time we reconfigure the device) - [x] Fix Windows Clients - [x] Fix Gateway - [x] Make sure connlib doesn't get the DNS control method from the env var (will be fixed in #5068) - [x] De-dupe consts - [ ] ~~Add DNS control test~~ (failed) - [ ] Smoke test Linux - [ ] Smoke test Windows ``` --- rust/Cargo.lock | 13 +- rust/connlib/shared/Cargo.toml | 9 +- rust/connlib/shared/src/callbacks.rs | 16 + rust/connlib/shared/src/lib.rs | 25 +- rust/connlib/shared/src/linux.rs | 30 -- rust/connlib/shared/src/tun_device_manager.rs | 14 + .../shared/src/tun_device_manager/linux.rs | 230 ++++++++++++ .../shared/src/tun_device_manager/windows.rs | 63 ++++ rust/connlib/shared/src/windows.rs | 116 +----- rust/connlib/tunnel/Cargo.toml | 1 - rust/connlib/tunnel/src/device_channel.rs | 31 +- .../tunnel/src/device_channel/tun_linux.rs | 337 +----------------- .../tunnel/src/device_channel/tun_windows.rs | 134 +++---- rust/connlib/tunnel/src/gateway.rs | 7 - rust/connlib/tunnel/src/lib.rs | 3 - rust/connlib/tunnel/src/sockets.rs | 2 +- rust/gateway/src/main.rs | 15 +- rust/gui-client/src-tauri/src/client.rs | 1 - .../src-tauri/src/client/deep_link/windows.rs | 2 +- rust/gui-client/src-tauri/src/client/gui.rs | 10 +- rust/gui-client/src-tauri/src/client/ipc.rs | 10 +- .../src/client/network_changes/linux.rs | 5 +- .../src/client/network_changes/windows.rs | 5 +- .../src-tauri/src/client/resolvers.rs | 33 -- rust/headless-client/Cargo.toml | 17 +- .../src/device_id.rs | 20 +- rust/headless-client/src/dns_control.rs | 15 + rust/headless-client/src/dns_control/linux.rs | 204 +++++++++++ .../src/dns_control}/linux/etc_resolv_conf.rs | 10 +- .../src/dns_control/windows.rs | 128 +++++++ rust/headless-client/src/lib.rs | 162 +++++---- rust/headless-client/src/linux.rs | 101 +----- rust/headless-client/src/windows.rs | 27 +- .../src/windows/wintun_install.rs | 1 - 34 files changed, 924 insertions(+), 873 deletions(-) delete mode 100644 rust/connlib/shared/src/linux.rs create mode 100644 rust/connlib/shared/src/tun_device_manager.rs create mode 100644 rust/connlib/shared/src/tun_device_manager/linux.rs create mode 100644 rust/connlib/shared/src/tun_device_manager/windows.rs delete mode 100644 rust/gui-client/src-tauri/src/client/resolvers.rs rename rust/{connlib/shared => headless-client}/src/device_id.rs (90%) create mode 100644 rust/headless-client/src/dns_control.rs create mode 100644 rust/headless-client/src/dns_control/linux.rs rename rust/{connlib/shared/src => headless-client/src/dns_control}/linux/etc_resolv_conf.rs (98%) create mode 100644 rust/headless-client/src/dns_control/windows.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4d75de41b..3c4db2673 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1173,7 +1173,6 @@ name = "connlib-shared" version = "1.0.6" dependencies = [ "anyhow", - "atomicwrites", "base64 0.22.1", "boringtun", "chrono", @@ -1186,20 +1185,19 @@ dependencies = [ "known-folders", "libc", "log", - "mutants", + "netlink-packet-core", + "netlink-packet-route", "os_info", "phoenix-channel", "proptest", "rand 0.8.5", "rand_core 0.6.4", - "resolv-conf", "ring", "rtnetlink", "secrecy", "serde", "serde_json", "swift-bridge", - "tempfile", "thiserror", "tokio", "tracing", @@ -1985,6 +1983,7 @@ name = "firezone-headless-client" version = "1.0.6" dependencies = [ "anyhow", + "atomicwrites", "clap", "connlib-client-shared", "connlib-shared", @@ -1993,21 +1992,26 @@ dependencies = [ "futures", "git-version", "humantime", + "ip_network", "ipconfig", "known-folders", + "mutants", "nix 0.28.0", "resolv-conf", "ring", + "rtnetlink", "sd-notify", "secrecy", "serde", "serde_json", + "tempfile", "thiserror", "tokio", "tokio-util", "tracing", "tracing-subscriber", "url", + "uuid", "windows 0.56.0", "windows-service", ] @@ -2087,7 +2091,6 @@ dependencies = [ "rand_core 0.6.4", "rangemap", "rtnetlink", - "sd-notify", "secrecy", "serde", "serde_json", diff --git a/rust/connlib/shared/Cargo.toml b/rust/connlib/shared/Cargo.toml index 292e03c6d..9595b7c83 100644 --- a/rust/connlib/shared/Cargo.toml +++ b/rust/connlib/shared/Cargo.toml @@ -21,7 +21,6 @@ ip_network = { version = "0.4", default-features = false, features = ["serde"] } os_info = { version = "3", default-features = false } rand = { version = "0.8", default-features = false, features = ["std"] } rand_core = { version = "0.6.4", default-features = false, features = ["std"] } -resolv-conf = "0.7.0" serde = { version = "1.0", default-features = false, features = ["derive", "std"] } serde_json = { version = "1.0", default-features = false, features = ["std"] } thiserror = { version = "1.0", default-features = false } @@ -41,9 +40,7 @@ hickory-proto = { workspace = true, optional = true } log = "0.4" [dev-dependencies] -tempfile = "3.10.1" itertools = "0.12" -mutants = "0.0.3" # Needed to mark functions as exempt from `cargo-mutants` testing tokio = { version = "1.36", features = ["macros", "rt"] } [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] @@ -52,11 +49,9 @@ swift-bridge = { workspace = true } [target.'cfg(target_os = "android")'.dependencies] tracing-android = "0.2" -[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies] -# Needed to safely backup `/etc/resolv.conf` and write the device ID on behalf of `gui-client` -atomicwrites = "0.4.3" - [target.'cfg(target_os = "linux")'.dependencies] +netlink-packet-route = { version = "0.19", default-features = false } +netlink-packet-core = { version = "0.7", default-features = false } rtnetlink = { workspace = true } # Windows tunnel dependencies diff --git a/rust/connlib/shared/src/callbacks.rs b/rust/connlib/shared/src/callbacks.rs index e2d32d873..e9363b506 100644 --- a/rust/connlib/shared/src/callbacks.rs +++ b/rust/connlib/shared/src/callbacks.rs @@ -42,6 +42,22 @@ impl From for Cidrv6 { } } +impl From for IpNetwork { + fn from(x: Cidrv4) -> Self { + Ipv4Network::new(x.address, x.prefix) + .expect("A Cidrv4 should always translate to a valid Ipv4Network") + .into() + } +} + +impl From for IpNetwork { + fn from(x: Cidrv6) -> Self { + Ipv6Network::new(x.address, x.prefix) + .expect("A Cidrv6 should always translate to a valid Ipv6Network") + .into() + } +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Status { Unknown, diff --git a/rust/connlib/shared/src/lib.rs b/rust/connlib/shared/src/lib.rs index 4d83bd877..b4de67126 100644 --- a/rust/connlib/shared/src/lib.rs +++ b/rust/connlib/shared/src/lib.rs @@ -6,15 +6,7 @@ pub mod callbacks; pub mod error; pub mod messages; - -/// Module to generate and store a persistent device ID on disk -/// -/// Only properly implemented on Linux and Windows (platforms with Tauri and headless client) -#[cfg(any(target_os = "linux", target_os = "windows"))] -pub mod device_id; - -#[cfg(target_os = "linux")] -pub mod linux; +pub mod tun_device_manager; #[cfg(target_os = "windows")] pub mod windows; @@ -48,22 +40,11 @@ pub type DomainName = domain::base::Name>; /// pub const BUNDLE_ID: &str = "dev.firezone.client"; +pub const DEFAULT_MTU: u32 = 1280; + const VERSION: &str = env!("CARGO_PKG_VERSION"); const LIB_NAME: &str = "connlib"; -/// Deactivates DNS control on Windows -#[cfg(target_os = "windows")] -pub fn deactivate_dns_control() -> anyhow::Result<()> { - windows::dns::deactivate() -} - -/// Deactivates DNS control on other platforms (does nothing) -#[cfg(not(target_os = "windows"))] -#[allow(clippy::unnecessary_wraps)] -pub fn deactivate_dns_control() -> anyhow::Result<()> { - Ok(()) -} - pub fn keypair() -> (StaticSecret, PublicKey) { let private_key = StaticSecret::random_from_rng(OsRng); let public_key = PublicKey::from(&private_key); diff --git a/rust/connlib/shared/src/linux.rs b/rust/connlib/shared/src/linux.rs deleted file mode 100644 index fc4e907f2..000000000 --- a/rust/connlib/shared/src/linux.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Linux-specific things like DNS control methods - -const FIREZONE_DNS_CONTROL: &str = "FIREZONE_DNS_CONTROL"; - -pub mod etc_resolv_conf; - -#[derive(Clone, Debug)] -pub enum DnsControlMethod { - /// Back up `/etc/resolv.conf` and replace it with our own - /// - /// Only suitable for the Alpine CI containers and maybe something like an - /// embedded system - EtcResolvConf, - /// Cooperate with NetworkManager (TODO) - NetworkManager, - /// Cooperate with `systemd-resolved` - /// - /// Suitable for most Ubuntu systems, probably - Systemd, -} - -/// Reads FIREZONE_DNS_CONTROL. Returns None if invalid or not set -pub fn get_dns_control_from_env() -> Option { - match std::env::var(FIREZONE_DNS_CONTROL).as_deref() { - Ok("etc-resolv-conf") => Some(DnsControlMethod::EtcResolvConf), - Ok("network-manager") => Some(DnsControlMethod::NetworkManager), - Ok("systemd-resolved") => Some(DnsControlMethod::Systemd), - _ => None, - } -} diff --git a/rust/connlib/shared/src/tun_device_manager.rs b/rust/connlib/shared/src/tun_device_manager.rs new file mode 100644 index 000000000..725107c44 --- /dev/null +++ b/rust/connlib/shared/src/tun_device_manager.rs @@ -0,0 +1,14 @@ +//! DNS and route control for the virtual network interface in `firezone-tunnel` + +#[cfg(target_os = "linux")] +pub mod linux; +#[cfg(target_os = "linux")] +pub use linux as platform; + +#[cfg(target_os = "windows")] +pub mod windows; +#[cfg(target_os = "windows")] +pub use windows as platform; + +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub use platform::TunDeviceManager; diff --git a/rust/connlib/shared/src/tun_device_manager/linux.rs b/rust/connlib/shared/src/tun_device_manager/linux.rs new file mode 100644 index 000000000..6f62eff17 --- /dev/null +++ b/rust/connlib/shared/src/tun_device_manager/linux.rs @@ -0,0 +1,230 @@ +//! Virtual network interface + +use crate::{ + callbacks::{Cidrv4, Cidrv6}, + DEFAULT_MTU, +}; +use anyhow::{anyhow, Context as _, Result}; +use futures::TryStreamExt; +use ip_network::{IpNetwork, Ipv4Network, Ipv6Network}; +use netlink_packet_route::route::{RouteProtocol, RouteScope}; +use netlink_packet_route::rule::RuleAction; +use rtnetlink::{new_connection, Error::NetlinkError, Handle, RouteAddRequest, RuleAddRequest}; +use std::{ + collections::HashSet, + net::{Ipv4Addr, Ipv6Addr}, +}; + +pub const FIREZONE_MARK: u32 = 0xfd002021; +pub const IFACE_NAME: &str = "tun-firezone"; +const FILE_ALREADY_EXISTS: i32 = -17; +const FIREZONE_TABLE: u32 = 0x2021_fd00; + +/// For lack of a better name +pub struct TunDeviceManager { + connection: Connection, + routes: HashSet, +} + +struct Connection { + handle: Handle, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for TunDeviceManager { + fn drop(&mut self) { + self.connection.task.abort(); + } +} + +impl TunDeviceManager { + /// Creates a new managed tunnel device. + /// + /// Panics if called without a Tokio runtime. + pub fn new() -> Result { + let (cxn, handle, _) = new_connection()?; + let task = tokio::spawn(cxn); + let connection = Connection { handle, task }; + + Ok(Self { + connection, + routes: Default::default(), + }) + } + + #[tracing::instrument(level = "trace", skip(self))] + pub async fn set_ips(&mut self, ipv4: Ipv4Addr, ipv6: Ipv6Addr) -> Result<()> { + let handle = &self.connection.handle; + let index = handle + .link() + .get() + .match_name(IFACE_NAME.to_string()) + .execute() + .try_next() + .await? + .ok_or_else(|| anyhow!("No interface"))? + .header + .index; + + let ips = handle + .address() + .get() + .set_link_index_filter(index) + .execute(); + + ips.try_for_each(|ip| handle.address().del(ip).execute()) + .await?; + + handle.link().set(index).mtu(DEFAULT_MTU).execute().await?; + + let res_v4 = handle.address().add(index, ipv4.into(), 32).execute().await; + let res_v6 = handle + .address() + .add(index, ipv6.into(), 128) + .execute() + .await; + + handle.link().set(index).up().execute().await?; + + if res_v4.is_ok() { + if let Err(e) = make_rule(handle).v4().execute().await { + if !matches!(&e, NetlinkError(err) if err.raw_code() == FILE_ALREADY_EXISTS) { + tracing::warn!( + "Couldn't add ip rule for ipv4: {e:?}, ipv4 packets won't be routed" + ); + } + // TODO: Be smarter about this + } else { + tracing::debug!("Successfully created ip rule for ipv4"); + } + } + + if res_v6.is_ok() { + if let Err(e) = make_rule(handle).v6().execute().await { + if !matches!(&e, NetlinkError(err) if err.raw_code() == FILE_ALREADY_EXISTS) { + tracing::warn!( + "Couldn't add ip rule for ipv6: {e:?}, ipv6 packets won't be routed" + ); + } + // TODO: Be smarter about this + } else { + tracing::debug!("Successfully created ip rule for ipv6"); + } + } + + res_v4.or(res_v6)?; + + Ok(()) + } + + #[tracing::instrument(level = "trace", skip(self))] + pub async fn set_routes(&mut self, ipv4: Vec, ipv6: Vec) -> Result<()> { + let new_routes: HashSet = ipv4 + .into_iter() + .map(IpNetwork::from) + .chain(ipv6.into_iter().map(IpNetwork::from)) + .collect(); + if new_routes == self.routes { + return Ok(()); + } + tracing::info!(?new_routes, "set_routes"); + let handle = &self.connection.handle; + + let index = handle + .link() + .get() + .match_name(IFACE_NAME.to_string()) + .execute() + .try_next() + .await? + .context("No interface")? + .header + .index; + + for route in new_routes.difference(&self.routes) { + add_route(route, index, handle).await?; + } + + for route in self.routes.difference(&new_routes) { + delete_route(route, index, handle).await?; + } + + self.routes = new_routes; + Ok(()) + } +} + +fn make_rule(handle: &Handle) -> RuleAddRequest { + let mut rule = handle + .rule() + .add() + .fw_mark(FIREZONE_MARK) + .table_id(FIREZONE_TABLE) + .action(RuleAction::ToTable); + + rule.message_mut() + .header + .flags + .push(netlink_packet_route::rule::RuleFlag::Invert); + + rule.message_mut() + .attributes + .push(netlink_packet_route::rule::RuleAttribute::Protocol( + RouteProtocol::Kernel, + )); + + rule +} + +fn make_route(idx: u32, handle: &Handle) -> RouteAddRequest { + handle + .route() + .add() + .output_interface(idx) + .protocol(RouteProtocol::Static) + .scope(RouteScope::Universe) + .table_id(FIREZONE_TABLE) +} + +fn make_route_v4(idx: u32, handle: &Handle, route: Ipv4Network) -> RouteAddRequest { + make_route(idx, handle) + .v4() + .destination_prefix(route.network_address(), route.netmask()) +} + +fn make_route_v6(idx: u32, handle: &Handle, route: Ipv6Network) -> RouteAddRequest { + make_route(idx, handle) + .v6() + .destination_prefix(route.network_address(), route.netmask()) +} + +async fn add_route(route: &IpNetwork, idx: u32, handle: &Handle) -> Result<()> { + let res = match route { + IpNetwork::V4(ipnet) => make_route_v4(idx, handle, *ipnet).execute().await, + IpNetwork::V6(ipnet) => make_route_v6(idx, handle, *ipnet).execute().await, + }; + + match res { + Ok(_) => {} + Err(NetlinkError(err)) if err.raw_code() == FILE_ALREADY_EXISTS => {} + // TODO: we should be able to surface this error and handle it depending on + // if any of the added routes succeeded. + Err(err) => Err(err).context("Failed to add route")?, + } + Ok(()) +} + +async fn delete_route(route: &IpNetwork, idx: u32, handle: &Handle) -> Result<()> { + let message = match route { + IpNetwork::V4(ipnet) => make_route_v4(idx, handle, *ipnet).message_mut().clone(), + IpNetwork::V6(ipnet) => make_route_v6(idx, handle, *ipnet).message_mut().clone(), + }; + + handle + .route() + .del(message) + .execute() + .await + .context("Failed to delete route")?; + Ok(()) +} diff --git a/rust/connlib/shared/src/tun_device_manager/windows.rs b/rust/connlib/shared/src/tun_device_manager/windows.rs new file mode 100644 index 000000000..906c895df --- /dev/null +++ b/rust/connlib/shared/src/tun_device_manager/windows.rs @@ -0,0 +1,63 @@ +use crate::{ + windows::{CREATE_NO_WINDOW, TUNNEL_NAME}, + Cidrv4, Cidrv6, +}; +use anyhow::Result; +use std::{ + net::{Ipv4Addr, Ipv6Addr}, + os::windows::process::CommandExt, + process::{Command, Stdio}, +}; + +pub struct TunDeviceManager {} + +impl TunDeviceManager { + // Fallible on Linux + #[allow(clippy::unnecessary_wraps)] + pub fn new() -> Result { + Ok(Self {}) + } + + #[tracing::instrument(level = "trace", skip(self))] + pub async fn set_ips(&mut self, ipv4: Ipv4Addr, ipv6: Ipv6Addr) -> Result<()> { + tracing::debug!("Setting our IPv4 = {}", ipv4); + tracing::debug!("Setting our IPv6 = {}", ipv6); + + // TODO: See if there's a good Win32 API for this + // Using netsh directly instead of wintun's `set_network_addresses_tuple` because their code doesn't work for IPv6 + Command::new("netsh") + .creation_flags(CREATE_NO_WINDOW) + .arg("interface") + .arg("ipv4") + .arg("set") + .arg("address") + .arg(format!("name=\"{TUNNEL_NAME}\"")) + .arg("source=static") + .arg(format!("address={}", ipv4)) + .arg("mask=255.255.255.255") + .stdout(Stdio::null()) + .status()?; + + Command::new("netsh") + .creation_flags(CREATE_NO_WINDOW) + .arg("interface") + .arg("ipv6") + .arg("set") + .arg("address") + .arg(format!("interface=\"{TUNNEL_NAME}\"")) + .arg(format!("address={}", ipv6)) + .stdout(Stdio::null()) + .status()?; + + Ok(()) + } + + #[tracing::instrument(level = "trace", skip(self))] + pub async fn set_routes(&mut self, _: Vec, _: Vec) -> Result<()> { + // TODO: Windows still does route updates in `tun_windows.rs`. I can move it up + // here, but since the Client and Gateway don't know the index of the WinTun + // interface, I'd have to use the Windows API + // + unimplemented!() + } +} diff --git a/rust/connlib/shared/src/windows.rs b/rust/connlib/shared/src/windows.rs index ea71ccf2b..97cf4dc07 100644 --- a/rust/connlib/shared/src/windows.rs +++ b/rust/connlib/shared/src/windows.rs @@ -4,6 +4,15 @@ use crate::Error; use known_folders::{get_known_folder_path, KnownFolder}; use std::path::PathBuf; +/// Hides Powershell's console on Windows +/// +/// +/// Also used for self-elevation +pub const CREATE_NO_WINDOW: u32 = 0x08000000; + +// wintun automatically append " Tunnel" to this +pub const TUNNEL_NAME: &str = "Firezone"; + /// Returns e.g. `C:/Users/User/AppData/Local/dev.firezone.client /// /// This is where we can save config, logs, crash dumps, etc. @@ -16,113 +25,6 @@ pub fn app_local_data_dir() -> Result { Ok(path) } -pub mod dns { - //! Gives Firezone DNS privilege over other DNS resolvers on the system - //! - //! This uses NRPT and claims all domains, similar to the `systemd-resolved` control method - //! on Linux. - //! This allows us to "shadow" DNS resolvers that are configured by the user or DHCP on - //! physical interfaces, as long as they don't have any NRPT rules that outrank us. - //! - //! If Firezone crashes, restarting Firezone and closing it gracefully will resume - //! normal DNS operation. The Powershell command to remove the NRPT rule can also be run - //! by hand. - //! - //! The system default resolvers don't need to be reverted because they're never deleted. - //! - //! - - use anyhow::Result; - use std::{net::IpAddr, os::windows::process::CommandExt, process::Command}; - - /// Hides Powershell's console on Windows - /// - /// - /// Also used for self-elevation - const CREATE_NO_WINDOW: u32 = 0x08000000; - - // Unique magic number that we can use to delete our well-known NRPT rule. - // Copied from the deep link schema - const FZ_MAGIC: &str = "firezone-fd0020211111"; - - /// Tells Windows to send all DNS queries to our sentinels - /// - /// Parameters: - /// - `dns_config_string`: Comma-separated IP addresses of DNS servers, e.g. "1.1.1.1,8.8.8.8" - pub fn activate(dns_config: &[IpAddr], iface_idx: u32) -> Result<()> { - let dns_config_string = dns_config - .iter() - .map(|ip| format!("\"{ip}\"")) - .collect::>() - .join(","); - - // Set our DNS IP as the DNS server for our interface - // TODO: Known issue where web browsers will keep a connection open to a site, - // using QUIC, HTTP/2, or even HTTP/1.1, and so they won't resolve the DNS - // again unless you let that connection time out: - // - // TODO: If we have a Windows gateway, it shouldn't configure DNS, right? - Command::new("powershell") - .creation_flags(CREATE_NO_WINDOW) - .arg("-Command") - .arg(format!("Set-DnsClientServerAddress -InterfaceIndex {iface_idx} -ServerAddresses({dns_config_string})")) - .status()?; - - tracing::info!("Activating DNS control"); - Command::new("powershell") - .creation_flags(CREATE_NO_WINDOW) - .args([ - "-Command", - "Add-DnsClientNrptRule", - "-Namespace", - ".", - "-Comment", - FZ_MAGIC, - "-NameServers", - &dns_config_string, - ]) - .status()?; - Ok(()) - } - - /// Tells Windows to send all DNS queries to this new set of sentinels - /// - /// Currently implemented as just removing the rule and re-adding it, which - /// creates a gap but doesn't require us to parse Powershell output to figure - /// out the rule's UUID. - /// - /// Parameters: - /// - `dns_config_string` - Passed verbatim to [`activate`] - pub fn change(dns_config: &[IpAddr], iface_idx: u32) -> Result<()> { - deactivate()?; - activate(dns_config, iface_idx)?; - Ok(()) - } - - pub fn deactivate() -> Result<()> { - Command::new("powershell") - .creation_flags(CREATE_NO_WINDOW) - .args(["-Command", "Get-DnsClientNrptRule", "|"]) - .args(["where", "Comment", "-eq", FZ_MAGIC, "|"]) - .args(["foreach", "{"]) - .args(["Remove-DnsClientNrptRule", "-Name", "$_.Name", "-Force"]) - .args(["}"]) - .status()?; - tracing::info!("Deactivated DNS control"); - Ok(()) - } - - /// Flush Windows' system-wide DNS cache - pub fn flush() -> Result<()> { - tracing::info!("Flushing Windows DNS cache"); - Command::new("powershell") - .creation_flags(CREATE_NO_WINDOW) - .args(["-Command", "Clear-DnsClientCache"]) - .status()?; - Ok(()) - } -} - /// Returns the absolute path for installing and loading `wintun.dll` /// /// e.g. `C:\Users\User\AppData\Local\dev.firezone.client\data\wintun.dll` diff --git a/rust/connlib/tunnel/Cargo.toml b/rust/connlib/tunnel/Cargo.toml index c958ec817..327778eed 100644 --- a/rust/connlib/tunnel/Cargo.toml +++ b/rust/connlib/tunnel/Cargo.toml @@ -55,7 +55,6 @@ proptest = ["dep:proptest", "connlib-shared/proptest"] netlink-packet-route = { version = "0.19", default-features = false } netlink-packet-core = { version = "0.7", default-features = false } rtnetlink = { workspace = true } -sd-notify = "0.4.1" # Android tunnel dependencies [target.'cfg(target_os = "android")'.dependencies] diff --git a/rust/connlib/tunnel/src/device_channel.rs b/rust/connlib/tunnel/src/device_channel.rs index e62d66928..e613e7aeb 100644 --- a/rust/connlib/tunnel/src/device_channel.rs +++ b/rust/connlib/tunnel/src/device_channel.rs @@ -1,6 +1,3 @@ -#![allow(clippy::module_inception)] -#![cfg_attr(target_family = "windows", allow(dead_code))] // TODO: Remove when windows is fully implemented. - #[cfg(any(target_os = "macos", target_os = "ios"))] mod tun_darwin; #[cfg(any(target_os = "macos", target_os = "ios"))] @@ -87,9 +84,9 @@ impl Device { dns_config: Vec, callbacks: &impl Callbacks, ) -> Result<(), ConnlibError> { + // On Android / Linux we recreate the tunnel every time we re-configure it self.tun = Some(Tun::new(config, dns_config.clone(), callbacks)?); - // The actual values are ignored, this is just used as a `TunnelReady` signal callbacks.on_set_interface_config(config.ipv4, config.ipv6, dns_config); if let Some(waker) = self.waker.take() { @@ -99,7 +96,7 @@ impl Device { Ok(()) } - #[cfg(any(target_os = "ios", target_os = "macos"))] + #[cfg(any(target_os = "ios", target_os = "macos", target_os = "windows"))] pub(crate) fn set_config( &mut self, config: &Interface, @@ -107,7 +104,7 @@ impl Device { callbacks: &impl Callbacks, ) -> Result<(), ConnlibError> { // For macos the filedescriptor is the same throughout its lifetime. - // If we reinitialzie tun, we might drop the old tun after the new one is created + // If we reinitialize tun, we might drop the old tun after the new one is created // this unregisters the file descriptor with the reactor so we never wake up // in case an event is triggered. if self.tun.is_none() { @@ -123,28 +120,6 @@ impl Device { Ok(()) } - #[cfg(target_family = "windows")] - pub(crate) fn set_config( - &mut self, - config: &Interface, - dns_config: Vec, - callbacks: &impl Callbacks, - ) -> Result<(), ConnlibError> { - if self.tun.is_none() { - self.tun = Some(Tun::new()?); - } - - self.tun.as_ref().unwrap().set_config(config, &dns_config)?; - - callbacks.on_set_interface_config(config.ipv4, config.ipv6, dns_config); - - if let Some(waker) = self.waker.take() { - waker.wake(); - } - - Ok(()) - } - #[cfg(target_family = "unix")] pub(crate) fn poll_read<'b>( &mut self, diff --git a/rust/connlib/tunnel/src/device_channel/tun_linux.rs b/rust/connlib/tunnel/src/device_channel/tun_linux.rs index 9529bc5e7..8a7d3992e 100644 --- a/rust/connlib/tunnel/src/device_channel/tun_linux.rs +++ b/rust/connlib/tunnel/src/device_channel/tun_linux.rs @@ -1,30 +1,21 @@ use super::utils; use crate::device_channel::ioctl; -use crate::FIREZONE_MARK; use connlib_shared::{ - linux::{etc_resolv_conf, DnsControlMethod}, - messages::Interface as InterfaceConfig, - Callbacks, Error, Result, + messages::Interface as InterfaceConfig, tun_device_manager::platform::IFACE_NAME, Callbacks, + Error, Result, }; -use futures::TryStreamExt; -use futures_util::future::BoxFuture; -use futures_util::FutureExt; -use ip_network::{IpNetwork, Ipv4Network, Ipv6Network}; +use ip_network::IpNetwork; use libc::{ close, fcntl, makedev, mknod, open, F_GETFL, F_SETFL, IFF_NO_PI, IFF_TUN, O_NONBLOCK, O_RDWR, S_IFCHR, }; -use netlink_packet_route::route::{RouteProtocol, RouteScope}; -use netlink_packet_route::rule::RuleAction; -use rtnetlink::{new_connection, Error::NetlinkError, Handle}; -use rtnetlink::{RouteAddRequest, RuleAddRequest}; use std::collections::HashSet; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::net::IpAddr; use std::path::Path; use std::task::{Context, Poll}; use std::{ ffi::CStr, - fmt, fs, io, + fs, io, os::{ fd::{AsRawFd, RawFd}, unix::fs::PermissionsExt, @@ -32,46 +23,21 @@ use std::{ }; use tokio::io::unix::AsyncFd; -const IFACE_NAME: &str = "tun-firezone"; const TUNSETIFF: libc::c_ulong = 0x4004_54ca; const TUN_DEV_MAJOR: u32 = 10; const TUN_DEV_MINOR: u32 = 200; -const DEFAULT_MTU: u32 = 1280; -const FILE_ALREADY_EXISTS: i32 = -17; -const FIREZONE_TABLE: u32 = 0x2021_fd00; // Safety: We know that this is a valid C string. const TUN_FILE: &CStr = unsafe { CStr::from_bytes_with_nul_unchecked(b"/dev/net/tun\0") }; +#[derive(Debug)] pub struct Tun { - handle: Handle, - connection: tokio::task::JoinHandle<()>, - dns_control_method: Option, fd: AsyncFd, - - worker: Option>>, - routes: HashSet, -} - -impl fmt::Debug for Tun { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Tun") - .field("handle", &self.handle) - .field("connection", &self.connection) - .field("fd", &self.fd) - .finish_non_exhaustive() - } } impl Drop for Tun { fn drop(&mut self) { unsafe { close(self.fd.as_raw_fd()) }; - self.connection.abort(); - tracing::debug!("Reverting DNS control..."); - if let Some(DnsControlMethod::EtcResolvConf) = self.dns_control_method { - // TODO: Check that nobody else modified the file while we were running. - etc_resolv_conf::revert().ok(); - } } } @@ -85,35 +51,16 @@ impl Tun { } pub fn poll_read(&mut self, buf: &mut [u8], cx: &mut Context<'_>) -> Poll> { - if let Some(worker) = self.worker.as_mut() { - match worker.poll_unpin(cx) { - Poll::Ready(Ok(())) => { - self.worker = None; - } - Poll::Ready(Err(e)) => { - self.worker = None; - return Poll::Ready(Err(io::Error::new(io::ErrorKind::Other, e))); - } - Poll::Pending => return Poll::Pending, - } - } - utils::poll_raw_fd(&self.fd, |fd| read(fd, buf), cx) } pub fn new( config: &InterfaceConfig, dns_config: Vec, - _: &impl Callbacks, + callbacks: &impl Callbacks, ) -> Result { tracing::debug!(?dns_config); - // TODO: Tech debt: - // TODO: Gateways shouldn't set up DNS, right? Only clients? - // TODO: Move this configuration up to the client - let dns_control_method = connlib_shared::linux::get_dns_control_from_env(); - tracing::info!(?dns_control_method); - create_tun_device()?; let fd = match unsafe { open(TUN_FILE.as_ptr() as _, O_RDWR) } { @@ -132,68 +79,23 @@ impl Tun { set_non_blocking(fd)?; - let (connection, handle, _) = new_connection()?; - let join_handle = tokio::spawn(connection); + callbacks.on_set_interface_config(config.ipv4, config.ipv6, dns_config); Ok(Self { - handle: handle.clone(), - connection: join_handle, - dns_control_method: dns_control_method.clone(), fd: AsyncFd::new(fd)?, - worker: Some( - set_iface_config(config.clone(), dns_config, handle, dns_control_method).boxed(), - ), - routes: HashSet::new(), }) } #[allow(clippy::unnecessary_wraps)] // fn signature needs to align with other platforms. - pub fn set_routes(&mut self, new_routes: HashSet, _: &impl Callbacks) -> Result<()> { - if new_routes == self.routes { - return Ok(()); - } - - let handle = self.handle.clone(); - let current_routes = self.routes.clone(); - self.routes.clone_from(&new_routes); - - let set_routes_worker = async move { - let index = handle - .link() - .get() - .match_name(IFACE_NAME.to_string()) - .execute() - .try_next() - .await? - .ok_or(Error::NoIface)? - .header - .index; - - for route in new_routes.difference(¤t_routes) { - add_route(route, index, &handle).await; - } - - for route in current_routes.difference(&new_routes) { - delete_route(route, index, &handle).await; - } - - Ok(()) - }; - - match self.worker.take() { - None => self.worker = Some(set_routes_worker.boxed()), - Some(current_worker) => { - self.worker = Some( - async move { - current_worker.await?; - set_routes_worker.await?; - - Ok(()) - } - .boxed(), - ) - } - } + pub fn set_routes( + &mut self, + new_routes: HashSet, + callbacks: &impl Callbacks, + ) -> Result<()> { + callbacks.on_update_routes( + new_routes.iter().copied().filter_map(super::ipv4).collect(), + new_routes.iter().copied().filter_map(super::ipv6).collect(), + ); Ok(()) } @@ -203,147 +105,6 @@ impl Tun { } } -#[tracing::instrument(level = "trace", skip(handle))] -async fn set_iface_config( - config: InterfaceConfig, - dns_config: Vec, - handle: Handle, - dns_control_method: Option, -) -> Result<()> { - let index = handle - .link() - .get() - .match_name(IFACE_NAME.to_string()) - .execute() - .try_next() - .await? - .ok_or(Error::NoIface)? - .header - .index; - - let ips = handle - .address() - .get() - .set_link_index_filter(index) - .execute(); - - ips.try_for_each(|ip| handle.address().del(ip).execute()) - .await?; - - handle.link().set(index).mtu(DEFAULT_MTU).execute().await?; - - let res_v4 = handle - .address() - .add(index, config.ipv4.into(), 32) - .execute() - .await; - let res_v6 = handle - .address() - .add(index, config.ipv6.into(), 128) - .execute() - .await; - - handle.link().set(index).up().execute().await?; - - if res_v4.is_ok() { - if let Err(e) = make_rule(&handle).v4().execute().await { - if !matches!(&e, NetlinkError(err) if err.raw_code() == FILE_ALREADY_EXISTS) { - tracing::warn!( - "Couldn't add ip rule for ipv4: {e:?}, ipv4 packets won't be routed" - ); - } - // TODO: Be smarter about this - } else { - tracing::debug!("Successfully created ip rule for ipv4"); - } - } - - if res_v6.is_ok() { - if let Err(e) = make_rule(&handle).v6().execute().await { - if !matches!(&e, NetlinkError(err) if err.raw_code() == FILE_ALREADY_EXISTS) { - tracing::warn!( - "Couldn't add ip rule for ipv6: {e:?}, ipv6 packets won't be routed" - ); - } - // TODO: Be smarter about this - } else { - tracing::debug!("Successfully created ip rule for ipv6"); - } - } - - res_v4.or(res_v6)?; - - if let Err(error) = match dns_control_method { - None => Ok(()), - Some(DnsControlMethod::EtcResolvConf) => etc_resolv_conf::configure(&dns_config) - .await - .map_err(Error::ResolvConf), - Some(DnsControlMethod::NetworkManager) => configure_network_manager(&dns_config), - Some(DnsControlMethod::Systemd) => configure_systemd_resolved(&dns_config).await, - } { - tracing::error!("Failed to control DNS: {error}"); - panic!("Failed to control DNS: {error}"); - } - - // TODO: Having this inside the library is definitely wrong. I think `set_iface_config` - // needs to return before `new` returns, so that the `on_tunnel_ready` callback - // happens after the IP address and DNS are set up. Then we can call `sd_notify` - // inside `on_tunnel_ready` in the client. - // - // `sd_notify::notify` is always safe to call, it silently returns `Ok(())` - // if we aren't running as a systemd service. - if let Err(error) = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]) { - // Nothing we can do about it - tracing::warn!(?error, "Failed to notify systemd that we're ready"); - } - - Ok(()) -} - -fn make_rule(handle: &Handle) -> RuleAddRequest { - let mut rule = handle - .rule() - .add() - .fw_mark(FIREZONE_MARK) - .table_id(FIREZONE_TABLE) - .action(RuleAction::ToTable); - - rule.message_mut() - .header - .flags - .push(netlink_packet_route::rule::RuleFlag::Invert); - - rule.message_mut() - .attributes - .push(netlink_packet_route::rule::RuleAttribute::Protocol( - RouteProtocol::Kernel, - )); - - rule -} - -fn make_route(idx: u32, handle: &Handle) -> RouteAddRequest { - handle - .route() - .add() - .output_interface(idx) - .protocol(RouteProtocol::Static) - .scope(RouteScope::Universe) - .table_id(FIREZONE_TABLE) -} - -fn make_route_v4(idx: u32, handle: &Handle, route: Ipv4Network) -> RouteAddRequest { - make_route(idx, handle) - .v4() - .destination_prefix(route.network_address(), route.netmask()) -} - -fn make_route_v6(idx: u32, handle: &Handle, route: Ipv6Network) -> RouteAddRequest { - make_route(idx, handle) - .v6() - .destination_prefix(route.network_address(), route.netmask()) -} - fn get_last_error() -> Error { Error::Io(io::Error::last_os_error()) } @@ -401,34 +162,6 @@ fn write(fd: RawFd, buf: &[u8]) -> io::Result { } } -async fn add_route(route: &IpNetwork, idx: u32, handle: &Handle) { - let res = match route { - IpNetwork::V4(ipnet) => make_route_v4(idx, handle, *ipnet).execute().await, - IpNetwork::V6(ipnet) => make_route_v6(idx, handle, *ipnet).execute().await, - }; - - match res { - Ok(_) => {} - Err(NetlinkError(err)) if err.raw_code() == FILE_ALREADY_EXISTS => {} - // TODO: we should be able to surface this error and handle it depending on - // if any of the added routes succeeded. - Err(err) => { - tracing::error!(%route, "failed to add route: {err}"); - } - } -} - -async fn delete_route(route: &IpNetwork, idx: u32, handle: &Handle) { - let message = match route { - IpNetwork::V4(ipnet) => make_route_v4(idx, handle, *ipnet).message_mut().clone(), - IpNetwork::V6(ipnet) => make_route_v6(idx, handle, *ipnet).message_mut().clone(), - }; - - if let Err(err) = handle.route().del(message).execute().await { - tracing::error!(%route, "failed to add route: {err:#?}"); - } -} - impl ioctl::Request { fn new() -> Self { let name_as_bytes = IFACE_NAME.as_bytes(); @@ -446,42 +179,6 @@ impl ioctl::Request { } } -fn configure_network_manager(_dns_config: &[IpAddr]) -> Result<()> { - Err(Error::Other( - "DNS control with NetworkManager is not implemented yet", - )) -} - -async fn configure_systemd_resolved(dns_config: &[IpAddr]) -> Result<()> { - let status = tokio::process::Command::new("resolvectl") - .arg("dns") - .arg(IFACE_NAME) - .args(dns_config.iter().map(ToString::to_string)) - .status() - .await - .map_err(|_| Error::ResolvectlFailed)?; - if !status.success() { - tracing::error!("`resolvectl dns` failed"); - return Err(Error::ResolvectlFailed); - } - - let status = tokio::process::Command::new("resolvectl") - .arg("domain") - .arg(IFACE_NAME) - .arg("~.") - .status() - .await - .map_err(|_| Error::ResolvectlFailed)?; - if !status.success() { - tracing::error!("`resolvectl domain` failed"); - return Err(Error::ResolvectlFailed); - } - - tracing::info!(?dns_config, "Configured DNS sentinels with `resolvectl`"); - - Ok(()) -} - #[repr(C)] struct SetTunFlagsPayload { flags: std::ffi::c_short, diff --git a/rust/connlib/tunnel/src/device_channel/tun_windows.rs b/rust/connlib/tunnel/src/device_channel/tun_windows.rs index b1ab69318..12083147a 100644 --- a/rust/connlib/tunnel/src/device_channel/tun_windows.rs +++ b/rust/connlib/tunnel/src/device_channel/tun_windows.rs @@ -1,9 +1,12 @@ -use connlib_shared::{messages::Interface as InterfaceConfig, Callbacks, Result}; +use connlib_shared::{ + windows::{CREATE_NO_WINDOW, TUNNEL_NAME}, + Callbacks, Result, DEFAULT_MTU, +}; use ip_network::IpNetwork; use std::{ collections::HashSet, io, - net::{IpAddr, SocketAddrV4, SocketAddrV6}, + net::{SocketAddrV4, SocketAddrV6}, os::windows::process::CommandExt, process::{Command, Stdio}, str::FromStr, @@ -22,12 +25,8 @@ use windows::Win32::{ Networking::WinSock::{AF_INET, AF_INET6}, }; -// wintun automatically appends " Tunnel" to this -const TUNNEL_NAME: &str = "Firezone"; - // TODO: Double-check that all these get dropped gracefully on disconnect pub struct Tun { - adapter: Arc, /// The index of our network adapter, we can use this when asking Windows to add / remove routes / DNS rules /// It's stable across app restarts and I'm assuming across system reboots too. iface_idx: u32, @@ -39,21 +38,12 @@ pub struct Tun { impl Drop for Tun { fn drop(&mut self) { - if let Err(error) = connlib_shared::windows::dns::deactivate() { - tracing::error!(?error, "Failed to deactivate DNS control"); - } if let Err(e) = self.session.shutdown() { tracing::error!("wintun::Session::shutdown: {e:#?}"); } } } -// Hides Powershell's console on Windows -// -const CREATE_NO_WINDOW: u32 = 0x08000000; -// Copied from tun_linux.rs -const DEFAULT_MTU: u32 = 1280; - impl Tun { pub fn new() -> Result { const TUNNEL_UUID: &str = "e9245bc1-b8c1-44ca-ab1d-c6aad4f13b9c"; @@ -62,7 +52,8 @@ impl Tun { // The Windows client, in `wintun_install` hashes the DLL at startup, before calling connlib, so it's unlikely for the DLL to be accidentally corrupted by the time we get here. let path = connlib_shared::windows::wintun_dll_path()?; let wintun = unsafe { wintun::load_from_path(path) }?; - let uuid = uuid::Uuid::from_str(TUNNEL_UUID).expect("static UUID to parse correctly"); + let uuid = + uuid::Uuid::from_str(TUNNEL_UUID).expect("static UUID should always parse correctly"); let adapter = match wintun::Adapter::create(&wintun, "Firezone", TUNNEL_NAME, Some(uuid.as_u128())) { Ok(x) => x, @@ -97,7 +88,6 @@ impl Tun { let recv_thread = start_recv_thread(packet_tx, Arc::clone(&session))?; Ok(Self { - adapter, iface_idx, _recv_thread: recv_thread, packet_rx, @@ -106,52 +96,16 @@ impl Tun { }) } - pub fn set_config(&self, config: &InterfaceConfig, dns_config: &[IpAddr]) -> Result<()> { - tracing::debug!("Setting our IPv4 = {}", config.ipv4); - tracing::debug!("Setting our IPv6 = {}", config.ipv6); - - // TODO: See if there's a good Win32 API for this - // Using netsh directly instead of wintun's `set_network_addresses_tuple` because their code doesn't work for IPv6 - Command::new("netsh") - .creation_flags(CREATE_NO_WINDOW) - .arg("interface") - .arg("ipv4") - .arg("set") - .arg("address") - .arg(format!("name=\"{TUNNEL_NAME}\"")) - .arg("source=static") - .arg(format!("address={}", config.ipv4)) - .arg("mask=255.255.255.255") - .stdout(Stdio::null()) - .status()?; - - Command::new("netsh") - .creation_flags(CREATE_NO_WINDOW) - .arg("interface") - .arg("ipv6") - .arg("set") - .arg("address") - .arg(format!("interface=\"{TUNNEL_NAME}\"")) - .arg(format!("address={}", config.ipv6)) - .stdout(Stdio::null()) - .status()?; - - tracing::debug!("Our IPs are {:?}", self.adapter.get_addresses()?); - - let iface_idx = self.adapter.get_adapter_index()?; - - connlib_shared::windows::dns::change(dns_config, iface_idx) - .expect("Should be able to control DNS"); - - Ok(()) - } - // It's okay if this blocks until the route is added in the OS. pub fn set_routes( &mut self, new_routes: HashSet, _callbacks: &impl Callbacks, ) -> Result<()> { + if new_routes == self.routes { + return Ok(()); + } + for new_route in new_routes.difference(&self.routes) { self.add_route(*new_route)?; } @@ -160,36 +114,9 @@ impl Tun { self.remove_route(*old_route)?; } - self.routes = new_routes; - // TODO: Might be calling this more often than it needs - connlib_shared::windows::dns::flush().expect("Should be able to flush Windows' DNS cache"); - - Ok(()) - } - - // It's okay if this blocks until the route is added in the OS. - fn add_route(&self, route: IpNetwork) -> Result<()> { - const DUPLICATE_ERR: u32 = 0x80071392; - let entry = self.forward_entry(route); - - // SAFETY: Windows shouldn't store the reference anywhere, it's just a way to pass lots of arguments at once. And no other thread sees this variable. - match unsafe { CreateIpForwardEntry2(&entry) }.ok() { - Ok(()) => Ok(()), - Err(e) if e.code().0 as u32 == DUPLICATE_ERR => { - tracing::debug!(%route, "Failed to add duplicate route, ignoring"); - Ok(()) - } - Err(e) => Err(e.into()), - } - } - - // It's okay if this blocks until the route is removed in the OS. - fn remove_route(&self, route: IpNetwork) -> Result<()> { - let entry = self.forward_entry(route); - - // SAFETY: Windows shouldn't store the reference anywhere, it's just a way to pass lots of arguments at once. And no other thread sees this variable. - unsafe { DeleteIpForwardEntry2(&entry) }.ok()?; + flush_dns().expect("Should be able to flush Windows' DNS cache"); + self.routes = new_routes; Ok(()) } @@ -247,6 +174,31 @@ impl Tun { Ok(bytes.len()) } + // It's okay if this blocks until the route is added in the OS. + fn add_route(&self, route: IpNetwork) -> Result<()> { + const DUPLICATE_ERR: u32 = 0x80071392; + let entry = self.forward_entry(route); + + // SAFETY: Windows shouldn't store the reference anywhere, it's just a way to pass lots of arguments at once. And no other thread sees this variable. + match unsafe { CreateIpForwardEntry2(&entry) }.ok() { + Ok(()) => Ok(()), + Err(e) if e.code().0 as u32 == DUPLICATE_ERR => { + tracing::debug!(%route, "Failed to add duplicate route, ignoring"); + Ok(()) + } + Err(e) => Err(e.into()), + } + } + + // It's okay if this blocks until the route is removed in the OS. + fn remove_route(&self, route: IpNetwork) -> Result<()> { + let entry = self.forward_entry(route); + + // SAFETY: Windows shouldn't store the reference anywhere, it's just a way to pass lots of arguments at once. And no other thread sees this variable. + unsafe { DeleteIpForwardEntry2(&entry) }.ok()?; + Ok(()) + } + fn forward_entry(&self, route: IpNetwork) -> MIB_IPFORWARD_ROW2 { let mut row = MIB_IPFORWARD_ROW2::default(); // SAFETY: Windows shouldn't store the reference anywhere, it's just setting defaults @@ -271,6 +223,16 @@ impl Tun { } } +/// Flush Windows' system-wide DNS cache +pub(crate) fn flush_dns() -> Result<()> { + tracing::info!("Flushing Windows DNS cache"); + Command::new("powershell") + .creation_flags(CREATE_NO_WINDOW) + .args(["-Command", "Clear-DnsClientCache"]) + .status()?; + Ok(()) +} + fn start_recv_thread( packet_tx: mpsc::Sender, session: Arc, diff --git a/rust/connlib/tunnel/src/gateway.rs b/rust/connlib/tunnel/src/gateway.rs index d197c3d53..18ff80e8c 100644 --- a/rust/connlib/tunnel/src/gateway.rs +++ b/rust/connlib/tunnel/src/gateway.rs @@ -17,9 +17,6 @@ use std::collections::{HashSet, VecDeque}; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; use std::time::{Duration, Instant}; -const PEERS_IPV4: &str = "100.64.0.0/11"; -const PEERS_IPV6: &str = "fd00:2021:1111::/107"; - const EXPIRE_RESOURCES_INTERVAL: Duration = Duration::from_secs(1); impl GatewayTunnel @@ -33,10 +30,6 @@ where self.io .device_mut() .set_config(config, vec![], &callbacks)?; - self.io.device_mut().set_routes( - HashSet::from([PEERS_IPV4.parse().unwrap(), PEERS_IPV6.parse().unwrap()]), - &callbacks, - )?; let name = self.io.device_mut().name().to_owned(); diff --git a/rust/connlib/tunnel/src/lib.rs b/rust/connlib/tunnel/src/lib.rs index d037a8e47..038139f32 100644 --- a/rust/connlib/tunnel/src/lib.rs +++ b/rust/connlib/tunnel/src/lib.rs @@ -43,9 +43,6 @@ const MTU: usize = 1280; const REALM: &str = "firezone"; -#[cfg(target_os = "linux")] -const FIREZONE_MARK: u32 = 0xfd002021; - pub type GatewayTunnel = Tunnel; pub type ClientTunnel = Tunnel; diff --git a/rust/connlib/tunnel/src/sockets.rs b/rust/connlib/tunnel/src/sockets.rs index be30c7ea5..4837ab4d4 100644 --- a/rust/connlib/tunnel/src/sockets.rs +++ b/rust/connlib/tunnel/src/sockets.rs @@ -339,7 +339,7 @@ fn make_socket(addr: impl Into) -> Result { #[cfg(target_os = "linux")] { - socket.set_mark(crate::FIREZONE_MARK)?; + socket.set_mark(connlib_shared::tun_device_manager::platform::FIREZONE_MARK)?; } // Note: for AF_INET sockets IPV6_V6ONLY is not a valid flag diff --git a/rust/gateway/src/main.rs b/rust/gateway/src/main.rs index 04b631d8d..650636f79 100644 --- a/rust/gateway/src/main.rs +++ b/rust/gateway/src/main.rs @@ -3,10 +3,11 @@ use crate::messages::InitGateway; use anyhow::{Context, Result}; use backoff::ExponentialBackoffBuilder; use clap::Parser; -use connlib_shared::{get_user_agent, keypair, Callbacks, LoginUrl, StaticSecret}; +use connlib_shared::{get_user_agent, keypair, Callbacks, Cidrv4, Cidrv6, LoginUrl, StaticSecret}; use firezone_cli_utils::{setup_global_subscriber, CommonArgs}; use firezone_tunnel::{GatewayTunnel, Sockets}; use futures::{future, TryFutureExt}; +use ip_network::{Ipv4Network, Ipv6Network}; use secrecy::{Secret, SecretString}; use std::collections::HashSet; use std::convert::Infallible; @@ -21,6 +22,8 @@ mod eventloop; mod messages; const ID_PATH: &str = "/var/lib/firezone/gateway_id"; +const PEERS_IPV4: &str = "100.64.0.0/11"; +const PEERS_IPV6: &str = "fd00:2021:1111::/107"; #[tokio::main] async fn main() { @@ -111,6 +114,16 @@ async fn run(login: LoginUrl, private_key: StaticSecret) -> Result { tunnel .set_interface(&init.interface) .context("Failed to set interface")?; + let mut tun_device = connlib_shared::tun_device_manager::TunDeviceManager::new()?; + tun_device + .set_ips(init.interface.ipv4, init.interface.ipv6) + .await?; + tun_device + .set_routes( + vec![Cidrv4::from(PEERS_IPV4.parse::().unwrap())], + vec![Cidrv6::from(PEERS_IPV6.parse::().unwrap())], + ) + .await?; tunnel.update_relays(HashSet::default(), init.relays); let mut eventloop = Eventloop::new(tunnel, portal); diff --git a/rust/gui-client/src-tauri/src/client.rs b/rust/gui-client/src-tauri/src/client.rs index cb65100b4..8123eb4c8 100644 --- a/rust/gui-client/src-tauri/src/client.rs +++ b/rust/gui-client/src-tauri/src/client.rs @@ -13,7 +13,6 @@ mod gui; mod ipc; mod logging; mod network_changes; -mod resolvers; mod settings; mod updates; mod uptime; diff --git a/rust/gui-client/src-tauri/src/client/deep_link/windows.rs b/rust/gui-client/src-tauri/src/client/deep_link/windows.rs index fe998e4d9..d98d77858 100644 --- a/rust/gui-client/src-tauri/src/client/deep_link/windows.rs +++ b/rust/gui-client/src-tauri/src/client/deep_link/windows.rs @@ -79,7 +79,7 @@ pub async fn open(url: &url::Url) -> Result<()> { } fn pipe_path() -> String { - firezone_headless_client::platform::named_pipe_path(&format!("{BUNDLE_ID}.deep_link")) + firezone_headless_client::windows::named_pipe_path(&format!("{BUNDLE_ID}.deep_link")) } /// Registers the current exe as the handler for our deep link scheme. diff --git a/rust/gui-client/src-tauri/src/client/gui.rs b/rust/gui-client/src-tauri/src/client/gui.rs index c1d4d2187..4bd20bfe0 100644 --- a/rust/gui-client/src-tauri/src/client/gui.rs +++ b/rust/gui-client/src-tauri/src/client/gui.rs @@ -191,10 +191,6 @@ pub(crate) fn run(cli: client::Cli) -> Result<(), Error> { if let Some(client::Cmd::SmokeTest) = &cli.command { let ctlr_tx = ctlr_tx.clone(); - // Generate the device ID so that the device ID code is covered - // by the smoke test. - // This is redundant since we will also lazily generate it when we boot connlib. - connlib_shared::device_id::get().ok(); tokio::spawn(async move { if let Err(error) = smoke_test(ctlr_tx).await { tracing::error!(?error, "Error during smoke test"); @@ -495,7 +491,7 @@ impl Controller { let api_url = self.advanced_settings.api_url.clone(); tracing::info!(api_url = api_url.to_string(), "Starting connlib..."); - let mut connlib = ipc::Client::connect( + let connlib = ipc::Client::connect( api_url.as_str(), token, callback_handler.clone(), @@ -503,10 +499,6 @@ impl Controller { ) .await?; - connlib - .set_dns(client::resolvers::get().unwrap_or_default()) - .await?; - self.session = Some(Session { callback_handler, connlib, diff --git a/rust/gui-client/src-tauri/src/client/ipc.rs b/rust/gui-client/src-tauri/src/client/ipc.rs index 2acf0f747..71541a657 100644 --- a/rust/gui-client/src-tauri/src/client/ipc.rs +++ b/rust/gui-client/src-tauri/src/client/ipc.rs @@ -41,7 +41,7 @@ impl CallbackHandler { .expect("controller channel failed"); } - fn on_set_interface_config(&self) { + fn on_tunnel_ready(&self) { self.ctlr_tx .try_send(ControllerRequest::TunnelReady) .expect("controller channel failed"); @@ -103,14 +103,8 @@ impl Client { error_msg, is_authentication_error, } => callback_handler.on_disconnect(error_msg, is_authentication_error), + IpcServerMsg::OnTunnelReady => callback_handler.on_tunnel_ready(), IpcServerMsg::OnUpdateResources(v) => callback_handler.on_update_resources(v), - IpcServerMsg::OnSetInterfaceConfig { - ipv4: _, - ipv6: _, - dns: _, - } => { - callback_handler.on_set_interface_config(); - } } } Ok(()) diff --git a/rust/gui-client/src-tauri/src/client/network_changes/linux.rs b/rust/gui-client/src-tauri/src/client/network_changes/linux.rs index 3519c2bf8..fdf64c380 100644 --- a/rust/gui-client/src-tauri/src/client/network_changes/linux.rs +++ b/rust/gui-client/src-tauri/src/client/network_changes/linux.rs @@ -1,6 +1,7 @@ //! Not implemented for Linux yet use anyhow::Result; +use firezone_headless_client::dns_control::system_resolvers_for_gui; use std::net::IpAddr; use tokio::time::Interval; @@ -47,7 +48,7 @@ impl DnsListener { pub(crate) fn new() -> Result { Ok(Self { interval: create_interval(), - last_seen: crate::client::resolvers::get().unwrap_or_default(), + last_seen: system_resolvers_for_gui().unwrap_or_default(), }) } @@ -55,7 +56,7 @@ impl DnsListener { loop { self.interval.tick().await; tracing::trace!("Checking for DNS changes"); - let new = crate::client::resolvers::get().unwrap_or_default(); + let new = system_resolvers_for_gui().unwrap_or_default(); if new != self.last_seen { self.last_seen.clone_from(&new); return Ok(new); diff --git a/rust/gui-client/src-tauri/src/client/network_changes/windows.rs b/rust/gui-client/src-tauri/src/client/network_changes/windows.rs index e455d6dd1..547a427a7 100644 --- a/rust/gui-client/src-tauri/src/client/network_changes/windows.rs +++ b/rust/gui-client/src-tauri/src/client/network_changes/windows.rs @@ -486,7 +486,10 @@ mod async_dns { r = self.listener_4.notified() => r?, r = self.listener_6.notified() => r?, } - Ok(crate::client::resolvers::get().unwrap_or_default()) + Ok( + firezone_headless_client::dns_control::system_resolvers_for_gui() + .unwrap_or_default(), + ) } } diff --git a/rust/gui-client/src-tauri/src/client/resolvers.rs b/rust/gui-client/src-tauri/src/client/resolvers.rs deleted file mode 100644 index 4d3989900..000000000 --- a/rust/gui-client/src-tauri/src/client/resolvers.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Module to handle Windows system-wide DNS resolvers - -pub(crate) use imp::get; - -#[cfg(target_os = "linux")] -mod imp { - use anyhow::Result; - use std::net::IpAddr; - - pub fn get() -> Result> { - firezone_headless_client::platform::get_system_default_resolvers_systemd_resolved() - } -} - -#[cfg(target_os = "macos")] -mod imp { - use anyhow::Result; - use std::net::IpAddr; - - pub fn get() -> Result> { - unimplemented!() - } -} - -#[cfg(target_os = "windows")] -mod imp { - use anyhow::Result; - use std::net::IpAddr; - - pub fn get() -> Result> { - firezone_headless_client::platform::system_resolvers() - } -} diff --git a/rust/headless-client/Cargo.toml b/rust/headless-client/Cargo.toml index f5e00486c..4b5b36ad7 100644 --- a/rust/headless-client/Cargo.toml +++ b/rust/headless-client/Cargo.toml @@ -9,6 +9,7 @@ authors = ["Firezone, Inc."] [dependencies] anyhow = { version = "1.0" } +atomicwrites = "0.4.3" # Needed to safely backup `/etc/resolv.conf` and write the device ID on behalf of `gui-client` clap = { version = "4.5", features = ["derive", "env", "string"] } connlib-client-shared = { workspace = true } connlib-shared = { workspace = true } @@ -16,6 +17,7 @@ firezone-cli-utils = { workspace = true } futures = "0.3.30" git-version = "0.3.9" humantime = "2.1" +ip_network = { version = "0.4", default-features = false } secrecy = { workspace = true } serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.117" @@ -26,11 +28,19 @@ tokio-util = { version = "0.7.11", features = ["codec"] } tracing = { workspace = true } tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = { version = "2.3.1", default-features = false } +uuid = { version = "1.7", default-features = false, features = ["std", "v4", "serde"] } + +[dev-dependencies] +tempfile = "3.10.1" + +[target.'cfg(target_os = "linux")'.dev-dependencies] +mutants = "0.0.3" # Needed to mark functions as exempt from `cargo-mutants` testing [target.'cfg(target_os = "linux")'.dependencies] dirs = "5.0.1" nix = { version = "0.28.0", features = ["fs", "user"] } resolv-conf = "0.7.0" +rtnetlink = { workspace = true } sd-notify = "0.4.1" # This is a pure Rust re-implementation, so it isn't vulnerable to CVE-2024-3094 [target.'cfg(target_os = "macos")'.dependencies] @@ -47,9 +57,14 @@ windows-service = "0.7.0" [target.'cfg(target_os = "windows")'.dependencies.windows] version = "0.56.0" features = [ + # For DNS control and route control + "Win32_Foundation", + "Win32_NetworkManagement_IpHelper", + "Win32_NetworkManagement_Ndis", + "Win32_Networking_WinSock", + # For named pipe IPC "Win32_Security", - # For named pipe IPC "Win32_System_SystemServices", ] diff --git a/rust/connlib/shared/src/device_id.rs b/rust/headless-client/src/device_id.rs similarity index 90% rename from rust/connlib/shared/src/device_id.rs rename to rust/headless-client/src/device_id.rs index 03243ff24..18e93f9d6 100644 --- a/rust/connlib/shared/src/device_id.rs +++ b/rust/headless-client/src/device_id.rs @@ -2,8 +2,8 @@ use anyhow::{Context, Result}; use std::fs; use std::io::Write; -pub struct DeviceId { - pub id: String, +pub(crate) struct DeviceId { + pub(crate) id: String, } /// Returns the device ID, generating it and saving it to disk if needed. @@ -14,8 +14,8 @@ pub struct DeviceId { /// Returns: The UUID as a String, suitable for sending verbatim to `connlib_client_shared::Session::connect`. /// /// Errors: If the disk is unwritable when initially generating the ID, or unwritable when re-generating an invalid ID. -pub fn get() -> Result { - let dir = imp::path().context("Failed to compute path for firezone-id file")?; +pub(crate) fn get() -> Result { + let dir = platform::path().context("Failed to compute path for firezone-id file")?; let path = dir.join("firezone-id.json"); // Try to read it from the disk @@ -60,14 +60,14 @@ impl DeviceIdJson { } #[cfg(not(any(target_os = "linux", target_os = "windows")))] -mod imp { +mod platform { pub(crate) fn path() -> Option { panic!("This function is only implemented on Linux and Windows since those have pure-Rust clients") } } #[cfg(target_os = "linux")] -mod imp { +mod platform { use std::path::PathBuf; /// `/var/lib/$BUNDLE_ID/config/firezone-id` /// @@ -82,14 +82,14 @@ mod imp { pub(crate) fn path() -> Option { Some( PathBuf::from("/var/lib") - .join(crate::BUNDLE_ID) + .join(connlib_shared::BUNDLE_ID) .join("config"), ) } } #[cfg(target_os = "windows")] -mod imp { +mod platform { use known_folders::{get_known_folder_path, KnownFolder}; /// e.g. `C:\ProgramData\dev.firezone.client\config` @@ -98,7 +98,7 @@ mod imp { pub(crate) fn path() -> Option { Some( get_known_folder_path(KnownFolder::ProgramData)? - .join(crate::BUNDLE_ID) + .join(connlib_shared::BUNDLE_ID) .join("config"), ) } @@ -108,7 +108,7 @@ mod imp { mod tests { #[test] fn smoke() { - let dir = super::imp::path().expect("should have gotten Some(path)"); + let dir = super::platform::path().expect("should have gotten Some(path)"); assert!(dir .components() .any(|x| x == std::path::Component::Normal("dev.firezone.client".as_ref()))); diff --git a/rust/headless-client/src/dns_control.rs b/rust/headless-client/src/dns_control.rs new file mode 100644 index 000000000..67e23a0cc --- /dev/null +++ b/rust/headless-client/src/dns_control.rs @@ -0,0 +1,15 @@ +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +use linux as platform; + +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +use windows as platform; + +pub(crate) use platform::{deactivate, system_resolvers, DnsController}; + +// TODO: Move DNS and network change listening to the IPC service, so this won't +// need to be public. +pub use platform::system_resolvers_for_gui; diff --git a/rust/headless-client/src/dns_control/linux.rs b/rust/headless-client/src/dns_control/linux.rs new file mode 100644 index 000000000..9f9d5cbf5 --- /dev/null +++ b/rust/headless-client/src/dns_control/linux.rs @@ -0,0 +1,204 @@ +use anyhow::{bail, Context as _, Result}; +use connlib_shared::tun_device_manager::linux::IFACE_NAME; +use std::{net::IpAddr, str::FromStr}; + +mod etc_resolv_conf; + +const FIREZONE_DNS_CONTROL: &str = "FIREZONE_DNS_CONTROL"; + +pub fn system_resolvers_for_gui() -> Result> { + get_system_default_resolvers_systemd_resolved() +} + +#[derive(Clone, Debug)] +enum DnsControlMethod { + /// Back up `/etc/resolv.conf` and replace it with our own + /// + /// Only suitable for the Alpine CI containers and maybe something like an + /// embedded system + EtcResolvConf, + /// Cooperate with NetworkManager (TODO) + NetworkManager, + /// Cooperate with `systemd-resolved` + /// + /// Suitable for most Ubuntu systems, probably + Systemd, +} + +pub(crate) struct DnsController { + dns_control_method: Option, +} + +impl Drop for DnsController { + fn drop(&mut self) { + tracing::debug!("Reverting DNS control..."); + if let Some(DnsControlMethod::EtcResolvConf) = self.dns_control_method { + // TODO: Check that nobody else modified the file while we were running. + etc_resolv_conf::revert().ok(); + } + } +} + +impl DnsController { + pub(crate) fn new() -> Self { + // We'll remove `get_dns_control_from_env` in #5068 + let dns_control_method = get_dns_control_from_env(); + tracing::info!(?dns_control_method); + + Self { dns_control_method } + } + + /// Set the computer's system-wide DNS servers + /// + /// The `mut` in `&mut self` is not needed by Rust's rules, but + /// it would be bad if this was called from 2 threads at once. + pub(crate) async fn set_dns(&mut self, dns_config: &[IpAddr]) -> Result<()> { + match self.dns_control_method { + None => Ok(()), + Some(DnsControlMethod::EtcResolvConf) => etc_resolv_conf::configure(dns_config).await, + Some(DnsControlMethod::NetworkManager) => configure_network_manager(dns_config), + Some(DnsControlMethod::Systemd) => configure_systemd_resolved(dns_config).await, + } + .context("Failed to control DNS") + } +} + +/// Reads FIREZONE_DNS_CONTROL. Returns None if invalid or not set +fn get_dns_control_from_env() -> Option { + match std::env::var(FIREZONE_DNS_CONTROL).as_deref() { + Ok("etc-resolv-conf") => Some(DnsControlMethod::EtcResolvConf), + Ok("network-manager") => Some(DnsControlMethod::NetworkManager), + Ok("systemd-resolved") => Some(DnsControlMethod::Systemd), + _ => None, + } +} + +fn configure_network_manager(_dns_config: &[IpAddr]) -> Result<()> { + anyhow::bail!("DNS control with NetworkManager is not implemented yet",) +} + +async fn configure_systemd_resolved(dns_config: &[IpAddr]) -> Result<()> { + let status = tokio::process::Command::new("resolvectl") + .arg("dns") + .arg(IFACE_NAME) + .args(dns_config.iter().map(ToString::to_string)) + .status() + .await + .context("`resolvectl dns` didn't run")?; + if !status.success() { + bail!("`resolvectl dns` returned non-zero"); + } + + let status = tokio::process::Command::new("resolvectl") + .arg("domain") + .arg(IFACE_NAME) + .arg("~.") + .status() + .await + .context("`resolvectl domain` didn't run")?; + if !status.success() { + bail!("`resolvectl domain` returned non-zero"); + } + + tracing::info!(?dns_config, "Configured DNS sentinels with `resolvectl`"); + + Ok(()) +} + +pub(crate) fn system_resolvers() -> Result> { + match crate::dns_control::platform::get_dns_control_from_env() { + None => get_system_default_resolvers_resolv_conf(), + Some(DnsControlMethod::EtcResolvConf) => get_system_default_resolvers_resolv_conf(), + Some(DnsControlMethod::NetworkManager) => get_system_default_resolvers_network_manager(), + Some(DnsControlMethod::Systemd) => get_system_default_resolvers_systemd_resolved(), + } +} + +fn get_system_default_resolvers_resolv_conf() -> Result> { + // Assume that `configure_resolv_conf` has run in `tun_linux.rs` + + let s = std::fs::read_to_string(etc_resolv_conf::ETC_RESOLV_CONF_BACKUP) + .or_else(|_| std::fs::read_to_string(etc_resolv_conf::ETC_RESOLV_CONF)) + .context("`resolv.conf` should be readable")?; + let parsed = resolv_conf::Config::parse(s).context("`resolv.conf` should be parsable")?; + + // Drop the scoping info for IPv6 since connlib doesn't take it + let nameservers = parsed + .nameservers + .into_iter() + .map(|addr| addr.into()) + .collect(); + Ok(nameservers) +} + +#[allow(clippy::unnecessary_wraps)] +fn get_system_default_resolvers_network_manager() -> Result> { + tracing::error!("get_system_default_resolvers_network_manager not implemented yet"); + Ok(vec![]) +} + +/// Returns the DNS servers listed in `resolvectl dns` +fn get_system_default_resolvers_systemd_resolved() -> Result> { + // Unfortunately systemd-resolved does not have a machine-readable + // text output for this command: + // + // The officially supported way is probably to use D-Bus. + let output = std::process::Command::new("resolvectl") + .arg("dns") + .output() + .context("Failed to run `resolvectl dns` and read output")?; + if !output.status.success() { + anyhow::bail!("`resolvectl dns` returned non-zero exit code"); + } + let output = String::from_utf8(output.stdout).context("`resolvectl` output was not UTF-8")?; + Ok(parse_resolvectl_output(&output)) +} + +/// Parses the text output of `resolvectl dns` +/// +/// Cannot fail. If the parsing code is wrong, the IP address vec will just be incomplete. +fn parse_resolvectl_output(s: &str) -> Vec { + s.lines() + .flat_map(|line| line.split(' ')) + .filter_map(|word| IpAddr::from_str(word).ok()) + .collect() +} + +// Does nothing on Linux, needed to match the Windows interface +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn deactivate() -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + + #[test] + fn parse_resolvectl_output() { + let cases = [ + // WSL + ( + r"Global: 172.24.80.1 +Link 2 (eth0): +Link 3 (docker0): +Link 24 (br-fc0b71997a3c): +Link 25 (br-0c129dafb204): +Link 26 (br-e67e83b19dce): +", + [IpAddr::from([172, 24, 80, 1])], + ), + // Ubuntu 20.04 + ( + r"Global: +Link 2 (enp0s3): 192.168.1.1", + [IpAddr::from([192, 168, 1, 1])], + ), + ]; + + for (i, (input, expected)) in cases.iter().enumerate() { + let actual = super::parse_resolvectl_output(input); + assert_eq!(actual, expected, "Case {i} failed"); + } + } +} diff --git a/rust/connlib/shared/src/linux/etc_resolv_conf.rs b/rust/headless-client/src/dns_control/linux/etc_resolv_conf.rs similarity index 98% rename from rust/connlib/shared/src/linux/etc_resolv_conf.rs rename to rust/headless-client/src/dns_control/linux/etc_resolv_conf.rs index 0f7c2668d..ce8e63277 100644 --- a/rust/connlib/shared/src/linux/etc_resolv_conf.rs +++ b/rust/headless-client/src/dns_control/linux/etc_resolv_conf.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; use std::{io::Write, net::IpAddr, path::PathBuf}; -pub const ETC_RESOLV_CONF: &str = "/etc/resolv.conf"; -pub const ETC_RESOLV_CONF_BACKUP: &str = "/etc/resolv.conf.before-firezone"; +pub(crate) const ETC_RESOLV_CONF: &str = "/etc/resolv.conf"; +pub(crate) const ETC_RESOLV_CONF_BACKUP: &str = "/etc/resolv.conf.before-firezone"; /// Used to figure out whether we crashed on our last run or not. /// /// If we did crash, we need to restore the system-wide DNS from the backup file. @@ -11,7 +11,7 @@ const MAGIC_HEADER: &str = "# BEGIN Firezone DNS configuration"; // Wanted these args to have names so they don't get mixed up #[derive(Clone)] -pub struct ResolvPaths { +pub(crate) struct ResolvPaths { resolv: PathBuf, backup: PathBuf, } @@ -30,7 +30,7 @@ impl Default for ResolvPaths { /// This is async because it's called in a Tokio context and it's nice to use their /// `fs` module #[cfg_attr(test, mutants::skip)] // Would modify system-wide `/etc/resolv.conf` -pub async fn configure(dns_config: &[IpAddr]) -> Result<()> { +pub(crate) async fn configure(dns_config: &[IpAddr]) -> Result<()> { configure_at_paths(dns_config, &ResolvPaths::default()).await } @@ -38,7 +38,7 @@ pub async fn configure(dns_config: &[IpAddr]) -> Result<()> { /// /// Must be sync because it's called in `Tun::drop` #[cfg_attr(test, mutants::skip)] // Would modify system-wide `/etc/resolv.conf` -pub fn revert() -> Result<()> { +pub(crate) fn revert() -> Result<()> { revert_at_paths(&ResolvPaths::default()) } diff --git a/rust/headless-client/src/dns_control/windows.rs b/rust/headless-client/src/dns_control/windows.rs new file mode 100644 index 000000000..abc0f2653 --- /dev/null +++ b/rust/headless-client/src/dns_control/windows.rs @@ -0,0 +1,128 @@ +//! Gives Firezone DNS privilege over other DNS resolvers on the system +//! +//! This uses NRPT and claims all domains, similar to the `systemd-resolved` control method +//! on Linux. +//! This allows us to "shadow" DNS resolvers that are configured by the user or DHCP on +//! physical interfaces, as long as they don't have any NRPT rules that outrank us. +//! +//! If Firezone crashes, restarting Firezone and closing it gracefully will resume +//! normal DNS operation. The Powershell command to remove the NRPT rule can also be run +//! by hand. +//! +//! The system default resolvers don't need to be reverted because they're never deleted. +//! +//! + +use anyhow::{Context as _, Result}; +use connlib_shared::windows::{CREATE_NO_WINDOW, TUNNEL_NAME}; +use std::{net::IpAddr, os::windows::process::CommandExt, process::Command}; + +pub fn system_resolvers_for_gui() -> Result> { + system_resolvers() +} + +pub(crate) struct DnsController {} + +// Unique magic number that we can use to delete our well-known NRPT rule. +// Copied from the deep link schema +const FZ_MAGIC: &str = "firezone-fd0020211111"; + +impl Drop for DnsController { + fn drop(&mut self) { + if let Err(error) = deactivate() { + tracing::error!(?error, "Failed to deactivate DNS control"); + } + } +} + +impl DnsController { + pub(crate) fn new() -> Self { + Self {} + } + + /// Set the computer's system-wide DNS servers + /// + /// There's a gap in this because on Windows we deactivate and re-activate control. + /// + /// The `mut` in `&mut self` is not needed by Rust's rules, but + /// it would be bad if this was called from 2 threads at once. + /// + /// Must be async to match the Linux signature + #[allow(clippy::unused_async)] + pub(crate) async fn set_dns(&mut self, dns_config: &[IpAddr]) -> Result<()> { + deactivate().context("Failed to deactivate DNS control")?; + activate(dns_config).context("Failed to activate DNS control")?; + Ok(()) + } +} + +pub(crate) fn system_resolvers() -> Result> { + let resolvers = ipconfig::get_adapters()? + .iter() + .flat_map(|adapter| adapter.dns_servers()) + .filter(|ip| match ip { + IpAddr::V4(_) => true, + // Filter out bogus DNS resolvers on my dev laptop that start with fec0: + IpAddr::V6(ip) => !ip.octets().starts_with(&[0xfe, 0xc0]), + }) + .copied() + .collect(); + // This is private, so keep it at `debug` or `trace` + tracing::debug!(?resolvers); + Ok(resolvers) +} + +/// Tells Windows to send all DNS queries to our sentinels +/// +/// Parameters: +/// - `dns_config_string`: Comma-separated IP addresses of DNS servers, e.g. "1.1.1.1,8.8.8.8" +pub(crate) fn activate(dns_config: &[IpAddr]) -> Result<()> { + let dns_config_string = dns_config + .iter() + .map(|ip| format!("\"{ip}\"")) + .collect::>() + .join(","); + + // Set our DNS IP as the DNS server for our interface + // TODO: Known issue where web browsers will keep a connection open to a site, + // using QUIC, HTTP/2, or even HTTP/1.1, and so they won't resolve the DNS + // again unless you let that connection time out: + // + Command::new("powershell") + .creation_flags(CREATE_NO_WINDOW) + .arg("-Command") + .arg(format!( + "Set-DnsClientServerAddress {TUNNEL_NAME} -ServerAddresses({dns_config_string})" + )) + .status()?; + + tracing::info!("Activating DNS control"); + Command::new("powershell") + .creation_flags(CREATE_NO_WINDOW) + .args([ + "-Command", + "Add-DnsClientNrptRule", + "-Namespace", + ".", + "-Comment", + FZ_MAGIC, + "-NameServers", + &dns_config_string, + ]) + .status()?; + Ok(()) +} + +// Must be `sync` so we can call it from `Drop` +pub(crate) fn deactivate() -> Result<()> { + Command::new("powershell") + .creation_flags(CREATE_NO_WINDOW) + .args(["-Command", "Get-DnsClientNrptRule", "|"]) + .args(["where", "Comment", "-eq", FZ_MAGIC, "|"]) + .args(["foreach", "{"]) + .args(["Remove-DnsClientNrptRule", "-Name", "$_.Name", "-Force"]) + .args(["}"]) + .status()?; + tracing::info!("Deactivated DNS control"); + Ok(()) +} diff --git a/rust/headless-client/src/lib.rs b/rust/headless-client/src/lib.rs index 3e34723d7..26877af65 100644 --- a/rust/headless-client/src/lib.rs +++ b/rust/headless-client/src/lib.rs @@ -13,7 +13,7 @@ use clap::Parser; use connlib_client_shared::{ file_logger, keypair, Callbacks, Error as ConnlibError, LoginUrl, Session, Sockets, }; -use connlib_shared::callbacks; +use connlib_shared::{callbacks, tun_device_manager, Cidrv4, Cidrv6}; use firezone_cli_utils::setup_global_subscriber; use futures::{future, SinkExt, StreamExt}; use secrecy::SecretString; @@ -34,6 +34,9 @@ use platform::default_token_path; /// Must be constructed inside a Tokio runtime context. use platform::Signals; +/// Generate a persistent device ID, stores it to disk, and reads it back. +pub(crate) mod device_id; +pub mod dns_control; pub mod known_dirs; #[cfg(target_os = "linux")] @@ -44,7 +47,7 @@ pub use linux as platform; #[cfg(target_os = "windows")] pub mod windows; #[cfg(target_os = "windows")] -pub use windows as platform; +pub(crate) use windows as platform; /// Only used on Linux pub const FIREZONE_GROUP: &str = "firezone-client"; @@ -57,7 +60,7 @@ pub const FIREZONE_GROUP: &str = "firezone-client"; /// * `g` doesn't mean anything /// * `ed5437c88` is the Git commit hash /// * `-modified` is present if the working dir has any changes from that commit number -pub const GIT_VERSION: &str = git_version::git_version!( +pub(crate) const GIT_VERSION: &str = git_version::git_version!( args = ["--always", "--dirty=-modified", "--tags"], fallback = "unknown" ); @@ -83,7 +86,7 @@ struct Cli { env = "FIREZONE_API_URL", default_value = "wss://api.firezone.dev" )] - pub api_url: url::Url, + api_url: url::Url, /// Check the configuration and return 0 before connecting to the API /// @@ -101,7 +104,7 @@ struct Cli { // AKA `device_id` in the Windows and Linux GUI clients // Generated automatically if not provided #[arg(short = 'i', long, env = "FIREZONE_ID")] - pub firezone_id: Option, + firezone_id: Option, /// Token generated by the portal to authorize websocket connection. // systemd recommends against passing secrets through env vars: @@ -170,6 +173,19 @@ pub enum IpcClientMsg { SetDns(Vec), } +enum InternalServerMsg { + Ipc(IpcServerMsg), + OnSetInterfaceConfig { + ipv4: Ipv4Addr, + ipv6: Ipv6Addr, + dns: Vec, + }, + OnUpdateRoutes { + ipv4: Vec, + ipv6: Vec, + }, +} + #[derive(Debug, serde::Deserialize, serde::Serialize)] pub enum IpcServerMsg { Ok, @@ -177,11 +193,7 @@ pub enum IpcServerMsg { error_msg: String, is_authentication_error: bool, }, - OnSetInterfaceConfig { - ipv4: Ipv4Addr, - ipv6: Ipv6Addr, - dns: Vec, - }, + OnTunnelReady, OnUpdateResources(Vec), } @@ -235,7 +247,7 @@ pub fn run_only_headless_client() -> Result<()> { // AKA "Device ID", not the Firezone slug let firezone_id = match cli.firezone_id { Some(id) => id, - None => connlib_shared::device_id::get().context("Could not get `firezone_id` from CLI, could not read it from disk, could not generate it and save it to disk")?.id, + None => device_id::get().context("Could not get `firezone_id` from CLI, could not read it from disk, could not generate it and save it to disk")?.id, }; let (private_key, public_key) = keypair(); @@ -252,10 +264,8 @@ pub fn run_only_headless_client() -> Result<()> { return Ok(()); } - // TODO: Can theoretically be removed the same way the Windows IPC service works, - // using an abort handle - let (on_disconnect_tx, mut on_disconnect_rx) = mpsc::channel(1); - let callback_handler = CallbackHandler { on_disconnect_tx }; + let (cb_tx, mut cb_rx) = mpsc::channel(10); + let callback_handler = CallbackHandler { cb_tx }; platform::setup_before_connlib()?; let session = Session::connect( @@ -268,13 +278,16 @@ pub fn run_only_headless_client() -> Result<()> { rt.handle().clone(), ); // TODO: this should be added dynamically - session.set_dns(platform::system_resolvers().unwrap_or_default()); + session.set_dns(dns_control::system_resolvers().unwrap_or_default()); + platform::notify_service_controller()?; let result = rt.block_on(async { + let mut dns_controller = dns_control::DnsController::new(); + let mut tun_device = tun_device_manager::TunDeviceManager::new()?; let mut signals = Signals::new()?; loop { - match future::select(pin!(signals.recv()), pin!(on_disconnect_rx.recv())).await { + match future::select(pin!(signals.recv()), pin!(cb_rx.recv())).await { future::Either::Left((SignalKind::Hangup, _)) => { tracing::info!("Caught Hangup signal"); session.reconnect(); @@ -284,11 +297,24 @@ pub fn run_only_headless_client() -> Result<()> { return Ok(()); } future::Either::Right((None, _)) => { - return Err(anyhow::anyhow!("on_disconnect_rx unexpectedly ran empty")); - } - future::Either::Right((Some(error), _)) => { - return Err(anyhow!(error).context("Firezone disconnected")) + return Err(anyhow::anyhow!("cb_rx unexpectedly ran empty")); } + future::Either::Right((Some(msg), _)) => match msg { + InternalServerMsg::Ipc(IpcServerMsg::OnDisconnect { + error_msg, + is_authentication_error: _, + }) => return Err(anyhow!(error_msg).context("Firezone disconnected")), + InternalServerMsg::Ipc(IpcServerMsg::Ok) + | InternalServerMsg::Ipc(IpcServerMsg::OnTunnelReady) + | InternalServerMsg::Ipc(IpcServerMsg::OnUpdateResources(_)) => {} + InternalServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns } => { + tun_device.set_ips(ipv4, ipv6).await?; + dns_controller.set_dns(&dns).await?; + } + InternalServerMsg::OnUpdateRoutes { ipv4, ipv6 } => { + tun_device.set_routes(ipv4, ipv6).await? + } + }, } } }); @@ -342,23 +368,23 @@ pub(crate) fn run_debug_ipc_service() -> Result<()> { } #[derive(Clone)] -struct CallbackHandlerIpc { - cb_tx: mpsc::Sender, +struct CallbackHandler { + cb_tx: mpsc::Sender, } -impl Callbacks for CallbackHandlerIpc { - fn on_disconnect(&self, error: &ConnlibError) { +impl Callbacks for CallbackHandler { + fn on_disconnect(&self, error: &connlib_client_shared::Error) { tracing::error!(?error, "Got `on_disconnect` from connlib"); + let is_authentication_error = if let ConnlibError::PortalConnectionFailed(error) = error { + error.is_authentication_error() + } else { + false + }; self.cb_tx - .try_send(IpcServerMsg::OnDisconnect { + .try_send(InternalServerMsg::Ipc(IpcServerMsg::OnDisconnect { error_msg: error.to_string(), - is_authentication_error: if let ConnlibError::PortalConnectionFailed(error) = error - { - error.is_authentication_error() - } else { - false - }, - }) + is_authentication_error, + })) .expect("should be able to send OnDisconnect"); } @@ -370,7 +396,7 @@ impl Callbacks for CallbackHandlerIpc { ) -> Option { tracing::info!("TunnelReady (on_set_interface_config)"); self.cb_tx - .try_send(IpcServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns }) + .try_send(InternalServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns }) .expect("Should be able to send TunnelReady"); None } @@ -378,15 +404,24 @@ impl Callbacks for CallbackHandlerIpc { fn on_update_resources(&self, resources: Vec) { tracing::debug!(len = resources.len(), "New resource list"); self.cb_tx - .try_send(IpcServerMsg::OnUpdateResources(resources)) + .try_send(InternalServerMsg::Ipc(IpcServerMsg::OnUpdateResources( + resources, + ))) .expect("Should be able to send OnUpdateResources"); } + + fn on_update_routes(&self, ipv4: Vec, ipv6: Vec) -> Option { + self.cb_tx + .try_send(InternalServerMsg::OnUpdateRoutes { ipv4, ipv6 }) + .expect("Should be able to send messages"); + None + } } async fn ipc_listen() -> Result { let mut server = platform::IpcServer::new().await?; loop { - connlib_shared::deactivate_dns_control()?; + dns_control::deactivate()?; let stream = server.next_client().await?; if let Err(error) = handle_ipc_client(stream).await { tracing::error!(?error, "Error while handling IPC client"); @@ -398,17 +433,31 @@ async fn handle_ipc_client(stream: platform::IpcStream) -> Result<()> { let (rx, tx) = tokio::io::split(stream); let mut rx = FramedRead::new(rx, LengthDelimitedCodec::new()); let mut tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - let (cb_tx, mut cb_rx) = mpsc::channel(100); + let (cb_tx, mut cb_rx) = mpsc::channel(10); let send_task = tokio::spawn(async move { + let mut dns_controller = dns_control::DnsController::new(); + let mut tun_device = tun_device_manager::TunDeviceManager::new()?; + while let Some(msg) = cb_rx.recv().await { - tx.send(serde_json::to_string(&msg)?.into()).await?; + match msg { + InternalServerMsg::Ipc(msg) => tx.send(serde_json::to_string(&msg)?.into()).await?, + InternalServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns } => { + tun_device.set_ips(ipv4, ipv6).await?; + dns_controller.set_dns(&dns).await?; + tx.send(serde_json::to_string(&IpcServerMsg::OnTunnelReady)?.into()) + .await?; + } + InternalServerMsg::OnUpdateRoutes { ipv4, ipv6 } => { + tun_device.set_routes(ipv4, ipv6).await? + } + } } Ok::<_, anyhow::Error>(()) }); let mut connlib = None; - let callback_handler = CallbackHandlerIpc { cb_tx }; + let callback_handler = CallbackHandler { cb_tx }; while let Some(msg) = rx.next().await { let msg = msg?; let msg: IpcClientMsg = serde_json::from_slice(&msg)?; @@ -417,8 +466,7 @@ async fn handle_ipc_client(stream: platform::IpcStream) -> Result<()> { IpcClientMsg::Connect { api_url, token } => { let token = secrecy::SecretString::from(token); assert!(connlib.is_none()); - let device_id = connlib_shared::device_id::get() - .context("Failed to read / create device ID")?; + let device_id = device_id::get().context("Failed to read / create device ID")?; let (private_key, public_key) = keypair(); let login = LoginUrl::client( @@ -429,7 +477,7 @@ async fn handle_ipc_client(stream: platform::IpcStream) -> Result<()> { public_key.to_bytes(), )?; - connlib = Some(connlib_client_shared::Session::connect( + let new_session = connlib_client_shared::Session::connect( login, Sockets::new(), private_key, @@ -437,7 +485,9 @@ async fn handle_ipc_client(stream: platform::IpcStream) -> Result<()> { callback_handler.clone(), Some(std::time::Duration::from_secs(60 * 60 * 24 * 30)), tokio::runtime::Handle::try_current()?, - )); + ); + new_session.set_dns(dns_control::system_resolvers().unwrap_or_default()); + connlib = Some(new_session); } IpcClientMsg::Disconnect => { if let Some(connlib) = connlib.take() { @@ -464,28 +514,6 @@ enum SignalKind { Interrupt, } -#[derive(Clone)] -struct CallbackHandler { - /// Channel for an error message if connlib disconnects due to an error - on_disconnect_tx: mpsc::Sender, -} - -impl Callbacks for CallbackHandler { - fn on_disconnect(&self, error: &connlib_client_shared::Error) { - // Convert the error to a String since we can't clone it - self.on_disconnect_tx - .try_send(error.to_string()) - .expect("should be able to tell the main thread that we disconnected"); - } - - fn on_update_resources(&self, resources: Vec) { - // See easily with `export RUST_LOG=firezone_headless_client=debug` - for resource in &resources { - tracing::debug!(?resource); - } - } -} - /// Read the token from disk if it was not in the environment /// /// # Returns @@ -562,10 +590,6 @@ mod tests { fn cli() -> anyhow::Result<()> { let exe_name = "firezone-headless-client"; - let actual = Cli::parse_from([exe_name]); - assert_eq!(actual.api_url, Url::parse("wss://api.firezone.dev")?); - assert!(!actual.check); - let actual = Cli::parse_from([exe_name, "--api-url", "wss://api.firez.one"]); assert_eq!(actual.api_url, Url::parse("wss://api.firez.one")?); diff --git a/rust/headless-client/src/linux.rs b/rust/headless-client/src/linux.rs index b43fe1226..f7ae75eca 100644 --- a/rust/headless-client/src/linux.rs +++ b/rust/headless-client/src/linux.rs @@ -3,15 +3,12 @@ use super::{CliCommon, SignalKind, FIREZONE_GROUP, TOKEN_ENV_KEY}; use anyhow::{bail, Context as _, Result}; use connlib_client_shared::file_logger; -use connlib_shared::linux::{etc_resolv_conf, get_dns_control_from_env, DnsControlMethod}; use firezone_cli_utils::setup_global_subscriber; use futures::future::{select, Either}; use std::{ - net::IpAddr, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, pin::pin, - str::FromStr, }; use tokio::{ net::{UnixListener, UnixStream}, @@ -44,7 +41,7 @@ impl Signals { } } -pub fn default_token_path() -> PathBuf { +pub(crate) fn default_token_path() -> PathBuf { PathBuf::from("/etc") .join(connlib_shared::BUNDLE_ID) .join("token") @@ -81,65 +78,6 @@ pub(crate) fn check_token_permissions(path: &Path) -> Result<()> { Ok(()) } -pub(crate) fn system_resolvers() -> Result> { - match get_dns_control_from_env() { - None => get_system_default_resolvers_resolv_conf(), - Some(DnsControlMethod::EtcResolvConf) => get_system_default_resolvers_resolv_conf(), - Some(DnsControlMethod::NetworkManager) => get_system_default_resolvers_network_manager(), - Some(DnsControlMethod::Systemd) => get_system_default_resolvers_systemd_resolved(), - } -} - -fn get_system_default_resolvers_resolv_conf() -> Result> { - // Assume that `configure_resolv_conf` has run in `tun_linux.rs` - - let s = std::fs::read_to_string(etc_resolv_conf::ETC_RESOLV_CONF_BACKUP) - .or_else(|_| std::fs::read_to_string(etc_resolv_conf::ETC_RESOLV_CONF)) - .context("`resolv.conf` should be readable")?; - let parsed = resolv_conf::Config::parse(s).context("`resolv.conf` should be parsable")?; - - // Drop the scoping info for IPv6 since connlib doesn't take it - let nameservers = parsed - .nameservers - .into_iter() - .map(|addr| addr.into()) - .collect(); - Ok(nameservers) -} - -#[allow(clippy::unnecessary_wraps)] -fn get_system_default_resolvers_network_manager() -> Result> { - tracing::error!("get_system_default_resolvers_network_manager not implemented yet"); - Ok(vec![]) -} - -/// Returns the DNS servers listed in `resolvectl dns` -pub fn get_system_default_resolvers_systemd_resolved() -> Result> { - // Unfortunately systemd-resolved does not have a machine-readable - // text output for this command: - // - // The officially supported way is probably to use D-Bus. - let output = std::process::Command::new("resolvectl") - .arg("dns") - .output() - .context("Failed to run `resolvectl dns` and read output")?; - if !output.status.success() { - anyhow::bail!("`resolvectl dns` returned non-zero exit code"); - } - let output = String::from_utf8(output.stdout).context("`resolvectl` output was not UTF-8")?; - Ok(parse_resolvectl_output(&output)) -} - -/// Parses the text output of `resolvectl dns` -/// -/// Cannot fail. If the parsing code is wrong, the IP address vec will just be incomplete. -fn parse_resolvectl_output(s: &str) -> Vec { - s.lines() - .flat_map(|line| line.split(' ')) - .filter_map(|word| IpAddr::from_str(word).ok()) - .collect() -} - /// The path for our Unix Domain Socket /// /// Docker keeps theirs in `/run` and also appears to use filesystem permissions @@ -215,6 +153,10 @@ impl IpcServer { } } +pub(crate) fn notify_service_controller() -> Result<()> { + Ok(sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?) +} + /// Platform-specific setup needed for connlib /// /// On Linux this does nothing @@ -222,36 +164,3 @@ impl IpcServer { pub(crate) fn setup_before_connlib() -> Result<()> { Ok(()) } - -#[cfg(test)] -mod tests { - use std::net::IpAddr; - - #[test] - fn parse_resolvectl_output() { - let cases = [ - // WSL - ( - r"Global: 172.24.80.1 -Link 2 (eth0): -Link 3 (docker0): -Link 24 (br-fc0b71997a3c): -Link 25 (br-0c129dafb204): -Link 26 (br-e67e83b19dce): -", - [IpAddr::from([172, 24, 80, 1])], - ), - // Ubuntu 20.04 - ( - r"Global: -Link 2 (enp0s3): 192.168.1.1", - [IpAddr::from([192, 168, 1, 1])], - ), - ]; - - for (i, (input, expected)) in cases.iter().enumerate() { - let actual = super::parse_resolvectl_output(input); - assert_eq!(actual, expected, "Case {i} failed"); - } - } -} diff --git a/rust/headless-client/src/windows.rs b/rust/headless-client/src/windows.rs index ed5a6a691..17ba73e77 100644 --- a/rust/headless-client/src/windows.rs +++ b/rust/headless-client/src/windows.rs @@ -10,7 +10,6 @@ use connlib_client_shared::file_logger; use connlib_shared::BUNDLE_ID; use std::{ ffi::{c_void, OsString}, - net::IpAddr, path::{Path, PathBuf}, str::FromStr, time::Duration, @@ -102,7 +101,7 @@ fn fallible_windows_service_run(arguments: Vec) -> Result<()> { // Fixes , // DNS rules persisting after reboot - connlib_shared::deactivate_dns_control().ok(); + crate::dns_control::deactivate().ok(); let ipc_task = rt.spawn(super::ipc_listen()); let ipc_task_ah = ipc_task.abort_handle(); @@ -242,22 +241,6 @@ pub fn pipe_path() -> String { named_pipe_path(&format!("{BUNDLE_ID}.ipc_service")) } -pub fn system_resolvers() -> Result> { - let resolvers = ipconfig::get_adapters()? - .iter() - .flat_map(|adapter| adapter.dns_servers()) - .filter(|ip| match ip { - IpAddr::V4(_) => true, - // Filter out bogus DNS resolvers on my dev laptop that start with fec0: - IpAddr::V6(ip) => !ip.octets().starts_with(&[0xfe, 0xc0]), - }) - .copied() - .collect(); - // This is private, so keep it at `debug` or `trace` - tracing::debug!(?resolvers); - Ok(resolvers) -} - /// Returns a valid name for a Windows named pipe /// /// # Arguments @@ -267,6 +250,14 @@ pub fn named_pipe_path(id: &str) -> String { format!(r"\\.\pipe\{}", id) } +// Does nothing on Windows. On Linux this notifies systemd that we're ready. +// When we eventually have a system service for the Windows Headless Client, +// this could notify the Windows service controller too. +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn notify_service_controller() -> Result<()> { + Ok(()) +} + pub(crate) fn setup_before_connlib() -> Result<()> { wintun_install::ensure_dll()?; Ok(()) diff --git a/rust/headless-client/src/windows/wintun_install.rs b/rust/headless-client/src/windows/wintun_install.rs index 96b31cf2a..73a9624c8 100644 --- a/rust/headless-client/src/windows/wintun_install.rs +++ b/rust/headless-client/src/windows/wintun_install.rs @@ -41,7 +41,6 @@ pub(crate) fn ensure_dll() -> Result { let dir = path.parent().ok_or(Error::DllPathInvalid)?; std::fs::create_dir_all(dir).map_err(|_| Error::CreateDirAll)?; - // TODO: This log never shows up because `tracing` isn't started when we install wintun.dll tracing::info!(?path, "wintun.dll path"); // This hash check is not meant to protect against attacks. It only lets us skip redundant disk writes, and it updates the DLL if needed.