From 00f6fcdd09158dd3fcfd9b1507d15b33b659335a Mon Sep 17 00:00:00 2001 From: Reactor Scram Date: Wed, 14 Feb 2024 17:50:01 -0600 Subject: [PATCH] feat(linux): If `FIREZONE_DNS_CONTROL` is `etc-resolv-conf`, modify '/etc/resolv.conf' (#3639) Only user-facing if users are using the Docker image for the Linux client. I split off a module for `/etc/resolv.conf` since the code and unit tests are about 300 lines and aren't related to the rest of the `tun_linux.rs` code. --------- Signed-off-by: Reactor Scram --- .github/workflows/ci.yml | 1 + docker-compose.yml | 2 - rust/Cargo.lock | 37 ++- rust/connlib/shared/src/error.rs | 9 + rust/connlib/shared/src/lib.rs | 1 - rust/connlib/shared/src/linux.rs | 5 +- rust/connlib/tunnel/Cargo.toml | 2 + .../src/device_channel/etc_resolv_conf.rs | 290 ++++++++++++++++++ .../tunnel/src/device_channel/tun_linux.rs | 151 ++------- rust/linux-client/Cargo.toml | 2 + rust/linux-client/src/main.rs | 69 ++++- scripts/tests/dns-etc-resolvconf.sh | 50 +++ 12 files changed, 466 insertions(+), 153 deletions(-) create mode 100644 rust/connlib/tunnel/src/device_channel/etc_resolv_conf.rs create mode 100755 scripts/tests/dns-etc-resolvconf.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 179cccc57..84c8c4e54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,7 @@ jobs: direct-ping-portal-down, relayed-ping-portal-down, direct-ping-portal-relay-down, + dns-etc-resolvconf, dns-nm, ] steps: diff --git a/docker-compose.yml b/docker-compose.yml index b89ab2998..4808a2956 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -123,8 +123,6 @@ services: args: PACKAGE: firezone-linux-client image: us-east1-docker.pkg.dev/firezone-staging/firezone/client:${VERSION:-main} - dns: - - 100.100.111.1 cap_add: - NET_ADMIN sysctls: diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e5ed0c889..59e225b89 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ "futures-lite 2.2.0", "parking", "polling 3.3.2", - "rustix 0.38.30", + "rustix 0.38.31", "slab", "tracing", "windows-sys 0.52.0", @@ -384,7 +384,7 @@ dependencies = [ "cfg-if", "event-listener 3.1.0", "futures-lite 1.13.0", - "rustix 0.38.30", + "rustix 0.38.31", "windows-sys 0.48.0", ] @@ -411,7 +411,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 0.38.30", + "rustix 0.38.31", "signal-hook-registry", "slab", "windows-sys 0.48.0", @@ -2043,10 +2043,12 @@ dependencies = [ "anyhow", "clap", "connlib-client-shared", + "connlib-shared", "firezone-cli-utils", "humantime", "resolv-conf", "secrecy", + "thiserror", "tracing", "tracing-subscriber", ] @@ -2098,6 +2100,7 @@ dependencies = [ name = "firezone-tunnel" version = "1.0.0" dependencies = [ + "anyhow", "arc-swap", "async-trait", "bimap", @@ -2126,6 +2129,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "tempfile", "thiserror", "tokio", "tracing", @@ -3330,7 +3334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bad00257d07be169d870ab665980b06cdb366d792ad690bf2e76876dc503455" dependencies = [ "hermit-abi", - "rustix 0.38.30", + "rustix 0.38.31", "windows-sys 0.52.0", ] @@ -4842,7 +4846,7 @@ dependencies = [ "cfg-if", "concurrent-queue", "pin-project-lite", - "rustix 0.38.30", + "rustix 0.38.31", "tracing", "windows-sys 0.52.0", ] @@ -5472,9 +5476,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.30" +version = "0.38.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" +checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" dependencies = [ "bitflags 2.4.1", "errno", @@ -6711,14 +6715,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.9.0" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" +checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67" dependencies = [ "cfg-if", "fastrand 2.0.1", - "redox_syscall", - "rustix 0.38.30", + "rustix 0.38.31", "windows-sys 0.52.0", ] @@ -6762,18 +6765,18 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" [[package]] name = "thiserror" -version = "1.0.56" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.56" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", @@ -7970,7 +7973,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix 0.38.30", + "rustix 0.38.31", ] [[package]] @@ -8645,7 +8648,7 @@ checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" dependencies = [ "libc", "linux-raw-sys 0.4.12", - "rustix 0.38.30", + "rustix 0.38.31", ] [[package]] diff --git a/rust/connlib/shared/src/error.rs b/rust/connlib/shared/src/error.rs index 958174606..8237975d2 100644 --- a/rust/connlib/shared/src/error.rs +++ b/rust/connlib/shared/src/error.rs @@ -164,6 +164,15 @@ pub enum ConnlibError { ClosedByPortal, #[error(transparent)] JoinError(#[from] JoinError), + + #[error("Failed to read `resolv.conf`: {0}")] + ReadResolvConf(std::io::Error), + #[error("Failed to parse `resolv.conf`")] + ParseResolvConf, + #[error("Failed to backup `resolv.conf`: {0}")] + WriteResolvConfBackup(std::io::Error), + #[error("Failed to rewrite `resolv.conf`: {0}")] + RewriteResolvConf(std::io::Error), } impl ConnlibError { diff --git a/rust/connlib/shared/src/lib.rs b/rust/connlib/shared/src/lib.rs index 0a8a70c34..31f26dc04 100644 --- a/rust/connlib/shared/src/lib.rs +++ b/rust/connlib/shared/src/lib.rs @@ -9,7 +9,6 @@ pub mod control; pub mod error; pub mod messages; -#[cfg(target_os = "linux")] pub mod linux; #[cfg(target_os = "windows")] diff --git a/rust/connlib/shared/src/linux.rs b/rust/connlib/shared/src/linux.rs index 1aeb8d461..801ddb5a2 100644 --- a/rust/connlib/shared/src/linux.rs +++ b/rust/connlib/shared/src/linux.rs @@ -2,7 +2,10 @@ const FIREZONE_DNS_CONTROL: &str = "FIREZONE_DNS_CONTROL"; -#[derive(Debug)] +pub const ETC_RESOLV_CONF: &str = "/etc/resolv.conf"; +pub const ETC_RESOLV_CONF_BACKUP: &str = "/etc/resolv.conf.firezone-backup"; + +#[derive(Clone, Debug)] pub enum DnsControlMethod { /// Back up `/etc/resolv.conf` and replace it with our own /// diff --git a/rust/connlib/tunnel/Cargo.toml b/rust/connlib/tunnel/Cargo.toml index 8f18f98e4..ce956d737 100644 --- a/rust/connlib/tunnel/Cargo.toml +++ b/rust/connlib/tunnel/Cargo.toml @@ -39,7 +39,9 @@ webrtc = { workspace = true } log = "0.4" [dev-dependencies] +anyhow = "1.0" serde_json = "1.0" +tempfile = "3.10.0" # Linux tunnel dependencies [target.'cfg(target_os = "linux")'.dependencies] diff --git a/rust/connlib/tunnel/src/device_channel/etc_resolv_conf.rs b/rust/connlib/tunnel/src/device_channel/etc_resolv_conf.rs new file mode 100644 index 000000000..5504d5ec0 --- /dev/null +++ b/rust/connlib/tunnel/src/device_channel/etc_resolv_conf.rs @@ -0,0 +1,290 @@ +use connlib_shared::{ + linux::{ETC_RESOLV_CONF, ETC_RESOLV_CONF_BACKUP}, + Error, Result, +}; +use std::{net::IpAddr, path::Path}; +use tokio::io::AsyncWriteExt; + +/// Back up `/etc/resolve.conf` and then modify it in-place +pub async fn configure_dns(dns_config: &[IpAddr]) -> Result<()> { + configure_dns_at_paths( + dns_config, + Path::new(ETC_RESOLV_CONF), + Path::new(ETC_RESOLV_CONF_BACKUP), + ) + .await +} + +async fn configure_dns_at_paths( + dns_config: &[IpAddr], + resolv_path: &Path, + backup_path: &Path, +) -> Result<()> { + if dns_config.is_empty() { + tracing::info!("`dns_config` is empty, leaving `/etc/resolv.conf` unchanged"); + return Ok(()); + } + + let text = tokio::fs::read_to_string(resolv_path) + .await + .map_err(Error::ReadResolvConf)?; + let parsed = resolv_conf::Config::parse(&text).map_err(|_| Error::ParseResolvConf)?; + + // Back up the original resolv.conf. If there's already a backup, don't modify it + match tokio::fs::File::options() + .write(true) + .create_new(true) + .open(backup_path) + .await + { + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + tracing::info!(?backup_path, "Backup path already exists, won't overwrite"); + } + Err(error) => return Err(Error::WriteResolvConfBackup(error)), + // TODO: Would do a rename-into-place here if the contents of the file mattered more + Ok(mut f) => f.write_all(text.as_bytes()).await?, + } + + // TODO: Would do an fsync here if resolv.conf was important and not + // auto-generated by Docker on every run. + + let mut new_resolv_conf = parsed.clone(); + new_resolv_conf.nameservers = dns_config.iter().map(|addr| (*addr).into()).collect(); + + // Over-writing `/etc/resolv.conf` actually violates Docker's plan for handling DNS + // https://docs.docker.com/network/#dns-services + // But this is just a hack to get a smoke test working in CI for now. + // + // Because Docker bind-mounts resolv.conf into the container, (visible in `mount`) we can't + // use the rename trick to safely update it, nor can we delete it. The best + // we can do is rewrite it in-place. + let new_text = format!( + r" +# Generated by the Firezone client +# The original is at {} +{} +", + backup_path.display(), + new_resolv_conf, + ); + + tokio::fs::write(resolv_path, new_text) + .await + .map_err(Error::RewriteResolvConf)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::configure_dns_at_paths; + use anyhow::{ensure, Context, Result}; + use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + path::Path, + }; + + const GOOGLE_DNS: Ipv4Addr = Ipv4Addr::new(8, 8, 8, 8); + + const DEBIAN_VM_RESOLV_CONF: &str = r#" +# This is /run/systemd/resolve/stub-resolv.conf managed by man:systemd-resolved(8). +# Do not edit. +# +# This file might be symlinked as /etc/resolv.conf. If you're looking at +# /etc/resolv.conf and seeing this text, you have followed the symlink. +# +# This is a dynamic resolv.conf file for connecting local clients to the +# internal DNS stub resolver of systemd-resolved. This file lists all +# configured search domains. +# +# Run "resolvectl status" to see details about the uplink DNS servers +# currently in use. +# +# Third party programs should typically not access this file directly, but only +# through the symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a +# different way, replace this symlink by a static file or a different symlink. +# +# See man:systemd-resolved.service(8) for details about the supported modes of +# operation for /etc/resolv.conf. +nameserver 127.0.0.53 +options edns0 trust-ad +search . +"#; + + // Docker seems to have injected the WSL host's resolv.conf into the Alpine container + // Also the nameserver is changed for privacy + const ALPINE_CONTAINER_RESOLV_CONF: &str = r#" +# This file was automatically generated by WSL. To stop automatic generation of this file, add the following entry to /etc/wsl.conf: +# [network] +# generateResolvConf = false +nameserver 9.9.9.9 +"#; + + // From a Debian desktop + const NETWORK_MANAGER_RESOLV_CONF: &str = r" +# Generated by NetworkManager +nameserver 192.168.1.1 +nameserver 2001:db8::%eno1 +"; + + #[test] + fn parse_resolv_conf() { + let parsed = resolv_conf::Config::parse(DEBIAN_VM_RESOLV_CONF).unwrap(); + let mut config = resolv_conf::Config::new(); + config + .nameservers + .push(resolv_conf::ScopedIp::V4(Ipv4Addr::new(127, 0, 0, 53))); + config.set_search(vec![".".into()]); + config.edns0 = true; + config.trust_ad = true; + assert_eq!(parsed, config); + + let parsed = resolv_conf::Config::parse(ALPINE_CONTAINER_RESOLV_CONF).unwrap(); + let mut config = resolv_conf::Config::new(); + config + .nameservers + .push(resolv_conf::ScopedIp::V4(Ipv4Addr::new(9, 9, 9, 9))); + assert_eq!(parsed, config); + + let parsed = resolv_conf::Config::parse(NETWORK_MANAGER_RESOLV_CONF).unwrap(); + let mut config = resolv_conf::Config::new(); + config + .nameservers + .push(resolv_conf::ScopedIp::V4(Ipv4Addr::new(192, 168, 1, 1))); + config.nameservers.push(resolv_conf::ScopedIp::V6( + Ipv6Addr::new( + 0x2001, 0x0db8, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + ), + Some("eno1".into()), + )); + assert_eq!(parsed, config); + + assert!(resolv_conf::Config::parse("").is_ok()); + assert!(resolv_conf::Config::parse("An invalid resolv.conf file.").is_err()); + } + + #[test] + fn print_resolv_conf() { + let mut new_resolv_conf = resolv_conf::Config::new(); + for addr in [ + IpAddr::from([100, 100, 111, 1]), + IpAddr::from([100, 100, 111, 2]), + ] { + new_resolv_conf.nameservers.push(addr.into()); + } + + let actual = new_resolv_conf.to_string(); + assert_eq!( + actual, + r"nameserver 100.100.111.1 +nameserver 100.100.111.2 +" + ); + } + + /// Returns `Ok(())` if the file at the given path contains the expected sentinels + /// + /// Return `Err(_)` if the file can't be parsed, the sentinels don't match, or + /// any DNS options are set in the file. + fn check_resolv_conf(path: &Path, expected_sentinels: &[IpAddr]) -> Result<()> { + let text = std::fs::read_to_string(path).context("could not read file")?; + let parsed = resolv_conf::Config::parse(text)?; + + let mut expected = resolv_conf::Config::new(); + expected_sentinels + .iter() + .for_each(|addr| expected.nameservers.push((*addr).into())); + + ensure!( + parsed == expected, + "Parsed resolv config didn't match expected resolv config" + ); + Ok(()) + } + + // Not shared with prod code because prod also writes the "Generated by" comment, + fn write_resolv_conf(path: &Path, nameservers: &[IpAddr]) -> Result<()> { + let mut conf = resolv_conf::Config::new(); + conf.nameservers = nameservers.iter().map(|addr| (*addr).into()).collect(); + std::fs::write(path, conf.to_string())?; + Ok(()) + } + + /// The original resolv.conf should be backed up, and the new one should only + /// contain our sentinels. + #[tokio::test] + async fn resolv_conf_happy_path() -> Result<()> { + // Try not to panic, it may leave temp files behind + // Using `TempDir` instead of `tempfile` because I need the path, and instead + // of `NamedTempFile` because those get deleted immediately on Linux, which confuses + // `configure_dns_at_paths when it tries to read from the path. + let temp_dir = tempfile::TempDir::with_prefix("firezone-dns-test")?; + + let resolv_path = temp_dir.path().join("resolv.conf"); + let backup_path = temp_dir.path().join("resolv.conf.firezone-backup"); + + write_resolv_conf(&resolv_path, &[GOOGLE_DNS.into()])?; + + configure_dns_at_paths( + &[IpAddr::from([100, 100, 111, 1])], + &resolv_path, + &backup_path, + ) + .await?; + + check_resolv_conf(&resolv_path, &[IpAddr::from([100, 100, 111, 1])]) + .context("{resolv_path}")?; + check_resolv_conf(&backup_path, &[GOOGLE_DNS.into()]).context("{backup_path}")?; + + Ok(()) + } + + /// If there are no sentinels for some reason, don't change resolv.conf + #[tokio::test] + async fn resolv_conf_no_sentinels() -> Result<()> { + let temp_dir = tempfile::TempDir::with_prefix("firezone-dns-test")?; + + let resolv_path = temp_dir.path().join("resolv.conf"); + let backup_path = temp_dir.path().join("resolv.conf.firezone-backup"); + + write_resolv_conf(&resolv_path, &[GOOGLE_DNS.into()])?; + + configure_dns_at_paths(&[], &resolv_path, &backup_path).await?; + + check_resolv_conf(&resolv_path, &[GOOGLE_DNS.into()]).context("{resolv_path}")?; + ensure!(Path::try_exists(&backup_path)? == false); + + Ok(()) + } + + /// If we run twice, don't overwrite the resolv.conf backup + #[tokio::test] + async fn resolv_conf_twice() -> Result<()> { + let temp_dir = tempfile::TempDir::with_prefix("firezone-dns-test")?; + + let resolv_path = temp_dir.path().join("resolv.conf"); + let backup_path = temp_dir.path().join("resolv.conf.firezone-backup"); + + write_resolv_conf(&resolv_path, &[GOOGLE_DNS.into()])?; + + configure_dns_at_paths( + &[IpAddr::from([100, 100, 111, 1])], + &resolv_path, + &backup_path, + ) + .await?; + + configure_dns_at_paths( + &[IpAddr::from([100, 100, 111, 2])], + &resolv_path, + &backup_path, + ) + .await?; + + check_resolv_conf(&resolv_path, &[IpAddr::from([100, 100, 111, 2])]) + .context("{resolv_path}")?; + check_resolv_conf(&backup_path, &[GOOGLE_DNS.into()]).context("{backup_path}")?; + + Ok(()) + } +} diff --git a/rust/connlib/tunnel/src/device_channel/tun_linux.rs b/rust/connlib/tunnel/src/device_channel/tun_linux.rs index 31b42c57a..dfe47d56b 100644 --- a/rust/connlib/tunnel/src/device_channel/tun_linux.rs +++ b/rust/connlib/tunnel/src/device_channel/tun_linux.rs @@ -26,6 +26,7 @@ use std::{ }; use tokio::io::unix::AsyncFd; +mod etc_resolv_conf; mod utils; pub(crate) const SIOCGIFMTU: libc::c_ulong = libc::SIOCGIFMTU; @@ -100,29 +101,8 @@ impl Tun { ) -> Result { // TODO: Tech debt: // TODO: Gateways shouldn't set up DNS, right? Only clients? - let dns_control_method = if !dns_config.is_empty() { - // TODO: Move to the client - let method = connlib_shared::linux::get_dns_control_from_env(); - match method { - None => { - tracing::info!("Will not modify the system's DNS settings"); - } - Some(DnsControlMethod::EtcResolvConf) => { - // TODO: Modify `/etc/resolv.conf` - tracing::info!("Will modify `/etc/resolv.conf`"); - } - Some(DnsControlMethod::NetworkManager) => { - // TODO: Cooperate with NetworkManager - } - Some(DnsControlMethod::Systemd) => { - // TODO: Cooperate with `systemd-resolved` - tracing::info!("Will use `systemd-resolved`"); - } - } - method - } else { - None - }; + let dns_control_method = connlib_shared::linux::get_dns_control_from_env(); + tracing::info!(?dns_control_method); create_tun_device()?; @@ -270,8 +250,14 @@ async fn set_iface_config( handle.link().set(index).up().execute().await?; res_v4.or(res_v6)?; - // TODO: Hook in DNS control methods - let _ = dns_control_method; + match dns_control_method { + None => {} + Some(DnsControlMethod::EtcResolvConf) => { + etc_resolv_conf::configure_dns(&dns_config).await? + } + Some(DnsControlMethod::NetworkManager) => configure_network_manager(&dns_config).await?, + Some(DnsControlMethod::Systemd) => configure_systemd_resolved(&dns_config).await?, + } Ok(()) } @@ -350,110 +336,19 @@ impl ioctl::Request { } } +async 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<()> { + Err(Error::Other( + "DNS control with `systemd-resolved` is not implemented yet", + )) +} + #[repr(C)] struct SetTunFlagsPayload { flags: std::ffi::c_short, } - -#[cfg(test)] -mod tests { - use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - str::FromStr, - }; - - const DEBIAN_VM_RESOLV_CONF: &str = r#" -# This is /run/systemd/resolve/stub-resolv.conf managed by man:systemd-resolved(8). -# Do not edit. -# -# This file might be symlinked as /etc/resolv.conf. If you're looking at -# /etc/resolv.conf and seeing this text, you have followed the symlink. -# -# This is a dynamic resolv.conf file for connecting local clients to the -# internal DNS stub resolver of systemd-resolved. This file lists all -# configured search domains. -# -# Run "resolvectl status" to see details about the uplink DNS servers -# currently in use. -# -# Third party programs should typically not access this file directly, but only -# through the symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a -# different way, replace this symlink by a static file or a different symlink. -# -# See man:systemd-resolved.service(8) for details about the supported modes of -# operation for /etc/resolv.conf. -nameserver 127.0.0.53 -options edns0 trust-ad -search . -"#; - - // Docker seems to have injected the WSL host's resolv.conf into the Alpine container - // Also the nameserver is changed for privacy - const ALPINE_CONTAINER_RESOLV_CONF: &str = r#" -# This file was automatically generated by WSL. To stop automatic generation of this file, add the following entry to /etc/wsl.conf: -# [network] -# generateResolvConf = false -nameserver 9.9.9.9 -"#; - - // From a Debian desktop - const NETWORK_MANAGER_RESOLV_CONF: &str = r" -# Generated by NetworkManager -nameserver 192.168.1.1 -nameserver 2001:db8::%eno1 -"; - - #[test] - fn parse_resolv_conf() { - let parsed = resolv_conf::Config::parse(DEBIAN_VM_RESOLV_CONF).unwrap(); - let mut config = resolv_conf::Config::new(); - config - .nameservers - .push(resolv_conf::ScopedIp::V4(Ipv4Addr::new(127, 0, 0, 53))); - config.set_search(vec![".".into()]); - config.edns0 = true; - config.trust_ad = true; - - assert_eq!(parsed, config); - - let parsed = resolv_conf::Config::parse(ALPINE_CONTAINER_RESOLV_CONF).unwrap(); - let mut config = resolv_conf::Config::new(); - config - .nameservers - .push(resolv_conf::ScopedIp::V4(Ipv4Addr::new(9, 9, 9, 9))); - - assert_eq!(parsed, config); - - let parsed = resolv_conf::Config::parse(NETWORK_MANAGER_RESOLV_CONF).unwrap(); - let mut config = resolv_conf::Config::new(); - config - .nameservers - .push(resolv_conf::ScopedIp::V4(Ipv4Addr::new(192, 168, 1, 1))); - config.nameservers.push(resolv_conf::ScopedIp::V6( - Ipv6Addr::new( - 0x2001, 0x0db8, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - ), - Some("eno1".into()), - )); - - assert_eq!(parsed, config); - } - - #[test] - fn print_resolv_conf() { - let mut new_resolv_conf = resolv_conf::Config::new(); - for addr in ["100.100.111.1", "100.100.111.2"] { - new_resolv_conf - .nameservers - .push(IpAddr::from_str(addr).unwrap().into()); - } - - let actual = new_resolv_conf.to_string(); - assert_eq!( - actual, - r"nameserver 100.100.111.1 -nameserver 100.100.111.2 -" - ); - } -} diff --git a/rust/linux-client/Cargo.toml b/rust/linux-client/Cargo.toml index 2427aa2b7..69b3e6878 100644 --- a/rust/linux-client/Cargo.toml +++ b/rust/linux-client/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" [dependencies] secrecy = { workspace = true } connlib-client-shared = { workspace = true } +connlib-shared = { workspace = true } firezone-cli-utils = { workspace = true } anyhow = { version = "1.0" } tracing = { workspace = true } @@ -16,3 +17,4 @@ clap = { version = "4.4", features = ["derive", "env"] } tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } humantime = "2.1" resolv-conf = "0.7.0" +thiserror = "1.0.57" diff --git a/rust/linux-client/src/main.rs b/rust/linux-client/src/main.rs index 6c27c12a4..c3313ac6e 100644 --- a/rust/linux-client/src/main.rs +++ b/rust/linux-client/src/main.rs @@ -1,9 +1,12 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; use connlib_client_shared::{file_logger, Callbacks, Session}; +use connlib_shared::linux::{ + get_dns_control_from_env, DnsControlMethod, ETC_RESOLV_CONF, ETC_RESOLV_CONF_BACKUP, +}; use firezone_cli_utils::{block_on_ctrl_c, setup_global_subscriber, CommonArgs}; use secrecy::SecretString; -use std::path::PathBuf; +use std::{net::IpAddr, path::PathBuf}; fn main() -> Result<()> { let cli = Cli::parse(); @@ -12,13 +15,19 @@ fn main() -> Result<()> { let (layer, handle) = cli.log_dir.as_deref().map(file_logger::layer).unzip(); setup_global_subscriber(layer); + let dns_control_method = get_dns_control_from_env(); + let callbacks = CallbackHandler { + dns_control_method, + handle, + }; + let mut session = Session::connect( cli.common.api_url, SecretString::from(cli.common.token), cli.firezone_id, None, None, - CallbackHandler { handle }, + callbacks, max_partition_time, ) .unwrap(); @@ -32,11 +41,36 @@ fn main() -> Result<()> { #[derive(Clone)] struct CallbackHandler { + dns_control_method: Option, handle: Option, } +#[derive(Debug, thiserror::Error)] +enum CbError { + #[error(transparent)] + Any(#[from] anyhow::Error), +} + impl Callbacks for CallbackHandler { - type Error = std::convert::Infallible; + // I spent several minutes messing with `anyhow` and couldn't figure out how to make + // it implement `std::error::Error`: + type Error = CbError; + + /// May return Firezone's own servers, e.g. `100.100.111.1`. + fn get_system_default_resolvers(&self) -> Result>, Self::Error> { + match self.dns_control_method { + None => Ok(Some(get_system_default_resolvers_resolv_conf()?)), + Some(DnsControlMethod::EtcResolvConf) => { + Ok(Some(get_system_default_resolvers_resolv_conf()?)) + } + Some(DnsControlMethod::NetworkManager) => { + Ok(Some(get_system_default_resolvers_network_manager()?)) + } + Some(DnsControlMethod::Systemd) => { + Ok(Some(get_system_default_resolvers_systemd_resolved()?)) + } + } + } fn on_disconnect( &self, @@ -57,6 +91,33 @@ impl Callbacks for CallbackHandler { } } +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_BACKUP) + .or_else(|_| std::fs::read_to_string(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) +} + +fn get_system_default_resolvers_network_manager() -> Result> { + tracing::error!("get_system_default_resolvers_network_manager not implemented yet"); + Ok(vec![]) +} + +fn get_system_default_resolvers_systemd_resolved() -> Result> { + tracing::error!("get_system_default_resolvers_systemd_resolved not implemented yet"); + Ok(vec![]) +} + #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Cli { diff --git a/scripts/tests/dns-etc-resolvconf.sh b/scripts/tests/dns-etc-resolvconf.sh new file mode 100755 index 000000000..ca392a467 --- /dev/null +++ b/scripts/tests/dns-etc-resolvconf.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# The integration tests call this to test Linux DNS control, using the `/etc/resolv.conf` +# method which only works well inside Alpine Docker containers. + +set -euo pipefail + +HTTPBIN=test.httpbin.docker.local + +function client() { + docker compose exec -it client "$@" +} + +function client_nslookup() { + # Skip the first 3 lines so that grep won't see the DNS server IP + # `tee` here copies stdout to stderr + client timeout 30 sh -c "nslookup $1 | tee >(cat 1>&2) | tail -n +4" +} + +function gateway() { + docker compose exec -it gateway "$@" +} + +# Wait for client to ping httpbin (CIDR) resource through the gateway +client timeout 60 sh -c "until ping -W 1 -c 10 172.20.0.100 &>/dev/null; do true; done" + +echo "# check original resolv.conf" +client sh -c "cat /etc/resolv.conf.firezone-backup" + +echo "# Make sure gateway can reach httpbin by DNS" +gateway sh -c "curl --fail $HTTPBIN/get" + +echo "# Try to ping httpbin as a DNS resource" +client timeout 60 \ +sh -c "ping -W 1 -c 10 $HTTPBIN" + +echo "# Access httpbin by DNS" +client sh -c "curl --fail $HTTPBIN/get" + +echo "# Make sure it's going through the tunnel" +client_nslookup "$HTTPBIN" | grep "100\\.96\\.0\\." + +echo "# Make sure a non-resource doesn't go through the tunnel" +client_nslookup "github.com" | grep -v "100\\.96.\\0\\." + +echo "# Stop the gateway and make sure the resource is inaccessible" +docker compose stop gateway +client sh -c "curl --connect-timeout 15 --fail $HTTPBIN/get" && exit 1 + +exit 0