Add diagnostic queries for security validation and mesh debugging

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 <johnathan@corganlabs.com>
This commit is contained in:
Tim O'Shea
2026-04-13 14:33:06 +00:00
committed by Johnathan Corgan
parent 5029b40d49
commit 2d342a4e47
9 changed files with 242 additions and 15 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",
}
}
}

View File

@@ -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")

View File

@@ -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<Item = (&NodeAddr, &CacheEntry)> {
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();

View File

@@ -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<Value> = cache
.iter(now)
.map(|(addr, entry)| {
let fips_addr = crate::identity::FipsAddress::from_node_addr(addr);
let coord_path: Vec<String> = 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<Value> = 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<Value> = 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<Value> = 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)),
}
}

View File

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

View File

@@ -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<Item = (&NodeAddr, &secp256k1::PublicKey, u64)> {
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<Item = (&NodeAddr, &handlers::discovery::PendingLookup)> {
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<Item = (&NodeAddr, &retry::RetryState)> {
self.retry_pending.iter()
}
// === Routing ===
/// Check if a peer is a tree neighbor (parent or child in the spanning tree).

View File

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