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}" + ); } }