Files
tsunagi/crates/tsunagi-tray/src/devices.rs
T
Aleksandr BogomiakovandClaude bb38562b0a 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>
2026-10-03 02:09:14 +01:00

586 lines
20 KiB
Rust

//! The per-network devices window: full detail about one network — its
//! settings and counters, and one table of every device, online first, with
//! inline traffic plots for the connected ones.
//!
//! Rendered in a separate OS window (an egui viewport) opened from a tile in
//! the main window.
use std::collections::HashMap;
use eframe::egui;
use tsunagi::ipc::{NetworkReport, OverlayPeerReport};
use crate::agent::{AgentClient, Command};
use crate::exit;
use crate::format;
use crate::stats::{Traffic, Unit};
/// A readable title for a network's window.
pub(crate) fn title(network: &NetworkReport) -> String {
format!("tsunagi · {}", network.name)
}
/// One row of the device table, built from a tunnel and/or a signed member.
struct Device<'a> {
online: bool,
/// The last hostname seen for it, kept while it is offline.
hostname: Option<&'a str>,
/// What clicking the hostname copies: `hostname.network`, so it resolves.
copy: Option<String>,
/// The full endpoint id, which clicking the short form copies.
id: &'a str,
proto: &'a str,
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,
/// 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<'_> {
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()
}
}
/// The first characters of an endpoint id, enough to tell devices apart.
fn short_id(id: &str) -> String {
id.chars().take(5).collect()
}
/// The device cell: the hostname and, in brackets, the start of the id.
/// Clicking either copies its own full value.
fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
match (device.hostname, &device.copy) {
(Some(hostname), Some(copy)) => {
format::copy_field(ui, hostname, copy);
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
}
_ => format::copy_field(ui, &short_id(device.id), device.id),
}
});
}
/// Draws the window body into its viewport.
pub(crate) fn show(
ctx: &egui::Context,
agent: &AgentClient,
unit: &mut Unit,
confirm: &mut Option<ExitConfirm>,
network: &NetworkReport,
own_id: &str,
traffic: &Traffic,
) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading(&network.name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.selectable_value(unit, Unit::Bytes, "bytes");
ui.selectable_value(unit, Unit::Packets, "pkts");
ui.label("show:");
});
});
// A copy of the chosen unit for the read-only rendering below.
let unit = *unit;
summary(ui, network);
ui.add_space(6.0);
ui.label(egui::RichText::new("traffic").strong());
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
ui.add_space(8.0);
let devices = devices(network, own_id);
let online = devices.iter().filter(|d| d.online).count();
ui.label(
egui::RichText::new(format!(
"devices ({} online / {} known)",
online,
devices.len()
))
.strong(),
);
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
device_table(ui, unit, network, &devices, traffic, confirm);
});
});
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.
///
/// `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: Option<&str>,
) {
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"))
.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("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 {
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(),
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.
fn summary(ui: &mut egui::Ui, network: &NetworkReport) {
egui::Grid::new("summary")
.num_columns(2)
.spacing([12.0, 3.0])
.show(ui, |ui| {
ui.weak("id");
format::copy_field(ui, &network.network_id, &network.network_id);
ui.end_row();
ui.weak("active");
ui.label(yes_no(network.active));
ui.end_row();
ui.weak("broadcast");
ui.label(yes_no(network.broadcast));
ui.end_row();
if let Some(range) = &network.range {
ui.weak("range");
format::copy_field(ui, range, range);
ui.end_row();
}
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
ui.weak("this device");
format::copy_field(ui, address, address);
ui.end_row();
}
ui.weak("candidates");
ui.label(network.candidates.to_string());
ui.end_row();
ui.weak("relay fwd/via/recv");
ui.label(format!(
"{} / {} / {}",
network.relay_forwarded, network.relay_sent_via, network.relay_received_via
));
ui.end_row();
ui.weak("dial/handshake fails");
ui.label(format!(
"{} / {}",
network.dial_failures, network.handshake_failures
));
ui.end_row();
});
}
/// Every other device this network knows of, from a live tunnel or the signed
/// membership. This device is in the roster too but is not one of the others.
fn known_ids<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<&'a str> {
let overlay_peers = network
.overlay
.as_ref()
.map(|o| o.peers.as_slice())
.unwrap_or(&[]);
let mut ids: Vec<&str> = Vec::new();
let candidates = overlay_peers
.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.exit.via.as_deref());
for id in candidates {
if id != own_id && !ids.contains(&id) {
ids.push(id);
}
}
ids
}
/// How many other devices this network knows of, connected or not.
pub(crate) fn known_count(network: &NetworkReport, own_id: &str) -> usize {
known_ids(network, own_id).len()
}
/// Builds the device list: every endpoint known from a live tunnel or the
/// signed membership, online ones first.
fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
// The signed members carry the last name seen, so a device that is away
// is still called by it; a live announcement is fresher and wins.
let mut hostnames: HashMap<&str, &str> = network
.members
.iter()
.filter_map(|m| m.hostname.as_deref().map(|h| (m.endpoint_id.as_str(), h)))
.collect();
hostnames.extend(
network
.peers
.iter()
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))),
);
let overlay_peers = network
.overlay
.as_ref()
.map(|o| o.peers.as_slice())
.unwrap_or(&[]);
let ids = known_ids(network, own_id);
let mut devices: Vec<Device> = ids
.into_iter()
.map(|id| {
let overlay = overlay_peers.iter().find(|p| p.endpoint_id == id);
let member = network.members.iter().find(|m| m.endpoint_id == id);
let online = overlay.is_some_and(|p| p.handshake_secs_ago.is_some());
let hostname = hostnames.get(id).copied();
Device {
online,
hostname,
copy: hostname.map(|h| format!("{h}.{}", network.name)),
id,
proto: 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())),
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();
devices.sort_by(|a, b| {
b.online
.cmp(&a.online)
.then_with(|| a.sort_key().cmp(&b.sort_key()))
});
devices
}
/// 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; 9] = [
"",
"exit",
"tcp-tls",
"255.255.255.255",
"23h 59m",
"99999/99999",
"1023.9 MB / 1023.9 MB",
"1023.9 kB/s",
"",
];
/// Column widths that only ever grow, kept across frames.
///
/// A grid sizes each column to its widest cell *this frame*, so a value that
/// 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; 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; 9];
for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) {
if !sample.is_empty() {
*width = ui.fonts(|fonts| {
fonts
.layout_no_wrap(sample.to_owned(), font.clone(), egui::Color32::WHITE)
.size()
.x
});
}
}
widths
})
}
/// One cell, at least as wide as its column has ever been.
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);
add(ui);
});
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,
main: false,
});
}
}
/// 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: &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;
}
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(9)
.striped(true)
.spacing([10.0, 4.0])
.show(ui, |ui| {
for (column, header) in [
"device",
"exit",
"proto",
"address",
"handshake",
"pkts",
"bytes",
"rate",
"plot",
]
.into_iter()
.enumerate()
{
cell(ui, column, &mut widths, |ui| {
ui.weak(header);
});
}
ui.end_row();
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, 3, &mut widths, |ui| match &device.address {
Some(address) => format::copy_field(ui, address, address),
None => {
ui.weak("—");
}
});
cell(ui, 4, &mut widths, |ui| {
ui.label(
device
.handshake_secs
.map_or_else(|| "offline".to_string(), format::age),
);
});
match device.overlay.filter(|_| device.online) {
Some(peer) => {
let series = traffic.peer(network_id, &peer.public_key);
cell(ui, 5, &mut widths, |ui| {
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
});
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, 7, &mut widths, |ui| {
ui.add(
egui::Label::new(format::series_rate(series, unit))
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| {
format::sparkline(ui, series, unit, 18.0);
});
}
None => {
for column in 5..8 {
cell(ui, column, &mut widths, |ui| {
ui.weak("—");
});
}
ui.weak("—");
}
}
ui.end_row();
}
// Everything this agent has moved through the overlay, including
// devices that have since gone away.
let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0);
for peer in devices.iter().filter_map(|d| d.overlay) {
tx_packets += peer.tx_packets;
rx_packets += peer.rx_packets;
tx_bytes += peer.tx_bytes;
rx_bytes += peer.rx_bytes;
}
cell(ui, 0, &mut widths, |ui| {
ui.strong("total");
});
for column in 1..5 {
cell(ui, column, &mut widths, |ui| {
ui.label("");
});
}
cell(ui, 5, &mut widths, |ui| {
ui.strong(format!("{tx_packets}/{rx_packets}"));
});
cell(ui, 6, &mut widths, |ui| {
ui.strong(format!(
"{} / {}",
format::bytes(tx_bytes as f64),
format::bytes(rx_bytes as f64)
));
});
cell(ui, 7, &mut widths, |ui| {
ui.add(
egui::Label::new(
egui::RichText::new(format::series_rate(traffic.network(network_id), unit))
.strong(),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.end_row();
});
ui.memory_mut(|m| m.data.insert_temp(widths_id, widths));
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}