1use 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
13fn 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
20fn 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
28fn 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
43pub 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 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
91pub 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
110pub 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 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 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 if let Some(addr) = peer.current_addr() {
159 peer_json["transport_addr"] = json!(format!("{}", addr));
160 }
161
162 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 if let Some(coords) = peer.coords() {
174 peer_json["tree_depth"] = json!(coords.depth());
175 }
176
177 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 peer_json["replay_suppressed"] = json!(peer.replay_suppressed_count());
188 peer_json["consecutive_decrypt_failures"] = json!(peer.consecutive_decrypt_failures());
189
190 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 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 if let Some(idx) = peer.our_index() {
225 peer_json["our_session_index"] = json!(format!("{:08x}", idx.as_u32()));
226 }
227
228 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 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
271pub 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
298pub 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 let coords: Vec<String> = my_coords
306 .entries()
307 .iter()
308 .map(|e| hex::encode(e.node_addr.as_bytes()))
309 .collect();
310
311 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 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
357pub 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 let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
382 session_json["npub"] = json!(encode_npub(&xonly));
383
384 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 if !entry.is_established() {
395 session_json["resend_count"] = json!(entry.resend_count());
396 }
397
398 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 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
441pub 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 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
485pub fn show_mmp(node: &Node) -> Value {
487 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 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 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
580pub 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 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
624pub 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
650pub 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 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
690pub 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 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 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
744pub 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
776pub 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
803pub 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
872fn 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
890pub 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 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
969pub 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 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
1022pub 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 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
1110pub 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
1146pub 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 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 const TEST_SEED: [u8; 32] = [0xAB; 32];
1226
1227 const VOLATILE_KEYS: &[&str] = &[
1231 "version",
1233 "pid",
1234 "exe_path",
1235 "control_socket",
1236 "tun_name",
1237 "allow_file",
1239 "deny_file",
1240 "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 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 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 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 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 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 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 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 let expected = expected.replace("\r\n", "\n");
1355 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 #[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 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 let params = json!({ "metric": "mesh_size", "window": "10s", "granularity": "1s" });
1470 let resp = show_stats_history(&node, Some(¶ms));
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 let params = json!({ "window": "10s", "granularity": "1s" });
1480 let resp = show_stats_all_history(&node, Some(¶ms));
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 let params = json!({ "metric": "srtt_ms", "window": "10s", "granularity": "1s" });
1497 let resp = show_stats_history_all_peers(&node, Some(¶ms));
1498 assert_snapshot("show_stats_history_all_peers", &render_response(resp));
1499 }
1500
1501 #[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 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}