Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2631eb6975 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<RuleOutcome>> {
|
||||
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.
|
||||
|
||||
@@ -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<Option<(String, Vec<super::linux_exit_mark::Listener>, 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 {
|
||||
|
||||
@@ -25,18 +25,19 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! table 0x7473: default dev <if>
|
||||
//! 5280: from all uidrange <euid>-<euid> lookup main
|
||||
//! 5205: from all fwmark 0x40000000/0x40000000 lookup main
|
||||
//! 5206: from all uidrange <euid>-<euid> 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<String> {
|
||||
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<u32, String> {
|
||||
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<u32> {
|
||||
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<RuleOutcome> {
|
||||
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<RuleOutcome>> {
|
||||
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");
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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<Vec<Listener>, 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::<u32>().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<Vec<Listener>, 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<Vec<String>> {
|
||||
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<String> {
|
||||
values.iter().map(|s| (*s).into()).collect()
|
||||
}
|
||||
|
||||
fn rules(interface: &str, uid: u32) -> Vec<(&'static str, Vec<String>)> {
|
||||
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<String>) -> Result<String, String> {
|
||||
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<Vec<Listener>, 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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 <if> table 0x7473
|
||||
/// ip rule add priority 5280 uidrange <uid>-<uid> lookup main
|
||||
/// ip rule add priority 5205 fwmark 0x40000000/0x40000000 lookup main
|
||||
/// ip rule add priority 5206 uidrange <uid>-<uid> 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();
|
||||
|
||||
+233
-100
@@ -48,6 +48,7 @@ struct Device<'a> {
|
||||
tunnel_protocol: &'a str,
|
||||
address: Option<String>,
|
||||
handshake_secs: Option<u64>,
|
||||
control_rtt_ms: Option<u64>,
|
||||
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<Device<'a>> {
|
||||
.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<Device<'a>> {
|
||||
|
||||
/// 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::<f32>()).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<u64>) -> 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<u64>, rtt: Option<u64>, 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::<f32>() + 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()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-6
@@ -43,12 +43,36 @@ be an exit node in one network and not in another.
|
||||
`tsunagi-exit:<interface>:<range>` 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 <interface>`, and three rules sit after Tailscale's:
|
||||
`5280 uidrange <agent uid> 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 <interface>`. Rules `5205 fwmark 0x40000000/0x40000000
|
||||
lookup main` and `5206 uidrange <agent uid> 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:<interface>` 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.
|
||||
|
||||
+21
-1
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user