feat(headless-client): use systemd-resolved DNS control by default (#6163)

Closes #5063, supersedes #5850 

Other refactors and changes made as part of this:

- Adds the ability to disable DNS control on Windows
- Removes the spooky-action-at-a-distance `from_env` functions that used
to be buried in `tunnel`
- `FIREZONE_DNS_CONTROL` is now a regular `clap` argument again

---------

Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
This commit is contained in:
Reactor Scram
2024-08-06 13:16:51 -05:00
committed by GitHub
parent 30622da24f
commit 5eb2bba47b
24 changed files with 281 additions and 204 deletions

1
rust/Cargo.lock generated
View File

@@ -1782,6 +1782,7 @@ name = "firezone-bin-shared"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"connlib-shared",
"futures",
"ip-packet",

View File

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

View File

@@ -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<Method>` 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<DnsControlMethod> {
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
}
}

View File

@@ -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<DnsControlMethod>,
method: DnsControlMethod,
) -> Result<Worker> {
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<DnsControlMethod>,
method: DnsControlMethod,
) -> Result<Worker> {
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",

View File

@@ -82,21 +82,15 @@ use windows::{
#[allow(clippy::unused_async)]
pub async fn new_dns_notifier(
_tokio_handle: tokio::runtime::Handle,
method: Option<DnsControlMethod>,
_method: DnsControlMethod,
) -> Result<async_dns::DnsNotifier> {
match method {
Some(DnsControlMethod::Nrpt) | None => {}
}
async_dns::DnsNotifier::new()
}
pub async fn new_network_notifier(
_tokio_handle: tokio::runtime::Handle,
method: Option<DnsControlMethod>,
_method: DnsControlMethod,
) -> Result<NetworkNotifier> {
match method {
Some(DnsControlMethod::Nrpt) | None => {}
}
NetworkNotifier::new().await
}

View File

@@ -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<Method>` 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<DnsControlMethod> {
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.

View File

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

View File

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

View File

@@ -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<IpAddr> {
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<Vec<IpAddr>> {
system_resolvers(DnsControlMethod::default())
}

View File

@@ -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<Vec<IpAddr>> {
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<DnsControlMethod>,
}
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<IpAddr>) -> 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<Vec<IpAddr>> {
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<Vec<IpAddr>> {
match dns_control_method {
DnsControlMethod::Disabled | DnsControlMethod::EtcResolvConf => {
get_system_default_resolvers_resolv_conf()
}
DnsControlMethod::SystemdResolved => get_system_default_resolvers_systemd_resolved(),
}
}

View File

@@ -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()])?;

View File

@@ -13,34 +13,15 @@
//!
//! <https://superuser.com/a/1752670>
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<Vec<IpAddr>> {
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<IpAddr>) -> 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<Vec<IpAddr>> {
pub(crate) fn system_resolvers(_method: DnsControlMethod) -> Result<Vec<IpAddr>> {
let resolvers = ipconfig::get_adapters()?
.iter()
.flat_map(|adapter| adapter.dns_servers())

View File

@@ -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. <https://github.com/firezone/firezone/issues/4899>
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));
}
}

View File

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

View File

@@ -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(()), _)) => {

View File

@@ -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<PathBuf>,
@@ -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::<InternalServerMsg>(), 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());
}
}

View File

@@ -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. <https://github.com/firezone/firezone/issues/4899>
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
// <https://github.com/firezone/firezone/pull/6026#discussion_r1692297438>
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")));
}

View File

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

View File

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

View File

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

View File

@@ -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=<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` | `<system hostname>` | 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` | `<system hostname>` | 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

View File

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

View File

@@ -15,6 +15,12 @@ export default function GUI({ title }: { title: string }) {
{/*
<Entry version="1.1.10" date={new Date("Invalid date")}>
<ul className="list-disc space-y-2 pl-4 mb-4">
<ChangeItem enable={title === "Linux GUI"} pull="6163">
Supports using `etc-resolv-conf` DNS control method, or disabling DNS control
</ChangeItem>
<ChangeItem enable={title === "Windows"} pull="6163">
Supports disabling DNS control
</ChangeItem>
<ChangeItem pull="6184">
Mitigates a bug where the IPC service can panic if an internal channel fills up
</ChangeItem>

View File

@@ -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() {
{/*
<Entry version="1.1.5" date={new Date("Invalid date")}>
<ul className="list-disc space-y-2 pl-4 mb-4">
<ChangeItem pull="6163">
Uses `systemd-resolved` DNS control by default on Linux
</ChangeItem>
<ChangeItem pull="6184">
Mitigates a bug where the Client can panic if an internal channel fills up
</ChangeItem>