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 <ReactorScram@users.noreply.github.com>
This commit is contained in:
Reactor Scram
2024-02-14 17:50:01 -06:00
committed by GitHub
parent 29ef4d7769
commit 00f6fcdd09
12 changed files with 466 additions and 153 deletions

View File

@@ -190,6 +190,7 @@ jobs:
direct-ping-portal-down,
relayed-ping-portal-down,
direct-ping-portal-relay-down,
dns-etc-resolvconf,
dns-nm,
]
steps:

View File

@@ -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:

37
rust/Cargo.lock generated
View File

@@ -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]]

View File

@@ -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 {

View File

@@ -9,7 +9,6 @@ pub mod control;
pub mod error;
pub mod messages;
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "windows")]

View File

@@ -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
///

View File

@@ -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]

View File

@@ -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(())
}
}

View File

@@ -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<Self> {
// TODO: Tech debt: <https://github.com/firezone/firezone/issues/3636>
// 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<SetTunFlagsPayload> {
}
}
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
"
);
}
}

View File

@@ -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"

View File

@@ -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<DnsControlMethod>,
handle: Option<file_logger::Handle>,
}
#[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`: <https://github.com/dtolnay/anyhow/issues/25>
type Error = CbError;
/// May return Firezone's own servers, e.g. `100.100.111.1`.
fn get_system_default_resolvers(&self) -> Result<Option<Vec<IpAddr>>, 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<Vec<IpAddr>> {
// 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<Vec<IpAddr>> {
tracing::error!("get_system_default_resolvers_network_manager not implemented yet");
Ok(vec![])
}
fn get_system_default_resolvers_systemd_resolved() -> Result<Vec<IpAddr>> {
tracing::error!("get_system_default_resolvers_systemd_resolved not implemented yet");
Ok(vec![])
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {

View File

@@ -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