From 781e359775ada535c261cf5eeb63dc22c35f9043 Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Thu, 8 Oct 2026 16:03:20 +0100 Subject: [PATCH] Make tunnel and transport layers explicit in status The tray called the end-to-end tunnel proto and the current route link, then put the tunnel handshake age beside the route. A valid wg tunnel over a tcp-tls transport consequently looked like contradictory state. The terminal status used similarly terse wording.\n\nThe tray now separates end-to-end from data path, renders direct paths as direct over TRANSPORT, keeps handshake or uptime beside the tunnel, and explains both layers in hover text. Terminal status uses the same end-to-end tunnel, data path, transport detail, and control path vocabulary. Routing, protocol selection, IPC, and wire behavior are unchanged.\n\nPresentation tests cover wg over tcp-tls, tcp-tls uptime, relayed and missing paths, a missing handshake, and offline devices. The WireGuard broadcast checksum test uses the current slice-array iterator spelling required by Clippy; its behavior is unchanged.\n\nChecks: cargo fmt --all -- --check; cargo clippy --locked --workspace --all-targets -- -D warnings; cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET removed from the test environment so integration agents use their temporary control sockets. All passed on Linux. --- crates/tsunagi-tray/src/devices.rs | 192 +++++++++++++++++----- crates/tsunagi-wg-quic/tests/wireguard.rs | 6 +- crates/tsunagi/src/main.rs | 74 +++++++-- 3 files changed, 213 insertions(+), 59 deletions(-) diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index 22e61d5..70f6034 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -42,7 +42,8 @@ struct Device<'a> { copy: Option, /// The full endpoint id, which clicking the short form copies. id: &'a str, - proto: &'a str, + /// End-to-end tunnel protocol, independent of the path carrying it. + tunnel_protocol: &'a str, address: Option, handshake_secs: Option, overlay: Option<&'a OverlayPeerReport>, @@ -360,7 +361,7 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec> { hostname, copy: hostname.map(|h| format!("{h}.{}", network.name)), id, - proto: overlay.map_or("—", |p| p.protocol.as_str()), + tunnel_protocol: overlay.map_or("—", |p| p.protocol.as_str()), address: overlay .and_then(|p| p.address.clone()) .or_else(|| member.and_then(|m| m.overlay_address_v4.clone())), @@ -389,9 +390,9 @@ fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec> { const COLUMN_SAMPLES: [&str; 9] = [ "", "exit", - "tcp-tls", + "wg-quic · waiting for handshake", "255.255.255.255", - "23h 59m", + "direct over tcp-tls", "99999/99999", "1023.9 MB / 1023.9 MB", "1023.9 kB/s", @@ -506,13 +507,36 @@ fn device_table( .spacing([10.0, 4.0]) .show(ui, |ui| { for (column, header) in [ - "device", "exit", "proto", "address", "link", "pkts", "bytes", "rate", "plot", + "device", + "exit", + "end-to-end", + "address", + "data path", + "pkts", + "bytes", + "rate", + "plot", ] .into_iter() .enumerate() { cell(ui, column, &mut widths, |ui| { - ui.weak(header); + let response = ui.weak(header); + match column { + 2 => { + response.on_hover_text( + "Protocol that encrypts traffic between this device and the \ + destination. It is independent of the route carrying it.", + ); + } + 4 => { + response.on_hover_text( + "Current route carrying the encrypted tunnel traffic. `direct \ + over tcp-tls` means one Tsunagi hop with no mesh relay.", + ); + } + _ => {} + } }); } ui.end_row(); @@ -523,7 +547,7 @@ fn device_table( exit_cell(ui, device, network_id, confirm); }); cell(ui, 2, &mut widths, |ui| { - ui.label(device.proto); + tunnel_cell(ui, device); }); cell(ui, 3, &mut widths, |ui| match &device.address { Some(address) => format::copy_field(ui, address, address), @@ -531,7 +555,7 @@ fn device_table( ui.weak("—"); } }); - cell(ui, 4, &mut widths, |ui| link_cell(ui, device)); + cell(ui, 4, &mut widths, |ui| data_path_cell(ui, device)); match device.overlay.filter(|_| device.online) { Some(peer) => { @@ -609,45 +633,95 @@ fn device_table( ui.memory_mut(|m| m.data.insert_temp(widths_id, widths)); } -/// How a device is reached, and how long its tunnel has been up. +/// A protocol name as it should appear in the table. +fn displayed_protocol(protocol: &str) -> &str { + if protocol.is_empty() { + "wg-quic" + } else { + protocol + } +} + +/// The end-to-end tunnel and its own health, without transport details. +fn tunnel_text(protocol: &str, handshake_secs: Option) -> String { + let protocol = displayed_protocol(protocol); + if protocol == "—" { + return protocol.to_string(); + } + match handshake_secs { + Some(secs) if protocol == "tcp-tls" => { + format!("{protocol} · up {}", format::age(secs)) + } + Some(secs) => format!("{protocol} · handshake {} ago", format::age(secs)), + None => format!("{protocol} · waiting for handshake"), + } +} + +fn tunnel_cell(ui: &mut egui::Ui, device: &Device<'_>) { + let protocol = displayed_protocol(device.tunnel_protocol); + let text = tunnel_text(protocol, device.handshake_secs); + if protocol == "—" { + ui.weak(text); + return; + } + let hint = match device.path { + Some(DataPathReport::Direct { transport }) => format!( + "The end-to-end {protocol} tunnel is currently carried over a direct {transport} \ + transport." + ), + Some(DataPathReport::Relayed { hops, .. }) => format!( + "The end-to-end {protocol} tunnel is currently carried through other devices over \ + {hops} transport hops." + ), + Some(DataPathReport::None) | None => { + format!("The end-to-end {protocol} tunnel does not have a data path carrying it yet.") + } + }; + ui.label(text).on_hover_text(hint); +} + +/// Current route text and whether that route is the preferred direct form. +fn data_path_text( + path: Option<&DataPathReport>, + via_name: Option<&str>, + online: bool, +) -> (String, bool) { + if !online { + return ("offline".into(), false); + } + match path { + Some(DataPathReport::Direct { transport }) => (format!("direct over {transport}"), true), + Some(DataPathReport::Relayed { hops, .. }) => ( + format!("via {} · {hops} hops", via_name.unwrap_or("another device")), + false, + ), + Some(DataPathReport::None) | None => ("no data path yet".into(), false), + } +} + +/// How a device is reached right now. /// /// A device reached through others is online, over a longer path, and says /// so: the colour is the only difference in how it is treated. -fn link_cell(ui: &mut egui::Ui, device: &Device<'_>) { - if !device.online { - ui.label("offline"); - return; - } - let (path, good) = match device.path { - Some(DataPathReport::Direct { transport }) => (format!("direct · {transport}"), true), - Some(DataPathReport::Relayed { hops, .. }) => ( - format!( - "via {} · {hops} hops", - device.via_name.as_deref().unwrap_or("another device") - ), - false, - ), - Some(DataPathReport::None) | None => ("no data path".to_string(), false), - }; - let age = device.handshake_secs.map(format::age); - let text = match age { - Some(age) => format!("{path} · {age}"), - None => path, - }; +fn data_path_cell(ui: &mut egui::Ui, device: &Device<'_>) { + let (text, good) = data_path_text(device.path, device.via_name.as_deref(), device.online); let response = if good { ui.label(text) } else { ui.label(egui::RichText::new(text).color(AMBER)) }; - if !good { - response.on_hover_text(match device.path { - Some(DataPathReport::Relayed { .. }) => { - "no direct link yet: traffic goes through another device, and moves to a \ - direct link by itself as soon as one comes up" - } - _ => "connected, but no path carries packets yet", - }); - } + response.on_hover_text(match device.path { + Some(DataPathReport::Direct { .. }) => { + "One Tsunagi hop with no mesh relay. The end-to-end tunnel may use a different \ + protocol from this transport." + } + Some(DataPathReport::Relayed { .. }) => { + "No direct transport yet: traffic goes through another device, and moves to a \ + direct path by itself as soon as one comes up." + } + _ if device.online => "Connected, but no data path carries packets yet.", + _ => "This device is not reachable now.", + }); } fn yes_no(value: bool) -> &'static str { @@ -726,4 +800,44 @@ mod tests { .is_none() ); } + + #[test] + fn tunnel_and_direct_transport_are_named_as_different_layers() { + assert_eq!(tunnel_text("wg", Some(1)), "wg · handshake 1s ago"); + assert_eq!( + data_path_text( + Some(&DataPathReport::Direct { + transport: "tcp-tls".into(), + }), + None, + true, + ), + ("direct over tcp-tls".into(), true) + ); + assert_eq!(tunnel_text("tcp-tls", Some(42)), "tcp-tls · up 42s"); + } + + #[test] + fn incomplete_and_longer_paths_stay_explicit() { + assert_eq!( + tunnel_text("wg-quic", None), + "wg-quic · waiting for handshake" + ); + assert_eq!( + data_path_text( + Some(&DataPathReport::Relayed { + hops: 2, + via: "near".into(), + }), + Some("iris"), + true, + ), + ("via iris · 2 hops".into(), false) + ); + assert_eq!( + data_path_text(Some(&DataPathReport::None), None, true), + ("no data path yet".into(), false) + ); + assert_eq!(data_path_text(None, None, false), ("offline".into(), false)); + } } diff --git a/crates/tsunagi-wg-quic/tests/wireguard.rs b/crates/tsunagi-wg-quic/tests/wireguard.rs index d2598dc..2172229 100644 --- a/crates/tsunagi-wg-quic/tests/wireguard.rs +++ b/crates/tsunagi-wg-quic/tests/wireguard.rs @@ -1661,8 +1661,10 @@ async fn lan_broadcast_fanout_is_scoped_opt_in_and_does_not_reflood() { bytes[28..].copy_from_slice(b"LAN GAME"); // UDP checksum zero is valid for IPv4. let sum: u32 = bytes[..20] - .chunks_exact(2) - .map(|w| u16::from_be_bytes([w[0], w[1]]) as u32) + .as_chunks::<2>() + .0 + .iter() + .map(|w| u16::from_be_bytes(*w) as u32) .sum(); let checksum = !((sum & 0xffff) + (sum >> 16)) as u16; bytes[10..12].copy_from_slice(&checksum.to_be_bytes()); diff --git a/crates/tsunagi/src/main.rs b/crates/tsunagi/src/main.rs index 76a59e6..6befb47 100644 --- a/crates/tsunagi/src/main.rs +++ b/crates/tsunagi/src/main.rs @@ -3626,15 +3626,17 @@ fn member_row(row: &MemberRow<'_>) -> report::Row { // Online is online: a member reached through others is as much here as // one reached directly, and is said to be reached that way. let (path, data_path_good) = match row.path { - Some(DataPathReport::Direct { transport }) => (format!("direct {transport}"), true), + Some(DataPathReport::Direct { transport }) => { + (format!("data path direct over {transport}"), true) + } Some(DataPathReport::Relayed { hops, .. }) => ( format!( - "relayed via {} ({hops} hops)", + "data path via {} · {hops} hops", row.via_label.as_deref().unwrap_or("another member") ), false, ), - Some(DataPathReport::None) | None => ("connected, no data path yet".to_string(), false), + Some(DataPathReport::None) | None => ("connected · no data path yet".to_string(), false), }; let mut detail = String::new(); @@ -3643,7 +3645,7 @@ fn member_row(row: &MemberRow<'_>) -> report::Row { } detail.push_str(&path); if let Some(transport) = row.transport { - detail.push_str(&format!(" · control {}", transport.to_lowercase())); + detail.push_str(&format!(" · control path {}", transport.to_lowercase())); } if let Some(rtt) = row.rtt_ms { detail.push_str(&format!(" rtt {rtt}ms")); @@ -3670,13 +3672,13 @@ fn member_row(row: &MemberRow<'_>) -> report::Row { }; out = out.with_note(match tunnel.handshake_secs_ago { Some(secs) => { - let stats = format!( - "tunnel up ({proto}), {} tx {} rx {}{}", - if proto == "tcp-tls" { - format!("uptime {secs}s,") - } else { - format!("handshake {secs}s ago,") - }, + let health = if proto == "tcp-tls" { + format!("uptime {secs}s") + } else { + format!("handshake {secs}s ago") + }; + format!( + "end-to-end tunnel {proto} · {health} · tx {} rx {}{} · transport detail: {}", tunnel.tx_packets, tunnel.rx_packets, if tunnel.dropped > 0 { @@ -3684,14 +3686,20 @@ fn member_row(row: &MemberRow<'_>) -> report::Row { } else { String::new() }, - ); - format!("{stats:<48} · {}", tunnel.path) + tunnel.path + ) } None => { if proto == "wg-quic" || proto == "wg" { - "no WireGuard handshake yet; the tunnel cannot carry traffic".to_string() + format!( + "end-to-end tunnel {proto} · no WireGuard handshake yet; the tunnel \ + cannot carry traffic" + ) } else { - format!("no {proto} handshake yet; the tunnel cannot carry traffic") + format!( + "end-to-end tunnel {proto} · no {proto} handshake yet; the tunnel \ + cannot carry traffic" + ) } } }); @@ -5814,7 +5822,7 @@ mod status_tests { let text = out.render(false); assert!(text.contains("2 of 2 online"), "{text}"); assert!( - text.contains("relayed via music (2 hops)"), + text.contains("data path via music · 2 hops"), "names the member it goes through: {text}" ); assert!(!text.contains("offline"), "{text}"); @@ -5907,10 +5915,40 @@ mod status_tests { out.push(network_section(&network, OWN, false)); let text = out.render(false); assert_eq!(out.worst(), Health::Good, "{text}"); - assert!(text.contains("tunnel up (tcp-tls)"), "{text}"); + assert!(text.contains("end-to-end tunnel tcp-tls"), "{text}"); assert!(text.contains("uptime 42s"), "{text}"); assert!(text.contains("tx 100 rx 200"), "{text}"); - assert!(text.contains("tcp-tls via 192.0.2.1:443"), "{text}"); + assert!( + text.contains("transport detail: tcp-tls via 192.0.2.1:443"), + "{text}" + ); + } + + #[test] + fn end_to_end_tunnel_and_data_transport_are_named_separately() { + let mut network = network_after_a_peer_returned(); + network.peers[0].path = tsunagi::ipc::DataPathReport::Direct { + transport: "tcp-tls".into(), + }; + network.members[1].path = network.peers[0].path.clone(); + let mut t = tunnel(ONLINE, Some(1)); + t.protocol = "wg".into(); + t.path = "tcp-tls via 192.0.2.1:443".into(); + network.overlay = Some(overlay(vec![t])); + + let mut out = report::Report::new(); + out.push(network_section(&network, OWN, false)); + let text = out.render(false); + assert!(text.contains("data path direct over tcp-tls"), "{text}"); + assert!(text.contains("control path direct"), "{text}"); + assert!( + text.contains("end-to-end tunnel wg · handshake 1s ago"), + "{text}" + ); + assert!( + text.contains("transport detail: tcp-tls via 192.0.2.1:443"), + "{text}" + ); } }