Skip to main content

fips_core/control/
queries.rs

1//! Control query implementations.
2//!
3//! Each function takes `&Node` and returns a `serde_json::Value`.
4//! Query logic is kept separate from socket handling.
5
6use crate::identity::{NodeAddr, PeerIdentity, encode_npub};
7use crate::node::Node;
8use crate::node::stats_history::{ALL_METRICS, ALL_PEER_METRICS, Granularity, Metric, PeerMetric};
9use serde_json::{Value, json};
10use std::str::FromStr;
11use std::time::Duration;
12
13/// Resolve an `npub1...` string to the corresponding `NodeAddr`.
14fn parse_peer_npub(s: &str) -> Result<NodeAddr, String> {
15    PeerIdentity::from_npub(s)
16        .map(|p| *p.node_addr())
17        .map_err(|e| format!("invalid peer npub: {e}"))
18}
19
20/// Helper: get current Unix time in milliseconds.
21fn now_ms() -> u64 {
22    std::time::SystemTime::now()
23        .duration_since(std::time::UNIX_EPOCH)
24        .map(|d| d.as_millis() as u64)
25        .unwrap_or(0)
26}
27
28/// Classify a DualEwma trend as "rising", "falling", or "stable".
29fn trend_label(short: f64, long: f64) -> &'static str {
30    if !short.is_finite() || !long.is_finite() || long == 0.0 {
31        return "stable";
32    }
33    let ratio = short / long;
34    if ratio > 1.05 {
35        "rising"
36    } else if ratio < 0.95 {
37        "falling"
38    } else {
39        "stable"
40    }
41}
42
43/// `show_status` — Node overview.
44pub fn show_status(node: &Node) -> Value {
45    let pid = std::process::id();
46    let exe_path = std::env::current_exe()
47        .map(|p| p.display().to_string())
48        .unwrap_or_else(|_| "-".into());
49    let uptime_secs = node.uptime().as_secs();
50    let fwd = node.stats().snapshot().forwarding;
51
52    // Inline last-N-second sparklines for dashboard rendering. Kept
53    // short so the status payload stays compact; longer windows use
54    // `show_stats_history`.
55    const SPARK_N: usize = 30;
56    let hist = node.stats_history();
57    let sparklines = json!({
58        "mesh_size": hist.recent(Metric::MeshSize, SPARK_N),
59        "tree_depth": hist.recent(Metric::TreeDepth, SPARK_N),
60        "peer_count": hist.recent(Metric::PeerCount, SPARK_N),
61        "bytes_in": hist.recent(Metric::BytesIn, SPARK_N),
62        "bytes_out": hist.recent(Metric::BytesOut, SPARK_N),
63        "loss_rate": hist.recent(Metric::LossRate, SPARK_N),
64    });
65
66    json!({
67        "version": crate::version::short_version(),
68        "npub": node.npub(),
69        "node_addr": hex::encode(node.node_addr().as_bytes()),
70        "ipv6_addr": format!("{}", node.identity().address()),
71        "state": format!("{}", node.state()),
72        "is_leaf_only": node.is_leaf_only(),
73        "peer_count": node.peer_count(),
74        "session_count": node.session_count(),
75        "link_count": node.link_count(),
76        "transport_count": node.transport_count(),
77        "connection_count": node.connection_count(),
78        "tun_state": format!("{}", node.tun_state()),
79        "tun_name": node.tun_name().unwrap_or("-"),
80        "effective_ipv6_mtu": node.effective_ipv6_mtu(),
81        "control_socket": &node.config().node.control.socket_path,
82        "pid": pid,
83        "exe_path": exe_path,
84        "uptime_secs": uptime_secs,
85        "estimated_mesh_size": node.estimated_mesh_size(),
86        "forwarding": serde_json::to_value(&fwd).unwrap_or_default(),
87        "sparklines": sparklines,
88    })
89}
90
91/// `show_acl` — Loaded peer ACL state.
92pub fn show_acl(node: &Node) -> Value {
93    let status = node.peer_acl_status();
94
95    json!({
96        "allow_file": status.allow_file,
97        "deny_file": status.deny_file,
98        "enforcement_active": status.enforcement_active,
99        "effective_mode": status.effective_mode,
100        "default_decision": status.default_decision,
101        "allow_all": status.allow_all,
102        "deny_all": status.deny_all,
103        "allow_file_entries": status.allow_file_entries,
104        "deny_file_entries": status.deny_file_entries,
105        "allow_entries": status.allow_entries,
106        "deny_entries": status.deny_entries,
107    })
108}
109
110/// `show_peers` — Authenticated peers.
111pub fn show_peers(node: &Node) -> Value {
112    let tree = node.tree_state();
113    let my_addr = *tree.my_node_addr();
114    let parent_id = *tree.my_declaration().parent_id();
115    let is_root = tree.is_root();
116
117    // Per-npub Nostr-traversal failure-state snapshot, indexed by npub
118    // for O(1) per-peer lookup. Empty if Nostr discovery is disabled.
119    let nostr_state: std::collections::HashMap<String, _> = node
120        .nostr_discovery_handle()
121        .map(|d| {
122            d.failure_state_snapshot()
123                .into_iter()
124                .map(|view| (view.npub.clone(), view))
125                .collect()
126        })
127        .unwrap_or_default();
128
129    let peers: Vec<Value> = node
130        .peers()
131        .map(|peer| {
132            let node_addr = *peer.node_addr();
133            let addr_hex = hex::encode(node_addr.as_bytes());
134
135            // Determine tree relationship
136            let is_parent = !is_root && node_addr == parent_id;
137            let is_child = tree
138                .peer_declaration(&node_addr)
139                .is_some_and(|decl| *decl.parent_id() == my_addr);
140
141            let mut peer_json = json!({
142                "node_addr": addr_hex,
143                "npub": peer.npub(),
144                "display_name": node.peer_display_name(&node_addr),
145                "ipv6_addr": format!("{}", peer.address()),
146                "connectivity": format!("{}", peer.connectivity()),
147                "link_id": peer.link_id().as_u64(),
148                "authenticated_at_ms": peer.authenticated_at(),
149                "last_seen_ms": peer.last_seen(),
150                "has_tree_position": peer.has_tree_position(),
151                "has_bloom_filter": peer.filter_sequence() > 0,
152                "filter_sequence": peer.filter_sequence(),
153                "is_parent": is_parent,
154                "is_child": is_child,
155            });
156
157            // Add transport address if available
158            if let Some(addr) = peer.current_addr() {
159                peer_json["transport_addr"] = json!(format!("{}", addr));
160            }
161
162            // Add link info (direction, transport type)
163            let link_id = peer.link_id();
164            if let Some(link) = node.get_link(&link_id) {
165                peer_json["direction"] = json!(format!("{}", link.direction()));
166                let transport_id = link.transport_id();
167                if let Some(handle) = node.get_transport(&transport_id) {
168                    peer_json["transport_type"] = json!(handle.transport_type().name);
169                }
170            }
171
172            // Add tree depth if available
173            if let Some(coords) = peer.coords() {
174                peer_json["tree_depth"] = json!(coords.depth());
175            }
176
177            // Add link stats
178            let stats = peer.link_stats();
179            peer_json["stats"] = json!({
180                "packets_sent": stats.packets_sent,
181                "packets_recv": stats.packets_recv,
182                "bytes_sent": stats.bytes_sent,
183                "bytes_recv": stats.bytes_recv,
184            });
185
186            // Security signals
187            peer_json["replay_suppressed"] = json!(peer.replay_suppressed_count());
188            peer_json["consecutive_decrypt_failures"] = json!(peer.consecutive_decrypt_failures());
189
190            // Nostr-traversal state if this peer's npub appears in
191            // failure-state. Always emitted (even null) so the schema
192            // stays stable; values populated only when Nostr discovery
193            // is enabled and the npub has been seen.
194            let npub = peer.npub();
195            let mut nostr_obj = json!({
196                "consecutive_failures": 0,
197                "in_cooldown": false,
198                "cooldown_until_ms": Value::Null,
199                "last_observed_skew_ms": Value::Null,
200            });
201            if let Some(state) = nostr_state.get(&npub) {
202                nostr_obj["consecutive_failures"] = json!(state.consecutive_failures);
203                nostr_obj["in_cooldown"] = json!(state.cooldown_until_ms.is_some());
204                nostr_obj["cooldown_until_ms"] = state
205                    .cooldown_until_ms
206                    .map(|t| json!(t))
207                    .unwrap_or(Value::Null);
208                nostr_obj["last_observed_skew_ms"] = state
209                    .last_observed_skew_ms
210                    .map(|s| json!(s))
211                    .unwrap_or(Value::Null);
212            }
213            peer_json["nostr_traversal"] = nostr_obj;
214
215            // Noise session counters (rekey urgency, replay window state)
216            if let Some(session) = peer.noise_session() {
217                peer_json["noise"] = json!({
218                    "send_counter": session.current_send_counter(),
219                    "highest_recv_counter": session.highest_received_counter(),
220                });
221            }
222
223            // Session indices (hijack detection)
224            if let Some(idx) = peer.our_index() {
225                peer_json["our_session_index"] = json!(format!("{:08x}", idx.as_u32()));
226            }
227
228            // Rekey state
229            if peer.rekey_in_progress() {
230                peer_json["rekey_in_progress"] = json!(true);
231            }
232            if peer.is_draining() {
233                peer_json["rekey_draining"] = json!(true);
234            }
235            peer_json["current_k_bit"] = json!(peer.current_k_bit());
236
237            // Add MMP metrics if available
238            if let Some(mmp) = peer.mmp() {
239                let mut mmp_json = json!({
240                    "mode": format!("{}", mmp.mode()),
241                });
242                if let Some(srtt) = mmp.metrics.srtt_ms() {
243                    mmp_json["srtt_ms"] = json!(srtt);
244                }
245                mmp_json["loss_rate"] = json!(mmp.metrics.loss_rate());
246                mmp_json["etx"] = json!(mmp.metrics.etx);
247                mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps);
248                mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
249                mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
250                if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
251                    mmp_json["smoothed_loss"] = json!(smoothed_loss);
252                }
253                if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
254                    mmp_json["smoothed_etx"] = json!(smoothed_etx);
255                }
256                if let Some(srtt) = mmp.metrics.srtt_ms()
257                    && let Some(setx) = mmp.metrics.smoothed_etx()
258                {
259                    mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0));
260                }
261                peer_json["mmp"] = mmp_json;
262            }
263
264            peer_json
265        })
266        .collect();
267
268    json!({ "peers": peers })
269}
270
271/// `show_links` — Active links.
272pub fn show_links(node: &Node) -> Value {
273    let links: Vec<Value> = node
274        .links()
275        .map(|link| {
276            let stats = link.stats();
277            json!({
278                "link_id": link.link_id().as_u64(),
279                "transport_id": link.transport_id().as_u32(),
280                "remote_addr": format!("{}", link.remote_addr()),
281                "direction": format!("{}", link.direction()),
282                "state": format!("{}", link.state()),
283                "created_at_ms": link.created_at(),
284                "stats": {
285                    "packets_sent": stats.packets_sent,
286                    "packets_recv": stats.packets_recv,
287                    "bytes_sent": stats.bytes_sent,
288                    "bytes_recv": stats.bytes_recv,
289                    "last_recv_ms": stats.last_recv_ms,
290                },
291            })
292        })
293        .collect();
294
295    json!({ "links": links })
296}
297
298/// `show_tree` — Spanning tree state.
299pub fn show_tree(node: &Node) -> Value {
300    let tree = node.tree_state();
301    let my_coords = tree.my_coords();
302    let decl = tree.my_declaration();
303
304    // Build coords array as hex strings
305    let coords: Vec<String> = my_coords
306        .entries()
307        .iter()
308        .map(|e| hex::encode(e.node_addr.as_bytes()))
309        .collect();
310
311    // Build peer tree data
312    let peers: Vec<Value> = tree
313        .peer_ids()
314        .map(|peer_id| {
315            let mut peer_json = json!({
316                "node_addr": hex::encode(peer_id.as_bytes()),
317                "display_name": node.peer_display_name(peer_id),
318            });
319            if let Some(coords) = tree.peer_coords(peer_id) {
320                let coord_path: Vec<String> = coords
321                    .entries()
322                    .iter()
323                    .map(|e| hex::encode(e.node_addr.as_bytes()))
324                    .collect();
325                peer_json["depth"] = json!(coords.depth());
326                peer_json["root"] = json!(hex::encode(coords.root_id().as_bytes()));
327                peer_json["coords"] = json!(coord_path);
328                peer_json["distance_to_us"] = json!(my_coords.distance_to(coords));
329            }
330            peer_json
331        })
332        .collect();
333
334    // Determine parent display name
335    let parent_addr = my_coords.parent_id();
336    let parent_hex = hex::encode(parent_addr.as_bytes());
337    let parent_display = node.peer_display_name(parent_addr);
338
339    let tree_stats = node.stats().snapshot().tree;
340
341    json!({
342        "my_node_addr": hex::encode(tree.my_node_addr().as_bytes()),
343        "root": hex::encode(tree.root().as_bytes()),
344        "is_root": tree.is_root(),
345        "depth": my_coords.depth(),
346        "my_coords": coords,
347        "parent": parent_hex,
348        "parent_display_name": parent_display,
349        "declaration_sequence": decl.sequence(),
350        "declaration_signed": decl.is_signed(),
351        "peer_tree_count": tree.peer_count(),
352        "peers": peers,
353        "stats": serde_json::to_value(&tree_stats).unwrap_or_default(),
354    })
355}
356
357/// `show_sessions` — End-to-end sessions.
358pub fn show_sessions(node: &Node) -> Value {
359    let sessions: Vec<Value> = node
360        .session_entries()
361        .map(|(addr, entry)| {
362            let state_str = if entry.is_established() {
363                "established"
364            } else if entry.is_initiating() {
365                "initiating"
366            } else if entry.is_awaiting_msg3() {
367                "awaiting_msg3"
368            } else {
369                "unknown"
370            };
371
372            let mut session_json = json!({
373                "remote_addr": hex::encode(addr.as_bytes()),
374                "display_name": node.peer_display_name(addr),
375                "state": state_str,
376                "is_initiator": entry.is_initiator(),
377                "last_activity_ms": entry.last_activity(),
378            });
379
380            // Derive npub from session's remote public key
381            let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
382            session_json["npub"] = json!(encode_npub(&xonly));
383
384            // Traffic counters
385            let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
386            session_json["stats"] = json!({
387                "packets_sent": pkts_tx,
388                "packets_recv": pkts_rx,
389                "bytes_sent": bytes_tx,
390                "bytes_recv": bytes_rx,
391            });
392
393            // Handshake health (visible during initiating/awaiting_msg3)
394            if !entry.is_established() {
395                session_json["resend_count"] = json!(entry.resend_count());
396            }
397
398            // Rekey and session health (visible when established)
399            if entry.is_established() {
400                session_json["session_start_ms"] = json!(entry.session_start_ms());
401                session_json["current_k_bit"] = json!(entry.current_k_bit());
402                session_json["coords_warmup_remaining"] = json!(entry.coords_warmup_remaining());
403                session_json["is_draining"] = json!(entry.is_draining());
404            }
405
406            // Add session MMP if available
407            if let Some(mmp) = entry.mmp() {
408                let mut mmp_json = json!({
409                    "mode": format!("{}", mmp.mode()),
410                    "loss_rate": mmp.metrics.loss_rate(),
411                    "etx": mmp.metrics.etx,
412                    "goodput_bps": mmp.metrics.goodput_bps,
413                    "delivery_ratio_forward": mmp.metrics.delivery_ratio_forward,
414                    "delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse,
415                    "path_mtu": mmp.path_mtu.current_mtu(),
416                });
417                if let Some(srtt) = mmp.metrics.srtt_ms() {
418                    mmp_json["srtt_ms"] = json!(srtt);
419                }
420                if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
421                    mmp_json["smoothed_loss"] = json!(smoothed_loss);
422                }
423                if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
424                    mmp_json["smoothed_etx"] = json!(smoothed_etx);
425                }
426                if let Some(srtt) = mmp.metrics.srtt_ms()
427                    && let Some(setx) = mmp.metrics.smoothed_etx()
428                {
429                    mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0));
430                }
431                session_json["mmp"] = mmp_json;
432            }
433
434            session_json
435        })
436        .collect();
437
438    json!({ "sessions": sessions })
439}
440
441/// `show_bloom` — Bloom filter state.
442pub fn show_bloom(node: &Node) -> Value {
443    let bloom = node.bloom_state();
444
445    let leaf_deps: Vec<String> = bloom
446        .leaf_dependents()
447        .iter()
448        .map(|addr| hex::encode(addr.as_bytes()))
449        .collect();
450
451    // Build per-peer filter info
452    let peer_filters: Vec<Value> = node
453        .peers()
454        .map(|peer| {
455            let addr = *peer.node_addr();
456            let mut pf = json!({
457                "peer": hex::encode(addr.as_bytes()),
458                "display_name": node.peer_display_name(&addr),
459                "has_filter": peer.filter_sequence() > 0,
460                "filter_sequence": peer.filter_sequence(),
461            });
462            if let Some(filter) = peer.inbound_filter() {
463                let max_fpr = node.config().node.bloom.max_inbound_fpr;
464                pf["estimated_count"] = json!(filter.estimated_count(max_fpr));
465                pf["set_bits"] = json!(filter.count_ones());
466                pf["fill_ratio"] = json!(filter.fill_ratio());
467            }
468            pf
469        })
470        .collect();
471
472    let bloom_stats = node.stats().snapshot().bloom;
473
474    json!({
475        "own_node_addr": hex::encode(node.node_addr().as_bytes()),
476        "is_leaf_only": node.is_leaf_only(),
477        "sequence": bloom.sequence(),
478        "leaf_dependent_count": bloom.leaf_dependents().len(),
479        "leaf_dependents": leaf_deps,
480        "peer_filters": peer_filters,
481        "stats": serde_json::to_value(&bloom_stats).unwrap_or_default(),
482    })
483}
484
485/// `show_mmp` — MMP metrics summary.
486pub fn show_mmp(node: &Node) -> Value {
487    // Link-layer MMP per peer
488    let peers: Vec<Value> = node.peers().filter_map(|peer| {
489        let mmp = peer.mmp()?;
490        let addr = *peer.node_addr();
491        let metrics = &mmp.metrics;
492
493        let mut link_layer = json!({
494            "loss_rate": metrics.loss_rate(),
495            "etx": metrics.etx,
496            "goodput_bps": metrics.goodput_bps,
497            "spin_bit_role": if mmp.spin_bit.is_initiator() { "initiator" } else { "responder" },
498        });
499
500        if let Some(smoothed_loss) = metrics.smoothed_loss() {
501            link_layer["smoothed_loss"] = json!(smoothed_loss);
502        }
503        if let Some(smoothed_etx) = metrics.smoothed_etx() {
504            link_layer["smoothed_etx"] = json!(smoothed_etx);
505        }
506        if let Some(srtt) = metrics.srtt_ms() {
507            link_layer["srtt_ms"] = json!(srtt);
508            if let Some(setx) = metrics.smoothed_etx() {
509                link_layer["lqi"] = json!(setx * (1.0 + srtt / 100.0));
510            }
511        }
512
513        // Trend indicators
514        if metrics.rtt_trend.initialized() {
515            link_layer["rtt_trend"] = json!(trend_label(metrics.rtt_trend.short(), metrics.rtt_trend.long()));
516        }
517        if metrics.loss_trend.initialized() {
518            link_layer["loss_trend"] = json!(trend_label(metrics.loss_trend.short(), metrics.loss_trend.long()));
519        }
520        if metrics.goodput_trend.initialized() {
521            link_layer["goodput_trend"] = json!(trend_label(metrics.goodput_trend.short(), metrics.goodput_trend.long()));
522        }
523        if metrics.jitter_trend.initialized() {
524            link_layer["jitter_trend"] = json!(trend_label(metrics.jitter_trend.short(), metrics.jitter_trend.long()));
525        }
526
527        link_layer["delivery_ratio_forward"] = json!(metrics.delivery_ratio_forward);
528        link_layer["delivery_ratio_reverse"] = json!(metrics.delivery_ratio_reverse);
529        link_layer["ecn_ce_count"] = json!(metrics.last_ecn_ce_count());
530
531        Some(json!({
532            "peer": hex::encode(addr.as_bytes()),
533            "display_name": node.peer_display_name(&addr),
534            "mode": format!("{}", mmp.mode()),
535            "link_layer": link_layer,
536        }))
537    }).collect();
538
539    // Session-layer MMP
540    let sessions: Vec<Value> = node
541        .session_entries()
542        .filter_map(|(addr, entry)| {
543            let mmp = entry.mmp()?;
544            let metrics = &mmp.metrics;
545
546            let mut session_layer = json!({
547                "loss_rate": metrics.loss_rate(),
548                "etx": metrics.etx,
549                "path_mtu": mmp.path_mtu.current_mtu(),
550            });
551
552            if let Some(smoothed_loss) = metrics.smoothed_loss() {
553                session_layer["smoothed_loss"] = json!(smoothed_loss);
554            }
555            if let Some(smoothed_etx) = metrics.smoothed_etx() {
556                session_layer["smoothed_etx"] = json!(smoothed_etx);
557            }
558            if let Some(srtt) = metrics.srtt_ms() {
559                session_layer["srtt_ms"] = json!(srtt);
560                if let Some(setx) = metrics.smoothed_etx() {
561                    session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0));
562                }
563            }
564
565            Some(json!({
566                "remote": hex::encode(addr.as_bytes()),
567                "display_name": node.peer_display_name(addr),
568                "mode": format!("{}", mmp.mode()),
569                "session_layer": session_layer,
570            }))
571        })
572        .collect();
573
574    json!({
575        "peers": peers,
576        "sessions": sessions,
577    })
578}
579
580/// `show_cache` — Coordinate cache stats and entries.
581pub fn show_cache(node: &Node) -> Value {
582    let cache = node.coord_cache();
583    let now = now_ms();
584    let stats = cache.stats(now);
585
586    // Include individual entries for route debugging
587    let entries: Vec<Value> = cache
588        .iter(now)
589        .map(|(addr, entry)| {
590            let fips_addr = crate::identity::FipsAddress::from_node_addr(addr);
591            let coord_path: Vec<String> = entry
592                .coords()
593                .entries()
594                .iter()
595                .map(|e| hex::encode(e.node_addr.as_bytes()))
596                .collect();
597            let mut entry_json = json!({
598                "node_addr": hex::encode(addr.as_bytes()),
599                "display_name": node.peer_display_name(addr),
600                "ipv6_addr": format!("{}", fips_addr),
601                "depth": entry.coords().depth(),
602                "coords": coord_path,
603                "age_ms": now.saturating_sub(entry.created_at()),
604                "last_used_ms": entry.last_used(),
605            });
606            if let Some(mtu) = entry.path_mtu() {
607                entry_json["path_mtu"] = json!(mtu);
608            }
609            entry_json
610        })
611        .collect();
612
613    json!({
614        "count": stats.entries,
615        "max_entries": stats.max_entries,
616        "fill_ratio": stats.fill_ratio(),
617        "default_ttl_ms": cache.default_ttl_ms(),
618        "expired": stats.expired,
619        "avg_age_ms": stats.avg_age_ms,
620        "entries": entries,
621    })
622}
623
624/// `show_connections` — Pending handshakes.
625pub fn show_connections(node: &Node) -> Value {
626    let now = now_ms();
627    let connections: Vec<Value> = node
628        .connections()
629        .map(|conn| {
630            let mut conn_json = json!({
631                "link_id": conn.link_id().as_u64(),
632                "direction": format!("{}", conn.direction()),
633                "handshake_state": format!("{}", conn.handshake_state()),
634                "started_at_ms": conn.started_at(),
635                "idle_ms": now.saturating_sub(conn.last_activity()),
636                "resend_count": conn.resend_count(),
637            });
638
639            if let Some(identity) = conn.expected_identity() {
640                conn_json["expected_peer"] = json!(identity.npub());
641            }
642
643            conn_json
644        })
645        .collect();
646
647    json!({ "connections": connections })
648}
649
650/// `show_transports` — Transport instances.
651pub fn show_transports(node: &Node) -> Value {
652    let transports: Vec<Value> = node
653        .transport_ids()
654        .map(|id| {
655            let handle = node.get_transport(id).unwrap();
656            let mut t_json = json!({
657                "transport_id": id.as_u32(),
658                "type": handle.transport_type().name,
659                "state": format!("{}", handle.state()),
660                "mtu": handle.mtu(),
661            });
662
663            if let Some(name) = handle.name() {
664                t_json["name"] = json!(name);
665            }
666            if let Some(addr) = handle.local_addr() {
667                t_json["local_addr"] = json!(format!("{}", addr));
668            }
669
670            // Tor-specific fields
671            if let Some(mode) = handle.tor_mode() {
672                t_json["tor_mode"] = json!(mode);
673            }
674            if let Some(onion) = handle.onion_address() {
675                t_json["onion_address"] = json!(onion);
676            }
677            if let Some(monitoring) = handle.tor_monitoring() {
678                t_json["tor_monitoring"] = serde_json::to_value(&monitoring).unwrap_or_default();
679            }
680
681            t_json["stats"] = handle.transport_stats();
682
683            t_json
684        })
685        .collect();
686
687    json!({ "transports": transports })
688}
689
690/// `show_routing` — Routing table summary and node statistics.
691pub fn show_routing(node: &Node) -> Value {
692    let cache = node.coord_cache();
693    let now = now_ms();
694    let cache_stats = cache.stats(now);
695    let node_stats = node.stats().snapshot();
696    let learned_routes = node.learned_route_table_snapshot(now);
697
698    // Pending discovery lookups (individual targets)
699    let lookups: Vec<Value> = node
700        .pending_lookups_iter()
701        .map(|(addr, lookup)| {
702            json!({
703                "target": hex::encode(addr.as_bytes()),
704                "display_name": node.peer_display_name(addr),
705                "initiated_ms": lookup.initiated_ms,
706                "last_sent_ms": lookup.last_sent_ms,
707                "attempt": lookup.attempt,
708                "age_ms": now.saturating_sub(lookup.initiated_ms),
709            })
710        })
711        .collect();
712
713    // Connection retry state
714    let retries: Vec<Value> = node
715        .retry_state_iter()
716        .map(|(addr, state)| {
717            json!({
718                "node_addr": hex::encode(addr.as_bytes()),
719                "display_name": node.peer_display_name(addr),
720                "retry_count": state.retry_count,
721                "retry_after_ms": state.retry_after_ms,
722                "auto_reconnect": state.reconnect,
723            })
724        })
725        .collect();
726
727    json!({
728        "coord_cache_entries": cache_stats.entries,
729        "routing_mode": node.config().node.routing.mode.to_string(),
730        "learned_routes": serde_json::to_value(&learned_routes).unwrap_or_default(),
731        "identity_cache_entries": node.identity_cache_len(),
732        "pending_lookups": lookups,
733        "pending_tun_destinations": node.pending_tun_destinations(),
734        "pending_tun_packets": node.pending_tun_total_packets(),
735        "recent_requests": node.recent_request_count(),
736        "retries": retries,
737        "forwarding": serde_json::to_value(&node_stats.forwarding).unwrap_or_default(),
738        "discovery": serde_json::to_value(&node_stats.discovery).unwrap_or_default(),
739        "error_signals": serde_json::to_value(&node_stats.errors).unwrap_or_default(),
740        "congestion": serde_json::to_value(&node_stats.congestion).unwrap_or_default(),
741    })
742}
743
744/// `show_identity_cache` — Known node identities.
745///
746/// Lists every node whose public key has been cached by this daemon.
747/// Identities are learned from DNS resolution, peer handshakes, session
748/// establishment, and configured peer npubs.  The cache uses LRU eviction
749/// bounded by `node.cache.identity_size`.
750pub fn show_identity_cache(node: &Node) -> Value {
751    let now = now_ms();
752    let entries: Vec<Value> = node
753        .identity_cache_iter()
754        .map(|(node_addr, pubkey, last_seen_ms)| {
755            let (xonly, _parity) = pubkey.x_only_public_key();
756            let fips_addr = crate::identity::FipsAddress::from_node_addr(node_addr);
757            json!({
758                "node_addr": hex::encode(node_addr.as_bytes()),
759                "npub": encode_npub(&xonly),
760                "display_name": node.peer_display_name(node_addr),
761                "ipv6_addr": format!("{}", fips_addr),
762                "last_seen_ms": last_seen_ms,
763                "age_ms": now.saturating_sub(last_seen_ms),
764            })
765        })
766        .collect();
767    let count = entries.len();
768
769    json!({
770        "entries": entries,
771        "count": count,
772        "max_entries": node.identity_cache_max(),
773    })
774}
775
776/// `show_stats_list` — Enumerate available history metrics and their units.
777pub fn show_stats_list() -> Value {
778    let metrics: Vec<Value> = ALL_METRICS
779        .iter()
780        .map(|m| {
781            json!({
782                "name": m.name(),
783                "unit": m.unit(),
784                "scope": "node",
785            })
786        })
787        .chain(ALL_PEER_METRICS.iter().map(|m| {
788            json!({
789                "name": m.name(),
790                "unit": m.unit(),
791                "scope": "peer",
792            })
793        }))
794        .collect();
795    json!({
796        "metrics": metrics,
797        "fast_ring_seconds": crate::node::stats_history::FAST_RING_CAPACITY,
798        "slow_ring_minutes": crate::node::stats_history::SLOW_RING_CAPACITY,
799        "peer_retention_seconds": crate::node::stats_history::PEER_EVICTION_SECS,
800    })
801}
802
803/// `show_stats_history` — Time-series samples for one metric.
804///
805/// Params:
806/// - `metric` (required): metric name. Node-level metrics (e.g.
807///   `mesh_size`) are resolved against `Metric`; per-peer metrics (e.g.
808///   `srtt_ms`, `ecn_ce`) require the `peer` param and resolve against
809///   `PeerMetric`.
810/// - `peer` (optional): `npub1...` of the peer; required for per-peer
811///   metrics.
812/// - `window` (default `10m`): duration `<N>s`, `<N>m`, or `<N>h`.
813/// - `granularity` (default `1s`): `1s` or `1m`.
814pub fn show_stats_history(node: &Node, params: Option<&Value>) -> super::protocol::Response {
815    use super::protocol::Response;
816    let Some(params) = params else {
817        return Response::error("missing params for show_stats_history");
818    };
819
820    let metric_name = match params.get("metric").and_then(|v| v.as_str()) {
821        Some(v) => v,
822        None => return Response::error("missing 'metric' parameter"),
823    };
824
825    let window_str = params
826        .get("window")
827        .and_then(|v| v.as_str())
828        .unwrap_or("10m");
829    let window = match parse_duration(window_str) {
830        Ok(d) => d,
831        Err(e) => return Response::error(e),
832    };
833
834    let granularity_str = params
835        .get("granularity")
836        .and_then(|v| v.as_str())
837        .unwrap_or("1s");
838    let granularity = match Granularity::from_str(granularity_str) {
839        Ok(g) => g,
840        Err(e) => return Response::error(e),
841    };
842
843    let peer_npub = params.get("peer").and_then(|v| v.as_str());
844    let hist = node.stats_history();
845
846    if let Some(npub) = peer_npub {
847        let addr = match parse_peer_npub(npub) {
848            Ok(a) => a,
849            Err(e) => return Response::error(e),
850        };
851        let peer_metric = match PeerMetric::from_str(metric_name) {
852            Ok(m) => m,
853            Err(e) => return Response::error(e),
854        };
855        match hist.peer_query(&addr, peer_metric, window, granularity) {
856            Some(series) => Response::ok(serde_json::to_value(&series).unwrap_or(Value::Null)),
857            None => Response::error(format!(
858                "peer not tracked in stats history: {}",
859                node.peer_display_name(&addr)
860            )),
861        }
862    } else {
863        let metric = match Metric::from_str(metric_name) {
864            Ok(m) => m,
865            Err(e) => return Response::error(e),
866        };
867        let series = hist.query(metric, window, granularity);
868        Response::ok(serde_json::to_value(&series).unwrap_or(Value::Null))
869    }
870}
871
872/// Parse a duration of the form `<N>s`, `<N>m`, or `<N>h` into a `Duration`.
873fn parse_duration(s: &str) -> Result<Duration, String> {
874    if s.is_empty() {
875        return Err("empty duration".to_string());
876    }
877    let (num_part, unit) = s.split_at(s.len() - 1);
878    let n: u64 = num_part
879        .parse()
880        .map_err(|_| format!("invalid duration: {s}"))?;
881    let secs = match unit {
882        "s" => n,
883        "m" => n * 60,
884        "h" => n * 3600,
885        _ => return Err(format!("unknown duration unit: {unit} (expected s, m, h)")),
886    };
887    Ok(Duration::from_secs(secs))
888}
889
890/// `show_stats_all_history` — Return a series for every tracked metric
891/// in one round trip. Intended for the fipstop Graphs tab.
892///
893/// Without `peer`: returns the 10 node-level metrics.
894/// With `peer` (npub): returns the 7 per-peer metrics for that peer.
895///
896/// Params: `{"peer": "<npub>"?, "window": "<dur>", "granularity": "<1s|1m>"}`.
897pub fn show_stats_all_history(node: &Node, params: Option<&Value>) -> super::protocol::Response {
898    use super::protocol::Response;
899    let params = params.cloned().unwrap_or_else(|| json!({}));
900
901    let window_str = params
902        .get("window")
903        .and_then(|v| v.as_str())
904        .unwrap_or("10m");
905    let window = match parse_duration(window_str) {
906        Ok(d) => d,
907        Err(e) => return Response::error(e),
908    };
909
910    let granularity_str = params
911        .get("granularity")
912        .and_then(|v| v.as_str())
913        .unwrap_or("1s");
914    let granularity = match Granularity::from_str(granularity_str) {
915        Ok(g) => g,
916        Err(e) => return Response::error(e),
917    };
918
919    let peer_npub = params.get("peer").and_then(|v| v.as_str());
920    let hist = node.stats_history();
921
922    let series: Vec<Value> = if let Some(npub) = peer_npub {
923        let addr = match parse_peer_npub(npub) {
924            Ok(a) => a,
925            Err(e) => return Response::error(e),
926        };
927        if !hist.has_peer(&addr) {
928            return Response::error(format!(
929                "peer not tracked in stats history: {}",
930                node.peer_display_name(&addr)
931            ));
932        }
933        ALL_PEER_METRICS
934            .iter()
935            .map(|m| {
936                let s = hist
937                    .peer_query(&addr, *m, window, granularity)
938                    .unwrap_or_else(|| {
939                        // Unreachable: has_peer checked above, but degrade
940                        // gracefully rather than panic.
941                        crate::node::stats_history::Series {
942                            metric: m.name(),
943                            unit: m.unit(),
944                            granularity_seconds: granularity.seconds(),
945                            values: Vec::new(),
946                        }
947                    });
948                serde_json::to_value(&s).unwrap_or(Value::Null)
949            })
950            .collect()
951    } else {
952        ALL_METRICS
953            .iter()
954            .map(|m| {
955                let s = hist.query(*m, window, granularity);
956                serde_json::to_value(&s).unwrap_or(Value::Null)
957            })
958            .collect()
959    };
960
961    Response::ok(json!({
962        "granularity_seconds": granularity.seconds(),
963        "window_seconds": window.as_secs(),
964        "peer": peer_npub,
965        "series": series,
966    }))
967}
968
969/// `show_stats_peers` — Enumerate peers tracked in the stats history
970/// with their lifecycle metadata. Used by operator tools to populate
971/// peer selectors and to confirm a peer is in the retention window.
972pub fn show_stats_peers(node: &Node) -> Value {
973    let hist = node.stats_history();
974    let now = std::time::Instant::now();
975
976    let mut peers: Vec<Value> = hist
977        .peers()
978        .map(|(addr, rings)| {
979            let last_contact_secs = now.duration_since(rings.last_contact()).as_secs();
980            let first_seen_secs = now.duration_since(rings.first_seen()).as_secs();
981            let is_active = node.peers().any(|p| p.node_addr() == addr);
982            let npub = node
983                .peers()
984                .find(|p| p.node_addr() == addr)
985                .map(|p| p.npub())
986                .unwrap_or_else(|| hex::encode(addr.as_bytes()));
987            json!({
988                "npub": npub,
989                "node_addr": hex::encode(addr.as_bytes()),
990                "display_name": node.peer_display_name(addr),
991                "is_active": is_active,
992                "first_seen_secs_ago": first_seen_secs,
993                "last_contact_secs_ago": last_contact_secs,
994            })
995        })
996        .collect();
997
998    // Stable display order: active peers first, then by display name.
999    peers.sort_by(|a, b| {
1000        let a_active = a
1001            .get("is_active")
1002            .and_then(|v| v.as_bool())
1003            .unwrap_or(false);
1004        let b_active = b
1005            .get("is_active")
1006            .and_then(|v| v.as_bool())
1007            .unwrap_or(false);
1008        match (b_active, a_active) {
1009            (true, false) => std::cmp::Ordering::Greater,
1010            (false, true) => std::cmp::Ordering::Less,
1011            _ => a
1012                .get("display_name")
1013                .and_then(|v| v.as_str())
1014                .unwrap_or("")
1015                .cmp(b.get("display_name").and_then(|v| v.as_str()).unwrap_or("")),
1016        }
1017    });
1018
1019    json!({ "peers": peers, "count": peers.len() })
1020}
1021
1022/// `show_stats_history_all_peers` — One metric across every tracked
1023/// peer in one round trip. Backs the fipstop MetricByPeer grid view.
1024///
1025/// Params: `{"metric": "<name>", "window": "<dur>", "granularity": "<1s|1m>"}`.
1026/// `metric` must be a per-peer metric name (see `PeerMetric`).
1027pub fn show_stats_history_all_peers(
1028    node: &Node,
1029    params: Option<&Value>,
1030) -> super::protocol::Response {
1031    use super::protocol::Response;
1032    let Some(params) = params else {
1033        return Response::error("missing params for show_stats_history_all_peers");
1034    };
1035
1036    let metric_name = match params.get("metric").and_then(|v| v.as_str()) {
1037        Some(v) => v,
1038        None => return Response::error("missing 'metric' parameter"),
1039    };
1040    let metric = match PeerMetric::from_str(metric_name) {
1041        Ok(m) => m,
1042        Err(e) => return Response::error(e),
1043    };
1044
1045    let window_str = params
1046        .get("window")
1047        .and_then(|v| v.as_str())
1048        .unwrap_or("10m");
1049    let window = match parse_duration(window_str) {
1050        Ok(d) => d,
1051        Err(e) => return Response::error(e),
1052    };
1053
1054    let granularity_str = params
1055        .get("granularity")
1056        .and_then(|v| v.as_str())
1057        .unwrap_or("1s");
1058    let granularity = match Granularity::from_str(granularity_str) {
1059        Ok(g) => g,
1060        Err(e) => return Response::error(e),
1061    };
1062
1063    let hist = node.stats_history();
1064    let peer_addrs: Vec<NodeAddr> = hist.peer_addrs().copied().collect();
1065
1066    let mut peers: Vec<Value> = peer_addrs
1067        .iter()
1068        .filter_map(|addr| {
1069            let s = hist.peer_query(addr, metric, window, granularity)?;
1070            let is_active = node.peers().any(|p| p.node_addr() == addr);
1071            Some(json!({
1072                "node_addr": hex::encode(addr.as_bytes()),
1073                "display_name": node.peer_display_name(addr),
1074                "is_active": is_active,
1075                "values": serde_json::to_value(&s.values).unwrap_or(Value::Null),
1076            }))
1077        })
1078        .collect();
1079
1080    // Active peers first, then by display name.
1081    peers.sort_by(|a, b| {
1082        let a_active = a
1083            .get("is_active")
1084            .and_then(|v| v.as_bool())
1085            .unwrap_or(false);
1086        let b_active = b
1087            .get("is_active")
1088            .and_then(|v| v.as_bool())
1089            .unwrap_or(false);
1090        match (b_active, a_active) {
1091            (true, false) => std::cmp::Ordering::Greater,
1092            (false, true) => std::cmp::Ordering::Less,
1093            _ => a
1094                .get("display_name")
1095                .and_then(|v| v.as_str())
1096                .unwrap_or("")
1097                .cmp(b.get("display_name").and_then(|v| v.as_str()).unwrap_or("")),
1098        }
1099    });
1100
1101    Response::ok(json!({
1102        "metric": metric.name(),
1103        "unit": metric.unit(),
1104        "granularity_seconds": granularity.seconds(),
1105        "window_seconds": window.as_secs(),
1106        "peers": peers,
1107    }))
1108}
1109
1110/// `show_listening_sockets` - IPv6 listeners reachable from fips0, annotated
1111/// with the current `inet fips` filter classification.
1112pub fn show_listening_sockets(node: &Node) -> Value {
1113    let fips0 = crate::FipsAddress::from_node_addr(node.identity().node_addr()).to_ipv6();
1114    #[cfg(test)]
1115    let sockets: Vec<super::listening::ListeningSocket> = Vec::new();
1116    #[cfg(not(test))]
1117    let sockets = super::listening::enumerate(fips0);
1118    #[cfg(test)]
1119    let classifier = super::firewall_state::FilterClassifier::no_firewall();
1120    #[cfg(not(test))]
1121    let classifier = super::firewall_state::FilterClassifier::query();
1122
1123    let rows: Vec<Value> = sockets
1124        .iter()
1125        .map(|socket| {
1126            let filter = classifier.classify(socket.proto, socket.port);
1127            json!({
1128                "proto": socket.proto.as_str(),
1129                "local_addr": socket.local_addr.to_string(),
1130                "port": socket.port,
1131                "pid": socket.pid,
1132                "process": socket.process,
1133                "filter": filter.as_str(),
1134                "wildcard_bind": socket.wildcard_bind,
1135            })
1136        })
1137        .collect();
1138
1139    json!({
1140        "fips0_addr": fips0.to_string(),
1141        "firewall_active": classifier.is_active(),
1142        "sockets": rows,
1143    })
1144}
1145
1146/// Dispatch a command string to the appropriate query function.
1147pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response {
1148    match command {
1149        "show_acl" => super::protocol::Response::ok(show_acl(node)),
1150        "show_status" => super::protocol::Response::ok(show_status(node)),
1151        "show_peers" => super::protocol::Response::ok(show_peers(node)),
1152        "show_links" => super::protocol::Response::ok(show_links(node)),
1153        "show_tree" => super::protocol::Response::ok(show_tree(node)),
1154        "show_sessions" => super::protocol::Response::ok(show_sessions(node)),
1155        "show_bloom" => super::protocol::Response::ok(show_bloom(node)),
1156        "show_mmp" => super::protocol::Response::ok(show_mmp(node)),
1157        "show_cache" => super::protocol::Response::ok(show_cache(node)),
1158        "show_connections" => super::protocol::Response::ok(show_connections(node)),
1159        "show_transports" => super::protocol::Response::ok(show_transports(node)),
1160        "show_routing" => super::protocol::Response::ok(show_routing(node)),
1161        "show_identity_cache" => super::protocol::Response::ok(show_identity_cache(node)),
1162        "show_listening_sockets" => super::protocol::Response::ok(show_listening_sockets(node)),
1163        "show_stats_list" => super::protocol::Response::ok(show_stats_list()),
1164        "show_stats_history" => show_stats_history(node, params),
1165        "show_stats_all_history" => show_stats_all_history(node, params),
1166        "show_stats_peers" => super::protocol::Response::ok(show_stats_peers(node)),
1167        "show_stats_history_all_peers" => show_stats_history_all_peers(node, params),
1168        _ => super::protocol::Response::error(format!("unknown command: {}", command)),
1169    }
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    //! Schema-stability snapshot tests for all 18 control-socket query
1175    //! handlers.
1176    //!
1177    //! Each handler is invoked against a deterministically-constructed
1178    //! `Node` (fixed identity seed, empty peer/link/transport/cache
1179    //! state). The resulting JSON is normalized — fields whose values
1180    //! depend on wall-clock, PID, build environment, or filesystem
1181    //! layout are replaced with the literal string `"<redacted>"` —
1182    //! and compared against versioned fixtures under
1183    //! `src/control/snapshots/`.
1184    //!
1185    //! The point is to catch accidental schema drift (renames, type
1186    //! changes, dropped fields) in the operator-facing wire format.
1187    //! Empty-state snapshots are sufficient because every top-level
1188    //! key still appears, and per-element shapes inside `[]` arrays
1189    //! are covered by the dispatcher contract test plus serde
1190    //! derives elsewhere.
1191    //!
1192    //! ## Updating snapshots
1193    //!
1194    //! When a schema change is intentional, regenerate fixtures by
1195    //! deleting the relevant `.json` files (or the whole
1196    //! `snapshots/` directory) and re-running this test. Missing
1197    //! fixtures are written from the current output rather than
1198    //! failing — the next run then enforces the new shape. Review
1199    //! the resulting diff before committing.
1200    //!
1201    //! ## Determinism
1202    //!
1203    //! The `Node` is built via `Node::with_identity` from a fixed
1204    //! 32-byte seed (`[0xAB; 32]`), so `npub`, `node_addr`, and
1205    //! `ipv6_addr` are stable across runs and machines.
1206    //! Time-dependent scalars are redacted in `normalize_value` —
1207    //! see the `VOLATILE_KEYS` list there for the exact set.
1208    //! Empty arrays/maps are intrinsically stable and need no
1209    //! redaction.
1210    //!
1211    //! Schnorr signatures are non-deterministic, but the only
1212    //! signature surfaced by these handlers is `declaration_signed:
1213    //! bool` (a flag, not the signature itself), so no redaction is
1214    //! needed for that.
1215    use super::*;
1216    use crate::config::Config;
1217    use crate::identity::Identity;
1218    use crate::node::Node;
1219    use serde_json::{Map, Value, json};
1220    use std::path::PathBuf;
1221
1222    /// 32-byte seed for the deterministic test identity.
1223    /// Any non-zero secret-key-shaped value works; 0xAB-fill is just
1224    /// readable in hex.
1225    const TEST_SEED: [u8; 32] = [0xAB; 32];
1226
1227    /// Fields whose value is environment-, time-, or build-dependent
1228    /// and therefore must be redacted before comparison. Matched by
1229    /// JSON key name anywhere in the document.
1230    const VOLATILE_KEYS: &[&str] = &[
1231        // Process / build environment
1232        "version",
1233        "pid",
1234        "exe_path",
1235        "control_socket",
1236        "tun_name",
1237        // Filesystem layout (ACL, hosts, etc.)
1238        "allow_file",
1239        "deny_file",
1240        // Wall-clock derived
1241        "uptime_secs",
1242        "started_at_ms",
1243        "session_start_ms",
1244        "authenticated_at_ms",
1245        "last_seen_ms",
1246        "last_activity_ms",
1247        "last_recv_ms",
1248        "created_at_ms",
1249        "initiated_ms",
1250        "last_sent_ms",
1251        "age_ms",
1252        "last_used_ms",
1253        "idle_ms",
1254        "first_seen_secs_ago",
1255        "last_contact_secs_ago",
1256    ];
1257
1258    /// Build a Node with a fixed identity, default config, and empty
1259    /// runtime state (no peers, links, sessions, transports, or cache
1260    /// entries). This keeps every per-element list empty and every
1261    /// scalar deterministic modulo `VOLATILE_KEYS`.
1262    fn build_test_node() -> Node {
1263        let identity =
1264            Identity::from_secret_bytes(&TEST_SEED).expect("test seed is a valid secret key");
1265        let config = Config::new();
1266        Node::with_identity(identity, config).expect("default config is valid")
1267    }
1268
1269    /// Recursively walk a JSON value, replacing the value of any key
1270    /// listed in `VOLATILE_KEYS` with the literal string
1271    /// `"<redacted>"`. Array elements are recursed into.
1272    fn normalize_value(value: &mut Value) {
1273        match value {
1274            Value::Object(map) => {
1275                for (key, v) in map.iter_mut() {
1276                    if VOLATILE_KEYS.contains(&key.as_str()) {
1277                        *v = Value::String("<redacted>".to_string());
1278                    } else {
1279                        normalize_value(v);
1280                    }
1281                }
1282            }
1283            Value::Array(items) => {
1284                for item in items.iter_mut() {
1285                    normalize_value(item);
1286                }
1287            }
1288            _ => {}
1289        }
1290    }
1291
1292    /// Wrap a handler value in the on-the-wire `Response` envelope so
1293    /// the snapshot reflects exactly what a control-socket client
1294    /// receives. Pretty-printed and sorted-keyed for readable diffs.
1295    fn render(value: Value) -> String {
1296        let mut wrapped = json!({ "status": "ok", "data": value });
1297        normalize_value(&mut wrapped);
1298        let sorted = sort_object_keys(&wrapped);
1299        serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
1300    }
1301
1302    /// Same as `render` but takes a `Response` directly (for handlers
1303    /// that return `Response`, not `Value`).
1304    fn render_response(resp: super::super::protocol::Response) -> String {
1305        let value = serde_json::to_value(&resp).expect("response always serializes");
1306        let mut value = value;
1307        normalize_value(&mut value);
1308        let sorted = sort_object_keys(&value);
1309        serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
1310    }
1311
1312    /// Recursively sort object keys for stable diff-friendly output.
1313    /// `serde_json::Value` preserves insertion order; handlers don't
1314    /// guarantee any particular emit order, so normalize here.
1315    fn sort_object_keys(value: &Value) -> Value {
1316        match value {
1317            Value::Object(map) => {
1318                let mut sorted: Map<String, Value> = Map::new();
1319                let mut keys: Vec<&String> = map.keys().collect();
1320                keys.sort();
1321                for key in keys {
1322                    sorted.insert(key.clone(), sort_object_keys(&map[key]));
1323                }
1324                Value::Object(sorted)
1325            }
1326            Value::Array(items) => Value::Array(items.iter().map(sort_object_keys).collect()),
1327            other => other.clone(),
1328        }
1329    }
1330
1331    fn snapshot_dir() -> PathBuf {
1332        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1333            .join("src")
1334            .join("control")
1335            .join("snapshots")
1336    }
1337
1338    /// Compare `actual` against the on-disk fixture for `name`. If the
1339    /// fixture does not exist, write it (first-run convention) and
1340    /// pass. Any subsequent mismatch fails with an inline diff hint.
1341    fn assert_snapshot(name: &str, actual: &str) {
1342        let path = snapshot_dir().join(format!("{name}.json"));
1343        if !path.exists() {
1344            std::fs::create_dir_all(path.parent().unwrap())
1345                .expect("failed to create snapshots dir");
1346            std::fs::write(&path, actual).expect("failed to write new snapshot");
1347            // Newly written: nothing to compare. Subsequent runs enforce.
1348            return;
1349        }
1350        let expected = std::fs::read_to_string(&path)
1351            .unwrap_or_else(|e| panic!("failed to read snapshot {}: {e}", path.display()));
1352        // Normalize line endings: Windows checkouts with core.autocrlf=true
1353        // convert fixture files to CRLF; the in-memory JSON output is LF.
1354        let expected = expected.replace("\r\n", "\n");
1355        // Tolerate trailing newline differences from editors.
1356        if expected.trim_end() != actual.trim_end() {
1357            panic!(
1358                "snapshot mismatch for {name}\n\
1359                 fixture: {}\n\
1360                 -- expected --\n{expected}\n\
1361                 -- actual --\n{actual}\n\
1362                 -- end --\n\
1363                 If the schema change is intentional, delete the fixture \
1364                 and re-run to regenerate.",
1365                path.display()
1366            );
1367        }
1368    }
1369
1370    // ---- 18 handler snapshot tests --------------------------------------
1371
1372    #[test]
1373    fn snapshot_show_status() {
1374        let node = build_test_node();
1375        assert_snapshot("show_status", &render(show_status(&node)));
1376    }
1377
1378    #[test]
1379    fn snapshot_show_acl() {
1380        let node = build_test_node();
1381        assert_snapshot("show_acl", &render(show_acl(&node)));
1382    }
1383
1384    #[test]
1385    fn snapshot_show_peers() {
1386        let node = build_test_node();
1387        assert_snapshot("show_peers", &render(show_peers(&node)));
1388    }
1389
1390    #[test]
1391    fn snapshot_show_links() {
1392        let node = build_test_node();
1393        assert_snapshot("show_links", &render(show_links(&node)));
1394    }
1395
1396    #[test]
1397    fn snapshot_show_tree() {
1398        let node = build_test_node();
1399        assert_snapshot("show_tree", &render(show_tree(&node)));
1400    }
1401
1402    #[test]
1403    fn snapshot_show_sessions() {
1404        let node = build_test_node();
1405        assert_snapshot("show_sessions", &render(show_sessions(&node)));
1406    }
1407
1408    #[test]
1409    fn snapshot_show_bloom() {
1410        let node = build_test_node();
1411        assert_snapshot("show_bloom", &render(show_bloom(&node)));
1412    }
1413
1414    #[test]
1415    fn snapshot_show_mmp() {
1416        let node = build_test_node();
1417        assert_snapshot("show_mmp", &render(show_mmp(&node)));
1418    }
1419
1420    #[test]
1421    fn snapshot_show_cache() {
1422        let node = build_test_node();
1423        assert_snapshot("show_cache", &render(show_cache(&node)));
1424    }
1425
1426    #[test]
1427    fn snapshot_show_connections() {
1428        let node = build_test_node();
1429        assert_snapshot("show_connections", &render(show_connections(&node)));
1430    }
1431
1432    #[test]
1433    fn snapshot_show_transports() {
1434        let node = build_test_node();
1435        assert_snapshot("show_transports", &render(show_transports(&node)));
1436    }
1437
1438    #[test]
1439    fn snapshot_show_routing() {
1440        let node = build_test_node();
1441        assert_snapshot("show_routing", &render(show_routing(&node)));
1442    }
1443
1444    #[test]
1445    fn snapshot_show_identity_cache() {
1446        let node = build_test_node();
1447        assert_snapshot("show_identity_cache", &render(show_identity_cache(&node)));
1448    }
1449
1450    #[test]
1451    fn snapshot_show_listening_sockets() {
1452        let node = build_test_node();
1453        assert_snapshot(
1454            "show_listening_sockets",
1455            &render(show_listening_sockets(&node)),
1456        );
1457    }
1458
1459    #[test]
1460    fn snapshot_show_stats_list() {
1461        // Static — no Node needed.
1462        assert_snapshot("show_stats_list", &render(show_stats_list()));
1463    }
1464
1465    #[test]
1466    fn snapshot_show_stats_history() {
1467        let node = build_test_node();
1468        // Pin the empty-history series shape for one node-level metric.
1469        let params = json!({ "metric": "mesh_size", "window": "10s", "granularity": "1s" });
1470        let resp = show_stats_history(&node, Some(&params));
1471        assert_snapshot("show_stats_history", &render_response(resp));
1472    }
1473
1474    #[test]
1475    fn snapshot_show_stats_all_history() {
1476        let node = build_test_node();
1477        // Empty-history all-node series; small window keeps the
1478        // per-series `values` arrays short and stable.
1479        let params = json!({ "window": "10s", "granularity": "1s" });
1480        let resp = show_stats_all_history(&node, Some(&params));
1481        assert_snapshot("show_stats_all_history", &render_response(resp));
1482    }
1483
1484    #[test]
1485    fn snapshot_show_stats_peers() {
1486        let node = build_test_node();
1487        assert_snapshot("show_stats_peers", &render(show_stats_peers(&node)));
1488    }
1489
1490    #[test]
1491    fn snapshot_show_stats_history_all_peers() {
1492        let node = build_test_node();
1493        // No peers tracked → empty `peers: []` envelope. Per-peer
1494        // `values` shape is exercised once a real peer is wired in;
1495        // here we only pin the envelope.
1496        let params = json!({ "metric": "srtt_ms", "window": "10s", "granularity": "1s" });
1497        let resp = show_stats_history_all_peers(&node, Some(&params));
1498        assert_snapshot("show_stats_history_all_peers", &render_response(resp));
1499    }
1500
1501    /// Sanity check: every handler advertised in `dispatch` is also
1502    /// covered by a snapshot test above. If a new handler is added
1503    /// without a matching snapshot, this test fails.
1504    #[test]
1505    fn dispatch_covers_all_snapshotted_handlers() {
1506        let expected = [
1507            "show_status",
1508            "show_acl",
1509            "show_peers",
1510            "show_links",
1511            "show_tree",
1512            "show_sessions",
1513            "show_bloom",
1514            "show_mmp",
1515            "show_cache",
1516            "show_connections",
1517            "show_transports",
1518            "show_routing",
1519            "show_identity_cache",
1520            "show_listening_sockets",
1521            "show_stats_list",
1522            "show_stats_history",
1523            "show_stats_all_history",
1524            "show_stats_peers",
1525            "show_stats_history_all_peers",
1526        ];
1527        assert_eq!(expected.len(), 19, "expected exactly 19 query handlers");
1528        let node = build_test_node();
1529        for cmd in expected {
1530            // Each must dispatch successfully (status == "ok") with
1531            // minimal params. Handlers requiring params get them.
1532            let params = match cmd {
1533                "show_stats_history" => Some(json!({
1534                    "metric": "mesh_size", "window": "10s", "granularity": "1s"
1535                })),
1536                "show_stats_all_history" => Some(json!({ "window": "10s", "granularity": "1s" })),
1537                "show_stats_history_all_peers" => Some(json!({
1538                    "metric": "srtt_ms", "window": "10s", "granularity": "1s"
1539                })),
1540                _ => None,
1541            };
1542            let resp = dispatch(&node, cmd, params.as_ref());
1543            assert_eq!(
1544                resp.status, "ok",
1545                "dispatch({cmd}) returned status={} message={:?}",
1546                resp.status, resp.message
1547            );
1548        }
1549    }
1550}