From 5edfa692b2c88901c221920536ca787aea44deed Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Fri, 3 Jul 2026 22:50:46 -0700 Subject: [PATCH 1/9] Add daemon self-description to admin state dump Teach the daemon to report its own build hash and start time over the admin socket. The daemon may be running an older binary than the one on disk, so the CLI's version can't stand in for it. First piece of `intermesh debug report` (IM-16 Phase 1). Older daemons leave the fields empty; old TOML dumps still parse fine. --- context/interfaces/src/state.md | 2 ++ context/interfaces/src/state_dump.md | 7 ++++++ proto/intermesh.proto | 5 ++++ src/admin.rs | 2 ++ src/state.rs | 10 ++++++++ src/state_dump.rs | 36 ++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+) diff --git a/context/interfaces/src/state.md b/context/interfaces/src/state.md index 28311e0..78898d0 100644 --- a/context/interfaces/src/state.md +++ b/context/interfaces/src/state.md @@ -37,6 +37,8 @@ pub(crate) struct State { pub(crate) log_file: Option, pub(crate) listen_addr: SocketAddr, pub(crate) local_ip: IpAddr, + /// Unix time when this state was created (daemon start). Not persisted. + pub(crate) started_at_unix: u64, pub(crate) endorse_local_ip: bool, pub(crate) intercept: bool, } diff --git a/context/interfaces/src/state_dump.md b/context/interfaces/src/state_dump.md index 544def5..6d71d5c 100644 --- a/context/interfaces/src/state_dump.md +++ b/context/interfaces/src/state_dump.md @@ -12,6 +12,11 @@ pub struct StateDump { pub adhoc_membership: Vec, pub admin_socket: Option, pub log_file: Option, + /// Daemon self-description: build hash and start time reported by the + /// running daemon (which may predate the binary on disk). Empty/zero + /// when the daemon did not report them (older build). + pub daemon_build: String, + pub daemon_started_at_unix: u64, } /// Structured snapshot of ad-hoc membership state. @@ -29,6 +34,8 @@ impl StateDump { membership: Option, admin_socket: Option, log_file: Option, + daemon_build: String, + daemon_started_at_unix: u64, ) -> Self; /// Encode the dump as TOML for debugging. diff --git a/proto/intermesh.proto b/proto/intermesh.proto index b5eb1d5..41db298 100644 --- a/proto/intermesh.proto +++ b/proto/intermesh.proto @@ -173,6 +173,11 @@ message StateDumpResponse { optional string log_file = 8; repeated intermesh.adhoc.Membership adhoc_membership = 9; + + // Daemon self-description. The daemon reports its own build and start + // time because the running daemon may predate the binary on disk. + string daemon_build = 14; // git commit hash of the running daemon + uint64 daemon_started_at_unix = 15; // (Timestamp) } message ShowRequest { diff --git a/src/admin.rs b/src/admin.rs index 3adaab3..15d5865 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -41,6 +41,8 @@ impl AdminService for GrpcService { .log_file .as_ref() .map(|p| p.display().to_string()), + env!("GIT_COMMIT").to_string(), + self.state.started_at_unix, ); Ok(Response::new(dump.to_proto())) diff --git a/src/state.rs b/src/state.rs index d50656b..0150493 100644 --- a/src/state.rs +++ b/src/state.rs @@ -17,6 +17,7 @@ use std::collections::BTreeSet; use std::io::Write; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; #[cfg(unix)] use std::fs::Permissions; @@ -92,6 +93,8 @@ pub(crate) struct State { pub(crate) listen_addr: SocketAddr, /// This node's IP, resolved at startup. Not persisted. pub(crate) local_ip: IpAddr, + /// Unix time when this state was created (daemon start). Not persisted. + pub(crate) started_at_unix: u64, pub(crate) endorse_local_ip: bool, pub(crate) intercept: bool, mutable: Mutex, @@ -230,6 +233,12 @@ impl State { ); let (change_tx, _) = watch::channel(()); + + let started_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .assert() + .as_secs(); + let state = Self { keypair, imid, @@ -238,6 +247,7 @@ impl State { log_file, listen_addr, local_ip, + started_at_unix, endorse_local_ip, intercept, mutable: Mutex::new(MutableState { diff --git a/src/state_dump.rs b/src/state_dump.rs index cd6eae8..07e39a4 100644 --- a/src/state_dump.rs +++ b/src/state_dump.rs @@ -32,6 +32,14 @@ pub struct StateDump { pub admin_socket: Option, #[serde(skip_serializing_if = "Option::is_none")] pub log_file: Option, + + // Daemon self-description. The daemon reports its own build and start + // time because the running daemon may predate the binary on disk. + // Empty/zero means the daemon did not report them (older build). + #[serde(default)] + pub daemon_build: String, + #[serde(default)] + pub daemon_started_at_unix: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -60,6 +68,8 @@ impl StateDump { membership: Option, admin_socket: Option, log_file: Option, + daemon_build: String, + daemon_started_at_unix: u64, ) -> Self { let adhoc_membership = membership .and_then(|m| { @@ -79,6 +89,8 @@ impl StateDump { adhoc_membership, admin_socket, log_file, + daemon_build, + daemon_started_at_unix, } } @@ -319,6 +331,8 @@ impl StateDump { admin_socket: self.admin_socket.clone().unwrap_or_default(), log_file: self.log_file.clone(), adhoc_membership, + daemon_build: self.daemon_build.clone(), + daemon_started_at_unix: self.daemon_started_at_unix, } } @@ -431,6 +445,8 @@ impl StateDump { adhoc_membership, admin_socket, log_file: resp.log_file.filter(|s| !s.is_empty()), + daemon_build: resp.daemon_build, + daemon_started_at_unix: resp.daemon_started_at_unix, }) } } @@ -547,6 +563,8 @@ mod tests { None, // no mesh membership in this test Some("/run/intermesh/admin.sock".to_string()), Some("/var/log/intermesh.log".to_string()), + "a1b2c3d".to_string(), + 1_700_000_000, ); // Proto round-trip preserves structure @@ -563,6 +581,8 @@ mod tests { proto_rt.log_file, Some("/var/log/intermesh.log".to_string()) ); + assert_eq!(proto_rt.daemon_build, "a1b2c3d"); + assert_eq!(proto_rt.daemon_started_at_unix, 1_700_000_000); // TOML round-trip preserves structure let toml_str = original.to_toml().assert(); @@ -575,6 +595,18 @@ mod tests { Some("/run/intermesh/admin.sock".to_string()) ); assert_eq!(toml_rt.log_file, Some("/var/log/intermesh.log".to_string())); + assert_eq!(toml_rt.daemon_build, "a1b2c3d"); + assert_eq!(toml_rt.daemon_started_at_unix, 1_700_000_000); + + // A dump without the daemon fields (older daemon) still parses. + let mut value: toml::Value = toml::from_str(&toml_str).assert(); + let table = value.as_table_mut().assert(); + table.remove("daemon_build").assert(); + table.remove("daemon_started_at_unix").assert(); + let legacy = toml::to_string(&value).assert(); + let legacy_rt: StateDump = toml::from_str(&legacy).assert(); + assert_eq!(legacy_rt.daemon_build, ""); + assert_eq!(legacy_rt.daemon_started_at_unix, 0); } #[test] @@ -742,6 +774,8 @@ mod tests { adhoc_membership, admin_socket: Some("/run/intermesh/admin.sock".to_string()), log_file: Some("/var/log/intermesh.log".to_string()), + daemon_build: String::new(), + daemon_started_at_unix: 0, }; let out = dump.format(); @@ -840,6 +874,8 @@ Aj1Lizc7Xdv-DjFv6wlk9-ocRmzqejaHz-9H27ngqcXf1 endorses AoHQqnbGQu_8OBYXtpQQLvyrA adhoc_membership: vec![], admin_socket: None, log_file: None, + daemon_build: String::new(), + daemon_started_at_unix: 0, }; let out = dump.format_status_table(); From 8f17f000e92151f36767853fca5eca024c801601 Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Fri, 3 Jul 2026 23:18:42 -0700 Subject: [PATCH 2/9] Add debug report model and renderer The report is assembled first, rendered second: collectors fill an in-memory Report, and rendering is a pure function of it, so the whole artifact is unit-tested without a daemon or network. Includes the state fingerprint ("do these two nodes agree?") and the per-node inputs digest, plus a proxy helper describing expected listeners. Part of `intermesh debug report` (IM-16 Phase 1). --- Cargo.lock | 8 + Cargo.toml | 2 + context/interfaces/src/cmd_report.md | 43 ++ context/interfaces/src/proxy.md | 5 + src/cmd_report.rs | 777 +++++++++++++++++++++++++++ src/lib.rs | 1 + src/proxy.rs | 16 +- src/proxy/intercept.rs | 2 +- 8 files changed, 851 insertions(+), 3 deletions(-) create mode 100644 context/interfaces/src/cmd_report.md create mode 100644 src/cmd_report.rs diff --git a/Cargo.lock b/Cargo.lock index 80a5067..fed56f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1261,6 +1261,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + [[package]] name = "hyper" version = "1.8.1" @@ -1510,12 +1516,14 @@ dependencies = [ "dialoguer", "dirs", "futures", + "hex", "hickory-proto", "hickory-resolver", "hickory-server", "hostname", "http", "http-body-util", + "humantime", "hyper", "hyper-util", "inquire", diff --git a/Cargo.toml b/Cargo.toml index ffe7a9a..7f39a2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ inquire = "0.9.1" ipnet = { version = "2.10.1", features = ["serde"] } itertools = "0.14.0" libc = "0.2" +hex = "0.4" local-ip-address = "0.6.5" prost = "0.14.1" rand = "0.9" @@ -70,6 +71,7 @@ tempfile = "3.23.0" hostname = "0.4.1" scopeguard = "1.2.0" users = "0.11" +humantime = "2.4.0" [build-dependencies] protoc-bin-vendored = "3.2.0" diff --git a/context/interfaces/src/cmd_report.md b/context/interfaces/src/cmd_report.md new file mode 100644 index 0000000..a25189e --- /dev/null +++ b/context/interfaces/src/cmd_report.md @@ -0,0 +1,43 @@ +# `src/cmd_report.rs` + +## Responsible for +- The `debug report` artifact: an in-memory `Report` model filled by the + CLI's impure collectors, rendered to text as a pure function. + +## Public interface +```rust +/// Everything the report file shows, collected before rendering. +pub struct Report { + pub id: String, + pub captured_at_unix: u64, + pub tool_build: String, + pub dump: StateDump, + pub intercept: Option, + pub environment: Vec, + pub log_tail: Option>, +} + +/// One environment probe: a label plus its output or an error note. +pub struct Probe { + pub label: String, + pub result: Result, +} + +impl Report { + /// Render the report artifact. Pure: the same `Report` always + /// produces the same text. + pub fn render(&self) -> String; +} + +/// This node's mesh name (lexicographically first if several), or +/// `unknown` if the derivation assigns it none. +pub fn node_name(dump: &StateDump) -> String; + +/// Hash of the shared derived view: the "do these nodes agree?" +/// comparison key. +pub fn state_fingerprint(dump: &StateDump) -> String; + +/// Hash of the input endorsement bases; per-node change marker, not a +/// cross-node comparison key. +pub fn inputs_digest(input: &BTreeSet) -> String; +``` diff --git a/context/interfaces/src/proxy.md b/context/interfaces/src/proxy.md index bfc9815..1429854 100644 --- a/context/interfaces/src/proxy.md +++ b/context/interfaces/src/proxy.md @@ -18,4 +18,9 @@ impl Handle { /// Run the proxy/intercept service until cancelled. pub(crate) async fn run(self, cancel: CancellationToken) -> anyhow::Result<()>; } + +/// The listener addresses the proxy binds when traffic interception is +/// enabled. Expectations for the debug report, not confirmed-bound +/// addresses. +pub(crate) fn expected_listeners() -> Vec<(&'static str, SocketAddr)>; ``` diff --git a/src/cmd_report.rs b/src/cmd_report.rs new file mode 100644 index 0000000..4fe3391 --- /dev/null +++ b/src/cmd_report.rs @@ -0,0 +1,777 @@ +//! The `debug report` artifact: model and renderer. +//! +//! `Report` is an in-memory value holding everything the report file +//! shows. The CLI side does all impure work (admin fetch, shell-outs, +//! log tail) up front and fills the model; `render` turns it into the +//! artifact text as a pure function, which is where the feature is +//! tested. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; +use std::time::{Duration, UNIX_EPOCH}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::assert::UnwrapAssert; +use crate::endor; +use crate::gossip::GOSSIP_PORT; +use crate::proxy; +use crate::state_dump::StateDump; + +/// Everything the report file shows, collected before rendering. +#[derive(Debug, Clone)] +pub struct Report { + /// Unique, time-ordered report ID (ULID). + pub id: String, + /// Capture time (unix seconds). + pub captured_at_unix: u64, + /// Build hash of the CLI producing the report. + pub tool_build: String, + /// Trust snapshot from the daemon's admin socket. + pub dump: StateDump, + /// Interception state inferred from the environment (the intermesh + /// nftables table exists only while interception is active). `None` + /// means it could not be determined. + pub intercept: Option, + /// Environment probe results, rendered in order. + pub environment: Vec, + /// Bounded tail of the daemon's log file. `None` means no log file + /// is configured; `Some(Err(_))` means the read failed. + pub log_tail: Option>, +} + +/// One environment probe: a label plus its output or an error note. +#[derive(Debug, Clone)] +pub struct Probe { + pub label: String, + pub result: Result, +} + +impl Report { + /// Render the report artifact. Pure: the same `Report` always + /// produces the same text. + #[must_use] + pub fn render(&self) -> String { + let mut out = String::new(); + self.header(&mut out); + self.summary(&mut out); + self.identity(&mut out); + self.daemon_config(&mut out); + self.names(&mut out); + self.peers(&mut out); + self.routes_tunnels_listeners(&mut out); + self.recent_events(&mut out); + self.environment(&mut out); + while out.ends_with("\n\n") { + out.pop(); + } + out + } + + fn header(&self, out: &mut String) { + writeln!( + out, + "intermesh debug report — node {} — {}", + node_name(&self.dump), + rfc3339(self.captured_at_unix) + ) + .assert(); + let state = state_fingerprint(&self.dump); + let inputs = inputs_digest(&self.dump.derivation.input); + writeln!( + out, + "report id {} · state {state} · inputs {inputs}", + self.id + ) + .assert(); + writeln!( + out, + "tool build {} · daemon build {}", + self.tool_build, + build_or_unknown(&self.dump.daemon_build) + ) + .assert(); + out.push('\n'); + } + + fn summary(&self, out: &mut String) { + writeln!(out, "SUMMARY ✓ ok ⚠ attention ✗ problem").assert(); + writeln!( + out, + " ○ planned — no version of intermesh reports this yet" + ) + .assert(); + + // Daemon running. Reaching this code means the admin socket + // answered; the CLI errors out otherwise. + let mut detail = String::from("admin socket reachable"); + let started = self.dump.daemon_started_at_unix; + if started > 0 && self.captured_at_unix >= started { + let up = format_uptime(self.captured_at_unix - started); + write!(detail, ", up {up}").assert(); + } + write!( + detail, + ", build {}", + build_or_unknown(&self.dump.daemon_build) + ) + .assert(); + row(out, "✓", "daemon running", &detail); + + match self.dump.adhoc_membership.first() { + Some(m) => { + let role = if m.is_root { "root" } else { "a member" }; + let detail = format!( + "**.{}, root {}, this node is {role}", + m.mesh_domain, + short(&m.root_imid) + ); + row(out, "✓", "mesh joined", &detail); + } + None => row(out, "⚠", "mesh joined", "not joined to any mesh"), + } + + let names = self.dump.derivation.name_to_imid.len(); + let with_ips = self.names_with_ips(); + if names == 0 { + row(out, "⚠", "name resolution", "no names derived"); + } else { + let symbol = if with_ips == names { "✓" } else { "⚠" }; + let detail = format!("{names} names, {with_ips} with IPs"); + row(out, symbol, "name resolution", &detail); + } + + let peers = self.peer_rows().len(); + let detail = format!("{peers} known (reachability: planned)"); + row(out, "○", "peers", &detail); + + row(out, "○", "tunnels", "planned"); + + let (symbol, detail) = match self.intercept { + Some(true) => { + let mut s = format!("gossip :{GOSSIP_PORT}"); + for (name, addr) in proxy::expected_listeners() { + write!(s, ", {name} :{}", addr.port()).assert(); + } + s.push_str(" · intercept on"); + ("✓", s) + } + Some(false) => ("✓", format!("gossip :{GOSSIP_PORT} · intercept off")), + None => ("⚠", format!("gossip :{GOSSIP_PORT} · intercept unknown")), + }; + row(out, symbol, "listeners (expected)", &detail); + out.push('\n'); + } + + fn identity(&self, out: &mut String) { + let d = &self.dump.derivation; + let names = join_or_dash(d.imid_to_names.get(&d.my_imid).map(sorted)); + let ips = join_or_dash(d.imid_to_ip.get(&d.my_imid).map(sorted)); + writeln!(out, "IDENTITY").assert(); + writeln!( + out, + " me {names} {} {ips}", + short(&d.my_imid.to_string()) + ) + .assert(); + out.push('\n'); + } + + fn daemon_config(&self, out: &mut String) { + let socket = self.dump.admin_socket.as_deref().unwrap_or("(not set)"); + let log = self.dump.log_file.as_deref().unwrap_or("(not set)"); + writeln!(out, "DAEMON CONFIG").assert(); + writeln!(out, " admin socket {socket} · log file {log}").assert(); + out.push('\n'); + } + + fn names(&self, out: &mut String) { + let d = &self.dump.derivation; + writeln!(out, "NAMES → IDENTITIES → IPS").assert(); + if d.name_to_imid.is_empty() { + writeln!(out, " (none)").assert(); + } + let mut entries: Vec<_> = d.name_to_imid.iter().collect(); + entries.sort_by_key(|(name, _)| name.to_string()); + for (name, imids) in entries { + let imid_list = sorted(imids) + .iter() + .map(|i| short(i)) + .collect::>() + .join(", "); + let mut ips: BTreeSet = BTreeSet::new(); + for imid in imids { + if let Some(set) = d.imid_to_ip.get(imid) { + ips.extend(set.iter().map(ToString::to_string)); + } + } + let ips = join_or_dash(Some(ips.into_iter().collect())); + writeln!(out, " {name} → {imid_list} → {ips}").assert(); + } + out.push('\n'); + } + + fn peers(&self, out: &mut String) { + writeln!(out, "PEERS").assert(); + let rows = self.peer_rows(); + if rows.is_empty() { + writeln!(out, " (none known)").assert(); + } + for (imid, names, ips) in rows { + writeln!( + out, + " {} {names} {ips} last contact: planned", + short(&imid) + ) + .assert(); + } + out.push('\n'); + } + + fn routes_tunnels_listeners(&self, out: &mut String) { + writeln!(out, "ROUTES (name → VIP)").assert(); + writeln!(out, " planned").assert(); + out.push('\n'); + + writeln!(out, "TUNNELS (open)").assert(); + writeln!(out, " planned").assert(); + out.push('\n'); + + writeln!(out, "LISTENERS (expected)").assert(); + writeln!(out, " {:<8} 0.0.0.0:{GOSSIP_PORT}", "gossip").assert(); + if self.intercept == Some(true) { + for (name, addr) in proxy::expected_listeners() { + writeln!(out, " {name:<8} {addr}").assert(); + } + } + out.push('\n'); + } + + fn recent_events(&self, out: &mut String) { + writeln!(out, "RECENT EVENTS").assert(); + let path = self.dump.log_file.as_deref().unwrap_or("(unknown path)"); + match &self.log_tail { + None => { + writeln!(out, " event history: planned — no log file configured").assert(); + writeln!( + out, + " (start the daemon with --log-file to include a log tail today)" + ) + .assert(); + } + Some(Ok(tail)) => { + writeln!(out, " log tail: {path}").assert(); + for line in tail.lines() { + writeln!(out, " {line}").assert(); + } + } + Some(Err(e)) => { + writeln!(out, " log tail: {path} — unavailable: {e}").assert(); + } + } + out.push('\n'); + } + + fn environment(&self, out: &mut String) { + writeln!(out, "ENVIRONMENT").assert(); + if self.environment.is_empty() { + writeln!(out, " (none captured)").assert(); + } + for probe in &self.environment { + match &probe.result { + Ok(value) => writeln!(out, " {}: {value}", probe.label).assert(), + Err(e) => writeln!(out, " {}: unavailable — {e}", probe.label).assert(), + } + } + out.push('\n'); + } + + /// Names that resolve to at least one IP. + fn names_with_ips(&self) -> usize { + let d = &self.dump.derivation; + d.name_to_imid + .values() + .filter(|imids| { + imids + .iter() + .any(|i| d.imid_to_ip.get(i).is_some_and(|ips| !ips.is_empty())) + }) + .count() + } + + /// Known peers (every identity except self), sorted by IMID: + /// `(imid, names or "-", ips or "-")`. + fn peer_rows(&self) -> Vec<(String, String, String)> { + let d = &self.dump.derivation; + let me = d.my_imid.to_string(); + + let mut peers: BTreeMap, BTreeSet)> = BTreeMap::new(); + for (imid, names) in &d.imid_to_names { + let entry = peers.entry(imid.to_string()).or_default(); + entry.0.extend(names.iter().map(ToString::to_string)); + } + for (imid, ips) in &d.imid_to_ip { + let entry = peers.entry(imid.to_string()).or_default(); + entry.1.extend(ips.iter().map(ToString::to_string)); + } + peers.remove(&me); + + peers + .into_iter() + .map(|(imid, (names, ips))| { + ( + imid, + join_or_dash(Some(names.into_iter().collect())), + join_or_dash(Some(ips.into_iter().collect())), + ) + }) + .collect() + } +} + +/// This node's mesh name (lexicographically first if several), or +/// `unknown` if the derivation assigns it none. +#[must_use] +pub fn node_name(dump: &StateDump) -> String { + let d = &dump.derivation; + d.imid_to_names + .get(&d.my_imid) + .map(sorted) + .and_then(|names| names.into_iter().next()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Canonical, node-independent view of derived state used for +/// `state_fingerprint`. Node-local fields (`my_imid`, `iteration`, +/// `derived_at`, `my_constraints`, the `my_authz*` views, membership +/// `self_name` / `is_root`) are deliberately excluded so two nodes +/// that agree on the mesh produce the same fingerprint. +#[derive(Serialize)] +struct FingerprintView { + names: BTreeMap>, + imids: BTreeMap>, + ips: BTreeMap>, + mesh: BTreeSet<(String, String)>, +} + +/// Hash of the shared derived view: the "do these nodes agree?" +/// comparison key. Two nodes with the same derived mesh state produce +/// the same fingerprint. +#[must_use] +pub fn state_fingerprint(dump: &StateDump) -> String { + let d = &dump.derivation; + let view = FingerprintView { + names: canonical_map(d.name_to_imid.iter()), + imids: canonical_map(d.imid_to_names.iter()), + ips: canonical_map(d.imid_to_ip.iter()), + mesh: dump + .adhoc_membership + .iter() + .map(|m| (m.mesh_domain.clone(), m.root_imid.clone())) + .collect(), + }; + sha256_short(&serde_json::to_vec(&view).assert()) +} + +/// Hash of the input endorsement bases. Node-local only: input sets +/// legitimately differ across nodes (some endorsements are never +/// gossiped), so this is a per-node change marker, not a cross-node +/// comparison key. +#[must_use] +pub fn inputs_digest(input: &BTreeSet) -> String { + sha256_short(&serde_json::to_vec(input).assert()) +} + +fn canonical_map<'a, K, V>( + entries: impl Iterator)>, +) -> BTreeMap> +where + K: ToString + 'a, + V: ToString + 'a, +{ + entries + .map(|(k, vs)| (k.to_string(), vs.iter().map(ToString::to_string).collect())) + .collect() +} + +/// First 16 hex chars of the sha256, in `sha256:` form. Enough to +/// compare two reports; short enough to keep the header readable. +fn sha256_short(bytes: &[u8]) -> String { + let digest = hex::encode(Sha256::digest(bytes)); + format!("sha256:{}", &digest[..16]) +} + +fn rfc3339(unix: u64) -> String { + humantime::format_rfc3339_seconds(UNIX_EPOCH + Duration::from_secs(unix)).to_string() +} + +fn format_uptime(secs: u64) -> String { + let days = secs / 86_400; + let hours = secs / 3_600 % 24; + let minutes = secs / 60 % 60; + if days > 0 { + format!("{days}d{hours}h") + } else if hours > 0 { + format!("{hours}h{minutes}m") + } else if minutes > 0 { + format!("{minutes}m") + } else { + format!("{secs}s") + } +} + +fn short(s: &str) -> String { + match s.get(..16) { + Some(prefix) if s.len() > 16 => format!("{prefix}…"), + _ => s.to_string(), + } +} + +fn build_or_unknown(build: &str) -> &str { + if build.is_empty() { + "unknown" + } else { + build + } +} + +fn sorted(values: &BTreeSet) -> Vec { + let mut v: Vec = values.iter().map(ToString::to_string).collect(); + v.sort(); + v +} + +fn join_or_dash(values: Option>) -> String { + match values { + Some(v) if !v.is_empty() => v.join(", "), + _ => "-".to_string(), + } +} + +fn row(out: &mut String, symbol: &str, label: &str, detail: &str) { + writeln!(out, " {symbol} {label:<22}{detail}").assert(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::imid::ImidKeypair; + use crate::state_dump::AdhocMembershipDump; + use crate::test_utils::{TestConstraint, TestEndorsement, TestFixture}; + use crate::trust_engine::Derivation; + use std::net::IpAddr; + + /// A three-node dump: me (10.0.0.1), db (10.0.0.2), and web + /// (10.0.0.3 + `fd00::1`), joined to **.test.mesh under `root`. + fn test_dump() -> StateDump { + let me = ImidKeypair::test_keypair("me"); + let db = ImidKeypair::test_keypair("db"); + let web = ImidKeypair::test_keypair("web"); + let root = ImidKeypair::test_keypair("root"); + + let name_to_imid = BTreeMap::from([ + ( + "me.test.mesh".parse().assert(), + BTreeSet::from([me.to_imid()]), + ), + ( + "db.test.mesh".parse().assert(), + BTreeSet::from([db.to_imid()]), + ), + ( + "web.test.mesh".parse().assert(), + BTreeSet::from([web.to_imid()]), + ), + ]); + let imid_to_names = BTreeMap::from([ + ( + me.to_imid(), + BTreeSet::from(["me.test.mesh".parse().assert()]), + ), + ( + db.to_imid(), + BTreeSet::from(["db.test.mesh".parse().assert()]), + ), + ( + web.to_imid(), + BTreeSet::from(["web.test.mesh".parse().assert()]), + ), + ]); + let imid_to_ip = BTreeMap::from([ + ( + me.to_imid(), + BTreeSet::from(["10.0.0.1".parse::().assert()]), + ), + ( + db.to_imid(), + BTreeSet::from(["10.0.0.2".parse::().assert()]), + ), + ( + web.to_imid(), + BTreeSet::from([ + "10.0.0.3".parse::().assert(), + "fd00::1".parse::().assert(), + ]), + ), + ]); + + StateDump { + derivation: Derivation { + my_imid: me.to_imid(), + iteration: 3, + name_to_imid, + imid_to_names, + imid_to_ip, + my_authz_to: BTreeSet::new(), + my_authz_from: BTreeSet::new(), + my_constraints: BTreeSet::new(), + my_authz: BTreeSet::new(), + derived_at: 0, + input: BTreeSet::new(), + }, + adhoc_membership: vec![AdhocMembershipDump { + mesh_domain: "test.mesh".to_string(), + root_imid: root.to_imid().to_string(), + self_name: Some("me.test.mesh".to_string()), + is_root: false, + }], + admin_socket: Some("/run/intermesh/admin.sock".to_string()), + log_file: None, + daemon_build: "a1b2c3d".to_string(), + daemon_started_at_unix: 1_699_988_480, // 3h12m before capture + } + } + + #[test] + fn render_view() { + // Golden test: the full artifact for a healthy three-node mesh. + let dump = test_dump(); + let report = Report { + id: "01JR8Z9K3F0000000000000000".to_string(), + captured_at_unix: 1_700_000_000, + tool_build: "e5f6a7b".to_string(), + dump, + intercept: Some(true), + environment: vec![ + Probe { + label: "os".to_string(), + result: Ok("Linux 6.8.0-test x86_64".to_string()), + }, + Probe { + label: "nftables".to_string(), + result: Ok("table ip intermesh present (4 rules)".to_string()), + }, + Probe { + label: "resolver".to_string(), + result: Err("read /etc/resolv.conf: not found".to_string()), + }, + ], + log_tail: None, + }; + + let state = state_fingerprint(&report.dump); + let inputs = inputs_digest(&report.dump.derivation.input); + let expected = format!( + r"intermesh debug report — node me.test.mesh — 2023-11-14T22:13:20Z +report id 01JR8Z9K3F0000000000000000 · state {state} · inputs {inputs} +tool build e5f6a7b · daemon build a1b2c3d + +SUMMARY ✓ ok ⚠ attention ✗ problem + ○ planned — no version of intermesh reports this yet + ✓ daemon running admin socket reachable, up 3h12m, build a1b2c3d + ✓ mesh joined **.test.mesh, root A3L-mN23j3FJOvXZ…, this node is a member + ✓ name resolution 3 names, 3 with IPs + ○ peers 2 known (reachability: planned) + ○ tunnels planned + ✓ listeners (expected) gossip :9898, proxy :15001, dns :15053, external :9797 · intercept on + +IDENTITY + me me.test.mesh Aj1Lizc7Xdv-DjFv… 10.0.0.1 + +DAEMON CONFIG + admin socket /run/intermesh/admin.sock · log file (not set) + +NAMES → IDENTITIES → IPS + db.test.mesh → A1UqiwUlUpEHC0b_… → 10.0.0.2 + me.test.mesh → Aj1Lizc7Xdv-DjFv… → 10.0.0.1 + web.test.mesh → AoHQqnbGQu_8OBYX… → 10.0.0.3, fd00::1 + +PEERS + A1UqiwUlUpEHC0b_… db.test.mesh 10.0.0.2 last contact: planned + AoHQqnbGQu_8OBYX… web.test.mesh 10.0.0.3, fd00::1 last contact: planned + +ROUTES (name → VIP) + planned + +TUNNELS (open) + planned + +LISTENERS (expected) + gossip 0.0.0.0:9898 + proxy 127.0.0.1:15001 + dns 127.0.0.1:15053 + external 0.0.0.0:9797 + +RECENT EVENTS + event history: planned — no log file configured + (start the daemon with --log-file to include a log tail today) + +ENVIRONMENT + os: Linux 6.8.0-test x86_64 + nftables: table ip intermesh present (4 rules) + resolver: unavailable — read /etc/resolv.conf: not found +" + ); + + assert_eq!(report.render(), expected); + + // Same model, same text. + assert_eq!(report.render(), report.render()); + } + + #[test] + fn render_degraded_view() { + // Not joined, intercept off, older daemon, log tail present. + let mut dump = test_dump(); + dump.adhoc_membership.clear(); + dump.daemon_build = String::new(); + dump.daemon_started_at_unix = 0; + dump.log_file = Some("/var/log/intermesh.log".to_string()); + + let report = Report { + id: "01JR8Z9K3F0000000000000001".to_string(), + captured_at_unix: 1_700_000_000, + tool_build: "e5f6a7b".to_string(), + dump, + intercept: Some(false), + environment: vec![], + log_tail: Some(Ok("line one\nline two".to_string())), + }; + let out = report.render(); + + assert!(out.contains("daemon build unknown")); + assert!(out.contains("admin socket reachable, build unknown")); + assert!(!out.contains(", up ")); + assert!(out.contains("⚠ mesh joined")); + assert!(out.contains("not joined to any mesh")); + assert!(out.contains("gossip :9898 · intercept off")); + assert!(!out.contains("external")); + assert!(out.contains("log tail: /var/log/intermesh.log")); + assert!(out.contains("\n line one\n line two\n")); + assert!(out.contains("(none captured)")); + + // Unknown interception state is flagged, not guessed. + let unknown = Report { + intercept: None, + ..report + }; + assert!(unknown + .render() + .contains("⚠ listeners (expected) gossip :9898 · intercept unknown")); + } + + #[test] + fn fingerprint_ignores_node_local_fields() { + let mut fix = TestFixture::new(); + let dump_a = test_dump(); + + // Same shared view from another node's perspective: different + // self identity, iteration, derived_at, constraints, input, and + // membership role. + let mut dump_b = dump_a.clone(); + dump_b.derivation.my_imid = ImidKeypair::test_keypair("db").to_imid(); + dump_b.derivation.iteration = 99; + dump_b.derivation.derived_at = 123_456; + dump_b.derivation.my_constraints = BTreeSet::from([TestConstraint { + endorser_imids: "me", + any_target: true, + permitted_patterns: "**.test.mesh", + ..Default::default() + } + .build(&mut fix)]); + dump_b.derivation.input = BTreeSet::from([TestEndorsement { + endorser: "me", + target_imids: "db", + names: "db.test.mesh", + ips: "10.0.0.2", + ..Default::default() + } + .to_base(&mut fix)]); + dump_b.adhoc_membership[0].self_name = Some("db.test.mesh".to_string()); + dump_b.adhoc_membership[0].is_root = true; + dump_b.daemon_build = "fffffff".to_string(); + dump_b.daemon_started_at_unix = 42; + + assert_eq!(state_fingerprint(&dump_a), state_fingerprint(&dump_b)); + + // Changing the shared view changes the fingerprint. + let mut dump_c = dump_a.clone(); + let db = ImidKeypair::test_keypair("db").to_imid(); + dump_c + .derivation + .imid_to_ip + .get_mut(&db) + .assert() + .insert("10.9.9.9".parse().assert()); + assert_ne!(state_fingerprint(&dump_a), state_fingerprint(&dump_c)); + + // So does a different mesh root. + let mut dump_d = dump_a.clone(); + dump_d.adhoc_membership[0].root_imid = + ImidKeypair::test_keypair("other").to_imid().to_string(); + assert_ne!(state_fingerprint(&dump_a), state_fingerprint(&dump_d)); + + // Format: sha256: prefix plus 16 hex chars. + let fp = state_fingerprint(&dump_a); + assert!(fp.starts_with("sha256:")); + assert_eq!(fp.len(), "sha256:".len() + 16); + } + + #[test] + fn inputs_digest_is_order_independent() { + let mut fix = TestFixture::new(); + let b1 = TestEndorsement { + endorser: "me", + target_imids: "db", + names: "db.test.mesh", + ips: "10.0.0.2", + ..Default::default() + } + .to_base(&mut fix); + let b2 = TestEndorsement { + endorser: "me", + target_imids: "web", + names: "web.test.mesh", + ips: "10.0.0.3", + ..Default::default() + } + .to_base(&mut fix); + + // Insertion order does not affect the digest; contents do. + assert_eq!( + inputs_digest(&BTreeSet::from([b1.clone(), b2.clone()])), + inputs_digest(&BTreeSet::from([b2.clone(), b1.clone()])) + ); + assert_ne!( + inputs_digest(&BTreeSet::from([b1.clone()])), + inputs_digest(&BTreeSet::from([b1, b2])) + ); + } + + #[test] + fn node_name_falls_back_to_unknown() { + let mut dump = test_dump(); + assert_eq!(node_name(&dump), "me.test.mesh"); + dump.derivation.imid_to_names.clear(); + assert_eq!(node_name(&dump), "unknown"); + } + + #[test] + fn uptime_formatting() { + assert_eq!(format_uptime(45), "45s"); + assert_eq!(format_uptime(300), "5m"); + assert_eq!(format_uptime(11_520), "3h12m"); + assert_eq!(format_uptime(90_061), "1d1h"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5064770..33e6bf7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod admin; pub mod assert; mod authorization; pub mod cli; +pub mod cmd_report; mod cmd_status; mod connect; pub mod constraint; diff --git a/src/proxy.rs b/src/proxy.rs index 9a4950c..e1349ce 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -4,7 +4,7 @@ //! rules, and provides mTLS tunneling to remote peers. use std::convert::Infallible; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use anyhow::{Context, Result}; @@ -41,10 +41,22 @@ mod intercept; use dns::{create_resolver, Dns}; use intercept::{ - bind_dns, bind_tcp, get_original_dst, nftables_clean, nftables_inject, EXTERNAL_PORT, + bind_dns, bind_tcp, get_original_dst, nftables_clean, nftables_inject, DNS_PORT, EXTERNAL_PORT, PROXY_PORT, }; +/// The listener addresses the proxy binds when traffic interception is +/// enabled. These are expectations, not confirmed-bound addresses; the +/// debug report renders them as "expected". +#[must_use] +pub(crate) fn expected_listeners() -> Vec<(&'static str, SocketAddr)> { + vec![ + ("proxy", (Ipv4Addr::LOCALHOST, PROXY_PORT).into()), + ("dns", (Ipv4Addr::LOCALHOST, DNS_PORT).into()), + ("external", (Ipv4Addr::UNSPECIFIED, EXTERNAL_PORT).into()), + ] +} + // ============================================================================ // Proxy // diff --git a/src/proxy/intercept.rs b/src/proxy/intercept.rs index d0c8ec0..2da59a2 100644 --- a/src/proxy/intercept.rs +++ b/src/proxy/intercept.rs @@ -23,7 +23,7 @@ use crate::assert::UnwrapAssert; /// on all proxy sockets via `SO_MARK`. pub(crate) const PROXY_MARK: u32 = 0x539; -const DNS_PORT: u16 = 15053; +pub(super) const DNS_PORT: u16 = 15053; pub(super) const PROXY_PORT: u16 = 15001; pub(super) const EXTERNAL_PORT: u16 = 9797; From 0f35082bab583de41701c8bdd0bdc599f438d349 Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Fri, 3 Jul 2026 23:27:49 -0700 Subject: [PATCH 3/9] Add `intermesh debug report` command One command, one file: fetch the trust snapshot over the admin socket, probe the environment (uname, nftables, resolver), tail the daemon log if configured, and write a single shareable report. If the daemon is unreachable, exit with an error instead of writing a near-empty file; all other probes degrade into notes in the report. --- context/interfaces/src/cmd_report.md | 5 + src/cli.rs | 5 + src/cmd_report.rs | 218 ++++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 2 deletions(-) diff --git a/context/interfaces/src/cmd_report.md b/context/interfaces/src/cmd_report.md index a25189e..a30f712 100644 --- a/context/interfaces/src/cmd_report.md +++ b/context/interfaces/src/cmd_report.md @@ -40,4 +40,9 @@ pub fn state_fingerprint(dump: &StateDump) -> String; /// Hash of the input endorsement bases; per-node change marker, not a /// cross-node comparison key. pub fn inputs_digest(input: &BTreeSet) -> String; + +/// Collect and write a debug report file, printing its path. Errors if +/// the daemon's admin socket does not answer; all other probes degrade +/// into notes in the report. +pub async fn run(socket_path: Option) -> anyhow::Result<()>; ``` diff --git a/src/cli.rs b/src/cli.rs index 272b3a7..a34878f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,6 +7,7 @@ use dialoguer::console; use tokio::process::Command; use crate::admin::AdminClient; +use crate::cmd_report; use crate::cmd_status; use crate::daemon; use crate::dsl::parse_endorsements; @@ -106,6 +107,9 @@ enum DebugCommands { }, /// Sign and insert endorsements from DSL input (use "-" for stdin). Endorse { input: String }, + + /// Write a shareable debug report file and print its path + Report, } fn print_banner() { @@ -223,6 +227,7 @@ pub async fn run() -> Result<()> { DebugCommands::Endorse { input } => { cmd_debug_endorse(input, cli.admin_socket).await?; } + DebugCommands::Report => cmd_report::run(cli.admin_socket).await?, }, Some(Commands::Version) => println!("intermesh {GIT_COMMIT}"), None => Cli::command().print_help()?, diff --git a/src/cmd_report.rs b/src/cmd_report.rs index 4fe3391..36c3bb2 100644 --- a/src/cmd_report.rs +++ b/src/cmd_report.rs @@ -8,11 +8,18 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; -use std::time::{Duration, UNIX_EPOCH}; - +use std::fs; +use std::io::{Read, Seek, SeekFrom}; +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use rand::Rng; use serde::Serialize; use sha2::{Digest, Sha256}; +use crate::admin::AdminClient; use crate::assert::UnwrapAssert; use crate::endor; use crate::gossip::GOSSIP_PORT; @@ -453,6 +460,173 @@ fn row(out: &mut String, symbol: &str, label: &str, detail: &str) { writeln!(out, " {symbol} {label:<22}{detail}").assert(); } +// ============================================================================ +// Collection — the impure side. Everything below gathers data and fills +// the model; nothing below renders. +// ============================================================================ + +const LOG_TAIL_MAX_BYTES: i64 = 64 * 1024; +const LOG_TAIL_MAX_LINES: usize = 200; + +/// Run `debug report`: collect, render, write one file, print its path. +/// +/// The daemon is the report's primary data source: if the admin socket +/// does not answer we exit with an error rather than write a +/// near-empty file. Every other probe degrades into a note in the +/// report. +pub async fn run(socket_path: Option) -> Result<()> { + let dump = AdminClient::new(socket_path) + .debug_dump() + .await + .context("cannot collect a report without the daemon; is it running?")?; + + let captured_at_unix = unix_now().as_secs(); + let (environment, intercept) = collect_environment(); + let log_tail = collect_log_tail(dump.log_file.as_deref()); + + let report = Report { + id: new_report_id(), + captured_at_unix, + tool_build: env!("GIT_COMMIT").to_string(), + dump, + intercept, + environment, + log_tail, + }; + + let path = format!( + "intermesh-report-{}-{}.txt", + node_name(&report.dump), + report.id + ); + fs::write(&path, report.render()).with_context(|| format!("failed to write {path}"))?; + println!("{path}"); + Ok(()) +} + +/// Run the environment probes. The nftables probe doubles as the +/// interception detector: the daemon injects the `intermesh` table only +/// while interception is active. +fn collect_environment() -> (Vec, Option) { + let (nftables, intercept) = nftables_probe(); + let probes = vec![os_probe(), nftables, resolver_probe()]; + (probes, intercept) +} + +fn os_probe() -> Probe { + let result = match Command::new("uname").arg("-a").output() { + Ok(out) if out.status.success() => Ok(first_line(&out.stdout)), + Ok(out) => Err(format!("uname exited with {}", out.status)), + Err(e) => Err(format!("uname unavailable: {e}")), + }; + Probe { + label: "os".to_string(), + result, + } +} + +fn nftables_probe() -> (Probe, Option) { + let (result, intercept) = match Command::new("nft") + .args(["list", "table", "ip", "intermesh"]) + .output() + { + Ok(out) if out.status.success() => ( + Ok("table ip intermesh present (interception active)".to_string()), + Some(true), + ), + Ok(_) => ( + Ok("table ip intermesh absent (interception inactive)".to_string()), + Some(false), + ), + Err(e) => (Err(format!("nft unavailable: {e}")), None), + }; + let probe = Probe { + label: "nftables".to_string(), + result, + }; + (probe, intercept) +} + +fn resolver_probe() -> Probe { + let result = match fs::read_to_string("/etc/resolv.conf") { + Ok(conf) => { + let servers: Vec<&str> = conf + .lines() + .filter_map(|line| line.trim().strip_prefix("nameserver")) + .map(str::trim) + .collect(); + if servers.is_empty() { + Ok("no nameservers in /etc/resolv.conf".to_string()) + } else { + Ok(servers.join(", ")) + } + } + Err(e) => Err(format!("read /etc/resolv.conf: {e}")), + }; + Probe { + label: "resolver".to_string(), + result, + } +} + +/// Tail of the configured log file; `None` when none is configured. +fn collect_log_tail(path: Option<&str>) -> Option> { + Some(tail_file(path?)) +} + +/// Bounded tail of a log file: at most the last 64 KiB, then at most +/// the last 200 lines of that. +fn tail_file(path: &str) -> Result { + let mut file = fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?; + let len = i64::try_from(file.metadata().map_err(|e| e.to_string())?.len()).unwrap_or(i64::MAX); + if len > LOG_TAIL_MAX_BYTES { + file.seek(SeekFrom::End(-LOG_TAIL_MAX_BYTES)) + .map_err(|e| format!("seek {path}: {e}"))?; + } + let mut buf = Vec::new(); + file.read_to_end(&mut buf) + .map_err(|e| format!("read {path}: {e}"))?; + Ok(last_lines( + &String::from_utf8_lossy(&buf), + LOG_TAIL_MAX_LINES, + )) +} + +fn last_lines(text: &str, n: usize) -> String { + let lines: Vec<&str> = text.lines().collect(); + lines[lines.len().saturating_sub(n)..].join("\n") +} + +fn first_line(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes) + .lines() + .next() + .unwrap_or_default() + .trim() + .to_string() +} + +/// A ULID: 48 bits of unix-ms timestamp then 80 random bits, Crockford +/// base32. Time-ordered, so report filenames sort by capture time. +/// Hand-rolled from deps already in the tree rather than adding one. +fn new_report_id() -> String { + let ms = u64::try_from(unix_now().as_millis()).assert(); + let mut random = [0u8; 10]; + rand::rng().fill(&mut random); + report_id_at(ms, random) +} + +fn report_id_at(unix_ms: u64, random: [u8; 10]) -> String { + let mut bytes = [0u8; 16]; + bytes[..6].copy_from_slice(&unix_ms.to_be_bytes()[2..8]); + bytes[6..].copy_from_slice(&random); + base32::encode(base32::Alphabet::Crockford, &bytes) +} + +fn unix_now() -> Duration { + SystemTime::now().duration_since(UNIX_EPOCH).assert() +} + #[cfg(test)] mod tests { use super::*; @@ -774,4 +948,44 @@ ENVIRONMENT assert_eq!(format_uptime(11_520), "3h12m"); assert_eq!(format_uptime(90_061), "1d1h"); } + + #[test] + fn report_ids_are_sortable_ulids() { + // All-zero input encodes to the zero ULID. + assert_eq!(report_id_at(0, [0; 10]), "0".repeat(26)); + + // 26 chars, and lexicographic order follows capture time. + let earlier = report_id_at(1_700_000_000_000, [0xff; 10]); + let later = report_id_at(1_700_000_000_001, [0x00; 10]); + assert_eq!(earlier.len(), 26); + assert!(earlier < later); + + let id = new_report_id(); + assert_eq!(id.len(), 26); + } + + #[test] + fn log_tail_is_bounded() { + assert_eq!(last_lines("a\nb\nc", 2), "b\nc"); + assert_eq!(last_lines("a\nb\nc", 5), "a\nb\nc"); + assert_eq!(last_lines("", 5), ""); + + // Byte bound: only the trailing 64 KiB of a large file is read. + let dir = tempfile::tempdir().assert(); + let path = dir.path().join("daemon.log"); + let big_line = "x".repeat(1024); + let mut content = String::new(); + for i in 0..100 { + writeln!(content, "line {i} {big_line}").assert(); + } + std::fs::write(&path, &content).assert(); + + let tail = tail_file(path.to_str().assert()).assert(); + assert!(tail.len() <= usize::try_from(LOG_TAIL_MAX_BYTES).assert()); + assert!(tail.ends_with(&format!("line 99 {big_line}"))); + assert!(!tail.contains("line 10 ")); // before the byte window + + // Missing file degrades to an error note, not a panic. + assert!(tail_file("/nonexistent/daemon.log").is_err()); + } } From 5830fdf086449517adc8fb82adc9d6690959cbc3 Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Fri, 3 Jul 2026 23:39:52 -0700 Subject: [PATCH 4/9] Light code cleanup --- src/cmd_report.rs | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/cmd_report.rs b/src/cmd_report.rs index 36c3bb2..9f2bff7 100644 --- a/src/cmd_report.rs +++ b/src/cmd_report.rs @@ -478,10 +478,11 @@ pub async fn run(socket_path: Option) -> Result<()> { let dump = AdminClient::new(socket_path) .debug_dump() .await - .context("cannot collect a report without the daemon; is it running?")?; + .context("cannot collect a report without the daemon; check that it is running")?; let captured_at_unix = unix_now().as_secs(); - let (environment, intercept) = collect_environment(); + let (nftables, intercept) = nftables_probe(); + let environment = vec![os_probe(), nftables, resolver_probe()]; let log_tail = collect_log_tail(dump.log_file.as_deref()); let report = Report { @@ -504,18 +505,14 @@ pub async fn run(socket_path: Option) -> Result<()> { Ok(()) } -/// Run the environment probes. The nftables probe doubles as the -/// interception detector: the daemon injects the `intermesh` table only -/// while interception is active. -fn collect_environment() -> (Vec, Option) { - let (nftables, intercept) = nftables_probe(); - let probes = vec![os_probe(), nftables, resolver_probe()]; - (probes, intercept) -} - fn os_probe() -> Probe { let result = match Command::new("uname").arg("-a").output() { - Ok(out) if out.status.success() => Ok(first_line(&out.stdout)), + Ok(out) if out.status.success() => Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .next() + .unwrap_or_default() + .trim() + .to_string()), Ok(out) => Err(format!("uname exited with {}", out.status)), Err(e) => Err(format!("uname unavailable: {e}")), }; @@ -525,6 +522,8 @@ fn os_probe() -> Probe { } } +/// The nftables probe doubles as the interception detector: the daemon +/// injects the `intermesh` table only while interception is active. fn nftables_probe() -> (Probe, Option) { let (result, intercept) = match Command::new("nft") .args(["list", "table", "ip", "intermesh"]) @@ -597,15 +596,6 @@ fn last_lines(text: &str, n: usize) -> String { lines[lines.len().saturating_sub(n)..].join("\n") } -fn first_line(bytes: &[u8]) -> String { - String::from_utf8_lossy(bytes) - .lines() - .next() - .unwrap_or_default() - .trim() - .to_string() -} - /// A ULID: 48 bits of unix-ms timestamp then 80 random bits, Crockford /// base32. Time-ordered, so report filenames sort by capture time. /// Hand-rolled from deps already in the tree rather than adding one. @@ -978,7 +968,7 @@ ENVIRONMENT for i in 0..100 { writeln!(content, "line {i} {big_line}").assert(); } - std::fs::write(&path, &content).assert(); + fs::write(&path, &content).assert(); let tail = tail_file(path.to_str().assert()).assert(); assert!(tail.len() <= usize::try_from(LOG_TAIL_MAX_BYTES).assert()); From e8b33d783063aa1237628d33dbe99a0b86f4acdd Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Sun, 5 Jul 2026 14:39:36 -0700 Subject: [PATCH 5/9] Add network context to the debug report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture interface addresses and the routing table, and keep the nftables rules the report was already fetching instead of throwing them away. Most "wrong self-IP" and "peer unreachable" questions can now be answered by eyeballing the report: the IPs the host actually has sit right next to the ones the mesh believes. Probe output is clipped to the first 25 lines so the report stays a report — a "… (+N lines)" marker says what was left out. The full inventories can live in a future debug archive. --- src/cmd_report.rs | 111 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/src/cmd_report.rs b/src/cmd_report.rs index 9f2bff7..55e4d3d 100644 --- a/src/cmd_report.rs +++ b/src/cmd_report.rs @@ -287,7 +287,14 @@ impl Report { } for probe in &self.environment { match &probe.result { - Ok(value) => writeln!(out, " {}: {value}", probe.label).assert(), + Ok(value) => { + let mut lines = value.lines(); + let first = lines.next().unwrap_or_default(); + writeln!(out, " {}: {first}", probe.label).assert(); + for line in lines { + writeln!(out, " {line}").assert(); + } + } Err(e) => writeln!(out, " {}: unavailable — {e}", probe.label).assert(), } } @@ -467,6 +474,8 @@ fn row(out: &mut String, symbol: &str, label: &str, detail: &str) { const LOG_TAIL_MAX_BYTES: i64 = 64 * 1024; const LOG_TAIL_MAX_LINES: usize = 200; +const PROBE_MAX_LINES: usize = 25; +const PROBE_MAX_BYTES: usize = 4 * 1024; /// Run `debug report`: collect, render, write one file, print its path. /// @@ -482,7 +491,13 @@ pub async fn run(socket_path: Option) -> Result<()> { let captured_at_unix = unix_now().as_secs(); let (nftables, intercept) = nftables_probe(); - let environment = vec![os_probe(), nftables, resolver_probe()]; + let environment = vec![ + command_probe("os", "uname", &["-a"]), + command_probe("interfaces", "ip", &["-brief", "addr"]), + command_probe("routes", "ip", &["route"]), + nftables, + resolver_probe(), + ]; let log_tail = collect_log_tail(dump.log_file.as_deref()); let report = Report { @@ -505,34 +520,64 @@ pub async fn run(socket_path: Option) -> Result<()> { Ok(()) } -fn os_probe() -> Probe { - let result = match Command::new("uname").arg("-a").output() { - Ok(out) if out.status.success() => Ok(String::from_utf8_lossy(&out.stdout) - .lines() - .next() - .unwrap_or_default() - .trim() - .to_string()), - Ok(out) => Err(format!("uname exited with {}", out.status)), - Err(e) => Err(format!("uname unavailable: {e}")), +/// Run a command and capture its clipped stdout, degrading to an error +/// note. For probes with no output policy of their own. +fn command_probe(label: &str, cmd: &str, args: &[&str]) -> Probe { + let result = match Command::new(cmd).args(args).output() { + Ok(out) if out.status.success() => { + Ok(clip_probe(String::from_utf8_lossy(&out.stdout).trim())) + } + Ok(out) => Err(format!("{cmd} exited with {}", out.status)), + Err(e) => Err(format!("{cmd} unavailable: {e}")), }; Probe { - label: "os".to_string(), + label: label.to_string(), result, } } +/// Cap probe output at the first 25 lines / 4 KiB. The report wants +/// signal, not an inventory — fuller output belongs in a future debug +/// archive. First lines carry the signal here (default route, physical +/// interfaces), unlike the log tail where the newest lines do. +fn clip_probe(text: &str) -> String { + let total = text.lines().count(); + let mut out = String::new(); + for (i, line) in text.lines().enumerate() { + if i == PROBE_MAX_LINES || out.len() + line.len() > PROBE_MAX_BYTES { + if !out.is_empty() { + out.push('\n'); + } + write!(out, "… (+{} lines)", total - i).assert(); + return out; + } + if i > 0 { + out.push('\n'); + } + out.push_str(line); + } + out +} + /// The nftables probe doubles as the interception detector: the daemon /// injects the `intermesh` table only while interception is active. +/// When the table exists its rules are included, so a maintainer can +/// check the redirects the daemon actually installed. fn nftables_probe() -> (Probe, Option) { let (result, intercept) = match Command::new("nft") .args(["list", "table", "ip", "intermesh"]) .output() { - Ok(out) if out.status.success() => ( - Ok("table ip intermesh present (interception active)".to_string()), - Some(true), - ), + Ok(out) if out.status.success() => { + let rules = String::from_utf8_lossy(&out.stdout); + ( + Ok(format!( + "table ip intermesh present (interception active)\n{}", + clip_probe(rules.trim()) + )), + Some(true), + ) + } Ok(_) => ( Ok("table ip intermesh absent (interception inactive)".to_string()), Some(false), @@ -722,9 +767,16 @@ mod tests { label: "os".to_string(), result: Ok("Linux 6.8.0-test x86_64".to_string()), }, + Probe { + label: "interfaces".to_string(), + result: Ok("lo UNKNOWN 127.0.0.1/8\neth0 UP 10.2.0.7/24".to_string()), + }, Probe { label: "nftables".to_string(), - result: Ok("table ip intermesh present (4 rules)".to_string()), + result: Ok( + "table ip intermesh present (interception active)\ntable ip intermesh { chain output { … } }" + .to_string(), + ), }, Probe { label: "resolver".to_string(), @@ -783,7 +835,10 @@ RECENT EVENTS ENVIRONMENT os: Linux 6.8.0-test x86_64 - nftables: table ip intermesh present (4 rules) + interfaces: lo UNKNOWN 127.0.0.1/8 + eth0 UP 10.2.0.7/24 + nftables: table ip intermesh present (interception active) + table ip intermesh {{ chain output {{ … }} }} resolver: unavailable — read /etc/resolv.conf: not found " ); @@ -978,4 +1033,22 @@ ENVIRONMENT // Missing file degrades to an error note, not a panic. assert!(tail_file("/nonexistent/daemon.log").is_err()); } + + #[test] + fn probe_output_is_clipped() { + // Under the limits: unchanged. + assert_eq!(clip_probe("a\nb"), "a\nb"); + assert_eq!(clip_probe(""), ""); + + // Line cap: first 25 lines kept, the rest counted, not dumped. + let routes: Vec = (0..40).map(|i| format!("route {i}")).collect(); + let clipped = clip_probe(&routes.join("\n")); + assert!(clipped.starts_with("route 0\n")); + assert!(clipped.ends_with("route 24\n… (+15 lines)")); + assert_eq!(clipped.lines().count(), PROBE_MAX_LINES + 1); + + // Byte cap holds even with no newlines to count. + let giant = "x".repeat(10_000); + assert!(clip_probe(&giant).len() < PROBE_MAX_BYTES); + } } From caa1f7d4ea4a151f217ace15753c888ea58cdfe6 Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Tue, 21 Jul 2026 08:44:29 -0700 Subject: [PATCH 6/9] Make debug report easier to read Give the report a real header, clean up the summary legend, put environment info before the event log, and add a closing line so it's clear where the report ends. Also explain what the state and inputs hashes mean, both in the report itself and in --help. --- context/interfaces/src/cmd_report.md | 10 +- src/cli.rs | 8 ++ src/cmd_report.rs | 197 ++++++++++++++++++--------- 3 files changed, 153 insertions(+), 62 deletions(-) diff --git a/context/interfaces/src/cmd_report.md b/context/interfaces/src/cmd_report.md index a30f712..9be4ec7 100644 --- a/context/interfaces/src/cmd_report.md +++ b/context/interfaces/src/cmd_report.md @@ -14,7 +14,7 @@ pub struct Report { pub dump: StateDump, pub intercept: Option, pub environment: Vec, - pub log_tail: Option>, + pub log_tail: Option>, } /// One environment probe: a label plus its output or an error note. @@ -23,6 +23,14 @@ pub struct Probe { pub result: Result, } +/// A bounded tail of a log file, plus the facts needed to say honestly +/// whether older lines were dropped to fit the bound. +pub struct LogTail { + pub text: String, + pub window_lines: usize, + pub byte_clipped: bool, +} + impl Report { /// Render the report artifact. Pure: the same `Report` always /// produces the same text. diff --git a/src/cli.rs b/src/cli.rs index a34878f..50d84e2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -109,6 +109,14 @@ enum DebugCommands { Endorse { input: String }, /// Write a shareable debug report file and print its path + /// + /// The header carries two fingerprints for comparing captures. "state" + /// is a fingerprint of the shared mesh view (names, identities, IPs, and + /// mesh root); it excludes node-local fields, so match it across nodes + /// to confirm they agree on the mesh. "inputs" is a fingerprint of this + /// node's raw endorsement set; it is per-node by design and changes when + /// that node's inputs do, so compare it across two captures of the same + /// node to see whether anything changed. Report, } diff --git a/src/cmd_report.rs b/src/cmd_report.rs index 55e4d3d..d84c771 100644 --- a/src/cmd_report.rs +++ b/src/cmd_report.rs @@ -45,7 +45,7 @@ pub struct Report { pub environment: Vec, /// Bounded tail of the daemon's log file. `None` means no log file /// is configured; `Some(Err(_))` means the read failed. - pub log_tail: Option>, + pub log_tail: Option>, } /// One environment probe: a label plus its output or an error note. @@ -55,6 +55,20 @@ pub struct Probe { pub result: Result, } +/// A bounded tail of a log file, plus the facts needed to say honestly +/// whether older lines were dropped to fit the bound. +#[derive(Debug, Clone)] +pub struct LogTail { + /// The lines shown, newest last (at most `LOG_TAIL_MAX_LINES`). + pub text: String, + /// Lines present in the byte window we examined. If this exceeds the + /// lines shown, the oldest were dropped by the line cap. + pub window_lines: usize, + /// True if the file was larger than `LOG_TAIL_MAX_BYTES`, so history + /// before the byte window exists but was never read. + pub byte_clipped: bool, +} + impl Report { /// Render the report artifact. Pure: the same `Report` always /// produces the same text. @@ -68,8 +82,11 @@ impl Report { self.names(&mut out); self.peers(&mut out); self.routes_tunnels_listeners(&mut out); - self.recent_events(&mut out); self.environment(&mut out); + // The log tail is the one section that can run to hundreds of + // lines, so it sinks below the compact structured sections. + self.recent_events(&mut out); + self.footer(&mut out); while out.ends_with("\n\n") { out.pop(); } @@ -77,38 +94,47 @@ impl Report { } fn header(&self, out: &mut String) { - writeln!( - out, - "intermesh debug report — node {} — {}", - node_name(&self.dump), - rfc3339(self.captured_at_unix) - ) - .assert(); - let state = state_fingerprint(&self.dump); - let inputs = inputs_digest(&self.dump.derivation.input); - writeln!( - out, - "report id {} · state {state} · inputs {inputs}", - self.id - ) - .assert(); - writeln!( - out, - "tool build {} · daemon build {}", - self.tool_build, - build_or_unknown(&self.dump.daemon_build) - ) - .assert(); + let title = "intermesh debug report"; + writeln!(out, "{title}").assert(); + writeln!(out, "{}", "═".repeat(title.chars().count())).assert(); + + let fields = [ + ("node", node_name(&self.dump), ""), + ("captured", rfc3339(self.captured_at_unix), ""), + ("report id", self.id.clone(), ""), + ( + "state", + state_fingerprint(&self.dump), + "(derived mesh view — agreeing nodes share this hash)", + ), + ( + "inputs", + inputs_digest(&self.dump.derivation.input), + "(this node's endorsements — per-node; new hash = inputs changed)", + ), + ("tool build", self.tool_build.clone(), ""), + ( + "daemon build", + build_or_unknown(&self.dump.daemon_build).to_string(), + "", + ), + ]; + for (label, value, note) in fields { + let label = format!("{label}:"); + let line = format!(" {label:<13} {value} {note}"); + writeln!(out, "{}", line.trim_end()).assert(); + } out.push('\n'); } fn summary(&self, out: &mut String) { - writeln!(out, "SUMMARY ✓ ok ⚠ attention ✗ problem").assert(); + writeln!(out, "SUMMARY").assert(); writeln!( out, - " ○ planned — no version of intermesh reports this yet" + " legend: ✓ ok ⚠ attention ✗ problem ○ planned (not yet built in any release)" ) .assert(); + out.push('\n'); // Daemon running. Reaching this code means the admin socket // answered; the CLI errors out otherwise. @@ -150,10 +176,10 @@ impl Report { } let peers = self.peer_rows().len(); - let detail = format!("{peers} known (reachability: planned)"); + let detail = format!("{peers} known · reachability (planned — future release)"); row(out, "○", "peers", &detail); - row(out, "○", "tunnels", "planned"); + row(out, "○", "tunnels", "(planned — future release)"); let (symbol, detail) = match self.intercept { Some(true) => { @@ -228,7 +254,7 @@ impl Report { for (imid, names, ips) in rows { writeln!( out, - " {} {names} {ips} last contact: planned", + " {} {names} {ips} last contact: (planned — future release)", short(&imid) ) .assert(); @@ -238,11 +264,11 @@ impl Report { fn routes_tunnels_listeners(&self, out: &mut String) { writeln!(out, "ROUTES (name → VIP)").assert(); - writeln!(out, " planned").assert(); + writeln!(out, " (planned — future release)").assert(); out.push('\n'); writeln!(out, "TUNNELS (open)").assert(); - writeln!(out, " planned").assert(); + writeln!(out, " (planned — future release)").assert(); out.push('\n'); writeln!(out, "LISTENERS (expected)").assert(); @@ -260,7 +286,11 @@ impl Report { let path = self.dump.log_file.as_deref().unwrap_or("(unknown path)"); match &self.log_tail { None => { - writeln!(out, " event history: planned — no log file configured").assert(); + writeln!( + out, + " event history: (planned — future release), no log file configured" + ) + .assert(); writeln!( out, " (start the daemon with --log-file to include a log tail today)" @@ -268,8 +298,25 @@ impl Report { .assert(); } Some(Ok(tail)) => { - writeln!(out, " log tail: {path}").assert(); - for line in tail.lines() { + let shown = tail.text.lines().count(); + if tail.byte_clipped { + writeln!( + out, + " log tail: {path} · last {shown} lines \ + (log exceeds 64 KiB, older lines omitted)" + ) + .assert(); + } else if tail.window_lines > shown { + writeln!( + out, + " log tail: {path} · last {shown} of {} lines", + tail.window_lines + ) + .assert(); + } else { + writeln!(out, " log tail: {path}").assert(); + } + for line in tail.text.lines() { writeln!(out, " {line}").assert(); } } @@ -301,6 +348,12 @@ impl Report { out.push('\n'); } + fn footer(&self, out: &mut String) { + let line = format!("end of report · {}", self.id); + writeln!(out, "{}", "═".repeat(line.chars().count())).assert(); + writeln!(out, "{line}").assert(); + } + /// Names that resolve to at least one IP. fn names_with_ips(&self) -> usize { let d = &self.dump.derivation; @@ -614,26 +667,31 @@ fn resolver_probe() -> Probe { } /// Tail of the configured log file; `None` when none is configured. -fn collect_log_tail(path: Option<&str>) -> Option> { +fn collect_log_tail(path: Option<&str>) -> Option> { Some(tail_file(path?)) } /// Bounded tail of a log file: at most the last 64 KiB, then at most /// the last 200 lines of that. -fn tail_file(path: &str) -> Result { +fn tail_file(path: &str) -> Result { let mut file = fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?; let len = i64::try_from(file.metadata().map_err(|e| e.to_string())?.len()).unwrap_or(i64::MAX); - if len > LOG_TAIL_MAX_BYTES { + let byte_clipped = len > LOG_TAIL_MAX_BYTES; + if byte_clipped { file.seek(SeekFrom::End(-LOG_TAIL_MAX_BYTES)) .map_err(|e| format!("seek {path}: {e}"))?; } let mut buf = Vec::new(); file.read_to_end(&mut buf) .map_err(|e| format!("read {path}: {e}"))?; - Ok(last_lines( - &String::from_utf8_lossy(&buf), - LOG_TAIL_MAX_LINES, - )) + let content = String::from_utf8_lossy(&buf); + let window_lines = content.lines().count(); + let text = last_lines(&content, LOG_TAIL_MAX_LINES); + Ok(LogTail { + text, + window_lines, + byte_clipped, + }) } fn last_lines(text: &str, n: usize) -> String { @@ -788,18 +846,27 @@ mod tests { let state = state_fingerprint(&report.dump); let inputs = inputs_digest(&report.dump.derivation.input); + let footer = format!("end of report · {}", report.id); + let footer_rule = "═".repeat(footer.chars().count()); let expected = format!( - r"intermesh debug report — node me.test.mesh — 2023-11-14T22:13:20Z -report id 01JR8Z9K3F0000000000000000 · state {state} · inputs {inputs} -tool build e5f6a7b · daemon build a1b2c3d + r"intermesh debug report +══════════════════════ + node: me.test.mesh + captured: 2023-11-14T22:13:20Z + report id: 01JR8Z9K3F0000000000000000 + state: {state} (derived mesh view — agreeing nodes share this hash) + inputs: {inputs} (this node's endorsements — per-node; new hash = inputs changed) + tool build: e5f6a7b + daemon build: a1b2c3d + +SUMMARY + legend: ✓ ok ⚠ attention ✗ problem ○ planned (not yet built in any release) -SUMMARY ✓ ok ⚠ attention ✗ problem - ○ planned — no version of intermesh reports this yet ✓ daemon running admin socket reachable, up 3h12m, build a1b2c3d ✓ mesh joined **.test.mesh, root A3L-mN23j3FJOvXZ…, this node is a member ✓ name resolution 3 names, 3 with IPs - ○ peers 2 known (reachability: planned) - ○ tunnels planned + ○ peers 2 known · reachability (planned — future release) + ○ tunnels (planned — future release) ✓ listeners (expected) gossip :9898, proxy :15001, dns :15053, external :9797 · intercept on IDENTITY @@ -814,14 +881,14 @@ NAMES → IDENTITIES → IPS web.test.mesh → AoHQqnbGQu_8OBYX… → 10.0.0.3, fd00::1 PEERS - A1UqiwUlUpEHC0b_… db.test.mesh 10.0.0.2 last contact: planned - AoHQqnbGQu_8OBYX… web.test.mesh 10.0.0.3, fd00::1 last contact: planned + A1UqiwUlUpEHC0b_… db.test.mesh 10.0.0.2 last contact: (planned — future release) + AoHQqnbGQu_8OBYX… web.test.mesh 10.0.0.3, fd00::1 last contact: (planned — future release) ROUTES (name → VIP) - planned + (planned — future release) TUNNELS (open) - planned + (planned — future release) LISTENERS (expected) gossip 0.0.0.0:9898 @@ -829,10 +896,6 @@ LISTENERS (expected) dns 127.0.0.1:15053 external 0.0.0.0:9797 -RECENT EVENTS - event history: planned — no log file configured - (start the daemon with --log-file to include a log tail today) - ENVIRONMENT os: Linux 6.8.0-test x86_64 interfaces: lo UNKNOWN 127.0.0.1/8 @@ -840,6 +903,13 @@ ENVIRONMENT nftables: table ip intermesh present (interception active) table ip intermesh {{ chain output {{ … }} }} resolver: unavailable — read /etc/resolv.conf: not found + +RECENT EVENTS + event history: (planned — future release), no log file configured + (start the daemon with --log-file to include a log tail today) + +{footer_rule} +{footer} " ); @@ -865,11 +935,15 @@ ENVIRONMENT dump, intercept: Some(false), environment: vec![], - log_tail: Some(Ok("line one\nline two".to_string())), + log_tail: Some(Ok(LogTail { + text: "line one\nline two".to_string(), + window_lines: 2, + byte_clipped: false, + })), }; let out = report.render(); - assert!(out.contains("daemon build unknown")); + assert!(out.contains("daemon build: unknown")); assert!(out.contains("admin socket reachable, build unknown")); assert!(!out.contains(", up ")); assert!(out.contains("⚠ mesh joined")); @@ -1026,9 +1100,10 @@ ENVIRONMENT fs::write(&path, &content).assert(); let tail = tail_file(path.to_str().assert()).assert(); - assert!(tail.len() <= usize::try_from(LOG_TAIL_MAX_BYTES).assert()); - assert!(tail.ends_with(&format!("line 99 {big_line}"))); - assert!(!tail.contains("line 10 ")); // before the byte window + assert!(tail.text.len() <= usize::try_from(LOG_TAIL_MAX_BYTES).assert()); + assert!(tail.text.ends_with(&format!("line 99 {big_line}"))); + assert!(!tail.text.contains("line 10 ")); // before the byte window + assert!(tail.byte_clipped); // 100 KiB of lines exceeds the 64 KiB window // Missing file degrades to an error note, not a panic. assert!(tail_file("/nonexistent/daemon.log").is_err()); From f96f2e0fb2d6953f437146311becb406f89972ea Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Tue, 21 Jul 2026 17:12:05 -0700 Subject: [PATCH 7/9] report: format digest prefix with std, drop hex dep sha256_short only renders the first 8 bytes, so a u64 round-trip through {:016x} produces the same string without pulling hex back into the tree (upstream had already removed it). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 - Cargo.toml | 1 - src/cmd_report.rs | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fed56f6..d780dc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1516,7 +1516,6 @@ dependencies = [ "dialoguer", "dirs", "futures", - "hex", "hickory-proto", "hickory-resolver", "hickory-server", diff --git a/Cargo.toml b/Cargo.toml index 7f39a2c..922d0d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,6 @@ inquire = "0.9.1" ipnet = { version = "2.10.1", features = ["serde"] } itertools = "0.14.0" libc = "0.2" -hex = "0.4" local-ip-address = "0.6.5" prost = "0.14.1" rand = "0.9" diff --git a/src/cmd_report.rs b/src/cmd_report.rs index d84c771..ddb5e35 100644 --- a/src/cmd_report.rs +++ b/src/cmd_report.rs @@ -465,8 +465,8 @@ where /// First 16 hex chars of the sha256, in `sha256:` form. Enough to /// compare two reports; short enough to keep the header readable. fn sha256_short(bytes: &[u8]) -> String { - let digest = hex::encode(Sha256::digest(bytes)); - format!("sha256:{}", &digest[..16]) + let first8: [u8; 8] = Sha256::digest(bytes)[..8].try_into().assert(); + format!("sha256:{:016x}", u64::from_be_bytes(first8)) } fn rfc3339(unix: u64) -> String { From 77104794c587b367eab149186fef86550dd18255 Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Tue, 21 Jul 2026 18:40:27 -0700 Subject: [PATCH 8/9] report: clarify RECENT EVENTS when there's nothing to show Removed language that felt overly verbose or confusing. --- src/cmd_report.rs | 52 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/src/cmd_report.rs b/src/cmd_report.rs index ddb5e35..86b71b5 100644 --- a/src/cmd_report.rs +++ b/src/cmd_report.rs @@ -286,16 +286,10 @@ impl Report { let path = self.dump.log_file.as_deref().unwrap_or("(unknown path)"); match &self.log_tail { None => { - writeln!( - out, - " event history: (planned — future release), no log file configured" - ) - .assert(); - writeln!( - out, - " (start the daemon with --log-file to include a log tail today)" - ) - .assert(); + writeln!(out, " (no log output recorded — no log file configured)").assert(); + } + Some(Ok(tail)) if tail.text.is_empty() => { + writeln!(out, " (no log output recorded — {path} is empty)").assert(); } Some(Ok(tail)) => { let shown = tail.text.lines().count(); @@ -905,8 +899,7 @@ ENVIRONMENT resolver: unavailable — read /etc/resolv.conf: not found RECENT EVENTS - event history: (planned — future release), no log file configured - (start the daemon with --log-file to include a log tail today) + (no log output recorded — no log file configured) {footer_rule} {footer} @@ -964,6 +957,41 @@ RECENT EVENTS .contains("⚠ listeners (expected) gossip :9898 · intercept unknown")); } + #[test] + fn recent_events_distinguishes_unconfigured_from_empty() { + // No log file at all: says so, doesn't imply a missing feature. + let mut dump = test_dump(); + dump.log_file = None; + let report = Report { + id: "01JR8Z9K3F0000000000000002".to_string(), + captured_at_unix: 1_700_000_000, + tool_build: "e5f6a7b".to_string(), + dump, + intercept: Some(false), + environment: vec![], + log_tail: None, + }; + assert!(report + .render() + .contains("(no log output recorded — no log file configured)")); + + // Log file configured, but nothing has been written to it yet. + let mut dump = test_dump(); + dump.log_file = Some("/var/log/intermesh.log".to_string()); + let empty_log = Report { + log_tail: Some(Ok(LogTail { + text: String::new(), + window_lines: 0, + byte_clipped: false, + })), + dump, + ..report + }; + assert!(empty_log + .render() + .contains("(no log output recorded — /var/log/intermesh.log is empty)")); + } + #[test] fn fingerprint_ignores_node_local_fields() { let mut fix = TestFixture::new(); From 6ae79b67f8e82d0fc785345d458738ae9ce25a46 Mon Sep 17 00:00:00 2001 From: pcnofelt Date: Wed, 22 Jul 2026 01:11:08 -0700 Subject: [PATCH 9/9] report: drop redundant half of empty-log test --- src/cmd_report.rs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/cmd_report.rs b/src/cmd_report.rs index 86b71b5..1318406 100644 --- a/src/cmd_report.rs +++ b/src/cmd_report.rs @@ -958,10 +958,12 @@ RECENT EVENTS } #[test] - fn recent_events_distinguishes_unconfigured_from_empty() { - // No log file at all: says so, doesn't imply a missing feature. + fn recent_events_notes_configured_but_empty_log() { + // A configured log file with no output yet is called out as + // empty — distinct from the unconfigured case pinned by the + // `render_view` golden test. let mut dump = test_dump(); - dump.log_file = None; + dump.log_file = Some("/var/log/intermesh.log".to_string()); let report = Report { id: "01JR8Z9K3F0000000000000002".to_string(), captured_at_unix: 1_700_000_000, @@ -969,25 +971,13 @@ RECENT EVENTS dump, intercept: Some(false), environment: vec![], - log_tail: None, - }; - assert!(report - .render() - .contains("(no log output recorded — no log file configured)")); - - // Log file configured, but nothing has been written to it yet. - let mut dump = test_dump(); - dump.log_file = Some("/var/log/intermesh.log".to_string()); - let empty_log = Report { log_tail: Some(Ok(LogTail { text: String::new(), window_lines: 0, byte_clipped: false, })), - dump, - ..report }; - assert!(empty_log + assert!(report .render() .contains("(no log output recorded — /var/log/intermesh.log is empty)")); }