diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 43e82b2..6a397a2 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -3385,7 +3385,8 @@ fn exit_rows(network: &tsunagi::ipc::NetworkReport) -> Vec { Health::Good, "using exit", format!("{name} · all internet traffic leaves through it"), - ), + ) + .with_note("IPv6 is blocked meanwhile, so it cannot leave the ordinary way"), Some(rules) => Row::new( Health::Degraded, "using exit", diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index 1178d86..ff73dff 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -48,6 +48,21 @@ pub(crate) struct ExitConfirm { name: String, /// Stop using it, rather than start. stop: bool, + /// Asked in the main window, not in the devices window of the network. + main: bool, +} + +impl ExitConfirm { + /// A question about stopping the exit node a network's tile shows. + pub(crate) fn stop_from_tile(network: &NetworkReport, peer_id: &str) -> Self { + Self { + network_id: network.network_id.clone(), + peer_id: peer_id.to_string(), + name: exit::name_of(network, peer_id), + stop: true, + main: true, + } + } } impl Device<'_> { @@ -126,18 +141,24 @@ pub(crate) fn show( device_table(ui, unit, network, &devices, traffic, confirm); }); }); - confirm_exit(ctx, agent, confirm, &network.network_id); + confirm_exit(ctx, agent, confirm, Some(&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( +/// +/// `network_id` is the devices window the question is drawn in, or `None` for +/// the main window; a question appears in the window it was asked in only. +pub(crate) fn confirm_exit( ctx: &egui::Context, agent: &AgentClient, confirm: &mut Option, - network_id: &str, + network_id: Option<&str>, ) { - let Some(pending) = confirm.as_ref().filter(|c| c.network_id == network_id) else { + let Some(pending) = confirm.as_ref().filter(|c| match network_id { + Some(id) => !c.main && c.network_id == id, + None => c.main, + }) else { return; }; egui::Area::new(egui::Id::new("exit confirm backdrop")) @@ -151,48 +172,31 @@ fn confirm_exit( }); 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" + egui::Window::new("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); + ui.label(if pending.stop { + format!("Stop using {}?", pending.name) } else { - "Use as exit node" - }; - if ui.button(label).clicked() { - accepted = true; - } - if ui.button("Cancel").clicked() { - cancelled = true; - } + format!("Use {} as exit node?", pending.name) + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui + .button(if pending.stop { "Stop" } else { "Use" }) + .clicked() + { + accepted = true; + } + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); }); - }); if accepted { agent.send(Command::SetExitNode { network_id: pending.network_id.clone(), @@ -431,6 +435,7 @@ fn exit_cell( peer_id: device.id.to_string(), name: device.name(), stop: device.in_use, + main: false, }); } } diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index 17de09c..88340c2 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -128,6 +128,8 @@ pub(crate) fn draw( }); } }); + + devices::confirm_exit(ctx, agent, &mut state.exit_confirm, None); } /// The "agent cannot be reached" state. @@ -281,21 +283,32 @@ fn draw_network( } }); 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| { + // In the corner while this device sends its traffic through + // somebody: green while that works, red while it does not. + if let Some(via) = &network.exit.via { + let color = if network.exit.via_online { + exit::GOOD + } else { + exit::LOUD + }; + let response = ui + .add( + egui::Label::new( + egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color), + ) + .sense(egui::Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text(format!( + "exit node: {} — click to stop", + exit::name_of(network, via) + )); + if response.clicked() { + state.exit_confirm = + Some(devices::ExitConfirm::stop_from_tile(network, via)); + } + } if ui.button("Show devices").clicked() { state.open_devices.insert(network.network_id.clone()); } diff --git a/crates/tsunagi/src/overlay/provision/linux.rs b/crates/tsunagi/src/overlay/provision/linux.rs index 421f71d..6de00b8 100644 --- a/crates/tsunagi/src/overlay/provision/linux.rs +++ b/crates/tsunagi/src/overlay/provision/linux.rs @@ -702,14 +702,37 @@ fn exit_default_route(index: Option) -> rtnetlink::packet_route::route::Rou builder.build() } -/// Removes the exit node's routing rules and its table. Nothing there is +/// The exit table's IPv6 default: nothing is reachable through it. +/// +/// There is no IPv6 through an exit node, and what the ordinary route would do +/// is send it out in the clear. This turns it into an immediate refusal, which +/// is what makes applications fall back to IPv4. +fn exit_blocking_route_v6() -> rtnetlink::packet_route::route::RouteMessage { + RouteMessageBuilder::::new() + .destination_prefix(std::net::Ipv6Addr::UNSPECIFIED, 0) + .table_id(EXIT_TABLE) + .kind(rtnetlink::packet_route::route::RouteType::Unreachable) + .build() +} + +/// A host with no IPv6 at all has nothing to block, and says so this way. +fn family_unsupported(err: &impl std::fmt::Display) -> bool { + let text = err.to_string(); + text.contains("not supported") || text.contains("Address family") +} + +/// Removes the exit node's routing rules and its tables. 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(); +/// Deletes this agent's exit rules of one address family. +async fn remove_exit_rules( + handle: &rtnetlink::Handle, + version: IpVersion, +) -> Result<(), OverlayError> { + let mut rules = handle.rule().get(version).execute(); let mut ours = Vec::new(); while let Some(rule) = rules .try_next() @@ -732,12 +755,32 @@ async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayErr } } } + Ok(()) +} + +async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayError> { + remove_exit_rules(handle, IpVersion::V4).await?; match handle.route().del(exit_default_route(None)).execute().await { - Ok(()) => Ok(()), + Ok(()) => {} // "No such process": no such route, which is the goal. - Err(err) if err.to_string().contains("No such") => Ok(()), + Err(err) if err.to_string().contains("No such") => {} + Err(err) => { + return Err(OverlayError::Unavailable(format!( + "cannot remove the exit node default route: {err}" + ))); + } + } + + match remove_exit_rules(handle, IpVersion::V6).await { + Ok(()) => {} + Err(OverlayError::Unavailable(reason)) if family_unsupported(&reason) => return Ok(()), + Err(err) => return Err(err), + } + match handle.route().del(exit_blocking_route_v6()).execute().await { + Ok(()) => Ok(()), + Err(err) if err.to_string().contains("No such") || family_unsupported(&err) => Ok(()), Err(err) => Err(OverlayError::Unavailable(format!( - "cannot remove the exit node default route: {err}" + "cannot remove the exit node IPv6 block: {err}" ))), } } @@ -751,6 +794,9 @@ async fn remove_exit_client(handle: &rtnetlink::Handle) -> Result<(), OverlayErr /// ip rule add priority 5300 lookup 0x7473 /// ``` /// +/// and the same for IPv6 with `ip -6 route replace unreachable default table +/// 0x7473`, so that IPv6 is refused instead of leaving the ordinary way. +/// /// 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. @@ -817,11 +863,81 @@ async fn set_exit_client(name: &str, uid: u32) -> Result<(), OverlayError> { .priority(EXIT_DEFAULT_PRIORITY) .execute() .await - .map_err(|err| fail("rule for the default route", err)) + .map_err(|err| fail("rule for the default route", err))?; + + set_exit_block_v6(&handle, uid).await }) .await } +/// The IPv6 half: an unreachable default for everything but the agent's own +/// connections and the specific routes, which keep working. +async fn set_exit_block_v6(handle: &rtnetlink::Handle, uid: u32) -> Result<(), OverlayError> { + let fail = |what: &str, err: rtnetlink::Error| { + OverlayError::Unavailable(format!("cannot add the exit node IPv6 {what}: {err}")) + }; + if let Err(err) = handle + .route() + .add(exit_blocking_route_v6()) + .replace() + .execute() + .await + { + // Without IPv6 there is nothing to leak. + return if family_unsupported(&err) { + Ok(()) + } else { + Err(fail("block", err)) + }; + } + + let mut bypass = handle + .rule() + .add() + .v6() + .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() + .v6() + .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() + .v6() + .table_id(EXIT_TABLE) + .action(RuleAction::ToTable) + .priority(EXIT_DEFAULT_PRIORITY) + .execute() + .await + .map_err(|err| fail("rule for the default route", err)) +} + async fn configure_link(configure: &Configure) -> Result<(), OverlayError> { let name = configure.name.as_str(); with_netlink(|handle| async move { @@ -994,6 +1110,25 @@ mod tests { assert!(!is_exit_rule(&rule(5280, 100, vec![uid]))); } + #[test] + fn the_ipv6_default_of_the_exit_table_refuses_instead_of_routing() { + let route = exit_blocking_route_v6(); + assert_eq!(route.header.destination_prefix_length, 0); + let debug = format!("{route:?}"); + assert!(debug.contains("Unreachable"), "{debug}"); + assert!(debug.contains(&format!("Table({EXIT_TABLE})")), "{debug}"); + assert!(!debug.contains("Oif"), "{debug}"); + } + + #[test] + fn a_host_without_ipv6_is_not_a_failure() { + assert!(family_unsupported( + &"Address family not supported by protocol" + )); + assert!(family_unsupported(&"Operation not supported")); + assert!(!family_unsupported(&"Operation not permitted")); + } + #[test] fn the_default_route_of_the_exit_table_names_the_table_and_the_interface() { let route = exit_default_route(Some(7)); diff --git a/docs/exit-node.md b/docs/exit-node.md index d195b62..ff3f943 100644 --- a/docs/exit-node.md +++ b/docs/exit-node.md @@ -52,6 +52,12 @@ be an exit node in one network and not in another. - Packets for addresses outside every overlay range are sent to the exit node by the router; the exit node's replies are accepted from any source. A member that is not your exit node cannot do that. +- **IPv6 is blocked while you use one.** There is no IPv6 through an exit node, + and letting it go the ordinary way would leak it. The same three rules exist + for IPv6 with an `unreachable default` in the table, so applications get an + immediate "network unreachable" and fall back to IPv4. LAN, ULA and other + specific IPv6 routes keep working, and so does the agent's own IPv6. A host + with no IPv6 has nothing to block and is left alone. ## What it does not do @@ -62,7 +68,8 @@ be an exit node in one network and not in another. it returns or you run `tsunagi exit-node off`. This is deliberate: falling back to the direct path would leak traffic nobody agreed to send in the clear. The agent logs it once, `status` and the tray say so loudly. -- IPv6 and DNS are untouched. IPv6 traffic still leaves the ordinary way. +- **IPv6-only destinations are unreachable** meanwhile; carrying IPv6 through + the exit node is not implemented. DNS is untouched. - Kernel forwarding is only read, never changed. ## Trust