mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
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:
committed by
Johnathan Corgan
parent
5029b40d49
commit
2d342a4e47
30
CHANGELOG.md
30
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
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -300,12 +300,17 @@ Use `fipsctl` to query a running node:
|
||||
|
||||
```bash
|
||||
fipsctl show status # Node status overview
|
||||
fipsctl show peers # Authenticated peers
|
||||
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
|
||||
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 table summary
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
7
src/cache/coord_cache.rs
vendored
7
src/cache/coord_cache.rs
vendored
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user