Exit nodes: block IPv6 while one is in use; exit icon on the network tile
IPv6 has no way through an exit node, so the same policy rules get an unreachable default for IPv6 instead of leaking it. The tray shows a green icon in the tile corner while an exit node is in use, with a one-line confirmation to stop it; confirmations are shortened. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
b2431d7eb1
commit
bb38562b0a
5 files changed
+228
-67
No files matched your search
@@ -3385,7 +3385,8 @@ fn exit_rows(network: &tsunagi::ipc::NetworkReport) -> Vec<report::Row> {
|
||||
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",
|
||||
|
||||
@@ -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<ExitConfirm>,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -702,14 +702,37 @@ fn exit_default_route(index: Option<u32>) -> 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::<std::net::Ipv6Addr>::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));
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user