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).