diff --git a/crates/tsunagi-core/src/agent/network.rs b/crates/tsunagi-core/src/agent/network.rs index e39cf08..9725d6f 100644 --- a/crates/tsunagi-core/src/agent/network.rs +++ b/crates/tsunagi-core/src/agent/network.rs @@ -578,6 +578,13 @@ impl Runtime { message, reply, } => { + if matches!(&message, ControlMessage::Transport { .. }) + && let Some(interface) = self.params.interface.clone() + { + // Locally bound pair sockets must be protected before their + // private offer/accept invites the first inbound datagram. + interface.sync_exit_rules().await; + } let _ = reply.send(self.send_to(peer, message)); } NetCommand::Broadcast { message, reply } => { @@ -871,6 +878,9 @@ impl Runtime { // Last, so it carries what this round learned. self.introduce_peers(); self.refresh_underlay().await; + if let Some(interface) = self.params.interface.clone() { + interface.sync_exit_rules().await; + } } /// Hands the interface the underlay destinations this network depends diff --git a/crates/tsunagi-core/src/overlay/hostrules/exit.rs b/crates/tsunagi-core/src/overlay/hostrules/exit.rs index 313703d..96addd6 100644 --- a/crates/tsunagi-core/src/overlay/hostrules/exit.rs +++ b/crates/tsunagi-core/src/overlay/hostrules/exit.rs @@ -84,6 +84,11 @@ pub trait ExitHostRules: Send + Sync + std::fmt::Debug + 'static { /// startup to take away what a crashed run left. fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>; + /// Refreshes volatile local socket protection without replacing exit routes. + fn refresh<'a>(&'a self, _plan: &'a ExitHostPlan) -> BoxFuture<'a, Option> { + Box::pin(async { None }) + } + /// Whether using an exit node on this host needs the underlay addresses /// the agent talks to, because the host cannot exempt the agent's own /// traffic from the default route by user id (Windows). The default is no. diff --git a/crates/tsunagi-core/src/overlay/hostrules/linux.rs b/crates/tsunagi-core/src/overlay/hostrules/linux.rs index f2bcb3e..97eeeed 100644 --- a/crates/tsunagi-core/src/overlay/hostrules/linux.rs +++ b/crates/tsunagi-core/src/overlay/hostrules/linux.rs @@ -33,12 +33,13 @@ const CHAIN: &str = "INPUT"; /// Fixed places `iptables` is installed. Never `PATH`: the agent is /// privileged and a directory a user controls must not decide what it runs. -const IPTABLES_DIRECTORIES: [&str; 5] = [ +const IPTABLES_DIRECTORIES: [&str; 6] = [ "/usr/sbin/iptables", "/sbin/iptables", "/usr/bin/iptables", "/bin/iptables", "/usr/local/sbin/iptables", + "/run/current-system/sw/bin/iptables", ]; /// The first candidate that exists. @@ -328,7 +329,23 @@ fn clear_firewall(run: Run<'_>, interface: &str) { /// Runs the real `iptables`. pub(super) fn run_iptables(args: &[String]) -> Ran { - let Some(program) = pick_iptables(|path| std::path::Path::new(path).is_file()) else { + run_firewall(false, args) +} + +pub(super) fn run_firewall(ipv6: bool, args: &[String]) -> Ran { + let v6_paths: Vec<_> = IPTABLES_DIRECTORIES + .iter() + .map(|p| p.replace("iptables", "ip6tables")) + .collect(); + let v6_program = v6_paths + .iter() + .find(|path| std::path::Path::new(path).is_file()); + let program = if ipv6 { + v6_program.map(String::as_str) + } else { + pick_iptables(|path| std::path::Path::new(path).is_file()) + }; + let Some(program) = program else { return Ran::Missing; }; match std::process::Command::new(program).args(args).output() { @@ -350,11 +367,17 @@ pub(super) fn run_iptables(args: &[String]) -> Ran { #[derive(Debug)] pub struct LinuxHostRules { pub(super) route: RouteHandle, + /// Interface, sorted socket snapshot and whether exit routes committed. + pub(super) underlay_sockets: + std::sync::Mutex, bool)>>, } impl LinuxHostRules { pub(crate) fn new(route: RouteHandle) -> Self { - Self { route } + Self { + route, + underlay_sockets: std::sync::Mutex::new(None), + } } async fn set_route(&self, interface: String, source: Ipv4Addr) -> RuleOutcome { diff --git a/crates/tsunagi-core/src/overlay/hostrules/linux_exit.rs b/crates/tsunagi-core/src/overlay/hostrules/linux_exit.rs index 9a7ba96..c0dad7d 100644 --- a/crates/tsunagi-core/src/overlay/hostrules/linux_exit.rs +++ b/crates/tsunagi-core/src/overlay/hostrules/linux_exit.rs @@ -25,18 +25,19 @@ //! //! ```text //! table 0x7473: default dev -//! 5280: from all uidrange - lookup main +//! 5205: from all fwmark 0x40000000/0x40000000 lookup main +//! 5206: from all uidrange - lookup main //! 5290: from all lookup main suppress_prefixlength 0 //! 5300: from all lookup 0x7473 //! ``` //! -//! The first keeps the agent's *own* sockets off the tunnel: its QUIC, relay, +//! The first two keep the agent's *own* sockets and marked replies off the tunnel: its QUIC, relay, //! DHT and DNS traffic would otherwise be routed into the interface it is -//! carrying, a loop. The second lets every specific route — the LAN, another +//! carrying, a loop. The specific-route rule lets the LAN, another //! VPN, a tunnel to somewhere else — keep working, because only the default -//! route is suppressed. The priorities sit after Tailscale's 5270. +//! route is suppressed. Mark/UID bypass precedes Tailscale; application rules follow it. //! -//! The first rule exempts a *user id*, so it is only right when the agent has +//! The UID rule exempts a *user id*, so it is only right when the agent has //! one of its own, as the packaged service does. An agent started from a login //! session would take that user's own applications out of the tunnel with it, //! and the exit node would silently do nothing for them. That is refused, not @@ -48,9 +49,10 @@ use crate::state::Ipv4Range; use super::RuleOutcome; use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix}; use super::linux::{ - LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_iptables, same_rule, - split_rule_line, + LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_firewall, run_iptables, + same_rule, split_rule_line, }; +use super::linux_exit_mark; /// The chains the offering rules live in: `(table, chain)`. const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")]; @@ -377,6 +379,32 @@ fn read_sysctl(path: &str) -> Option { std::fs::read_to_string(path).ok() } +fn check_kernel_rpf(overlay: &str) -> Result<(), String> { + let directory = "/proc/sys/net/ipv4/conf"; + let value = |name: &str, setting: &str| -> Result { + read_sysctl(&format!("{directory}/{name}/{setting}")) + .and_then(|s| s.trim().parse().ok()) + .ok_or_else(|| format!("cannot check {name}/{setting} before installing exit routes")) + }; + let mut interfaces = Vec::new(); + for entry in std::fs::read_dir(directory).map_err(|err| err.to_string())? { + let entry = entry.map_err(|err| err.to_string())?; + let name = entry.file_name().to_string_lossy().into_owned(); + if name != "all" && name != "default" && name != "lo" && name != overlay { + interfaces.push(( + name.clone(), + value(&name, "rp_filter")?, + value(&name, "src_valid_mark")?, + )); + } + } + linux_exit_mark::check_kernel_rpf( + value("all", "rp_filter")?, + value("all", "src_valid_mark")?, + &interfaces, + ) +} + /// The effective user id from the text of `/proc/self/status`. fn parse_euid(status: &str) -> Option { status @@ -417,7 +445,7 @@ impl LinuxHostRules { let xdg = std::env::var("XDG_RUNTIME_DIR").ok(); if let Some(reason) = client_refusal(xdg.as_deref()) { // Whatever an earlier run left must not stay in force. - self.clear_client().await; + let _ = self.clear_client(interface).await; return RuleOutcome::Failed(reason); } let Some(euid) = std::fs::read_to_string("/proc/self/status") @@ -426,24 +454,147 @@ impl LinuxHostRules { else { return RuleOutcome::Failed("cannot tell which user this agent runs as".to_string()); }; + let installed = self + .underlay_sockets + .lock() + .unwrap_or_else(|p| p.into_inner()) + .as_ref() + .is_some_and(|(name, _, routed)| name == interface && *routed); + if installed { + // A failed socket refresh must never remove an active exit route. + return self + .refresh_sockets(interface) + .await + .unwrap_or(RuleOutcome::Applied); + } + if let Err(reason) = self.clear_client(interface).await { + return RuleOutcome::Failed(reason); + } + let name = interface.to_string(); + let prepared = Self::blocking(move || { + check_kernel_rpf(&name)?; + linux_exit_mark::install( + &run_firewall, + &name, + euid, + std::path::Path::new("/proc/sys/net/ipv6").is_dir(), + ) + }) + .await; + match prepared { + Ok(Ok(sockets)) => { + *self + .underlay_sockets + .lock() + .unwrap_or_else(|p| p.into_inner()) = Some((interface.into(), sockets, false)); + } + Ok(Err(reason)) | Err(reason) => return RuleOutcome::Failed(reason), + } let route = self.route.clone(); - let interface = interface.to_string(); - match Self::blocking(move || route.set_exit_client(&interface, euid)).await { + let name = interface.to_string(); + let outcome = match Self::blocking(move || route.set_exit_client(&name, euid)).await { Ok(Ok(())) => RuleOutcome::Applied, Ok(Err(err)) => RuleOutcome::Failed(err.to_string()), Err(reason) => RuleOutcome::Failed(reason), + }; + if let RuleOutcome::Failed(reason) = outcome { + let reason = match self.clear_client(interface).await { + Ok(()) => reason, + Err(cleanup) => format!("{reason}; cannot roll back exit rules: {cleanup}"), + }; + RuleOutcome::Failed(reason) + } else { + if let Some((_, _, routed)) = self + .underlay_sockets + .lock() + .unwrap_or_else(|p| p.into_inner()) + .as_mut() + { + *routed = true; + } + outcome } } - async fn clear_client(&self) { + async fn clear_client(&self, interface: &str) -> Result<(), String> { let route = self.route.clone(); - if let Ok(Err(err)) = Self::blocking(move || route.clear_exit_client()).await { - tracing::warn!(%err, "cannot remove the exit node routing rules"); + Self::blocking(move || route.clear_exit_client()) + .await? + .map_err(|err| err.to_string())?; + // Route removal must precede removing reply protection. + let name = interface.to_string(); + Self::blocking(move || { + linux_exit_mark::clear( + &run_firewall, + &name, + std::path::Path::new("/proc/sys/net/ipv6").is_dir(), + ) + }) + .await??; + *self + .underlay_sockets + .lock() + .unwrap_or_else(|p| p.into_inner()) = None; + Ok(()) + } + + async fn refresh_sockets(&self, interface: &str) -> Option { + let previous = self + .underlay_sockets + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + if previous + .as_ref() + .is_none_or(|(name, _, routed)| name != interface || !routed) + { + return None; + } + let name = interface.to_string(); + let work_name = name.clone(); + let result = Self::blocking(move || { + let uid = std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| parse_euid(&s)) + .ok_or("cannot read agent UID")?; + check_kernel_rpf(&work_name)?; + let v6 = std::path::Path::new("/proc/sys/net/ipv6").is_dir(); + let sockets = linux_exit_mark::local_sockets(uid, v6)?; + if previous + .as_ref() + .is_some_and(|(name, old, _)| name == &work_name && old == &sockets) + { + return Ok(None); + } + linux_exit_mark::refresh(&run_firewall, &work_name, v6, &sockets)?; + Ok(Some(sockets)) + }) + .await; + match result { + Ok(Ok(None)) => None, + Ok(Ok(Some(sockets))) => { + *self + .underlay_sockets + .lock() + .unwrap_or_else(|p| p.into_inner()) = Some((name, sockets, true)); + Some(RuleOutcome::Applied) + } + Ok(Err(reason)) | Err(reason) => Some(RuleOutcome::Failed(reason)), } } } impl ExitHostRules for LinuxHostRules { + fn refresh<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, Option> { + Box::pin(async move { + if plan.client { + self.refresh_sockets(&plan.interface).await + } else { + None + } + }) + } + fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> { Box::pin(async move { let interface = plan.interface.clone(); @@ -467,7 +618,9 @@ impl ExitHostRules for LinuxHostRules { let client = if plan.client { Some(self.apply_client(&interface).await) } else { - self.clear_client().await; + if let Err(reason) = self.clear_client(&interface).await { + tracing::warn!(%reason, "cannot remove exit-client rules"); + } None }; ExitHostReport { @@ -482,7 +635,9 @@ impl ExitHostRules for LinuxHostRules { Box::pin(async move { let name = interface.to_string(); let _ = Self::blocking(move || clear_offer(&run_iptables, &name)).await; - self.clear_client().await; + if let Err(reason) = self.clear_client(interface).await { + tracing::warn!(%reason, "cannot remove exit-client rules"); + } }) } } diff --git a/crates/tsunagi-core/src/overlay/hostrules/linux_exit_kernel.rs b/crates/tsunagi-core/src/overlay/hostrules/linux_exit_kernel.rs new file mode 100644 index 0000000..44bb856 --- /dev/null +++ b/crates/tsunagi-core/src/overlay/hostrules/linux_exit_kernel.rs @@ -0,0 +1,439 @@ +//! Privileged routing regression, isolated from the host in named namespaces. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream, UdpSocket}; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command}; +use std::time::Duration; + +use super::linux::run_firewall; +use super::linux_exit_mark::{clear, install, local_sockets, refresh}; +use crate::overlay::NetlinkProvisioner; + +const TEST: &str = "overlay::hostrules::linux_exit_kernel::strict_firewall_preserves_underlay_and_application_exit_routing"; +const ROLE: &str = "TSUNAGI_EXIT_KERNEL_TEST_ROLE"; + +fn ip(arguments: &[&str]) -> String { + let result = Command::new("ip").args(arguments).output().unwrap(); + assert!( + result.status.success(), + "ip {arguments:?}: {}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8_lossy(&result.stdout).into_owned() +} + +struct Namespace(String); +impl Drop for Namespace { + fn drop(&mut self) { + let _ = Command::new("ip").args(["netns", "del", &self.0]).status(); + } +} +struct Echo(Child); +impl Drop for Echo { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn probes() { + let should_pass = std::env::var("TSUNAGI_EXIT_KERNEL_EXPECT").unwrap() == "pass"; + for address in ["198.51.100.2:42000", "[2001:db8:2::2]:42000"] { + let address: SocketAddr = address.parse().unwrap(); + let socket = UdpSocket::bind(if address.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }) + .unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + socket.send_to(b"rpf", address).unwrap(); + let mut packet = [0u8; 3]; + assert_eq!( + socket.recv(&mut packet).is_ok(), + should_pass, + "UDP {address}" + ); + if should_pass { + assert_eq!(&packet, b"rpf"); + } + let connected = TcpStream::connect_timeout(&address, Duration::from_secs(1)); + assert_eq!(connected.is_ok(), should_pass, "TCP {address}"); + if let Ok(mut stream) = connected { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + stream.write_all(b"rpf").unwrap(); + stream.read_exact(&mut packet).unwrap(); + assert_eq!(&packet, b"rpf"); + } + } +} + +fn run_probes(expect: &str) { + let result = Command::new(std::env::current_exe().unwrap()) + .args(["--ignored", "--exact", TEST, "--nocapture"]) + .env(ROLE, "probe") + .env("TSUNAGI_EXIT_KERNEL_EXPECT", expect) + .uid(990) + .gid(990) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); +} + +fn passive_listener() { + let listeners: Vec<_> = ["192.0.2.1:42010", "[2001:db8:1::1]:42010"] + .into_iter() + .map(|address| TcpListener::bind(address).unwrap()) + .collect(); + println!("PASSIVE_READY"); + let tasks: Vec<_> = listeners + .into_iter() + .map(|listener| { + std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = [0; 3]; + socket.read_exact(&mut bytes).unwrap(); + socket.write_all(&bytes).unwrap(); + }) + }) + .collect(); + for task in tasks { + task.join().unwrap(); + } +} + +fn udp_listener() { + let sockets: Vec<_> = ["192.0.2.1:42011", "[2001:db8:1::1]:42011"] + .into_iter() + .map(|a| UdpSocket::bind(a).unwrap()) + .collect(); + println!("PASSIVE_READY"); + let tasks: Vec<_> = sockets + .into_iter() + .map(|socket| { + std::thread::spawn(move || { + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut bytes = [0; 3]; + let (len, peer) = socket.recv_from(&mut bytes).unwrap(); + socket.send_to(&bytes[..len], peer).unwrap(); + }) + }) + .collect(); + for task in tasks { + task.join().unwrap(); + } +} + +fn spawn_listener(role: &str) -> Echo { + let process = Command::new(std::env::current_exe().unwrap()) + .args(["--ignored", "--exact", TEST, "--nocapture"]) + .env(ROLE, role) + .uid(990) + .gid(990) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut child = Echo(process); + let mut output = BufReader::new(child.0.stdout.take().unwrap()); + loop { + let mut line = String::new(); + assert_ne!( + output.read_line(&mut line).unwrap(), + 0, + "listener exited before readiness" + ); + if line.contains("PASSIVE_READY") { + // Keep the reader open for libtest's final status after the socket + // worker finishes; closing it here makes success a broken pipe. + child.0.stdout = Some(output.into_inner()); + return child; + } + } +} + +fn client() { + for setting in ["rp_filter", "src_valid_mark"] { + std::fs::write(format!("/proc/sys/net/ipv4/conf/all/{setting}"), b"1").unwrap(); + } + std::fs::write("/proc/sys/net/ipv4/conf/rpf-c/rp_filter", b"1").unwrap(); + for family in [false, true] { + let args: Vec<_> = [ + "-t", + "mangle", + "-A", + "PREROUTING", + "-m", + "rpfilter", + "--invert", + "--validmark", + "-j", + "DROP", + ] + .into_iter() + .map(str::to_string) + .collect(); + let result = run_firewall(family, &args); + assert!( + matches!(result, super::linux::Ran::Exited { success: true, .. }), + "{result:?}" + ); + } + let provisioner = NetlinkProvisioner::new().unwrap(); + let route = provisioner.host_rules().route; + // Model Tailscale's earlier lookup, without running another VPN daemon. + ip(&["-4", "rule", "add", "priority", "5270", "table", "52"]); + ip(&[ + "-4", "route", "add", "default", "dev", "tsun0", "table", "52", + ]); + ip(&["-6", "rule", "add", "priority", "5270", "table", "52"]); + ip(&[ + "-6", + "route", + "add", + "unreachable", + "default", + "table", + "52", + ]); + // A foreign VPN must leave neighbour discovery and connected LAN paths + // usable; otherwise the fixture itself cannot resolve its uplink neighbour. + for prefix in ["fe80::/64", "2001:db8:1::/64"] { + ip(&["-6", "route", "add", "throw", prefix, "table", "52"]); + } + let mut passive = spawn_listener("listener"); + route.set_exit_client("tsun0", 990).unwrap(); + // uid bypass alone cannot protect reverse lookups, reproducing the bug. + run_probes("blocked"); + install(&run_firewall, "tsun0", 990, true).unwrap(); + run_probes("pass"); + // Pair transports may bind fresh UDP ports after exit activation. + let mut dynamic_udp = spawn_listener("udp-listener"); + refresh( + &run_firewall, + "tsun0", + true, + &local_sockets(990, true).unwrap(), + ) + .unwrap(); + let server = std::env::var("TSUNAGI_EXIT_KERNEL_SERVER").unwrap(); + let incoming = Command::new("ip").args(["netns", "exec", &server, "python3", "-c", r#"import socket +for family,source,target in [(socket.AF_INET,'198.51.100.2','192.0.2.1'),(socket.AF_INET6,'2001:db8:2::2','2001:db8:1::1')]: + for kind,port in [(socket.SOCK_STREAM,42010),(socket.SOCK_DGRAM,42011)]: + s=socket.socket(family,kind); s.settimeout(2); s.bind((source,0)); s.connect((target,port)); s.sendall(b'rpf'); assert s.recv(3)==b'rpf'; s.close() +"#]).output().unwrap(); + assert!( + incoming.status.success(), + "first inbound TCP/UDP must be protected: {}", + String::from_utf8_lossy(&incoming.stderr) + ); + assert!(passive.0.wait().unwrap().success()); + assert!(dynamic_udp.0.wait().unwrap().success()); + // Repeated apply remains usable, and does not redirect application sockets. + install(&run_firewall, "tsun0", 990, true).unwrap(); + run_probes("pass"); + assert!(ip(&["-4", "route", "get", "198.51.100.2", "uid", "991"]).contains("dev tsun0")); + let blocked = Command::new("ip") + .args(["-6", "route", "get", "2001:db8:2::2", "uid", "991"]) + .output() + .unwrap(); + assert!( + !blocked.status.success(), + "application IPv6 must stay blocked" + ); + route.clear_exit_client().unwrap(); + clear(&run_firewall, "tsun0", true).unwrap(); + for family in ["-4", "-6"] { + assert!( + ip(&[family, "rule", "show"]).contains("5270:"), + "foreign routing rules survive cleanup" + ); + ip(&[family, "rule", "del", "priority", "5270"]); + } + assert!(ip(&["-4", "route", "get", "198.51.100.2", "uid", "991"]).contains("dev rpf-c")); + assert!(ip(&["-6", "route", "get", "2001:db8:2::2", "uid", "991"]).contains("dev rpf-c")); + for family in [false, true] { + let result = run_firewall(family, &["-t".into(), "mangle".into(), "-S".into()]); + if let super::linux::Ran::Exited { stdout, .. } = result { + assert!(!stdout.contains("tsunagi-exit-underlay:")); + assert!(!stdout.contains("tsunagi-exit-sockets:")); + assert!( + stdout.contains("rpfilter"), + "strict host filtering is retained" + ); + } + } +} + +#[test] +#[ignore = "needs root, iproute2, iptables/ip6tables and Python; changes only temporary namespaces"] +fn strict_firewall_preserves_underlay_and_application_exit_routing() { + match std::env::var(ROLE).as_deref() { + Ok("probe") => { + probes(); + return; + } + Ok("client") => { + client(); + return; + } + Ok("listener") => { + passive_listener(); + return; + } + Ok("udp-listener") => { + udp_listener(); + return; + } + _ => {} + } + let suffix = std::process::id(); + let client_name = format!("tsng-rpf-c-{suffix}"); + let server_name = format!("tsng-rpf-s-{suffix}"); + ip(&["netns", "add", &client_name]); + let _client = Namespace(client_name.clone()); + ip(&["netns", "add", &server_name]); + let _server = Namespace(server_name.clone()); + ip(&[ + "link", + "add", + "rpf-c", + "netns", + &client_name, + "type", + "veth", + "peer", + "name", + "rpf-s", + "netns", + &server_name, + ]); + for (namespace, interface, v4, v6) in [ + (&client_name, "rpf-c", "192.0.2.1/24", "2001:db8:1::1/64"), + (&server_name, "rpf-s", "192.0.2.2/24", "2001:db8:1::2/64"), + ] { + ip(&["-n", namespace, "link", "set", "lo", "up"]); + ip(&["-n", namespace, "addr", "add", v4, "dev", interface]); + ip(&[ + "-n", namespace, "-6", "addr", "add", v6, "dev", interface, "nodad", + ]); + ip(&["-n", namespace, "link", "set", interface, "up"]); + } + ip(&[ + "-n", + &server_name, + "addr", + "add", + "198.51.100.2/32", + "dev", + "lo", + ]); + ip(&[ + "-n", + &server_name, + "-6", + "addr", + "add", + "2001:db8:2::2/128", + "dev", + "lo", + "nodad", + ]); + ip(&[ + "-n", + &client_name, + "route", + "add", + "default", + "via", + "192.0.2.2", + ]); + ip(&[ + "-n", + &client_name, + "-6", + "route", + "add", + "default", + "via", + "2001:db8:1::2", + ]); + ip(&["-n", &client_name, "link", "add", "tsun0", "type", "dummy"]); + ip(&[ + "-n", + &client_name, + "addr", + "add", + "10.13.37.1/24", + "dev", + "tsun0", + ]); + ip(&["-n", &client_name, "link", "set", "tsun0", "up"]); + let echo_code = r#"import socket,selectors,sys +selector=selectors.DefaultSelector() +for family in [socket.AF_INET,socket.AF_INET6]: + for kind in [socket.SOCK_DGRAM,socket.SOCK_STREAM]: + sock=socket.socket(family,kind) + if family==socket.AF_INET6: sock.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1) + sock.bind(('198.51.100.2' if family==socket.AF_INET else '2001:db8:2::2',42000)) + if kind==socket.SOCK_STREAM: sock.listen() + selector.register(sock,selectors.EVENT_READ,kind) +print('ready',flush=True) +while True: + for key,_ in selector.select(): + if key.data==socket.SOCK_DGRAM: + data,address=key.fileobj.recvfrom(128); key.fileobj.sendto(data,address) + else: + conn,_=key.fileobj.accept(); conn.settimeout(2) + try: conn.sendall(conn.recv(128)) + finally: conn.close() +"#; + let child = Command::new("ip") + .args([ + "netns", + "exec", + &server_name, + "python3", + "-u", + "-c", + echo_code, + ]) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut echo = Echo(child); + let mut ready = String::new(); + BufReader::new(echo.0.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + assert_eq!(ready.trim(), "ready"); + let result = Command::new("ip") + .args(["netns", "exec", &client_name]) + .arg(std::env::current_exe().unwrap()) + .args(["--ignored", "--exact", TEST, "--nocapture"]) + .env(ROLE, "client") + .env("TSUNAGI_EXIT_KERNEL_SERVER", &server_name) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); +} diff --git a/crates/tsunagi-core/src/overlay/hostrules/linux_exit_mark.rs b/crates/tsunagi-core/src/overlay/hostrules/linux_exit_mark.rs new file mode 100644 index 0000000..9eff582 --- /dev/null +++ b/crates/tsunagi-core/src/overlay/hostrules/linux_exit_mark.rs @@ -0,0 +1,535 @@ +//! Own-bit connection marks for exit-client underlay traffic. +//! Reply marks are restored before host reverse-path filtering, never accepted +//! around it. No peer address becomes a host route or firewall argument. + +use super::linux::{Ran, failure, locked, split_rule_line}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +/// Outside Tailscale's 0xff0000 mask and ordinary low-bit WireGuard marks. +pub(crate) const UNDERLAY_MARK: u32 = 0x4000_0000; +pub(crate) const UNDERLAY_MASK: u32 = UNDERLAY_MARK; +const MARK: &str = "0x40000000/0x40000000"; +const MASK: &str = "0x40000000"; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct Listener { + address: IpAddr, + port: u16, + tcp: bool, +} + +fn parse_listeners(listing: &str, uid: u32, v6: bool, tcp: bool) -> Result, String> { + let mut result = Vec::new(); + for line in listing.lines().skip(1) { + let fields: Vec<_> = line.split_whitespace().collect(); + if (tcp && fields.get(3) != Some(&"0A")) + || fields.get(7).and_then(|s| s.parse::().ok()) != Some(uid) + { + continue; + } + let (address, port) = fields + .get(1) + .and_then(|s| s.split_once(':')) + .ok_or("malformed local socket")?; + let port = u16::from_str_radix(port, 16).map_err(|e| e.to_string())?; + let address = if v6 { + let mut bytes = [0; 16]; + for (word, chunk) in bytes.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let hex = address + .get(word * 8..word * 8 + 8) + .ok_or("malformed local IPv6 listener")?; + chunk.copy_from_slice( + &u32::from_str_radix(hex, 16) + .map_err(|e| e.to_string())? + .to_ne_bytes(), + ); + } + IpAddr::V6(Ipv6Addr::from(bytes)) + } else { + IpAddr::V4(Ipv4Addr::from( + u32::from_str_radix(address, 16) + .map_err(|e| e.to_string())? + .to_ne_bytes(), + )) + }; + if result.len() == 128 { + return Err("too many local sockets for exit protection".into()); + } + if port != 0 { + result.push(Listener { address, port, tcp }); + } + } + Ok(result) +} + +pub(super) fn local_sockets(uid: u32, v6: bool) -> Result, String> { + let mut listeners = Vec::new(); + for (protocol, tcp) in [("tcp", true), ("udp", false)] { + for family in [false, true].into_iter().filter(|f| !f || v6) { + let path = format!("/proc/net/{protocol}{}", if family { "6" } else { "" }); + listeners.extend(parse_listeners( + &std::fs::read_to_string(path).map_err(|e| e.to_string())?, + uid, + family, + tcp, + )?); + } + } + listeners.sort(); + listeners.dedup(); + Ok(listeners) +} + +fn listener_rules(interface: &str, listener: &Listener) -> Vec> { + let mut base = words(&[ + "!", + "-i", + interface, + "-p", + if listener.tcp { "tcp" } else { "udp" }, + "--dport", + &listener.port.to_string(), + ]); + if !listener.address.is_unspecified() { + base.extend(words(&["-d", &listener.address.to_string()])); + } + // Match an actual local socket, not just a destination port. Listening + // sockets need protection on the first packet, before any outbound connmark. + base.extend(words(&[ + "-m", + "socket", + "--nowildcard", + "-m", + "comment", + "--comment", + &listener_tag(interface), + ])); + let save = base + .iter() + .cloned() + .chain(words(&[ + "-m", + "mark", + "--mark", + MARK, + "-j", + "CONNMARK", + "--save-mark", + "--nfmask", + MASK, + "--ctmask", + MASK, + ])) + .collect(); + let mark = base + .into_iter() + .chain(words(&["-j", "MARK", "--set-xmark", MARK])) + .collect(); + vec![save, mark] +} +pub(super) type RunFamily<'a> = &'a dyn Fn(bool, &[String]) -> Ran; + +fn tag(interface: &str) -> String { + format!("tsunagi-exit-underlay:{interface}") +} + +fn listener_tag(interface: &str) -> String { + format!("tsunagi-exit-sockets:{interface}") +} + +fn words(values: &[&str]) -> Vec { + values.iter().map(|s| (*s).into()).collect() +} + +fn rules(interface: &str, uid: u32) -> Vec<(&'static str, Vec)> { + let tag = tag(interface); + let uid = uid.to_string(); + // Insert at position one in this order: final OUTPUT order is MARK, SAVE. + vec![ + ( + "PREROUTING", + words(&[ + "!", + "-i", + interface, + "-m", + "connmark", + "--mark", + MARK, + "-m", + "comment", + "--comment", + &tag, + "-j", + "CONNMARK", + "--restore-mark", + "--nfmask", + MASK, + "--ctmask", + MASK, + ]), + ), + ( + "OUTPUT", + words(&[ + "-m", + "owner", + "--uid-owner", + &uid, + "-m", + "mark", + "--mark", + MARK, + "-m", + "comment", + "--comment", + &tag, + "-j", + "CONNMARK", + "--save-mark", + "--nfmask", + MASK, + "--ctmask", + MASK, + ]), + ), + ( + "OUTPUT", + words(&[ + "-m", + "owner", + "--uid-owner", + &uid, + "-m", + "comment", + "--comment", + &tag, + "-j", + "MARK", + "--set-xmark", + MARK, + ]), + ), + ] +} + +fn execute(run: RunFamily<'_>, v6: bool, args: Vec) -> Result { + match run(v6, &locked(args)) { + Ran::Exited { + success: true, + stdout, + .. + } => Ok(stdout), + Ran::Exited { stderr, .. } => Err(failure(&stderr)), + Ran::Missing => Err(format!( + "{} is required for exit-node connection marks", + if v6 { "ip6tables" } else { "iptables" } + )), + } +} + +/// Removes only exact interface tags, retaining other agents and host rules. +pub(super) fn clear(run: RunFamily<'_>, interface: &str, v6: bool) -> Result<(), String> { + clear_tags(run, interface, v6, false) +} + +fn clear_tags( + run: RunFamily<'_>, + interface: &str, + v6: bool, + sockets_only: bool, +) -> Result<(), String> { + let wanted = tag(interface); + let sockets = listener_tag(interface); + for family in [false, true].into_iter().filter(|family| !family || v6) { + let chains: &[&str] = if sockets_only { + &["PREROUTING"] + } else { + &["OUTPUT", "PREROUTING"] + }; + for chain in chains { + let listing = execute(run, family, words(&["-t", "mangle", "-S", chain]))?; + for line in listing.lines() { + let tokens = split_rule_line(line); + let [append, listed_chain, rest @ ..] = tokens.as_slice() else { + continue; + }; + if append == "-A" + && listed_chain == *chain + && rest.windows(2).any(|pair| { + pair[0] == "--comment" + && (pair[1] == sockets || (!sockets_only && pair[1] == wanted)) + }) + { + let args = words(&["-t", "mangle", "-D", chain]) + .into_iter() + .chain(rest.iter().cloned()) + .collect(); + execute(run, family, args)?; + } + } + } + } + Ok(()) +} + +/// Establishes protection before routing may redirect application traffic. +pub(super) fn install( + run: RunFamily<'_>, + interface: &str, + uid: u32, + v6: bool, +) -> Result, String> { + let listeners = local_sockets(uid, v6)?; + install_with_listeners(run, interface, uid, v6, &listeners)?; + Ok(listeners) +} + +fn install_with_listeners( + run: RunFamily<'_>, + interface: &str, + uid: u32, + v6: bool, + listeners: &[Listener], +) -> Result<(), String> { + clear(run, interface, v6)?; + let result = (|| { + for family in [false, true].into_iter().filter(|family| !family || v6) { + for (chain, spec) in rules(interface, uid) { + let args = words(&["-t", "mangle", "-I", chain, "1"]) + .into_iter() + .chain(spec) + .collect(); + execute(run, family, args)?; + } + for listener in listeners.iter().filter(|s| s.address.is_ipv6() == family) { + for spec in listener_rules(interface, listener) { + execute( + run, + family, + words(&["-t", "mangle", "-I", "PREROUTING", "1"]) + .into_iter() + .chain(spec) + .collect(), + )?; + } + } + } + Ok(()) + })(); + if result.is_err() + && let Err(cleanup) = clear(run, interface, v6) + { + return Err(format!( + "{}; cannot roll back connection marks: {cleanup}", + result.err().unwrap_or_default() + )); + } + result +} + +/// Updates passive protection without interrupting established connmark rules. +pub(super) fn refresh( + run: RunFamily<'_>, + interface: &str, + v6: bool, + listeners: &[Listener], +) -> Result<(), String> { + clear_tags(run, interface, v6, true)?; + for family in [false, true].into_iter().filter(|f| !f || v6) { + for listener in listeners.iter().filter(|s| s.address.is_ipv6() == family) { + for spec in listener_rules(interface, listener) { + execute( + run, + family, + words(&["-t", "mangle", "-I", "PREROUTING", "1"]) + .into_iter() + .chain(spec) + .collect(), + )?; + } + } + } + Ok(()) +} + +/// Kernel strict RPF must include the restored mark, independently of iptables. +pub(super) fn check_kernel_rpf( + all_rpf: u32, + all_valid: u32, + interfaces: &[(String, u32, u32)], +) -> Result<(), String> { + for (interface, rpf, valid) in interfaces { + if all_rpf.max(*rpf) == 1 && all_valid.max(*valid) == 0 { + return Err(format!( + "strict rp_filter on `{interface}` ignores connection marks; enable net.ipv4.conf.all.src_valid_mark=1 before using an exit node (rp_filter stays enabled)" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use super::*; + use std::cell::RefCell; + + #[test] + fn both_families_restore_before_firewall_and_preserve_foreign_mark_bits() { + let calls = RefCell::new(Vec::new()); + let run = |v6, args: &[String]| { + calls.borrow_mut().push((v6, args.to_vec())); + Ran::Exited { + success: true, + stdout: String::new(), + stderr: String::new(), + } + }; + install_with_listeners(&run, "tsun0", 990, true, &[]).unwrap(); + let calls = calls.borrow(); + for family in [false, true] { + let inserts: Vec<_> = calls + .iter() + .filter(|(v6, a)| *v6 == family && a.iter().any(|s| s == "-I")) + .map(|(_, a)| a.join(" ")) + .collect(); + assert_eq!(inserts.len(), 3); + assert!(inserts[0].contains("-I PREROUTING 1 ! -i tsun0 -m connmark")); + assert!(inserts[0].contains("--restore-mark --nfmask 0x40000000 --ctmask 0x40000000")); + assert!(inserts[1].contains("--uid-owner 990")); + assert!(inserts[1].contains("--save-mark")); + assert!(inserts[2].contains("--set-xmark 0x40000000/0x40000000")); + assert!( + !inserts + .iter() + .any(|s| s.contains("-j ACCEPT") || s.contains("-j RETURN")) + ); + } + assert_eq!(UNDERLAY_MASK & 0x00ff_0000, 0); + } + + #[test] + fn cleanup_retains_other_interfaces_and_rolls_back_partial_ipv6_installation() { + let calls = RefCell::new(Vec::new()); + let run = |v6, args: &[String]| { + calls.borrow_mut().push((v6, args.to_vec())); + let listing = "-A OUTPUT -m comment --comment tsunagi-exit-underlay:tsun0 -j MARK --set-xmark 0x40000000/0x40000000\n-A OUTPUT -m comment --comment tsunagi-exit-underlay:tsun1 -j MARK --set-xmark 0x40000000/0x40000000\n-A OUTPUT -m comment --comment unrelated -j RETURN\n"; + Ran::Exited { + success: !(v6 && args.iter().any(|s| s == "-I")), + stdout: if args.iter().any(|s| s == "-S") { + listing.into() + } else { + String::new() + }, + stderr: "injected IPv6 failure".into(), + } + }; + assert!(install_with_listeners(&run, "tsun0", 990, true, &[]).is_err()); + let calls = calls.borrow(); + let deletions: Vec<_> = calls + .iter() + .filter(|(_, a)| a.iter().any(|s| s == "-D")) + .collect(); + assert!( + deletions.len() >= 4, + "both initial and rollback cleanup run" + ); + assert!( + deletions + .iter() + .all(|(_, a)| a.iter().any(|s| s == "tsunagi-exit-underlay:tsun0")) + ); + } + + #[test] + fn kernel_strict_validation_requires_mark_awareness_without_weakening_rpf() { + let interfaces = vec![("eth0".into(), 1, 0)]; + assert!(check_kernel_rpf(0, 0, &interfaces).is_err()); + assert!(check_kernel_rpf(0, 1, &interfaces).is_ok()); + assert!(check_kernel_rpf(2, 0, &interfaces).is_ok()); + assert!(check_kernel_rpf(0, 0, &[("eth0".into(), 1, 1)]).is_ok()); + } + + #[test] + fn passive_tcp_protection_uses_only_locally_owned_listeners() { + let listing = "header\n0: 00000000:01BB 00000000:0000 0A 0:0 0:0 0 990 0 1\n1: 0100007F:1111 00000000:0000 0A 0:0 0:0 0 1000 0 2\n2: 0100007F:2222 00000000:0000 01 0:0 0:0 0 990 0 3\n"; + let listeners = parse_listeners(listing, 990, false, true).unwrap(); + assert_eq!( + listeners, + vec![Listener { + address: IpAddr::V4(Ipv4Addr::UNSPECIFIED), + port: 443, + tcp: true + }] + ); + let rules = listener_rules("tsun0", &listeners[0]); + assert!( + rules[0] + .join(" ") + .contains("-p tcp --dport 443 -m socket --nowildcard") + ); + assert!(rules[0].join(" ").contains("--save-mark")); + assert!(rules[1].join(" ").contains("--set-xmark")); + } + + #[test] + fn listener_parser_checks_family_encoding_and_bounds() { + let loopback = Ipv6Addr::LOCALHOST.octets(); + let hex: String = loopback + .as_chunks::<4>() + .0 + .iter() + .map(|bytes| format!("{:08X}", u32::from_ne_bytes(*bytes))) + .collect(); + let listing = format!("header\n0: {hex}:01BB 0:0 0A 0:0 0:0 0 990 0 1\n"); + assert_eq!( + parse_listeners(&listing, 990, true, true).unwrap(), + vec![Listener { + address: IpAddr::V6(Ipv6Addr::LOCALHOST), + port: 443, + tcp: true + }] + ); + assert!( + parse_listeners( + "header\n0: BAD:01BB 0:0 0A 0:0 0:0 0 990 0 1", + 990, + true, + true + ) + .is_err() + ); + let huge = format!( + "header\n{}", + "0: 00000000:01BB 0:0 0A 0:0 0:0 0 990 0 1\n".repeat(129) + ); + assert!(parse_listeners(&huge, 990, false, true).is_err()); + } + + #[test] + fn udp_refresh_preserves_general_connmark_protection_and_other_interfaces() { + let listing = "header\n0: 00000000:ABA0 0:0 07 0:0 0:0 0 990 0 1\n1: 00000000:0000 0:0 07 0:0 0:0 0 990 0 2\n"; + let sockets = parse_listeners(listing, 990, false, false).unwrap(); + assert_eq!(sockets.len(), 1); + assert!(!sockets[0].tcp); + let calls = RefCell::new(Vec::new()); + let run = |v6, args: &[String]| { + calls.borrow_mut().push((v6, args.join(" "))); + Ran::Exited { success: true, stdout: "-A PREROUTING -m comment --comment tsunagi-exit-underlay:tsun0 -j CONNMARK --restore-mark\n-A PREROUTING -m comment --comment tsunagi-exit-sockets:tsun0 -j MARK\n-A PREROUTING -m comment --comment tsunagi-exit-sockets:tsun1 -j MARK\n".into(), stderr: String::new() } + }; + refresh(&run, "tsun0", false, &sockets).unwrap(); + let calls = calls.borrow(); + assert!( + calls + .iter() + .any(|(_, a)| a.contains("-I PREROUTING 1") && a.contains("-p udp")) + ); + assert!( + calls + .iter() + .filter(|(_, a)| a.contains("-D")) + .all(|(_, a)| a.contains("tsunagi-exit-sockets:tsun0")) + ); + } +} diff --git a/crates/tsunagi-core/src/overlay/hostrules/mod.rs b/crates/tsunagi-core/src/overlay/hostrules/mod.rs index 4a53ffe..eb59f69 100644 --- a/crates/tsunagi-core/src/overlay/hostrules/mod.rs +++ b/crates/tsunagi-core/src/overlay/hostrules/mod.rs @@ -55,6 +55,10 @@ pub use exit::{ mod linux; #[cfg(all(feature = "tun-device", target_os = "linux"))] mod linux_exit; +#[cfg(all(test, feature = "tun-device", target_os = "linux"))] +mod linux_exit_kernel; +#[cfg(all(feature = "tun-device", target_os = "linux"))] +pub(in crate::overlay) mod linux_exit_mark; #[cfg(all(feature = "tun-device", target_os = "linux"))] pub use linux::LinuxHostRules; diff --git a/crates/tsunagi-core/src/overlay/interface.rs b/crates/tsunagi-core/src/overlay/interface.rs index 6ca55f5..5730a61 100644 --- a/crates/tsunagi-core/src/overlay/interface.rs +++ b/crates/tsunagi-core/src/overlay/interface.rs @@ -397,6 +397,14 @@ impl Interface { return; } if planned.as_ref() == Some(&wanted) && ok { + if let Some(outcome) = exit.refresh(&wanted).await { + if let RuleOutcome::Failed(reason) = &outcome { + tracing::warn!(interface = %self.name, %reason, "cannot refresh exit-client socket protection"); + } + if let Some(report) = self.exit_state().report.as_mut() { + report.client = Some(outcome); + } + } return; } let report = exit.apply(&wanted).await; diff --git a/crates/tsunagi-core/src/overlay/provision/linux.rs b/crates/tsunagi-core/src/overlay/provision/linux.rs index 6de00b8..052f56e 100644 --- a/crates/tsunagi-core/src/overlay/provision/linux.rs +++ b/crates/tsunagi-core/src/overlay/provision/linux.rs @@ -55,6 +55,7 @@ use crate::BoxFuture; use crate::overlay::OverlayError; use super::super::config::Cidr; +use super::super::hostrules::linux_exit_mark::{UNDERLAY_MARK, UNDERLAY_MASK}; use super::super::tun::{TunDevice, TunRequest}; use super::privilege::{NetAdmin, Privilege, probe_net_admin}; use super::{ @@ -641,8 +642,10 @@ async fn del_broadcast_route(name: &str) -> Result<(), OverlayError> { const EXIT_TABLE: u32 = 0x7473; /// The main table. const MAIN_TABLE: u32 = 254; -/// Where the exit rules sit, after Tailscale's 5270. -const EXIT_BYPASS_PRIORITY: u32 = 5280; +/// Protect marked underlay and initial socket routing before Tailscale's rules. +const EXIT_MARK_PRIORITY: u32 = 5205; +const EXIT_BYPASS_PRIORITY: u32 = 5206; +const LEGACY_EXIT_BYPASS_PRIORITY: u32 = 5280; const EXIT_SPECIFIC_PRIORITY: u32 = 5290; const EXIT_DEFAULT_PRIORITY: u32 = 5300; @@ -666,14 +669,23 @@ fn rule_priority(rule: &RuleMessage) -> Option { }) } -/// Whether a rule is one of the three this agent adds for an exit node. +/// Whether a rule is one this agent adds for an exit node, including old UID priority. /// /// Recognised by shape as well as priority: those numbers are not reserved, /// and a rule somebody else put at one of them must not be removed. fn is_exit_rule(rule: &RuleMessage) -> bool { let table = rule_table(rule); match rule_priority(rule) { - Some(EXIT_BYPASS_PRIORITY) => { + Some(EXIT_MARK_PRIORITY) => { + table == MAIN_TABLE + && rule + .attributes + .contains(&RuleAttribute::FwMark(UNDERLAY_MARK)) + && rule + .attributes + .contains(&RuleAttribute::FwMask(UNDERLAY_MASK)) + } + Some(EXIT_BYPASS_PRIORITY | LEGACY_EXIT_BYPASS_PRIORITY) => { table == MAIN_TABLE && rule .attributes @@ -789,7 +801,8 @@ async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayErr /// /// ```text /// ip route replace default dev table 0x7473 -/// ip rule add priority 5280 uidrange - lookup main +/// ip rule add priority 5205 fwmark 0x40000000/0x40000000 lookup main +/// ip rule add priority 5206 uidrange - lookup main /// ip rule add priority 5290 lookup main suppress_prefixlength 0 /// ip rule add priority 5300 lookup 0x7473 /// ``` @@ -819,6 +832,8 @@ async fn set_exit_client(name: &str, uid: u32) -> Result<(), OverlayError> { .await .map_err(|err| fail("default route", err))?; + add_mark_bypass(&handle, false).await?; + let mut bypass = handle .rule() .add() @@ -891,6 +906,7 @@ async fn set_exit_block_v6(handle: &rtnetlink::Handle, uid: u32) -> Result<(), O }; } + add_mark_bypass(handle, true).await?; let mut bypass = handle .rule() .add() @@ -938,6 +954,27 @@ async fn set_exit_block_v6(handle: &rtnetlink::Handle, uid: u32) -> Result<(), O .map_err(|err| fail("rule for the default route", err)) } +async fn add_mark_bypass(handle: &rtnetlink::Handle, v6: bool) -> Result<(), OverlayError> { + let mut rule = handle + .rule() + .add() + .table_id(MAIN_TABLE) + .action(RuleAction::ToTable) + .priority(EXIT_MARK_PRIORITY) + .fw_mark(UNDERLAY_MARK); + rule.message_mut().header.family = if v6 { + rtnetlink::packet_route::AddressFamily::Inet6 + } else { + rtnetlink::packet_route::AddressFamily::Inet + }; + rule.message_mut() + .attributes + .push(RuleAttribute::FwMask(UNDERLAY_MASK)); + rule.execute().await.map_err(|err| { + OverlayError::Unavailable(format!("cannot protect marked underlay traffic: {err}")) + }) +} + async fn configure_link(configure: &Configure) -> Result<(), OverlayError> { let name = configure.name.as_str(); with_netlink(|handle| async move { @@ -1110,6 +1147,28 @@ mod tests { assert!(!is_exit_rule(&rule(5280, 100, vec![uid]))); } + #[test] + fn underlay_bypass_recognises_only_our_exact_bit_and_mask() { + let rule = |table, mark, mask| { + let mut message = RuleMessage::default(); + message.attributes.extend([ + RuleAttribute::Table(table), + RuleAttribute::Priority(EXIT_MARK_PRIORITY), + RuleAttribute::FwMark(mark), + RuleAttribute::FwMask(mask), + ]); + message + }; + assert!(is_exit_rule(&rule( + MAIN_TABLE, + UNDERLAY_MARK, + UNDERLAY_MASK + ))); + assert!(!is_exit_rule(&rule(MAIN_TABLE, 0x80000, 0xff0000))); + assert!(!is_exit_rule(&rule(MAIN_TABLE, UNDERLAY_MARK, u32::MAX))); + assert!(!is_exit_rule(&rule(52, UNDERLAY_MARK, UNDERLAY_MASK))); + } + #[test] fn the_ipv6_default_of_the_exit_table_refuses_instead_of_routing() { let route = exit_blocking_route_v6(); diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index 04655e5..9076b87 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -48,6 +48,7 @@ struct Device<'a> { tunnel_protocol: &'a str, address: Option, handshake_secs: Option, + control_rtt_ms: Option, overlay: Option<&'a OverlayPeerReport>, /// It is connected and offers to be an exit node. exit_node: bool, @@ -120,8 +121,7 @@ fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) { if let Some((name, holder)) = &device.name_taken { ui.label(egui::RichText::new(egui_phosphor::regular::WARNING).color(AMBER)) .on_hover_text(format!( - "`{name}` was claimed first by {holder}. This device works by address, \ - but nobody finds it by that name." + "Name `{name}` belongs to {holder}. Use this device's address." )); } }); @@ -437,6 +437,11 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec> { .and_then(|p| p.address.clone()) .or_else(|| member.and_then(|m| m.overlay_address_v4.clone())), handshake_secs: overlay.and_then(|p| p.handshake_secs_ago), + control_rtt_ms: network + .peers + .iter() + .find(|p| p.endpoint_id == id) + .and_then(|p| p.rtt_ms), overlay, exit_node: online && network @@ -458,16 +463,17 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec> { /// The widest value each column is expected to hold, so the table starts out /// wide enough and does not reflow as numbers grow. -const COLUMN_SAMPLES: [&str; 9] = [ - "", +const COLUMN_SAMPLES: [&str; 10] = [ + "device", "exit", - "wg-quic · waiting for handshake", + "end-to-end", + "999d / 9999 ms", "255.255.255.255", "direct over tcp-tls", "99999/99999", "1023.9 MB / 1023.9 MB", "1023.9 kB/s", - "", + "plot", ]; /// Column widths that only ever grow, kept across frames. @@ -476,11 +482,11 @@ const COLUMN_SAMPLES: [&str; 9] = [ /// gets shorter — `14.4 MB/s` giving way to `382 pkt/s` — would pull the /// column in and push every cell after it. Remembering the widest seen keeps /// the table where it is. -fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 9] { - let stored: Option<[f32; 9]> = ui.memory(|m| m.data.get_temp(id)); +fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 10] { + let stored: Option<[f32; 10]> = ui.memory(|m| m.data.get_temp(id)); stored.unwrap_or_else(|| { let font = egui::TextStyle::Body.resolve(ui.style()); - let mut widths = [0.0; 9]; + let mut widths = [0.0; 10]; for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) { if !sample.is_empty() { *width = ui.fonts(|fonts| { @@ -496,13 +502,24 @@ fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 9] { } /// One cell, at least as wide as its column has ever been. -fn cell(ui: &mut egui::Ui, column: usize, widths: &mut [f32; 9], add: impl FnOnce(&mut egui::Ui)) { +fn cell(ui: &mut egui::Ui, column: usize, widths: &[f32; 10], add: impl FnOnce(&mut egui::Ui)) { let floor = widths[column]; - let response = ui.scope(|ui| { - ui.set_min_width(floor); - add(ui); - }); - widths[column] = widths[column].max(response.response.rect.width()); + ui.allocate_ui_with_layout( + egui::vec2(floor, 0.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_min_width(floor); + ui.set_max_width(floor); + add(ui); + }, + ); +} + +fn stretch_columns(mut widths: [f32; 10], available: f32) -> [f32; 10] { + let gaps = 10.0 * (widths.len() - 1) as f32; + widths[9] = widths[9].max(100.0); + widths[9] += (available - gaps - widths.iter().sum::()).max(0.0); + widths } /// The exit-node mark of a device: shown when it offers to be one, bright @@ -528,10 +545,7 @@ fn exit_cell( }; (color, "in use as your exit node — click to stop") } else { - ( - ui.visuals().text_color(), - "offers to be an exit node — click to send all your internet traffic through it", - ) + (ui.visuals().text_color(), "Use this exit node") }; let response = ui .add( @@ -573,8 +587,27 @@ fn device_table( } let widths_id = egui::Id::new(("devices column widths", network_id)); let mut widths = column_widths(ui, widths_id); + let font = egui::TextStyle::Body.resolve(ui.style()); + for device in devices { + let name = format!( + "{} ({}){}", + device.name(), + short_id(device.id), + if !device.online { " Forget" } else { "" } + ); + let natural = ui.fonts(|fonts| { + fonts + .layout_no_wrap(name, font.clone(), egui::Color32::WHITE) + .size() + .x + }); + widths[0] = widths[0].max(natural + 24.0); + } + ui.memory_mut(|m| m.data.insert_temp(widths_id, widths)); + let widths = stretch_columns(widths, ui.available_width()); egui::Grid::new("devices") - .num_columns(9) + .num_columns(10) + .min_col_width(0.0) .striped(true) .spacing([10.0, 4.0]) .show(ui, |ui| { @@ -582,6 +615,7 @@ fn device_table( "device", "exit", "end-to-end", + "handshake / RTT", "address", "data path", "pkts", @@ -592,20 +626,17 @@ fn device_table( .into_iter() .enumerate() { - cell(ui, column, &mut widths, |ui| { + cell(ui, column, &widths, |ui| { let response = ui.weak(header); match column { 2 => { - response.on_hover_text( - "Protocol that encrypts traffic between this device and the \ - destination. It is independent of the route carrying it.", - ); + response.on_hover_text("End-to-end encryption."); } - 4 => { - response.on_hover_text( - "Current route carrying the encrypted tunnel traffic. `direct \ - over tcp-tls` means one Tsunagi hop with no mesh relay.", - ); + 3 => { + response.on_hover_text("Handshake age / control-connection RTT."); + } + 5 => { + response.on_hover_text("Transport carrying the encrypted tunnel."); } _ => {} } @@ -614,14 +645,15 @@ fn device_table( ui.end_row(); for device in devices { - cell(ui, 0, &mut widths, |ui| { + cell(ui, 0, &widths, |ui| { ui.horizontal(|ui| { name_cell(ui, device); if !device.online && !device.in_use && let Some(version) = device.record_version - && ui.add_enabled(network.active, egui::Button::new("Forget")) - .on_hover_text("Forget this offline claim across the network; returning devices may rejoin") + && ui + .add_enabled(network.active, egui::Button::new("Forget")) + .on_hover_text("Free this offline device's name and IPv4") .clicked() { *forget_confirm = Some(ForgetConfirm { @@ -634,50 +666,68 @@ fn device_table( } }); }); - cell(ui, 1, &mut widths, |ui| { + cell(ui, 1, &widths, |ui| { exit_cell(ui, device, network_id, confirm); }); - cell(ui, 2, &mut widths, |ui| { - tunnel_cell(ui, device); + cell(ui, 2, &widths, |ui| { + ui.push_id((device.id, "end-to-end"), |ui| { + tunnel_cell(ui, device); + }); }); - cell(ui, 3, &mut widths, |ui| match &device.address { + cell(ui, 3, &widths, |ui| { + ui.label(handshake_rtt_text( + device.handshake_secs, + device.control_rtt_ms, + displayed_protocol(device.tunnel_protocol) != "—", + )) + .on_hover_text(if device.tunnel_protocol == "tcp-tls" { + "Session age / control RTT." + } else { + "Last handshake age / control RTT." + }); + }); + cell(ui, 4, &widths, |ui| match &device.address { Some(address) => format::copy_field(ui, address, address), None => { ui.weak("—"); } }); - cell(ui, 4, &mut widths, |ui| data_path_cell(ui, device)); + cell(ui, 5, &widths, |ui| { + ui.push_id((device.id, "data-path"), |ui| data_path_cell(ui, device)); + }); match device.overlay.filter(|_| device.online) { Some(peer) => { let series = traffic.peer(network_id, &peer.public_key); - cell(ui, 5, &mut widths, |ui| { + cell(ui, 6, &widths, |ui| { ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets)); }); - cell(ui, 6, &mut widths, |ui| { + cell(ui, 7, &widths, |ui| { ui.label(format!( "{} / {}", format::bytes(peer.tx_bytes as f64), format::bytes(peer.rx_bytes as f64) )); }); - cell(ui, 7, &mut widths, |ui| { + cell(ui, 8, &widths, |ui| { ui.add( egui::Label::new(format::series_rate(series, unit)) .wrap_mode(egui::TextWrapMode::Extend), ); }); - ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| { + ui.allocate_ui(egui::vec2(widths[9], 20.0), |ui| { format::sparkline(ui, series, unit, 18.0); }); } None => { - for column in 5..8 { - cell(ui, column, &mut widths, |ui| { + for column in 6..9 { + cell(ui, column, &widths, |ui| { ui.weak("—"); }); } - ui.weak("—"); + cell(ui, 9, &widths, |ui| { + ui.weak("—"); + }); } } ui.end_row(); @@ -692,25 +742,25 @@ fn device_table( tx_bytes += peer.tx_bytes; rx_bytes += peer.rx_bytes; } - cell(ui, 0, &mut widths, |ui| { + cell(ui, 0, &widths, |ui| { ui.strong("total"); }); - for column in 1..5 { - cell(ui, column, &mut widths, |ui| { + for column in 1..6 { + cell(ui, column, &widths, |ui| { ui.label(""); }); } - cell(ui, 5, &mut widths, |ui| { + cell(ui, 6, &widths, |ui| { ui.strong(format!("{tx_packets}/{rx_packets}")); }); - cell(ui, 6, &mut widths, |ui| { + cell(ui, 7, &widths, |ui| { ui.strong(format!( "{} / {}", format::bytes(tx_bytes as f64), format::bytes(rx_bytes as f64) )); }); - cell(ui, 7, &mut widths, |ui| { + cell(ui, 8, &widths, |ui| { ui.add( egui::Label::new( egui::RichText::new(format::series_rate(traffic.network(network_id), unit)) @@ -721,54 +771,45 @@ fn device_table( }); ui.end_row(); }); - ui.memory_mut(|m| m.data.insert_temp(widths_id, widths)); } /// A protocol name as it should appear in the table. fn displayed_protocol(protocol: &str) -> &str { - if protocol.is_empty() { - "wg-quic" - } else { - protocol - } + if protocol.is_empty() { "—" } else { protocol } } /// The end-to-end tunnel and its own health, without transport details. -fn tunnel_text(protocol: &str, handshake_secs: Option) -> String { - let protocol = displayed_protocol(protocol); - if protocol == "—" { - return protocol.to_string(); - } - match handshake_secs { - Some(secs) if protocol == "tcp-tls" => { - format!("{protocol} · up {}", format::age(secs)) - } - Some(secs) => format!("{protocol} · handshake {} ago", format::age(secs)), - None => format!("{protocol} · waiting for handshake"), +fn handshake_rtt_text(handshake: Option, rtt: Option, has_tunnel: bool) -> String { + let age = handshake.map_or_else( + || { + if has_tunnel { + "waiting".into() + } else { + "—".into() + } + }, + format::age, + ); + let rtt = rtt.map_or_else(|| "—".into(), |value| format!("{value} ms")); + format!("{age} / {rtt}") +} + +fn tunnel_hint(protocol: &str) -> String { + let name = displayed_protocol(protocol); + if name == "tcp-tls" { + format!("{name}: Noise end-to-end encryption") + } else { + format!("{name}: WireGuard end-to-end encryption") } } fn tunnel_cell(ui: &mut egui::Ui, device: &Device<'_>) { let protocol = displayed_protocol(device.tunnel_protocol); - let text = tunnel_text(protocol, device.handshake_secs); if protocol == "—" { - ui.weak(text); + ui.weak(protocol); return; } - let hint = match device.path { - Some(DataPathReport::Direct { transport }) => format!( - "The end-to-end {protocol} tunnel is currently carried over a direct {transport} \ - transport." - ), - Some(DataPathReport::Relayed { hops, .. }) => format!( - "The end-to-end {protocol} tunnel is currently carried through other devices over \ - {hops} transport hops." - ), - Some(DataPathReport::None) | None => { - format!("The end-to-end {protocol} tunnel does not have a data path carrying it yet.") - } - }; - ui.label(text).on_hover_text(hint); + ui.label(protocol).on_hover_text(tunnel_hint(protocol)); } /// Current route text and whether that route is the preferred direct form. @@ -802,16 +843,10 @@ fn data_path_cell(ui: &mut egui::Ui, device: &Device<'_>) { ui.label(egui::RichText::new(text).color(AMBER)) }; response.on_hover_text(match device.path { - Some(DataPathReport::Direct { .. }) => { - "One Tsunagi hop with no mesh relay. The end-to-end tunnel may use a different \ - protocol from this transport." - } - Some(DataPathReport::Relayed { .. }) => { - "No direct transport yet: traffic goes through another device, and moves to a \ - direct path by itself as soon as one comes up." - } - _ if device.online => "Connected, but no data path carries packets yet.", - _ => "This device is not reachable now.", + Some(DataPathReport::Direct { .. }) => "Direct transport; no mesh relay.", + Some(DataPathReport::Relayed { .. }) => "Traffic forwarded through another device.", + _ if device.online => "Control connected; data transport unavailable.", + _ => "Unreachable.", }); } @@ -895,7 +930,8 @@ mod tests { #[test] fn tunnel_and_direct_transport_are_named_as_different_layers() { - assert_eq!(tunnel_text("wg", Some(1)), "wg · handshake 1s ago"); + assert_eq!(displayed_protocol("wg"), "wg"); + assert_eq!(tunnel_hint("wg"), "wg: WireGuard end-to-end encryption"); assert_eq!( data_path_text( Some(&DataPathReport::Direct { @@ -906,15 +942,15 @@ mod tests { ), ("direct over tcp-tls".into(), true) ); - assert_eq!(tunnel_text("tcp-tls", Some(42)), "tcp-tls · up 42s"); + assert_eq!( + tunnel_hint("tcp-tls"), + "tcp-tls: Noise end-to-end encryption" + ); } #[test] fn incomplete_and_longer_paths_stay_explicit() { - assert_eq!( - tunnel_text("wg-quic", None), - "wg-quic · waiting for handshake" - ); + assert_eq!(handshake_rtt_text(None, Some(25), true), "waiting / 25 ms"); assert_eq!( data_path_text( Some(&DataPathReport::Relayed { @@ -932,4 +968,101 @@ mod tests { ); assert_eq!(data_path_text(None, None, false), ("offline".into(), false)); } + + #[test] + fn handshake_rtt_has_no_invented_values_and_uses_control_measurements() { + use tsunagi::ipc::{OverlayReport, PeerReport}; + let network = NetworkReport { + peers: vec![PeerReport { + endpoint_id: "peer".into(), + rtt_ms: Some(27), + ..Default::default() + }], + overlay: Some(OverlayReport { + peers: vec![OverlayPeerReport { + endpoint_id: "peer".into(), + protocol: "wg".into(), + handshake_secs_ago: Some(5), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }; + let rows = devices(&network, "own"); + assert_eq!( + handshake_rtt_text(rows[0].handshake_secs, rows[0].control_rtt_ms, true), + "5s / 27 ms" + ); + assert_eq!(handshake_rtt_text(None, None, false), "— / —"); + assert_eq!(rows[0].tunnel_protocol, "wg"); + assert!(!tunnel_hint(rows[0].tunnel_protocol).contains("wg-quic")); + } + + #[test] + fn resized_table_fills_the_window_without_growing_fixed_columns() { + let minimum = [100.0; 10]; + let wide = stretch_columns(minimum, 1440.0); + assert!((wide.iter().sum::() + 90.0 - 1440.0).abs() < 0.1); + assert_eq!(&wide[..9], &minimum[..9]); + let narrow = stretch_columns(minimum, 800.0); + assert_eq!( + narrow, minimum, + "narrow windows scroll rather than clipping columns" + ); + assert_eq!( + stretch_columns(minimum, 1440.0), + wide, + "a resize does not accumulate padding" + ); + } + + #[test] + fn rendered_table_uses_window_width_without_an_expanding_device_cell() { + let context = egui::Context::default(); + let network = NetworkReport { + members: vec![MemberReport { + endpoint_id: "0123456789abcdef".into(), + hostname: Some("laptop".into()), + online: true, + ..Default::default() + }], + ..Default::default() + }; + let rows = devices(&network, "own"); + for width in [1440.0, 1200.0, 1440.0] { + for _ in 0..3 { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(width, 640.0), + )), + ..Default::default() + }; + let _output = context.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let right = ui.max_rect().right(); + egui::ScrollArea::both() + .auto_shrink([false, false]) + .show(ui, |ui| { + device_table( + ui, + Unit::Bytes, + &network, + &rows, + &Traffic::default(), + &mut None, + &mut None, + ); + }); + assert!( + (ui.min_rect().right() - right).abs() < 2.0, + "table width after resize: {} vs {right}", + ui.min_rect().right() + ); + }); + }); + } + } + } } diff --git a/docs/exit-node.md b/docs/exit-node.md index b132e9e..c1194f6 100644 --- a/docs/exit-node.md +++ b/docs/exit-node.md @@ -43,12 +43,36 @@ be an exit node in one network and not in another. `tsunagi-exit::` and are removed when the offer ends, the network stops, and when the agent exits. Only networks that offer have rules, so the others' traffic is not forwarded. -- **Client:** policy routing, with no change to the main table. Table `28787` - holds `default dev `, and three rules sit after Tailscale's: - `5280 uidrange lookup main` (the agent's own connections never - go through the tunnel, which would be a loop), `5290 lookup main - suppress_prefixlength 0` (LAN and other specific routes keep working) and - `5300 lookup 28787`. +- **Client:** policy routing, with no change to the main table. Table `29811` + holds `default dev `. Rules `5205 fwmark 0x40000000/0x40000000 + lookup main` and `5206 uidrange lookup main` keep the agent's + sockets and their replies outside the tunnel, before Tailscale's rules. + `5290 lookup main suppress_prefixlength 0` preserves LAN and other specific + routes, and `5300 lookup 29811` directs other traffic through the exit. + The exit table is `29811` (`0x7473`). +- **Linux reverse-path filtering:** only the agent's dedicated UID is marked + in mangle `OUTPUT`. Its own bit, `0x40000000`, is saved to conntrack and + restored in `PREROUTING` before the host firewall checks reply routing. + Other mark bits, including Tailscale's `0xff0000` mask, are preserved. The + rules are tagged `tsunagi-exit-underlay:` and never accept packets + around the host firewall. IPv4 and IPv6 protection is installed before exit + routes; routes are removed before protection, including rollback on failure. + Locally owned TCP listeners and bound UDP sockets also protect the first + incoming packet, before any outgoing packet has created a connection mark. + Socket addresses and ports + come from local kernel socket tables and match an actual socket; remote + announcements never supply these firewall arguments. + The socket set is refreshed before private pair negotiation is sent and on + discovery ticks, so fresh AWG ports are included. An unchanged set causes no + firewall writes; a socket-refresh failure leaves active exit routes in place. + Linux needs both `iptables` and `ip6tables` when IPv6 is present. +- **Kernel strict `rp_filter`:** if an interface uses strict mode (`1`), the + kernel must include marks in reverse lookups: set + `net.ipv4.conf.all.src_valid_mark=1`, or enable it on those interfaces. + The agent checks this before installing exit routes and reports an actionable + error if marks would be ignored. It never disables `rp_filter` or changes + host sysctls. Netfilter strict reverse checks also need their mark-aware + form, such as `rpfilter --validmark`, which NixOS already uses. - Packets for addresses outside every overlay range are sent to the exit node by the router; the exit node's replies are accepted from any source. A member that is not your exit node cannot do that. diff --git a/docs/testing.md b/docs/testing.md index 2c5dae9..9f7f923 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -207,4 +207,24 @@ future cutoffs, bounds, deterministic merge and resistance to stale snapshots. SQLite tests migrate schema 8 to 9 and inject counter-write failure to prove atomic rollback. The CLI test starts two real agent processes and exercises offline cleanup by network and hostname through the control socket. -Tray rendering and physical screen sizing are not exercised automatically. +Native tray rendering and physical screen sizing are not exercised automatically. + +Linux exit-client unit tests cover preservation of foreign mark bits, matching +only the agent's UID and locally owned TCP/UDP sockets, exact tagged cleanup, +partial IPv6 failure and mark-aware strict kernel RPF validation. The ignored +`overlay::hostrules::linux_exit_kernel::strict_firewall_preserves_underlay_and_application_exit_routing` +test creates two disposable network namespaces. It reproduces the UID-only +failure with real sockets, then runs TCP/UDP IPv4/IPv6 and first inbound TCP/UDP +packets, including a UDP port bound after activation, through real mark/conntrack +rules with both strict kernel and +Netfilter reverse-path checks. It verifies application exit routing, IPv6 +blocking, repeated apply and cleanup with foreign rules at Tailscale's priority +retained. It requires root, iproute2, +iptables/ip6tables and Python; it does not run in the default suite or claim +to test the entire mesh inside these namespaces. + +Devices-window tests render the egui grid at multiple widths with its real +scroll area. They check stretching and shrinking without an expanding first +column, separate handshake/control RTT values, unknown measurements and short +end-to-end hints that do not substitute the carrier protocol. This does not +establish native desktop rendering on every platform. diff --git a/nix/module.nix b/nix/module.nix index bc3dc82..612fbfd 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -102,6 +102,7 @@ in # Exit nodes: the agent looks for iptables only in fixed FHS directories. systemd.tmpfiles.rules = [ "L+ /usr/bin/iptables - - - - ${pkgs.iptables}/bin/iptables" + "L+ /usr/bin/ip6tables - - - - ${pkgs.iptables}/bin/ip6tables" ]; # Names of mesh members are served locally and handed to the system resolver.