diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 68ec005a2..173c36f94 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1782,6 +1782,7 @@ name = "firezone-bin-shared" version = "0.1.0" dependencies = [ "anyhow", + "clap", "connlib-shared", "futures", "ip-packet", diff --git a/rust/bin-shared/Cargo.toml b/rust/bin-shared/Cargo.toml index f608d1f62..a2bbe74b9 100644 --- a/rust/bin-shared/Cargo.toml +++ b/rust/bin-shared/Cargo.toml @@ -7,6 +7,7 @@ description = "Firezone-specific modules shared between binaries." [dependencies] anyhow = "1.0.82" +clap = { version = "4.5.4", features = ["derive"] } connlib-shared = { workspace = true } futures = "0.3" ip-packet = { workspace = true } diff --git a/rust/bin-shared/src/linux.rs b/rust/bin-shared/src/linux.rs index 46894206c..15ef42944 100644 --- a/rust/bin-shared/src/linux.rs +++ b/rust/bin-shared/src/linux.rs @@ -4,10 +4,13 @@ use crate::FIREZONE_MARK; use nix::sys::socket::{setsockopt, sockopt}; use socket_factory::{TcpSocket, UdpSocket}; -const FIREZONE_DNS_CONTROL: &str = "FIREZONE_DNS_CONTROL"; - -#[derive(Clone, Copy, Debug)] +#[derive(clap::ValueEnum, Clone, Copy, Debug)] pub enum DnsControlMethod { + /// Explicitly disable DNS control. + /// + /// We don't use an `Option` because leaving out the CLI arg should + /// use Systemd, not disable DNS control. + Disabled, /// Back up `/etc/resolv.conf` and replace it with our own /// /// Only suitable for the Alpine CI containers and maybe something like an @@ -16,23 +19,12 @@ pub enum DnsControlMethod { /// Cooperate with `systemd-resolved` /// /// Suitable for most Ubuntu systems, probably - Systemd, + SystemdResolved, } impl Default for DnsControlMethod { fn default() -> Self { - Self::Systemd - } -} - -impl DnsControlMethod { - /// Reads FIREZONE_DNS_CONTROL. Returns None if invalid or not set - pub fn from_env() -> Option { - match std::env::var(FIREZONE_DNS_CONTROL).as_deref() { - Ok("etc-resolv-conf") => Some(DnsControlMethod::EtcResolvConf), - Ok("systemd-resolved") => Some(DnsControlMethod::Systemd), - _ => None, - } + Self::SystemdResolved } } diff --git a/rust/bin-shared/src/network_changes/linux.rs b/rust/bin-shared/src/network_changes/linux.rs index 45ec68474..49620176e 100644 --- a/rust/bin-shared/src/network_changes/linux.rs +++ b/rust/bin-shared/src/network_changes/linux.rs @@ -30,11 +30,13 @@ struct SignalParams { /// Should be equivalent to `dbus-monitor --system "type='signal',interface='org.freedesktop.DBus.Properties',path='/org/freedesktop/resolve1',member='PropertiesChanged'"` pub async fn new_dns_notifier( _tokio_handle: tokio::runtime::Handle, - method: Option, + method: DnsControlMethod, ) -> Result { match method { - Some(DnsControlMethod::EtcResolvConf) | None => Ok(Worker::new_dns_poller()), - Some(DnsControlMethod::Systemd) => { + DnsControlMethod::Disabled | DnsControlMethod::EtcResolvConf => { + Ok(Worker::new_dns_poller()) + } + DnsControlMethod::SystemdResolved => { Worker::new_dbus(SignalParams { dest: "org.freedesktop.resolve1", path: "/org/freedesktop/resolve1", @@ -51,11 +53,11 @@ pub async fn new_dns_notifier( /// Should be similar to `dbus-monitor --system "type='signal',interface='org.freedesktop.NetworkManager',member='StateChanged'"` pub async fn new_network_notifier( _tokio_handle: tokio::runtime::Handle, - method: Option, + method: DnsControlMethod, ) -> Result { match method { - Some(DnsControlMethod::EtcResolvConf) | None => Ok(Worker::Null), - Some(DnsControlMethod::Systemd) => { + DnsControlMethod::Disabled | DnsControlMethod::EtcResolvConf => Ok(Worker::Null), + DnsControlMethod::SystemdResolved => { Worker::new_dbus(SignalParams { dest: "org.freedesktop.NetworkManager", path: "/org/freedesktop/NetworkManager", diff --git a/rust/bin-shared/src/network_changes/windows.rs b/rust/bin-shared/src/network_changes/windows.rs index cfdda5170..08733e600 100644 --- a/rust/bin-shared/src/network_changes/windows.rs +++ b/rust/bin-shared/src/network_changes/windows.rs @@ -82,21 +82,15 @@ use windows::{ #[allow(clippy::unused_async)] pub async fn new_dns_notifier( _tokio_handle: tokio::runtime::Handle, - method: Option, + _method: DnsControlMethod, ) -> Result { - match method { - Some(DnsControlMethod::Nrpt) | None => {} - } async_dns::DnsNotifier::new() } pub async fn new_network_notifier( _tokio_handle: tokio::runtime::Handle, - method: Option, + _method: DnsControlMethod, ) -> Result { - match method { - Some(DnsControlMethod::Nrpt) | None => {} - } NetworkNotifier::new().await } diff --git a/rust/bin-shared/src/windows.rs b/rust/bin-shared/src/windows.rs index 9b60ebd74..8b50b21ae 100644 --- a/rust/bin-shared/src/windows.rs +++ b/rust/bin-shared/src/windows.rs @@ -8,8 +8,13 @@ use std::path::PathBuf; /// Also used for self-elevation pub const CREATE_NO_WINDOW: u32 = 0x08000000; -#[derive(Clone, Copy, Debug)] +#[derive(clap::ValueEnum, Clone, Copy, Debug)] pub enum DnsControlMethod { + /// Explicitly disable DNS control. + /// + /// We don't use an `Option` because leaving out the CLI arg should + /// use NRPT, not disable DNS control. + Disabled, /// NRPT, the only DNS control method we use on Windows. Nrpt, } @@ -20,14 +25,6 @@ impl Default for DnsControlMethod { } } -impl DnsControlMethod { - /// Needed to match Linux - #[allow(clippy::unnecessary_wraps)] - pub fn from_env() -> Option { - Some(DnsControlMethod::Nrpt) - } -} - /// Returns e.g. `C:/Users/User/AppData/Local/dev.firezone.client /// /// This is where we can save config, logs, crash dumps, etc. diff --git a/rust/gui-client/src-tauri/deb_files/firezone-client-ipc.service b/rust/gui-client/src-tauri/deb_files/firezone-client-ipc.service index 5c01fab23..3286074d3 100644 --- a/rust/gui-client/src-tauri/deb_files/firezone-client-ipc.service +++ b/rust/gui-client/src-tauri/deb_files/firezone-client-ipc.service @@ -37,7 +37,6 @@ SystemCallArchitectures=native SystemCallFilter=@aio @basic-io @file-system @io-event @ipc @network-io @signal @system-service UMask=077 -Environment="FIREZONE_DNS_CONTROL=systemd-resolved" Environment="LOG_DIR=/var/log/dev.firezone.client" EnvironmentFile="/etc/default/firezone-client-ipc" diff --git a/rust/gui-client/src-tauri/src/client/gui.rs b/rust/gui-client/src-tauri/src/client/gui.rs index ae16b668e..b91da6247 100644 --- a/rust/gui-client/src-tauri/src/client/gui.rs +++ b/rust/gui-client/src-tauri/src/client/gui.rs @@ -800,7 +800,7 @@ async fn run_controller( } let tokio_handle = tokio::runtime::Handle::current(); - let dns_control_method = Some(Default::default()); + let dns_control_method = Default::default(); let mut dns_notifier = new_dns_notifier(tokio_handle.clone(), dns_control_method).await?; let mut network_notifier = diff --git a/rust/headless-client/src/dns_control.rs b/rust/headless-client/src/dns_control.rs index 7d4569c53..00ed381af 100644 --- a/rust/headless-client/src/dns_control.rs +++ b/rust/headless-client/src/dns_control.rs @@ -1,3 +1,14 @@ +//! Platform-specific code to control the system's DNS resolution +//! +//! On Linux, we use `systemd-resolved` by default. We can also control +//! `/etc/resolv.conf` or explicitly not control DNS. +//! +//! On Windows, we use NRPT by default. We can also explicitly not control DNS. + +use anyhow::Result; +use firezone_bin_shared::DnsControlMethod; +use std::net::IpAddr; + #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] @@ -8,8 +19,33 @@ mod windows; #[cfg(target_os = "windows")] use windows as platform; -pub(crate) use platform::{system_resolvers, DnsController}; +use platform::system_resolvers; + +/// Controls system-wide DNS. +/// +/// Always call `deactivate` when Firezone starts. +/// +/// Only one of these should exist on the entire system at a time. +pub(crate) struct DnsController { + pub dns_control_method: DnsControlMethod, +} + +impl Drop for DnsController { + fn drop(&mut self) { + if let Err(error) = self.deactivate() { + tracing::error!(?error, "Failed to deactivate DNS control"); + } + } +} + +impl DnsController { + pub(crate) fn system_resolvers(&self) -> Vec { + system_resolvers(self.dns_control_method).unwrap_or_default() + } +} // 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; +pub fn system_resolvers_for_gui() -> Result> { + system_resolvers(DnsControlMethod::default()) +} diff --git a/rust/headless-client/src/dns_control/linux.rs b/rust/headless-client/src/dns_control/linux.rs index 5400a580c..f69d9d2ab 100644 --- a/rust/headless-client/src/dns_control/linux.rs +++ b/rust/headless-client/src/dns_control/linux.rs @@ -1,44 +1,15 @@ +use super::DnsController; use anyhow::{bail, Context as _, Result}; use firezone_bin_shared::{DnsControlMethod, TunDeviceManager}; use std::{net::IpAddr, process::Command, str::FromStr}; mod etc_resolv_conf; -pub fn system_resolvers_for_gui() -> Result> { - get_system_default_resolvers_systemd_resolved() -} - -/// Controls system-wide DNS. -/// -/// Always call `deactivate` when Firezone starts. -/// -/// Only one of these should exist on the entire system at a time. -pub(crate) struct DnsController { - dns_control_method: Option, -} - -impl Default for DnsController { - fn default() -> Self { - // We'll remove `get_from_env` in #5068 - let dns_control_method = DnsControlMethod::from_env(); - tracing::info!(?dns_control_method); - Self { dns_control_method } - } -} - -impl Drop for DnsController { - fn drop(&mut self) { - if let Err(error) = self.deactivate() { - tracing::error!(?error, "Failed to deactivate DNS control"); - } - } -} - impl DnsController { #[allow(clippy::unnecessary_wraps)] pub(crate) fn deactivate(&mut self) -> Result<()> { tracing::debug!("Deactivating DNS control..."); - if let Some(DnsControlMethod::EtcResolvConf) = self.dns_control_method { + if let DnsControlMethod::EtcResolvConf = self.dns_control_method { // TODO: Check that nobody else modified the file while we were running. etc_resolv_conf::revert()?; } @@ -51,11 +22,15 @@ impl DnsController { /// it would be bad if this was called from 2 threads at once. /// /// Cancel safety: Try not to cancel this. - pub(crate) async fn set_dns(&mut self, dns_config: &[IpAddr]) -> Result<()> { + pub(crate) async fn set_dns(&mut self, dns_config: Vec) -> Result<()> { match self.dns_control_method { - None => Ok(()), - Some(DnsControlMethod::EtcResolvConf) => etc_resolv_conf::configure(dns_config).await, - Some(DnsControlMethod::Systemd) => configure_systemd_resolved(dns_config).await, + DnsControlMethod::Disabled => Ok(()), + DnsControlMethod::EtcResolvConf => { + tokio::task::spawn_blocking(move || etc_resolv_conf::configure(&dns_config)) + .await + .context("Failed to `spawn_blocking` DNS control task")? + } + DnsControlMethod::SystemdResolved => configure_systemd_resolved(&dns_config).await, } .context("Failed to control DNS") } @@ -65,7 +40,7 @@ impl DnsController { /// Does nothing if we're using other DNS control methods or none at all pub(crate) fn flush(&self) -> Result<()> { // Flushing is only implemented for systemd-resolved - if matches!(self.dns_control_method, Some(DnsControlMethod::Systemd)) { + if matches!(self.dns_control_method, DnsControlMethod::SystemdResolved) { tracing::debug!("Flushing systemd-resolved DNS cache..."); Command::new("resolvectl").arg("flush-caches").status()?; tracing::debug!("Flushed DNS."); @@ -106,11 +81,12 @@ async fn configure_systemd_resolved(dns_config: &[IpAddr]) -> Result<()> { Ok(()) } -pub(crate) fn system_resolvers() -> Result> { - match DnsControlMethod::from_env() { - None => get_system_default_resolvers_resolv_conf(), - Some(DnsControlMethod::EtcResolvConf) => get_system_default_resolvers_resolv_conf(), - Some(DnsControlMethod::Systemd) => get_system_default_resolvers_systemd_resolved(), +pub(crate) fn system_resolvers(dns_control_method: DnsControlMethod) -> Result> { + match dns_control_method { + DnsControlMethod::Disabled | DnsControlMethod::EtcResolvConf => { + get_system_default_resolvers_resolv_conf() + } + DnsControlMethod::SystemdResolved => get_system_default_resolvers_systemd_resolved(), } } diff --git a/rust/headless-client/src/dns_control/linux/etc_resolv_conf.rs b/rust/headless-client/src/dns_control/linux/etc_resolv_conf.rs index 8cc9ffdf5..fc93839b9 100644 --- a/rust/headless-client/src/dns_control/linux/etc_resolv_conf.rs +++ b/rust/headless-client/src/dns_control/linux/etc_resolv_conf.rs @@ -1,8 +1,9 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use std::{ + fs, io::{self, Write}, net::IpAddr, - path::PathBuf, + path::{Path, PathBuf}, }; pub(crate) const ETC_RESOLV_CONF: &str = "/etc/resolv.conf"; @@ -34,8 +35,8 @@ 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(crate) async fn configure(dns_config: &[IpAddr]) -> Result<()> { - configure_at_paths(dns_config, &ResolvPaths::default()).await +pub(crate) fn configure(dns_config: &[IpAddr]) -> Result<()> { + configure_at_paths(dns_config, &ResolvPaths::default()) } /// Revert changes Firezone made to `/etc/resolv.conf` @@ -46,25 +47,22 @@ pub(crate) fn revert() -> Result<()> { revert_at_paths(&ResolvPaths::default()) } -async fn configure_at_paths(dns_config: &[IpAddr], paths: &ResolvPaths) -> Result<()> { +fn configure_at_paths(dns_config: &[IpAddr], paths: &ResolvPaths) -> Result<()> { if dns_config.is_empty() { tracing::warn!("`dns_config` is empty, leaving `/etc/resolv.conf` unchanged"); return Ok(()); } - let text = tokio::fs::read_to_string(&paths.resolv) - .await - .context("Failed to read `resolv.conf`")?; + // There is a TOCTOU here, if the user somehow enables `systemd-resolved` while Firezone is booting up. + ensure_regular_file(&paths.resolv)?; + + let text = fs::read_to_string(&paths.resolv).context("Failed to read `resolv.conf`")?; let text = if text.starts_with(MAGIC_HEADER) { tracing::info!("The last run of Firezone crashed before reverting `/etc/resolv.conf`. Reverting it now before re-writing it."); let resolv_path = &paths.resolv; let paths = paths.clone(); - tokio::task::spawn_blocking(move || revert_at_paths(&paths)) - .await - .context("`spawn_blocking` failed while trying to run `revert_at_paths`")? - .context("Failed to revert `'resolv.conf`")?; - tokio::fs::read_to_string(resolv_path) - .await + revert_at_paths(&paths).context("Failed to revert `'resolv.conf`")?; + fs::read_to_string(resolv_path) .context("Failed to re-read `resolv.conf` after reverting it")? } else { // The last run of Firezone reverted resolv.conf successfully, @@ -86,15 +84,11 @@ async fn configure_at_paths(dns_config: &[IpAddr], paths: &ResolvPaths) -> Resul &paths.backup, atomicwrites::OverwriteBehavior::AllowOverwrite, ); - tokio::task::spawn_blocking(move || { - backup_file - .write(|f| f.write_all(text.as_bytes())) - .context("Failed to back up `resolv.conf`") - }) - .await - .context("Failed to run sync file operation in a blocking task")??; + backup_file + .write(|f| f.write_all(text.as_bytes())) + .context("Failed to back up `resolv.conf`")?; - let mut new_resolv_conf = parsed.clone(); + let mut new_resolv_conf = parsed; new_resolv_conf.nameservers = dns_config.iter().map(|addr| (*addr).into()).collect(); @@ -119,15 +113,15 @@ async fn configure_at_paths(dns_config: &[IpAddr], paths: &ResolvPaths) -> Resul new_resolv_conf, ); - tokio::fs::write(&paths.resolv, new_text) - .await - .context("Failed to rewrite `resolv.conf`")?; + fs::write(&paths.resolv, new_text).context("Failed to rewrite `resolv.conf`")?; Ok(()) } +// Must be sync so we can call it from `Drop` impls fn revert_at_paths(paths: &ResolvPaths) -> Result<()> { - match std::fs::copy(&paths.backup, &paths.resolv) { + ensure_regular_file(&paths.resolv)?; + match fs::copy(&paths.backup, &paths.resolv) { Err(e) if e.kind() == io::ErrorKind::NotFound => { tracing::debug!("Didn't revert `/etc/resolv.conf`, no backup file found"); return Ok(()); @@ -142,6 +136,14 @@ fn revert_at_paths(paths: &ResolvPaths) -> Result<()> { Ok(()) } +fn ensure_regular_file(path: &Path) -> Result<()> { + let file_type = fs::symlink_metadata(path)?.file_type(); + if !file_type.is_file() { + bail!("File `{path:?}` is not a regular file, cannot use it to control DNS"); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{configure_at_paths, revert_at_paths, ResolvPaths}; @@ -302,7 +304,7 @@ nameserver 100.100.111.2 write_resolv_conf(&paths.resolv, &[GOOGLE_DNS.into()])?; - configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths)?; check_resolv_conf(&paths.resolv, &[IpAddr::from([100, 100, 111, 1])])?; check_resolv_conf(&paths.backup, &[GOOGLE_DNS.into()])?; @@ -324,7 +326,7 @@ nameserver 100.100.111.2 write_resolv_conf(&paths.resolv, &[GOOGLE_DNS.into()])?; - configure_at_paths(&[], &paths).await?; + configure_at_paths(&[], &paths)?; check_resolv_conf(&paths.resolv, &[GOOGLE_DNS.into()])?; // No backup since we didn't touch the original file @@ -339,11 +341,11 @@ nameserver 100.100.111.2 let (_temp_dir, paths) = create_temp_paths(); write_resolv_conf(&paths.resolv, &[GOOGLE_DNS.into()])?; - configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths)?; revert_at_paths(&paths)?; write_resolv_conf(&paths.resolv, &[CLOUDFLARE_DNS.into()])?; - configure_at_paths(&[IpAddr::from([100, 100, 111, 2])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 2])], &paths)?; check_resolv_conf(&paths.resolv, &[IpAddr::from([100, 100, 111, 2])])?; check_resolv_conf(&paths.backup, &[CLOUDFLARE_DNS.into()])?; revert_at_paths(&paths)?; @@ -366,7 +368,7 @@ nameserver 100.100.111.2 write_resolv_conf(&paths.resolv, &[GOOGLE_DNS.into()])?; // First run - configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths)?; check_resolv_conf(&paths.resolv, &[IpAddr::from([100, 100, 111, 1])]) .context("First run, resolv.conf should have sentinel")?; check_resolv_conf(&paths.backup, &[GOOGLE_DNS.into()]) @@ -375,7 +377,7 @@ nameserver 100.100.111.2 // Crash happens // Second run - configure_at_paths(&[IpAddr::from([100, 100, 111, 2])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 2])], &paths)?; check_resolv_conf(&paths.resolv, &[IpAddr::from([100, 100, 111, 2])]) .context("Second run, resolv.conf should have new sentinel")?; check_resolv_conf(&paths.backup, &[GOOGLE_DNS.into()]) @@ -399,7 +401,7 @@ nameserver 100.100.111.2 write_resolv_conf(&paths.resolv, &[GOOGLE_DNS.into()])?; // First run - configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths)?; check_resolv_conf(&paths.resolv, &[IpAddr::from([100, 100, 111, 1])]) .context("First run, resolv.conf should have sentinel")?; check_resolv_conf(&paths.backup, &[GOOGLE_DNS.into()]) @@ -410,7 +412,7 @@ nameserver 100.100.111.2 write_resolv_conf(&paths.resolv, &[CLOUDFLARE_DNS.into()])?; // Second run - configure_at_paths(&[IpAddr::from([100, 100, 111, 2])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 2])], &paths)?; check_resolv_conf(&paths.resolv, &[IpAddr::from([100, 100, 111, 2])]) .context("Second run, resolv.conf should have new sentinel")?; check_resolv_conf(&paths.backup, &[CLOUDFLARE_DNS.into()]) @@ -435,11 +437,11 @@ nameserver 100.100.111.2 write_resolv_conf(&paths.resolv, &[GOOGLE_DNS.into()])?; // Configure twice - configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths)?; check_resolv_conf(&paths.resolv, &[IpAddr::from([100, 100, 111, 1])])?; check_resolv_conf(&paths.backup, &[GOOGLE_DNS.into()])?; - configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths).await?; + configure_at_paths(&[IpAddr::from([100, 100, 111, 1])], &paths)?; check_resolv_conf(&paths.resolv, &[IpAddr::from([100, 100, 111, 1])])?; check_resolv_conf(&paths.backup, &[GOOGLE_DNS.into()])?; diff --git a/rust/headless-client/src/dns_control/windows.rs b/rust/headless-client/src/dns_control/windows.rs index 7f44f23a7..c2848e31a 100644 --- a/rust/headless-client/src/dns_control/windows.rs +++ b/rust/headless-client/src/dns_control/windows.rs @@ -13,34 +13,15 @@ //! //! +use super::DnsController; use anyhow::{Context as _, Result}; -use firezone_bin_shared::platform::CREATE_NO_WINDOW; +use firezone_bin_shared::{platform::CREATE_NO_WINDOW, DnsControlMethod}; use std::{net::IpAddr, os::windows::process::CommandExt, path::Path, process::Command}; -pub fn system_resolvers_for_gui() -> Result> { - system_resolvers() -} - -/// Controls system-wide DNS. -/// -/// Always call `deactivate` when Firezone starts. -/// -/// Only one of these should exist on the entire system at a time. -#[derive(Default)] -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) = self.deactivate() { - tracing::error!(?error, "Failed to deactivate DNS control"); - } - } -} - impl DnsController { /// Deactivate any control Firezone has over the computer's DNS /// @@ -64,10 +45,15 @@ impl DnsController { /// 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 + /// Must be async and an owned `Vec` to match the Linux signature #[allow(clippy::unused_async)] - pub(crate) async fn set_dns(&mut self, dns_config: &[IpAddr]) -> Result<()> { - activate(dns_config).context("Failed to activate DNS control")?; + pub(crate) async fn set_dns(&mut self, dns_config: Vec) -> Result<()> { + match self.dns_control_method { + DnsControlMethod::Disabled => {} + DnsControlMethod::Nrpt => { + activate(&dns_config).context("Failed to activate DNS control")? + } + } Ok(()) } @@ -85,7 +71,7 @@ impl DnsController { } } -pub(crate) fn system_resolvers() -> Result> { +pub(crate) fn system_resolvers(_method: DnsControlMethod) -> Result> { let resolvers = ipconfig::get_adapters()? .iter() .flat_map(|adapter| adapter.dns_servers()) diff --git a/rust/headless-client/src/ipc_service.rs b/rust/headless-client/src/ipc_service.rs index f5fe255ca..d666a413d 100644 --- a/rust/headless-client/src/ipc_service.rs +++ b/rust/headless-client/src/ipc_service.rs @@ -1,12 +1,11 @@ use crate::{ - device_id, - dns_control::{self, DnsController}, - known_dirs, signals, CallbackHandler, CliCommon, InternalServerMsg, IpcServerMsg, - TOKEN_ENV_KEY, + device_id, dns_control::DnsController, known_dirs, signals, CallbackHandler, CliCommon, + InternalServerMsg, IpcServerMsg, TOKEN_ENV_KEY, }; use anyhow::{Context as _, Result}; use clap::Parser; use connlib_client_shared::{file_logger, keypair, ConnectArgs, LoginUrl, Session}; +use firezone_bin_shared::{DnsControlMethod, TunDeviceManager}; use futures::{ future::poll_fn, task::{Context, Poll}, @@ -21,7 +20,6 @@ use url::Url; pub mod ipc; use backoff::ExponentialBackoffBuilder; use connlib_shared::get_user_agent; -use firezone_bin_shared::TunDeviceManager; use ipc::{Server as IpcServer, ServiceId}; use phoenix_channel::PhoenixChannel; use secrecy::Secret; @@ -92,12 +90,12 @@ pub fn run_only_ipc_service() -> Result<()> { match cli.command { Cmd::Install => platform::install_ipc_service(), Cmd::Run => platform::run_ipc_service(cli.common), - Cmd::RunDebug => run_debug_ipc_service(), + Cmd::RunDebug => run_debug_ipc_service(cli), Cmd::RunSmokeTest => run_smoke_test(), } } -fn run_debug_ipc_service() -> Result<()> { +fn run_debug_ipc_service(cli: Cli) -> Result<()> { crate::setup_stdout_logging()?; tracing::info!( arch = std::env::consts::ARCH, @@ -108,7 +106,7 @@ fn run_debug_ipc_service() -> Result<()> { let _guard = rt.enter(); let mut signals = signals::Terminate::new()?; - rt.block_on(ipc_listen(&mut signals)) + rt.block_on(ipc_listen(cli.common.dns_control, &mut signals)) } #[cfg(not(debug_assertions))] @@ -124,7 +122,9 @@ fn run_smoke_test() -> Result<()> { crate::setup_stdout_logging()?; let rt = tokio::runtime::Runtime::new()?; let _guard = rt.enter(); - let mut dns_controller = DnsController::default(); + let mut dns_controller = DnsController { + dns_control_method: Default::default(), + }; // Deactivate Firezone DNS control in case the system or IPC service crashed // and we need to recover. dns_controller.deactivate()?; @@ -146,12 +146,15 @@ fn run_smoke_test() -> Result<()> { /// /// If an IPC client is connected when we catch a terminate signal, we send the /// client a hint about that before we exit. -async fn ipc_listen(signals: &mut signals::Terminate) -> Result<()> { +async fn ipc_listen( + dns_control_method: DnsControlMethod, + signals: &mut signals::Terminate, +) -> Result<()> { // Create the device ID and IPC service config dir if needed // This also gives the GUI a safe place to put the log filter config device_id::get_or_create().context("Failed to read / create device ID")?; let mut server = IpcServer::new(ServiceId::Prod).await?; - let mut dns_controller = DnsController::default(); + let mut dns_controller = DnsController { dns_control_method }; loop { let mut handler_fut = pin!(Handler::new(&mut server, &mut dns_controller)); let Some(handler) = poll_fn(|cx| { @@ -320,7 +323,7 @@ impl<'a> Handler<'a> { } InternalServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns } => { self.tun_device.set_ips(ipv4, ipv6).await?; - self.dns_controller.set_dns(&dns).await?; + self.dns_controller.set_dns(dns).await?; } InternalServerMsg::OnUpdateRoutes { ipv4, ipv6 } => { self.tun_device.set_routes(ipv4, ipv6).await? @@ -371,7 +374,7 @@ impl<'a> Handler<'a> { Session::connect(args, portal, tokio::runtime::Handle::try_current()?); let tun = self.tun_device.make_tun()?; new_session.set_tun(Box::new(tun)); - new_session.set_dns(dns_control::system_resolvers().unwrap_or_default()); + new_session.set_dns(self.dns_controller.system_resolvers()); self.connlib = Some(new_session); } ClientMsg::Disconnect => { @@ -453,17 +456,18 @@ mod tests { use clap::Parser; use std::path::PathBuf; + const EXE_NAME: &str = "firezone-client-ipc"; + // Can't remember how Clap works sometimes // Also these are examples #[test] fn cli() { - let exe_name = "firezone-client-ipc"; - - let actual = Cli::parse_from([exe_name, "--log-dir", "bogus_log_dir", "run-debug"]); + let actual = + Cli::try_parse_from([EXE_NAME, "--log-dir", "bogus_log_dir", "run-debug"]).unwrap(); assert!(matches!(actual.command, Cmd::RunDebug)); assert_eq!(actual.common.log_dir, Some(PathBuf::from("bogus_log_dir"))); - let actual = Cli::parse_from([exe_name, "run"]); + let actual = Cli::try_parse_from([EXE_NAME, "run"]).unwrap(); assert!(matches!(actual.command, Cmd::Run)); } } diff --git a/rust/headless-client/src/ipc_service/linux.rs b/rust/headless-client/src/ipc_service/linux.rs index 1b865e706..c583e8b04 100644 --- a/rust/headless-client/src/ipc_service/linux.rs +++ b/rust/headless-client/src/ipc_service/linux.rs @@ -14,7 +14,7 @@ pub(crate) fn run_ipc_service(cli: CliCommon) -> Result<()> { let _guard = rt.enter(); let mut signals = signals::Terminate::new()?; - rt.block_on(super::ipc_listen(&mut signals)) + rt.block_on(super::ipc_listen(cli.dns_control, &mut signals)) } pub(crate) fn install_ipc_service() -> Result<()> { diff --git a/rust/headless-client/src/ipc_service/windows.rs b/rust/headless-client/src/ipc_service/windows.rs index 44107ff76..aedf4c44b 100644 --- a/rust/headless-client/src/ipc_service/windows.rs +++ b/rust/headless-client/src/ipc_service/windows.rs @@ -1,6 +1,7 @@ use crate::CliCommon; use anyhow::{bail, Context as _, Result}; use connlib_client_shared::file_logger; +use firezone_bin_shared::DnsControlMethod; use futures::future::{self, Either}; use std::{ffi::OsString, pin::pin, time::Duration}; use tokio::sync::mpsc; @@ -176,7 +177,7 @@ async fn service_run_async(mut shutdown_rx: mpsc::Receiver<()>) -> Result<()> { // Useless - Windows will never send us Ctrl+C when running as a service // This just keeps the signatures simpler let mut signals = crate::signals::Terminate::new()?; - let listen_fut = pin!(super::ipc_listen(&mut signals)); + let listen_fut = pin!(super::ipc_listen(DnsControlMethod::Nrpt, &mut signals)); match future::select(listen_fut, pin!(shutdown_rx.recv())).await { Either::Left((Err(error), _)) => Err(error).context("`ipc_listen` threw an error"), Either::Left((Ok(()), _)) => { diff --git a/rust/headless-client/src/lib.rs b/rust/headless-client/src/lib.rs index dbc42caee..8857f0de1 100644 --- a/rust/headless-client/src/lib.rs +++ b/rust/headless-client/src/lib.rs @@ -11,6 +11,7 @@ use anyhow::{Context as _, Result}; use connlib_client_shared::{Callbacks, Error as ConnlibError}; use connlib_shared::callbacks; +use firezone_bin_shared::DnsControlMethod; use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr}, path::PathBuf, @@ -66,8 +67,16 @@ pub(crate) const GIT_VERSION: &str = git_version::git_version!( const TOKEN_ENV_KEY: &str = "FIREZONE_TOKEN"; /// CLI args common to both the IPC service and the headless Client -#[derive(clap::Args)] +#[derive(clap::Parser)] struct CliCommon { + #[cfg(target_os = "linux")] + #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "systemd-resolved")] + dns_control: DnsControlMethod, + + #[cfg(target_os = "windows")] + #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "nrpt")] + dns_control: DnsControlMethod, + /// File logging directory. Should be a path that's writeable by the current user. #[arg(short, long, env = "LOG_DIR")] log_dir: Option, @@ -163,10 +172,55 @@ pub fn setup_stdout_logging() -> Result<()> { #[cfg(test)] mod tests { use super::*; + use clap::Parser; + + const EXE_NAME: &str = "firezone-client-ipc"; // Make sure it's okay to store a bunch of these to mitigate #5880 #[test] fn callback_msg_size() { assert_eq!(std::mem::size_of::(), 56) } + + #[test] + #[cfg(target_os = "linux")] + fn dns_control() { + let actual = CliCommon::parse_from([EXE_NAME]); + assert!(matches!( + actual.dns_control, + DnsControlMethod::SystemdResolved + )); + + let actual = CliCommon::parse_from([EXE_NAME, "--dns-control", "disabled"]); + assert!(matches!(actual.dns_control, DnsControlMethod::Disabled)); + + let actual = CliCommon::parse_from([EXE_NAME, "--dns-control", "etc-resolv-conf"]); + assert!(matches!( + actual.dns_control, + DnsControlMethod::EtcResolvConf + )); + + let actual = CliCommon::parse_from([EXE_NAME, "--dns-control", "systemd-resolved"]); + assert!(matches!( + actual.dns_control, + DnsControlMethod::SystemdResolved + )); + + assert!(CliCommon::try_parse_from([EXE_NAME, "--dns-control", "invalid"]).is_err()); + } + + #[test] + #[cfg(target_os = "windows")] + fn dns_control() { + let actual = CliCommon::parse_from([EXE_NAME]); + assert!(matches!(actual.dns_control, DnsControlMethod::Nrpt)); + + let actual = CliCommon::parse_from([EXE_NAME, "--dns-control", "disabled"]); + assert!(matches!(actual.dns_control, DnsControlMethod::Disabled)); + + let actual = CliCommon::parse_from([EXE_NAME, "--dns-control", "nrpt"]); + assert!(matches!(actual.dns_control, DnsControlMethod::Nrpt)); + + assert!(CliCommon::try_parse_from([EXE_NAME, "--dns-control", "invalid"]).is_err()); + } } diff --git a/rust/headless-client/src/standalone.rs b/rust/headless-client/src/standalone.rs index e8db7bb7c..55d769c57 100644 --- a/rust/headless-client/src/standalone.rs +++ b/rust/headless-client/src/standalone.rs @@ -1,8 +1,8 @@ //! AKA "Headless" use crate::{ - default_token_path, device_id, dns_control, platform, signals, CallbackHandler, CliCommon, - DnsController, InternalServerMsg, IpcServerMsg, TOKEN_ENV_KEY, + default_token_path, device_id, platform, signals, CallbackHandler, CliCommon, DnsController, + InternalServerMsg, IpcServerMsg, TOKEN_ENV_KEY, }; use anyhow::{anyhow, Context as _, Result}; use backoff::ExponentialBackoffBuilder; @@ -10,8 +10,7 @@ use clap::Parser; use connlib_client_shared::{file_logger, keypair, ConnectArgs, LoginUrl, Session}; use connlib_shared::get_user_agent; use firezone_bin_shared::{ - new_dns_notifier, new_network_notifier, setup_global_subscriber, DnsControlMethod, - TunDeviceManager, + new_dns_notifier, new_network_notifier, setup_global_subscriber, TunDeviceManager, }; use futures::{FutureExt as _, StreamExt as _}; use phoenix_channel::PhoenixChannel; @@ -191,7 +190,8 @@ pub fn run_only_headless_client() -> Result<()> { let mut hangup = signals::Hangup::new()?; let mut terminate = pin!(terminate.recv().fuse()); let mut hangup = pin!(hangup.recv().fuse()); - let mut dns_controller = DnsController::default(); + let dns_control_method = cli.common.dns_control; + let mut dns_controller = DnsController { dns_control_method }; // Deactivate Firezone DNS control in case the system or IPC service crashed // and we need to recover. dns_controller.deactivate()?; @@ -199,7 +199,6 @@ pub fn run_only_headless_client() -> Result<()> { let mut cb_rx = ReceiverStream::new(cb_rx).fuse(); let tokio_handle = tokio::runtime::Handle::current(); - let dns_control_method = DnsControlMethod::from_env(); let mut dns_notifier = new_dns_notifier(tokio_handle.clone(), dns_control_method).await?; @@ -209,7 +208,7 @@ pub fn run_only_headless_client() -> Result<()> { let tun = tun_device.make_tun()?; session.set_tun(Box::new(tun)); - session.set_dns(dns_control::system_resolvers().unwrap_or_default()); + session.set_dns(dns_controller.system_resolvers()); let result = loop { let mut dns_changed = pin!(dns_notifier.notified().fuse()); @@ -230,7 +229,7 @@ pub fn run_only_headless_client() -> Result<()> { // If the DNS control method is not `systemd-resolved` // then we'll use polling here, so no point logging every 5 seconds that we're checking the DNS tracing::trace!("DNS change, notifying Session"); - session.set_dns(dns_control::system_resolvers()?); + session.set_dns(dns_controller.system_resolvers()); continue; }, result = network_changed => { @@ -257,7 +256,7 @@ pub fn run_only_headless_client() -> Result<()> { ), InternalServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns } => { tun_device.set_ips(ipv4, ipv6).await?; - dns_controller.set_dns(&dns).await?; + dns_controller.set_dns(dns).await?; // `on_set_interface_config` is guaranteed to be called when the tunnel is completely ready // if let Some(instant) = last_connlib_start_instant.take() { @@ -353,14 +352,15 @@ mod tests { fn cli() { let exe_name = "firezone-headless-client"; - let actual = Cli::parse_from([exe_name, "--api-url", "wss://api.firez.one"]); + let actual = Cli::try_parse_from([exe_name, "--api-url", "wss://api.firez.one"]).unwrap(); assert_eq!( actual.api_url, Url::parse("wss://api.firez.one").expect("Hard-coded URL should always be parsable") ); assert!(!actual.check); - let actual = Cli::parse_from([exe_name, "--check", "--log-dir", "bogus_log_dir"]); + let actual = + Cli::try_parse_from([exe_name, "--check", "--log-dir", "bogus_log_dir"]).unwrap(); assert!(actual.check); assert_eq!(actual.common.log_dir, Some(PathBuf::from("bogus_log_dir"))); } diff --git a/scripts/tests/dns-failsafe.sh b/scripts/tests/dns-failsafe.sh index 8c12f8d25..77c1569fd 100755 --- a/scripts/tests/dns-failsafe.sh +++ b/scripts/tests/dns-failsafe.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # If we set the DNS control to `systemd-resolved` but that's not available, -# we should still boot up and allow IP / CIDR resources to work +# the Client should bail out with an error message and non-zero exit code. + +set -euox pipefail source "./scripts/tests/lib.sh" diff --git a/scripts/tests/systemd/firezone-client-headless.service b/scripts/tests/systemd/firezone-client-headless.service index 56abcd71f..5e8a0d36f 100644 --- a/scripts/tests/systemd/firezone-client-headless.service +++ b/scripts/tests/systemd/firezone-client-headless.service @@ -35,6 +35,7 @@ SystemCallFilter=@aio @basic-io @file-system @io-event @network-io @signal @syst UMask=077 Environment="FIREZONE_API_URL=ws://localhost:8081" +# TODO: Remove after #6163 gets into a release Environment="FIREZONE_DNS_CONTROL=systemd-resolved" Environment="RUST_LOG=info" diff --git a/terraform/modules/google-cloud/apps/client-monitor/templates/cloud-init.yaml b/terraform/modules/google-cloud/apps/client-monitor/templates/cloud-init.yaml index 8d83df3b0..4c1e38cb1 100644 --- a/terraform/modules/google-cloud/apps/client-monitor/templates/cloud-init.yaml +++ b/terraform/modules/google-cloud/apps/client-monitor/templates/cloud-init.yaml @@ -42,7 +42,6 @@ write_files: UMask=077 Environment="FIREZONE_API_URL=${firezone_api_url}" - Environment="FIREZONE_DNS_CONTROL=systemd-resolved" Environment="FIREZONE_ID=${firezone_client_id}" Environment="RUST_LOG=${firezone_client_log_level}" Environment="LOG_DIR=/var/log/firezone" diff --git a/website/src/app/kb/client-apps/linux-client/readme.mdx b/website/src/app/kb/client-apps/linux-client/readme.mdx index 6ab24e96d..a184cdee0 100644 --- a/website/src/app/kb/client-apps/linux-client/readme.mdx +++ b/website/src/app/kb/client-apps/linux-client/readme.mdx @@ -59,7 +59,6 @@ Set some environment variables to configure it: export FIREZONE_NAME="Development API test client" export FIREZONE_ID=$(uuidgen) export FIREZONE_TOKEN= -export FIREZONE_DNS_CONTROL="systemd-resolved" # or "etc-resolv-conf" export LOG_DIR="./" sudo -E ./firezone-client-headless-linux_1.0.0_x86_64 ``` @@ -74,11 +73,14 @@ automatically use it to securely connect to Resources. ### Split DNS -By default, Split DNS is **disabled** for the Linux headless Client. This means -that access to DNS-based Resources won't be routed through Firezone. +By default, Split DNS is **enabled** for the Linux headless Client as of version +1.1.5. -To enable Split DNS for the Linux Client, set the `FIREZONE_DNS_CONTROL` -environment variable to `systemd-resolved` or `etc-resolv-conf`. +To disable Split DNS for the Linux Client, set the `FIREZONE_DNS_CONTROL` +environment variable to `disabled`. + +To control `/etc/resolv.conf` directly, set `FIREZONE_DNS_CONTROL` to +`etc-resolv-conf`. Read more below to figure out which DNS control method is appropriate for your system. @@ -86,15 +88,15 @@ system. #### systemd-resolved On most modern Linux distributions, DNS resolution is handled by -`systemd-resolved`. If this is the case for you, set `FIREZONE_DNS_CONTROL` to -`systemd-resolved`. If you're not sure, you can check by running the following -command: +`systemd-resolved`. If this is the case for you, do not set +`FIREZONE_DNS_CONTROL`. If you're not sure whether you use `systemd-resolved`, +you can check by running the following command: ```bash systemctl status systemd-resolved ``` -You'll need to ensure that `/etc/resolv.conf` is a symlink to +Ensure that `/etc/resolv.conf` is a symlink to `/run/systemd/resolve/stub-resolv.conf`: ```bash @@ -108,9 +110,9 @@ sudo mv /etc/resolve.conf.new /etc/resolv.conf #### NetworkManager -In most cases, if you're using NetworkManager your system is likely already -using `systemd-resolved`, and you should set `FIREZONE_DNS_CONTROL` to -`systemd-resolved`. +In most cases, if you're using NetworkManager your system already uses +`systemd-resolved`, and you should leave `FIREZONE_DNS_CONTROL` unset, which +will use the default `systemd-resolved` DNS control method. When NetworkManager detects that symlink exists, it will automatically use `systemd-resolved` for DNS resolution and no other configuration is necessary. @@ -134,14 +136,14 @@ sudo mv /etc/resolv.conf.before-firezone /etc/resolv.conf ### Environment variable reference -| Variable Name | Default Value | Description | -| ---------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `FIREZONE_TOKEN` | | Service account token generated by the portal to authenticate this Client. | -| `FIREZONE_NAME` | `` | Friendly name for this client to display in the UI. | -| `FIREZONE_ID` | | Identifier used by the portal to identify this client for metadata and display purposes. | -| `FIREZONE_DNS_CONTROL` | (blank) | The DNS control method to use. Set this to `systemd-resolved` to use systemd-resolved for Split DNS, or `etc-resolv-conf` to use the `/etc/resolv.conf` file. If left blank, Split DNS will be **disabled**. | -| `LOG_DIR` | | File logging directory. Should be a path that's writeable by the current user. If unset, logs will be written to `stdout` only. | -| `RUST_LOG` | `error` | Log level for the client. Set to `debug` for verbose logging. Read more about configuring Rust log levels [here](https://docs.rs/env_logger/latest/env_logger/). | +| Variable Name | Default Value | Description | +| ---------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FIREZONE_TOKEN` | | Service account token generated by the portal to authenticate this Client. | +| `FIREZONE_NAME` | `` | Friendly name for this client to display in the UI. | +| `FIREZONE_ID` | | Identifier used by the portal to identify this client for metadata and display purposes. | +| `FIREZONE_DNS_CONTROL` | (blank) | The DNS control method to use. The default is `systemd-resolved`. Set this to `disabled` to disable DNS control, or `etc-resolv-conf` to use the `/etc/resolv.conf` file. Do not use `etc-resolv-conf` if `/etc/resolv.conf` is not a regular file, e.g. if it's a symlink to `/run/systemd/resolve/stub-resolv.conf` | +| `LOG_DIR` | | File logging directory. Should be a path that's writeable by the current user. If unset, logs will be written to `stdout` only. | +| `RUST_LOG` | `error` | Log level for the client. Set to `debug` for verbose logging. Read more about configuring Rust log levels [here](https://docs.rs/env_logger/latest/env_logger/). | ### Help output @@ -239,7 +241,7 @@ nameserver 192.168.1.1 ### Check if Firezone is being used Check if `curl` reports a remote IP in Firezone's range when you connect to a -resource: +Resource: ```text > curl --silent --output /dev/null --write-out %{remote_ip}\\n https://example.com diff --git a/website/src/app/kb/client-apps/linux-gui-client/readme.mdx b/website/src/app/kb/client-apps/linux-gui-client/readme.mdx index 81118079a..55d4726bb 100644 --- a/website/src/app/kb/client-apps/linux-gui-client/readme.mdx +++ b/website/src/app/kb/client-apps/linux-gui-client/readme.mdx @@ -160,11 +160,30 @@ Global: Link 2 (enp0s6): 10.0.2.3 fec0::3 ``` +```bash +cat /etc/resolv.conf +``` + +Normal `resolv.conf` if `systemd-resolved` is installed, whether or not Firezone +is running: + +```text +# This file is managed by man:systemd-resolved(8). Do not edit. +... +``` + +Firezone `resolv.conf` if you set `FIREZONE_DNS_CONTROL=etc-resolv-conf`: + +```text +# BEGIN Firezone DNS configuration +... +``` + ### Revert Firezone DNS control -The Firezone GUI Client for Linux uses `systemd-resolved` to control DNS, which -will automatically revert DNS to the system defaults when you quit the Firezone -GUI, which destroys the `tun-firezone` virtual network interface. +By default, the Firezone GUI Client for Linux controls DNS using +`systemd-resolved`, which will automatically revert DNS to the system defaults +when Firezone is disconnected. If the network interface stays up and DNS does not revert, you can try restarting the tunnel service. Quit the Firezone GUI, then run: diff --git a/website/src/components/Changelog/GUI.tsx b/website/src/components/Changelog/GUI.tsx index da646cacc..80cfd65bb 100644 --- a/website/src/components/Changelog/GUI.tsx +++ b/website/src/components/Changelog/GUI.tsx @@ -15,6 +15,12 @@ export default function GUI({ title }: { title: string }) { {/*
    + + Supports using `etc-resolv-conf` DNS control method, or disabling DNS control + + + Supports disabling DNS control + Mitigates a bug where the IPC service can panic if an internal channel fills up diff --git a/website/src/components/Changelog/Headless.tsx b/website/src/components/Changelog/Headless.tsx index d2f50f6d0..e72157c29 100644 --- a/website/src/components/Changelog/Headless.tsx +++ b/website/src/components/Changelog/Headless.tsx @@ -1,7 +1,7 @@ -import Link from "next/link"; +import ChangeItem from "./ChangeItem"; import Entry from "./Entry"; import Entries from "./Entries"; -import ChangeItem from "./ChangeItem"; +import Link from "next/link"; export default function Headless() { const href = "/dl/firezone-client-headless-linux/:version/:arch"; @@ -12,6 +12,9 @@ export default function Headless() { {/*
      + + Uses `systemd-resolved` DNS control by default on Linux + Mitigates a bug where the Client can panic if an internal channel fills up