Files
tsunagi/crates/tsunagi-tray/src/exit.rs
T
UltradesuandClaude Sonnet 5.5 637915600e 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:<if>:<range>),
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 <noreply@anthropic.com>
2026-10-03 12:33:22 +01:00

171 lines
5.4 KiB
Rust

//! 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.
#[cfg(target_os = "macos")]
pub(crate) const FORWARDING_COMMAND: &str = "sudo sysctl -w net.inet.ip.forwarding=1";
#[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.
#[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<Warning> {
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<RuleSetReport> {
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));
}
}