From 637915600e4f22019a3410971f7889ce67681b44 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sat, 3 Oct 2026 12:33:22 +0100 Subject: [PATCH] Exit nodes on Windows: offer through WinNAT; using is refused Windows reported exit nodes as unsupported. The offering side now creates one WinNAT object per offered range (New-NetNat, named tsunagi-exit::), prunes this interface's stale ones, reads (never writes) IPv4 forwarding of the overlay interface, and removes everything on clear. All values are single-quoted PowerShell literals. The using side is deliberately not implemented: Windows has no per-user or per-process routing, so the agent's own QUIC/relay traffic cannot be kept out of a default route through the overlay and would loop. It reports that reason and installs no route. The CLI/tray forwarding hint and the kernel-forwarding warning name the Windows command; docs/exit-node.md has a Windows section. Checks: cargo fmt --check, cargo clippy --locked --workspace --all-targets -D warnings and cargo test --locked --workspace --all-targets passed on macOS. The script builders and plan logic are unit-tested with an injected runner on every platform; nothing was run against a real Windows host or cross-compiled (no Windows target installed). Co-Authored-By: Claude Sonnet 5.5 --- .../tsunagi-core/src/overlay/hostrules/mod.rs | 2 + .../src/overlay/hostrules/windows.rs | 47 ++- .../src/overlay/hostrules/windows_exit.rs | 267 ++++++++++++++++++ crates/tsunagi-core/src/overlay/interface.rs | 4 +- crates/tsunagi-tray/src/exit.rs | 5 +- crates/tsunagi/src/main.rs | 8 +- docs/exit-node.md | 15 + 7 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 crates/tsunagi-core/src/overlay/hostrules/windows_exit.rs diff --git a/crates/tsunagi-core/src/overlay/hostrules/mod.rs b/crates/tsunagi-core/src/overlay/hostrules/mod.rs index 1bb5c6b..4a53ffe 100644 --- a/crates/tsunagi-core/src/overlay/hostrules/mod.rs +++ b/crates/tsunagi-core/src/overlay/hostrules/mod.rs @@ -61,6 +61,8 @@ pub use linux::LinuxHostRules; // Compiled everywhere so the scripts it builds are tested everywhere; only // the type that runs them is exported on Windows. mod windows; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +mod windows_exit; #[cfg(all(feature = "tun-device", target_os = "windows"))] pub use windows::WindowsHostRules; diff --git a/crates/tsunagi-core/src/overlay/hostrules/windows.rs b/crates/tsunagi-core/src/overlay/hostrules/windows.rs index e9ce2c0..57689d5 100644 --- a/crates/tsunagi-core/src/overlay/hostrules/windows.rs +++ b/crates/tsunagi-core/src/overlay/hostrules/windows.rs @@ -29,6 +29,8 @@ use crate::BoxFuture; +use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules}; +use super::windows_exit; use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for}; /// A PowerShell single-quoted literal, with any embedded quote doubled. @@ -113,6 +115,11 @@ fn powershell_path() -> std::path::PathBuf { /// Runs a script and returns what it said on failure. fn run_powershell(script: &str) -> Result<(), String> { + run_powershell_output(script).map(|_| ()) +} + +/// Runs a script and returns what it printed, or what it said on failure. +fn run_powershell_output(script: &str) -> Result { let output = std::process::Command::new(powershell_path()) .args([ "-NoProfile", @@ -125,7 +132,7 @@ fn run_powershell(script: &str) -> Result<(), String> { .output() .map_err(|err| format!("could not run PowerShell: {err}"))?; if output.status.success() { - return Ok(()); + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); } let stderr = String::from_utf8_lossy(&output.stderr); let text = if stderr.trim().is_empty() { @@ -155,11 +162,49 @@ impl WindowsHostRules { } } +impl ExitHostRules for WindowsHostRules { + fn apply<'a>(&'a self, plan: &'a ExitHostPlan) -> BoxFuture<'a, ExitHostReport> { + Box::pin(async move { + let job = plan.clone(); + tokio::task::spawn_blocking(move || { + windows_exit::apply_plan(&|script| run_powershell_output(script), &job) + }) + .await + .unwrap_or_else(|err| { + let failed = RuleOutcome::Failed(format!("the task failed: {err}")); + ExitHostReport { + offer: plan + .offer + .iter() + .map(|range| (*range, failed.clone())) + .collect(), + forwarding: None, + client: plan.client.then_some(failed), + } + }) + }) + } + + fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> { + Box::pin(async move { + let name = interface.to_string(); + let _ = tokio::task::spawn_blocking(move || { + windows_exit::clear_all(&|script| run_powershell_output(script), &name) + }) + .await; + }) + } +} + impl BroadcastHostRules for WindowsHostRules { fn name(&self) -> &str { "windows-netsecurity" } + fn exit_rules(&self) -> Option<&dyn ExitHostRules> { + Some(self) + } + fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> { Box::pin(async move { let route = step("route", route_script(plan)).await; diff --git a/crates/tsunagi-core/src/overlay/hostrules/windows_exit.rs b/crates/tsunagi-core/src/overlay/hostrules/windows_exit.rs new file mode 100644 index 0000000..e62068b --- /dev/null +++ b/crates/tsunagi-core/src/overlay/hostrules/windows_exit.rs @@ -0,0 +1,267 @@ +//! Exit-node host rules on Windows: WinNAT through PowerShell. +//! +//! Compiled on every platform so the scripts and the plan logic are tested +//! everywhere; only `WindowsHostRules` runs them, and only on Windows. It +//! could not be run against a real Windows host while it was written. +//! +//! # Offering +//! +//! One NAT object per offered range, named with the usual tag +//! `tsunagi-exit::`: +//! +//! ```text +//! New-NetNat -Name -InternalIPInterfaceAddressPrefix +//! ``` +//! +//! WinNAT translates that range out of whichever interface the traffic leaves +//! by, which is the Windows counterpart of the Linux masquerade. Windows +//! forwards per interface, and the egress is not known here, so forwarding is +//! only *read* for the overlay interface; turning it on for the interfaces +//! involved is the owner's decision (`Set-NetIPInterface -Forwarding Enabled`). +//! Some Windows versions allow a single NAT per host: a second offered range +//! then reports its own failure. +//! +//! # Using +//! +//! Not available. Windows has no per-user or per-process routing, so the +//! agent's own QUIC, relay and DHT traffic cannot be kept out of a default +//! route through the overlay: it would loop into the tunnel it carries. Rather +//! than install something that stops the agent working, the using side +//! reports why it is not in place and changes nothing. + +use super::RuleOutcome; +use super::exit::{ExitHostPlan, ExitHostReport, exit_tag, exit_tag_prefix}; +use crate::state::Ipv4Range; + +/// Why the using side is not installed. +pub(super) const CLIENT_UNAVAILABLE: &str = "using an exit node is not available on Windows yet: \ + Windows cannot keep the agent's own traffic out of a default route through the overlay, \ + so it would loop"; + +/// Runs a PowerShell script and returns what it printed. +pub(super) type RunScript<'a> = &'a dyn Fn(&str) -> Result; + +/// A PowerShell single-quoted literal, with any embedded quote doubled. +fn ps_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +/// Creates the NAT of one range, replacing one this agent made before. +fn nat_script(interface: &str, range: &Ipv4Range) -> String { + let name = ps_literal(&exit_tag(interface, range)); + let prefix = ps_literal(&range.to_string()); + format!( + "$ErrorActionPreference = 'Stop'\n\ + $name = {name}\n\ + Get-NetNat -Name $name -ErrorAction SilentlyContinue | \ + Remove-NetNat -Confirm:$false -ErrorAction SilentlyContinue\n\ + New-NetNat -Name $name -InternalIPInterfaceAddressPrefix {prefix} | Out-Null\n" + ) +} + +/// Removes this interface's NAT objects except those named in `keep`. +fn prune_script(interface: &str, keep: &[Ipv4Range]) -> String { + let prefix = ps_literal(&exit_tag_prefix(interface)); + let keep: Vec = keep + .iter() + .map(|range| ps_literal(&exit_tag(interface, range))) + .collect(); + format!( + "$ErrorActionPreference = 'SilentlyContinue'\n\ + $prefix = {prefix}\n\ + $keep = @({})\n\ + Get-NetNat | Where-Object {{ $_.Name.StartsWith($prefix) -and \ + ($keep -notcontains $_.Name) }} | Remove-NetNat -Confirm:$false\n\ + exit 0\n", + keep.join(", ") + ) +} + +/// Prints `Enabled` or `Disabled` for the interface's IPv4 forwarding. +fn forwarding_script(interface: &str) -> String { + let alias = ps_literal(interface); + format!( + "$ErrorActionPreference = 'Stop'\n\ + (Get-NetIPInterface -InterfaceAlias {alias} -AddressFamily IPv4).Forwarding\n" + ) +} + +fn parse_forwarding(text: &str) -> Option { + match text.trim().to_ascii_lowercase().as_str() { + "enabled" | "1" => Some(true), + "disabled" | "0" => Some(false), + _ => None, + } +} + +/// Makes the host match the plan. +pub(super) fn apply_plan(run: RunScript<'_>, plan: &ExitHostPlan) -> ExitHostReport { + let interface = plan.interface.as_str(); + // What an earlier plan left and this one no longer wants. + let pruned = run(&prune_script(interface, &plan.offer)); + let offer = plan + .offer + .iter() + .map(|range| { + let outcome = match &pruned { + Err(why) => RuleOutcome::Failed(why.clone()), + Ok(_) => match run(&nat_script(interface, range)) { + Ok(_) => RuleOutcome::Applied, + Err(why) => RuleOutcome::Failed(why), + }, + }; + (*range, outcome) + }) + .collect(); + let forwarding = if plan.offer.is_empty() { + None + } else { + run(&forwarding_script(interface)) + .ok() + .and_then(|text| parse_forwarding(&text)) + }; + ExitHostReport { + offer, + forwarding, + client: plan + .client + .then(|| RuleOutcome::Failed(CLIENT_UNAVAILABLE.to_string())), + } +} + +/// Removes every NAT object of this agent's for the interface. +pub(super) fn clear_all(run: RunScript<'_>, interface: &str) { + if let Err(why) = run(&prune_script(interface, &[])) { + tracing::warn!(interface, "cannot remove the exit node NAT: {why}"); + } +} + +#[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 plan(client: bool) -> ExitHostPlan { + ExitHostPlan { + interface: "tsun0".into(), + offer: vec![range()], + client, + overlay: vec![range()], + } + } + + #[test] + fn the_nat_is_named_with_the_tag_and_replaces_its_own() { + let script = nat_script("tsun0", &range()); + assert!( + script.contains("$name = 'tsunagi-exit:tsun0:10.13.37.0/24'"), + "{script}" + ); + assert!(script.contains("-InternalIPInterfaceAddressPrefix '10.13.37.0/24'")); + assert!( + script.find("Remove-NetNat").unwrap() < script.find("New-NetNat").unwrap(), + "{script}" + ); + } + + #[test] + fn a_quote_in_a_value_cannot_escape_the_literal() { + let script = nat_script("x'; Remove-Item C:\\ #", &range()); + assert!(script.contains("'tsunagi-exit:x''; Remove-Item C:\\ #:10.13.37.0/24'")); + assert!(prune_script("x'y", &[]).contains("'tsunagi-exit:x''y:'")); + assert!(forwarding_script("x'y").contains("'x''y'")); + } + + #[test] + fn pruning_keeps_wanted_ranges_and_only_selects_this_interfaces_nats() { + let script = prune_script("tsun0", &[range()]); + assert!(script.contains("$prefix = 'tsunagi-exit:tsun0:'")); + assert!(script.contains("$keep = @('tsunagi-exit:tsun0:10.13.37.0/24')")); + assert!(script.contains("StartsWith($prefix)")); + assert!(script.contains("exit 0")); + assert!(prune_script("tsun0", &[]).contains("$keep = @()")); + } + + #[test] + fn forwarding_text_is_read_never_written() { + assert_eq!(parse_forwarding("Enabled\r\n"), Some(true)); + assert_eq!(parse_forwarding("Disabled"), Some(false)); + assert_eq!(parse_forwarding("what"), None); + let script = forwarding_script("tsun0"); + assert!(script.contains("Get-NetIPInterface")); + assert!(!script.contains("Set-NetIPInterface")); + } + + #[test] + fn offering_prunes_then_creates_and_reads_forwarding() { + let calls = RefCell::new(Vec::new()); + let run = |script: &str| -> Result { + calls.borrow_mut().push(script.to_string()); + Ok(if script.contains("Get-NetIPInterface") { + "Disabled\n".to_string() + } else { + String::new() + }) + }; + let report = apply_plan(&run, &plan(false)); + assert_eq!(report.offer, vec![(range(), RuleOutcome::Applied)]); + assert_eq!(report.forwarding, Some(false)); + assert_eq!(report.client, None); + let calls = calls.borrow(); + assert!(calls[0].contains("StartsWith"), "{calls:?}"); + assert!(calls[1].contains("New-NetNat"), "{calls:?}"); + assert!(calls[2].contains("Get-NetIPInterface"), "{calls:?}"); + } + + #[test] + fn a_failing_nat_is_that_ranges_failure() { + let run = |script: &str| -> Result { + if script.contains("New-NetNat") { + Err("A NAT with this prefix already exists".into()) + } else { + Ok(String::new()) + } + }; + let report = apply_plan(&run, &plan(false)); + let RuleOutcome::Failed(why) = &report.offer[0].1 else { + panic!("{report:?}"); + }; + assert!(why.contains("already exists"), "{why}"); + } + + #[test] + fn using_an_exit_node_is_refused_and_changes_nothing() { + let calls = RefCell::new(0usize); + let run = |script: &str| -> Result { + *calls.borrow_mut() += 1; + // No script may touch routes. + assert!(!script.contains("Route"), "{script}"); + Ok(String::new()) + }; + let report = apply_plan(&run, &plan(true)); + let Some(RuleOutcome::Failed(why)) = report.client else { + panic!("{report:?}"); + }; + assert!(why.contains("not available on Windows"), "{why}"); + assert!(why.contains("loop"), "{why}"); + } + + #[test] + fn clearing_prunes_everything_for_the_interface() { + let seen = RefCell::new(String::new()); + let run = |script: &str| -> Result { + *seen.borrow_mut() = script.to_string(); + Ok(String::new()) + }; + clear_all(&run, "tsun0"); + assert!(seen.borrow().contains("$keep = @()")); + assert!(seen.borrow().contains("'tsunagi-exit:tsun0:'")); + } +} diff --git a/crates/tsunagi-core/src/overlay/interface.rs b/crates/tsunagi-core/src/overlay/interface.rs index 610a653..1119724 100644 --- a/crates/tsunagi-core/src/overlay/interface.rs +++ b/crates/tsunagi-core/src/overlay/interface.rs @@ -400,7 +400,9 @@ impl Interface { interface = %self.name, "kernel forwarding is off for this interface, so nothing is forwarded for the \ exit node yet: {}", - if cfg!(target_os = "macos") { + if cfg!(target_os = "windows") { + "`Set-NetIPInterface -Forwarding Enabled` on the interfaces involved" + } else if cfg!(target_os = "macos") { "`sysctl -w net.inet.ip.forwarding=1`, set again after every reboot" } else { "`sysctl -w net.ipv4.ip_forward=1` (or `net.ipv4.conf..forwarding=1`), \ diff --git a/crates/tsunagi-tray/src/exit.rs b/crates/tsunagi-tray/src/exit.rs index bae2cf0..045c62c 100644 --- a/crates/tsunagi-tray/src/exit.rs +++ b/crates/tsunagi-tray/src/exit.rs @@ -15,7 +15,10 @@ 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. #[cfg(target_os = "macos")] pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.inet.ip.forwarding=1"; -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +pub(crate) const FORWARDING_COMMAND: &str = + "Get-NetIPInterface | Set-NetIPInterface -Forwarding Enabled"; +#[cfg(not(any(target_os = "macos", target_os = "windows")))] pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1"; /// One thing wrong, loud enough to read at a glance. diff --git a/crates/tsunagi/src/main.rs b/crates/tsunagi/src/main.rs index 186531b..1feeb4e 100644 --- a/crates/tsunagi/src/main.rs +++ b/crates/tsunagi/src/main.rs @@ -2087,14 +2087,18 @@ async fn broadcast_command( /// What to run to turn kernel forwarding on, which the agent never does. #[cfg(target_os = "macos")] const FORWARDING_COMMAND: &str = "sudo sysctl -w net.inet.ip.forwarding=1"; -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +const FORWARDING_COMMAND: &str = "Get-NetIPInterface | Set-NetIPInterface -Forwarding Enabled # in an administrator PowerShell"; +#[cfg(not(any(target_os = "macos", target_os = "windows")))] const FORWARDING_COMMAND: &str = "sudo sysctl -w net.ipv4.ip_forward=1"; /// How to keep forwarding on across reboots. #[cfg(target_os = "macos")] const FORWARDING_PERSIST: &str = "macOS forgets it at reboot; set it again from a boot script \ such as a LaunchDaemon"; -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +const FORWARDING_PERSIST: &str = "the setting is kept across reboots"; +#[cfg(not(any(target_os = "macos", target_os = "windows")))] const FORWARDING_PERSIST: &str = "keep it across reboots with `net.ipv4.ip_forward = 1` in /etc/sysctl.d/99-tsunagi.conf"; diff --git a/docs/exit-node.md b/docs/exit-node.md index 1aad713..1f977b2 100644 --- a/docs/exit-node.md +++ b/docs/exit-node.md @@ -80,6 +80,21 @@ is built from `pf` and `route`. The agent runs as root there. - Not verified on a live host when this was written: run `tsng exit-node` and check `sudo pfctl -a com.apple/tsunagi-exit-utunN -s rules -s nat`. +## Windows + +Offering only. Each offered range gets a WinNAT object +`tsunagi-exit::` (`New-NetNat`), removed with the offer, the +network and the agent. Forwarding is read for the overlay interface and never +changed; Windows forwards per interface, so enable it on the ones involved +(`Get-NetIPInterface | Set-NetIPInterface -Forwarding Enabled`, administrator +PowerShell). Some Windows versions allow one NAT per host; a second range then +reports its own failure. + +Using one is **not available**: Windows has no per-user routing, so the agent's +own traffic cannot be kept out of a default route through the overlay and would +loop. `tsng exit-node ` reports that and installs nothing. This was +written without a Windows host to run it on. + ## What it does not do - **The agent must run as a system service** (its own user). The uid exemption