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>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-03 01:14:51 +01:00
1 parent c62d29fa14
commit b2431d7eb1
34 files changed
+4148 -61

No files matched your search

+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();