Merge branch 'master' into next

# Conflicts:
#	src/node/tests/discovery.rs
#	testing/ci-local.sh
This commit is contained in:
Johnathan Corgan
2026-05-03 23:47:16 +00:00
51 changed files with 4107 additions and 44 deletions
+370
View File
@@ -1105,3 +1105,373 @@ pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::pr
_ => super::protocol::Response::error(format!("unknown command: {}", command)),
}
}
#[cfg(test)]
mod tests {
//! Schema-stability snapshot tests for all 18 control-socket query
//! handlers.
//!
//! Each handler is invoked against a deterministically-constructed
//! `Node` (fixed identity seed, empty peer/link/transport/cache
//! state). The resulting JSON is normalized — fields whose values
//! depend on wall-clock, PID, build environment, or filesystem
//! layout are replaced with the literal string `"<redacted>"` —
//! and compared against versioned fixtures under
//! `src/control/snapshots/`.
//!
//! The point is to catch accidental schema drift (renames, type
//! changes, dropped fields) in the operator-facing wire format.
//! Empty-state snapshots are sufficient because every top-level
//! key still appears, and per-element shapes inside `[]` arrays
//! are covered by the dispatcher contract test plus serde
//! derives elsewhere.
//!
//! ## Updating snapshots
//!
//! When a schema change is intentional, regenerate fixtures by
//! deleting the relevant `.json` files (or the whole
//! `snapshots/` directory) and re-running this test. Missing
//! fixtures are written from the current output rather than
//! failing — the next run then enforces the new shape. Review
//! the resulting diff before committing.
//!
//! ## Determinism
//!
//! The `Node` is built via `Node::with_identity` from a fixed
//! 32-byte seed (`[0xAB; 32]`), so `npub`, `node_addr`, and
//! `ipv6_addr` are stable across runs and machines.
//! Time-dependent scalars are redacted in `normalize_value` —
//! see the `VOLATILE_KEYS` list there for the exact set.
//! Empty arrays/maps are intrinsically stable and need no
//! redaction.
//!
//! Schnorr signatures are non-deterministic, but the only
//! signature surfaced by these handlers is `declaration_signed:
//! bool` (a flag, not the signature itself), so no redaction is
//! needed for that.
use super::*;
use crate::config::Config;
use crate::identity::Identity;
use crate::node::Node;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
/// 32-byte seed for the deterministic test identity.
/// Any non-zero secret-key-shaped value works; 0xAB-fill is just
/// readable in hex.
const TEST_SEED: [u8; 32] = [0xAB; 32];
/// Fields whose value is environment-, time-, or build-dependent
/// and therefore must be redacted before comparison. Matched by
/// JSON key name anywhere in the document.
const VOLATILE_KEYS: &[&str] = &[
// Process / build environment
"version",
"pid",
"exe_path",
"control_socket",
"tun_name",
// Filesystem layout (ACL, hosts, etc.)
"allow_file",
"deny_file",
// Wall-clock derived
"uptime_secs",
"started_at_ms",
"session_start_ms",
"authenticated_at_ms",
"last_seen_ms",
"last_activity_ms",
"last_recv_ms",
"created_at_ms",
"initiated_ms",
"last_sent_ms",
"age_ms",
"last_used_ms",
"idle_ms",
"first_seen_secs_ago",
"last_contact_secs_ago",
];
/// Build a Node with a fixed identity, default config, and empty
/// runtime state (no peers, links, sessions, transports, or cache
/// entries). This keeps every per-element list empty and every
/// scalar deterministic modulo `VOLATILE_KEYS`.
fn build_test_node() -> Node {
let identity =
Identity::from_secret_bytes(&TEST_SEED).expect("test seed is a valid secret key");
let config = Config::new();
Node::with_identity(identity, config).expect("default config is valid")
}
/// Recursively walk a JSON value, replacing the value of any key
/// listed in `VOLATILE_KEYS` with the literal string
/// `"<redacted>"`. Array elements are recursed into.
fn normalize_value(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, v) in map.iter_mut() {
if VOLATILE_KEYS.contains(&key.as_str()) {
*v = Value::String("<redacted>".to_string());
} else {
normalize_value(v);
}
}
}
Value::Array(items) => {
for item in items.iter_mut() {
normalize_value(item);
}
}
_ => {}
}
}
/// Wrap a handler value in the on-the-wire `Response` envelope so
/// the snapshot reflects exactly what a control-socket client
/// receives. Pretty-printed and sorted-keyed for readable diffs.
fn render(value: Value) -> String {
let mut wrapped = json!({ "status": "ok", "data": value });
normalize_value(&mut wrapped);
let sorted = sort_object_keys(&wrapped);
serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
}
/// Same as `render` but takes a `Response` directly (for handlers
/// that return `Response`, not `Value`).
fn render_response(resp: super::super::protocol::Response) -> String {
let value = serde_json::to_value(&resp).expect("response always serializes");
let mut value = value;
normalize_value(&mut value);
let sorted = sort_object_keys(&value);
serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
}
/// Recursively sort object keys for stable diff-friendly output.
/// `serde_json::Value` preserves insertion order; handlers don't
/// guarantee any particular emit order, so normalize here.
fn sort_object_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted: Map<String, Value> = Map::new();
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
sorted.insert(key.clone(), sort_object_keys(&map[key]));
}
Value::Object(sorted)
}
Value::Array(items) => Value::Array(items.iter().map(sort_object_keys).collect()),
other => other.clone(),
}
}
fn snapshot_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("control")
.join("snapshots")
}
/// Compare `actual` against the on-disk fixture for `name`. If the
/// fixture does not exist, write it (first-run convention) and
/// pass. Any subsequent mismatch fails with an inline diff hint.
fn assert_snapshot(name: &str, actual: &str) {
let path = snapshot_dir().join(format!("{name}.json"));
if !path.exists() {
std::fs::create_dir_all(path.parent().unwrap())
.expect("failed to create snapshots dir");
std::fs::write(&path, actual).expect("failed to write new snapshot");
// Newly written: nothing to compare. Subsequent runs enforce.
return;
}
let expected = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read snapshot {}: {e}", path.display()));
// Normalize line endings: Windows checkouts with core.autocrlf=true
// convert fixture files to CRLF; the in-memory JSON output is LF.
let expected = expected.replace("\r\n", "\n");
// Tolerate trailing newline differences from editors.
if expected.trim_end() != actual.trim_end() {
panic!(
"snapshot mismatch for {name}\n\
fixture: {}\n\
-- expected --\n{expected}\n\
-- actual --\n{actual}\n\
-- end --\n\
If the schema change is intentional, delete the fixture \
and re-run to regenerate.",
path.display()
);
}
}
// ---- 18 handler snapshot tests --------------------------------------
#[test]
fn snapshot_show_status() {
let node = build_test_node();
assert_snapshot("show_status", &render(show_status(&node)));
}
#[test]
fn snapshot_show_acl() {
let node = build_test_node();
assert_snapshot("show_acl", &render(show_acl(&node)));
}
#[test]
fn snapshot_show_peers() {
let node = build_test_node();
assert_snapshot("show_peers", &render(show_peers(&node)));
}
#[test]
fn snapshot_show_links() {
let node = build_test_node();
assert_snapshot("show_links", &render(show_links(&node)));
}
#[test]
fn snapshot_show_tree() {
let node = build_test_node();
assert_snapshot("show_tree", &render(show_tree(&node)));
}
#[test]
fn snapshot_show_sessions() {
let node = build_test_node();
assert_snapshot("show_sessions", &render(show_sessions(&node)));
}
#[test]
fn snapshot_show_bloom() {
let node = build_test_node();
assert_snapshot("show_bloom", &render(show_bloom(&node)));
}
#[test]
fn snapshot_show_mmp() {
let node = build_test_node();
assert_snapshot("show_mmp", &render(show_mmp(&node)));
}
#[test]
fn snapshot_show_cache() {
let node = build_test_node();
assert_snapshot("show_cache", &render(show_cache(&node)));
}
#[test]
fn snapshot_show_connections() {
let node = build_test_node();
assert_snapshot("show_connections", &render(show_connections(&node)));
}
#[test]
fn snapshot_show_transports() {
let node = build_test_node();
assert_snapshot("show_transports", &render(show_transports(&node)));
}
#[test]
fn snapshot_show_routing() {
let node = build_test_node();
assert_snapshot("show_routing", &render(show_routing(&node)));
}
#[test]
fn snapshot_show_identity_cache() {
let node = build_test_node();
assert_snapshot("show_identity_cache", &render(show_identity_cache(&node)));
}
#[test]
fn snapshot_show_stats_list() {
// Static — no Node needed.
assert_snapshot("show_stats_list", &render(show_stats_list()));
}
#[test]
fn snapshot_show_stats_history() {
let node = build_test_node();
// Pin the empty-history series shape for one node-level metric.
let params = json!({ "metric": "mesh_size", "window": "10s", "granularity": "1s" });
let resp = show_stats_history(&node, Some(&params));
assert_snapshot("show_stats_history", &render_response(resp));
}
#[test]
fn snapshot_show_stats_all_history() {
let node = build_test_node();
// Empty-history all-node series; small window keeps the
// per-series `values` arrays short and stable.
let params = json!({ "window": "10s", "granularity": "1s" });
let resp = show_stats_all_history(&node, Some(&params));
assert_snapshot("show_stats_all_history", &render_response(resp));
}
#[test]
fn snapshot_show_stats_peers() {
let node = build_test_node();
assert_snapshot("show_stats_peers", &render(show_stats_peers(&node)));
}
#[test]
fn snapshot_show_stats_history_all_peers() {
let node = build_test_node();
// No peers tracked → empty `peers: []` envelope. Per-peer
// `values` shape is exercised once a real peer is wired in;
// here we only pin the envelope.
let params = json!({ "metric": "srtt_ms", "window": "10s", "granularity": "1s" });
let resp = show_stats_history_all_peers(&node, Some(&params));
assert_snapshot("show_stats_history_all_peers", &render_response(resp));
}
/// Sanity check: every handler advertised in `dispatch` is also
/// covered by a snapshot test above. If a new handler is added
/// without a matching snapshot, this test fails.
#[test]
fn dispatch_covers_all_snapshotted_handlers() {
let expected = [
"show_status",
"show_acl",
"show_peers",
"show_links",
"show_tree",
"show_sessions",
"show_bloom",
"show_mmp",
"show_cache",
"show_connections",
"show_transports",
"show_routing",
"show_identity_cache",
"show_stats_list",
"show_stats_history",
"show_stats_all_history",
"show_stats_peers",
"show_stats_history_all_peers",
];
assert_eq!(expected.len(), 18, "expected exactly 18 query handlers");
let node = build_test_node();
for cmd in expected {
// Each must dispatch successfully (status == "ok") with
// minimal params. Handlers requiring params get them.
let params = match cmd {
"show_stats_history" => Some(json!({
"metric": "mesh_size", "window": "10s", "granularity": "1s"
})),
"show_stats_all_history" => Some(json!({ "window": "10s", "granularity": "1s" })),
"show_stats_history_all_peers" => Some(json!({
"metric": "srtt_ms", "window": "10s", "granularity": "1s"
})),
_ => None,
};
let resp = dispatch(&node, cmd, params.as_ref());
assert_eq!(
resp.status, "ok",
"dispatch({cmd}) returned status={} message={:?}",
resp.status, resp.message
);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"data": {
"allow_all": false,
"allow_entries": [],
"allow_file": "<redacted>",
"allow_file_entries": [],
"default_decision": "allow",
"deny_all": false,
"deny_entries": [],
"deny_file": "<redacted>",
"deny_file_entries": [],
"effective_mode": "default_open",
"enforcement_active": false
},
"status": "ok"
}
+24
View File
@@ -0,0 +1,24 @@
{
"data": {
"is_leaf_only": false,
"leaf_dependent_count": 0,
"leaf_dependents": [],
"own_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"peer_filters": [],
"sequence": 0,
"stats": {
"accepted": 0,
"debounce_suppressed": 0,
"decode_error": 0,
"fill_exceeded": 0,
"invalid": 0,
"non_v1": 0,
"received": 0,
"send_failed": 0,
"sent": 0,
"stale": 0,
"unknown_peer": 0
}
},
"status": "ok"
}
+12
View File
@@ -0,0 +1,12 @@
{
"data": {
"avg_age_ms": 0,
"count": 0,
"default_ttl_ms": 300000,
"entries": [],
"expired": 0,
"fill_ratio": 0.0,
"max_entries": 50000
},
"status": "ok"
}
@@ -0,0 +1,6 @@
{
"data": {
"connections": []
},
"status": "ok"
}
@@ -0,0 +1,8 @@
{
"data": {
"count": 0,
"entries": [],
"max_entries": 10000
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"links": []
},
"status": "ok"
}
+7
View File
@@ -0,0 +1,7 @@
{
"data": {
"peers": [],
"sessions": []
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"peers": []
},
"status": "ok"
}
+65
View File
@@ -0,0 +1,65 @@
{
"data": {
"congestion": {
"ce_forwarded": 0,
"ce_received": 0,
"congestion_detected": 0,
"kernel_drop_events": 0
},
"coord_cache_entries": 0,
"discovery": {
"req_backoff_suppressed": 0,
"req_bloom_miss": 0,
"req_decode_error": 0,
"req_deduplicated": 0,
"req_duplicate": 0,
"req_fallback_forwarded": 0,
"req_forward_rate_limited": 0,
"req_forwarded": 0,
"req_initiated": 0,
"req_no_tree_peer": 0,
"req_received": 0,
"req_target_is_us": 0,
"req_ttl_exhausted": 0,
"resp_accepted": 0,
"resp_decode_error": 0,
"resp_forwarded": 0,
"resp_identity_miss": 0,
"resp_proof_failed": 0,
"resp_received": 0,
"resp_timed_out": 0
},
"error_signals": {
"coords_required": 0,
"mtu_exceeded": 0,
"path_broken": 0
},
"forwarding": {
"decode_error_bytes": 0,
"decode_error_packets": 0,
"delivered_bytes": 0,
"delivered_packets": 0,
"drop_mtu_exceeded_bytes": 0,
"drop_mtu_exceeded_packets": 0,
"drop_no_route_bytes": 0,
"drop_no_route_packets": 0,
"drop_send_error_bytes": 0,
"drop_send_error_packets": 0,
"forwarded_bytes": 0,
"forwarded_packets": 0,
"originated_bytes": 0,
"originated_packets": 0,
"received_bytes": 0,
"received_packets": 0,
"ttl_exhausted_bytes": 0,
"ttl_exhausted_packets": 0
},
"identity_cache_entries": 0,
"pending_lookups": [],
"pending_tun_destinations": 0,
"pending_tun_packets": 0,
"recent_requests": 0,
"retries": []
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"sessions": []
},
"status": "ok"
}
@@ -0,0 +1,180 @@
{
"data": {
"granularity_seconds": 1,
"peer": null,
"series": [
{
"granularity_seconds": 1,
"metric": "mesh_size",
"unit": "nodes",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "tree_depth",
"unit": "hops",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "peer_count",
"unit": "peers",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "parent_switches",
"unit": "events/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "bytes_in",
"unit": "bytes/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "bytes_out",
"unit": "bytes/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "packets_in",
"unit": "packets/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "packets_out",
"unit": "packets/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "loss_rate",
"unit": "fraction",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "active_sessions",
"unit": "sessions",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
}
],
"window_seconds": 10
},
"status": "ok"
}
@@ -0,0 +1,20 @@
{
"data": {
"granularity_seconds": 1,
"metric": "mesh_size",
"unit": "nodes",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"status": "ok"
}
@@ -0,0 +1,10 @@
{
"data": {
"granularity_seconds": 1,
"metric": "srtt_ms",
"peers": [],
"unit": "ms",
"window_seconds": 10
},
"status": "ok"
}
@@ -0,0 +1,95 @@
{
"data": {
"fast_ring_seconds": 3600,
"metrics": [
{
"name": "mesh_size",
"scope": "node",
"unit": "nodes"
},
{
"name": "tree_depth",
"scope": "node",
"unit": "hops"
},
{
"name": "peer_count",
"scope": "node",
"unit": "peers"
},
{
"name": "parent_switches",
"scope": "node",
"unit": "events/s"
},
{
"name": "bytes_in",
"scope": "node",
"unit": "bytes/s"
},
{
"name": "bytes_out",
"scope": "node",
"unit": "bytes/s"
},
{
"name": "packets_in",
"scope": "node",
"unit": "packets/s"
},
{
"name": "packets_out",
"scope": "node",
"unit": "packets/s"
},
{
"name": "loss_rate",
"scope": "node",
"unit": "fraction"
},
{
"name": "active_sessions",
"scope": "node",
"unit": "sessions"
},
{
"name": "srtt_ms",
"scope": "peer",
"unit": "ms"
},
{
"name": "loss_rate",
"scope": "peer",
"unit": "fraction"
},
{
"name": "bytes_in",
"scope": "peer",
"unit": "bytes/s"
},
{
"name": "bytes_out",
"scope": "peer",
"unit": "bytes/s"
},
{
"name": "packets_in",
"scope": "peer",
"unit": "packets/s"
},
{
"name": "packets_out",
"scope": "peer",
"unit": "packets/s"
},
{
"name": "ecn_ce",
"scope": "peer",
"unit": "events/s"
}
],
"peer_retention_seconds": 86400,
"slow_ring_minutes": 1440
},
"status": "ok"
}
@@ -0,0 +1,7 @@
{
"data": {
"count": 0,
"peers": []
},
"status": "ok"
}
+52
View File
@@ -0,0 +1,52 @@
{
"data": {
"connection_count": 0,
"control_socket": "<redacted>",
"effective_ipv6_mtu": 1203,
"estimated_mesh_size": null,
"exe_path": "<redacted>",
"forwarding": {
"decode_error_bytes": 0,
"decode_error_packets": 0,
"delivered_bytes": 0,
"delivered_packets": 0,
"drop_mtu_exceeded_bytes": 0,
"drop_mtu_exceeded_packets": 0,
"drop_no_route_bytes": 0,
"drop_no_route_packets": 0,
"drop_send_error_bytes": 0,
"drop_send_error_packets": 0,
"forwarded_bytes": 0,
"forwarded_packets": 0,
"originated_bytes": 0,
"originated_packets": 0,
"received_bytes": 0,
"received_packets": 0,
"ttl_exhausted_bytes": 0,
"ttl_exhausted_packets": 0
},
"ipv6_addr": "fd1b:4788:b7ab:7a43:6a61:1fc5:9fb1:e34c",
"is_leaf_only": false,
"link_count": 0,
"node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"npub": "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r",
"peer_count": 0,
"pid": "<redacted>",
"session_count": 0,
"sparklines": {
"bytes_in": [],
"bytes_out": [],
"loss_rate": [],
"mesh_size": [],
"peer_count": [],
"tree_depth": []
},
"state": "created",
"transport_count": 0,
"tun_name": "<redacted>",
"tun_state": "disabled",
"uptime_secs": "<redacted>",
"version": "<redacted>"
},
"status": "ok"
}
@@ -0,0 +1,6 @@
{
"data": {
"transports": []
},
"status": "ok"
}
+36
View File
@@ -0,0 +1,36 @@
{
"data": {
"declaration_sequence": 1,
"declaration_signed": true,
"depth": 0,
"is_root": true,
"my_coords": [
"1b4788b7ab7a436a611fc59fb1e34c6e"
],
"my_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"parent": "1b4788b7ab7a436a611fc59fb1e34c6e",
"parent_display_name": "1b4788b7...",
"peer_tree_count": 0,
"peers": [],
"root": "1b4788b7ab7a436a611fc59fb1e34c6e",
"stats": {
"accepted": 0,
"addr_mismatch": 0,
"ancestry_changed": 0,
"decode_error": 0,
"flap_dampened": 0,
"loop_detected": 0,
"parent_losses": 0,
"parent_switched": 0,
"parent_switches": 0,
"rate_limited": 0,
"received": 0,
"send_failed": 0,
"sent": 0,
"sig_failed": 0,
"stale": 0,
"unknown_peer": 0
}
},
"status": "ok"
}