Compare commits

..
Author SHA1 Message Date
Aleksandr BogomiakovandClaude b2431d7eb1 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 <noreply@anthropic.com>
2026-10-03 01:14:51 +01:00
34 changed files with 4148 additions and 61 deletions

No files matched your search

+1
View File
@@ -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.
+606
View File
@@ -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<PathBuf>,
/// Only look in this network: exact name, full id or unique id prefix.
#[arg(long, short = 'n')]
network: Option<String>,
/// 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<String>,
}
#[derive(Debug, Args)]
struct NetworkArgs {
#[command(flatten)]
@@ -162,6 +187,20 @@ enum NetworkAction {
#[arg(value_enum)]
choice: Option<BroadcastChoice>,
},
/// 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<dyn std::error::Error>> {
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<bool, String>> {
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<String>,
) -> tsunagi::BoxFuture<'_, Result<Option<String>, 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::<EndpointId>()
.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<dyn std::error::Er
Some(NetworkAction::Broadcast { network, choice }) => {
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<dyn std::error::Error>> {
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<Vec<&'a tsunagi::ipc::NetworkReport>, 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<dyn std::error::Error>> {
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 <network> on"),
);
}
let mut out = Report::new();
out.push(section);
print_report("tsunagi exit-node", &out)?;
if any {
println!(
"\n`tsunagi exit-node <name|id>` 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<MemberRow<'a>> = 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<report::Row> {
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<tsunagi::overlay::RuleSetReport>| {
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,
+43
View File
@@ -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)
);
}
+24
View File
@@ -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<String>,
},
/// 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))
+11 -2
View File
@@ -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 {
+182 -25
View File
@@ -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<String>,
handshake_secs: Option<u64>,
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<ExitConfirm>,
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<ExitConfirm>,
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<Device<'a>> {
.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<Device<'a>> {
/// The widest value each column is expected to hold, so the table starts out
/// wide enough and does not reflow as numbers grow.
const COLUMN_SAMPLES: [&str; 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<ExitConfirm>,
) {
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<ExitConfirm>,
) {
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 {
+164
View File
@@ -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<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));
}
}
+1
View File
@@ -13,6 +13,7 @@ mod about;
mod agent;
mod app;
mod devices;
mod exit;
mod format;
mod stats;
mod tray;
+60
View File
@@ -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<String>,
/// 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<devices::ExitConfirm>,
}
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;
+313
View File
@@ -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<Arc<dyn BroadcastHostRules>> {
Some(Arc::new(self.rules.clone()))
}
fn create<'a>(
&'a self,
request: TunRequest,
) -> tsunagi::BoxFuture<'a, Result<Arc<dyn TunDevice>, 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<WireguardPlugin>,
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<dyn IpPlugin>),
)
.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<MemoryTun> {
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;
}
@@ -41,6 +41,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::R
hostname: peer.hostname.clone(),
transport: format!("{:?}", peer.transport),
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
exit_node: peer.exit_node,
})
.collect(),
overlay: plugin.overview(net.network_id).map(|view| {
+84 -3
View File
@@ -388,6 +388,7 @@ impl Agent {
async fn reserve_range(&self, network: NetworkId) -> 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<EndpointId>) -> 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<NetworkId> = 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<dyn crate::discovery::NetworkDiscovery>,
)
};
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,
+134
View File
@@ -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<EndpointId>,
reply: oneshot::Sender<std::result::Result<(), String>>,
},
/// 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<EndpointId>,
pub(crate) keys: NetworkKeys,
pub(crate) adapter: EndpointAdapter,
pub(crate) storage: Storage,
@@ -331,6 +347,11 @@ struct Runtime {
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
/// 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<EndpointId, String>,
@@ -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,
+3
View File
@@ -74,6 +74,8 @@ pub(crate) struct Session {
pub(crate) hostname: Option<String>,
pub(crate) capabilities: Vec<PluginCapability>,
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,
+10
View File
@@ -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<EndpointId>,
/// 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.
+7
View File
@@ -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),
+131
View File
@@ -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<String>,
},
}
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: <redacted> }}"),
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<DnsReport>),
/// 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<String>),
/// The request could not be served.
Error(String),
}
@@ -457,6 +484,9 @@ pub struct NetworkReport {
pub range_conflict: Option<String>,
/// The overlay, when a protocol is running one.
pub overlay: Option<OverlayReport>,
/// 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<u64>,
/// 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<HostRulesReport>,
}
/// 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<RuleSetReport>,
/// 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<bool>,
/// The member this device sends its internet traffic through, as an
/// endpoint id.
pub via: Option<String>,
/// 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<RuleSetReport>,
}
/// 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<String, String>> {
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<bool, String>> {
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<String>,
) -> BoxFuture<'_, std::result::Result<Option<String>, String>> {
Box::pin(async move { Err("this agent cannot use an exit node".to_string()) })
}
}
impl<F> 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<Path>,
network_id: &str,
enabled: bool,
) -> Result<bool> {
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<Path>,
network_id: &str,
peer: Option<&str>,
) -> Result<Option<String>> {
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.
+3
View File
@@ -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()
+32
View File
@@ -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<RuleSetReport>,
/// 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<bool>,
/// The routes that send this device's traffic through its exit node.
/// `None` means it uses none through this network.
pub client: Option<RuleSetReport>,
}
@@ -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<Ipv4Range>,
/// 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<bool>,
/// The routes and rules that send this device's traffic through the
/// interface, when the plan asked for them.
pub client: Option<RuleOutcome>,
}
/// 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<Mutex<Option<ExitHostPlan>>>,
calls: Arc<Mutex<Vec<String>>>,
forwarding: Arc<Mutex<Option<bool>>>,
failure: Arc<Mutex<Option<String>>>,
}
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<bool>) {
*lock(&self.forwarding) = forwarding;
}
/// Makes every step fail with this reason from now on.
pub fn fail_with(&self, reason: Option<String>) {
*lock(&self.failure) = reason;
}
/// What is installed now.
pub fn installed(&self) -> Option<ExitHostPlan> {
lock(&self.state).clone()
}
/// Every `apply:<interface>` / `clear:<interface>` call, in order.
pub fn calls(&self) -> Vec<String> {
lock(&self.calls).clone()
}
}
fn lock<T>(mutex: &Mutex<T>) -> 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"]);
}
}
+11 -7
View File
@@ -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<String> {
@@ -88,7 +88,7 @@ fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> {
/// Waits briefly for the xtables lock instead of failing when another tool
/// holds it.
fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
pub(super) fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
["-w", "5"]
.into_iter()
.map(str::to_string)
@@ -118,7 +118,7 @@ fn list_args() -> Vec<String> {
/// 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<String> {
pub(super) fn split_rule_line(line: &str) -> Vec<String> {
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<Vec<String>> {
}
/// 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;
@@ -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:<if>:<range>`:
//!
//! ```text
//! -t nat POSTROUTING -s <range> ! -o <if> -j MASQUERADE
//! -t filter FORWARD -i <if> -s <range> ! -o <if> -j ACCEPT
//! -t filter FORWARD -o <if> -d <range> -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
//! ```
//!
//! `! -o <if>` 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 <if>
//! 5280: from all uidrange <euid>-<euid> lookup main
//! 5290: from all lookup main suppress_prefixlength 0
//! 5300: from all lookup 0x7473
//! ```
//!
//! The first keeps the agent's *own* sockets off the tunnel: its QUIC, relay,
//! 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<String>,
}
fn owned(words: &[&str]) -> Vec<String> {
words.iter().map(|word| word.to_string()).collect()
}
/// The three rules of one offered range.
fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec<Rule> {
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<String> {
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<String> {
locked(
owned(&["-t", rule.table, "-C", rule.chain])
.into_iter()
.chain(rule.spec.iter().cloned()),
)
}
fn insert_args(rule: &Rule) -> Vec<String> {
locked(
owned(&["-t", rule.table, "-I", rule.chain, "1"])
.into_iter()
.chain(rule.spec.iter().cloned()),
)
}
fn list_args(table: &str, chain: &str) -> Vec<String> {
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 <chain>`, which is what `-D` needs.
rest: Vec<String>,
}
/// Every rule in an `iptables -S <chain>` 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<Found> {
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<String> {
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<String, Failure> {
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<String> = ranges.iter().map(|r| exit_tag(interface, r)).collect();
// What is there now, per chain.
let mut present: Vec<(&str, &str, Vec<Found>)> = 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<String>, interface: &str) -> Option<bool> {
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<String> {
std::fs::read_to_string(path).ok()
}
/// The effective user id from the text of `/proc/self/status`.
fn parse_euid(status: &str) -> Option<u32> {
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<String> {
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<T: Send + 'static>(
work: impl FnOnce() -> T + Send + 'static,
) -> Result<T, String> {
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<Vec<String>>,
/// Rules present, as `-S` lines per `(table, chain)`.
listing: RefCell<Vec<(String, String)>>,
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<String> = 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<String> {
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());
}
}
+31 -1
View File
@@ -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<String, BroadcastRulesPlan>
/// An in-memory host, for tests: nothing is touched.
#[derive(Debug, Clone, Default)]
pub struct MockHostRules {
exit: MockExitRules,
state: Arc<Mutex<MockRulesState>>,
calls: Arc<Mutex<Vec<String>>>,
firewall_failure: Arc<Mutex<Option<String>>>,
@@ -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));
+495 -2
View File
@@ -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<Arc<dyn BroadcastHostRules>>,
/// What those rules last reported, `None` while none are installed.
rules_report: std::sync::Mutex<Option<BroadcastRulesReport>>,
/// What the exit-node rules were last asked for, and what came of it.
exit_state: std::sync::Mutex<ExitState>,
}
/// 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<ExitHostPlan>,
/// What came of it.
report: Option<ExitHostReport>,
}
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<BroadcastRulesReport> {
@@ -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;
}
}
+5 -3
View File
@@ -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"))]
+270 -2
View File
@@ -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>) {
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<Command>) {
});
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<u32> {
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<u32>) -> rtnetlink::packet_route::route::RouteMessage {
let mut builder = RouteMessageBuilder::<Ipv4Addr>::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 <if> table 0x7473
/// ip rule add priority 5280 uidrange <uid>-<uid> 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<RuleAttribute>) -> 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();
+356 -7
View File
@@ -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<EndpointId>,
}
/// 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<Route> {
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<Ipv4Range>)> {
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<NetworkId, NetworkRoutes>, 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<NetworkId, NetworkRoutes>) -> 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<Ipv4Addr>| 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<EndpointId>) -> 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());
}
}
+7 -2
View File
@@ -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.
+12
View File
@@ -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))
+111 -5
View File
@@ -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<Vec<StoredNetwork>> {
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<u8> = 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<Vec<u8>>>(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();
+75
View File
@@ -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 <network> 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 <name|id> # 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:<interface>:<range>` and are removed when the offer ends, the
network stops, and when the agent exits. Only networks that offer have
rules, so the others' traffic is not forwarded.
- **Client:** policy routing, with no change to the main table. Table `28787`
holds `default dev <interface>`, and three rules sit after Tailscale's:
`5280 uidrange <agent uid> lookup main` (the agent's own connections never
go through the tunnel, which would be a loop), `5290 lookup main
suppress_prefixlength 0` (LAN and other specific routes keep working) and
`5300 lookup 28787`.
- 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.
+1 -1
View File
@@ -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`.
+1 -1
View File
@@ -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.
+4
View File
@@ -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.