mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -190,6 +190,7 @@ jobs:
|
||||
direct-ping-portal-down,
|
||||
relayed-ping-portal-down,
|
||||
direct-ping-portal-relay-down,
|
||||
dns-etc-resolvconf,
|
||||
dns-nm,
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -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
37
rust/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,7 +9,6 @@ pub mod control;
|
||||
pub mod error;
|
||||
pub mod messages;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -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
|
||||
///
|
||||
|
||||
@@ -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]
|
||||
|
||||
290
rust/connlib/tunnel/src/device_channel/etc_resolv_conf.rs
Normal file
290
rust/connlib/tunnel/src/device_channel/etc_resolv_conf.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
50
scripts/tests/dns-etc-resolvconf.sh
Executable file
50
scripts/tests/dns-etc-resolvconf.sh
Executable 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
|
||||
Reference in New Issue
Block a user