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 17:34:01 +00:00
committed by Johnathan Corgan
co-authored by Johnathan Corgan
parent 5029b40d49
commit 2d342a4e47
9 changed files with 242 additions and 15 deletions
+139 -5
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)),
}
}