From 2d342a4e4783be0b78065107b603014d203294b3 Mon Sep 17 00:00:00 2001 From: Tim O'Shea Date: Mon, 13 Apr 2026 14:33:06 +0000 Subject: [PATCH] Add diagnostic queries for security validation and mesh debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the fipsctl control query interface with visibility into internal state critical for protocol security auditing, mesh troubleshooting, and operational monitoring. New command: fipsctl show identity-cache Lists every node identity cached by the daemon (learned from DNS resolution, peer handshakes, sessions, and static config). Shows npub, IPv6 address, display name, and LRU age alongside the configured cache capacity. Extended queries: show peers — Noise session counters (send_counter, highest received counter) for rekey urgency assessment. Per-peer replay suppression and consecutive decrypt failure counts for active attack detection. Session index visibility for hijack analysis. Rekey lifecycle state (in_progress, draining, K-bit epoch). show sessions — Handshake resend count during establishment for connectivity debugging. Rekey and session health fields (session_start, K-bit, coords warmup, drain state) when established. show cache — Individual coordinate cache entries with tree coordinates, depth, path MTU, and age. Enables route-level debugging by showing exactly which destinations have cached routes and via what tree path. Renames the top-level count field from "entries" to "count" for clarity. show routing — Pending discovery lookups expanded from count to per-target detail (attempt number, age, last sent). Pending TUN packet queue depth for backpressure visibility. Connection retry state per peer (retry count, next attempt, auto-reconnect flag). Updates fipstop to match the revised show_cache and show_routing response schemas. Updates README monitoring section with the complete fipsctl command list. Co-authored-by: Johnathan Corgan --- CHANGELOG.md | 30 +++++++ README.md | 19 +++-- src/bin/fipsctl.rs | 3 + src/bin/fipstop/ui/routing.rs | 8 +- src/cache/coord_cache.rs | 7 ++ src/control/queries.rs | 144 +++++++++++++++++++++++++++++++-- src/node/handlers/discovery.rs | 2 +- src/node/mod.rs | 39 +++++++++ src/peer/active.rs | 5 ++ 9 files changed, 242 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f422d..dc1e137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 loads config before initializing tracing so the configured level takes effect; `RUST_LOG` still overrides when set +#### Operator Tooling + +- `fipsctl show identity-cache` lists every cached node identity + (npub, IPv6 address, display name, LRU age) alongside the + configured cache capacity +- `fipsctl show peers` extended with per-peer security signals + (replay suppression count, consecutive decrypt failures), Noise + session counters, session indices, and rekey lifecycle state +- `fipsctl show sessions` extended with handshake resend count + during establishment and rekey/session health fields when + established (session start, K-bit epoch, coords warmup remaining, + drain state) +- `fipsctl show cache` now includes individual coordinate cache + entries (tree coordinates, depth, path MTU, age). The top-level + count field was renamed from `entries` to `count` for clarity +- `fipsctl show routing` expands `pending_lookups` from a count to + per-target detail (attempt, age, last sent), adds pending TUN + packet queue depth, and adds per-peer connection retry state + ([#42](https://github.com/jmcorgan/fips/pull/42), + [@osh](https://github.com/osh)) + #### Documentation - Pre-implementation proposal for NAT traversal using Nostr relays @@ -108,6 +129,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shutdown, retry scheduling). Info output now focuses on operator-relevant state changes: lifecycle events, peer promotions, session establishment, parent switches, transport start/stop +- **Breaking (control socket JSON):** `show_cache` response field + `entries` has changed type from a `u64` count to an array of entry + objects; a new `count` field carries the previous scalar value. + `show_routing` response field `pending_lookups` has changed type + from a `u64` count to an array of per-target lookup objects. + External consumers parsing these fields as numbers must be + updated. In-tree `fipstop` is adjusted to the new schema. The + control socket interface is still pre-1.0 and not covered by + stability guarantees ### Fixed diff --git a/README.md b/README.md index 8cf6cc8..0a57c17 100644 --- a/README.md +++ b/README.md @@ -299,13 +299,18 @@ ssh -6 npub1bbb....fips Use `fipsctl` to query a running node: ```bash -fipsctl show status # Node status overview -fipsctl show peers # Authenticated peers -fipsctl show links # Active links -fipsctl show tree # Spanning tree state -fipsctl show sessions # End-to-end sessions -fipsctl show transports # Transport instances -fipsctl show routing # Routing table summary +fipsctl show status # Node status overview +fipsctl show peers # Authenticated peers and security state +fipsctl show links # Active links +fipsctl show tree # Spanning tree state +fipsctl show sessions # End-to-end sessions and rekey health +fipsctl show bloom # Bloom filter state +fipsctl show mmp # MMP metrics summary +fipsctl show cache # Coordinate cache entries and routes +fipsctl show connections # Pending handshake connections +fipsctl show transports # Transport instances +fipsctl show routing # Routing, discovery, and retry state +fipsctl show identity-cache # Known node identities (npubs) ``` `fipstop` provides an interactive TUI dashboard with live-updating diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index fb28146..799d336 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -92,6 +92,8 @@ enum ShowCommands { Transports, /// Routing table summary Routing, + /// Identity cache entries (known node pubkeys) + IdentityCache, } impl ShowCommands { @@ -108,6 +110,7 @@ impl ShowCommands { ShowCommands::Connections => "show_connections", ShowCommands::Transports => "show_transports", ShowCommands::Routing => "show_routing", + ShowCommands::IdentityCache => "show_identity_cache", } } } diff --git a/src/bin/fipstop/ui/routing.rs b/src/bin/fipstop/ui/routing.rs index 4eabc30..9547203 100644 --- a/src/bin/fipstop/ui/routing.rs +++ b/src/bin/fipstop/ui/routing.rs @@ -43,7 +43,11 @@ fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { ), helpers::kv_line( "Pending Lookups", - &helpers::u64_field(data, "pending_lookups"), + &data + .get("pending_lookups") + .and_then(|v| v.as_array()) + .map(|a| a.len().to_string()) + .unwrap_or_else(|| "0".into()), ), helpers::kv_line( "Recent Requests", @@ -261,7 +265,7 @@ fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) { } }; - let entries = helpers::u64_field(data, "entries"); + let entries = helpers::u64_field(data, "count"); let max_entries = helpers::u64_field(data, "max_entries"); let fill_pct = data .get("fill_ratio") diff --git a/src/cache/coord_cache.rs b/src/cache/coord_cache.rs index 31f641d..84fda86 100644 --- a/src/cache/coord_cache.rs +++ b/src/cache/coord_cache.rs @@ -185,6 +185,13 @@ impl CoordCache { self.entries.is_empty() } + /// Iterate over non-expired entries. + pub fn iter(&self, current_time_ms: u64) -> impl Iterator { + self.entries + .iter() + .filter(move |(_, entry)| !entry.is_expired(current_time_ms)) + } + /// Remove all expired entries. pub fn purge_expired(&mut self, current_time_ms: u64) -> usize { let before = self.entries.len(); diff --git a/src/control/queries.rs b/src/control/queries.rs index 0ec2475..4d39697 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -127,6 +127,32 @@ pub fn show_peers(node: &Node) -> Value { "bytes_recv": stats.bytes_recv, }); + // Security signals + peer_json["replay_suppressed"] = json!(peer.replay_suppressed_count()); + peer_json["consecutive_decrypt_failures"] = json!(peer.consecutive_decrypt_failures()); + + // Noise session counters (rekey urgency, replay window state) + if let Some(session) = peer.noise_session() { + peer_json["noise"] = json!({ + "send_counter": session.current_send_counter(), + "highest_recv_counter": session.highest_received_counter(), + }); + } + + // Session indices (hijack detection) + if let Some(idx) = peer.our_index() { + peer_json["our_session_index"] = json!(format!("{:08x}", idx.as_u32())); + } + + // Rekey state + if peer.rekey_in_progress() { + peer_json["rekey_in_progress"] = json!(true); + } + if peer.is_draining() { + peer_json["rekey_draining"] = json!(true); + } + peer_json["current_k_bit"] = json!(peer.current_k_bit()); + // Add MMP metrics if available if let Some(mmp) = peer.mmp() { let mut mmp_json = json!({ @@ -283,6 +309,19 @@ pub fn show_sessions(node: &Node) -> Value { "bytes_recv": bytes_rx, }); + // Handshake health (visible during initiating/awaiting_msg3) + if !entry.is_established() { + session_json["resend_count"] = json!(entry.resend_count()); + } + + // Rekey and session health (visible when established) + if entry.is_established() { + session_json["session_start_ms"] = json!(entry.session_start_ms()); + session_json["current_k_bit"] = json!(entry.current_k_bit()); + session_json["coords_warmup_remaining"] = json!(entry.coords_warmup_remaining()); + session_json["is_draining"] = json!(entry.is_draining()); + } + // Add session MMP if available if let Some(mmp) = entry.mmp() { let mut mmp_json = json!({ @@ -456,18 +495,47 @@ pub fn show_mmp(node: &Node) -> Value { }) } -/// `show_cache` — Coordinate cache stats. +/// `show_cache` — Coordinate cache stats and entries. pub fn show_cache(node: &Node) -> Value { let cache = node.coord_cache(); - let stats = cache.stats(now_ms()); + let now = now_ms(); + let stats = cache.stats(now); + + // Include individual entries for route debugging + let entries: Vec = cache + .iter(now) + .map(|(addr, entry)| { + let fips_addr = crate::identity::FipsAddress::from_node_addr(addr); + let coord_path: Vec = entry + .coords() + .entries() + .iter() + .map(|e| hex::encode(e.node_addr.as_bytes())) + .collect(); + let mut entry_json = json!({ + "node_addr": hex::encode(addr.as_bytes()), + "display_name": node.peer_display_name(addr), + "ipv6_addr": format!("{}", fips_addr), + "depth": entry.coords().depth(), + "coords": coord_path, + "age_ms": now.saturating_sub(entry.created_at()), + "last_used_ms": entry.last_used(), + }); + if let Some(mtu) = entry.path_mtu() { + entry_json["path_mtu"] = json!(mtu); + } + entry_json + }) + .collect(); json!({ - "entries": stats.entries, + "count": stats.entries, "max_entries": stats.max_entries, "fill_ratio": stats.fill_ratio(), "default_ttl_ms": cache.default_ttl_ms(), "expired": stats.expired, "avg_age_ms": stats.avg_age_ms, + "entries": entries, }) } @@ -540,14 +608,47 @@ pub fn show_transports(node: &Node) -> Value { /// `show_routing` — Routing table summary and node statistics. pub fn show_routing(node: &Node) -> Value { let cache = node.coord_cache(); - let cache_stats = cache.stats(now_ms()); + let now = now_ms(); + let cache_stats = cache.stats(now); let node_stats = node.stats().snapshot(); + // Pending discovery lookups (individual targets) + let lookups: Vec = node + .pending_lookups_iter() + .map(|(addr, lookup)| { + json!({ + "target": hex::encode(addr.as_bytes()), + "display_name": node.peer_display_name(addr), + "initiated_ms": lookup.initiated_ms, + "last_sent_ms": lookup.last_sent_ms, + "attempt": lookup.attempt, + "age_ms": now.saturating_sub(lookup.initiated_ms), + }) + }) + .collect(); + + // Connection retry state + let retries: Vec = node + .retry_state_iter() + .map(|(addr, state)| { + json!({ + "node_addr": hex::encode(addr.as_bytes()), + "display_name": node.peer_display_name(addr), + "retry_count": state.retry_count, + "retry_after_ms": state.retry_after_ms, + "auto_reconnect": state.reconnect, + }) + }) + .collect(); + json!({ "coord_cache_entries": cache_stats.entries, "identity_cache_entries": node.identity_cache_len(), - "pending_lookups": node.pending_lookup_count(), + "pending_lookups": lookups, + "pending_tun_destinations": node.pending_tun_destinations(), + "pending_tun_packets": node.pending_tun_total_packets(), "recent_requests": node.recent_request_count(), + "retries": retries, "forwarding": serde_json::to_value(&node_stats.forwarding).unwrap_or_default(), "discovery": serde_json::to_value(&node_stats.discovery).unwrap_or_default(), "error_signals": serde_json::to_value(&node_stats.errors).unwrap_or_default(), @@ -555,6 +656,38 @@ pub fn show_routing(node: &Node) -> Value { }) } +/// `show_identity_cache` — Known node identities. +/// +/// Lists every node whose public key has been cached by this daemon. +/// Identities are learned from DNS resolution, peer handshakes, session +/// establishment, and configured peer npubs. The cache uses LRU eviction +/// bounded by `node.cache.identity_size`. +pub fn show_identity_cache(node: &Node) -> Value { + let now = now_ms(); + let entries: Vec = node + .identity_cache_iter() + .map(|(node_addr, pubkey, last_seen_ms)| { + let (xonly, _parity) = pubkey.x_only_public_key(); + let fips_addr = crate::identity::FipsAddress::from_node_addr(node_addr); + json!({ + "node_addr": hex::encode(node_addr.as_bytes()), + "npub": encode_npub(&xonly), + "display_name": node.peer_display_name(node_addr), + "ipv6_addr": format!("{}", fips_addr), + "last_seen_ms": last_seen_ms, + "age_ms": now.saturating_sub(last_seen_ms), + }) + }) + .collect(); + let count = entries.len(); + + json!({ + "entries": entries, + "count": count, + "max_entries": node.identity_cache_max(), + }) +} + /// Dispatch a command string to the appropriate query function. pub fn dispatch(node: &Node, command: &str) -> super::protocol::Response { match command { @@ -569,6 +702,7 @@ pub fn dispatch(node: &Node, command: &str) -> super::protocol::Response { "show_connections" => super::protocol::Response::ok(show_connections(node)), "show_transports" => super::protocol::Response::ok(show_transports(node)), "show_routing" => super::protocol::Response::ok(show_routing(node)), + "show_identity_cache" => super::protocol::Response::ok(show_identity_cache(node)), _ => super::protocol::Response::error(format!("unknown command: {}", command)), } } diff --git a/src/node/handlers/discovery.rs b/src/node/handlers/discovery.rs index a5e9844..dd051cf 100644 --- a/src/node/handlers/discovery.rs +++ b/src/node/handlers/discovery.rs @@ -575,7 +575,7 @@ impl Node { } /// Tracks a pending discovery lookup with retry state. -pub(crate) struct PendingLookup { +pub struct PendingLookup { /// When the lookup was first initiated. pub initiated_ms: u64, /// When the last attempt was sent. diff --git a/src/node/mod.rs b/src/node/mod.rs index a623bf6..a9d8c97 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1425,16 +1425,55 @@ impl Node { self.identity_cache.len() } + /// Iterate over identity cache entries. + /// + /// Returns `(NodeAddr, PublicKey, last_seen_ms)` for each cached identity. + /// Used by the `show_identity_cache` control query. + pub fn identity_cache_iter( + &self, + ) -> impl Iterator { + self.identity_cache + .values() + .map(|(addr, pk, ts)| (addr, pk, *ts)) + } + + /// Configured maximum identity cache size. + pub fn identity_cache_max(&self) -> usize { + self.config.node.cache.identity_size + } + /// Number of pending discovery lookups. pub fn pending_lookup_count(&self) -> usize { self.pending_lookups.len() } + /// Iterate over pending discovery lookups for diagnostics. + pub fn pending_lookups_iter( + &self, + ) -> impl Iterator { + self.pending_lookups.iter() + } + /// Number of recent discovery requests tracked. pub fn recent_request_count(&self) -> usize { self.recent_requests.len() } + /// Count of destinations with queued TUN packets awaiting session setup. + pub fn pending_tun_destinations(&self) -> usize { + self.pending_tun_packets.len() + } + + /// Total TUN packets queued across all destinations. + pub fn pending_tun_total_packets(&self) -> usize { + self.pending_tun_packets.values().map(|q| q.len()).sum() + } + + /// Iterate over retry state for diagnostics. + pub fn retry_state_iter(&self) -> impl Iterator { + self.retry_pending.iter() + } + // === Routing === /// Check if a peer is a tree neighbor (parent or child in the spanning tree). diff --git a/src/peer/active.rs b/src/peer/active.rs index ce708c5..819064c 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -495,6 +495,11 @@ impl ActivePeer { self.consecutive_decrypt_failures = 0; } + /// Current consecutive decryption failure count. + pub fn consecutive_decrypt_failures(&self) -> u32 { + self.consecutive_decrypt_failures + } + // === Epoch Accessors === /// Get the remote peer's startup epoch (from handshake).