diff --git a/README.md b/README.md index 5683de2..d09283d 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ On the first machine: ```bash cargo build --release ./target/release/tsunagi secret # prints tsn1...; share it privately -./target/release/tsunagi doctor # what this host can and cannot do +./target/release/tsunagi status # this device, the agent, and this host ./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard ``` @@ -169,12 +169,17 @@ allocation changes. Everything else, including every byte from the network, is handled with it lowered. `+ep` works too; the agent lowers it on the way in. -`tsunagi doctor` says which of these applies on the host it runs on. It -grades each finding: **ok** for what works, **warn** for what the agent runs -without and you can fix from the line it prints, **FAIL** for what it cannot -work around. The words carry the grade as well as the colour, so the report -reads the same piped to a file or on a terminal without colour, and it honours -`NO_COLOR`. +`tsunagi status` says which of these applies on the host it runs on, along +with what the agent is doing. It grades each finding: **ok** for what works, +**warn** for what the agent runs without and you can fix from the line it +prints, **FAIL** for what it cannot work around. The words carry the grade as +well as the colour, so the report reads the same piped to a file or on a +terminal without colour, and it honours `NO_COLOR`. + +`status` and `id` both prefer a running agent, which is live and +authoritative, and fall back to reading the state store when there is none. +Reading takes no directory lock, so neither has to wait for the agent it is +asking about — nor does either need one to be running. ### It cleans up after itself diff --git a/src/bin/tsunagi.rs b/src/bin/tsunagi.rs index a9002a8..3d51066 100644 --- a/src/bin/tsunagi.rs +++ b/src/bin/tsunagi.rs @@ -40,16 +40,14 @@ struct Cli { enum Command { /// Generates a fresh network secret and prints it. Secret, - /// Reports what this machine can and cannot do. - Doctor(PathArgs), /// Shows this device's identity without joining anything. - Id(PathArgs), + Id(StatusArgs), /// Joins a network and runs until interrupted. // Boxed: it is much larger than the other variants, and every command // but this one would otherwise pay for its size. A `//` comment, not a // `///` one, or clap would print it as help. Up(Box), - /// Asks a running agent what it is doing. + /// Reports this device, what the agent is doing, and what this host can do. Status(StatusArgs), } @@ -294,8 +292,7 @@ async fn run(command: Command) -> Result<(), Box> { ); Ok(()) } - Command::Doctor(paths) => doctor(paths).await, - Command::Id(paths) => show_id(paths).await, + Command::Id(args) => show_id(args).await, Command::Up(args) => up(*args).await, Command::Status(args) => status(args).await, } @@ -309,111 +306,378 @@ fn control_socket(paths: &StoragePaths, override_path: Option<&PathBuf>) -> Path } } -async fn status(args: StatusArgs) -> Result<(), Box> { +/// What could be learned about this device, and from where. +/// +/// A running agent is authoritative and live, so it is asked first. With no +/// agent there is still plenty to say: the mandatory state store holds the +/// identity and the configured networks, and reading it takes no directory +/// lock — so asking who this device is never collides with the agent that is +/// being asked about, and never needs one to be running. +enum Observed { + /// A running agent answered over the control socket. + Agent(Box), + /// Read from the state store, with no agent running. + Stored { + endpoint_id: Option, + hostname: Option, + networks: Vec<(String, String, bool)>, + /// Why there was no agent to ask. + why: String, + }, +} + +/// Asks the agent, and falls back to the state store. +async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed { + let why = if socket.exists() { + match tsunagi::ipc::unix::request_status(socket).await { + Ok(report) => return Observed::Agent(Box::new(report)), + Err(err) => format!("cannot reach the agent at {}: {err}", socket.display()), + } + } else { + "no agent is running for this state directory".to_string() + }; + + // Read-only, and deliberately tolerant: a state directory that has never + // been used is not an error, it just has nothing to report yet. + let (endpoint_id, hostname, networks) = + match tsunagi::storage::StateStore::open(paths.state_db()) { + Ok(store) => ( + store + .device_identity() + .ok() + .flatten() + .map(|identity| identity.endpoint_id().to_string()), + store.hostname().ok().flatten(), + store + .list_networks() + .unwrap_or_default() + .into_iter() + .map(|network| { + ( + network.name.to_string(), + network.network_id.to_string(), + network.auto_start, + ) + }) + .collect(), + ), + Err(_) => (None, None, Vec::new()), + }; + Observed::Stored { + endpoint_id, + hostname, + networks, + why, + } +} + +/// The `device` section: who this is and where it keeps things. +fn device_section(paths: &StoragePaths, observed: &Observed) -> report::Section { + use report::{Health, Row, Section}; + + let mut device = Section::new("device"); + let (endpoint_id, hostname) = match observed { + Observed::Agent(report) => ( + Some(report.endpoint_id.clone()), + Some(report.hostname.clone()), + ), + Observed::Stored { + endpoint_id, + hostname, + .. + } => (endpoint_id.clone(), hostname.clone()), + }; + + device.push(match endpoint_id { + Some(id) => Row::new(Health::Info, "endpoint id", id), + None => Row::new(Health::Info, "endpoint id", "not created yet") + .with_note("generated the first time an agent starts here"), + }); + if let Some(hostname) = hostname { + device.push(Row::new(Health::Info, "hostname", hostname)); + } + device.push(Row::new( + Health::Info, + "state directory", + paths.state_dir.display().to_string(), + )); + device.push(Row::new( + Health::Info, + "cache directory", + paths.cache_dir.display().to_string(), + )); + device +} + +/// The `networks` section, as the state store knows them. +fn stored_networks_section(networks: &[(String, String, bool)]) -> report::Section { + use report::{Health, Row, Section}; + + let mut section = Section::new("networks"); + if networks.is_empty() { + section.push(Row::new(Health::Info, "none", "no network has been joined")); + } + for (name, id, auto_start) in networks { + section.push(Row::new( + Health::Info, + name, + format!("{id}{}", if *auto_start { " (auto-start)" } else { "" }), + )); + } + section +} + +async fn show_id(args: StatusArgs) -> Result<(), Box> { + use report::{Health, Report, Row, Section}; + let paths = args.paths.resolve()?; let socket = control_socket(&paths, args.control_socket.as_ref()); - if !socket.exists() { - return Err(format!( - "no agent is running for {} (no control socket at {})", - paths.state_dir.display(), - socket.display() - ) - .into()); + let observed = observe(&paths, &socket).await; + + let mut out = Report::new(); + out.push(device_section(&paths, &observed)); + match &observed { + Observed::Agent(report) => { + let mut section = Section::new("networks"); + if report.networks.is_empty() { + section.push(Row::new(Health::Info, "none", "no network has been joined")); + } + for network in &report.networks { + section.push(Row::new( + Health::Info, + &network.name, + format!( + "{} ({})", + network.network_id, + if network.active { "active" } else { "inactive" } + ), + )); + } + out.push(section); + } + Observed::Stored { networks, .. } => out.push(stored_networks_section(networks)), } - let report = tsunagi::ipc::unix::request_status(&socket) - .await - .map_err(|err| format!("cannot reach the agent at {}: {err}", socket.display()))?; - print!("{}", report.render()); - Ok(()) + print_report("tsunagi id", &out) } -async fn show_id(paths: PathArgs) -> Result<(), Box> { - let paths = paths.resolve()?; - println!("state directory {}", paths.state_dir.display()); - println!("cache directory {}", paths.cache_dir.display()); - - let agent = - Agent::spawn(AgentConfig::new(paths).with_transport(TransportPolicy::LocalOnly)).await?; - println!("endpoint id {}", agent.endpoint_id()); - println!("hostname {}", agent.hostname()); - for network in agent.list_networks().await? { - println!( - "network {} ({}) auto-start={}", - network.name, network.network_id, network.auto_start - ); - } - agent.shutdown().await; - Ok(()) -} - -/// Reports what this machine can and cannot do, and how badly it matters. +/// Reports this device, what the agent is doing, and what this host can do. /// /// Three levels, and the distinction between the middle two is deliberate: /// *degraded* is something the agent runs without and that the user can fix /// from a stated one-liner, *broken* is something it cannot work around. /// Getting those the wrong way round makes a diagnostic tool useless, so /// each check below says which it is and why. -async fn doctor(paths: PathArgs) -> Result<(), Box> { +async fn status(args: StatusArgs) -> Result<(), Box> { use report::{Health, Report, Row, Section}; - let paths = paths.resolve()?; - let mut doctor = Report::new(); + let paths = args.paths.resolve()?; + let socket = control_socket(&paths, args.control_socket.as_ref()); + let observed = observe(&paths, &socket).await; - // Storage. The asymmetry here is the point: state is mandatory and cache - // is disposable, so the same failure means different things. - let mut storage = Section::new("storage"); - storage.push(match std::fs::create_dir_all(&paths.state_dir) { - Ok(()) => Row::new( + let mut out = Report::new(); + out.push(device_section(&paths, &observed)); + + let mut agent = Section::new("agent"); + match &observed { + Observed::Agent(report) => { + agent.push(Row::new( + Health::Good, + "running", + format!("reachable at {}", socket.display()), + )); + if !report.bound_sockets.is_empty() { + agent.push(Row::new( + Health::Info, + "bound", + report.bound_sockets.join(", "), + )); + } + agent.push(if report.cache_healthy { + Row::new(Health::Good, "cache", "usable") + } else { + Row::new(Health::Degraded, "cache", "unavailable") + .with_note("disposable: the agent runs, rediscovering what it cached") + }); + } + Observed::Stored { why, .. } => { + agent.push( + Row::new(Health::Degraded, "running", "no") + .with_note(format!("{why}; everything below was read from the store")), + ); + } + } + out.push(agent); + + if let Observed::Agent(report) = &observed { + for network in &report.networks { + out.push(network_section(network)); + } + } + + out.push(host_section()); + out.push(addresses_section().await); + print_report("tsunagi status", &out) +} + +/// One network's control plane and overlay. +fn network_section(network: &tsunagi::ipc::NetworkReport) -> report::Section { + use report::{Health, Row, Section}; + + let mut section = Section::new(format!("network {}", network.name)); + section.push(if network.active { + Row::new( Health::Good, - "state directory", - format!("{} (writable)", paths.state_dir.display()), - ), - Err(err) => Row::new( - Health::Broken, - "state directory", - format!("{}: {err}", paths.state_dir.display()), + "state", + format!("active {}", network.network_id), ) - .with_note("mandatory: the agent will not start without it"), - }); - storage.push(match std::fs::create_dir_all(&paths.cache_dir) { - Ok(()) => Row::new( - Health::Good, - "cache directory", - format!("{} (writable)", paths.cache_dir.display()), - ), - Err(err) => Row::new( + } else { + Row::new( Health::Degraded, - "cache directory", - format!("{}: {err}", paths.cache_dir.display()), + "state", + format!("inactive {}", network.network_id), ) - .with_note("disposable: the agent runs, rediscovering what it cached"), }); - doctor.push(storage); - // Control plane. Binding a socket is a real check rather than a claim. - let mut control = Section::new("control plane"); - control.push( - match std::net::UdpSocket::bind((std::net::Ipv6Addr::UNSPECIFIED, 0)) - .or_else(|_| std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0))) - { - Ok(_) => Row::new(Health::Good, "udp socket", "can bind; no privileges needed"), - Err(err) => Row::new(Health::Broken, "udp socket", format!("cannot bind: {err}")) - .with_note("nothing will reach any peer"), - }, - ); - doctor.push(control); + if network.peers.is_empty() { + section.push(Row::new(Health::Degraded, "peers", "none authenticated")); + } + for peer in &network.peers { + // A relayed path works but goes through somebody else's machine and + // costs latency, so it is the middle grade rather than a good one. + // Compared without regard to case: the agent answering may be a + // different build from the client asking, and the spelling of this + // field has already changed once. + let health = if peer.transport.eq_ignore_ascii_case("direct") { + Health::Good + } else { + Health::Degraded + }; + section.push(Row::new( + health, + format!("peer {}", short(&peer.endpoint_id, 10)), + format!( + "{} {}{}", + peer.hostname.as_deref().unwrap_or("unnamed"), + peer.transport.to_lowercase(), + match peer.rtt_ms { + Some(rtt) => format!(" rtt {rtt}ms"), + None => String::new(), + } + ), + )); + } - let mut data = Section::new("data plane (WireGuard)"); - data.push(Row::new( - Health::Good, + let (sent, received) = network.control_messages; + section.push(Row::new( + Health::Info, + "control messages", + format!("{sent} sent, {received} received"), + )); + if network.dial_failures > 0 || network.handshake_failures > 0 { + section.push(Row::new( + Health::Degraded, + "failures", + format!( + "{} dial, {} handshake", + network.dial_failures, network.handshake_failures + ), + )); + } + + if let Some(overlay) = &network.overlay { + section.push(Row::new( + Health::Info, + "overlay", + format!( + "{} {}/{}{} mtu {}", + overlay.interface, + overlay.address, + overlay.prefix_len, + match &overlay.address_v4 { + Some(v4) => format!(" and {v4}"), + None => String::new(), + }, + overlay.mtu + ), + )); + for peer in &overlay.peers { + let row = match peer.handshake_secs_ago { + Some(secs) => Row::new( + Health::Good, + format!("tunnel {}", short(&peer.public_key, 8)), + format!( + "{}{} handshake {secs}s ago tx {} rx {} {}", + peer.address, + match &peer.address_v4 { + Some(v4) => format!(" / {v4}"), + None => String::new(), + }, + peer.tx_packets, + peer.rx_packets, + peer.path + ), + ), + // A snapshot cannot tell a tunnel that is still coming up + // from one that is stuck, so this is the middle grade with + // the consequence spelled out rather than an alarm. + None => Row::new( + Health::Degraded, + format!("tunnel {}", short(&peer.public_key, 8)), + format!("{} no handshake yet", peer.address), + ) + .with_note("the tunnel cannot carry traffic until it handshakes"), + }; + let row = if peer.dropped > 0 { + row.with_note(format!("{} packet(s) dropped", peer.dropped)) + } else { + row + }; + section.push(row); + } + if overlay.unroutable_packets > 0 { + section.push( + Row::new( + Health::Degraded, + "unroutable", + format!( + "{} packet(s) sent to an address no peer owns", + overlay.unroutable_packets + ), + ) + .with_note(match &overlay.unroutable_sample { + Some(sample) => format!("for example {sample}"), + None => "no sample recorded".to_string(), + }), + ); + } + } + section +} + +/// Shortens an identifier for a column, with an ellipsis when it was cut. +fn short(text: &str, len: usize) -> String { + if text.chars().count() <= len { + text.to_string() + } else { + format!("{}…", text.chars().take(len).collect::()) + } +} + +/// What this host can and cannot do for the data plane. +fn host_section() -> report::Section { + use report::{Health, Row, Section}; + + let mut host = Section::new("host"); + host.push(Row::new( + Health::Info, "implementation", - "userspace (boringtun); no kernel module needed", + "userspace WireGuard (boringtun); no kernel module needed", )); #[cfg(feature = "tun-device")] { if cfg!(target_os = "linux") { let tun_path = std::path::Path::new("/dev/net/tun"); - data.push(if !tun_path.exists() { + host.push(if !tun_path.exists() { Row::new(Health::Broken, "/dev/net/tun", "missing") .with_note("load the `tun` module; without it there can be no interface") } else { @@ -436,8 +700,8 @@ async fn doctor(paths: PathArgs) -> Result<(), Box> { use tsunagi::dataplane::wireguard::{Privilege, probe_net_admin}; match probe_net_admin() { Privilege::Available => { - data.push(Row::new(Health::Good, "privileges", "CAP_NET_ADMIN held")); - data.push(Row::new( + host.push(Row::new(Health::Good, "privileges", "CAP_NET_ADMIN held")); + host.push(Row::new( Health::Good, "interface", "managed by the agent: created on start, removed on exit", @@ -447,18 +711,18 @@ async fn doctor(paths: PathArgs) -> Result<(), Box> { // The note is the command and nothing else: a paragraph of // explanation belongs in the runtime error, not in a column // the eye is meant to scan. - data.push( + host.push( Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held") .with_note(format!("sudo setcap cap_net_admin+p {}", program_path())), ); - data.push(Row::new( + host.push(Row::new( Health::Degraded, "interface", "cannot be created; run with `--no-tun` meanwhile", )); } Privilege::Unsupported => { - data.push(Row::new( + host.push(Row::new( Health::Degraded, "privileges", format!( @@ -466,7 +730,7 @@ async fn doctor(paths: PathArgs) -> Result<(), Box> { std::env::consts::OS ), )); - data.push(Row::new( + host.push(Row::new( Health::Degraded, "interface", "cannot be created; run with `--no-tun`", @@ -475,7 +739,7 @@ async fn doctor(paths: PathArgs) -> Result<(), Box> { } } #[cfg(not(feature = "tun-device"))] - data.push( + host.push( Row::new( Health::Degraded, "interface", @@ -483,7 +747,12 @@ async fn doctor(paths: PathArgs) -> Result<(), Box> { ) .with_note("rebuild with the `tun-device` feature, or run with `--no-tun`"), ); - doctor.push(data); + host +} + +/// The addresses this host could reach a peer from. +async fn addresses_section() -> report::Section { + use report::{Health, Row, Section}; let mut addresses = Section::new("local addresses"); let found = netwatch_addresses().await; @@ -501,21 +770,25 @@ async fn doctor(paths: PathArgs) -> Result<(), Box> { (false, true) => "ipv4", (false, false) => "ipv6", }; - addresses.push(Row::new(Health::Good, kind, addr.to_string())); + addresses.push(Row::new(Health::Info, kind, addr.to_string())); } - doctor.push(addresses); + addresses +} - // `anstream` decides whether the escapes survive: they are stripped when - // stdout is not a terminal, when NO_COLOR is set, and on a Windows console - // that cannot render them. +/// Writes a report to stdout under a title. +/// +/// `anstream` decides whether the escapes survive: they are stripped when +/// stdout is not a terminal, when `NO_COLOR` is set, and on a Windows console +/// that cannot render them. +fn print_report(title: &str, out: &report::Report) -> Result<(), Box> { use std::io::Write; - let mut out = anstream::stdout().lock(); - writeln!(out, "tsunagi doctor\n")?; - write!(out, "{}", doctor.render(true))?; + let mut stdout = anstream::stdout().lock(); + writeln!(stdout, "{title}\n")?; + write!(stdout, "{}", out.render(true))?; Ok(()) } -/// The shape of what `tsunagi doctor` reports. +/// The shape of what `tsunagi status` reports. /// /// Findings are built first and rendered second, so what is reported is /// decided separately from how it looks and can be tested without a @@ -529,6 +802,12 @@ mod report { /// How healthy one finding is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Health { + /// Not a check at all: a fact, such as an identifier or a path. + /// + /// Grading these would be noise — an endpoint id is neither good nor + /// bad — and a column of green `ok` next to plain data teaches the + /// eye to ignore the column, which is the opposite of the point. + Info, /// Works, nothing to do. Good, /// The agent runs, but something it could do it cannot, and there is @@ -543,6 +822,7 @@ mod report { /// The word printed in the margin. Four characters, so rows line up. fn word(self) -> &'static str { match self { + Health::Info => " ", Health::Good => "ok ", Health::Degraded => "warn", Health::Broken => "FAIL", @@ -551,6 +831,7 @@ mod report { fn style(self) -> Style { let colour = match self { + Health::Info => return Style::new(), Health::Good => AnsiColor::Green, Health::Degraded => AnsiColor::Yellow, Health::Broken => AnsiColor::Red, @@ -647,6 +928,18 @@ mod report { } } + /// Whether anything in the report was graded at all. + /// + /// A report of plain facts — `tsunagi id` — has nothing to summarise, + /// and "everything checked out" under a list of identifiers would be + /// claiming something that was never checked. + fn has_checks(&self) -> bool { + self.sections + .iter() + .flat_map(|section| §ion.rows) + .any(|row| row.health != Health::Info) + } + /// The closing line. fn summary(&self) -> String { fn checks(count: usize) -> String { @@ -695,12 +988,17 @@ mod report { out.push_str(&paint(bold, §ion.title)); out.push('\n'); for row in §ion.rows { + let label = format!("{:width$}", row.label, width = width); + let label = if row.health == Health::Info { + paint(dim, &label) + } else { + label + }; out.push_str(&format!( - " {} {:width$} {}\n", + " {} {} {}\n", paint(row.health.style(), row.health.word()), - row.label, - row.detail, - width = width + label, + row.detail )); if let Some(note) = &row.note { // Indented under the row it belongs to, and dimmed so @@ -717,9 +1015,16 @@ mod report { out.push('\n'); } - let worst = self.worst(); - out.push_str(&paint(worst.style(), &self.summary())); - out.push('\n'); + if self.has_checks() { + let worst = self.worst(); + out.push_str(&paint(worst.style(), &self.summary())); + out.push('\n'); + } else { + // Trim the blank line the last section left behind. + while out.ends_with("\n\n") { + out.pop(); + } + } out } } @@ -829,6 +1134,21 @@ mod report { assert!(clean.render(false).contains("everything checked out")); } + #[test] + fn a_report_of_plain_facts_claims_nothing_at_the_end() { + // `tsunagi id` reports identifiers, not checks. Summarising them + // as fine would assert something that was never tested. + let mut report = Report::new(); + let mut section = Section::new("device"); + section.push(Row::new(Health::Info, "endpoint id", "abc123")); + report.push(section); + + let text = report.render(false); + assert!(!text.contains("everything checked out"), "{text:?}"); + assert!(!text.contains("degraded") && !text.contains("broken")); + assert!(text.ends_with("abc123\n"), "{text:?}"); + } + #[test] fn an_empty_section_is_left_out_rather_than_printed_bare() { let mut report = Report::new(); @@ -1066,7 +1386,7 @@ async fn build_report( .map(|peer| PeerReport { endpoint_id: peer.endpoint_id.to_string(), hostname: peer.hostname.clone(), - transport: format!("{:?}", peer.transport), + transport: peer.transport.to_string(), rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64), }) .collect(), diff --git a/src/dataplane/transport/iroh_link.rs b/src/dataplane/transport/iroh_link.rs index 9657a84..e9c06df 100644 --- a/src/dataplane/transport/iroh_link.rs +++ b/src/dataplane/transport/iroh_link.rs @@ -137,8 +137,8 @@ impl PacketLink for IrohLink { // Report what iroh actually knows, never a guess. let snapshot = crate::net::snapshot_connection(&self.conn); match snapshot.paths.iter().find(|path| path.is_selected) { - Some(path) => format!("{:?} via {:?}", snapshot.transport, path.remote), - None => format!("{:?}, no selected path yet", snapshot.transport), + Some(path) => format!("{} via {}", snapshot.transport, path.remote), + None => format!("{}, no selected path yet", snapshot.transport), } } } diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs index a979449..fc7dcc6 100644 --- a/src/ipc/mod.rs +++ b/src/ipc/mod.rs @@ -121,7 +121,8 @@ pub struct PeerReport { pub endpoint_id: String, /// Hostname it announced, if any. pub hostname: Option, - /// `Direct`, `Relay` or `Unknown`, as the transport reports it. + /// How the connection reaches the peer: `direct`, `relay` or `unknown`, + /// as the transport reports it. pub transport: String, /// Round-trip time in milliseconds, when a path is selected. pub rtt_ms: Option, @@ -186,104 +187,3 @@ impl OverlayPeerReport { self.handshake_secs_ago.is_some() } } - -impl StatusReport { - /// Renders the report the way the command line prints it. - pub fn render(&self) -> String { - use std::fmt::Write as _; - let mut out = String::new(); - let _ = writeln!(out, "endpoint {}", self.endpoint_id); - let _ = writeln!(out, "hostname {}", self.hostname); - let _ = writeln!(out, "bound {}", self.bound_sockets.join(", ")); - if !self.cache_healthy { - let _ = writeln!(out, "cache UNAVAILABLE"); - } - - for network in &self.networks { - let _ = writeln!( - out, - "\nnetwork {} ({}) {}", - network.name, - network.network_id, - if network.active { "active" } else { "inactive" } - ); - if network.peers.is_empty() { - let _ = writeln!(out, " no peers"); - } - for peer in &network.peers { - let _ = writeln!( - out, - " peer {} {} {}{}", - &peer.endpoint_id[..10.min(peer.endpoint_id.len())], - peer.hostname.as_deref().unwrap_or("?"), - peer.transport, - match peer.rtt_ms { - Some(rtt) => format!(" rtt {rtt}ms"), - None => String::new(), - } - ); - } - if network.dial_failures > 0 || network.handshake_failures > 0 { - let _ = writeln!( - out, - " {} dial failure(s), {} handshake failure(s)", - network.dial_failures, network.handshake_failures - ); - } - - if let Some(overlay) = &network.overlay { - let up = overlay.peers.iter().filter(|peer| peer.is_up()).count(); - let _ = writeln!( - out, - " overlay {} {}/{}{} mtu {} {}/{} tunnel(s) up", - overlay.interface, - overlay.address, - overlay.prefix_len, - match &overlay.address_v4 { - Some(v4) => format!(" and {v4}"), - None => String::new(), - }, - overlay.mtu, - up, - overlay.peers.len() - ); - for peer in &overlay.peers { - let _ = writeln!( - out, - " {} {}{} {} tx {} rx {}{} {}", - &peer.public_key[..8.min(peer.public_key.len())], - peer.address, - match &peer.address_v4 { - Some(v4) => format!(" / {v4}"), - None => String::new(), - }, - match peer.handshake_secs_ago { - Some(secs) => format!("handshake {secs}s ago"), - None => "NOT HANDSHAKEN".to_string(), - }, - peer.tx_packets, - peer.rx_packets, - if peer.dropped > 0 { - format!(" DROPPED {}", peer.dropped) - } else { - String::new() - }, - peer.path - ); - } - if overlay.unroutable_packets > 0 { - let _ = writeln!( - out, - " {} packet(s) to addresses nobody owns{}", - overlay.unroutable_packets, - match &overlay.unroutable_sample { - Some(sample) => format!(", most recently {sample}"), - None => String::new(), - } - ); - } - } - } - out - } -} diff --git a/src/net.rs b/src/net.rs index 62304d9..c31a1b4 100644 --- a/src/net.rs +++ b/src/net.rs @@ -45,6 +45,16 @@ pub enum PathAddr { Other(String), } +impl std::fmt::Display for PathAddr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PathAddr::Ip(addr) => write!(f, "{addr}"), + PathAddr::Relay(url) => write!(f, "relay {url}"), + PathAddr::Other(what) => write!(f, "{what}"), + } + } +} + /// One verified network path of a connection. #[derive(Debug, Clone)] pub struct PathInfo { @@ -81,6 +91,17 @@ pub enum TransportKind { Unknown, } +impl std::fmt::Display for TransportKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let word = match self { + TransportKind::Direct => "direct", + TransportKind::Relay => "relay", + TransportKind::Unknown => "unknown", + }; + f.write_str(word) + } +} + /// Counters for one connection. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ConnectionCounters { diff --git a/src/storage/state.rs b/src/storage/state.rs index 5a519fe..5e555ab 100644 --- a/src/storage/state.rs +++ b/src/storage/state.rs @@ -175,11 +175,15 @@ impl StateStore { Ok(()) } - /// Loads the stored device identity, creating one on first use. + /// Loads the stored device identity, if there is one. /// - /// A stored key of the wrong length is a corruption error, never a reason to - /// silently mint a new identity. - pub fn load_or_create_device_identity(&self) -> Result { + /// Reads and never writes, so asking who this device is does not decide + /// it. A store that has never run an agent has no identity yet, which is + /// `None` rather than an error. + /// + /// A stored key of the wrong length is a corruption error, never a reason + /// to silently mint a new identity. + pub fn device_identity(&self) -> Result> { let stored: Option> = self .conn .query_row( @@ -190,14 +194,22 @@ impl StateStore { .optional() .map_err(|err| self.corrupt(format!("cannot read device identity: {err}")))?; - if let Some(bytes) = stored { - let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { - self.corrupt(format!( - "stored device key has {} bytes, expected 32; refusing to replace it", - bytes.len() - )) - })?; - return Ok(DeviceIdentity::from_secret_bytes(&bytes)); + let Some(bytes) = stored else { + return Ok(None); + }; + let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { + self.corrupt(format!( + "stored device key has {} bytes, expected 32; refusing to replace it", + bytes.len() + )) + })?; + Ok(Some(DeviceIdentity::from_secret_bytes(&bytes))) + } + + /// Loads the stored device identity, creating one on first use. + pub fn load_or_create_device_identity(&self) -> Result { + if let Some(identity) = self.device_identity()? { + return Ok(identity); } let identity = DeviceIdentity::generate(); diff --git a/tests/local_control.rs b/tests/local_control.rs index 2f958b5..1cf9c4a 100644 --- a/tests/local_control.rs +++ b/tests/local_control.rs @@ -158,11 +158,16 @@ async fn a_client_sees_the_agent_and_its_overlay() { assert_eq!(overlay.mtu, 1280); assert_eq!(overlay.peers.len(), 1); - // The rendering a user actually sees mentions the important parts. - let rendered = report.render(); - assert!(rendered.contains(&agent.endpoint_id().to_string())); - assert!(rendered.contains("1/1 tunnel(s) up"), "{rendered}"); - assert!(rendered.contains(&overlay.address)); + // The report carries what a reader needs, in structured form: how it is + // laid out is the CLI's business and is tested there. + assert_eq!(report.endpoint_id, agent.endpoint_id().to_string()); + assert_eq!( + overlay.peers.iter().filter(|peer| peer.is_up()).count(), + 1, + "the tunnel is up: {:?}", + overlay.peers + ); + assert!(!overlay.address.is_empty()); control.shutdown().await; assert!(!socket_path.exists(), "the socket is removed on shutdown"); @@ -226,3 +231,46 @@ fn the_socket_path_is_derived_and_short_enough() { control_socket_path(&std::path::PathBuf::from("/somewhere/else")) ); } + +/// Asking who this device is must not need the directory lock. +/// +/// The lock belongs to the one agent allowed to *write* the state. `id` only +/// reads, so making it take the lock would mean the question could never be +/// answered while an agent was running — which is exactly when you want to +/// ask it. +#[cfg(feature = "cli")] +#[tokio::test] +async fn identity_can_be_read_while_an_agent_holds_the_directory() { + let dir = TempDir::new().unwrap(); + let discovery = SharedMemoryDiscovery::new(); + + // Hold the directory the way a running agent does. + let agent = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + let expected = agent.endpoint_id().to_string(); + + let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) + .arg("id") + // The same layout `StoragePaths::under` gives the agent above. + .arg("--state-dir") + .arg(dir.path().join("state")) + .arg("--cache-dir") + .arg(dir.path().join("cache")) + .output() + .await + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "`id` failed while an agent was running:\n{stderr}" + ); + assert!( + stdout.contains(&expected), + "expected {expected} in:\n{stdout}" + ); + + agent.shutdown().await; +}