refactor(firezone-tunnel): move routes and DNS control out of connlib and up to the Client (#5111)

Refs #3636 (This pays down some of the technical debt from Linux DNS)
Refs #4473 (This partially fulfills it)
Refs #5068 (This is needed to make `FIREZONE_DNS_CONTROL` mandatory)

As of dd6421:

- On both Linux and Windows, DNS control and IP setting (i.e.
`on_set_interface_config`) both move to the Client
- On Windows, route setting stays in `tun_windows.rs`. Route setting in
Windows requires us to know the interface index, which we don't know in
the Client code. If we could pass opaque platform-specific data between
the tunnel and the Client it would be easy.
- On Linux, route setting moves to the Client and Gateway, which
completely removes the `worker` task in `tun_linux.rs`
- Notifying systemd that we're ready moves up to the headless Client /
IPC service

```[tasklist]
### Before merging / notes
- [x] Does DNS roaming work on Linux on `main`? I don't see where it hooks up. I think I only set up DNS in `Tun::new` (Yes, the `Tun` gets recreated every time we reconfigure the device)
- [x] Fix Windows Clients
- [x] Fix Gateway
- [x] Make sure connlib doesn't get the DNS control method from the env var (will be fixed in #5068)
- [x] De-dupe consts
- [ ] ~~Add DNS control test~~ (failed)
- [ ] Smoke test Linux
- [ ] Smoke test Windows
```
This commit is contained in:
Reactor Scram
2024-06-03 09:32:08 -05:00
committed by GitHub
parent 94cb494e0a
commit deefabd8f8
34 changed files with 924 additions and 873 deletions

13
rust/Cargo.lock generated
View File

@@ -1173,7 +1173,6 @@ name = "connlib-shared"
version = "1.0.6"
dependencies = [
"anyhow",
"atomicwrites",
"base64 0.22.1",
"boringtun",
"chrono",
@@ -1186,20 +1185,19 @@ dependencies = [
"known-folders",
"libc",
"log",
"mutants",
"netlink-packet-core",
"netlink-packet-route",
"os_info",
"phoenix-channel",
"proptest",
"rand 0.8.5",
"rand_core 0.6.4",
"resolv-conf",
"ring",
"rtnetlink",
"secrecy",
"serde",
"serde_json",
"swift-bridge",
"tempfile",
"thiserror",
"tokio",
"tracing",
@@ -1985,6 +1983,7 @@ name = "firezone-headless-client"
version = "1.0.6"
dependencies = [
"anyhow",
"atomicwrites",
"clap",
"connlib-client-shared",
"connlib-shared",
@@ -1993,21 +1992,26 @@ dependencies = [
"futures",
"git-version",
"humantime",
"ip_network",
"ipconfig",
"known-folders",
"mutants",
"nix 0.28.0",
"resolv-conf",
"ring",
"rtnetlink",
"sd-notify",
"secrecy",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"url",
"uuid",
"windows 0.56.0",
"windows-service",
]
@@ -2087,7 +2091,6 @@ dependencies = [
"rand_core 0.6.4",
"rangemap",
"rtnetlink",
"sd-notify",
"secrecy",
"serde",
"serde_json",

View File

@@ -21,7 +21,6 @@ ip_network = { version = "0.4", default-features = false, features = ["serde"] }
os_info = { version = "3", default-features = false }
rand = { version = "0.8", default-features = false, features = ["std"] }
rand_core = { version = "0.6.4", default-features = false, features = ["std"] }
resolv-conf = "0.7.0"
serde = { version = "1.0", default-features = false, features = ["derive", "std"] }
serde_json = { version = "1.0", default-features = false, features = ["std"] }
thiserror = { version = "1.0", default-features = false }
@@ -41,9 +40,7 @@ hickory-proto = { workspace = true, optional = true }
log = "0.4"
[dev-dependencies]
tempfile = "3.10.1"
itertools = "0.12"
mutants = "0.0.3" # Needed to mark functions as exempt from `cargo-mutants` testing
tokio = { version = "1.36", features = ["macros", "rt"] }
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
@@ -52,11 +49,9 @@ swift-bridge = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
tracing-android = "0.2"
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
# Needed to safely backup `/etc/resolv.conf` and write the device ID on behalf of `gui-client`
atomicwrites = "0.4.3"
[target.'cfg(target_os = "linux")'.dependencies]
netlink-packet-route = { version = "0.19", default-features = false }
netlink-packet-core = { version = "0.7", default-features = false }
rtnetlink = { workspace = true }
# Windows tunnel dependencies

View File

@@ -42,6 +42,22 @@ impl From<Ipv6Network> for Cidrv6 {
}
}
impl From<Cidrv4> for IpNetwork {
fn from(x: Cidrv4) -> Self {
Ipv4Network::new(x.address, x.prefix)
.expect("A Cidrv4 should always translate to a valid Ipv4Network")
.into()
}
}
impl From<Cidrv6> for IpNetwork {
fn from(x: Cidrv6) -> Self {
Ipv6Network::new(x.address, x.prefix)
.expect("A Cidrv6 should always translate to a valid Ipv6Network")
.into()
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Status {
Unknown,

View File

@@ -6,15 +6,7 @@
pub mod callbacks;
pub mod error;
pub mod messages;
/// Module to generate and store a persistent device ID on disk
///
/// Only properly implemented on Linux and Windows (platforms with Tauri and headless client)
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod device_id;
#[cfg(target_os = "linux")]
pub mod linux;
pub mod tun_device_manager;
#[cfg(target_os = "windows")]
pub mod windows;
@@ -48,22 +40,11 @@ pub type DomainName = domain::base::Name<Vec<u8>>;
/// <https://learn.microsoft.com/en-us/windows/configuration/find-the-application-user-model-id-of-an-installed-app>
pub const BUNDLE_ID: &str = "dev.firezone.client";
pub const DEFAULT_MTU: u32 = 1280;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const LIB_NAME: &str = "connlib";
/// Deactivates DNS control on Windows
#[cfg(target_os = "windows")]
pub fn deactivate_dns_control() -> anyhow::Result<()> {
windows::dns::deactivate()
}
/// Deactivates DNS control on other platforms (does nothing)
#[cfg(not(target_os = "windows"))]
#[allow(clippy::unnecessary_wraps)]
pub fn deactivate_dns_control() -> anyhow::Result<()> {
Ok(())
}
pub fn keypair() -> (StaticSecret, PublicKey) {
let private_key = StaticSecret::random_from_rng(OsRng);
let public_key = PublicKey::from(&private_key);

View File

@@ -1,30 +0,0 @@
//! Linux-specific things like DNS control methods
const FIREZONE_DNS_CONTROL: &str = "FIREZONE_DNS_CONTROL";
pub mod etc_resolv_conf;
#[derive(Clone, Debug)]
pub enum DnsControlMethod {
/// Back up `/etc/resolv.conf` and replace it with our own
///
/// Only suitable for the Alpine CI containers and maybe something like an
/// embedded system
EtcResolvConf,
/// Cooperate with NetworkManager (TODO)
NetworkManager,
/// Cooperate with `systemd-resolved`
///
/// Suitable for most Ubuntu systems, probably
Systemd,
}
/// Reads FIREZONE_DNS_CONTROL. Returns None if invalid or not set
pub fn get_dns_control_from_env() -> Option<DnsControlMethod> {
match std::env::var(FIREZONE_DNS_CONTROL).as_deref() {
Ok("etc-resolv-conf") => Some(DnsControlMethod::EtcResolvConf),
Ok("network-manager") => Some(DnsControlMethod::NetworkManager),
Ok("systemd-resolved") => Some(DnsControlMethod::Systemd),
_ => None,
}
}

View File

@@ -0,0 +1,14 @@
//! DNS and route control for the virtual network interface in `firezone-tunnel`
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "linux")]
pub use linux as platform;
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(target_os = "windows")]
pub use windows as platform;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub use platform::TunDeviceManager;

View File

@@ -0,0 +1,230 @@
//! Virtual network interface
use crate::{
callbacks::{Cidrv4, Cidrv6},
DEFAULT_MTU,
};
use anyhow::{anyhow, Context as _, Result};
use futures::TryStreamExt;
use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
use netlink_packet_route::route::{RouteProtocol, RouteScope};
use netlink_packet_route::rule::RuleAction;
use rtnetlink::{new_connection, Error::NetlinkError, Handle, RouteAddRequest, RuleAddRequest};
use std::{
collections::HashSet,
net::{Ipv4Addr, Ipv6Addr},
};
pub const FIREZONE_MARK: u32 = 0xfd002021;
pub const IFACE_NAME: &str = "tun-firezone";
const FILE_ALREADY_EXISTS: i32 = -17;
const FIREZONE_TABLE: u32 = 0x2021_fd00;
/// For lack of a better name
pub struct TunDeviceManager {
connection: Connection,
routes: HashSet<IpNetwork>,
}
struct Connection {
handle: Handle,
task: tokio::task::JoinHandle<()>,
}
impl Drop for TunDeviceManager {
fn drop(&mut self) {
self.connection.task.abort();
}
}
impl TunDeviceManager {
/// Creates a new managed tunnel device.
///
/// Panics if called without a Tokio runtime.
pub fn new() -> Result<Self> {
let (cxn, handle, _) = new_connection()?;
let task = tokio::spawn(cxn);
let connection = Connection { handle, task };
Ok(Self {
connection,
routes: Default::default(),
})
}
#[tracing::instrument(level = "trace", skip(self))]
pub async fn set_ips(&mut self, ipv4: Ipv4Addr, ipv6: Ipv6Addr) -> Result<()> {
let handle = &self.connection.handle;
let index = handle
.link()
.get()
.match_name(IFACE_NAME.to_string())
.execute()
.try_next()
.await?
.ok_or_else(|| anyhow!("No interface"))?
.header
.index;
let ips = handle
.address()
.get()
.set_link_index_filter(index)
.execute();
ips.try_for_each(|ip| handle.address().del(ip).execute())
.await?;
handle.link().set(index).mtu(DEFAULT_MTU).execute().await?;
let res_v4 = handle.address().add(index, ipv4.into(), 32).execute().await;
let res_v6 = handle
.address()
.add(index, ipv6.into(), 128)
.execute()
.await;
handle.link().set(index).up().execute().await?;
if res_v4.is_ok() {
if let Err(e) = make_rule(handle).v4().execute().await {
if !matches!(&e, NetlinkError(err) if err.raw_code() == FILE_ALREADY_EXISTS) {
tracing::warn!(
"Couldn't add ip rule for ipv4: {e:?}, ipv4 packets won't be routed"
);
}
// TODO: Be smarter about this
} else {
tracing::debug!("Successfully created ip rule for ipv4");
}
}
if res_v6.is_ok() {
if let Err(e) = make_rule(handle).v6().execute().await {
if !matches!(&e, NetlinkError(err) if err.raw_code() == FILE_ALREADY_EXISTS) {
tracing::warn!(
"Couldn't add ip rule for ipv6: {e:?}, ipv6 packets won't be routed"
);
}
// TODO: Be smarter about this
} else {
tracing::debug!("Successfully created ip rule for ipv6");
}
}
res_v4.or(res_v6)?;
Ok(())
}
#[tracing::instrument(level = "trace", skip(self))]
pub async fn set_routes(&mut self, ipv4: Vec<Cidrv4>, ipv6: Vec<Cidrv6>) -> Result<()> {
let new_routes: HashSet<IpNetwork> = ipv4
.into_iter()
.map(IpNetwork::from)
.chain(ipv6.into_iter().map(IpNetwork::from))
.collect();
if new_routes == self.routes {
return Ok(());
}
tracing::info!(?new_routes, "set_routes");
let handle = &self.connection.handle;
let index = handle
.link()
.get()
.match_name(IFACE_NAME.to_string())
.execute()
.try_next()
.await?
.context("No interface")?
.header
.index;
for route in new_routes.difference(&self.routes) {
add_route(route, index, handle).await?;
}
for route in self.routes.difference(&new_routes) {
delete_route(route, index, handle).await?;
}
self.routes = new_routes;
Ok(())
}
}
fn make_rule(handle: &Handle) -> RuleAddRequest {
let mut rule = handle
.rule()
.add()
.fw_mark(FIREZONE_MARK)
.table_id(FIREZONE_TABLE)
.action(RuleAction::ToTable);
rule.message_mut()
.header
.flags
.push(netlink_packet_route::rule::RuleFlag::Invert);
rule.message_mut()
.attributes
.push(netlink_packet_route::rule::RuleAttribute::Protocol(
RouteProtocol::Kernel,
));
rule
}
fn make_route(idx: u32, handle: &Handle) -> RouteAddRequest {
handle
.route()
.add()
.output_interface(idx)
.protocol(RouteProtocol::Static)
.scope(RouteScope::Universe)
.table_id(FIREZONE_TABLE)
}
fn make_route_v4(idx: u32, handle: &Handle, route: Ipv4Network) -> RouteAddRequest<Ipv4Addr> {
make_route(idx, handle)
.v4()
.destination_prefix(route.network_address(), route.netmask())
}
fn make_route_v6(idx: u32, handle: &Handle, route: Ipv6Network) -> RouteAddRequest<Ipv6Addr> {
make_route(idx, handle)
.v6()
.destination_prefix(route.network_address(), route.netmask())
}
async fn add_route(route: &IpNetwork, idx: u32, handle: &Handle) -> Result<()> {
let res = match route {
IpNetwork::V4(ipnet) => make_route_v4(idx, handle, *ipnet).execute().await,
IpNetwork::V6(ipnet) => make_route_v6(idx, handle, *ipnet).execute().await,
};
match res {
Ok(_) => {}
Err(NetlinkError(err)) if err.raw_code() == FILE_ALREADY_EXISTS => {}
// TODO: we should be able to surface this error and handle it depending on
// if any of the added routes succeeded.
Err(err) => Err(err).context("Failed to add route")?,
}
Ok(())
}
async fn delete_route(route: &IpNetwork, idx: u32, handle: &Handle) -> Result<()> {
let message = match route {
IpNetwork::V4(ipnet) => make_route_v4(idx, handle, *ipnet).message_mut().clone(),
IpNetwork::V6(ipnet) => make_route_v6(idx, handle, *ipnet).message_mut().clone(),
};
handle
.route()
.del(message)
.execute()
.await
.context("Failed to delete route")?;
Ok(())
}

View File

@@ -0,0 +1,63 @@
use crate::{
windows::{CREATE_NO_WINDOW, TUNNEL_NAME},
Cidrv4, Cidrv6,
};
use anyhow::Result;
use std::{
net::{Ipv4Addr, Ipv6Addr},
os::windows::process::CommandExt,
process::{Command, Stdio},
};
pub struct TunDeviceManager {}
impl TunDeviceManager {
// Fallible on Linux
#[allow(clippy::unnecessary_wraps)]
pub fn new() -> Result<Self> {
Ok(Self {})
}
#[tracing::instrument(level = "trace", skip(self))]
pub async fn set_ips(&mut self, ipv4: Ipv4Addr, ipv6: Ipv6Addr) -> Result<()> {
tracing::debug!("Setting our IPv4 = {}", ipv4);
tracing::debug!("Setting our IPv6 = {}", ipv6);
// TODO: See if there's a good Win32 API for this
// Using netsh directly instead of wintun's `set_network_addresses_tuple` because their code doesn't work for IPv6
Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.arg("interface")
.arg("ipv4")
.arg("set")
.arg("address")
.arg(format!("name=\"{TUNNEL_NAME}\""))
.arg("source=static")
.arg(format!("address={}", ipv4))
.arg("mask=255.255.255.255")
.stdout(Stdio::null())
.status()?;
Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.arg("interface")
.arg("ipv6")
.arg("set")
.arg("address")
.arg(format!("interface=\"{TUNNEL_NAME}\""))
.arg(format!("address={}", ipv6))
.stdout(Stdio::null())
.status()?;
Ok(())
}
#[tracing::instrument(level = "trace", skip(self))]
pub async fn set_routes(&mut self, _: Vec<Cidrv4>, _: Vec<Cidrv6>) -> Result<()> {
// TODO: Windows still does route updates in `tun_windows.rs`. I can move it up
// here, but since the Client and Gateway don't know the index of the WinTun
// interface, I'd have to use the Windows API
// <https://microsoft.github.io/windows-docs-rs/doc/windows/Win32/NetworkManagement/IpHelper/fn.GetAdaptersAddresses.html>
unimplemented!()
}
}

View File

@@ -4,6 +4,15 @@ use crate::Error;
use known_folders::{get_known_folder_path, KnownFolder};
use std::path::PathBuf;
/// Hides Powershell's console on Windows
///
/// <https://stackoverflow.com/questions/59692146/is-it-possible-to-use-the-standard-library-to-spawn-a-process-without-showing-th#60958956>
/// Also used for self-elevation
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
// wintun automatically append " Tunnel" to this
pub const TUNNEL_NAME: &str = "Firezone";
/// Returns e.g. `C:/Users/User/AppData/Local/dev.firezone.client
///
/// This is where we can save config, logs, crash dumps, etc.
@@ -16,113 +25,6 @@ pub fn app_local_data_dir() -> Result<PathBuf, Error> {
Ok(path)
}
pub mod dns {
//! Gives Firezone DNS privilege over other DNS resolvers on the system
//!
//! This uses NRPT and claims all domains, similar to the `systemd-resolved` control method
//! on Linux.
//! This allows us to "shadow" DNS resolvers that are configured by the user or DHCP on
//! physical interfaces, as long as they don't have any NRPT rules that outrank us.
//!
//! If Firezone crashes, restarting Firezone and closing it gracefully will resume
//! normal DNS operation. The Powershell command to remove the NRPT rule can also be run
//! by hand.
//!
//! The system default resolvers don't need to be reverted because they're never deleted.
//!
//! <https://superuser.com/a/1752670>
use anyhow::Result;
use std::{net::IpAddr, os::windows::process::CommandExt, process::Command};
/// Hides Powershell's console on Windows
///
/// <https://stackoverflow.com/questions/59692146/is-it-possible-to-use-the-standard-library-to-spawn-a-process-without-showing-th#60958956>
/// Also used for self-elevation
const CREATE_NO_WINDOW: u32 = 0x08000000;
// 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";
/// Tells Windows to send all DNS queries to our sentinels
///
/// Parameters:
/// - `dns_config_string`: Comma-separated IP addresses of DNS servers, e.g. "1.1.1.1,8.8.8.8"
pub fn activate(dns_config: &[IpAddr], iface_idx: u32) -> Result<()> {
let dns_config_string = dns_config
.iter()
.map(|ip| format!("\"{ip}\""))
.collect::<Vec<_>>()
.join(",");
// Set our DNS IP as the DNS server for our interface
// TODO: Known issue where web browsers will keep a connection open to a site,
// using QUIC, HTTP/2, or even HTTP/1.1, and so they won't resolve the DNS
// again unless you let that connection time out:
// <https://github.com/firezone/firezone/issues/3113#issuecomment-1882096111>
// TODO: If we have a Windows gateway, it shouldn't configure DNS, right?
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.arg("-Command")
.arg(format!("Set-DnsClientServerAddress -InterfaceIndex {iface_idx} -ServerAddresses({dns_config_string})"))
.status()?;
tracing::info!("Activating DNS control");
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args([
"-Command",
"Add-DnsClientNrptRule",
"-Namespace",
".",
"-Comment",
FZ_MAGIC,
"-NameServers",
&dns_config_string,
])
.status()?;
Ok(())
}
/// Tells Windows to send all DNS queries to this new set of sentinels
///
/// Currently implemented as just removing the rule and re-adding it, which
/// creates a gap but doesn't require us to parse Powershell output to figure
/// out the rule's UUID.
///
/// Parameters:
/// - `dns_config_string` - Passed verbatim to [`activate`]
pub fn change(dns_config: &[IpAddr], iface_idx: u32) -> Result<()> {
deactivate()?;
activate(dns_config, iface_idx)?;
Ok(())
}
pub fn deactivate() -> Result<()> {
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args(["-Command", "Get-DnsClientNrptRule", "|"])
.args(["where", "Comment", "-eq", FZ_MAGIC, "|"])
.args(["foreach", "{"])
.args(["Remove-DnsClientNrptRule", "-Name", "$_.Name", "-Force"])
.args(["}"])
.status()?;
tracing::info!("Deactivated DNS control");
Ok(())
}
/// Flush Windows' system-wide DNS cache
pub fn flush() -> Result<()> {
tracing::info!("Flushing Windows DNS cache");
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args(["-Command", "Clear-DnsClientCache"])
.status()?;
Ok(())
}
}
/// Returns the absolute path for installing and loading `wintun.dll`
///
/// e.g. `C:\Users\User\AppData\Local\dev.firezone.client\data\wintun.dll`

View File

@@ -55,7 +55,6 @@ proptest = ["dep:proptest", "connlib-shared/proptest"]
netlink-packet-route = { version = "0.19", default-features = false }
netlink-packet-core = { version = "0.7", default-features = false }
rtnetlink = { workspace = true }
sd-notify = "0.4.1"
# Android tunnel dependencies
[target.'cfg(target_os = "android")'.dependencies]

View File

@@ -1,6 +1,3 @@
#![allow(clippy::module_inception)]
#![cfg_attr(target_family = "windows", allow(dead_code))] // TODO: Remove when windows is fully implemented.
#[cfg(any(target_os = "macos", target_os = "ios"))]
mod tun_darwin;
#[cfg(any(target_os = "macos", target_os = "ios"))]
@@ -87,9 +84,9 @@ impl Device {
dns_config: Vec<IpAddr>,
callbacks: &impl Callbacks,
) -> Result<(), ConnlibError> {
// On Android / Linux we recreate the tunnel every time we re-configure it
self.tun = Some(Tun::new(config, dns_config.clone(), callbacks)?);
// The actual values are ignored, this is just used as a `TunnelReady` signal
callbacks.on_set_interface_config(config.ipv4, config.ipv6, dns_config);
if let Some(waker) = self.waker.take() {
@@ -99,7 +96,7 @@ impl Device {
Ok(())
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "windows"))]
pub(crate) fn set_config(
&mut self,
config: &Interface,
@@ -107,7 +104,7 @@ impl Device {
callbacks: &impl Callbacks,
) -> Result<(), ConnlibError> {
// For macos the filedescriptor is the same throughout its lifetime.
// If we reinitialzie tun, we might drop the old tun after the new one is created
// If we reinitialize tun, we might drop the old tun after the new one is created
// this unregisters the file descriptor with the reactor so we never wake up
// in case an event is triggered.
if self.tun.is_none() {
@@ -123,28 +120,6 @@ impl Device {
Ok(())
}
#[cfg(target_family = "windows")]
pub(crate) fn set_config(
&mut self,
config: &Interface,
dns_config: Vec<IpAddr>,
callbacks: &impl Callbacks,
) -> Result<(), ConnlibError> {
if self.tun.is_none() {
self.tun = Some(Tun::new()?);
}
self.tun.as_ref().unwrap().set_config(config, &dns_config)?;
callbacks.on_set_interface_config(config.ipv4, config.ipv6, dns_config);
if let Some(waker) = self.waker.take() {
waker.wake();
}
Ok(())
}
#[cfg(target_family = "unix")]
pub(crate) fn poll_read<'b>(
&mut self,

View File

@@ -1,30 +1,21 @@
use super::utils;
use crate::device_channel::ioctl;
use crate::FIREZONE_MARK;
use connlib_shared::{
linux::{etc_resolv_conf, DnsControlMethod},
messages::Interface as InterfaceConfig,
Callbacks, Error, Result,
messages::Interface as InterfaceConfig, tun_device_manager::platform::IFACE_NAME, Callbacks,
Error, Result,
};
use futures::TryStreamExt;
use futures_util::future::BoxFuture;
use futures_util::FutureExt;
use ip_network::{IpNetwork, Ipv4Network, Ipv6Network};
use ip_network::IpNetwork;
use libc::{
close, fcntl, makedev, mknod, open, F_GETFL, F_SETFL, IFF_NO_PI, IFF_TUN, O_NONBLOCK, O_RDWR,
S_IFCHR,
};
use netlink_packet_route::route::{RouteProtocol, RouteScope};
use netlink_packet_route::rule::RuleAction;
use rtnetlink::{new_connection, Error::NetlinkError, Handle};
use rtnetlink::{RouteAddRequest, RuleAddRequest};
use std::collections::HashSet;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::net::IpAddr;
use std::path::Path;
use std::task::{Context, Poll};
use std::{
ffi::CStr,
fmt, fs, io,
fs, io,
os::{
fd::{AsRawFd, RawFd},
unix::fs::PermissionsExt,
@@ -32,46 +23,21 @@ use std::{
};
use tokio::io::unix::AsyncFd;
const IFACE_NAME: &str = "tun-firezone";
const TUNSETIFF: libc::c_ulong = 0x4004_54ca;
const TUN_DEV_MAJOR: u32 = 10;
const TUN_DEV_MINOR: u32 = 200;
const DEFAULT_MTU: u32 = 1280;
const FILE_ALREADY_EXISTS: i32 = -17;
const FIREZONE_TABLE: u32 = 0x2021_fd00;
// Safety: We know that this is a valid C string.
const TUN_FILE: &CStr = unsafe { CStr::from_bytes_with_nul_unchecked(b"/dev/net/tun\0") };
#[derive(Debug)]
pub struct Tun {
handle: Handle,
connection: tokio::task::JoinHandle<()>,
dns_control_method: Option<DnsControlMethod>,
fd: AsyncFd<RawFd>,
worker: Option<BoxFuture<'static, Result<()>>>,
routes: HashSet<IpNetwork>,
}
impl fmt::Debug for Tun {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Tun")
.field("handle", &self.handle)
.field("connection", &self.connection)
.field("fd", &self.fd)
.finish_non_exhaustive()
}
}
impl Drop for Tun {
fn drop(&mut self) {
unsafe { close(self.fd.as_raw_fd()) };
self.connection.abort();
tracing::debug!("Reverting DNS control...");
if let Some(DnsControlMethod::EtcResolvConf) = self.dns_control_method {
// TODO: Check that nobody else modified the file while we were running.
etc_resolv_conf::revert().ok();
}
}
}
@@ -85,35 +51,16 @@ impl Tun {
}
pub fn poll_read(&mut self, buf: &mut [u8], cx: &mut Context<'_>) -> Poll<io::Result<usize>> {
if let Some(worker) = self.worker.as_mut() {
match worker.poll_unpin(cx) {
Poll::Ready(Ok(())) => {
self.worker = None;
}
Poll::Ready(Err(e)) => {
self.worker = None;
return Poll::Ready(Err(io::Error::new(io::ErrorKind::Other, e)));
}
Poll::Pending => return Poll::Pending,
}
}
utils::poll_raw_fd(&self.fd, |fd| read(fd, buf), cx)
}
pub fn new(
config: &InterfaceConfig,
dns_config: Vec<IpAddr>,
_: &impl Callbacks,
callbacks: &impl Callbacks,
) -> Result<Self> {
tracing::debug!(?dns_config);
// TODO: Tech debt: <https://github.com/firezone/firezone/issues/3636>
// TODO: Gateways shouldn't set up DNS, right? Only clients?
// TODO: Move this configuration up to the client
let dns_control_method = connlib_shared::linux::get_dns_control_from_env();
tracing::info!(?dns_control_method);
create_tun_device()?;
let fd = match unsafe { open(TUN_FILE.as_ptr() as _, O_RDWR) } {
@@ -132,68 +79,23 @@ impl Tun {
set_non_blocking(fd)?;
let (connection, handle, _) = new_connection()?;
let join_handle = tokio::spawn(connection);
callbacks.on_set_interface_config(config.ipv4, config.ipv6, dns_config);
Ok(Self {
handle: handle.clone(),
connection: join_handle,
dns_control_method: dns_control_method.clone(),
fd: AsyncFd::new(fd)?,
worker: Some(
set_iface_config(config.clone(), dns_config, handle, dns_control_method).boxed(),
),
routes: HashSet::new(),
})
}
#[allow(clippy::unnecessary_wraps)] // fn signature needs to align with other platforms.
pub fn set_routes(&mut self, new_routes: HashSet<IpNetwork>, _: &impl Callbacks) -> Result<()> {
if new_routes == self.routes {
return Ok(());
}
let handle = self.handle.clone();
let current_routes = self.routes.clone();
self.routes.clone_from(&new_routes);
let set_routes_worker = async move {
let index = handle
.link()
.get()
.match_name(IFACE_NAME.to_string())
.execute()
.try_next()
.await?
.ok_or(Error::NoIface)?
.header
.index;
for route in new_routes.difference(&current_routes) {
add_route(route, index, &handle).await;
}
for route in current_routes.difference(&new_routes) {
delete_route(route, index, &handle).await;
}
Ok(())
};
match self.worker.take() {
None => self.worker = Some(set_routes_worker.boxed()),
Some(current_worker) => {
self.worker = Some(
async move {
current_worker.await?;
set_routes_worker.await?;
Ok(())
}
.boxed(),
)
}
}
pub fn set_routes(
&mut self,
new_routes: HashSet<IpNetwork>,
callbacks: &impl Callbacks,
) -> Result<()> {
callbacks.on_update_routes(
new_routes.iter().copied().filter_map(super::ipv4).collect(),
new_routes.iter().copied().filter_map(super::ipv6).collect(),
);
Ok(())
}
@@ -203,147 +105,6 @@ impl Tun {
}
}
#[tracing::instrument(level = "trace", skip(handle))]
async fn set_iface_config(
config: InterfaceConfig,
dns_config: Vec<IpAddr>,
handle: Handle,
dns_control_method: Option<DnsControlMethod>,
) -> Result<()> {
let index = handle
.link()
.get()
.match_name(IFACE_NAME.to_string())
.execute()
.try_next()
.await?
.ok_or(Error::NoIface)?
.header
.index;
let ips = handle
.address()
.get()
.set_link_index_filter(index)
.execute();
ips.try_for_each(|ip| handle.address().del(ip).execute())
.await?;
handle.link().set(index).mtu(DEFAULT_MTU).execute().await?;
let res_v4 = handle
.address()
.add(index, config.ipv4.into(), 32)
.execute()
.await;
let res_v6 = handle
.address()
.add(index, config.ipv6.into(), 128)
.execute()
.await;
handle.link().set(index).up().execute().await?;
if res_v4.is_ok() {
if let Err(e) = make_rule(&handle).v4().execute().await {
if !matches!(&e, NetlinkError(err) if err.raw_code() == FILE_ALREADY_EXISTS) {
tracing::warn!(
"Couldn't add ip rule for ipv4: {e:?}, ipv4 packets won't be routed"
);
}
// TODO: Be smarter about this
} else {
tracing::debug!("Successfully created ip rule for ipv4");
}
}
if res_v6.is_ok() {
if let Err(e) = make_rule(&handle).v6().execute().await {
if !matches!(&e, NetlinkError(err) if err.raw_code() == FILE_ALREADY_EXISTS) {
tracing::warn!(
"Couldn't add ip rule for ipv6: {e:?}, ipv6 packets won't be routed"
);
}
// TODO: Be smarter about this
} else {
tracing::debug!("Successfully created ip rule for ipv6");
}
}
res_v4.or(res_v6)?;
if let Err(error) = match dns_control_method {
None => Ok(()),
Some(DnsControlMethod::EtcResolvConf) => etc_resolv_conf::configure(&dns_config)
.await
.map_err(Error::ResolvConf),
Some(DnsControlMethod::NetworkManager) => configure_network_manager(&dns_config),
Some(DnsControlMethod::Systemd) => configure_systemd_resolved(&dns_config).await,
} {
tracing::error!("Failed to control DNS: {error}");
panic!("Failed to control DNS: {error}");
}
// TODO: Having this inside the library is definitely wrong. I think `set_iface_config`
// needs to return before `new` returns, so that the `on_tunnel_ready` callback
// happens after the IP address and DNS are set up. Then we can call `sd_notify`
// inside `on_tunnel_ready` in the client.
//
// `sd_notify::notify` is always safe to call, it silently returns `Ok(())`
// if we aren't running as a systemd service.
if let Err(error) = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]) {
// Nothing we can do about it
tracing::warn!(?error, "Failed to notify systemd that we're ready");
}
Ok(())
}
fn make_rule(handle: &Handle) -> RuleAddRequest {
let mut rule = handle
.rule()
.add()
.fw_mark(FIREZONE_MARK)
.table_id(FIREZONE_TABLE)
.action(RuleAction::ToTable);
rule.message_mut()
.header
.flags
.push(netlink_packet_route::rule::RuleFlag::Invert);
rule.message_mut()
.attributes
.push(netlink_packet_route::rule::RuleAttribute::Protocol(
RouteProtocol::Kernel,
));
rule
}
fn make_route(idx: u32, handle: &Handle) -> RouteAddRequest {
handle
.route()
.add()
.output_interface(idx)
.protocol(RouteProtocol::Static)
.scope(RouteScope::Universe)
.table_id(FIREZONE_TABLE)
}
fn make_route_v4(idx: u32, handle: &Handle, route: Ipv4Network) -> RouteAddRequest<Ipv4Addr> {
make_route(idx, handle)
.v4()
.destination_prefix(route.network_address(), route.netmask())
}
fn make_route_v6(idx: u32, handle: &Handle, route: Ipv6Network) -> RouteAddRequest<Ipv6Addr> {
make_route(idx, handle)
.v6()
.destination_prefix(route.network_address(), route.netmask())
}
fn get_last_error() -> Error {
Error::Io(io::Error::last_os_error())
}
@@ -401,34 +162,6 @@ fn write(fd: RawFd, buf: &[u8]) -> io::Result<usize> {
}
}
async fn add_route(route: &IpNetwork, idx: u32, handle: &Handle) {
let res = match route {
IpNetwork::V4(ipnet) => make_route_v4(idx, handle, *ipnet).execute().await,
IpNetwork::V6(ipnet) => make_route_v6(idx, handle, *ipnet).execute().await,
};
match res {
Ok(_) => {}
Err(NetlinkError(err)) if err.raw_code() == FILE_ALREADY_EXISTS => {}
// TODO: we should be able to surface this error and handle it depending on
// if any of the added routes succeeded.
Err(err) => {
tracing::error!(%route, "failed to add route: {err}");
}
}
}
async fn delete_route(route: &IpNetwork, idx: u32, handle: &Handle) {
let message = match route {
IpNetwork::V4(ipnet) => make_route_v4(idx, handle, *ipnet).message_mut().clone(),
IpNetwork::V6(ipnet) => make_route_v6(idx, handle, *ipnet).message_mut().clone(),
};
if let Err(err) = handle.route().del(message).execute().await {
tracing::error!(%route, "failed to add route: {err:#?}");
}
}
impl ioctl::Request<SetTunFlagsPayload> {
fn new() -> Self {
let name_as_bytes = IFACE_NAME.as_bytes();
@@ -446,42 +179,6 @@ impl ioctl::Request<SetTunFlagsPayload> {
}
}
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<()> {
let status = tokio::process::Command::new("resolvectl")
.arg("dns")
.arg(IFACE_NAME)
.args(dns_config.iter().map(ToString::to_string))
.status()
.await
.map_err(|_| Error::ResolvectlFailed)?;
if !status.success() {
tracing::error!("`resolvectl dns` failed");
return Err(Error::ResolvectlFailed);
}
let status = tokio::process::Command::new("resolvectl")
.arg("domain")
.arg(IFACE_NAME)
.arg("~.")
.status()
.await
.map_err(|_| Error::ResolvectlFailed)?;
if !status.success() {
tracing::error!("`resolvectl domain` failed");
return Err(Error::ResolvectlFailed);
}
tracing::info!(?dns_config, "Configured DNS sentinels with `resolvectl`");
Ok(())
}
#[repr(C)]
struct SetTunFlagsPayload {
flags: std::ffi::c_short,

View File

@@ -1,9 +1,12 @@
use connlib_shared::{messages::Interface as InterfaceConfig, Callbacks, Result};
use connlib_shared::{
windows::{CREATE_NO_WINDOW, TUNNEL_NAME},
Callbacks, Result, DEFAULT_MTU,
};
use ip_network::IpNetwork;
use std::{
collections::HashSet,
io,
net::{IpAddr, SocketAddrV4, SocketAddrV6},
net::{SocketAddrV4, SocketAddrV6},
os::windows::process::CommandExt,
process::{Command, Stdio},
str::FromStr,
@@ -22,12 +25,8 @@ use windows::Win32::{
Networking::WinSock::{AF_INET, AF_INET6},
};
// wintun automatically appends " Tunnel" to this
const TUNNEL_NAME: &str = "Firezone";
// TODO: Double-check that all these get dropped gracefully on disconnect
pub struct Tun {
adapter: Arc<wintun::Adapter>,
/// The index of our network adapter, we can use this when asking Windows to add / remove routes / DNS rules
/// It's stable across app restarts and I'm assuming across system reboots too.
iface_idx: u32,
@@ -39,21 +38,12 @@ pub struct Tun {
impl Drop for Tun {
fn drop(&mut self) {
if let Err(error) = connlib_shared::windows::dns::deactivate() {
tracing::error!(?error, "Failed to deactivate DNS control");
}
if let Err(e) = self.session.shutdown() {
tracing::error!("wintun::Session::shutdown: {e:#?}");
}
}
}
// Hides Powershell's console on Windows
// <https://stackoverflow.com/questions/59692146/is-it-possible-to-use-the-standard-library-to-spawn-a-process-without-showing-th#60958956>
const CREATE_NO_WINDOW: u32 = 0x08000000;
// Copied from tun_linux.rs
const DEFAULT_MTU: u32 = 1280;
impl Tun {
pub fn new() -> Result<Self> {
const TUNNEL_UUID: &str = "e9245bc1-b8c1-44ca-ab1d-c6aad4f13b9c";
@@ -62,7 +52,8 @@ impl Tun {
// The Windows client, in `wintun_install` hashes the DLL at startup, before calling connlib, so it's unlikely for the DLL to be accidentally corrupted by the time we get here.
let path = connlib_shared::windows::wintun_dll_path()?;
let wintun = unsafe { wintun::load_from_path(path) }?;
let uuid = uuid::Uuid::from_str(TUNNEL_UUID).expect("static UUID to parse correctly");
let uuid =
uuid::Uuid::from_str(TUNNEL_UUID).expect("static UUID should always parse correctly");
let adapter =
match wintun::Adapter::create(&wintun, "Firezone", TUNNEL_NAME, Some(uuid.as_u128())) {
Ok(x) => x,
@@ -97,7 +88,6 @@ impl Tun {
let recv_thread = start_recv_thread(packet_tx, Arc::clone(&session))?;
Ok(Self {
adapter,
iface_idx,
_recv_thread: recv_thread,
packet_rx,
@@ -106,52 +96,16 @@ impl Tun {
})
}
pub fn set_config(&self, config: &InterfaceConfig, dns_config: &[IpAddr]) -> Result<()> {
tracing::debug!("Setting our IPv4 = {}", config.ipv4);
tracing::debug!("Setting our IPv6 = {}", config.ipv6);
// TODO: See if there's a good Win32 API for this
// Using netsh directly instead of wintun's `set_network_addresses_tuple` because their code doesn't work for IPv6
Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.arg("interface")
.arg("ipv4")
.arg("set")
.arg("address")
.arg(format!("name=\"{TUNNEL_NAME}\""))
.arg("source=static")
.arg(format!("address={}", config.ipv4))
.arg("mask=255.255.255.255")
.stdout(Stdio::null())
.status()?;
Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.arg("interface")
.arg("ipv6")
.arg("set")
.arg("address")
.arg(format!("interface=\"{TUNNEL_NAME}\""))
.arg(format!("address={}", config.ipv6))
.stdout(Stdio::null())
.status()?;
tracing::debug!("Our IPs are {:?}", self.adapter.get_addresses()?);
let iface_idx = self.adapter.get_adapter_index()?;
connlib_shared::windows::dns::change(dns_config, iface_idx)
.expect("Should be able to control DNS");
Ok(())
}
// It's okay if this blocks until the route is added in the OS.
pub fn set_routes(
&mut self,
new_routes: HashSet<IpNetwork>,
_callbacks: &impl Callbacks,
) -> Result<()> {
if new_routes == self.routes {
return Ok(());
}
for new_route in new_routes.difference(&self.routes) {
self.add_route(*new_route)?;
}
@@ -160,36 +114,9 @@ impl Tun {
self.remove_route(*old_route)?;
}
self.routes = new_routes;
// TODO: Might be calling this more often than it needs
connlib_shared::windows::dns::flush().expect("Should be able to flush Windows' DNS cache");
Ok(())
}
// It's okay if this blocks until the route is added in the OS.
fn add_route(&self, route: IpNetwork) -> Result<()> {
const DUPLICATE_ERR: u32 = 0x80071392;
let entry = self.forward_entry(route);
// SAFETY: Windows shouldn't store the reference anywhere, it's just a way to pass lots of arguments at once. And no other thread sees this variable.
match unsafe { CreateIpForwardEntry2(&entry) }.ok() {
Ok(()) => Ok(()),
Err(e) if e.code().0 as u32 == DUPLICATE_ERR => {
tracing::debug!(%route, "Failed to add duplicate route, ignoring");
Ok(())
}
Err(e) => Err(e.into()),
}
}
// It's okay if this blocks until the route is removed in the OS.
fn remove_route(&self, route: IpNetwork) -> Result<()> {
let entry = self.forward_entry(route);
// SAFETY: Windows shouldn't store the reference anywhere, it's just a way to pass lots of arguments at once. And no other thread sees this variable.
unsafe { DeleteIpForwardEntry2(&entry) }.ok()?;
flush_dns().expect("Should be able to flush Windows' DNS cache");
self.routes = new_routes;
Ok(())
}
@@ -247,6 +174,31 @@ impl Tun {
Ok(bytes.len())
}
// It's okay if this blocks until the route is added in the OS.
fn add_route(&self, route: IpNetwork) -> Result<()> {
const DUPLICATE_ERR: u32 = 0x80071392;
let entry = self.forward_entry(route);
// SAFETY: Windows shouldn't store the reference anywhere, it's just a way to pass lots of arguments at once. And no other thread sees this variable.
match unsafe { CreateIpForwardEntry2(&entry) }.ok() {
Ok(()) => Ok(()),
Err(e) if e.code().0 as u32 == DUPLICATE_ERR => {
tracing::debug!(%route, "Failed to add duplicate route, ignoring");
Ok(())
}
Err(e) => Err(e.into()),
}
}
// It's okay if this blocks until the route is removed in the OS.
fn remove_route(&self, route: IpNetwork) -> Result<()> {
let entry = self.forward_entry(route);
// SAFETY: Windows shouldn't store the reference anywhere, it's just a way to pass lots of arguments at once. And no other thread sees this variable.
unsafe { DeleteIpForwardEntry2(&entry) }.ok()?;
Ok(())
}
fn forward_entry(&self, route: IpNetwork) -> MIB_IPFORWARD_ROW2 {
let mut row = MIB_IPFORWARD_ROW2::default();
// SAFETY: Windows shouldn't store the reference anywhere, it's just setting defaults
@@ -271,6 +223,16 @@ impl Tun {
}
}
/// Flush Windows' system-wide DNS cache
pub(crate) fn flush_dns() -> Result<()> {
tracing::info!("Flushing Windows DNS cache");
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args(["-Command", "Clear-DnsClientCache"])
.status()?;
Ok(())
}
fn start_recv_thread(
packet_tx: mpsc::Sender<wintun::Packet>,
session: Arc<wintun::Session>,

View File

@@ -17,9 +17,6 @@ use std::collections::{HashSet, VecDeque};
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::{Duration, Instant};
const PEERS_IPV4: &str = "100.64.0.0/11";
const PEERS_IPV6: &str = "fd00:2021:1111::/107";
const EXPIRE_RESOURCES_INTERVAL: Duration = Duration::from_secs(1);
impl<CB> GatewayTunnel<CB>
@@ -33,10 +30,6 @@ where
self.io
.device_mut()
.set_config(config, vec![], &callbacks)?;
self.io.device_mut().set_routes(
HashSet::from([PEERS_IPV4.parse().unwrap(), PEERS_IPV6.parse().unwrap()]),
&callbacks,
)?;
let name = self.io.device_mut().name().to_owned();

View File

@@ -43,9 +43,6 @@ const MTU: usize = 1280;
const REALM: &str = "firezone";
#[cfg(target_os = "linux")]
const FIREZONE_MARK: u32 = 0xfd002021;
pub type GatewayTunnel<CB> = Tunnel<CB, GatewayState>;
pub type ClientTunnel<CB> = Tunnel<CB, ClientState>;

View File

@@ -339,7 +339,7 @@ fn make_socket(addr: impl Into<SocketAddr>) -> Result<std::net::UdpSocket> {
#[cfg(target_os = "linux")]
{
socket.set_mark(crate::FIREZONE_MARK)?;
socket.set_mark(connlib_shared::tun_device_manager::platform::FIREZONE_MARK)?;
}
// Note: for AF_INET sockets IPV6_V6ONLY is not a valid flag

View File

@@ -3,10 +3,11 @@ use crate::messages::InitGateway;
use anyhow::{Context, Result};
use backoff::ExponentialBackoffBuilder;
use clap::Parser;
use connlib_shared::{get_user_agent, keypair, Callbacks, LoginUrl, StaticSecret};
use connlib_shared::{get_user_agent, keypair, Callbacks, Cidrv4, Cidrv6, LoginUrl, StaticSecret};
use firezone_cli_utils::{setup_global_subscriber, CommonArgs};
use firezone_tunnel::{GatewayTunnel, Sockets};
use futures::{future, TryFutureExt};
use ip_network::{Ipv4Network, Ipv6Network};
use secrecy::{Secret, SecretString};
use std::collections::HashSet;
use std::convert::Infallible;
@@ -21,6 +22,8 @@ mod eventloop;
mod messages;
const ID_PATH: &str = "/var/lib/firezone/gateway_id";
const PEERS_IPV4: &str = "100.64.0.0/11";
const PEERS_IPV6: &str = "fd00:2021:1111::/107";
#[tokio::main]
async fn main() {
@@ -111,6 +114,16 @@ async fn run(login: LoginUrl, private_key: StaticSecret) -> Result<Infallible> {
tunnel
.set_interface(&init.interface)
.context("Failed to set interface")?;
let mut tun_device = connlib_shared::tun_device_manager::TunDeviceManager::new()?;
tun_device
.set_ips(init.interface.ipv4, init.interface.ipv6)
.await?;
tun_device
.set_routes(
vec![Cidrv4::from(PEERS_IPV4.parse::<Ipv4Network>().unwrap())],
vec![Cidrv6::from(PEERS_IPV6.parse::<Ipv6Network>().unwrap())],
)
.await?;
tunnel.update_relays(HashSet::default(), init.relays);
let mut eventloop = Eventloop::new(tunnel, portal);

View File

@@ -13,7 +13,6 @@ mod gui;
mod ipc;
mod logging;
mod network_changes;
mod resolvers;
mod settings;
mod updates;
mod uptime;

View File

@@ -79,7 +79,7 @@ pub async fn open(url: &url::Url) -> Result<()> {
}
fn pipe_path() -> String {
firezone_headless_client::platform::named_pipe_path(&format!("{BUNDLE_ID}.deep_link"))
firezone_headless_client::windows::named_pipe_path(&format!("{BUNDLE_ID}.deep_link"))
}
/// Registers the current exe as the handler for our deep link scheme.

View File

@@ -191,10 +191,6 @@ pub(crate) fn run(cli: client::Cli) -> Result<(), Error> {
if let Some(client::Cmd::SmokeTest) = &cli.command {
let ctlr_tx = ctlr_tx.clone();
// Generate the device ID so that the device ID code is covered
// by the smoke test.
// This is redundant since we will also lazily generate it when we boot connlib.
connlib_shared::device_id::get().ok();
tokio::spawn(async move {
if let Err(error) = smoke_test(ctlr_tx).await {
tracing::error!(?error, "Error during smoke test");
@@ -495,7 +491,7 @@ impl Controller {
let api_url = self.advanced_settings.api_url.clone();
tracing::info!(api_url = api_url.to_string(), "Starting connlib...");
let mut connlib = ipc::Client::connect(
let connlib = ipc::Client::connect(
api_url.as_str(),
token,
callback_handler.clone(),
@@ -503,10 +499,6 @@ impl Controller {
)
.await?;
connlib
.set_dns(client::resolvers::get().unwrap_or_default())
.await?;
self.session = Some(Session {
callback_handler,
connlib,

View File

@@ -41,7 +41,7 @@ impl CallbackHandler {
.expect("controller channel failed");
}
fn on_set_interface_config(&self) {
fn on_tunnel_ready(&self) {
self.ctlr_tx
.try_send(ControllerRequest::TunnelReady)
.expect("controller channel failed");
@@ -103,14 +103,8 @@ impl Client {
error_msg,
is_authentication_error,
} => callback_handler.on_disconnect(error_msg, is_authentication_error),
IpcServerMsg::OnTunnelReady => callback_handler.on_tunnel_ready(),
IpcServerMsg::OnUpdateResources(v) => callback_handler.on_update_resources(v),
IpcServerMsg::OnSetInterfaceConfig {
ipv4: _,
ipv6: _,
dns: _,
} => {
callback_handler.on_set_interface_config();
}
}
}
Ok(())

View File

@@ -1,6 +1,7 @@
//! Not implemented for Linux yet
use anyhow::Result;
use firezone_headless_client::dns_control::system_resolvers_for_gui;
use std::net::IpAddr;
use tokio::time::Interval;
@@ -47,7 +48,7 @@ impl DnsListener {
pub(crate) fn new() -> Result<Self> {
Ok(Self {
interval: create_interval(),
last_seen: crate::client::resolvers::get().unwrap_or_default(),
last_seen: system_resolvers_for_gui().unwrap_or_default(),
})
}
@@ -55,7 +56,7 @@ impl DnsListener {
loop {
self.interval.tick().await;
tracing::trace!("Checking for DNS changes");
let new = crate::client::resolvers::get().unwrap_or_default();
let new = system_resolvers_for_gui().unwrap_or_default();
if new != self.last_seen {
self.last_seen.clone_from(&new);
return Ok(new);

View File

@@ -486,7 +486,10 @@ mod async_dns {
r = self.listener_4.notified() => r?,
r = self.listener_6.notified() => r?,
}
Ok(crate::client::resolvers::get().unwrap_or_default())
Ok(
firezone_headless_client::dns_control::system_resolvers_for_gui()
.unwrap_or_default(),
)
}
}

View File

@@ -1,33 +0,0 @@
//! Module to handle Windows system-wide DNS resolvers
pub(crate) use imp::get;
#[cfg(target_os = "linux")]
mod imp {
use anyhow::Result;
use std::net::IpAddr;
pub fn get() -> Result<Vec<IpAddr>> {
firezone_headless_client::platform::get_system_default_resolvers_systemd_resolved()
}
}
#[cfg(target_os = "macos")]
mod imp {
use anyhow::Result;
use std::net::IpAddr;
pub fn get() -> Result<Vec<IpAddr>> {
unimplemented!()
}
}
#[cfg(target_os = "windows")]
mod imp {
use anyhow::Result;
use std::net::IpAddr;
pub fn get() -> Result<Vec<IpAddr>> {
firezone_headless_client::platform::system_resolvers()
}
}

View File

@@ -9,6 +9,7 @@ authors = ["Firezone, Inc."]
[dependencies]
anyhow = { version = "1.0" }
atomicwrites = "0.4.3" # Needed to safely backup `/etc/resolv.conf` and write the device ID on behalf of `gui-client`
clap = { version = "4.5", features = ["derive", "env", "string"] }
connlib-client-shared = { workspace = true }
connlib-shared = { workspace = true }
@@ -16,6 +17,7 @@ firezone-cli-utils = { workspace = true }
futures = "0.3.30"
git-version = "0.3.9"
humantime = "2.1"
ip_network = { version = "0.4", default-features = false }
secrecy = { workspace = true }
serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.117"
@@ -26,11 +28,19 @@ tokio-util = { version = "0.7.11", features = ["codec"] }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
url = { version = "2.3.1", default-features = false }
uuid = { version = "1.7", default-features = false, features = ["std", "v4", "serde"] }
[dev-dependencies]
tempfile = "3.10.1"
[target.'cfg(target_os = "linux")'.dev-dependencies]
mutants = "0.0.3" # Needed to mark functions as exempt from `cargo-mutants` testing
[target.'cfg(target_os = "linux")'.dependencies]
dirs = "5.0.1"
nix = { version = "0.28.0", features = ["fs", "user"] }
resolv-conf = "0.7.0"
rtnetlink = { workspace = true }
sd-notify = "0.4.1" # This is a pure Rust re-implementation, so it isn't vulnerable to CVE-2024-3094
[target.'cfg(target_os = "macos")'.dependencies]
@@ -47,9 +57,14 @@ windows-service = "0.7.0"
[target.'cfg(target_os = "windows")'.dependencies.windows]
version = "0.56.0"
features = [
# For DNS control and route control
"Win32_Foundation",
"Win32_NetworkManagement_IpHelper",
"Win32_NetworkManagement_Ndis",
"Win32_Networking_WinSock",
# For named pipe IPC
"Win32_Security",
# For named pipe IPC
"Win32_System_SystemServices",
]

View File

@@ -2,8 +2,8 @@ use anyhow::{Context, Result};
use std::fs;
use std::io::Write;
pub struct DeviceId {
pub id: String,
pub(crate) struct DeviceId {
pub(crate) id: String,
}
/// Returns the device ID, generating it and saving it to disk if needed.
@@ -14,8 +14,8 @@ pub struct DeviceId {
/// Returns: The UUID as a String, suitable for sending verbatim to `connlib_client_shared::Session::connect`.
///
/// Errors: If the disk is unwritable when initially generating the ID, or unwritable when re-generating an invalid ID.
pub fn get() -> Result<DeviceId> {
let dir = imp::path().context("Failed to compute path for firezone-id file")?;
pub(crate) fn get() -> Result<DeviceId> {
let dir = platform::path().context("Failed to compute path for firezone-id file")?;
let path = dir.join("firezone-id.json");
// Try to read it from the disk
@@ -60,14 +60,14 @@ impl DeviceIdJson {
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
mod imp {
mod platform {
pub(crate) fn path() -> Option<std::path::PathBuf> {
panic!("This function is only implemented on Linux and Windows since those have pure-Rust clients")
}
}
#[cfg(target_os = "linux")]
mod imp {
mod platform {
use std::path::PathBuf;
/// `/var/lib/$BUNDLE_ID/config/firezone-id`
///
@@ -82,14 +82,14 @@ mod imp {
pub(crate) fn path() -> Option<PathBuf> {
Some(
PathBuf::from("/var/lib")
.join(crate::BUNDLE_ID)
.join(connlib_shared::BUNDLE_ID)
.join("config"),
)
}
}
#[cfg(target_os = "windows")]
mod imp {
mod platform {
use known_folders::{get_known_folder_path, KnownFolder};
/// e.g. `C:\ProgramData\dev.firezone.client\config`
@@ -98,7 +98,7 @@ mod imp {
pub(crate) fn path() -> Option<std::path::PathBuf> {
Some(
get_known_folder_path(KnownFolder::ProgramData)?
.join(crate::BUNDLE_ID)
.join(connlib_shared::BUNDLE_ID)
.join("config"),
)
}
@@ -108,7 +108,7 @@ mod imp {
mod tests {
#[test]
fn smoke() {
let dir = super::imp::path().expect("should have gotten Some(path)");
let dir = super::platform::path().expect("should have gotten Some(path)");
assert!(dir
.components()
.any(|x| x == std::path::Component::Normal("dev.firezone.client".as_ref())));

View File

@@ -0,0 +1,15 @@
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
use linux as platform;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
use windows as platform;
pub(crate) use platform::{deactivate, system_resolvers, DnsController};
// 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;

View File

@@ -0,0 +1,204 @@
use anyhow::{bail, Context as _, Result};
use connlib_shared::tun_device_manager::linux::IFACE_NAME;
use std::{net::IpAddr, str::FromStr};
mod etc_resolv_conf;
const FIREZONE_DNS_CONTROL: &str = "FIREZONE_DNS_CONTROL";
pub fn system_resolvers_for_gui() -> Result<Vec<IpAddr>> {
get_system_default_resolvers_systemd_resolved()
}
#[derive(Clone, Debug)]
enum DnsControlMethod {
/// Back up `/etc/resolv.conf` and replace it with our own
///
/// Only suitable for the Alpine CI containers and maybe something like an
/// embedded system
EtcResolvConf,
/// Cooperate with NetworkManager (TODO)
NetworkManager,
/// Cooperate with `systemd-resolved`
///
/// Suitable for most Ubuntu systems, probably
Systemd,
}
pub(crate) struct DnsController {
dns_control_method: Option<DnsControlMethod>,
}
impl Drop for DnsController {
fn drop(&mut self) {
tracing::debug!("Reverting DNS control...");
if let Some(DnsControlMethod::EtcResolvConf) = self.dns_control_method {
// TODO: Check that nobody else modified the file while we were running.
etc_resolv_conf::revert().ok();
}
}
}
impl DnsController {
pub(crate) fn new() -> Self {
// We'll remove `get_dns_control_from_env` in #5068
let dns_control_method = get_dns_control_from_env();
tracing::info!(?dns_control_method);
Self { dns_control_method }
}
/// Set the computer's system-wide DNS servers
///
/// 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.
pub(crate) async fn set_dns(&mut self, dns_config: &[IpAddr]) -> Result<()> {
match self.dns_control_method {
None => Ok(()),
Some(DnsControlMethod::EtcResolvConf) => etc_resolv_conf::configure(dns_config).await,
Some(DnsControlMethod::NetworkManager) => configure_network_manager(dns_config),
Some(DnsControlMethod::Systemd) => configure_systemd_resolved(dns_config).await,
}
.context("Failed to control DNS")
}
}
/// Reads FIREZONE_DNS_CONTROL. Returns None if invalid or not set
fn get_dns_control_from_env() -> Option<DnsControlMethod> {
match std::env::var(FIREZONE_DNS_CONTROL).as_deref() {
Ok("etc-resolv-conf") => Some(DnsControlMethod::EtcResolvConf),
Ok("network-manager") => Some(DnsControlMethod::NetworkManager),
Ok("systemd-resolved") => Some(DnsControlMethod::Systemd),
_ => None,
}
}
fn configure_network_manager(_dns_config: &[IpAddr]) -> Result<()> {
anyhow::bail!("DNS control with NetworkManager is not implemented yet",)
}
async fn configure_systemd_resolved(dns_config: &[IpAddr]) -> Result<()> {
let status = tokio::process::Command::new("resolvectl")
.arg("dns")
.arg(IFACE_NAME)
.args(dns_config.iter().map(ToString::to_string))
.status()
.await
.context("`resolvectl dns` didn't run")?;
if !status.success() {
bail!("`resolvectl dns` returned non-zero");
}
let status = tokio::process::Command::new("resolvectl")
.arg("domain")
.arg(IFACE_NAME)
.arg("~.")
.status()
.await
.context("`resolvectl domain` didn't run")?;
if !status.success() {
bail!("`resolvectl domain` returned non-zero");
}
tracing::info!(?dns_config, "Configured DNS sentinels with `resolvectl`");
Ok(())
}
pub(crate) fn system_resolvers() -> Result<Vec<IpAddr>> {
match crate::dns_control::platform::get_dns_control_from_env() {
None => get_system_default_resolvers_resolv_conf(),
Some(DnsControlMethod::EtcResolvConf) => get_system_default_resolvers_resolv_conf(),
Some(DnsControlMethod::NetworkManager) => get_system_default_resolvers_network_manager(),
Some(DnsControlMethod::Systemd) => get_system_default_resolvers_systemd_resolved(),
}
}
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::ETC_RESOLV_CONF_BACKUP)
.or_else(|_| std::fs::read_to_string(etc_resolv_conf::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)
}
#[allow(clippy::unnecessary_wraps)]
fn get_system_default_resolvers_network_manager() -> Result<Vec<IpAddr>> {
tracing::error!("get_system_default_resolvers_network_manager not implemented yet");
Ok(vec![])
}
/// Returns the DNS servers listed in `resolvectl dns`
fn get_system_default_resolvers_systemd_resolved() -> Result<Vec<IpAddr>> {
// Unfortunately systemd-resolved does not have a machine-readable
// text output for this command: <https://github.com/systemd/systemd/issues/29755>
//
// The officially supported way is probably to use D-Bus.
let output = std::process::Command::new("resolvectl")
.arg("dns")
.output()
.context("Failed to run `resolvectl dns` and read output")?;
if !output.status.success() {
anyhow::bail!("`resolvectl dns` returned non-zero exit code");
}
let output = String::from_utf8(output.stdout).context("`resolvectl` output was not UTF-8")?;
Ok(parse_resolvectl_output(&output))
}
/// Parses the text output of `resolvectl dns`
///
/// Cannot fail. If the parsing code is wrong, the IP address vec will just be incomplete.
fn parse_resolvectl_output(s: &str) -> Vec<IpAddr> {
s.lines()
.flat_map(|line| line.split(' '))
.filter_map(|word| IpAddr::from_str(word).ok())
.collect()
}
// Does nothing on Linux, needed to match the Windows interface
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn deactivate() -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use std::net::IpAddr;
#[test]
fn parse_resolvectl_output() {
let cases = [
// WSL
(
r"Global: 172.24.80.1
Link 2 (eth0):
Link 3 (docker0):
Link 24 (br-fc0b71997a3c):
Link 25 (br-0c129dafb204):
Link 26 (br-e67e83b19dce):
",
[IpAddr::from([172, 24, 80, 1])],
),
// Ubuntu 20.04
(
r"Global:
Link 2 (enp0s3): 192.168.1.1",
[IpAddr::from([192, 168, 1, 1])],
),
];
for (i, (input, expected)) in cases.iter().enumerate() {
let actual = super::parse_resolvectl_output(input);
assert_eq!(actual, expected, "Case {i} failed");
}
}
}

View File

@@ -1,8 +1,8 @@
use anyhow::{Context, Result};
use std::{io::Write, net::IpAddr, path::PathBuf};
pub const ETC_RESOLV_CONF: &str = "/etc/resolv.conf";
pub const ETC_RESOLV_CONF_BACKUP: &str = "/etc/resolv.conf.before-firezone";
pub(crate) const ETC_RESOLV_CONF: &str = "/etc/resolv.conf";
pub(crate) const ETC_RESOLV_CONF_BACKUP: &str = "/etc/resolv.conf.before-firezone";
/// Used to figure out whether we crashed on our last run or not.
///
/// If we did crash, we need to restore the system-wide DNS from the backup file.
@@ -11,7 +11,7 @@ const MAGIC_HEADER: &str = "# BEGIN Firezone DNS configuration";
// Wanted these args to have names so they don't get mixed up
#[derive(Clone)]
pub struct ResolvPaths {
pub(crate) struct ResolvPaths {
resolv: PathBuf,
backup: PathBuf,
}
@@ -30,7 +30,7 @@ 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 async fn configure(dns_config: &[IpAddr]) -> Result<()> {
pub(crate) async fn configure(dns_config: &[IpAddr]) -> Result<()> {
configure_at_paths(dns_config, &ResolvPaths::default()).await
}
@@ -38,7 +38,7 @@ pub async fn configure(dns_config: &[IpAddr]) -> Result<()> {
///
/// Must be sync because it's called in `Tun::drop`
#[cfg_attr(test, mutants::skip)] // Would modify system-wide `/etc/resolv.conf`
pub fn revert() -> Result<()> {
pub(crate) fn revert() -> Result<()> {
revert_at_paths(&ResolvPaths::default())
}

View File

@@ -0,0 +1,128 @@
//! Gives Firezone DNS privilege over other DNS resolvers on the system
//!
//! This uses NRPT and claims all domains, similar to the `systemd-resolved` control method
//! on Linux.
//! This allows us to "shadow" DNS resolvers that are configured by the user or DHCP on
//! physical interfaces, as long as they don't have any NRPT rules that outrank us.
//!
//! If Firezone crashes, restarting Firezone and closing it gracefully will resume
//! normal DNS operation. The Powershell command to remove the NRPT rule can also be run
//! by hand.
//!
//! The system default resolvers don't need to be reverted because they're never deleted.
//!
//! <https://superuser.com/a/1752670>
use anyhow::{Context as _, Result};
use connlib_shared::windows::{CREATE_NO_WINDOW, TUNNEL_NAME};
use std::{net::IpAddr, os::windows::process::CommandExt, process::Command};
pub fn system_resolvers_for_gui() -> Result<Vec<IpAddr>> {
system_resolvers()
}
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) = deactivate() {
tracing::error!(?error, "Failed to deactivate DNS control");
}
}
}
impl DnsController {
pub(crate) fn new() -> Self {
Self {}
}
/// Set the computer's system-wide DNS servers
///
/// There's a gap in this because on Windows we deactivate and re-activate control.
///
/// 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
#[allow(clippy::unused_async)]
pub(crate) async fn set_dns(&mut self, dns_config: &[IpAddr]) -> Result<()> {
deactivate().context("Failed to deactivate DNS control")?;
activate(dns_config).context("Failed to activate DNS control")?;
Ok(())
}
}
pub(crate) fn system_resolvers() -> Result<Vec<IpAddr>> {
let resolvers = ipconfig::get_adapters()?
.iter()
.flat_map(|adapter| adapter.dns_servers())
.filter(|ip| match ip {
IpAddr::V4(_) => true,
// Filter out bogus DNS resolvers on my dev laptop that start with fec0:
IpAddr::V6(ip) => !ip.octets().starts_with(&[0xfe, 0xc0]),
})
.copied()
.collect();
// This is private, so keep it at `debug` or `trace`
tracing::debug!(?resolvers);
Ok(resolvers)
}
/// Tells Windows to send all DNS queries to our sentinels
///
/// Parameters:
/// - `dns_config_string`: Comma-separated IP addresses of DNS servers, e.g. "1.1.1.1,8.8.8.8"
pub(crate) fn activate(dns_config: &[IpAddr]) -> Result<()> {
let dns_config_string = dns_config
.iter()
.map(|ip| format!("\"{ip}\""))
.collect::<Vec<_>>()
.join(",");
// Set our DNS IP as the DNS server for our interface
// TODO: Known issue where web browsers will keep a connection open to a site,
// using QUIC, HTTP/2, or even HTTP/1.1, and so they won't resolve the DNS
// again unless you let that connection time out:
// <https://github.com/firezone/firezone/issues/3113#issuecomment-1882096111>
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.arg("-Command")
.arg(format!(
"Set-DnsClientServerAddress {TUNNEL_NAME} -ServerAddresses({dns_config_string})"
))
.status()?;
tracing::info!("Activating DNS control");
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args([
"-Command",
"Add-DnsClientNrptRule",
"-Namespace",
".",
"-Comment",
FZ_MAGIC,
"-NameServers",
&dns_config_string,
])
.status()?;
Ok(())
}
// Must be `sync` so we can call it from `Drop`
pub(crate) fn deactivate() -> Result<()> {
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args(["-Command", "Get-DnsClientNrptRule", "|"])
.args(["where", "Comment", "-eq", FZ_MAGIC, "|"])
.args(["foreach", "{"])
.args(["Remove-DnsClientNrptRule", "-Name", "$_.Name", "-Force"])
.args(["}"])
.status()?;
tracing::info!("Deactivated DNS control");
Ok(())
}

View File

@@ -13,7 +13,7 @@ use clap::Parser;
use connlib_client_shared::{
file_logger, keypair, Callbacks, Error as ConnlibError, LoginUrl, Session, Sockets,
};
use connlib_shared::callbacks;
use connlib_shared::{callbacks, tun_device_manager, Cidrv4, Cidrv6};
use firezone_cli_utils::setup_global_subscriber;
use futures::{future, SinkExt, StreamExt};
use secrecy::SecretString;
@@ -34,6 +34,9 @@ use platform::default_token_path;
/// Must be constructed inside a Tokio runtime context.
use platform::Signals;
/// Generate a persistent device ID, stores it to disk, and reads it back.
pub(crate) mod device_id;
pub mod dns_control;
pub mod known_dirs;
#[cfg(target_os = "linux")]
@@ -44,7 +47,7 @@ pub use linux as platform;
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(target_os = "windows")]
pub use windows as platform;
pub(crate) use windows as platform;
/// Only used on Linux
pub const FIREZONE_GROUP: &str = "firezone-client";
@@ -57,7 +60,7 @@ pub const FIREZONE_GROUP: &str = "firezone-client";
/// * `g` doesn't mean anything
/// * `ed5437c88` is the Git commit hash
/// * `-modified` is present if the working dir has any changes from that commit number
pub const GIT_VERSION: &str = git_version::git_version!(
pub(crate) const GIT_VERSION: &str = git_version::git_version!(
args = ["--always", "--dirty=-modified", "--tags"],
fallback = "unknown"
);
@@ -83,7 +86,7 @@ struct Cli {
env = "FIREZONE_API_URL",
default_value = "wss://api.firezone.dev"
)]
pub api_url: url::Url,
api_url: url::Url,
/// Check the configuration and return 0 before connecting to the API
///
@@ -101,7 +104,7 @@ struct Cli {
// AKA `device_id` in the Windows and Linux GUI clients
// Generated automatically if not provided
#[arg(short = 'i', long, env = "FIREZONE_ID")]
pub firezone_id: Option<String>,
firezone_id: Option<String>,
/// Token generated by the portal to authorize websocket connection.
// systemd recommends against passing secrets through env vars:
@@ -170,6 +173,19 @@ pub enum IpcClientMsg {
SetDns(Vec<IpAddr>),
}
enum InternalServerMsg {
Ipc(IpcServerMsg),
OnSetInterfaceConfig {
ipv4: Ipv4Addr,
ipv6: Ipv6Addr,
dns: Vec<IpAddr>,
},
OnUpdateRoutes {
ipv4: Vec<Cidrv4>,
ipv6: Vec<Cidrv6>,
},
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub enum IpcServerMsg {
Ok,
@@ -177,11 +193,7 @@ pub enum IpcServerMsg {
error_msg: String,
is_authentication_error: bool,
},
OnSetInterfaceConfig {
ipv4: Ipv4Addr,
ipv6: Ipv6Addr,
dns: Vec<IpAddr>,
},
OnTunnelReady,
OnUpdateResources(Vec<callbacks::ResourceDescription>),
}
@@ -235,7 +247,7 @@ pub fn run_only_headless_client() -> Result<()> {
// AKA "Device ID", not the Firezone slug
let firezone_id = match cli.firezone_id {
Some(id) => id,
None => connlib_shared::device_id::get().context("Could not get `firezone_id` from CLI, could not read it from disk, could not generate it and save it to disk")?.id,
None => device_id::get().context("Could not get `firezone_id` from CLI, could not read it from disk, could not generate it and save it to disk")?.id,
};
let (private_key, public_key) = keypair();
@@ -252,10 +264,8 @@ pub fn run_only_headless_client() -> Result<()> {
return Ok(());
}
// TODO: Can theoretically be removed the same way the Windows IPC service works,
// using an abort handle
let (on_disconnect_tx, mut on_disconnect_rx) = mpsc::channel(1);
let callback_handler = CallbackHandler { on_disconnect_tx };
let (cb_tx, mut cb_rx) = mpsc::channel(10);
let callback_handler = CallbackHandler { cb_tx };
platform::setup_before_connlib()?;
let session = Session::connect(
@@ -268,13 +278,16 @@ pub fn run_only_headless_client() -> Result<()> {
rt.handle().clone(),
);
// TODO: this should be added dynamically
session.set_dns(platform::system_resolvers().unwrap_or_default());
session.set_dns(dns_control::system_resolvers().unwrap_or_default());
platform::notify_service_controller()?;
let result = rt.block_on(async {
let mut dns_controller = dns_control::DnsController::new();
let mut tun_device = tun_device_manager::TunDeviceManager::new()?;
let mut signals = Signals::new()?;
loop {
match future::select(pin!(signals.recv()), pin!(on_disconnect_rx.recv())).await {
match future::select(pin!(signals.recv()), pin!(cb_rx.recv())).await {
future::Either::Left((SignalKind::Hangup, _)) => {
tracing::info!("Caught Hangup signal");
session.reconnect();
@@ -284,11 +297,24 @@ pub fn run_only_headless_client() -> Result<()> {
return Ok(());
}
future::Either::Right((None, _)) => {
return Err(anyhow::anyhow!("on_disconnect_rx unexpectedly ran empty"));
}
future::Either::Right((Some(error), _)) => {
return Err(anyhow!(error).context("Firezone disconnected"))
return Err(anyhow::anyhow!("cb_rx unexpectedly ran empty"));
}
future::Either::Right((Some(msg), _)) => match msg {
InternalServerMsg::Ipc(IpcServerMsg::OnDisconnect {
error_msg,
is_authentication_error: _,
}) => return Err(anyhow!(error_msg).context("Firezone disconnected")),
InternalServerMsg::Ipc(IpcServerMsg::Ok)
| InternalServerMsg::Ipc(IpcServerMsg::OnTunnelReady)
| InternalServerMsg::Ipc(IpcServerMsg::OnUpdateResources(_)) => {}
InternalServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns } => {
tun_device.set_ips(ipv4, ipv6).await?;
dns_controller.set_dns(&dns).await?;
}
InternalServerMsg::OnUpdateRoutes { ipv4, ipv6 } => {
tun_device.set_routes(ipv4, ipv6).await?
}
},
}
}
});
@@ -342,23 +368,23 @@ pub(crate) fn run_debug_ipc_service() -> Result<()> {
}
#[derive(Clone)]
struct CallbackHandlerIpc {
cb_tx: mpsc::Sender<IpcServerMsg>,
struct CallbackHandler {
cb_tx: mpsc::Sender<InternalServerMsg>,
}
impl Callbacks for CallbackHandlerIpc {
fn on_disconnect(&self, error: &ConnlibError) {
impl Callbacks for CallbackHandler {
fn on_disconnect(&self, error: &connlib_client_shared::Error) {
tracing::error!(?error, "Got `on_disconnect` from connlib");
let is_authentication_error = if let ConnlibError::PortalConnectionFailed(error) = error {
error.is_authentication_error()
} else {
false
};
self.cb_tx
.try_send(IpcServerMsg::OnDisconnect {
.try_send(InternalServerMsg::Ipc(IpcServerMsg::OnDisconnect {
error_msg: error.to_string(),
is_authentication_error: if let ConnlibError::PortalConnectionFailed(error) = error
{
error.is_authentication_error()
} else {
false
},
})
is_authentication_error,
}))
.expect("should be able to send OnDisconnect");
}
@@ -370,7 +396,7 @@ impl Callbacks for CallbackHandlerIpc {
) -> Option<i32> {
tracing::info!("TunnelReady (on_set_interface_config)");
self.cb_tx
.try_send(IpcServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns })
.try_send(InternalServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns })
.expect("Should be able to send TunnelReady");
None
}
@@ -378,15 +404,24 @@ impl Callbacks for CallbackHandlerIpc {
fn on_update_resources(&self, resources: Vec<callbacks::ResourceDescription>) {
tracing::debug!(len = resources.len(), "New resource list");
self.cb_tx
.try_send(IpcServerMsg::OnUpdateResources(resources))
.try_send(InternalServerMsg::Ipc(IpcServerMsg::OnUpdateResources(
resources,
)))
.expect("Should be able to send OnUpdateResources");
}
fn on_update_routes(&self, ipv4: Vec<Cidrv4>, ipv6: Vec<Cidrv6>) -> Option<i32> {
self.cb_tx
.try_send(InternalServerMsg::OnUpdateRoutes { ipv4, ipv6 })
.expect("Should be able to send messages");
None
}
}
async fn ipc_listen() -> Result<std::convert::Infallible> {
let mut server = platform::IpcServer::new().await?;
loop {
connlib_shared::deactivate_dns_control()?;
dns_control::deactivate()?;
let stream = server.next_client().await?;
if let Err(error) = handle_ipc_client(stream).await {
tracing::error!(?error, "Error while handling IPC client");
@@ -398,17 +433,31 @@ async fn handle_ipc_client(stream: platform::IpcStream) -> Result<()> {
let (rx, tx) = tokio::io::split(stream);
let mut rx = FramedRead::new(rx, LengthDelimitedCodec::new());
let mut tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
let (cb_tx, mut cb_rx) = mpsc::channel(100);
let (cb_tx, mut cb_rx) = mpsc::channel(10);
let send_task = tokio::spawn(async move {
let mut dns_controller = dns_control::DnsController::new();
let mut tun_device = tun_device_manager::TunDeviceManager::new()?;
while let Some(msg) = cb_rx.recv().await {
tx.send(serde_json::to_string(&msg)?.into()).await?;
match msg {
InternalServerMsg::Ipc(msg) => tx.send(serde_json::to_string(&msg)?.into()).await?,
InternalServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns } => {
tun_device.set_ips(ipv4, ipv6).await?;
dns_controller.set_dns(&dns).await?;
tx.send(serde_json::to_string(&IpcServerMsg::OnTunnelReady)?.into())
.await?;
}
InternalServerMsg::OnUpdateRoutes { ipv4, ipv6 } => {
tun_device.set_routes(ipv4, ipv6).await?
}
}
}
Ok::<_, anyhow::Error>(())
});
let mut connlib = None;
let callback_handler = CallbackHandlerIpc { cb_tx };
let callback_handler = CallbackHandler { cb_tx };
while let Some(msg) = rx.next().await {
let msg = msg?;
let msg: IpcClientMsg = serde_json::from_slice(&msg)?;
@@ -417,8 +466,7 @@ async fn handle_ipc_client(stream: platform::IpcStream) -> Result<()> {
IpcClientMsg::Connect { api_url, token } => {
let token = secrecy::SecretString::from(token);
assert!(connlib.is_none());
let device_id = connlib_shared::device_id::get()
.context("Failed to read / create device ID")?;
let device_id = device_id::get().context("Failed to read / create device ID")?;
let (private_key, public_key) = keypair();
let login = LoginUrl::client(
@@ -429,7 +477,7 @@ async fn handle_ipc_client(stream: platform::IpcStream) -> Result<()> {
public_key.to_bytes(),
)?;
connlib = Some(connlib_client_shared::Session::connect(
let new_session = connlib_client_shared::Session::connect(
login,
Sockets::new(),
private_key,
@@ -437,7 +485,9 @@ async fn handle_ipc_client(stream: platform::IpcStream) -> Result<()> {
callback_handler.clone(),
Some(std::time::Duration::from_secs(60 * 60 * 24 * 30)),
tokio::runtime::Handle::try_current()?,
));
);
new_session.set_dns(dns_control::system_resolvers().unwrap_or_default());
connlib = Some(new_session);
}
IpcClientMsg::Disconnect => {
if let Some(connlib) = connlib.take() {
@@ -464,28 +514,6 @@ enum SignalKind {
Interrupt,
}
#[derive(Clone)]
struct CallbackHandler {
/// Channel for an error message if connlib disconnects due to an error
on_disconnect_tx: mpsc::Sender<String>,
}
impl Callbacks for CallbackHandler {
fn on_disconnect(&self, error: &connlib_client_shared::Error) {
// Convert the error to a String since we can't clone it
self.on_disconnect_tx
.try_send(error.to_string())
.expect("should be able to tell the main thread that we disconnected");
}
fn on_update_resources(&self, resources: Vec<callbacks::ResourceDescription>) {
// See easily with `export RUST_LOG=firezone_headless_client=debug`
for resource in &resources {
tracing::debug!(?resource);
}
}
}
/// Read the token from disk if it was not in the environment
///
/// # Returns
@@ -562,10 +590,6 @@ mod tests {
fn cli() -> anyhow::Result<()> {
let exe_name = "firezone-headless-client";
let actual = Cli::parse_from([exe_name]);
assert_eq!(actual.api_url, Url::parse("wss://api.firezone.dev")?);
assert!(!actual.check);
let actual = Cli::parse_from([exe_name, "--api-url", "wss://api.firez.one"]);
assert_eq!(actual.api_url, Url::parse("wss://api.firez.one")?);

View File

@@ -3,15 +3,12 @@
use super::{CliCommon, SignalKind, FIREZONE_GROUP, TOKEN_ENV_KEY};
use anyhow::{bail, Context as _, Result};
use connlib_client_shared::file_logger;
use connlib_shared::linux::{etc_resolv_conf, get_dns_control_from_env, DnsControlMethod};
use firezone_cli_utils::setup_global_subscriber;
use futures::future::{select, Either};
use std::{
net::IpAddr,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
pin::pin,
str::FromStr,
};
use tokio::{
net::{UnixListener, UnixStream},
@@ -44,7 +41,7 @@ impl Signals {
}
}
pub fn default_token_path() -> PathBuf {
pub(crate) fn default_token_path() -> PathBuf {
PathBuf::from("/etc")
.join(connlib_shared::BUNDLE_ID)
.join("token")
@@ -81,65 +78,6 @@ pub(crate) fn check_token_permissions(path: &Path) -> Result<()> {
Ok(())
}
pub(crate) fn system_resolvers() -> Result<Vec<IpAddr>> {
match get_dns_control_from_env() {
None => get_system_default_resolvers_resolv_conf(),
Some(DnsControlMethod::EtcResolvConf) => get_system_default_resolvers_resolv_conf(),
Some(DnsControlMethod::NetworkManager) => get_system_default_resolvers_network_manager(),
Some(DnsControlMethod::Systemd) => get_system_default_resolvers_systemd_resolved(),
}
}
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::ETC_RESOLV_CONF_BACKUP)
.or_else(|_| std::fs::read_to_string(etc_resolv_conf::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)
}
#[allow(clippy::unnecessary_wraps)]
fn get_system_default_resolvers_network_manager() -> Result<Vec<IpAddr>> {
tracing::error!("get_system_default_resolvers_network_manager not implemented yet");
Ok(vec![])
}
/// Returns the DNS servers listed in `resolvectl dns`
pub fn get_system_default_resolvers_systemd_resolved() -> Result<Vec<IpAddr>> {
// Unfortunately systemd-resolved does not have a machine-readable
// text output for this command: <https://github.com/systemd/systemd/issues/29755>
//
// The officially supported way is probably to use D-Bus.
let output = std::process::Command::new("resolvectl")
.arg("dns")
.output()
.context("Failed to run `resolvectl dns` and read output")?;
if !output.status.success() {
anyhow::bail!("`resolvectl dns` returned non-zero exit code");
}
let output = String::from_utf8(output.stdout).context("`resolvectl` output was not UTF-8")?;
Ok(parse_resolvectl_output(&output))
}
/// Parses the text output of `resolvectl dns`
///
/// Cannot fail. If the parsing code is wrong, the IP address vec will just be incomplete.
fn parse_resolvectl_output(s: &str) -> Vec<IpAddr> {
s.lines()
.flat_map(|line| line.split(' '))
.filter_map(|word| IpAddr::from_str(word).ok())
.collect()
}
/// The path for our Unix Domain Socket
///
/// Docker keeps theirs in `/run` and also appears to use filesystem permissions
@@ -215,6 +153,10 @@ impl IpcServer {
}
}
pub(crate) fn notify_service_controller() -> Result<()> {
Ok(sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?)
}
/// Platform-specific setup needed for connlib
///
/// On Linux this does nothing
@@ -222,36 +164,3 @@ impl IpcServer {
pub(crate) fn setup_before_connlib() -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use std::net::IpAddr;
#[test]
fn parse_resolvectl_output() {
let cases = [
// WSL
(
r"Global: 172.24.80.1
Link 2 (eth0):
Link 3 (docker0):
Link 24 (br-fc0b71997a3c):
Link 25 (br-0c129dafb204):
Link 26 (br-e67e83b19dce):
",
[IpAddr::from([172, 24, 80, 1])],
),
// Ubuntu 20.04
(
r"Global:
Link 2 (enp0s3): 192.168.1.1",
[IpAddr::from([192, 168, 1, 1])],
),
];
for (i, (input, expected)) in cases.iter().enumerate() {
let actual = super::parse_resolvectl_output(input);
assert_eq!(actual, expected, "Case {i} failed");
}
}
}

View File

@@ -10,7 +10,6 @@ use connlib_client_shared::file_logger;
use connlib_shared::BUNDLE_ID;
use std::{
ffi::{c_void, OsString},
net::IpAddr,
path::{Path, PathBuf},
str::FromStr,
time::Duration,
@@ -102,7 +101,7 @@ fn fallible_windows_service_run(arguments: Vec<OsString>) -> Result<()> {
// Fixes <https://github.com/firezone/firezone/issues/4899>,
// DNS rules persisting after reboot
connlib_shared::deactivate_dns_control().ok();
crate::dns_control::deactivate().ok();
let ipc_task = rt.spawn(super::ipc_listen());
let ipc_task_ah = ipc_task.abort_handle();
@@ -242,22 +241,6 @@ pub fn pipe_path() -> String {
named_pipe_path(&format!("{BUNDLE_ID}.ipc_service"))
}
pub fn system_resolvers() -> Result<Vec<IpAddr>> {
let resolvers = ipconfig::get_adapters()?
.iter()
.flat_map(|adapter| adapter.dns_servers())
.filter(|ip| match ip {
IpAddr::V4(_) => true,
// Filter out bogus DNS resolvers on my dev laptop that start with fec0:
IpAddr::V6(ip) => !ip.octets().starts_with(&[0xfe, 0xc0]),
})
.copied()
.collect();
// This is private, so keep it at `debug` or `trace`
tracing::debug!(?resolvers);
Ok(resolvers)
}
/// Returns a valid name for a Windows named pipe
///
/// # Arguments
@@ -267,6 +250,14 @@ pub fn named_pipe_path(id: &str) -> String {
format!(r"\\.\pipe\{}", id)
}
// Does nothing on Windows. On Linux this notifies systemd that we're ready.
// When we eventually have a system service for the Windows Headless Client,
// this could notify the Windows service controller too.
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn notify_service_controller() -> Result<()> {
Ok(())
}
pub(crate) fn setup_before_connlib() -> Result<()> {
wintun_install::ensure_dll()?;
Ok(())

View File

@@ -41,7 +41,6 @@ pub(crate) fn ensure_dll() -> Result<PathBuf, Error> {
let dir = path.parent().ok_or(Error::DllPathInvalid)?;
std::fs::create_dir_all(dir).map_err(|_| Error::CreateDirAll)?;
// TODO: This log never shows up because `tracing` isn't started when we install wintun.dll
tracing::info!(?path, "wintun.dll path");
// This hash check is not meant to protect against attacks. It only lets us skip redundant disk writes, and it updates the DLL if needed.