From b2431d7eb118612199efbde53690d4423480fc89 Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Sat, 3 Oct 2026 01:14:51 +0100 Subject: [PATCH] Exit nodes: offer one per network, send all traffic through a member A member can offer itself as an exit node per network (off by default); it is announced over the control channel (ctrl/4) once its iptables masquerade and forward rules are in place, with a warning when kernel forwarding is off. A device picks one member as its exit node via policy routing, with the agent's own uid exempt from the tunnel; if the exit node disappears the rules stay and traffic is dropped, loudly reported. CLI: `network exit-node`, `exit-node`; tray: tile checkbox, warnings and a per-device icon with confirmation. Linux only. Co-Authored-By: Claude --- README.md | 1 + crates/tsunagi-cli/src/main.rs | 606 +++++++++++++++ crates/tsunagi-cli/tests/network_cli.rs | 43 ++ crates/tsunagi-tray/src/agent.rs | 24 + crates/tsunagi-tray/src/app.rs | 13 +- crates/tsunagi-tray/src/devices.rs | 207 ++++- crates/tsunagi-tray/src/exit.rs | 164 ++++ crates/tsunagi-tray/src/main.rs | 1 + crates/tsunagi-tray/src/ui.rs | 60 ++ crates/tsunagi-wg-quic/tests/exit_node.rs | 313 ++++++++ crates/tsunagi-wg-quic/tests/local_control.rs | 1 + crates/tsunagi/src/agent/mod.rs | 87 ++- crates/tsunagi/src/agent/network.rs | 134 ++++ crates/tsunagi/src/agent/session.rs | 3 + crates/tsunagi/src/agent/status.rs | 10 + crates/tsunagi/src/error.rs | 7 + crates/tsunagi/src/ipc/mod.rs | 131 ++++ crates/tsunagi/src/overlay/broadcast.rs | 3 + crates/tsunagi/src/overlay/exit.rs | 32 + crates/tsunagi/src/overlay/hostrules/exit.rs | 231 ++++++ crates/tsunagi/src/overlay/hostrules/linux.rs | 18 +- .../src/overlay/hostrules/linux_exit.rs | 728 ++++++++++++++++++ crates/tsunagi/src/overlay/hostrules/mod.rs | 32 +- crates/tsunagi/src/overlay/interface.rs | 497 +++++++++++- crates/tsunagi/src/overlay/mod.rs | 8 +- crates/tsunagi/src/overlay/provision/linux.rs | 272 ++++++- crates/tsunagi/src/overlay/router.rs | 363 ++++++++- crates/tsunagi/src/proto/message.rs | 9 +- crates/tsunagi/src/storage/mod.rs | 12 + crates/tsunagi/src/storage/state.rs | 116 ++- docs/exit-node.md | 75 ++ docs/protocol.md | 2 +- docs/routing.md | 2 +- docs/threat-model.md | 4 + 34 files changed, 4148 insertions(+), 61 deletions(-) create mode 100644 crates/tsunagi-tray/src/exit.rs create mode 100644 crates/tsunagi-wg-quic/tests/exit_node.rs create mode 100644 crates/tsunagi/src/overlay/exit.rs create mode 100644 crates/tsunagi/src/overlay/hostrules/exit.rs create mode 100644 crates/tsunagi/src/overlay/hostrules/linux_exit.rs create mode 100644 docs/exit-node.md diff --git a/README.md b/README.md index c4a2c56..f37775f 100644 --- a/README.md +++ b/README.md @@ -746,6 +746,7 @@ networks first if the addresses should be freed. - [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake. - [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and what is deliberately not built yet. +- [docs/exit-node.md](docs/exit-node.md) — exit nodes: sending all traffic through a member. - [docs/threat-model.md](docs/threat-model.md) — threat model and known limits. - [docs/testing.md](docs/testing.md) — what the suite covers and what it does not. - [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo. diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 3fa9437..43e82b2 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -76,6 +76,12 @@ enum Command { Network(NetworkArgs), /// Shows the local resolver, and turns it on or off. Dns(DnsArgs), + /// Lists devices offering to be an exit node, and sends all this + /// device's internet traffic through one. + /// + /// The other half of `tsunagi network exit-node`, which is what makes a + /// device offer itself. A device uses one exit node at a time. + ExitNode(ExitNodeArgs), /// Removes everything this device has stored and starts over. Wipe(WipeArgs), } @@ -105,6 +111,25 @@ enum DnsAction { Off, } +#[derive(Debug, Args)] +struct ExitNodeArgs { + #[command(flatten)] + paths: PathArgs, + + /// Control socket to talk to. Derived from the state directory by default. + #[arg(long, env = "TSUNAGI_CONTROL_SOCKET")] + control_socket: Option, + + /// Only look in this network: exact name, full id or unique id prefix. + #[arg(long, short = 'n')] + network: Option, + + /// The device to use: its hostname, endpoint id or a unique prefix of + /// the id. `off` goes back to sending traffic the ordinary way. Omit to + /// list the devices that offer. + target: Option, +} + #[derive(Debug, Args)] struct NetworkArgs { #[command(flatten)] @@ -162,6 +187,20 @@ enum NetworkAction { #[arg(value_enum)] choice: Option, }, + /// Offers this device as an exit node in a network, or stops. + /// + /// Off unless turned on. While on, other members can send all their + /// internet traffic through this device, which can see it. The firewall + /// and NAT rules are installed by the agent; kernel forwarding is not + /// turned on for you, and is reported when it is off. + #[command(name = "exit-node")] + ExitNode { + /// Exact network name, full id or unique id prefix. + network: String, + /// Offer, or stop offering. + #[arg(value_enum)] + choice: BroadcastChoice, + }, /// Makes a network, or joins one, in the agent that is already running. /// /// The state directory belongs to one live agent, so this is how a @@ -755,6 +794,7 @@ async fn run(command: Command) -> Result<(), Box> { Command::Join(args) => join_command(args).await, Command::Network(args) => network_command(args).await, Command::Dns(args) => dns_command(args).await, + Command::ExitNode(args) => exit_node_command(args).await, Command::Wipe(args) => wipe(args).await, } } @@ -1544,6 +1584,47 @@ impl tsunagi::ipc::ReportSource for AgentControl { }) } + fn set_exit_offer( + &self, + network_id: String, + enabled: bool, + ) -> tsunagi::BoxFuture<'_, Result> { + Box::pin(async move { + let id = network_id + .parse() + .map_err(|err| format!("invalid network id: {err}"))?; + self.agent + .set_exit_offer(id, enabled) + .await + .map_err(|err| err.to_string())?; + Ok(enabled) + }) + } + + fn set_exit_node( + &self, + network_id: String, + peer: Option, + ) -> tsunagi::BoxFuture<'_, Result, String>> { + Box::pin(async move { + let id = network_id + .parse() + .map_err(|err| format!("invalid network id: {err}"))?; + let via = peer + .as_deref() + .map(|peer| { + peer.parse::() + .map_err(|err| format!("`{peer}` is not an endpoint id: {err}")) + }) + .transpose()?; + self.agent + .set_exit_via(id, via) + .await + .map_err(|err| err.to_string())?; + Ok(peer) + }) + } + fn set_active( &self, network_id: String, @@ -1701,6 +1782,9 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box { broadcast_command(&paths, &socket, &network, choice).await } + Some(NetworkAction::ExitNode { network, choice }) => { + exit_offer_command(&paths, &socket, &network, choice).await + } Some(NetworkAction::Join(args)) => join_command(args).await, Some(NetworkAction::Stop { network }) => set_active(&paths, &socket, &network, false).await, Some(NetworkAction::Start { network }) => set_active(&paths, &socket, &network, true).await, @@ -2000,6 +2084,293 @@ async fn broadcast_command( Ok(()) } +/// What to run to turn kernel forwarding on, which the agent never does. +const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1"; + +/// The warning for an exit node whose rules are in place while the kernel is +/// not forwarding, so that nothing passes. +fn forwarding_off_note() -> String { + format!( + "kernel forwarding is OFF, so nothing passes yet: run `{FORWARDING_COMMAND}` \ + (and keep it across reboots with `net.ipv4.ip_forward = 1` in \ + /etc/sysctl.d/99-tsunagi.conf)" + ) +} + +/// Offers this device as an exit node in a network, or stops. +async fn exit_offer_command( + paths: &StoragePaths, + socket: &std::path::Path, + wanted: &str, + choice: BroadcastChoice, +) -> Result<(), Box> { + let configured = stored_networks(paths); + let network = resolve_network(&configured, wanted)?; + let enabled = matches!(choice, BroadcastChoice::On); + let id = network.network_id.to_string(); + + if !tsunagi::ipc::is_serving(socket).await { + let storage = tsunagi::storage::Storage::open(paths)?; + storage.set_exit_node(network.network_id, enabled).await?; + storage.release_ownership_lock(); + println!( + "exit node {} for `{}` ({}) with the next `tsunagi up`", + if enabled { + "will be offered" + } else { + "will not be offered" + }, + network.name, + short(&id, 10) + ); + return Ok(()); + } + + tsunagi::ipc::set_exit_offer(socket, &id, enabled).await?; + println!( + "exit node {} for `{}` ({})", + if enabled { "on" } else { "off" }, + network.name, + short(&id, 10) + ); + if !enabled { + return Ok(()); + } + let status = tsunagi::ipc::request_status(socket).await?; + let Some(report) = status.networks.iter().find(|n| n.network_id == id) else { + return Ok(()); + }; + match &report.exit.offer_rules { + Some(rules) if rules.ok => println!(" rules {}", rules.detail), + Some(rules) => eprintln!("warning: the rules are not all in place: {}", rules.detail), + None => eprintln!( + "warning: no rules were installed, so this device is not offered yet: the \ + network needs an address on the overlay interface first" + ), + } + if report.exit.forwarding == Some(false) { + eprintln!("warning: {}", forwarding_off_note()); + } + println!( + "\nMembers can now send all their internet traffic through this device, and it \ + can see it. `tsunagi network exit-node {wanted} off` stops." + ); + Ok(()) +} + +/// Every connected device that offers to be an exit node, across networks. +fn exit_candidates( + networks: &[tsunagi::ipc::NetworkReport], +) -> Vec<(&tsunagi::ipc::NetworkReport, &tsunagi::ipc::PeerReport)> { + networks + .iter() + .flat_map(|network| { + network + .peers + .iter() + .filter(|peer| peer.exit_node) + .map(move |peer| (network, peer)) + }) + .collect() +} + +/// What to call a peer: its hostname, or a short form of its id. +fn peer_label(peer: &tsunagi::ipc::PeerReport) -> String { + peer.hostname + .clone() + .unwrap_or_else(|| short(&peer.endpoint_id, 12)) +} + +/// The network a user named, among those the agent reports. +fn select_networks<'a>( + networks: &'a [tsunagi::ipc::NetworkReport], + only: Option<&str>, +) -> Result, String> { + let Some(only) = only else { + return Ok(networks.iter().collect()); + }; + let only = only.trim().trim_end_matches('…'); + let chosen: Vec<_> = networks + .iter() + .filter(|n| n.name == only || n.network_id == only || n.network_id.starts_with(only)) + .collect(); + if chosen.is_empty() { + return Err(format!("no network here is called or has the id `{only}`")); + } + Ok(chosen) +} + +/// Finds the device a user means among those that offer to be an exit node. +/// +/// By hostname, endpoint id or a unique prefix of the id. A name that is in +/// several networks, or on several devices, is an error that lists them: a +/// device that all traffic goes through must not be picked by guessing. +fn resolve_exit_node<'a>( + networks: &'a [tsunagi::ipc::NetworkReport], + wanted: &str, + only: Option<&str>, +) -> Result< + ( + &'a tsunagi::ipc::NetworkReport, + &'a tsunagi::ipc::PeerReport, + ), + String, +> { + let wanted = wanted.trim().trim_end_matches('…'); + let scope = select_networks(networks, only)?; + let named: Vec<_> = scope + .iter() + .flat_map(|network| network.peers.iter().map(move |peer| (*network, peer))) + .filter(|(_, peer)| { + peer.hostname + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(wanted)) + || peer.endpoint_id == wanted + || (wanted.len() >= 4 && peer.endpoint_id.starts_with(&wanted.to_ascii_lowercase())) + }) + .collect(); + let offering: Vec<_> = named.iter().filter(|(_, peer)| peer.exit_node).collect(); + match offering.as_slice() { + [(network, peer)] => Ok((*network, *peer)), + [] => match named.first() { + Some((network, peer)) => Err(format!( + "`{}` is connected but does not offer to be an exit node. On that device: \ + tsunagi network exit-node {} on", + peer_label(peer), + network.name + )), + None => Err(format!( + "no connected device matches `{wanted}`. `tsunagi exit-node` lists the \ + ones that offer to be an exit node" + )), + }, + several => { + let mut message = format!( + "`{wanted}` matches more than one exit node; say which with --network or a \ + longer id:" + ); + for (network, peer) in several { + message.push_str(&format!( + "\n {} ({}) in network {}", + peer_label(peer), + short(&peer.endpoint_id, 12), + network.name + )); + } + Err(message) + } + } +} + +/// `tsunagi exit-node`: list, use or stop using an exit node. +async fn exit_node_command(args: ExitNodeArgs) -> Result<(), Box> { + use report::{Health, Report, Row, Section}; + + let paths = args.paths.resolve()?; + let is_custom_state = args.paths.state_dir.is_some(); + let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); + if !tsunagi::ipc::is_serving(&socket).await { + return Err( + "no agent is running: an exit node is used through the running agent, which \ + changes the routes of this device. Start it with `tsunagi up`." + .into(), + ); + } + let status = tsunagi::ipc::request_status(&socket).await?; + let scope = select_networks(&status.networks, args.network.as_deref())?; + + match args.target.as_deref() { + None => { + let mut section = Section::new("exit nodes"); + let mut any = false; + for network in &scope { + for (_, peer) in exit_candidates(std::slice::from_ref(*network)) { + any = true; + let in_use = network.exit.via.as_deref() == Some(peer.endpoint_id.as_str()); + let mut detail = format!("network {}", network.name); + if let Some(rtt) = peer.rtt_ms { + detail.push_str(&format!(" · rtt {rtt}ms")); + } + if in_use { + detail.push_str(" · IN USE"); + } + section.push(Row::new( + if in_use { Health::Good } else { Health::Info }, + peer_label(peer), + detail, + )); + } + } + if !any { + section.push( + Row::new( + Health::Info, + "none", + "no connected device offers to be an exit node", + ) + .with_note("on that device: tsunagi network exit-node on"), + ); + } + let mut out = Report::new(); + out.push(section); + print_report("tsunagi exit-node", &out)?; + if any { + println!( + "\n`tsunagi exit-node ` uses one; `tsunagi exit-node off` stops." + ); + } + Ok(()) + } + Some(target) if target.eq_ignore_ascii_case("off") => { + let using: Vec<_> = scope.iter().filter(|n| n.exit.via.is_some()).collect(); + if using.is_empty() { + println!("no exit node is in use"); + return Ok(()); + } + for network in using { + tsunagi::ipc::set_exit_node(&socket, &network.network_id, None).await?; + println!( + "stopped using the exit node in `{}`: internet traffic leaves this \ + device the ordinary way again", + network.name + ); + } + Ok(()) + } + Some(target) => { + let (network, peer) = + resolve_exit_node(&status.networks, target, args.network.as_deref())?; + tsunagi::ipc::set_exit_node(&socket, &network.network_id, Some(&peer.endpoint_id)) + .await?; + println!( + "all internet traffic from this device now leaves through `{}` (network `{}`)", + peer_label(peer), + network.name + ); + let after = tsunagi::ipc::request_status(&socket).await?; + if let Some(exit) = after + .networks + .iter() + .find(|n| n.network_id == network.network_id) + .map(|n| &n.exit) + { + match &exit.client_rules { + Some(rules) if rules.ok => println!(" routes {}", rules.detail), + Some(rules) => { + eprintln!("warning: the routes are not all in place: {}", rules.detail) + } + None => {} + } + } + eprintln!( + "\nThat device can see everything that is not encrypted. If it goes away the \ + traffic is dropped, not sent the old way; `tsunagi exit-node off` stops." + ); + Ok(()) + } + } +} + async fn set_active( paths: &StoragePaths, socket: &std::path::Path, @@ -2670,6 +3041,10 @@ struct MemberRow<'a> { overlay_address_v4: Option<&'a str>, tunnel: Option<&'a tsunagi::ipc::OverlayPeerReport>, failed_dials: u32, + /// It offers to be an exit node. + exit_node: bool, + /// It is the exit node this device sends its traffic through. + exit_in_use: bool, } impl MemberRow<'_> { @@ -2703,6 +3078,8 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve overlay_address_v4: None, tunnel: None, failed_dials: 0, + exit_node: false, + exit_in_use: false, }) } @@ -2716,6 +3093,7 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve for peer in &network.peers { let row = entry(&mut rows, &peer.endpoint_id); row.hostname = peer.hostname.as_deref().or(row.hostname); + row.exit_node = peer.exit_node; row.transport = Some(&peer.transport); row.rtt_ms = peer.rtt_ms; } @@ -2729,6 +3107,10 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve } } + for row in rows.values_mut() { + row.exit_in_use = network.exit.via.as_deref() == Some(row.endpoint_id); + } + // This agent is in the roster too — it signs claims like everyone else — // but it is already the subject of the `device` section. let mut rows: Vec> = rows @@ -2851,6 +3233,9 @@ fn network_section( ), )); } + for row in exit_rows(network) { + section.push(row); + } let relayed = network.relay_forwarded + network.relay_sent_via + network.relay_received_via; if relayed > 0 { section.push(Row::new( @@ -2955,6 +3340,87 @@ fn network_section( section } +/// The exit-node rows of a network: what this device offers, and what it uses. +/// +/// Nothing at all for a network that does neither, which is nearly all of +/// them: an off switch is not worth a line in every report. +fn exit_rows(network: &tsunagi::ipc::NetworkReport) -> Vec { + use report::{Health, Row}; + + let exit = &network.exit; + let mut rows = Vec::new(); + + if exit.offering { + let forwarding_off = exit.forwarding == Some(false); + let (rules_ok, detail) = match &exit.offer_rules { + Some(rules) => (rules.ok, rules.detail.clone()), + None => (false, "rules not installed yet".to_string()), + }; + let row = Row::new( + if rules_ok && !forwarding_off { + Health::Good + } else { + Health::Degraded + }, + "exit node", + format!("offered to members · {detail}"), + ); + rows.push(if forwarding_off { + row.with_note(forwarding_off_note()) + } else { + row + }); + } + + if let Some(via) = &exit.via { + let name = exit_name(network, via); + let row = if !exit.via_online { + Row::new(Health::Broken, "using exit", format!("{name} · OFFLINE")).with_note( + "internet traffic is dropped until it returns or you run \ + `tsunagi exit-node off`", + ) + } else { + match &exit.client_rules { + Some(rules) if rules.ok => Row::new( + Health::Good, + "using exit", + format!("{name} · all internet traffic leaves through it"), + ), + Some(rules) => Row::new( + Health::Degraded, + "using exit", + format!("{name} · routes incomplete"), + ) + .with_note(rules.detail.clone()), + None => Row::new( + Health::Degraded, + "using exit", + format!("{name} · routes not installed"), + ), + } + }; + rows.push(row); + } + rows +} + +/// What to call the device a network's exit node is, even while it is away. +fn exit_name(network: &tsunagi::ipc::NetworkReport, id: &str) -> String { + network + .peers + .iter() + .find(|peer| peer.endpoint_id == id) + .and_then(|peer| peer.hostname.clone()) + .or_else(|| { + network + .members + .iter() + .find(|member| member.endpoint_id == id) + .and_then(|member| member.hostname.clone()) + }) + .unwrap_or_else(|| short(id, 12)) +} + /// One member: connected or not, and what is known either way. fn member_row(row: &MemberRow<'_>) -> report::Row { use report::{Health, Row}; @@ -2966,6 +3432,9 @@ fn member_row(row: &MemberRow<'_>) -> report::Row { if let Some(v4) = row.overlay_address_v4 { detail = format!("{v4:<15} offline · {v4} still reserved for it"); } + if row.exit_in_use { + detail.push_str(" · your exit node"); + } let out = Row::new(Health::Info, row.label(), detail); return if row.failed_dials > 0 { // Attributed to the member it concerns, rather than left as a @@ -2991,6 +3460,11 @@ fn member_row(row: &MemberRow<'_>) -> report::Row { if let Some(rtt) = row.rtt_ms { detail.push_str(&format!(" rtt {rtt}ms")); } + if row.exit_in_use { + detail.push_str(" · your exit node"); + } else if row.exit_node { + detail.push_str(" · exit node"); + } let health = if !direct || (has_overlay && !tunnel_up) { Health::Degraded @@ -4276,6 +4750,7 @@ async fn build_report( }; NetworkReport { + exit: exit_report(network), broadcast: network.broadcast, name: network.name.to_string(), network_id: network.network_id.to_string(), @@ -4292,6 +4767,7 @@ async fn build_report( hostname: peer.hostname.clone(), transport: peer.transport.to_string(), rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64), + exit_node: peer.exit_node, }) .collect(), members: network @@ -4357,6 +4833,24 @@ async fn build_report( } } +/// One network's exit-node settings, as the control interface reports them. +fn exit_report(network: &tsunagi::agent::NetworkStatus) -> tsunagi::ipc::ExitReport { + let rules = |report: &Option| { + report.as_ref().map(|report| tsunagi::ipc::RuleSetReport { + ok: report.ok, + detail: report.detail.clone(), + }) + }; + tsunagi::ipc::ExitReport { + offering: network.exit_offer, + offer_rules: rules(&network.exit_rules.offer), + forwarding: network.exit_rules.forwarding, + via: network.exit_via.map(|peer| peer.to_string()), + via_online: network.exit_via_online, + client_rules: rules(&network.exit_rules.client), + } +} + /// The status line for the host rules, saying so when they are bound to /// another network's address. fn broadcast_rules_line( @@ -4636,6 +5130,7 @@ mod status_tests { /// The situation that prompted this: one peer left and came back. fn network_after_a_peer_returned() -> NetworkReport { NetworkReport { + exit: Default::default(), broadcast: true, name: "LAB".into(), network_id: "xa7gyz".into(), @@ -4649,6 +5144,7 @@ mod status_tests { hostname: Some("music".into()), transport: "direct".into(), rtt_ms: Some(24), + exit_node: false, }], members: vec![ MemberReport { @@ -4912,6 +5408,112 @@ mod status_tests { assert_eq!(rows[1].endpoint_id, AWAY); } + fn exit_peer(network: &mut NetworkReport, offers: bool) { + network.peers[0].exit_node = offers; + } + + #[test] + fn a_network_that_neither_offers_nor_uses_an_exit_node_says_nothing() { + let network = network_after_a_peer_returned(); + let mut out = report::Report::new(); + out.push(network_section(&network, OWN, false)); + let text = out.render(false); + assert!(!text.contains("exit"), "{text}"); + } + + #[test] + fn offering_with_forwarding_off_is_degraded_and_says_the_command() { + let mut network = network_after_a_peer_returned(); + network.exit.offering = true; + network.exit.offer_rules = Some(tsunagi::ipc::RuleSetReport { + ok: true, + detail: "ok (nat + forward)".into(), + }); + network.exit.forwarding = Some(false); + let rows = exit_rows(&network); + assert_eq!(rows.len(), 1); + let mut out = report::Report::new(); + out.push(network_section(&network, OWN, false)); + let text = out.render(false); + assert_eq!(out.worst(), Health::Degraded, "{text}"); + assert!( + text.contains("sudo sysctl -w net.ipv4.ip_forward=1"), + "{text}" + ); + assert!(text.contains("ok (nat + forward)"), "{text}"); + + network.exit.forwarding = Some(true); + let mut out = report::Report::new(); + out.push(network_section(&network, OWN, false)); + assert_eq!(out.worst(), Health::Good, "{}", out.render(false)); + } + + #[test] + fn losing_the_exit_node_in_use_is_loud_and_says_the_traffic_is_dropped() { + let mut network = network_after_a_peer_returned(); + exit_peer(&mut network, true); + network.exit.via = Some(ONLINE.into()); + network.exit.via_online = true; + network.exit.client_rules = Some(tsunagi::ipc::RuleSetReport { + ok: true, + detail: "ok".into(), + }); + let mut out = report::Report::new(); + out.push(network_section(&network, OWN, false)); + let text = out.render(false); + assert_eq!(out.worst(), Health::Good, "{text}"); + assert!( + text.contains("music · all internet traffic leaves through it"), + "{text}" + ); + assert!(text.contains("your exit node"), "{text}"); + + network.exit.via_online = false; + let mut out = report::Report::new(); + out.push(network_section(&network, OWN, false)); + let text = out.render(false); + assert_eq!(out.worst(), Health::Broken, "{text}"); + assert!(text.contains("OFFLINE"), "{text}"); + assert!(text.contains("dropped until it returns"), "{text}"); + assert!(text.contains("tsunagi exit-node off"), "{text}"); + } + + #[test] + fn an_exit_node_is_resolved_by_name_or_id_and_only_among_those_that_offer() { + let mut network = network_after_a_peer_returned(); + let networks = |network: &NetworkReport| vec![network.clone()]; + + let err = resolve_exit_node(&networks(&network), "music", None).unwrap_err(); + assert!(err.contains("does not offer to be an exit node"), "{err}"); + let err = resolve_exit_node(&networks(&network), "nobody", None).unwrap_err(); + assert!(err.contains("no connected device matches"), "{err}"); + + exit_peer(&mut network, true); + let all = networks(&network); + for wanted in ["music", "MUSIC", ONLINE, &ONLINE[..8]] { + let (found_network, peer) = resolve_exit_node(&all, wanted, None).unwrap(); + assert_eq!(found_network.name, "LAB"); + assert_eq!(peer.endpoint_id, ONLINE); + } + assert!(resolve_exit_node(&all, "music", Some("other")).is_err()); + assert!(resolve_exit_node(&all, "music", Some("LAB")).is_ok()); + } + + #[test] + fn the_same_exit_node_in_two_networks_has_to_be_told_apart() { + let mut first = network_after_a_peer_returned(); + exit_peer(&mut first, true); + let mut second = first.clone(); + second.name = "OTHER".into(); + second.network_id = "zzzzzz".into(); + let both = vec![first, second]; + let err = resolve_exit_node(&both, "music", None).unwrap_err(); + assert!(err.contains("--network"), "{err}"); + assert!(err.contains("LAB") && err.contains("OTHER"), "{err}"); + let (network, _) = resolve_exit_node(&both, "music", Some("OTHER")).unwrap(); + assert_eq!(network.name, "OTHER"); + } + #[test] fn an_offline_member_keeps_the_last_name_it_announced() { let mut network = network_after_a_peer_returned(); @@ -5047,6 +5649,8 @@ mod network_tests { NetworkSecret::from_bytes(&[secret.as_bytes(), &[0u8; 32]].concat()[..32]).unwrap(); let keys = NetworkKeys::derive(&name, &secret); StoredNetwork { + exit_node: false, + exit_via: None, broadcast: true, network_id: keys.network_id(), name, @@ -5268,6 +5872,8 @@ mod network_context_tests { let secret = NetworkSecret::from_bytes([seed; 32]).unwrap(); let keys = NetworkKeys::derive(&name, &secret); StoredNetwork { + exit_node: false, + exit_via: None, broadcast: true, network_id: keys.network_id(), name, diff --git a/crates/tsunagi-cli/tests/network_cli.rs b/crates/tsunagi-cli/tests/network_cli.rs index b583248..1d48468 100644 --- a/crates/tsunagi-cli/tests/network_cli.rs +++ b/crates/tsunagi-cli/tests/network_cli.rs @@ -418,3 +418,46 @@ fn ambiguous_and_unknown_names_leave_every_network_untouched() { assert_eq!(remaining.len(), 1); assert_eq!(remaining[0].network_id, before[1].network_id); } + +#[test] +fn an_exit_node_is_offered_per_network_only_when_asked_and_nobody_offers_by_default() { + let agent = start_bare(0); + assert!(agent.run(&["join", "-n", "exit-offer"]).status.success()); + let offered = || { + read_networks(&agent.dir) + .iter() + .find(|n| n.name.as_str() == "exit-offer") + .unwrap() + .exit_node + }; + assert!(!offered(), "off unless somebody turns it on"); + + let on = agent.run(&["network", "exit-node", "exit-offer", "on"]); + assert!( + on.status.success(), + "{}", + String::from_utf8_lossy(&on.stderr) + ); + assert!(String::from_utf8_lossy(&on.stdout).contains("exit node on")); + assert!(offered()); + + let off = agent.run(&["network", "exit-node", "exit-offer", "off"]); + assert!(off.status.success()); + assert!(!offered()); + + let listed = agent.run(&["exit-node"]); + assert!(listed.status.success()); + assert!( + String::from_utf8_lossy(&listed.stdout).contains("no connected device offers"), + "{}", + String::from_utf8_lossy(&listed.stdout) + ); + + let unknown = agent.run(&["exit-node", "nobody"]); + assert!(!unknown.status.success()); + assert!( + String::from_utf8_lossy(&unknown.stderr).contains("no connected device matches"), + "{}", + String::from_utf8_lossy(&unknown.stderr) + ); +} diff --git a/crates/tsunagi-tray/src/agent.rs b/crates/tsunagi-tray/src/agent.rs index 3912a93..ba9d60b 100644 --- a/crates/tsunagi-tray/src/agent.rs +++ b/crates/tsunagi-tray/src/agent.rs @@ -25,6 +25,14 @@ pub(crate) enum Command { SetActive { network_id: String, active: bool }, /// Turn local broadcast participation on or off for a network. SetBroadcast { network_id: String, enabled: bool }, + /// Offer this device as an exit node in a network, or stop. + SetExitOffer { network_id: String, enabled: bool }, + /// Send all this device's internet traffic through a member of a + /// network, or (`None`) back the ordinary way. + SetExitNode { + network_id: String, + peer: Option, + }, /// Join a network, or start one already configured. Join { name: String, secret: String }, /// Leave a network entirely. @@ -185,6 +193,22 @@ async fn run( .await .map(|on| format!("broadcast {}", if on { "on" } else { "off" })) .map_err(|err| err.to_string()), + Command::SetExitOffer { + network_id, + enabled, + } => ipc::set_exit_offer(socket, &network_id, enabled) + .await + .map(|on| format!("exit node {}", if on { "on" } else { "off" })) + .map_err(|err| err.to_string()), + Command::SetExitNode { network_id, peer } => { + ipc::set_exit_node(socket, &network_id, peer.as_deref()) + .await + .map(|peer| match peer { + Some(_) => "now using the exit node".to_string(), + None => "stopped using an exit node".to_string(), + }) + .map_err(|err| err.to_string()) + } Command::Join { name, secret } => ipc::join_network(socket, &name, &secret) .await .map(|report| format!("joined {}", report.name)) diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index da6947d..bf1c6e6 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -112,15 +112,24 @@ impl App { let viewport_id = egui::ViewportId::from_hash_of(("devices", &id)); let builder = egui::ViewportBuilder::default() .with_title(devices::title(network)) - .with_inner_size([900.0, 540.0]) + .with_inner_size([940.0, 540.0]) .with_min_inner_size([480.0, 320.0]); let agent = &self.agent; let traffic = &self.traffic; // The window shares the single unit setting, so its toggle and the // main window's stay in step. let unit = &mut self.state.unit; + let confirm = &mut self.state.exit_confirm; let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| { - devices::show(vctx, agent, unit, network, &report.endpoint_id, traffic); + devices::show( + vctx, + agent, + unit, + confirm, + network, + &report.endpoint_id, + traffic, + ); !vctx.input(|i| i.viewport().close_requested()) }); if !keep { diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index 79e3151..1178d86 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -11,7 +11,8 @@ use eframe::egui; use tsunagi::ipc::{NetworkReport, OverlayPeerReport}; -use crate::agent::AgentClient; +use crate::agent::{AgentClient, Command}; +use crate::exit; use crate::format; use crate::stats::{Traffic, Unit}; @@ -33,9 +34,28 @@ struct Device<'a> { address: Option, handshake_secs: Option, overlay: Option<&'a OverlayPeerReport>, + /// It is connected and offers to be an exit node. + exit_node: bool, + /// It is the exit node this device sends its internet traffic through. + in_use: bool, +} + +/// An exit-node choice the user has to confirm: all their traffic goes through +/// somebody else's device, or stops doing so. +pub(crate) struct ExitConfirm { + network_id: String, + peer_id: String, + name: String, + /// Stop using it, rather than start. + stop: bool, } impl Device<'_> { + fn name(&self) -> String { + self.hostname + .map_or_else(|| format::short(self.id), str::to_string) + } + fn sort_key(&self) -> String { self.hostname.unwrap_or(self.id).to_lowercase() } @@ -64,8 +84,9 @@ fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) { /// Draws the window body into its viewport. pub(crate) fn show( ctx: &egui::Context, - _agent: &AgentClient, + agent: &AgentClient, unit: &mut Unit, + confirm: &mut Option, network: &NetworkReport, own_id: &str, traffic: &Traffic, @@ -102,9 +123,85 @@ pub(crate) fn show( egui::ScrollArea::both() .auto_shrink([false, false]) .show(ui, |ui| { - device_table(ui, unit, &network.network_id, &devices, traffic); + device_table(ui, unit, network, &devices, traffic, confirm); }); }); + confirm_exit(ctx, agent, confirm, &network.network_id); +} + +/// The confirmation for an exit-node choice, drawn in the window it was +/// made in. Everything behind it is blocked until it is answered. +fn confirm_exit( + ctx: &egui::Context, + agent: &AgentClient, + confirm: &mut Option, + network_id: &str, +) { + let Some(pending) = confirm.as_ref().filter(|c| c.network_id == network_id) else { + return; + }; + egui::Area::new(egui::Id::new("exit confirm backdrop")) + .order(egui::Order::Middle) + .fixed_pos(egui::Pos2::ZERO) + .show(ctx, |ui| { + let screen = ctx.screen_rect(); + ui.allocate_response(screen.size(), egui::Sense::click_and_drag()); + ui.painter() + .rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140)); + }); + + let (mut accepted, mut cancelled) = (false, false); + egui::Window::new(if pending.stop { + "Stop using exit node" + } else { + "Use as exit node" + }) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(380.0); + if pending.stop { + ui.label(format!("Stop using {} as exit node?", pending.name)); + ui.label("Your internet traffic will leave this device the ordinary way again."); + } else { + ui.label( + egui::RichText::new(format!( + "Send ALL your internet traffic through {}?", + pending.name + )) + .strong(), + ); + ui.label( + "Everything not on this network will leave through that device, which can \ + see it.", + ); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + let label = if pending.stop { + "Stop" + } else { + "Use as exit node" + }; + if ui.button(label).clicked() { + accepted = true; + } + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + if accepted { + agent.send(Command::SetExitNode { + network_id: pending.network_id.clone(), + peer: (!pending.stop).then(|| pending.peer_id.clone()), + }); + } + if accepted || cancelled || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + *confirm = None; + } } /// The network's own settings and counters. @@ -165,7 +262,8 @@ fn known_ids<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<&'a str> { .iter() .map(|p| p.endpoint_id.as_str()) .chain(network.peers.iter().map(|p| p.endpoint_id.as_str())) - .chain(network.members.iter().map(|m| m.endpoint_id.as_str())); + .chain(network.members.iter().map(|m| m.endpoint_id.as_str())) + .chain(network.exit.via.as_deref()); for id in candidates { if id != own_id && !ids.contains(&id) { ids.push(id); @@ -221,6 +319,12 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec> { .or_else(|| member.and_then(|m| m.overlay_address_v4.clone())), handshake_secs: overlay.and_then(|p| p.handshake_secs_ago), overlay, + exit_node: online + && network + .peers + .iter() + .any(|p| p.endpoint_id == id && p.exit_node), + in_use: network.exit.via.as_deref() == Some(id), } }) .collect(); @@ -235,8 +339,9 @@ 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; 8] = [ +const COLUMN_SAMPLES: [&str; 9] = [ "", + "exit", "tcp-tls", "255.255.255.255", "23h 59m", @@ -252,11 +357,11 @@ const COLUMN_SAMPLES: [&str; 8] = [ /// 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; 8] { - let stored: Option<[f32; 8]> = ui.memory(|m| m.data.get_temp(id)); +fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 9] { + let stored: Option<[f32; 9]> = 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; 8]; + let mut widths = [0.0; 9]; for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) { if !sample.is_empty() { *width = ui.fonts(|fonts| { @@ -272,7 +377,7 @@ fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 8] { } /// One cell, at least as wide as its column has ever been. -fn cell(ui: &mut egui::Ui, column: usize, widths: &mut [f32; 8], add: impl FnOnce(&mut egui::Ui)) { +fn cell(ui: &mut egui::Ui, column: usize, widths: &mut [f32; 9], add: impl FnOnce(&mut egui::Ui)) { let floor = widths[column]; let response = ui.scope(|ui| { ui.set_min_width(floor); @@ -281,15 +386,66 @@ fn cell(ui: &mut egui::Ui, column: usize, widths: &mut [f32; 8], add: impl FnOnc widths[column] = widths[column].max(response.response.rect.width()); } +/// The exit-node mark of a device: shown when it offers to be one, bright +/// for the one in use, and clickable (to confirm) only while that can be done. +/// +/// The one in use stays clickable even when it has gone away, since stopping +/// is the only way out of the traffic being blocked. +fn exit_cell( + ui: &mut egui::Ui, + device: &Device<'_>, + network_id: &str, + confirm: &mut Option, +) { + if !device.exit_node && !device.in_use { + ui.label(""); + return; + } + let (color, hint) = if device.in_use { + let color = if device.online { + exit::GOOD + } else { + exit::LOUD + }; + (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", + ) + }; + let response = ui + .add( + egui::Label::new( + egui::RichText::new(egui_phosphor::regular::SIGN_OUT) + .size(16.0) + .color(color), + ) + .sense(egui::Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text(hint); + if response.clicked() { + *confirm = Some(ExitConfirm { + network_id: network_id.to_string(), + peer_id: device.id.to_string(), + name: device.name(), + stop: device.in_use, + }); + } +} + /// One table of all devices: online rows carry live counters and a plot, /// offline rows show what is known (name and address) and dashes. fn device_table( ui: &mut egui::Ui, unit: Unit, - network_id: &str, + network: &NetworkReport, devices: &[Device<'_>], traffic: &Traffic, + confirm: &mut Option, ) { + let network_id = network.network_id.as_str(); if devices.is_empty() { ui.weak("none"); return; @@ -297,12 +453,13 @@ fn device_table( let widths_id = egui::Id::new(("devices column widths", network_id)); let mut widths = column_widths(ui, widths_id); egui::Grid::new("devices") - .num_columns(8) + .num_columns(9) .striped(true) .spacing([10.0, 4.0]) .show(ui, |ui| { for (column, header) in [ "device", + "exit", "proto", "address", "handshake", @@ -323,15 +480,18 @@ fn device_table( for device in devices { cell(ui, 0, &mut widths, |ui| name_cell(ui, device)); cell(ui, 1, &mut widths, |ui| { + exit_cell(ui, device, network_id, confirm); + }); + cell(ui, 2, &mut widths, |ui| { ui.label(device.proto); }); - cell(ui, 2, &mut widths, |ui| match &device.address { + cell(ui, 3, &mut widths, |ui| match &device.address { Some(address) => format::copy_field(ui, address, address), None => { ui.weak("—"); } }); - cell(ui, 3, &mut widths, |ui| { + cell(ui, 4, &mut widths, |ui| { ui.label( device .handshake_secs @@ -342,17 +502,17 @@ fn device_table( match device.overlay.filter(|_| device.online) { Some(peer) => { let series = traffic.peer(network_id, &peer.public_key); - cell(ui, 4, &mut widths, |ui| { + cell(ui, 5, &mut widths, |ui| { ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets)); }); - cell(ui, 5, &mut widths, |ui| { + cell(ui, 6, &mut widths, |ui| { ui.label(format!( "{} / {}", format::bytes(peer.tx_bytes as f64), format::bytes(peer.rx_bytes as f64) )); }); - cell(ui, 6, &mut widths, |ui| { + cell(ui, 7, &mut widths, |ui| { ui.add( egui::Label::new(format::series_rate(series, unit)) .wrap_mode(egui::TextWrapMode::Extend), @@ -363,7 +523,7 @@ fn device_table( }); } None => { - for column in 4..7 { + for column in 5..8 { cell(ui, column, &mut widths, |ui| { ui.weak("—"); }); @@ -386,22 +546,22 @@ fn device_table( cell(ui, 0, &mut widths, |ui| { ui.strong("total"); }); - for column in 1..4 { + for column in 1..5 { cell(ui, column, &mut widths, |ui| { ui.label(""); }); } - cell(ui, 4, &mut widths, |ui| { + cell(ui, 5, &mut widths, |ui| { ui.strong(format!("{tx_packets}/{rx_packets}")); }); - cell(ui, 5, &mut widths, |ui| { + cell(ui, 6, &mut widths, |ui| { ui.strong(format!( "{} / {}", format::bytes(tx_bytes as f64), format::bytes(rx_bytes as f64) )); }); - cell(ui, 6, &mut widths, |ui| { + cell(ui, 7, &mut widths, |ui| { ui.add( egui::Label::new( egui::RichText::new(format::series_rate(traffic.network(network_id), unit)) @@ -412,10 +572,7 @@ fn device_table( }); ui.end_row(); }); - ui.memory_mut(|m| { - m.data - .insert_temp(egui::Id::new("devices column widths"), widths) - }); + ui.memory_mut(|m| m.data.insert_temp(widths_id, widths)); } fn yes_no(value: bool) -> &'static str { diff --git a/crates/tsunagi-tray/src/exit.rs b/crates/tsunagi-tray/src/exit.rs new file mode 100644 index 0000000..ebbc64a --- /dev/null +++ b/crates/tsunagi-tray/src/exit.rs @@ -0,0 +1,164 @@ +//! Exit nodes as the windows show them: what to call one, and what is wrong +//! with the ones in play. + +use eframe::egui; + +use tsunagi::ipc::NetworkReport; + +use crate::format; + +/// An exit node that is working. +pub(crate) const GOOD: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a); +/// Something about exit nodes the user has to look at. +pub(crate) const LOUD: egui::Color32 = egui::Color32::from_rgb(0xe0, 0x4a, 0x3c); + +/// What to run to turn kernel forwarding on, which the agent never does. +pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1"; + +/// One thing wrong, loud enough to read at a glance. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Warning { + pub(crate) text: String, + /// A command the user will want, copied by clicking the warning. + pub(crate) copy: Option<&'static str>, +} + +/// What to call a device in a network, even while it is away. +pub(crate) fn name_of(network: &NetworkReport, id: &str) -> String { + network + .peers + .iter() + .find(|peer| peer.endpoint_id == id) + .and_then(|peer| peer.hostname.clone()) + .or_else(|| { + network + .members + .iter() + .find(|member| member.endpoint_id == id) + .and_then(|member| member.hostname.clone()) + }) + .unwrap_or_else(|| format::short(id)) +} + +/// Everything wrong with a network's exit-node settings, most urgent first. +pub(crate) fn warnings(network: &NetworkReport) -> Vec { + let exit = &network.exit; + let mut out = Vec::new(); + + if let Some(via) = &exit.via { + let name = name_of(network, via); + if !exit.via_online { + out.push(Warning { + text: format!( + "exit node {name} is offline — your internet traffic is blocked until it \ + returns; stop using it from the devices window" + ), + copy: None, + }); + } else { + match &exit.client_rules { + Some(rules) if rules.ok => {} + Some(rules) => out.push(Warning { + text: format!( + "the routes to exit node {name} are incomplete: {}", + rules.detail + ), + copy: None, + }), + None => out.push(Warning { + text: format!("the routes to exit node {name} are not installed"), + copy: None, + }), + } + } + } + + if exit.offering { + match &exit.offer_rules { + Some(rules) if !rules.ok => out.push(Warning { + text: format!("exit node: its rules are incomplete: {}", rules.detail), + copy: None, + }), + None => out.push(Warning { + text: "exit node: its rules are not installed yet".to_string(), + copy: None, + }), + Some(_) => {} + } + if exit.forwarding == Some(false) { + out.push(Warning { + text: format!("exit node: kernel forwarding is OFF — run: {FORWARDING_COMMAND}"), + copy: Some(FORWARDING_COMMAND), + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use tsunagi::ipc::{ExitReport, PeerReport, RuleSetReport}; + + fn network(exit: ExitReport) -> NetworkReport { + NetworkReport { + peers: vec![PeerReport { + endpoint_id: "abcdef0123456789".into(), + hostname: Some("music".into()), + exit_node: true, + ..Default::default() + }], + exit, + ..Default::default() + } + } + + fn ok() -> Option { + Some(RuleSetReport { + ok: true, + detail: "ok".into(), + }) + } + + #[test] + fn a_network_with_no_exit_settings_has_nothing_to_warn_about() { + assert!(warnings(&network(ExitReport::default())).is_empty()); + } + + #[test] + fn a_lost_exit_node_says_the_traffic_is_blocked() { + let found = warnings(&network(ExitReport { + via: Some("abcdef0123456789".into()), + via_online: false, + client_rules: ok(), + ..Default::default() + })); + assert_eq!(found.len(), 1); + assert!( + found[0].text.contains("music is offline"), + "{}", + found[0].text + ); + assert!(found[0].text.contains("blocked"), "{}", found[0].text); + } + + #[test] + fn a_working_exit_node_is_quiet_and_forwarding_off_gives_the_command() { + let working = ExitReport { + via: Some("abcdef0123456789".into()), + via_online: true, + client_rules: ok(), + ..Default::default() + }; + assert!(warnings(&network(working)).is_empty()); + + let offering = warnings(&network(ExitReport { + offering: true, + offer_rules: ok(), + forwarding: Some(false), + ..Default::default() + })); + assert_eq!(offering.len(), 1); + assert_eq!(offering[0].copy, Some(FORWARDING_COMMAND)); + } +} diff --git a/crates/tsunagi-tray/src/main.rs b/crates/tsunagi-tray/src/main.rs index 3c1357d..73a156c 100644 --- a/crates/tsunagi-tray/src/main.rs +++ b/crates/tsunagi-tray/src/main.rs @@ -13,6 +13,7 @@ mod about; mod agent; mod app; mod devices; +mod exit; mod format; mod stats; mod tray; diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index 22b9487..17de09c 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -16,6 +16,7 @@ use tsunagi::ipc::{NetworkReport, StatusReport}; use crate::agent::{AgentClient, Command, Snapshot}; use crate::devices; +use crate::exit; use crate::format; use crate::stats::{Traffic, Unit}; @@ -42,6 +43,8 @@ pub(crate) struct UiState { pub(crate) open_devices: BTreeSet, /// Whether the About window is open. pub(crate) about_open: bool, + /// An exit-node choice waiting for the user to confirm it. + pub(crate) exit_confirm: Option, } impl Default for UiState { @@ -52,6 +55,7 @@ impl Default for UiState { host_edit: None, open_devices: BTreeSet::new(), about_open: false, + exit_confirm: None, } } } @@ -261,6 +265,36 @@ fn draw_network( enabled: broadcast, }); } + let mut offering = network.exit.offering; + if ui + .checkbox(&mut offering, "exit node") + .on_hover_text( + "let other members send all their internet traffic through this \ + device, which can see it", + ) + .changed() + { + agent.send(Command::SetExitOffer { + network_id: network.network_id.clone(), + enabled: offering, + }); + } + }); + ui.horizontal(|ui| { + if let Some(via) = &network.exit.via { + ui.weak("exit"); + ui.label(exit::name_of(network, via)); + if ui + .small_button("off") + .on_hover_text("send internet traffic the ordinary way again") + .clicked() + { + agent.send(Command::SetExitNode { + network_id: network.network_id.clone(), + peer: None, + }); + } + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Show devices").clicked() { state.open_devices.insert(network.network_id.clone()); @@ -276,9 +310,35 @@ fn draw_network( } }); }); + for warning in exit::warnings(network) { + draw_exit_warning(ui, &warning); + } }); } +/// A prominent line about an exit node. One that carries a command copies it +/// when clicked. +fn draw_exit_warning(ui: &mut egui::Ui, warning: &exit::Warning) { + let text = egui::RichText::new(format!("{} {}", icon::WARNING, warning.text)) + .color(exit::LOUD) + .strong(); + let label = egui::Label::new(text).wrap(); + match warning.copy { + Some(command) => { + let response = ui + .add(label.sense(egui::Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text("click to copy the command"); + if response.clicked() { + ui.ctx().copy_text(command.to_owned()); + } + } + None => { + ui.add(label); + } + } +} + /// The join form: aligned name and secret, a generate button, and Join. fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) { const INPUT_WIDTH: f32 = 208.0; diff --git a/crates/tsunagi-wg-quic/tests/exit_node.rs b/crates/tsunagi-wg-quic/tests/exit_node.rs new file mode 100644 index 0000000..56229ba --- /dev/null +++ b/crates/tsunagi-wg-quic/tests/exit_node.rs @@ -0,0 +1,313 @@ +//! Exit nodes, end to end: real agents, a real control plane, real iroh data +//! links and real WireGuard, with an in-memory packet interface and a mock +//! host, so no privileges are needed and the machine's network is untouched. +//! +//! One agent offers itself as an exit node; another chooses it, and an +//! ordinary internet packet leaves the second agent's interface, arrives on +//! the first one's, and the answer comes back with the internet's own source +//! address. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::net::Ipv4Addr; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use iroh::EndpointId; +use tempfile::TempDir; +use tsunagi::dataplane::IpPlugin; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::NetworkId; +use tsunagi::overlay::{ + BroadcastHostRules, MemoryTun, MemoryTunFactory, MockHostRules, OverlayError, TunDevice, + TunFactory, TunRequest, +}; +use tsunagi::testing::{network, wait_until}; +use tsunagi::{Agent, NetworkStatus}; +use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin}; + +const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34); + +/// An in-memory interface on a host whose rules are a mock. +/// +/// Not "on the host": the agent must not go looking for an address on a real +/// link. It only has host *rules*, which is what an exit node needs from it. +#[derive(Debug, Clone)] +struct RuledTuns { + tuns: MemoryTunFactory, + rules: MockHostRules, +} + +impl TunFactory for RuledTuns { + fn name(&self) -> &str { + "memory-with-rules" + } + + fn on_host(&self) -> bool { + false + } + + fn host_rules(&self) -> Option> { + Some(Arc::new(self.rules.clone())) + } + + fn create<'a>( + &'a self, + request: TunRequest, + ) -> tsunagi::BoxFuture<'a, Result, OverlayError>> { + self.tuns.create(request) + } + + fn reconfigure<'a>( + &'a self, + request: TunRequest, + ) -> tsunagi::BoxFuture<'a, Result<(), OverlayError>> { + self.tuns.reconfigure(request) + } + + fn destroy<'a>(&'a self, name: &'a str) -> tsunagi::BoxFuture<'a, ()> { + self.tuns.destroy(name) + } +} + +struct Node { + _dir: TempDir, + agent: Agent, + plugin: Arc, + tuns: MemoryTunFactory, + rules: MockHostRules, +} + +impl Node { + async fn spawn(discovery: &SharedMemoryDiscovery, tag: &str) -> Self { + let dir = TempDir::new().unwrap(); + let tuns = MemoryTunFactory::new(); + let rules = MockHostRules::new(); + let plugin = WireguardPlugin::open( + WireguardConfig::new(dir.path().join("wireguard")) + .with_reconcile(Duration::from_millis(20), Duration::from_millis(250)), + ) + .await + .unwrap(); + let agent = Agent::spawn( + tsunagi::testing::config_with(dir.path(), discovery) + .with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE)) + .with_interface( + Arc::new(RuledTuns { + tuns: tuns.clone(), + rules: rules.clone(), + }), + tag, + 1280, + ) + .with_plugin(plugin.clone() as Arc), + ) + .await + .unwrap(); + Self { + _dir: dir, + agent, + plugin, + tuns, + rules, + } + } + + fn id(&self) -> EndpointId { + self.agent.endpoint_id() + } + + async fn overlay(&self, network: NetworkId) -> Ipv4Addr { + wait_until("the network agreed an overlay address", || async { + self.plugin.overview(network)?.overlay_address_v4 + }) + .await + } + + async fn tun(&self) -> Arc { + let name = wait_until("the packet interface exists", || async { + self.agent.overlay().map(|overlay| overlay.interface) + }) + .await; + self.tuns.device(&name).expect("the device was created") + } + + async fn wait_for_tunnels(&self, network: NetworkId, count: usize) { + wait_until("established WireGuard tunnels", || async { + let view = self.plugin.overview(network)?; + (view.established_peers() == count).then_some(()) + }) + .await; + } + + async fn status(&self, network: NetworkId) -> NetworkStatus { + self.agent.network_status(network).await.unwrap() + } +} + +fn ipv4_packet(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes { + let total = 20 + payload.len(); + let mut packet = Vec::with_capacity(total); + packet.push((4 << 4) | 5); + packet.push(0); + packet.extend_from_slice(&(total as u16).to_be_bytes()); + packet.extend_from_slice(&[0, 0, 0, 0]); + packet.push(64); + packet.push(253); + packet.extend_from_slice(&[0, 0]); + packet.extend_from_slice(&source.octets()); + packet.extend_from_slice(&destination.octets()); + packet.extend_from_slice(payload); + Bytes::from(packet) +} + +async fn next(tun: &MemoryTun) -> Bytes { + tokio::time::timeout(Duration::from_secs(10), tun.pop_to_os()) + .await + .expect("a packet reaches the operating system") + .unwrap() +} + +#[tokio::test] +async fn a_member_sends_its_internet_traffic_through_an_exit_node_and_gets_the_answers() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("exit-node"); + let server = Node::spawn(&discovery, "texita").await; + let client = Node::spawn(&discovery, "texitb").await; + let id = server.agent.join_network(&name, &secret).await.unwrap(); + client.agent.join_network(&name, &secret).await.unwrap(); + server.wait_for_tunnels(id, 1).await; + client.wait_for_tunnels(id, 1).await; + let server_addr = server.overlay(id).await; + let client_addr = client.overlay(id).await; + let server_tun = server.tun().await; + let client_tun = client.tun().await; + + // Off until its owner turns it on: nothing is announced, and choosing it + // is refused rather than quietly accepted. + assert!(!server.status(id).await.exit_offer); + assert!( + client + .status(id) + .await + .peers + .iter() + .all(|peer| !peer.exit_node) + ); + let refused = client.agent.set_exit_via(id, Some(server.id())).await; + assert!(refused.is_err(), "{refused:?}"); + assert!(client.status(id).await.exit_via.is_none()); + + // The server turns it on. Its host rules are applied, and the client + // learns of it over the control channel. + server.agent.set_exit_offer(id, true).await.unwrap(); + let plan = server.rules.exit().installed().expect("rules were applied"); + assert_eq!(plan.offer, vec![tsunagi::state::DEFAULT_IPV4_RANGE]); + assert!(!plan.client); + let status = server.status(id).await; + assert!(status.exit_offer); + assert!(status.exit_rules.offer.as_ref().unwrap().ok); + wait_until("the client sees an exit node", || async { + client + .status(id) + .await + .peers + .iter() + .any(|peer| peer.endpoint_id == server.id() && peer.exit_node) + .then_some(()) + }) + .await; + + // The client chooses it; its routes are installed. + client + .agent + .set_exit_via(id, Some(server.id())) + .await + .unwrap(); + let status = client.status(id).await; + assert_eq!(status.exit_via, Some(server.id())); + assert!(status.exit_via_online); + assert!( + client + .rules + .exit() + .installed() + .expect("client rules") + .client + ); + + // An ordinary packet for the internet leaves the client's interface and + // arrives on the server's, from the client's overlay address. + client_tun.push_from_os(ipv4_packet(client_addr, INTERNET, b"GET /")); + let request = next(&server_tun).await; + assert_eq!(&request[12..16], &client_addr.octets()); + assert_eq!(&request[16..20], &INTERNET.octets()); + assert_eq!(&request[20..], b"GET /"); + + // The host's answer, from the internet's own address, comes back. + server_tun.push_from_os(ipv4_packet(INTERNET, client_addr, b"200 OK")); + let reply = next(&client_tun).await; + assert_eq!(&reply[12..16], &INTERNET.octets()); + assert_eq!(&reply[16..20], &client_addr.octets()); + assert_eq!(&reply[20..], b"200 OK"); + + // Members still reach each other directly, exit node or not. + client_tun.push_from_os(ipv4_packet(client_addr, server_addr, b"ping")); + let direct = next(&server_tun).await; + assert_eq!(&direct[12..16], &client_addr.octets()); + assert_eq!(&direct[16..20], &server_addr.octets()); + + // Stopping puts everything back, and the choice is gone. + client.agent.set_exit_via(id, None).await.unwrap(); + assert!(client.status(id).await.exit_via.is_none()); + assert!(client.rules.exit().installed().is_none()); + server.agent.set_exit_offer(id, false).await.unwrap(); + assert!(server.rules.exit().installed().is_none()); + wait_until("the client sees it withdrawn", || async { + client + .status(id) + .await + .peers + .iter() + .all(|peer| !peer.exit_node) + .then_some(()) + }) + .await; + + client.agent.shutdown().await; + server.agent.shutdown().await; +} + +#[tokio::test] +async fn an_agent_that_is_not_offering_drops_a_members_internet_packets() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("exit-node-off"); + let server = Node::spawn(&discovery, "texitc").await; + let client = Node::spawn(&discovery, "texitd").await; + let id = server.agent.join_network(&name, &secret).await.unwrap(); + client.agent.join_network(&name, &secret).await.unwrap(); + server.wait_for_tunnels(id, 1).await; + client.wait_for_tunnels(id, 1).await; + let client_addr = client.overlay(id).await; + let server_tun = server.tun().await; + let client_tun = client.tun().await; + + // With no exit node chosen, the client's own interface has nowhere to + // send the packet, and nothing reaches the server. + client_tun.push_from_os(ipv4_packet(client_addr, INTERNET, b"nowhere")); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + tokio::time::timeout(Duration::from_millis(300), server_tun.pop_to_os()) + .await + .is_err(), + "nothing was sent" + ); + assert!( + server.rules.exit().installed().is_none(), + "no rules without the switch" + ); + + client.agent.shutdown().await; + server.agent.shutdown().await; +} diff --git a/crates/tsunagi-wg-quic/tests/local_control.rs b/crates/tsunagi-wg-quic/tests/local_control.rs index 5a538df..3bc8d89 100644 --- a/crates/tsunagi-wg-quic/tests/local_control.rs +++ b/crates/tsunagi-wg-quic/tests/local_control.rs @@ -41,6 +41,7 @@ fn source(agent: Agent, plugin: Arc) -> Arc RangePlan { let reserve = |wanted: Ipv4Range| { let reservation = crate::overlay::NetworkRoutes { + exit: Default::default(), broadcast: Default::default(), range: Some(wanted), local: None, @@ -603,6 +604,72 @@ impl Agent { } } + /// Offers this agent as an exit node in a network, or stops, now and + /// after restart. Off unless somebody turns it on. + pub async fn set_exit_offer(&self, network_id: NetworkId, enabled: bool) -> Result<()> { + self.inner + .storage + .set_exit_node(network_id, enabled) + .await?; + if self.is_active(network_id).await { + let (reply, receive) = oneshot::channel(); + self.command(network_id, NetCommand::SetExitOffer { enabled, reply }) + .await?; + receive.await.map_err(|_| Error::Stopped)?; + } + Ok(()) + } + + /// Sends this device's internet traffic through a member of a network, + /// or back the ordinary way with `None`. + /// + /// A device has one default route, so choosing in one network clears the + /// choice in the others. The member has to be connected and offering + /// to be an exit node. Remembered across restarts. + pub async fn set_exit_via(&self, network_id: NetworkId, via: Option) -> Result<()> { + let active = self.is_active(network_id).await; + if via.is_some() && !active { + return Err(Error::NetworkNotActive(network_id)); + } + if active { + let (reply, receive) = oneshot::channel(); + self.command(network_id, NetCommand::SetExitVia { via, reply }) + .await?; + receive + .await + .map_err(|_| Error::Stopped)? + .map_err(|reason| Error::Rejected { reason })?; + } + self.inner + .storage + .set_exit_via(network_id, via.map(|peer| *peer.as_bytes())) + .await?; + if via.is_some() { + // One default route: whatever the other networks were doing, they + // are not any more. + let others: Vec = self + .inner + .networks + .read() + .await + .keys() + .copied() + .filter(|other| *other != network_id) + .collect(); + for other in others { + let (reply, receive) = oneshot::channel(); + if self + .command(other, NetCommand::SetExitVia { via: None, reply }) + .await + .is_ok() + { + let _ = receive.await; + } + } + } + Ok(()) + } + /// Changes a network's local broadcast policy now and after restart. pub async fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> { self.inner @@ -662,16 +729,23 @@ impl Agent { as Arc, ) }; - let broadcast = self + let stored = self .inner .storage .list_networks() .await? .into_iter() - .find(|stored| stored.network_id == network_id) - .is_none_or(|stored| stored.broadcast); + .find(|stored| stored.network_id == network_id); + let broadcast = stored.as_ref().is_none_or(|stored| stored.broadcast); + let exit_offer = stored.as_ref().is_some_and(|stored| stored.exit_node); + let exit_via = stored + .as_ref() + .and_then(|stored| stored.exit_via) + .and_then(|bytes| EndpointId::from_bytes(&bytes).ok()); let handle = network::spawn(RuntimeParams { broadcast, + exit_offer, + exit_via, keys, adapter: self.inner.adapter.clone(), storage: self.inner.storage.clone(), @@ -733,6 +807,7 @@ impl Agent { } if let Some(interface) = self.inner.interface.get() { interface.sync_broadcast_rules().await; + interface.sync_exit_rules().await; } self.inner.storage.set_auto_start(network_id, false).await?; Ok(()) @@ -886,6 +961,12 @@ impl Agent { let keys = NetworkKeys::derive(&stored.name, &stored.secret); networks.push(NetworkStatus { broadcast: stored.broadcast, + exit_offer: stored.exit_node, + exit_via: stored + .exit_via + .and_then(|bytes| EndpointId::from_bytes(&bytes).ok()), + exit_via_online: false, + exit_rules: Default::default(), descriptor: keys.descriptor(), name: stored.name, network_id: stored.network_id, diff --git a/crates/tsunagi/src/agent/network.rs b/crates/tsunagi/src/agent/network.rs index 2052d51..0def324 100644 --- a/crates/tsunagi/src/agent/network.rs +++ b/crates/tsunagi/src/agent/network.rs @@ -66,6 +66,16 @@ pub(crate) enum NetCommand { enabled: bool, reply: oneshot::Sender<()>, }, + /// Offer this agent as an exit node in this network, or stop. + SetExitOffer { + enabled: bool, + reply: oneshot::Sender<()>, + }, + /// Send this device's internet traffic through a member, or stop. + SetExitVia { + via: Option, + reply: oneshot::Sender>, + }, /// Resend this agent's announcement to every peer of this network. Reannounce, /// Answer to a different name from now on. @@ -106,6 +116,8 @@ impl std::fmt::Debug for NetCommand { NetCommand::Status { .. } => f.write_str("Status"), NetCommand::Recheck => f.write_str("Recheck"), NetCommand::SetBroadcast { enabled, .. } => write!(f, "SetBroadcast({enabled})"), + NetCommand::SetExitOffer { enabled, .. } => write!(f, "SetExitOffer({enabled})"), + NetCommand::SetExitVia { via, .. } => write!(f, "SetExitVia({})", via.is_some()), NetCommand::Reannounce => f.write_str("Reannounce"), NetCommand::SetHostname(_) => f.write_str("SetHostname"), NetCommand::Release { .. } => f.write_str("Release"), @@ -137,6 +149,10 @@ impl NetworkHandle { /// Everything a network runtime needs to run. pub(crate) struct RuntimeParams { pub(crate) broadcast: bool, + /// This agent offers itself as an exit node here. + pub(crate) exit_offer: bool, + /// The member this device's internet traffic goes through. + pub(crate) exit_via: Option, pub(crate) keys: NetworkKeys, pub(crate) adapter: EndpointAdapter, pub(crate) storage: Storage, @@ -331,6 +347,11 @@ struct Runtime { pending_state: Vec<(EndpointId, Vec)>, /// The highest version this agent has ever published for this network. own_version: u64, + /// Whether the exit node in use was reachable when last looked at, so + /// that losing it is said once and getting it back is too. + exit_via_was_online: bool, + /// What the last announcement said about being an exit node. + announced_exit: bool, /// The last hostname each peer announced, kept across restarts so a /// member that is away can still be called by name. known_hostnames: HashMap, @@ -385,6 +406,8 @@ impl Runtime { state: StateSet::new(), pending_state: Vec::new(), own_version: 0, + exit_via_was_online: false, + announced_exit: false, known_hostnames: HashMap::new(), } } @@ -505,6 +528,27 @@ impl Runtime { self.reannounce(); let _ = reply.send(()); } + NetCommand::SetExitOffer { enabled, reply } => { + self.params.exit_offer = enabled; + self.apply_exit().await; + self.reannounce(); + let _ = reply.send(()); + } + NetCommand::SetExitVia { via, reply } => { + let outcome = match via { + Some(peer) if !self.exit_node_online(peer) => Err( + "that member is not connected or does not offer to be an exit node" + .to_string(), + ), + _ => Ok(()), + }; + if outcome.is_ok() { + self.params.exit_via = via; + self.exit_via_was_online = via.is_some(); + self.apply_exit().await; + } + let _ = reply.send(outcome); + } NetCommand::Recheck => self.discovery_round().await, NetCommand::Reannounce => self.reannounce(), NetCommand::Release { reply } => { @@ -1110,6 +1154,75 @@ impl Runtime { } } + fn exit_policy(&self) -> crate::overlay::ExitPolicy { + crate::overlay::ExitPolicy { + offer: self.params.exit_offer, + via: self.params.exit_via, + } + } + + /// Whether this agent offers to be an exit node and can really be one. + fn exit_ready(&self) -> bool { + self.params.exit_offer + && self + .params + .interface + .as_ref() + .is_some_and(|interface| interface.exit_offer_ready(self.network_id)) + } + + /// Whether a member is connected and says it is an exit node. + fn exit_node_online(&self, peer: EndpointId) -> bool { + self.sessions + .get(&peer) + .is_some_and(|session| session.exit_node) + } + + /// Puts the exit settings into the routing table and the host rules. + /// + /// The routes and the rules stay as they are when the exit node in use + /// goes away: dropping them would send the traffic out the old way, in + /// the clear, without anyone having asked. + async fn apply_exit(&mut self) { + self.params + .routes + .set_exit(self.network_id, self.exit_policy()); + if let Some(interface) = self.params.interface.clone() { + interface.sync_exit_rules().await; + } + // Said only once it is true: the rules may not have taken, or may + // only now be possible. + if self.exit_ready() != self.announced_exit { + self.reannounce(); + } + } + + /// Says so, once, when the exit node in use is lost or comes back. + fn watch_exit_via(&mut self) { + let Some(via) = self.params.exit_via else { + return; + }; + let online = self.exit_node_online(via); + if online == self.exit_via_was_online { + return; + } + self.exit_via_was_online = online; + if online { + tracing::info!( + network = %self.network_id, + exit_node = %via.fmt_short(), + "the exit node is back" + ); + } else { + tracing::warn!( + network = %self.network_id, + exit_node = %via.fmt_short(), + "the exit node is gone or no longer offers to be one; internet traffic stays \ + routed through it and is dropped until it returns or `tsunagi exit-node off`" + ); + } + } + fn update_broadcast(&self) { self.params .routes @@ -1138,6 +1251,7 @@ impl Runtime { // a packet for ourselves does not go over a tunnel. let local = self.state.address_of(&self.local_id); let routes = crate::overlay::NetworkRoutes { + exit: self.exit_policy(), broadcast: self.broadcast_policy(), range: Some(range), local, @@ -1234,6 +1348,7 @@ impl Runtime { // only once the address is really on the interface. if !is_missing { interface.sync_broadcast_rules().await; + self.apply_exit().await; } } @@ -1798,8 +1913,11 @@ impl Runtime { }); } capabilities.truncate(self.params.limits.max_capabilities); + let exit_node = self.exit_ready(); + self.announced_exit = exit_node; Announcement { broadcast: self.params.broadcast, + exit_node, hostname: self.params.hostname.clone(), capabilities, } @@ -1878,6 +1996,7 @@ impl Runtime { self.metrics.disconnects += 1; self.drop_links_for(peer); self.update_broadcast(); + self.watch_exit_via(); self.update_paths(); self.announce_reach(false); for plugin in &self.params.plugins { @@ -1934,10 +2053,12 @@ impl Runtime { if let Some(session) = self.sessions.get_mut(&peer) { session.hostname = Some(announcement.hostname.clone()); session.broadcast = announcement.broadcast; + session.exit_node = announcement.exit_node; session.capabilities = capabilities.clone(); } self.remember_hostname(peer, &announcement.hostname); self.update_broadcast(); + self.watch_exit_via(); self.dispatch_capabilities(peer, &capabilities); self.ensure_links(); self.update_paths(); @@ -2035,6 +2156,7 @@ impl Runtime { let snapshot = snapshot_connection(&session.conn); PeerStatus { broadcast: session.broadcast, + exit_node: session.exit_node, endpoint_id: session.peer, role: session.role, hostname: session @@ -2107,6 +2229,18 @@ impl Runtime { NetworkStatus { broadcast: self.params.broadcast, + exit_offer: self.params.exit_offer, + exit_via: self.params.exit_via, + exit_via_online: self + .params + .exit_via + .is_some_and(|via| self.exit_node_online(via)), + exit_rules: self + .params + .interface + .as_ref() + .map(|interface| interface.exit_rules(self.network_id)) + .unwrap_or_default(), descriptor: self.params.keys.descriptor(), name: self.params.keys.name().clone(), network_id: self.network_id, diff --git a/crates/tsunagi/src/agent/session.rs b/crates/tsunagi/src/agent/session.rs index a44e2a9..4543dd2 100644 --- a/crates/tsunagi/src/agent/session.rs +++ b/crates/tsunagi/src/agent/session.rs @@ -74,6 +74,8 @@ pub(crate) struct Session { pub(crate) hostname: Option, pub(crate) capabilities: Vec, pub(crate) broadcast: bool, + /// The peer offers itself as an exit node in this network. + pub(crate) exit_node: bool, pub(crate) messages_sent: u64, pub(crate) messages_received: u64, pub(crate) bytes_sent: u64, @@ -228,6 +230,7 @@ pub(crate) fn spawn( hostname: None, capabilities: Vec::new(), broadcast: false, + exit_node: false, messages_sent: 0, messages_received: 0, bytes_sent: 0, diff --git a/crates/tsunagi/src/agent/status.rs b/crates/tsunagi/src/agent/status.rs index 5a495b9..085c332 100644 --- a/crates/tsunagi/src/agent/status.rs +++ b/crates/tsunagi/src/agent/status.rs @@ -92,6 +92,8 @@ pub struct MemberStatus { pub struct PeerStatus { /// This authenticated peer accepts network broadcasts. pub broadcast: bool, + /// This authenticated peer offers itself as an exit node. + pub exit_node: bool, /// Authenticated endpoint id. pub endpoint_id: EndpointId, /// Which side this agent played in the handshake. @@ -164,6 +166,14 @@ pub struct NetworkMetrics { pub struct NetworkStatus { /// Local broadcast participation in this network. pub broadcast: bool, + /// This agent offers itself as an exit node in this network. + pub exit_offer: bool, + /// The member this device sends all its internet traffic through. + pub exit_via: Option, + /// Whether that member is connected and still offering to be one. + pub exit_via_online: bool, + /// What the host rules for the exit settings are doing. + pub exit_rules: crate::overlay::ExitRulesReport, /// Immutable deterministic description of the network space. pub descriptor: NetworkDescriptor, /// Network name, for convenience. diff --git a/crates/tsunagi/src/error.rs b/crates/tsunagi/src/error.rs index 47f9898..d6c3bbf 100644 --- a/crates/tsunagi/src/error.rs +++ b/crates/tsunagi/src/error.rs @@ -120,6 +120,13 @@ pub enum Error { what: String, }, + /// The request is understood and cannot be done as things stand. + #[error("{reason}")] + Rejected { + /// Why, phrased so the message stands on its own. + reason: String, + }, + /// Discovery backend failure. Never fatal for the agent. #[error("discovery error: {0}")] Discovery(String), diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index b67c3de..7862d82 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -234,6 +234,22 @@ pub enum Request { /// Which network, by the id text `status` prints. network_id: String, }, + /// Offer this agent as an exit node in one network, or stop. Off unless + /// asked. + SetExitOffer { + /// Public network identifier. + network_id: String, + /// Whether members may send their internet traffic through this agent. + enabled: bool, + }, + /// Send all this device's internet traffic through a member of one + /// network, or back the ordinary way. + SetExitNode { + /// Public network identifier. + network_id: String, + /// The member's endpoint id, or `None` to stop using an exit node. + peer: Option, + }, } impl std::fmt::Debug for Request { @@ -255,6 +271,13 @@ impl std::fmt::Debug for Request { } Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: }}"), Request::Secret { network_id } => write!(f, "Secret {{ {network_id} }}"), + Request::SetExitOffer { + network_id, + enabled, + } => write!(f, "SetExitOffer {{ {network_id}, enabled: {enabled} }}"), + Request::SetExitNode { network_id, peer } => { + write!(f, "SetExitNode {{ {network_id}, peer: {peer:?} }}") + } } } } @@ -281,6 +304,10 @@ pub enum Response { Dns(Option), /// A network's shared secret, redacted in `Debug`. Secret(RedactedText), + /// Whether this agent now offers itself as an exit node. + ExitOffer(bool), + /// The member this device now sends its internet traffic through. + ExitNode(Option), /// The request could not be served. Error(String), } @@ -457,6 +484,9 @@ pub struct NetworkReport { pub range_conflict: Option, /// The overlay, when a protocol is running one. pub overlay: Option, + /// Exit-node settings: whether this agent offers one, and whether it + /// uses one. + pub exit: ExitReport, } /// One member of the network, from signed state. @@ -484,6 +514,8 @@ pub struct PeerReport { pub transport: String, /// Round-trip time in milliseconds, when a path is selected. pub rtt_ms: Option, + /// The peer offers itself as an exit node in this network. + pub exit_node: bool, } /// The WireGuard overlay of one network. @@ -516,6 +548,35 @@ pub struct OverlayReport { pub broadcast_rules: Option, } +/// How one set of exit-node host rules went. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuleSetReport { + /// Whether every step took. + pub ok: bool, + /// One line: what is in place, or what is missing and why. + pub detail: String, +} + +/// One network's exit-node settings and what they are doing. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExitReport { + /// This agent offers itself as an exit node in this network. + pub offering: bool, + /// Masquerading and forwarding rules for members' traffic, when offering. + pub offer_rules: Option, + /// Whether the kernel forwards packets for the overlay interface. + /// `Some(false)` means the rules are in place and nothing passes until it + /// is turned on. `None` when unknown. + pub forwarding: Option, + /// The member this device sends its internet traffic through, as an + /// endpoint id. + pub via: Option, + /// Whether that member is connected and still offering to be one. + pub via_online: bool, + /// The routes that send this device's traffic through it. + pub client_rules: Option, +} + /// What the agent installed on the host so LAN broadcast reaches the overlay. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct HostRulesReport { @@ -666,6 +727,28 @@ pub trait ReportSource: Send + Sync + 'static { ) -> BoxFuture<'_, std::result::Result> { Box::pin(async move { Err("this agent cannot read secrets".to_string()) }) } + + /// Offers this agent as an exit node in a network, or stops. + /// + /// Defaulted to a refusal, like the others. + fn set_exit_offer( + &self, + _network_id: String, + _enabled: bool, + ) -> BoxFuture<'_, std::result::Result> { + Box::pin(async move { Err("this agent cannot be an exit node".to_string()) }) + } + + /// Sends this device's internet traffic through a member, or stops. + /// + /// Defaulted to a refusal, like the others. + fn set_exit_node( + &self, + _network_id: String, + _peer: Option, + ) -> BoxFuture<'_, std::result::Result, String>> { + Box::pin(async move { Err("this agent cannot use an exit node".to_string()) }) + } } impl ReportSource for F @@ -758,6 +841,19 @@ where Ok(secret) => Response::Secret(RedactedText::new(secret)), Err(reason) => Response::Error(reason), }, + Request::SetExitOffer { + network_id, + enabled, + } => match source.set_exit_offer(network_id, enabled).await { + Ok(enabled) => Response::ExitOffer(enabled), + Err(reason) => Response::Error(reason), + }, + Request::SetExitNode { network_id, peer } => { + match source.set_exit_node(network_id, peer).await { + Ok(peer) => Response::ExitNode(peer), + Err(reason) => Response::Error(reason), + } + } }; write_message(&mut stream, &response).await } @@ -912,6 +1008,41 @@ pub async fn set_dns( } } +/// Offers the running agent as an exit node in one network, or stops. +pub async fn set_exit_offer( + path: impl AsRef, + network_id: &str, + enabled: bool, +) -> Result { + let request = Request::SetExitOffer { + network_id: network_id.to_owned(), + enabled, + }; + match exchange(path.as_ref(), &request, EXCHANGE_TIMEOUT).await? { + Response::ExitOffer(enabled) => Ok(enabled), + Response::Error(message) => Err(Error::Storage(message)), + other => Err(Error::Storage(format!("unexpected answer: {other:?}"))), + } +} + +/// Sends the running agent's internet traffic through a member of one +/// network, or back the ordinary way with `None`. +pub async fn set_exit_node( + path: impl AsRef, + network_id: &str, + peer: Option<&str>, +) -> Result> { + let request = Request::SetExitNode { + network_id: network_id.to_owned(), + peer: peer.map(str::to_owned), + }; + match exchange(path.as_ref(), &request, EXCHANGE_TIMEOUT).await? { + Response::ExitNode(peer) => Ok(peer), + Response::Error(message) => Err(Error::Storage(message)), + other => Err(Error::Storage(format!("unexpected answer: {other:?}"))), + } +} + /// Asks a running agent for a configured network's shared secret, in its /// `tsn1…` text form, so a local client can copy it. The secret is never /// logged by this path. diff --git a/crates/tsunagi/src/overlay/broadcast.rs b/crates/tsunagi/src/overlay/broadcast.rs index 34f2ec1..4113229 100644 --- a/crates/tsunagi/src/overlay/broadcast.rs +++ b/crates/tsunagi/src/overlay/broadcast.rs @@ -140,6 +140,7 @@ mod tests { fn limited_and_directed_broadcasts_never_cross_domains_or_target_disabled_peers() { let peer = iroh::SecretKey::from_bytes(&[8; 32]).public(); let a = NetworkRoutes { + exit: Default::default(), range: Some("10.0.0.0/24".parse().unwrap()), local: Some("10.0.0.1".parse().unwrap()), peers: vec![("10.0.0.2".parse().unwrap(), peer)], @@ -149,6 +150,7 @@ mod tests { }, }; let b = NetworkRoutes { + exit: Default::default(), range: Some("10.1.0.0/24".parse().unwrap()), local: Some("10.1.0.1".parse().unwrap()), peers: vec![("10.1.0.2".parse().unwrap(), peer)], @@ -185,6 +187,7 @@ mod tests { assert!(!table.accepts(id("b"), peer, Ipv4Addr::BROADCAST)); assert!(table.accepts(id("a"), peer, Ipv4Addr::BROADCAST)); let disabled = NetworkRoutes { + exit: Default::default(), broadcast: BroadcastPolicy { enabled: false, ..a.broadcast.clone() diff --git a/crates/tsunagi/src/overlay/exit.rs b/crates/tsunagi/src/overlay/exit.rs new file mode 100644 index 0000000..97ccd34 --- /dev/null +++ b/crates/tsunagi/src/overlay/exit.rs @@ -0,0 +1,32 @@ +//! Exit-node host rules: what makes this device an exit node for others, and +//! what sends this device's own traffic through one. +//! +//! The packet side lives in [`super::router`]; this is the side the operating +//! system sees. It is kept apart from the broadcast rules, which have a +//! different lifetime and different tags. + +/// How one set of host rules went. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RuleSetReport { + /// Whether every step took. + pub ok: bool, + /// One line: what is in place, or what is missing and why. + pub detail: String, +} + +/// What the host rules of one network's exit-node settings are doing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExitRulesReport { + /// Masquerading and forwarding for members' traffic, when this agent + /// offers itself as an exit node here. `None` means none are wanted. + pub offer: Option, + /// Whether the kernel forwards packets for the overlay interface. + /// + /// `Some(false)` means the rules are in place but nothing passes until + /// forwarding is turned on, which the agent never does itself. `None` + /// when it could not be read. + pub forwarding: Option, + /// The routes that send this device's traffic through its exit node. + /// `None` means it uses none through this network. + pub client: Option, +} diff --git a/crates/tsunagi/src/overlay/hostrules/exit.rs b/crates/tsunagi/src/overlay/hostrules/exit.rs new file mode 100644 index 0000000..a07adc9 --- /dev/null +++ b/crates/tsunagi/src/overlay/hostrules/exit.rs @@ -0,0 +1,231 @@ +//! Host rules for exit nodes: what the operating system needs for this agent +//! to carry other members' internet traffic, and for this device's own +//! traffic to go through a member. +//! +//! Two independent halves, planned together because one interface carries +//! both: +//! +//! * **Offering** — one set of rules per network that offers this agent as an +//! exit node, chosen by that network's range: masquerade the range out of +//! every other interface and let it through the forward chain. A network +//! that does not offer has no rules, so its members' packets are dropped by +//! the firewall as well as by the agent. +//! * **Using** — policy routing that sends everything the host does not have +//! a more specific route for into the overlay interface, while the agent's +//! own sockets keep the ordinary route (see `linux_exit`). +//! +//! Nothing a peer announces becomes a host setting. Offering is the owner's +//! switch; using is the user's choice of a member. Every object is tagged, +//! reversible, and removed when it is no longer wanted, when the network +//! leaves or stops, and when the agent stops. + +use std::sync::{Arc, Mutex}; + +use crate::BoxFuture; +use crate::state::Ipv4Range; + +use super::RuleOutcome; + +/// What the host should hold for one interface's exit settings. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExitHostPlan { + /// The overlay interface. + pub interface: String, + /// The ranges of every network that offers this agent as an exit node. + pub offer: Vec, + /// Whether this device sends its internet traffic through a member. + pub client: bool, +} + +impl ExitHostPlan { + /// Whether there is nothing to hold. + pub fn is_empty(&self) -> bool { + self.offer.is_empty() && !self.client + } +} + +/// What applying a plan achieved. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExitHostReport { + /// One outcome per offered range: its masquerade and forward rules. + pub offer: Vec<(Ipv4Range, RuleOutcome)>, + /// Whether the kernel forwards packets for the interface. `None` when it + /// could not be read. The agent only reads this: turning forwarding on is + /// the owner's decision. + pub forwarding: Option, + /// The routes and rules that send this device's traffic through the + /// interface, when the plan asked for them. + pub client: Option, +} + +/// Installs and removes exit-node rules for an overlay interface. +/// +/// Like the broadcast rules, no call fails as a whole: a step that cannot be +/// done is in the report, because the caller's only useful response is to +/// say so. +pub trait ExitHostRules: Send + Sync + std::fmt::Debug + 'static { + /// Makes the host match the plan, replacing what this agent put there + /// for the interface before. + fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport>; + + /// Removes everything this agent put there for the interface. Succeeds + /// when nothing is there: it runs on the shutdown path, and once at + /// startup to take away what a crashed run left. + fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>; +} + +/// The tag every firewall object of one offered range carries. +pub fn exit_tag(interface: &str, range: &Ipv4Range) -> String { + format!("{}{range}", exit_tag_prefix(interface)) +} + +/// The start of every tag for an interface; what cleanup selects by. +pub fn exit_tag_prefix(interface: &str) -> String { + format!("tsunagi-exit:{interface}:") +} + +/// The one-line detail for a set of offer outcomes. +pub fn offer_detail(outcome: &RuleOutcome, range: &Ipv4Range) -> String { + match outcome { + RuleOutcome::Applied => format!("masquerade + forward for {range} in place"), + RuleOutcome::Failed(reason) => reason.clone(), + } +} + +/// An in-memory host for exit rules, for tests: nothing is touched. +#[derive(Debug, Clone, Default)] +pub struct MockExitRules { + state: Arc>>, + calls: Arc>>, + forwarding: Arc>>, + failure: Arc>>, +} + +impl MockExitRules { + /// An empty host with forwarding turned on. + pub fn new() -> Self { + let rules = Self::default(); + *lock(&rules.forwarding) = Some(true); + rules + } + + /// What the host reports for kernel forwarding from now on. + pub fn set_forwarding(&self, forwarding: Option) { + *lock(&self.forwarding) = forwarding; + } + + /// Makes every step fail with this reason from now on. + pub fn fail_with(&self, reason: Option) { + *lock(&self.failure) = reason; + } + + /// What is installed now. + pub fn installed(&self) -> Option { + lock(&self.state).clone() + } + + /// Every `apply:` / `clear:` call, in order. + pub fn calls(&self) -> Vec { + lock(&self.calls).clone() + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +impl ExitHostRules for MockExitRules { + fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> { + Box::pin(async move { + lock(&self.calls).push(format!("apply:{}", plan.interface)); + let outcome = match lock(&self.failure).clone() { + Some(reason) => RuleOutcome::Failed(reason), + None => { + *lock(&self.state) = Some(plan.clone()); + RuleOutcome::Applied + } + }; + ExitHostReport { + offer: plan + .offer + .iter() + .map(|range| (*range, outcome.clone())) + .collect(), + forwarding: *lock(&self.forwarding), + client: plan.client.then_some(outcome), + } + }) + } + + fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> { + Box::pin(async move { + lock(&self.calls).push(format!("clear:{interface}")); + *lock(&self.state) = None; + }) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + + fn range() -> Ipv4Range { + "10.13.37.0/24".parse().unwrap() + } + + #[test] + fn tags_name_the_interface_and_the_range() { + assert_eq!( + exit_tag("tsun0", &range()), + "tsunagi-exit:tsun0:10.13.37.0/24" + ); + assert!(exit_tag("tsun0", &range()).starts_with(&exit_tag_prefix("tsun0"))); + // Another interface's tag never matches this one's prefix. + assert!(!exit_tag("tsun01", &range()).starts_with(&exit_tag_prefix("tsun0"))); + } + + #[test] + fn an_empty_plan_is_nothing_to_hold() { + let mut plan = ExitHostPlan::default(); + assert!(plan.is_empty()); + plan.client = true; + assert!(!plan.is_empty()); + } + + #[tokio::test] + async fn the_mock_applies_reports_and_clears() { + let rules = MockExitRules::new(); + let plan = ExitHostPlan { + interface: "tsun0".into(), + offer: vec![range()], + client: true, + }; + let report = rules.apply(&plan).await; + assert_eq!(report.offer[0].1, RuleOutcome::Applied); + assert_eq!(report.forwarding, Some(true)); + assert_eq!(report.client, Some(RuleOutcome::Applied)); + assert_eq!(rules.installed(), Some(plan)); + + rules.set_forwarding(Some(false)); + rules.fail_with(Some("denied".into())); + let report = rules + .apply(&ExitHostPlan { + interface: "tsun0".into(), + offer: vec![range()], + client: false, + }) + .await; + assert_eq!(report.forwarding, Some(false)); + assert!(matches!(report.offer[0].1, RuleOutcome::Failed(_))); + assert_eq!(report.client, None); + + rules.clear("tsun0").await; + assert_eq!(rules.installed(), None); + assert_eq!(rules.calls(), ["apply:tsun0", "apply:tsun0", "clear:tsun0"]); + } +} diff --git a/crates/tsunagi/src/overlay/hostrules/linux.rs b/crates/tsunagi/src/overlay/hostrules/linux.rs index d5bdf84..6e98398 100644 --- a/crates/tsunagi/src/overlay/hostrules/linux.rs +++ b/crates/tsunagi/src/overlay/hostrules/linux.rs @@ -50,7 +50,7 @@ fn pick_iptables(exists: impl Fn(&str) -> bool) -> Option<&'static str> { /// What running `iptables` produced. #[derive(Debug, Clone, PartialEq, Eq)] -enum Ran { +pub(super) enum Ran { /// There is no `iptables` to run. Missing, /// It ran, and exited like this. @@ -63,7 +63,7 @@ enum Ran { /// Runs `iptables` with these arguments. A function value so the flow below /// can be tested without touching the host. -type Run<'a> = &'a dyn Fn(&[String]) -> Ran; +pub(super) type Run<'a> = &'a dyn Fn(&[String]) -> Ran; /// The match and target of the rule, without the chain or position. fn rule_spec(plan: &BroadcastRulesPlan) -> Vec { @@ -88,7 +88,7 @@ fn rule_spec(plan: &BroadcastRulesPlan) -> Vec { /// Waits briefly for the xtables lock instead of failing when another tool /// holds it. -fn locked(args: impl IntoIterator) -> Vec { +pub(super) fn locked(args: impl IntoIterator) -> Vec { ["-w", "5"] .into_iter() .map(str::to_string) @@ -118,7 +118,7 @@ fn list_args() -> Vec { /// Splits one `iptables -S` line into words, honouring double quotes, which /// `iptables` puts around comments that need them. -fn split_rule_line(line: &str) -> Vec { +pub(super) fn split_rule_line(line: &str) -> Vec { let mut words = Vec::new(); let mut current = String::new(); let mut quoted = false; @@ -175,7 +175,7 @@ fn tagged_deletions(listing: &str, tag: &str) -> Vec> { } /// A failure message with the likely fix for the likely cause. -fn failure(stderr: &str) -> String { +pub(super) fn failure(stderr: &str) -> String { let text = stderr.trim(); let lower = text.to_ascii_lowercase(); if lower.contains("permission denied") || lower.contains("must be root") { @@ -261,7 +261,7 @@ fn clear_firewall(run: Run<'_>, interface: &str) { } /// Runs the real `iptables`. -fn run_iptables(args: &[String]) -> Ran { +pub(super) fn run_iptables(args: &[String]) -> Ran { let Some(program) = pick_iptables(|path| std::path::Path::new(path).is_file()) else { return Ran::Missing; }; @@ -283,7 +283,7 @@ fn run_iptables(args: &[String]) -> Ran { /// The Linux implementation: netlink for the route, `iptables` for the rule. #[derive(Debug)] pub struct LinuxHostRules { - route: RouteHandle, + pub(super) route: RouteHandle, } impl LinuxHostRules { @@ -306,6 +306,10 @@ impl BroadcastHostRules for LinuxHostRules { "netlink+iptables" } + fn exit_rules(&self) -> Option<&dyn super::ExitHostRules> { + Some(self) + } + fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> { Box::pin(async move { let route = self.set_route(plan.interface.clone(), plan.source).await; diff --git a/crates/tsunagi/src/overlay/hostrules/linux_exit.rs b/crates/tsunagi/src/overlay/hostrules/linux_exit.rs new file mode 100644 index 0000000..f1b1221 --- /dev/null +++ b/crates/tsunagi/src/overlay/hostrules/linux_exit.rs @@ -0,0 +1,728 @@ +//! Exit-node host rules on Linux: `iptables` for the offering side, policy +//! routing over netlink for the using side. +//! +//! # Offering +//! +//! For every network that offers this agent as an exit node, three rules, +//! selected by that network's range and carrying the tag +//! `tsunagi-exit::`: +//! +//! ```text +//! -t nat POSTROUTING -s ! -o -j MASQUERADE +//! -t filter FORWARD -i -s ! -o -j ACCEPT +//! -t filter FORWARD -o -d -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +//! ``` +//! +//! `! -o ` is the egress without naming it: whatever interface the +//! default route uses, now or after it changes. Kernel forwarding is only +//! *read*. Turning it on changes how the whole host behaves, which is the +//! owner's decision, so when it is off the rules are installed anyway and the +//! report says so. +//! +//! # Using +//! +//! Policy routing, with no `/1` routes and nothing in the main table: +//! +//! ```text +//! table 0x7473: default dev +//! 5280: 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, +//! 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 +//! VPN, a tunnel to somewhere else — keep working, because only the default +//! route is suppressed. The priorities sit after Tailscale's 5270. +//! +//! The first 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 +//! guessed at: see [`client_refusal`]. + +use crate::BoxFuture; +use crate::state::Ipv4Range; + +use super::RuleOutcome; +use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix}; +use super::linux::{LinuxHostRules, Ran, Run, failure, locked, run_iptables, split_rule_line}; + +/// The chains the offering rules live in: `(table, chain)`. +const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")]; + +/// One firewall rule: where it goes, and its match and target. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Rule { + table: &'static str, + chain: &'static str, + spec: Vec, +} + +fn owned(words: &[&str]) -> Vec { + words.iter().map(|word| word.to_string()).collect() +} + +/// The three rules of one offered range. +fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec { + let range_text = range.to_string(); + let tag = exit_tag(interface, range); + let comment = ["-m", "comment", "--comment", tag.as_str()]; + let spec = |head: &[&str], tail: &[&str]| -> Vec { + owned(head) + .into_iter() + .chain(owned(&comment)) + .chain(owned(tail)) + .collect() + }; + vec![ + Rule { + table: "nat", + chain: "POSTROUTING", + spec: spec( + &["-s", &range_text, "!", "-o", interface], + &["-j", "MASQUERADE"], + ), + }, + Rule { + table: "filter", + chain: "FORWARD", + spec: spec( + &["-i", interface, "-s", &range_text, "!", "-o", interface], + &["-j", "ACCEPT"], + ), + }, + Rule { + table: "filter", + chain: "FORWARD", + spec: spec( + &[ + "-o", + interface, + "-d", + &range_text, + "-m", + "conntrack", + "--ctstate", + "RELATED,ESTABLISHED", + ], + &["-j", "ACCEPT"], + ), + }, + ] +} + +fn check_args(rule: &Rule) -> Vec { + locked( + owned(&["-t", rule.table, "-C", rule.chain]) + .into_iter() + .chain(rule.spec.iter().cloned()), + ) +} + +fn insert_args(rule: &Rule) -> Vec { + locked( + owned(&["-t", rule.table, "-I", rule.chain, "1"]) + .into_iter() + .chain(rule.spec.iter().cloned()), + ) +} + +fn list_args(table: &str, chain: &str) -> Vec { + locked(owned(&["-t", table, "-S", chain])) +} + +/// A rule found in a listing whose comment starts with our prefix. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Found { + /// The whole tag. + tag: String, + /// The words after `-A `, which is what `-D` needs. + rest: Vec, +} + +/// Every rule in an `iptables -S ` listing carrying a tag that starts +/// with `prefix`. Anything else in the chain is left alone. +fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec { + listing + .lines() + .filter_map(|line| { + let words = split_rule_line(line); + let [append, found_chain, rest @ ..] = words.as_slice() else { + return None; + }; + if append != "-A" || found_chain != chain { + return None; + } + let tag = rest + .windows(2) + .find(|pair| pair[0] == "--comment" && pair[1].starts_with(prefix)) + .map(|pair| pair[1].clone())?; + Some(Found { + tag, + rest: rest.to_vec(), + }) + }) + .collect() +} + +fn delete_args(table: &str, chain: &str, found: &Found) -> Vec { + locked( + owned(&["-t", table, "-D", chain]) + .into_iter() + .chain(found.rest.iter().cloned()), + ) +} + +fn missing_iptables(range: &Ipv4Range, interface: &str) -> String { + format!( + "iptables not found; install it (the iptables-nft package is enough), or masquerade \ + {range} out of every interface but `{interface}` and forward it yourself" + ) +} + +/// Reads one chain, or says why not. +fn read_chain(run: Run<'_>, table: &str, chain: &str) -> Result { + match run(&list_args(table, chain)) { + Ran::Missing => Err(Failure::Missing), + Ran::Exited { + success: true, + stdout, + .. + } => Ok(stdout), + Ran::Exited { stderr, .. } => Err(Failure::Failed(failure(&stderr))), + } +} + +enum Failure { + Missing, + Failed(String), +} + +/// Deletes found rules; the first failure ends it. +fn delete_all( + run: Run<'_>, + table: &str, + chain: &str, + found: &[Found], + keep: impl Fn(&Found) -> bool, +) -> Result<(), String> { + for rule in found.iter().filter(|rule| !keep(rule)) { + if let Ran::Exited { + success: false, + stderr, + .. + } = run(&delete_args(table, chain, rule)) + { + return Err(failure(&stderr)); + } + } + Ok(()) +} + +/// Makes the firewall hold exactly the offering rules of these ranges for the +/// interface, and no others of ours. +fn apply_offer( + run: Run<'_>, + interface: &str, + ranges: &[Ipv4Range], +) -> Vec<(Ipv4Range, RuleOutcome)> { + let fail_all = |reason: String| -> Vec<(Ipv4Range, RuleOutcome)> { + ranges + .iter() + .map(|range| (*range, RuleOutcome::Failed(reason.clone()))) + .collect() + }; + let prefix = exit_tag_prefix(interface); + let wanted: Vec = ranges.iter().map(|r| exit_tag(interface, r)).collect(); + + // What is there now, per chain. + let mut present: Vec<(&str, &str, Vec)> = Vec::new(); + for (table, chain) in CHAINS { + match read_chain(run, table, chain) { + Ok(listing) => present.push((table, chain, tagged_rules(&listing, chain, &prefix))), + Err(Failure::Missing) => { + return match ranges.first() { + Some(range) => fail_all(missing_iptables(range, interface)), + None => Vec::new(), + }; + } + Err(Failure::Failed(reason)) => return fail_all(reason), + } + } + + // Rules for a range no longer offered, or left by an earlier run. + for (table, chain, found) in &present { + if let Err(reason) = delete_all(run, table, chain, found, |rule| wanted.contains(&rule.tag)) + { + return fail_all(reason); + } + } + + ranges + .iter() + .map(|range| { + let rules = offer_rules(interface, range); + let tag = exit_tag(interface, range); + let in_place = rules + .iter() + .all(|rule| matches!(run(&check_args(rule)), Ran::Exited { success: true, .. })) + && present.iter().all(|(_, chain, found)| { + let have = found.iter().filter(|rule| rule.tag == tag).count(); + have == rules.iter().filter(|rule| rule.chain == *chain).count() + }); + if in_place { + return (*range, RuleOutcome::Applied); + } + // Start this range from a clean slate: a half-installed set, or + // one that differs from the plan, is replaced as a whole. + for (table, chain, found) in &present { + if let Err(reason) = delete_all(run, table, chain, found, |rule| rule.tag != tag) { + return (*range, RuleOutcome::Failed(reason)); + } + } + for rule in &rules { + match run(&insert_args(rule)) { + Ran::Missing => { + return ( + *range, + RuleOutcome::Failed(missing_iptables(range, interface)), + ); + } + Ran::Exited { success: true, .. } => {} + Ran::Exited { stderr, .. } => { + return (*range, RuleOutcome::Failed(failure(&stderr))); + } + } + } + (*range, RuleOutcome::Applied) + }) + .collect() +} + +/// Removes every offering rule of ours for the interface. Best effort. +fn clear_offer(run: Run<'_>, interface: &str) { + let prefix = exit_tag_prefix(interface); + for (table, chain) in CHAINS { + let Ok(listing) = read_chain(run, table, chain) else { + continue; + }; + let found = tagged_rules(&listing, chain, &prefix); + if let Err(reason) = delete_all(run, table, chain, &found, |_| false) { + tracing::warn!( + interface, + "cannot remove an exit node firewall rule: {reason}" + ); + } + } +} + +/// The kernel's forwarding switch for packets arriving on the interface, +/// read from the text of the sysctl files. Never written. +fn forwarding_state(read: impl Fn(&str) -> Option, interface: &str) -> Option { + let flag = |text: String| match text.trim() { + "1" => Some(true), + "0" => Some(false), + _ => None, + }; + // The name is derived here, but it ends up in a path. + if !interface.is_empty() && !interface.contains(['/', '\0']) && interface != ".." { + let per_interface = format!("/proc/sys/net/ipv4/conf/{interface}/forwarding"); + if let Some(state) = read(&per_interface).and_then(flag) { + return Some(state); + } + } + read("/proc/sys/net/ipv4/ip_forward").and_then(flag) +} + +fn read_sysctl(path: &str) -> Option { + std::fs::read_to_string(path).ok() +} + +/// The effective user id from the text of `/proc/self/status`. +fn parse_euid(status: &str) -> Option { + status + .lines() + .find_map(|line| line.strip_prefix("Uid:")) + .and_then(|rest| rest.split_whitespace().nth(1)) + .and_then(|effective| effective.parse().ok()) +} + +/// Why the using side must not be installed for this process, if it must not. +/// +/// The bypass rule exempts this process's *user id* from the tunnel. That is +/// right for a service with an id of its own and wrong for a process started +/// from somebody's login session, which shares its id with every application +/// that person runs. A login session is recognised by `XDG_RUNTIME_DIR`, +/// which `systemd --user` and the login managers set and a system service +/// does not. Crude on purpose: refusing a service that was started oddly is +/// a message, and exempting a user's applications is a silent failure. +fn client_refusal(xdg_runtime_dir: Option<&str>) -> Option { + xdg_runtime_dir.filter(|dir| !dir.is_empty()).map(|_| { + "the exit node needs the agent to run as a system service with a user of its own: \ + this agent was started from a login session, so routing everything through the \ + overlay would take that user's own applications out of the tunnel with it" + .to_string() + }) +} + +impl LinuxHostRules { + async fn blocking( + work: impl FnOnce() -> T + Send + 'static, + ) -> Result { + tokio::task::spawn_blocking(work) + .await + .map_err(|err| format!("the task failed: {err}")) + } + + async fn apply_client(&self, interface: &str) -> RuleOutcome { + 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; + return RuleOutcome::Failed(reason); + } + let Some(euid) = std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|status| parse_euid(&status)) + else { + return RuleOutcome::Failed("cannot tell which user this agent runs as".to_string()); + }; + let route = self.route.clone(); + let interface = interface.to_string(); + match Self::blocking(move || route.set_exit_client(&interface, euid)).await { + Ok(Ok(())) => RuleOutcome::Applied, + Ok(Err(err)) => RuleOutcome::Failed(err.to_string()), + Err(reason) => RuleOutcome::Failed(reason), + } + } + + async fn clear_client(&self) { + 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"); + } + } +} + +impl ExitHostRules for LinuxHostRules { + fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> { + Box::pin(async move { + let interface = plan.interface.clone(); + let ranges = plan.offer.clone(); + let offer = { + let (job_interface, job_ranges) = (interface.clone(), ranges.clone()); + Self::blocking(move || apply_offer(&run_iptables, &job_interface, &job_ranges)) + .await + .unwrap_or_else(|reason| { + ranges + .iter() + .map(|range| (*range, RuleOutcome::Failed(reason.clone()))) + .collect() + }) + }; + let forwarding = if ranges.is_empty() { + None + } else { + forwarding_state(read_sysctl, &interface) + }; + let client = if plan.client { + Some(self.apply_client(&interface).await) + } else { + self.clear_client().await; + None + }; + ExitHostReport { + offer, + forwarding, + client, + } + }) + } + + fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> { + Box::pin(async move { + let name = interface.to_string(); + let _ = Self::blocking(move || clear_offer(&run_iptables, &name)).await; + self.clear_client().await; + }) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use std::cell::RefCell; + + use super::*; + + fn range() -> Ipv4Range { + "10.13.37.0/24".parse().unwrap() + } + + fn words(args: &[String]) -> String { + args.join(" ") + } + + #[test] + fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() { + let rules = offer_rules("tsun0", &range()); + assert_eq!(rules.len(), 3); + assert_eq!( + words(&rules[0].spec), + "-s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j MASQUERADE" + ); + assert_eq!((rules[0].table, rules[0].chain), ("nat", "POSTROUTING")); + assert_eq!( + words(&rules[1].spec), + "-i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT" + ); + assert_eq!((rules[1].table, rules[1].chain), ("filter", "FORWARD")); + assert_eq!( + words(&rules[2].spec), + "-o tsun0 -d 10.13.37.0/24 -m conntrack --ctstate RELATED,ESTABLISHED -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT" + ); + // Nothing may widen to every source or to the other direction. + for rule in &rules { + assert!(!words(&rule.spec).contains("0.0.0.0/0")); + } + assert!(words(&insert_args(&rules[1])).starts_with("-w 5 -t filter -I FORWARD 1 -i tsun0")); + assert!(words(&check_args(&rules[0])).starts_with("-w 5 -t nat -C POSTROUTING -s")); + } + + #[test] + fn only_rules_with_our_tag_for_this_interface_are_selected() { + let listing = "\ +-P FORWARD DROP +-A FORWARD -i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT +-A FORWARD -i tsun1 -m comment --comment tsunagi-exit:tsun1:10.99.0.0/24 -j ACCEPT +-A FORWARD -i tsun01 -m comment --comment tsunagi-exit:tsun01:10.5.0.0/24 -j ACCEPT +-A FORWARD -m comment --comment \"allow tsunagi-exit:tsun0:x\" -j ACCEPT +-A FORWARD -m comment --comment tsunagi:tsun0 -j ACCEPT +-A INPUT -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT +-A FORWARD -j ACCEPT +"; + let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0")); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].tag, "tsunagi-exit:tsun0:10.13.37.0/24"); + assert_eq!( + words(&delete_args("filter", "FORWARD", &found[0])), + "-w 5 -t filter -D FORWARD -i tsun0 -s 10.13.37.0/24 ! -o tsun0 -m comment --comment tsunagi-exit:tsun0:10.13.37.0/24 -j ACCEPT" + ); + assert!(tagged_rules("", "FORWARD", "tsunagi-exit:tsun0:").is_empty()); + } + + /// A scripted `iptables` that remembers which rules it was given. + struct Fake { + calls: RefCell>, + /// Rules present, as `-S` lines per `(table, chain)`. + listing: RefCell>, + missing: bool, + check_ok: bool, + } + + impl Fake { + fn new(check_ok: bool) -> Self { + Self { + calls: RefCell::new(Vec::new()), + listing: RefCell::new(Vec::new()), + missing: false, + check_ok, + } + } + + fn with_rule(self, table: &str, line: &str) -> Self { + self.listing + .borrow_mut() + .push((table.to_string(), line.to_string())); + self + } + + fn run(&self, args: &[String]) -> Ran { + if self.missing { + return Ran::Missing; + } + let line = args.join(" "); + self.calls.borrow_mut().push(line.clone()); + let table = args + .iter() + .position(|word| word == "-t") + .map(|at| args[at + 1].clone()) + .unwrap_or_default(); + let ok = |stdout: String| Ran::Exited { + success: true, + stdout, + stderr: String::new(), + }; + if line.contains(" -S ") { + let listing: Vec = self + .listing + .borrow() + .iter() + .filter(|(t, _)| *t == table) + .map(|(_, l)| l.clone()) + .collect(); + return ok(listing.join("\n")); + } + if line.contains(" -C ") { + return Ran::Exited { + success: self.check_ok, + stdout: String::new(), + stderr: String::new(), + }; + } + ok(String::new()) + } + + fn verbs(&self, verb: &str) -> Vec { + self.calls + .borrow() + .iter() + .filter(|call| call.contains(&format!(" {verb} "))) + .cloned() + .collect() + } + } + + #[test] + fn a_fresh_host_gets_the_three_rules() { + let fake = Fake::new(false); + let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]); + assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]); + let inserted = fake.verbs("-I"); + assert_eq!(inserted.len(), 3, "{inserted:?}"); + assert!(inserted[0].contains("-t nat -I POSTROUTING 1")); + assert!(fake.verbs("-D").is_empty()); + } + + #[test] + fn rules_already_in_place_are_left_alone() { + let tag = exit_tag("tsun0", &range()); + let fake = Fake::new(true) + .with_rule( + "nat", + &format!("-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -m comment --comment {tag} -j MASQUERADE"), + ) + .with_rule("filter", &format!("-A FORWARD -i tsun0 -m comment --comment {tag} -j ACCEPT")) + .with_rule("filter", &format!("-A FORWARD -o tsun0 -m comment --comment {tag} -j ACCEPT")); + let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]); + assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]); + assert!(fake.verbs("-I").is_empty()); + assert!(fake.verbs("-D").is_empty()); + } + + #[test] + fn a_stale_range_is_removed_and_a_half_installed_set_is_replaced() { + let old: Ipv4Range = "10.99.0.0/24".parse().unwrap(); + let old_tag = exit_tag("tsun0", &old); + let tag = exit_tag("tsun0", &range()); + let fake = Fake::new(true) + .with_rule( + "nat", + &format!( + "-A POSTROUTING -s 10.99.0.0/24 -m comment --comment {old_tag} -j MASQUERADE" + ), + ) + // Only one of the three for the current range. + .with_rule( + "filter", + &format!("-A FORWARD -i tsun0 -m comment --comment {tag} -j ACCEPT"), + ) + // Somebody else's rule, which must survive. + .with_rule("filter", "-A FORWARD -i eth0 -j ACCEPT"); + let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]); + assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]); + let deleted = fake.verbs("-D"); + assert!( + deleted.iter().any(|call| call.contains(&old_tag)), + "{deleted:?}" + ); + assert!( + deleted.iter().any(|call| call.contains(&tag)), + "{deleted:?}" + ); + assert!( + deleted.iter().all(|call| !call.contains("eth0")), + "{deleted:?}" + ); + assert_eq!(fake.verbs("-I").len(), 3); + } + + #[test] + fn clearing_removes_only_our_rules_for_the_interface() { + let tag = exit_tag("tsun0", &range()); + let other = exit_tag("tsun1", &range()); + let fake = Fake::new(true) + .with_rule( + "nat", + &format!("-A POSTROUTING -m comment --comment {tag} -j MASQUERADE"), + ) + .with_rule( + "nat", + &format!("-A POSTROUTING -m comment --comment {other} -j MASQUERADE"), + ) + .with_rule("filter", "-A FORWARD -j ACCEPT"); + clear_offer(&|args| fake.run(args), "tsun0"); + let deleted = fake.verbs("-D"); + assert_eq!(deleted.len(), 1, "{deleted:?}"); + assert!(deleted[0].contains(&tag)); + } + + #[test] + fn a_missing_iptables_is_a_failure_that_says_what_to_do() { + let mut fake = Fake::new(false); + fake.missing = true; + let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]); + let RuleOutcome::Failed(reason) = &outcome[0].1 else { + panic!("{outcome:?}"); + }; + assert!(reason.contains("iptables not found"), "{reason}"); + assert!(reason.contains("10.13.37.0/24"), "{reason}"); + // Nothing wanted and nothing to run is not a failure of anything. + assert!(apply_offer(&|args| fake.run(args), "tsun0", &[]).is_empty()); + } + + #[test] + fn forwarding_is_read_per_interface_then_globally_and_never_written() { + let files = |per: Option<&'static str>, global: Option<&'static str>| { + move |path: &str| match path { + "/proc/sys/net/ipv4/conf/tsun0/forwarding" => per.map(str::to_string), + "/proc/sys/net/ipv4/ip_forward" => global.map(str::to_string), + _ => None, + } + }; + assert_eq!( + forwarding_state(files(Some("1\n"), Some("0\n")), "tsun0"), + Some(true) + ); + assert_eq!( + forwarding_state(files(Some("0\n"), Some("1\n")), "tsun0"), + Some(false) + ); + assert_eq!( + forwarding_state(files(None, Some("1\n")), "tsun0"), + Some(true) + ); + assert_eq!(forwarding_state(files(None, None), "tsun0"), None); + assert_eq!(forwarding_state(files(Some("junk"), None), "tsun0"), None); + // A name that would leave the directory is not looked up. + assert_eq!(forwarding_state(files(Some("1"), None), "../../x"), None); + } + + #[test] + fn the_effective_uid_is_the_second_field() { + let status = "Name:\ttsunagi\nUid:\t1001\t1002\t1001\t1001\nGid:\t5\t5\t5\t5\n"; + assert_eq!(parse_euid(status), Some(1002)); + assert_eq!(parse_euid("Name:\tx\n"), None); + } + + #[test] + fn a_login_session_is_refused_and_a_service_is_not() { + let reason = client_refusal(Some("/run/user/1000")).unwrap(); + assert!(reason.contains("system service"), "{reason}"); + assert!(client_refusal(None).is_none()); + assert!(client_refusal(Some("")).is_none()); + } +} diff --git a/crates/tsunagi/src/overlay/hostrules/mod.rs b/crates/tsunagi/src/overlay/hostrules/mod.rs index 1eac5a4..26fc05b 100644 --- a/crates/tsunagi/src/overlay/hostrules/mod.rs +++ b/crates/tsunagi/src/overlay/hostrules/mod.rs @@ -45,9 +45,17 @@ use std::sync::{Arc, Mutex}; use crate::BoxFuture; use crate::state::Ipv4Range; +mod exit; +pub use exit::{ + ExitHostPlan, ExitHostReport, ExitHostRules, MockExitRules, exit_tag, exit_tag_prefix, + offer_detail, +}; + #[cfg(all(feature = "tun-device", target_os = "linux"))] mod linux; #[cfg(all(feature = "tun-device", target_os = "linux"))] +mod linux_exit; +#[cfg(all(feature = "tun-device", target_os = "linux"))] pub use linux::LinuxHostRules; // Compiled everywhere so the scripts it builds are tested everywhere; only @@ -159,6 +167,15 @@ pub trait BroadcastHostRules: Send + Sync + std::fmt::Debug + 'static { /// Removes what this agent put there for the interface. Succeeds when /// nothing is there: it runs on the shutdown path. fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()>; + + /// The exit-node rules that go with these, when the platform has them. + /// + /// One object per host, because both halves need the same privileged + /// helpers; the default is that this platform has none, which is said + /// where it matters rather than pretended. + fn exit_rules(&self) -> Option<&dyn ExitHostRules> { + None + } } /// The platform has no implementation, which is said rather than pretended. @@ -195,6 +212,7 @@ pub type MockRulesState = std::collections::BTreeMap /// An in-memory host, for tests: nothing is touched. #[derive(Debug, Clone, Default)] pub struct MockHostRules { + exit: MockExitRules, state: Arc>, calls: Arc>>, firewall_failure: Arc>>, @@ -203,7 +221,15 @@ pub struct MockHostRules { impl MockHostRules { /// An empty host. pub fn new() -> Self { - Self::default() + Self { + exit: MockExitRules::new(), + ..Self::default() + } + } + + /// The exit-node half of this host, for inspecting and steering tests. + pub fn exit(&self) -> &MockExitRules { + &self.exit } /// Makes the firewall step fail with this reason from now on. @@ -234,6 +260,10 @@ impl BroadcastHostRules for MockHostRules { "mock" } + fn exit_rules(&self) -> Option<&dyn ExitHostRules> { + Some(&self.exit) + } + fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> { Box::pin(async move { lock(&self.calls).push(format!("apply:{}", plan.interface)); diff --git a/crates/tsunagi/src/overlay/interface.rs b/crates/tsunagi/src/overlay/interface.rs index ed58630..fc404fa 100644 --- a/crates/tsunagi/src/overlay/interface.rs +++ b/crates/tsunagi/src/overlay/interface.rs @@ -29,7 +29,11 @@ use tokio::task::JoinHandle; use crate::identity::NetworkId; -use super::hostrules::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport}; +use super::exit::{ExitRulesReport, RuleSetReport}; +use super::hostrules::{ + BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport, + RuleOutcome, offer_detail, +}; use super::packet::IpHeader; use super::router::{Route, RoutingTable}; use super::tun::{TunDevice, TunFactory, TunRequest}; @@ -158,6 +162,31 @@ pub struct Interface { host_rules: Option>, /// What those rules last reported, `None` while none are installed. rules_report: std::sync::Mutex>, + /// What the exit-node rules were last asked for, and what came of it. + exit_state: std::sync::Mutex, +} + +/// The exit-node host rules as this interface last left them. +#[derive(Debug, Default)] +struct ExitState { + /// Whether what an earlier run left behind has been taken away. Done once, + /// at the first sync, whether or not anything is wanted now: a firewall + /// rule that forwards a range must not outlive the switch that offered it. + cleaned: bool, + /// What was last applied, `None` while nothing is. + plan: Option, + /// What came of it. + report: Option, +} + +impl ExitState { + /// Whether every step of the last apply took. + fn is_ok(&self) -> bool { + self.report.as_ref().is_some_and(|report| { + report.offer.iter().all(|(_, outcome)| outcome.is_applied()) + && report.client.as_ref().is_none_or(RuleOutcome::is_applied) + }) + } } impl Interface { @@ -245,6 +274,7 @@ impl Interface { sync_lock: Arc::new(tokio::sync::Mutex::new(())), host_rules, rules_report: std::sync::Mutex::new(None), + exit_state: std::sync::Mutex::new(ExitState::default()), }) } @@ -298,6 +328,193 @@ impl Interface { } } + /// Brings the host's exit-node rules in line with the routing table. + /// + /// Idempotent, and cheap when nothing changed. It never fails: the + /// outcome is kept for [`Self::exit_rules`]. The first call also takes + /// away whatever an earlier run left, even when nothing is wanted now. + pub async fn sync_exit_rules(&self) { + let Some(exit) = self + .host_rules + .as_ref() + .and_then(|rules| rules.exit_rules()) + else { + return; + }; + let _guard = self.sync_lock.lock().await; + let mut ranges: Vec<_> = self + .routes + .exit_offers() + .into_iter() + .map(|(_, range)| range) + .collect(); + ranges.sort_by_key(|range| (range.base, range.prefix_len)); + ranges.dedup(); + let wanted = ExitHostPlan { + interface: self.name.clone(), + offer: ranges, + client: self.routes.exit_via().is_some(), + }; + + let first = !std::mem::replace(&mut self.exit_state().cleaned, true); + if first { + exit.clear(&self.name).await; + } + let (planned, ok) = { + let state = self.exit_state(); + (state.plan.clone(), state.is_ok()) + }; + if wanted.is_empty() { + if planned.is_some() { + exit.clear(&self.name).await; + let mut state = self.exit_state(); + state.plan = None; + state.report = None; + tracing::info!(interface = %self.name, "exit node host rules removed"); + } + return; + } + if planned.as_ref() == Some(&wanted) && ok { + return; + } + let report = exit.apply(&wanted).await; + for (range, outcome) in &report.offer { + match outcome { + RuleOutcome::Applied => tracing::info!( + interface = %self.name, + %range, + "exit node firewall rules applied" + ), + RuleOutcome::Failed(reason) => tracing::warn!( + interface = %self.name, + %range, + "exit node firewall rules are incomplete: {reason}" + ), + } + } + if report.offer.iter().any(|(_, outcome)| outcome.is_applied()) + && report.forwarding == Some(false) + { + tracing::warn!( + interface = %self.name, + "kernel forwarding is off for this interface, so nothing is forwarded for the \ + exit node yet: `sysctl -w net.ipv4.conf.{0}.forwarding=1` (or \ + `net.ipv4.ip_forward=1`), and set it in sysctl.d to keep it", + self.name + ); + } + match &report.client { + Some(RuleOutcome::Applied) => tracing::info!( + interface = %self.name, + "this device's traffic is routed through its exit node" + ), + Some(RuleOutcome::Failed(reason)) => tracing::warn!( + interface = %self.name, + "cannot route this device's traffic through the exit node: {reason}" + ), + None => {} + } + let mut state = self.exit_state(); + state.plan = Some(wanted); + state.report = Some(report); + } + + fn exit_state(&self) -> std::sync::MutexGuard<'_, ExitState> { + match self.exit_state.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + /// What the exit-node rules of a network are doing now. + /// + /// `offer` is `Some` while the network offers this agent as an exit node, + /// `client` while this device sends its traffic through a member of it. + pub fn exit_rules(&self, network: NetworkId) -> ExitRulesReport { + let Some((policy, range)) = self.routes.exit_settings(network) else { + return ExitRulesReport::default(); + }; + let unavailable = match self + .host_rules + .as_ref() + .map(|rules| rules.exit_rules().is_some()) + { + None => Some( + "this overlay interface is not on the host (no TUN device), so there is \ + nothing to forward to", + ), + Some(false) => Some("exit nodes are only supported on Linux yet"), + Some(true) => None, + }; + let failed = |detail: &str| RuleSetReport { + ok: false, + detail: detail.to_string(), + }; + let state = self.exit_state(); + let mut out = ExitRulesReport::default(); + if policy.offer { + out.offer = Some(match (unavailable, range) { + (Some(why), _) => failed(why), + (None, None) => failed("waiting for the network to agree an address range"), + (None, Some(range)) => { + match state + .report + .as_ref() + .and_then(|report| report.offer.iter().find(|(r, _)| *r == range)) + { + Some((_, outcome)) => RuleSetReport { + ok: outcome.is_applied(), + detail: offer_detail(outcome, &range), + }, + None => failed("not applied yet"), + } + } + }); + if unavailable.is_none() { + out.forwarding = state.report.as_ref().and_then(|report| report.forwarding); + } + } + if policy.via.is_some() + && self + .routes + .exit_via() + .is_some_and(|(selected, _)| selected == network) + { + out.client = Some(match unavailable { + Some(why) => failed(why), + None => match state + .report + .as_ref() + .and_then(|report| report.client.as_ref()) + { + Some(RuleOutcome::Applied) => RuleSetReport { + ok: true, + detail: "this device's traffic is routed through the exit node".to_string(), + }, + Some(RuleOutcome::Failed(reason)) => failed(reason), + None => failed("not applied yet"), + }, + }); + } + out + } + + /// Whether this agent can really act as an exit node in a network: the + /// rules members' traffic needs are in place. Kernel forwarding is not + /// part of it, because the agent never turns that on itself. + pub fn exit_offer_ready(&self, network: NetworkId) -> bool { + let Some((policy, Some(range))) = self.routes.exit_settings(network) else { + return false; + }; + policy.offer + && self.exit_state().report.as_ref().is_some_and(|report| { + report + .offer + .iter() + .any(|(r, outcome)| *r == range && outcome.is_applied()) + }) + } + /// What the broadcast host rules achieved, `None` when none are installed /// or this interface has no host to configure. pub fn broadcast_rules(&self) -> Option { @@ -379,6 +596,16 @@ impl Interface { if let Some(rules) = &self.host_rules { rules.clear(&self.name).await; self.store_rules_report(None); + let touched = { + let state = self.exit_state(); + state.cleaned || state.plan.is_some() + }; + if let (true, Some(exit)) = (touched, rules.exit_rules()) { + exit.clear(&self.name).await; + let mut state = self.exit_state(); + state.plan = None; + state.report = None; + } } // The packet loop holds the device open, and it ends only when the // device reports end of stream — which a real interface never does @@ -435,6 +662,11 @@ impl Interface { IpAddr::V4(destination) if self.routes.is_local_destination(network, destination) => { false } + // This agent is an exit node of this network: a member's packet + // for the internet goes to the host, which masquerades it. + IpAddr::V4(destination) if self.routes.accepts_exit_traffic(network, destination) => { + false + } _ => { self.tally.wrong_destination.fetch_add(1, Ordering::Relaxed); return Err(Rejected::WrongDestination); @@ -525,7 +757,7 @@ mod tests { use crate::overlay::broadcast::BroadcastPolicy; use crate::overlay::hostrules::{MockHostRules, RuleOutcome}; use crate::overlay::provision::{ManagedTunFactory, MockProvisioner}; - use crate::overlay::router::NetworkRoutes; + use crate::overlay::router::{ExitPolicy, NetworkRoutes}; use crate::overlay::tun::{MemoryTun, MemoryTunFactory}; fn network(name: &str) -> NetworkId { @@ -667,6 +899,7 @@ mod tests { .set_network( id, NetworkRoutes { + exit: Default::default(), broadcast: Default::default(), range: Some("10.13.37.0/24".parse().unwrap()), local: Some(addr(1)), @@ -833,6 +1066,7 @@ mod tests { .set_network( id, NetworkRoutes { + exit: Default::default(), broadcast: Default::default(), range: Some("10.13.37.0/24".parse().unwrap()), local: Some(addr(9)), @@ -856,6 +1090,7 @@ mod tests { .set_network( id, NetworkRoutes { + exit: Default::default(), broadcast: BroadcastPolicy { enabled: broadcast_enabled, ..Default::default() @@ -910,6 +1145,7 @@ mod tests { .set_network( id, NetworkRoutes { + exit: Default::default(), broadcast: BroadcastPolicy::default(), range: Some("10.13.37.0/24".parse().unwrap()), local: Some(addr(9)), @@ -978,4 +1214,261 @@ mod tests { assert!(interface.broadcast_rules().is_none()); interface.remove().await; } + + const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34); + + fn offering() -> ExitPolicy { + ExitPolicy { + offer: true, + via: None, + } + } + + fn using(exit: EndpointId) -> ExitPolicy { + ExitPolicy { + offer: false, + via: Some(exit), + } + } + + #[tokio::test] + async fn internet_traffic_goes_to_the_exit_node_and_only_once_one_is_chosen() { + let (interface, device, routes, carrier, id) = interface(false).await; + device.push_from_os(ipv4(addr(1), INTERNET, b"before")); + wait_for(&interface, |counters| counters.unroutable).await; + assert!(carrier.carried().is_empty(), "no exit node, no route"); + + routes.set_exit(id, using(peer(2))); + device.push_from_os(ipv4(addr(1), INTERNET, b"after")); + wait_for(&interface, |counters| counters.sent).await; + let carried = carrier.carried(); + assert_eq!(carried.len(), 1); + assert_eq!(carried[0].0.peer, peer(2)); + assert_eq!(&carried[0].1[20..], b"after"); + } + + #[tokio::test] + async fn the_exit_node_may_answer_with_any_source_and_nobody_else_may() { + let (interface, device, routes, _carrier, id) = interface(false).await; + routes.set_exit(id, using(peer(2))); + + interface + .deliver(id, peer(2), ipv4(INTERNET, addr(1), b"reply")) + .await + .unwrap(); + let written = tokio::time::timeout(std::time::Duration::from_secs(5), device.pop_to_os()) + .await + .expect("the reply reaches the interface") + .unwrap(); + assert_eq!(&written[20..], b"reply"); + + // Another member cannot use the internet's addresses. + assert_eq!( + interface + .deliver(id, peer(3), ipv4(INTERNET, addr(1), b"spoof")) + .await, + Err(Rejected::WrongSource) + ); + // Nor can the exit node speak for a member. + assert_eq!( + interface + .deliver(id, peer(2), ipv4(addr(3), addr(1), b"spoof")) + .await, + Err(Rejected::WrongSource) + ); + // And what it sends still has to be for this agent. + assert_eq!( + interface + .deliver(id, peer(2), ipv4(INTERNET, addr(200), b"elsewhere")) + .await, + Err(Rejected::WrongDestination) + ); + assert_eq!(interface.counters().received, 1); + } + + #[tokio::test] + async fn an_exit_node_hands_a_members_internet_packet_to_the_host_only_while_offering() { + let (interface, device, routes, _carrier, id) = interface(false).await; + + // Not offering: dropped exactly as before. + assert_eq!( + interface + .deliver(id, peer(2), ipv4(addr(2), INTERNET, b"request")) + .await, + Err(Rejected::WrongDestination) + ); + assert_eq!(interface.counters().received, 0); + + routes.set_exit(id, offering()); + interface + .deliver(id, peer(2), ipv4(addr(2), INTERNET, b"request")) + .await + .unwrap(); + let written = tokio::time::timeout(std::time::Duration::from_secs(5), device.pop_to_os()) + .await + .expect("the request reaches the interface") + .unwrap(); + assert_eq!(&written[20..], b"request"); + + // The source must still be the sender's own address. + assert_eq!( + interface + .deliver(id, peer(2), ipv4(addr(3), INTERNET, b"spoof")) + .await, + Err(Rejected::WrongSource) + ); + assert_eq!( + interface + .deliver(id, peer(2), ipv4(INTERNET, INTERNET, b"spoof")) + .await, + Err(Rejected::WrongSource) + ); + // Offering does not make this a door to the overlay or to this host. + for destination in [addr(3), addr(200), Ipv4Addr::LOCALHOST] { + assert_eq!( + interface + .deliver(id, peer(2), ipv4(addr(2), destination, b"no")) + .await, + Err(Rejected::WrongDestination), + "{destination}" + ); + } + assert_eq!(interface.counters().received, 1); + } + + #[tokio::test] + async fn the_exit_rules_follow_the_routing_table_and_leave_with_the_interface() { + let rules = MockHostRules::new(); + let (interface, routes, id) = managed_interface(&rules, false).await; + let range: crate::state::Ipv4Range = "10.13.37.0/24".parse().unwrap(); + + // Nothing wanted: nothing installed, but what a crashed run left is + // taken away once. + interface.sync_exit_rules().await; + interface.sync_exit_rules().await; + assert_eq!(rules.exit().calls(), ["clear:tsunrules"]); + assert!(rules.exit().installed().is_none()); + assert!(!interface.exit_offer_ready(id)); + assert_eq!(interface.exit_rules(id), ExitRulesReport::default()); + + // Offering: the range is masqueraded, and the agent can say so. + routes.set_exit(id, offering()); + assert!(!interface.exit_offer_ready(id), "not applied yet"); + let waiting = interface.exit_rules(id).offer.unwrap(); + assert!(!waiting.ok); + assert!(waiting.detail.contains("not applied yet"), "{waiting:?}"); + interface.sync_exit_rules().await; + assert_eq!(rules.exit().installed().unwrap().offer, vec![range]); + assert!(interface.exit_offer_ready(id)); + let report = interface.exit_rules(id); + assert!(report.offer.as_ref().unwrap().ok); + assert_eq!(report.forwarding, Some(true)); + assert!(report.client.is_none()); + + // Unchanged: not applied again. + interface.sync_exit_rules().await; + assert_eq!( + rules + .exit() + .calls() + .iter() + .filter(|call| call.starts_with("apply")) + .count(), + 1 + ); + + // Forwarding off in the kernel: still installed, still ready, and said. + rules.exit().set_forwarding(Some(false)); + routes.set_exit( + id, + ExitPolicy { + offer: true, + via: Some(peer(2)), + }, + ); + interface.sync_exit_rules().await; + assert!(rules.exit().installed().unwrap().client); + assert!(interface.exit_offer_ready(id)); + let report = interface.exit_rules(id); + assert_eq!(report.forwarding, Some(false)); + assert!(report.offer.unwrap().ok); + assert!(report.client.unwrap().ok); + + // Turned off: removed, and no longer announced as ready. + routes.set_exit(id, ExitPolicy::default()); + interface.sync_exit_rules().await; + assert!(rules.exit().installed().is_none()); + assert!(!interface.exit_offer_ready(id)); + assert_eq!(interface.exit_rules(id), ExitRulesReport::default()); + + routes.set_exit(id, offering()); + interface.sync_exit_rules().await; + assert!(rules.exit().installed().is_some()); + interface.remove().await; + assert!( + rules.exit().installed().is_none(), + "gone with the interface" + ); + } + + #[tokio::test] + async fn a_failed_exit_step_is_reported_and_the_agent_does_not_claim_to_be_an_exit_node() { + let rules = MockHostRules::new(); + rules.exit().fail_with(Some("iptables needs root".into())); + let (interface, routes, id) = managed_interface(&rules, false).await; + routes.set_exit(id, offering()); + interface.sync_exit_rules().await; + + assert!(!interface.exit_offer_ready(id)); + let offer = interface.exit_rules(id).offer.unwrap(); + assert!(!offer.ok); + assert!(offer.detail.contains("iptables needs root"), "{offer:?}"); + + // It is tried again, not given up on, the next time it is asked. + rules.exit().fail_with(None); + interface.sync_exit_rules().await; + assert!(interface.exit_offer_ready(id)); + interface.remove().await; + } + + #[tokio::test] + async fn an_interface_without_a_host_cannot_be_an_exit_node_and_says_why() { + let (interface, _device, routes, _carrier, id) = interface(false).await; + routes.set_exit( + id, + ExitPolicy { + offer: true, + via: Some(peer(2)), + }, + ); + interface.sync_exit_rules().await; + assert!(!interface.exit_offer_ready(id)); + let report = interface.exit_rules(id); + let offer = report.offer.unwrap(); + assert!(!offer.ok); + assert!(offer.detail.contains("not on the host"), "{offer:?}"); + assert!(!report.client.unwrap().ok); + interface.remove().await; + } + + #[tokio::test] + async fn a_network_waiting_for_its_range_has_nothing_to_masquerade_yet() { + let rules = MockHostRules::new(); + let (interface, routes, _id) = managed_interface(&rules, false).await; + let waiting = network("waiting"); + routes + .set_network( + waiting, + NetworkRoutes { + exit: offering(), + ..Default::default() + }, + ) + .unwrap(); + interface.sync_exit_rules().await; + assert!(rules.exit().installed().is_none()); + let offer = interface.exit_rules(waiting).offer.unwrap(); + assert!(offer.detail.contains("address range"), "{offer:?}"); + interface.remove().await; + } } diff --git a/crates/tsunagi/src/overlay/mod.rs b/crates/tsunagi/src/overlay/mod.rs index 5f2c230..1f47dd5 100644 --- a/crates/tsunagi/src/overlay/mod.rs +++ b/crates/tsunagi/src/overlay/mod.rs @@ -40,6 +40,7 @@ pub enum OverlayError { pub mod broadcast; pub mod config; +pub mod exit; pub mod hostrules; pub mod interface; pub mod packet; @@ -48,9 +49,10 @@ pub mod router; pub mod tun; pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name}; +pub use exit::{ExitRulesReport, RuleSetReport}; pub use hostrules::{ - BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, MockHostRules, RuleOutcome, - UnsupportedHostRules, + BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, ExitHostPlan, ExitHostReport, + ExitHostRules, MockExitRules, MockHostRules, RuleOutcome, UnsupportedHostRules, }; pub use interface::{Counters, Interface, PacketCarrier, Rejected}; pub use packet::IpHeader; @@ -59,7 +61,7 @@ pub use provision::{ MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes, probe_net_admin, }; -pub use router::{NetworkRoutes, Route, RouteError, RoutingTable}; +pub use router::{ExitPolicy, NetworkRoutes, Route, RouteError, RoutingTable}; pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local}; #[cfg(all(feature = "tun-device", target_os = "linux"))] diff --git a/crates/tsunagi/src/overlay/provision/linux.rs b/crates/tsunagi/src/overlay/provision/linux.rs index 4de2cd1..421f71d 100644 --- a/crates/tsunagi/src/overlay/provision/linux.rs +++ b/crates/tsunagi/src/overlay/provision/linux.rs @@ -48,7 +48,8 @@ use futures_util::TryStreamExt; use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage}; use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage}; use rtnetlink::packet_route::route::RouteScope; -use rtnetlink::{LinkMessageBuilder, LinkUnspec, RouteMessageBuilder}; +use rtnetlink::packet_route::rule::{RuleAction, RuleAttribute, RuleMessage, RuleUidRange}; +use rtnetlink::{IpVersion, LinkMessageBuilder, LinkUnspec, RouteMessageBuilder}; use crate::BoxFuture; use crate::overlay::OverlayError; @@ -72,6 +73,11 @@ enum Command { SetBroadcastRoute(String, Ipv4Addr, Reply<()>), /// Remove that route from this interface. DelBroadcastRoute(String, Reply<()>), + /// Send the host's default traffic through this interface, except what + /// this user id sends. + SetExitClient(String, u32, Reply<()>), + /// Take those routing rules away again. + ClearExitClient(Reply<()>), /// Stop the thread. Stop, } @@ -203,6 +209,20 @@ impl RouteHandle { Command::DelBroadcastRoute(interface, reply) }) } + + /// Sends the host's default traffic through `interface`, except what the + /// user id `uid` sends. Replaces what an earlier call set up. + pub(crate) fn set_exit_client(&self, interface: &str, uid: u32) -> Result<(), OverlayError> { + let interface = interface.to_string(); + call_thread(&self.commands, |reply| { + Command::SetExitClient(interface, uid, reply) + }) + } + + /// Removes those rules. Succeeds when none are there. + pub(crate) fn clear_exit_client(&self) -> Result<(), OverlayError> { + call_thread(&self.commands, Command::ClearExitClient) + } } impl Drop for NetlinkProvisioner { @@ -324,7 +344,11 @@ fn netlink_thread(requests: mpsc::Receiver) { Command::Delete(_, reply) | Command::Configure(_, reply) | Command::SetBroadcastRoute(_, _, reply) - | Command::DelBroadcastRoute(_, reply) => { + | Command::DelBroadcastRoute(_, reply) + | Command::SetExitClient(_, _, reply) => { + let _ = reply.send(Err(OverlayError::Unavailable(message))); + } + Command::ClearExitClient(reply) => { let _ = reply.send(Err(OverlayError::Unavailable(message))); } Command::Stop => return, @@ -373,6 +397,22 @@ fn netlink_thread(requests: mpsc::Receiver) { }); let _ = reply.send(result); } + Command::SetExitClient(name, uid, reply) => { + let result = NetAdmin::acquire().and_then(|guard| { + let result = runtime.block_on(set_exit_client(&name, uid)); + drop(guard); + result + }); + let _ = reply.send(result); + } + Command::ClearExitClient(reply) => { + let result = NetAdmin::acquire().and_then(|guard| { + let result = runtime.block_on(clear_exit_client()); + drop(guard); + result + }); + let _ = reply.send(result); + } } } } @@ -596,6 +636,192 @@ async fn del_broadcast_route(name: &str) -> Result<(), OverlayError> { .await } +/// The routing table that holds the exit node's default route: `tsun` in +/// ASCII-ish hex, and nothing else uses it. +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; +const EXIT_SPECIFIC_PRIORITY: u32 = 5290; +const EXIT_DEFAULT_PRIORITY: u32 = 5300; + +/// The table a rule looks up. +fn rule_table(rule: &RuleMessage) -> u32 { + rule.attributes + .iter() + .find_map(|attribute| match attribute { + RuleAttribute::Table(table) => Some(*table), + _ => None, + }) + .unwrap_or(u32::from(rule.header.table)) +} + +fn rule_priority(rule: &RuleMessage) -> Option { + rule.attributes + .iter() + .find_map(|attribute| match attribute { + RuleAttribute::Priority(priority) => Some(*priority), + _ => None, + }) +} + +/// Whether a rule is one of the three this agent adds for an exit node. +/// +/// 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) => { + table == MAIN_TABLE + && rule + .attributes + .iter() + .any(|attribute| matches!(attribute, RuleAttribute::UidRange(_))) + } + Some(EXIT_SPECIFIC_PRIORITY) => { + table == MAIN_TABLE + && rule + .attributes + .iter() + .any(|attribute| matches!(attribute, RuleAttribute::SuppressPrefixLen(0))) + } + Some(EXIT_DEFAULT_PRIORITY) => table == EXIT_TABLE, + _ => false, + } +} + +fn exit_default_route(index: Option) -> rtnetlink::packet_route::route::RouteMessage { + let mut builder = RouteMessageBuilder::::new() + .destination_prefix(Ipv4Addr::UNSPECIFIED, 0) + .table_id(EXIT_TABLE); + if let Some(index) = index { + builder = builder.output_interface(index).scope(RouteScope::Link); + } + builder.build() +} + +/// Removes the exit node's routing rules and its table. Nothing there is +/// success: it also runs once at startup, for what a crashed run left. +async fn clear_exit_client() -> Result<(), OverlayError> { + with_netlink(|handle| async move { remove_exit_client(&handle).await }).await +} + +async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayError> { + let mut rules = handle.rule().get(IpVersion::V4).execute(); + let mut ours = Vec::new(); + while let Some(rule) = rules + .try_next() + .await + .map_err(|err| OverlayError::Unavailable(format!("cannot read the routing rules: {err}")))? + { + if is_exit_rule(&rule) { + ours.push(rule); + } + } + for rule in ours { + match handle.rule().del(rule).execute().await { + Ok(()) => {} + // Gone already. + Err(err) if err.to_string().contains("No such") => {} + Err(err) => { + return Err(OverlayError::Unavailable(format!( + "cannot remove an exit node routing rule: {err}" + ))); + } + } + } + match handle.route().del(exit_default_route(None)).execute().await { + Ok(()) => Ok(()), + // "No such process": no such route, which is the goal. + Err(err) if err.to_string().contains("No such") => Ok(()), + Err(err) => Err(OverlayError::Unavailable(format!( + "cannot remove the exit node default route: {err}" + ))), + } +} + +/// Sets up the using side of an exit node. +/// +/// ```text +/// ip route replace default dev table 0x7473 +/// ip rule add priority 5280 uidrange - lookup main +/// ip rule add priority 5290 lookup main suppress_prefixlength 0 +/// ip rule add priority 5300 lookup 0x7473 +/// ``` +/// +/// What an earlier call left is removed first, so repeating it is safe, and +/// the rules go in last, so a failure part way never leaves a rule that +/// sends traffic to a table with no route in it. +async fn set_exit_client(name: &str, uid: u32) -> Result<(), OverlayError> { + with_netlink(|handle| async move { + let index = link_index(&handle, name).await.ok_or_else(|| { + OverlayError::Unavailable(format!( + "interface `{name}` disappeared before the exit node routes could be added" + )) + })?; + remove_exit_client(&handle).await?; + let fail = |what: &str, err: rtnetlink::Error| { + OverlayError::Unavailable(format!("cannot add the exit node {what}: {err}")) + }; + handle + .route() + .add(exit_default_route(Some(index))) + .replace() + .execute() + .await + .map_err(|err| fail("default route", err))?; + + let mut bypass = handle + .rule() + .add() + .v4() + .table_id(MAIN_TABLE) + .action(RuleAction::ToTable) + .priority(EXIT_BYPASS_PRIORITY); + bypass + .message_mut() + .attributes + .push(RuleAttribute::UidRange(RuleUidRange { + start: uid, + end: uid, + })); + bypass + .execute() + .await + .map_err(|err| fail("rule for the agent's own traffic", err))?; + + let mut specific = handle + .rule() + .add() + .v4() + .table_id(MAIN_TABLE) + .action(RuleAction::ToTable) + .priority(EXIT_SPECIFIC_PRIORITY); + specific + .message_mut() + .attributes + .push(RuleAttribute::SuppressPrefixLen(0)); + specific + .execute() + .await + .map_err(|err| fail("rule for the specific routes", err))?; + + handle + .rule() + .add() + .v4() + .table_id(EXIT_TABLE) + .action(RuleAction::ToTable) + .priority(EXIT_DEFAULT_PRIORITY) + .execute() + .await + .map_err(|err| fail("rule for the default route", err)) + }) + .await +} + async fn configure_link(configure: &Configure) -> Result<(), OverlayError> { let name = configure.name.as_str(); with_netlink(|handle| async move { @@ -737,6 +963,48 @@ mod tests { ); } + fn rule(priority: u32, table: u32, extra: Vec) -> RuleMessage { + let mut message = RuleMessage::default(); + message.attributes.push(RuleAttribute::Priority(priority)); + if table > 255 { + message.attributes.push(RuleAttribute::Table(table)); + } else { + message.header.table = table as u8; + } + message.attributes.extend(extra); + message + } + + #[test] + fn only_the_three_exit_rules_are_recognised_as_ours() { + let uid = RuleAttribute::UidRange(RuleUidRange { start: 5, end: 5 }); + assert!(is_exit_rule(&rule(5280, 254, vec![uid.clone()]))); + assert!(is_exit_rule(&rule( + 5290, + 254, + vec![RuleAttribute::SuppressPrefixLen(0)] + ))); + assert!(is_exit_rule(&rule(5300, EXIT_TABLE, vec![]))); + + // Same priorities, somebody else's rules. + assert!(!is_exit_rule(&rule(5280, 254, vec![]))); + assert!(!is_exit_rule(&rule(5290, 254, vec![]))); + assert!(!is_exit_rule(&rule(5300, 52, vec![]))); + assert!(!is_exit_rule(&rule(5270, 52, vec![]))); + assert!(!is_exit_rule(&rule(5280, 100, vec![uid]))); + } + + #[test] + fn the_default_route_of_the_exit_table_names_the_table_and_the_interface() { + let route = exit_default_route(Some(7)); + assert_eq!(route.header.destination_prefix_length, 0); + let debug = format!("{route:?}"); + assert!(debug.contains(&format!("Table({EXIT_TABLE})")), "{debug}"); + assert!(debug.contains("Oif(7)"), "{debug}"); + // Deleting names no interface, so it matches whatever it was added on. + assert!(!format!("{:?}", exit_default_route(None)).contains("Oif")); + } + #[test] fn an_interface_that_is_not_a_tun_is_reported_foreign() { let message = LinkMessage::default(); diff --git a/crates/tsunagi/src/overlay/router.rs b/crates/tsunagi/src/overlay/router.rs index 6b882f9..2cfd1a7 100644 --- a/crates/tsunagi/src/overlay/router.rs +++ b/crates/tsunagi/src/overlay/router.rs @@ -40,9 +40,21 @@ pub struct Route { pub peer: EndpointId, } +/// A network's exit-node settings, as far as packets are concerned. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ExitPolicy { + /// This agent offers itself as an exit node here: packets from members + /// for addresses outside every overlay range are handed to the host. + pub offer: bool, + /// The member all other traffic is sent to, if this device uses one. + pub via: Option, +} + /// What one network contributes to the table. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct NetworkRoutes { + /// Exit-node settings of this network. + pub exit: ExitPolicy, /// Local broadcast policy and authenticated willing recipients. pub broadcast: BroadcastPolicy, /// The range this network allocates from, once it has agreed one. @@ -153,6 +165,16 @@ impl RoutingTable { } } + /// Replaces only a network's exit-node settings, without changing addresses. + /// + /// A network that has no entry yet (its range is not agreed) takes them + /// from [`NetworkRoutes::exit`] when it is added. + pub fn set_exit(&self, network: NetworkId, policy: ExitPolicy) { + if let Some(routes) = self.write().get_mut(&network) { + routes.exit = policy; + } + } + /// Recognizes limited and configured subnet broadcasts before unicast lookup. pub fn is_broadcast(&self, destination: Ipv4Addr) -> bool { self.broadcast.load().is_destination(destination) @@ -181,6 +203,10 @@ impl RoutingTable { /// /// This agent's own address returns `None`: a packet for ourselves does /// not go over a tunnel, and answering with a peer would send it to one. + /// + /// An address nobody holds, outside every overlay range and an ordinary + /// unicast destination, goes to this device's exit node when it has one: + /// that is what sending *all* traffic through a member means. pub fn route(&self, destination: Ipv4Addr) -> Option { let networks = self.read(); for (network, routes) in networks.iter() { @@ -198,17 +224,80 @@ impl RoutingTable { }); } } - None + // Inside an overlay range, an unowned address is nobody's, not the + // internet's. + if !is_internet_destination(destination) || in_any_range(&networks, destination) { + return None; + } + exit_via(&networks).map(|(network, peer)| Route { network, peer }) } /// Whether a peer may send from a source address. + /// + /// A peer speaks only for the address the network agreed it holds. The + /// one exception is this device's exit node: what comes back from the + /// internet carries the internet's addresses, so from *that* peer any + /// source outside the overlay ranges is accepted. pub fn may_send_from(&self, network: NetworkId, peer: EndpointId, source: Ipv4Addr) -> bool { - self.read().get(&network).is_some_and(|routes| { - routes - .peers - .iter() - .any(|(address, holder)| *address == source && *holder == peer) - }) + let networks = self.read(); + let Some(routes) = networks.get(&network) else { + return false; + }; + if routes + .peers + .iter() + .any(|(address, holder)| *address == source && *holder == peer) + { + return true; + } + routes.exit.via == Some(peer) + && is_internet_destination(source) + && !in_any_range(&networks, source) + } + + /// Whether this agent, as an exit node of `network`, takes a member's + /// packet for `destination` to the host. + /// + /// Only an ordinary unicast address outside every overlay range, so a + /// packet for another member, for this agent or for the local network + /// segment never leaves by this door. The sender's own source address is + /// checked separately, by [`Self::may_send_from`]. + pub fn accepts_exit_traffic(&self, network: NetworkId, destination: Ipv4Addr) -> bool { + let networks = self.read(); + networks + .get(&network) + .is_some_and(|routes| routes.exit.offer) + && is_internet_destination(destination) + && !in_any_range(&networks, destination) + } + + /// The network and member this device sends its internet traffic through. + /// + /// One default route, so one answer; if several networks claim one (they + /// should not) the lowest network id decides, so two runs agree. + pub fn exit_via(&self) -> Option<(NetworkId, EndpointId)> { + exit_via(&self.read()) + } + + /// Networks that offer this agent as an exit node, with the range each + /// agreed. A network that has not agreed one yet has nothing to + /// masquerade and is left out. + pub fn exit_offers(&self) -> Vec<(NetworkId, Ipv4Range)> { + let mut offers: Vec<(NetworkId, Ipv4Range)> = self + .read() + .iter() + .filter(|(_, routes)| routes.exit.offer) + .filter_map(|(network, routes)| Some((*network, routes.range?))) + .collect(); + offers.sort_by_key(|(network, _)| *network); + offers + } + + /// One network's exit-node settings and its range, if it is in the table. + pub fn exit_settings(&self, network: NetworkId) -> Option<(ExitPolicy, Option)> { + self.read() + .get(&network) + .map(|routes| (routes.exit, routes.range)) } /// Whether an ordinary packet terminates on this host in this network. @@ -275,6 +364,31 @@ impl RoutingTable { } } +/// Whether an address is an ordinary unicast one: not this host, not a +/// multicast or broadcast group, not link-local, not unspecified. +fn is_internet_destination(address: Ipv4Addr) -> bool { + !(address.is_unspecified() + || address.is_loopback() + || address.is_multicast() + || address.is_broadcast() + || address.is_link_local()) +} + +/// Whether an address lies in any network's range. +fn in_any_range(networks: &HashMap, address: Ipv4Addr) -> bool { + networks + .values() + .any(|routes| routes.range.is_some_and(|range| range.contains(address))) +} + +/// The exit node in use, if any, from the lowest network that names one. +fn exit_via(networks: &HashMap) -> Option<(NetworkId, EndpointId)> { + networks + .iter() + .filter_map(|(network, routes)| Some((*network, routes.exit.via?))) + .min_by_key(|(network, _)| *network) +} + /// Whether two ranges share any address. fn ranges_overlap(one: Ipv4Range, other: Ipv4Range) -> bool { // A range contains the other's base, or the other way round. With @@ -308,6 +422,7 @@ mod tests { fn routes() -> NetworkRoutes { NetworkRoutes { + exit: Default::default(), broadcast: Default::default(), range: Some("10.13.37.0/24".parse().unwrap()), local: Some(addr(1)), @@ -391,6 +506,7 @@ mod tests { .set_network( second, NetworkRoutes { + exit: Default::default(), broadcast: Default::default(), range: Some("10.99.0.0/16".parse().unwrap()), local: Some(Ipv4Addr::new(10, 99, 0, 1)), @@ -420,6 +536,7 @@ mod tests { if a < b { (a, b) } else { (b, a) } }; let mk = |enabled: bool, base: [u8; 4], local: Option| NetworkRoutes { + exit: Default::default(), broadcast: BroadcastPolicy { enabled, ..Default::default() @@ -495,6 +612,7 @@ mod tests { .set_network( network("wide"), NetworkRoutes { + exit: Default::default(), broadcast: Default::default(), range: Some("10.0.0.0/8".parse().unwrap()), ..Default::default() @@ -507,6 +625,7 @@ mod tests { .set_network( network("narrow"), NetworkRoutes { + exit: Default::default(), broadcast: Default::default(), range: Some("10.13.37.0/24".parse().unwrap()), ..Default::default() @@ -549,6 +668,7 @@ mod tests { .set_network( network("two"), NetworkRoutes { + exit: Default::default(), broadcast: Default::default(), range: Some("10.99.0.0/16".parse().unwrap()), local: Some(Ipv4Addr::new(10, 99, 0, 1)), @@ -573,4 +693,233 @@ mod tests { assert_eq!(table.route(addr(2)), None); assert_eq!(table.len(), 1, "the network is known, it just has nothing"); } + + fn with_exit(offer: bool, via: Option) -> NetworkRoutes { + NetworkRoutes { + exit: ExitPolicy { offer, via }, + ..routes() + } + } + + const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34); + + #[test] + fn without_an_exit_node_the_internet_has_nowhere_to_go() { + let table = RoutingTable::new(); + table.set_network(network("plain"), routes()).unwrap(); + assert_eq!(table.route(INTERNET), None); + assert_eq!(table.exit_via(), None); + } + + #[test] + fn with_an_exit_node_every_ordinary_destination_goes_to_it() { + let table = RoutingTable::new(); + let id = network("client"); + table + .set_network(id, with_exit(false, Some(peer(2)))) + .unwrap(); + let exit = Route { + network: id, + peer: peer(2), + }; + assert_eq!(table.route(INTERNET), Some(exit)); + assert_eq!(table.route(Ipv4Addr::new(192, 168, 1, 10)), Some(exit)); + assert_eq!(table.exit_via(), Some((id, peer(2)))); + // A member's own address still goes to that member. + assert_eq!( + table.route(addr(3)), + Some(Route { + network: id, + peer: peer(3) + }) + ); + } + + #[test] + fn the_exit_node_is_never_the_answer_for_what_is_not_the_internet() { + let table = RoutingTable::new(); + table + .set_network(network("client"), with_exit(false, Some(peer(2)))) + .unwrap(); + // Ourselves, a free address inside the overlay range, and everything + // that is not an ordinary unicast destination. + for destination in [ + addr(1), + addr(200), + Ipv4Addr::new(127, 0, 0, 1), + Ipv4Addr::new(224, 0, 0, 251), + Ipv4Addr::new(239, 255, 255, 250), + Ipv4Addr::BROADCAST, + Ipv4Addr::UNSPECIFIED, + Ipv4Addr::new(169, 254, 1, 1), + ] { + assert_eq!(table.route(destination), None, "{destination}"); + } + } + + #[test] + fn another_networks_range_is_not_the_internet_either() { + let table = RoutingTable::new(); + table + .set_network(network("client"), with_exit(false, Some(peer(2)))) + .unwrap(); + table + .set_network( + network("other"), + NetworkRoutes { + range: Some("10.99.0.0/16".parse().unwrap()), + local: Some(Ipv4Addr::new(10, 99, 0, 1)), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(table.route(Ipv4Addr::new(10, 99, 7, 7)), None); + } + + #[test] + fn two_exit_choices_resolve_to_the_lowest_network_id() { + let table = RoutingTable::new(); + let (a, b) = (network("one"), network("two")); + table + .set_network(a, with_exit(false, Some(peer(2)))) + .unwrap(); + table + .set_network( + b, + NetworkRoutes { + exit: ExitPolicy { + offer: false, + via: Some(peer(9)), + }, + range: Some("10.99.0.0/16".parse().unwrap()), + ..Default::default() + }, + ) + .unwrap(); + let (chosen, _) = table.exit_via().unwrap(); + assert_eq!(chosen, a.min(b)); + assert_eq!(table.route(INTERNET).unwrap().network, a.min(b)); + } + + #[test] + fn the_exit_peer_may_send_from_any_internet_address_but_nobody_else_may() { + let table = RoutingTable::new(); + let id = network("client"); + table + .set_network(id, with_exit(false, Some(peer(2)))) + .unwrap(); + + // What comes back from the internet carries the internet's addresses. + assert!(table.may_send_from(id, peer(2), INTERNET)); + // Another member still speaks only for its own address. + assert!(!table.may_send_from(id, peer(3), INTERNET)); + assert!(table.may_send_from(id, peer(3), addr(3))); + // The exit peer cannot pass itself off as somebody else in the + // overlay, nor as this agent, nor as something that is not a source. + assert!(!table.may_send_from(id, peer(2), addr(3))); + assert!(!table.may_send_from(id, peer(2), addr(1))); + assert!(!table.may_send_from(id, peer(2), addr(200))); + for source in [ + Ipv4Addr::LOCALHOST, + Ipv4Addr::UNSPECIFIED, + Ipv4Addr::new(224, 0, 0, 1), + Ipv4Addr::BROADCAST, + ] { + assert!(!table.may_send_from(id, peer(2), source), "{source}"); + } + // Without choosing an exit node nobody gets the exception. + let plain = RoutingTable::new(); + plain.set_network(id, routes()).unwrap(); + assert!(!plain.may_send_from(id, peer(2), INTERNET)); + } + + #[test] + fn an_exit_node_takes_a_members_internet_packets_only_while_it_offers() { + let table = RoutingTable::new(); + let id = network("server"); + table.set_network(id, with_exit(false, None)).unwrap(); + assert!(!table.accepts_exit_traffic(id, INTERNET)); + + table.set_exit( + id, + ExitPolicy { + offer: true, + via: None, + }, + ); + assert!(table.accepts_exit_traffic(id, INTERNET)); + assert!(table.accepts_exit_traffic(id, Ipv4Addr::new(192, 168, 1, 10))); + // Never for another member, for this agent, a free overlay address, + // or anything that is not an ordinary unicast destination. + for destination in [ + addr(1), + addr(2), + addr(200), + Ipv4Addr::LOCALHOST, + Ipv4Addr::new(224, 0, 0, 251), + Ipv4Addr::BROADCAST, + Ipv4Addr::UNSPECIFIED, + ] { + assert!( + !table.accepts_exit_traffic(id, destination), + "{destination}" + ); + } + // An unknown network offers nothing. + assert!(!table.accepts_exit_traffic(network("elsewhere"), INTERNET)); + + table.set_exit(id, ExitPolicy::default()); + assert!(!table.accepts_exit_traffic(id, INTERNET)); + } + + #[test] + fn offers_are_listed_by_network_with_the_range_each_agreed() { + let table = RoutingTable::new(); + let (a, b) = (network("a"), network("b")); + table.set_network(a, with_exit(true, None)).unwrap(); + table + .set_network( + b, + NetworkRoutes { + exit: ExitPolicy { + offer: true, + via: None, + }, + // No range agreed yet: nothing to masquerade. + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + table.exit_offers(), + vec![(a, "10.13.37.0/24".parse().unwrap())] + ); + assert_eq!( + table.exit_settings(a), + Some(( + ExitPolicy { + offer: true, + via: None + }, + Some("10.13.37.0/24".parse().unwrap()) + )) + ); + assert_eq!(table.exit_settings(network("none")), None); + } + + #[test] + fn setting_exit_for_a_network_with_no_entry_is_remembered_by_the_caller() { + // The runtime keeps the policy and passes it with the network when + // its range is agreed; a setter for a network the table does not know + // yet changes nothing. + let table = RoutingTable::new(); + table.set_exit( + network("later"), + ExitPolicy { + offer: true, + via: None, + }, + ); + assert!(table.is_empty()); + } } diff --git a/crates/tsunagi/src/proto/message.rs b/crates/tsunagi/src/proto/message.rs index 6e5d8ed..4eafe82 100644 --- a/crates/tsunagi/src/proto/message.rs +++ b/crates/tsunagi/src/proto/message.rs @@ -20,7 +20,7 @@ use crate::error::ProtocolError; /// The version in the ALPN is the wire-compatibility version of the control /// protocol. It is independent of the network identity scheme version, so /// bumping it must not change any existing [`crate::NetworkId`]. -pub const ALPN: &[u8] = b"tsunagi/ctrl/3"; +pub const ALPN: &[u8] = b"tsunagi/ctrl/4"; /// ALPN of the tsunagi data plane. /// @@ -41,7 +41,7 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32; pub const MAX_SIGNATURE_LEN: usize = 64; /// Control protocol version carried inside the handshake. -pub const PROTOCOL_VERSION: u16 = 3; +pub const PROTOCOL_VERSION: u16 = 4; /// First message of the handshake, sent by the initiator. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -75,6 +75,11 @@ pub struct AuthProof { pub struct Announcement { /// Local opt-in to receive LAN broadcasts in this network. pub broadcast: bool, + /// This agent offers itself as an exit node in this network: members may + /// send all their internet traffic through it. Off unless its owner + /// turned it on, and only said once the rules that make it work are in + /// place. + pub exit_node: bool, /// Human-readable hostname. A mutable binding, not an identity. pub hostname: String, /// Announced IP plugin capabilities. Opaque to the core. diff --git a/crates/tsunagi/src/storage/mod.rs b/crates/tsunagi/src/storage/mod.rs index 1d0af3b..806c90d 100644 --- a/crates/tsunagi/src/storage/mod.rs +++ b/crates/tsunagi/src/storage/mod.rs @@ -254,6 +254,18 @@ impl Storage { .await } + /// Persists whether this agent offers itself as an exit node in a network. + pub async fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> { + self.with_state(move |state| state.set_exit_node(network_id, enabled)) + .await + } + + /// Persists the member this device sends its internet traffic through. + pub async fn set_exit_via(&self, network_id: NetworkId, via: Option<[u8; 32]>) -> Result<()> { + self.with_state(move |state| state.set_exit_via(network_id, via.as_ref())) + .await + } + /// Updates the auto-start flag of a network. pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> { self.with_state(move |state| state.set_auto_start(network_id, auto_start)) diff --git a/crates/tsunagi/src/storage/state.rs b/crates/tsunagi/src/storage/state.rs index 94c4285..aadf24d 100644 --- a/crates/tsunagi/src/storage/state.rs +++ b/crates/tsunagi/src/storage/state.rs @@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret}; use crate::state::{RecordBody, SignedRecord}; /// Schema version written by this build. -pub const SCHEMA_VERSION: i64 = 5; +pub const SCHEMA_VERSION: i64 = 6; /// Key of the stored hostname setting. const SETTING_HOSTNAME: &str = "hostname"; @@ -42,6 +42,10 @@ pub struct StoredNetwork { pub auto_start: bool, /// Local broadcast participation, enabled unless explicitly disabled. pub broadcast: bool, + /// This agent offers itself as an exit node here. Off unless turned on. + pub exit_node: bool, + /// The member this device sends all its internet traffic through, if any. + pub exit_via: Option<[u8; 32]>, } /// The mandatory state store. @@ -206,6 +210,17 @@ impl StateStore { ) .map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?; } + if found < 6 { + self.conn + .execute_batch( + "BEGIN; + ALTER TABLE networks ADD COLUMN exit_node INTEGER NOT NULL DEFAULT 0 CHECK (exit_node IN (0,1)); + ALTER TABLE networks ADD COLUMN exit_via BLOB; + PRAGMA user_version = 6; + COMMIT;", + ) + .map_err(|err| self.corrupt(format!("cannot migrate schema to 6: {err}")))?; + } Ok(()) } @@ -451,11 +466,56 @@ impl StateStore { Ok(()) } + /// Saves whether this agent offers itself as an exit node in a network. + pub fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> { + let changed = self + .conn + .execute( + "UPDATE networks SET exit_node = ?2 WHERE network_id = ?1", + params![network_id.as_bytes().as_slice(), enabled as i64], + ) + .map_err(|err| Error::Storage(format!("cannot update exit node offer: {err}")))?; + if changed == 0 { + return Err(Error::NetworkUnknown(network_id)); + } + Ok(()) + } + + /// Saves which member this device sends its internet traffic through. + /// + /// One device has one default route, so choosing an exit node in one + /// network clears the choice in every other. + pub fn set_exit_via(&self, network_id: NetworkId, via: Option<&[u8; 32]>) -> Result<()> { + let tx = self + .conn + .unchecked_transaction() + .map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?; + let changed = tx + .execute( + "UPDATE networks SET exit_via = ?2 WHERE network_id = ?1", + params![network_id.as_bytes().as_slice(), via.map(|v| v.as_slice())], + ) + .map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?; + if changed == 0 { + return Err(Error::NetworkUnknown(network_id)); + } + if via.is_some() { + tx.execute( + "UPDATE networks SET exit_via = NULL WHERE network_id != ?1", + params![network_id.as_bytes().as_slice()], + ) + .map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?; + } + tx.commit() + .map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?; + Ok(()) + } + /// Lists every configured network. pub fn list_networks(&self) -> Result> { let mut stmt = self .conn - .prepare("SELECT network_id, name, secret, auto_start, broadcast FROM networks ORDER BY name") + .prepare("SELECT network_id, name, secret, auto_start, broadcast, exit_node, exit_via FROM networks ORDER BY name") .map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?; let rows = stmt .query_map([], |row| { @@ -463,13 +523,21 @@ impl StateStore { let name: String = row.get(1)?; let secret: Vec = row.get(2)?; let auto_start: i64 = row.get(3)?; - Ok((id, name, secret, auto_start != 0, row.get::<_, bool>(4)?)) + Ok(( + id, + name, + secret, + auto_start != 0, + row.get::<_, bool>(4)?, + row.get::<_, bool>(5)?, + row.get::<_, Option>>(6)?, + )) }) .map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?; let mut out = Vec::new(); for row in rows { - let (id, name, secret, auto_start, broadcast) = + let (id, name, secret, auto_start, broadcast, exit_node, exit_via) = row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?; let id: [u8; 32] = id .as_slice() @@ -481,6 +549,8 @@ impl StateStore { secret: NetworkSecret::from_bytes(secret)?, auto_start, broadcast, + exit_node, + exit_via: exit_via.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()), }); } Ok(out) @@ -774,6 +844,37 @@ mod broadcast_storage_tests { use super::*; use crate::identity::NetworkKeys; + #[test] + fn exit_node_choices_are_off_by_default_and_one_choice_clears_the_others() { + let dir = tempfile::tempdir().unwrap(); + let store = StateStore::open(dir.path().join("state.sqlite")).unwrap(); + let secret = NetworkSecret::from_bytes([5; 32]).unwrap(); + let make = |name: &str| { + let name = NetworkName::new(name).unwrap(); + let id = NetworkKeys::derive(&name, &secret).network_id(); + store.upsert_network(id, &name, &secret, true).unwrap(); + id + }; + let (a, b) = (make("exit-a"), make("exit-b")); + let listed = || store.list_networks().unwrap(); + assert!( + listed() + .iter() + .all(|n| !n.exit_node && n.exit_via.is_none()) + ); + + store.set_exit_node(a, true).unwrap(); + store.set_exit_via(a, Some(&[7; 32])).unwrap(); + store.set_exit_via(b, Some(&[8; 32])).unwrap(); + let by_id = |id: NetworkId| listed().into_iter().find(|n| n.network_id == id).unwrap(); + assert!(by_id(a).exit_node, "offering is per network and kept"); + assert_eq!(by_id(a).exit_via, None, "one default route: a was cleared"); + assert_eq!(by_id(b).exit_via, Some([8; 32])); + + store.set_exit_via(b, None).unwrap(); + assert_eq!(by_id(b).exit_via, None); + } + #[test] fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() { let dir = tempfile::tempdir().unwrap(); @@ -813,7 +914,12 @@ mod broadcast_storage_tests { store.set_hostname("old-host").unwrap(); store .conn - .execute_batch("ALTER TABLE networks DROP COLUMN broadcast; PRAGMA user_version=3;") + .execute_batch( + "ALTER TABLE networks DROP COLUMN broadcast; \ + ALTER TABLE networks DROP COLUMN exit_node; \ + ALTER TABLE networks DROP COLUMN exit_via; \ + PRAGMA user_version=3;", + ) .unwrap(); drop(store); let store = StateStore::open(&path).unwrap(); diff --git a/docs/exit-node.md b/docs/exit-node.md new file mode 100644 index 0000000..d195b62 --- /dev/null +++ b/docs/exit-node.md @@ -0,0 +1,75 @@ +# Exit nodes + +A member can offer itself as an **exit node** in a network. Other members can +then send *all* their internet traffic through it, the way a commercial VPN +or Tailscale's exit nodes work. Linux only for now. + +## Using it + +The offering side, per network, off by default: + +```sh +tsunagi network exit-node on # or off +``` + +Rules are installed even when the kernel does not forward yet, and `status` +says so. Turning forwarding on is left to you, on purpose: + +```sh +sudo sysctl -w net.ipv4.ip_forward=1 +``` + +The using side: + +```sh +tsunagi exit-node # who offers one +tsunagi exit-node # send everything through it +tsunagi exit-node off +``` + +The tray does the same: a checkbox on the network tile to offer, and an icon on +each device in the devices window that offers one, with a confirmation. + +One device has one default route, so it uses **one** exit node at a time; +choosing another replaces the first. Offering is per network, so a device can +be an exit node in one network and not in another. + +## How it works + +- An offering agent says so in its control-plane `Announce` (`exit_node`), and + only once its rules are in place. Members see which peers offer one. +- **Server:** `iptables` masquerades the overlay range of that network out of + any other interface and allows forwarding for it. Rules carry the tag + `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`. +- 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. + +## What it does not do + +- **The agent must run as a system service** (its own user). The uid exemption + would otherwise cover everything you run. A per-user agent refuses to use an + exit node and says why. +- **If the exit node disappears the rules stay** and traffic is dropped until + it returns or you run `tsunagi exit-node off`. This is deliberate: falling + back to the direct path would leak traffic nobody agreed to send in the + clear. The agent logs it once, `status` and the tray say so loudly. +- IPv6 and DNS are untouched. IPv6 traffic still leaves the ordinary way. +- Kernel forwarding is only read, never changed. + +## Trust + +Anyone who knows the network's secret can pick your exit node if you offer +one, and you carry what they send: it leaves from your address and is yours to +answer for. The exit node sees every destination and, for plain traffic, its +contents. Choosing an exit node means trusting its owner completely. Offer one +only in networks whose members you trust, and only from a machine you are +willing to have that traffic come from. diff --git a/docs/protocol.md b/docs/protocol.md index 3f09f68..8c728d6 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -9,7 +9,7 @@ Two versions exist and are independent: - **Identity scheme**, `tsunagi-network-id-v1`. Frozen. Changing it creates a different network space for the same name and secret. -- **Control protocol**, ALPN `tsunagi/ctrl/3`, `PROTOCOL_VERSION = 3`. +- **Control protocol**, ALPN `tsunagi/ctrl/4`, `PROTOCOL_VERSION = 4`. Upgrading the crate or bumping the control protocol must never change an existing `NetworkId`. diff --git a/docs/routing.md b/docs/routing.md index 678aeb9..6963f35 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -86,5 +86,5 @@ counts sends without storing frames. Results are CPU forwarding cost, **not** end-to-end network latency or VPN throughput; encryption, fragmentation, sockets, congestion and scheduling contribute separately. -Wire compatibility: control ALPN `tsunagi/ctrl/3`, data ALPN `tsunagi/data/4`. +Wire compatibility: control ALPN `tsunagi/ctrl/4`, data ALPN `tsunagi/data/4`. Upgrade every participant together; saved identities and network state persist. diff --git a/docs/threat-model.md b/docs/threat-model.md index cea0e61..f30c32f 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -69,6 +69,10 @@ Read this before relying on anything here. The protocol is in punch, the data connection goes through a relay, which then sees the volume and timing of tunnelled traffic — though not its contents, which WireGuard encrypted, nor the iroh layer's contents. +- **Exit nodes.** A member that offers one carries other members' internet + traffic and sees where it goes; a member that uses one trusts it completely. + Anyone holding the network secret can use an offered exit node. See + [exit-node.md](exit-node.md). - **Overlay address squatting.** A member can mint many WireGuard keys and therefore occupy many overlay addresses. It cannot pick which ones, but it can consume them and appear as many participants.