diff --git a/Cargo.lock b/Cargo.lock index 80a5067..d780dc7 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" @@ -1516,6 +1522,7 @@ dependencies = [ "hostname", "http", "http-body-util", + "humantime", "hyper", "hyper-util", "inquire", diff --git a/Cargo.toml b/Cargo.toml index ffe7a9a..922d0d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,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..9be4ec7 --- /dev/null +++ b/context/interfaces/src/cmd_report.md @@ -0,0 +1,56 @@ +# `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, +} + +/// 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. + 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; + +/// 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/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/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/cli.rs b/src/cli.rs index 272b3a7..50d84e2 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,17 @@ 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 + /// + /// 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, } fn print_banner() { @@ -223,6 +235,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 new file mode 100644 index 0000000..1318406 --- /dev/null +++ b/src/cmd_report.rs @@ -0,0 +1,1147 @@ +//! 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::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; +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, +} + +/// 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. + #[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.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(); + } + out + } + + fn header(&self, out: &mut String) { + 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").assert(); + writeln!( + out, + " 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. + 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 — future release)"); + row(out, "○", "peers", &detail); + + row(out, "○", "tunnels", "(planned — future release)"); + + 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 — future release)", + short(&imid) + ) + .assert(); + } + out.push('\n'); + } + + fn routes_tunnels_listeners(&self, out: &mut String) { + writeln!(out, "ROUTES (name → VIP)").assert(); + writeln!(out, " (planned — future release)").assert(); + out.push('\n'); + + writeln!(out, "TUNNELS (open)").assert(); + writeln!(out, " (planned — future release)").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, " (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(); + 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(); + } + } + 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) => { + 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(), + } + } + 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; + 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 first8: [u8; 8] = Sha256::digest(bytes)[..8].try_into().assert(); + format!("sha256:{:016x}", u64::from_be_bytes(first8)) +} + +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(); +} + +// ============================================================================ +// 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; +const PROBE_MAX_LINES: usize = 25; +const PROBE_MAX_BYTES: usize = 4 * 1024; + +/// 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; check that it is running")?; + + let captured_at_unix = unix_now().as_secs(); + let (nftables, intercept) = nftables_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 { + 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 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: 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() => { + 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), + ), + 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); + 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}"))?; + 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 { + let lines: Vec<&str> = text.lines().collect(); + lines[lines.len().saturating_sub(n)..].join("\n") +} + +/// 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::*; + 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: "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 (interception active)\ntable ip intermesh { chain output { … } }" + .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 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 + 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) + + ✓ 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 — future release) + ○ tunnels (planned — future release) + ✓ 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 — future release) + AoHQqnbGQu_8OBYX… web.test.mesh 10.0.0.3, fd00::1 last contact: (planned — future release) + +ROUTES (name → VIP) + (planned — future release) + +TUNNELS (open) + (planned — future release) + +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 + +ENVIRONMENT + os: Linux 6.8.0-test x86_64 + 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 + +RECENT EVENTS + (no log output recorded — no log file configured) + +{footer_rule} +{footer} +" + ); + + 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(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("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 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 = Some("/var/log/intermesh.log".to_string()); + 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: Some(Ok(LogTail { + text: String::new(), + window_lines: 0, + byte_clipped: false, + })), + }; + assert!(report + .render() + .contains("(no log output recorded — /var/log/intermesh.log is empty)")); + } + + #[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"); + } + + #[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(); + } + fs::write(&path, &content).assert(); + + let tail = tail_file(path.to_str().assert()).assert(); + 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()); + } + + #[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); + } +} 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; 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();