mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 16:24:45 +00:00
Merge branch 'master' into next
# Conflicts: # src/node/tests/discovery.rs # testing/ci-local.sh
This commit is contained in:
@@ -1096,6 +1096,52 @@ mod tests {
|
||||
assert_eq!(c.startup_sweep_max_age_secs, 3_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_level_parser() {
|
||||
// Pin the observed behavior of NodeConfig::log_level():
|
||||
// - 5 explicit lowercased match arms (trace/debug/warn|warning/error)
|
||||
// - INFO is the default (no explicit "info" arm; falls through default)
|
||||
// - Case-insensitive via .to_lowercase()
|
||||
// - Unknown strings and None both fall through to INFO
|
||||
let cases: &[(Option<&str>, tracing::Level)] = &[
|
||||
// Explicit arms (lowercase canonical form)
|
||||
(Some("trace"), tracing::Level::TRACE),
|
||||
(Some("debug"), tracing::Level::DEBUG),
|
||||
(Some("warn"), tracing::Level::WARN),
|
||||
(Some("warning"), tracing::Level::WARN),
|
||||
(Some("error"), tracing::Level::ERROR),
|
||||
// "info" has no explicit arm — falls through default
|
||||
(Some("info"), tracing::Level::INFO),
|
||||
// None → default INFO
|
||||
(None, tracing::Level::INFO),
|
||||
// Case-insensitivity (parser lowercases via .to_lowercase())
|
||||
(Some("TRACE"), tracing::Level::TRACE),
|
||||
(Some("Debug"), tracing::Level::DEBUG),
|
||||
(Some("Warning"), tracing::Level::WARN),
|
||||
(Some("WARN"), tracing::Level::WARN),
|
||||
(Some("ERROR"), tracing::Level::ERROR),
|
||||
(Some("INFO"), tracing::Level::INFO),
|
||||
// Unknown strings → INFO default (no error path)
|
||||
(Some("verbose"), tracing::Level::INFO),
|
||||
(Some("nonsense"), tracing::Level::INFO),
|
||||
(Some(""), tracing::Level::INFO),
|
||||
];
|
||||
|
||||
for (input, expected) in cases {
|
||||
let cfg = NodeConfig {
|
||||
log_level: input.map(|s| s.to_string()),
|
||||
..NodeConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.log_level(),
|
||||
*expected,
|
||||
"input {:?} should map to {:?}",
|
||||
input,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_default_socket_path_windows() {
|
||||
|
||||
@@ -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(¶ms));
|
||||
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(¶ms));
|
||||
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(¶ms));
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"data": {
|
||||
"links": []
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"data": {
|
||||
"peers": [],
|
||||
"sessions": []
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"data": {
|
||||
"peers": []
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1100,3 +1100,69 @@ impl NostrDiscovery {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl NostrDiscovery {
|
||||
/// Build a minimal `NostrDiscovery` for unit tests. No relay client is
|
||||
/// connected and no background tasks are spawned; only the in-memory
|
||||
/// `advert_cache` and `npub` are usable. Intended for cache-injection
|
||||
/// tests of consumers (e.g. `Node::run_open_discovery_sweep`).
|
||||
pub(crate) fn new_for_test() -> Self {
|
||||
let keys = nostr::Keys::generate();
|
||||
let pubkey = keys.public_key();
|
||||
let npub = pubkey.to_bech32().expect("bech32 encode");
|
||||
let client = Client::builder()
|
||||
.signer(keys.clone())
|
||||
.opts(ClientOptions::new().autoconnect(false))
|
||||
.build();
|
||||
let config = NostrDiscoveryConfig::default();
|
||||
let offer_slots = Arc::new(Semaphore::new(config.max_concurrent_incoming_offers));
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
client,
|
||||
keys,
|
||||
pubkey,
|
||||
npub,
|
||||
config,
|
||||
advert_cache: RwLock::new(HashMap::new()),
|
||||
local_advert: RwLock::new(None),
|
||||
current_advert_event_id: RwLock::new(None),
|
||||
pending_answers: Mutex::new(HashMap::new()),
|
||||
active_initiators: Mutex::new(HashSet::new()),
|
||||
seen_sessions: Mutex::new(HashMap::new()),
|
||||
offer_slots,
|
||||
event_tx,
|
||||
event_rx: Mutex::new(event_rx),
|
||||
notify_task: Mutex::new(None),
|
||||
advertise_task: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `CachedOverlayAdvert` for tests with a single endpoint and
|
||||
/// a generous validity window (one hour from `now_ms()`).
|
||||
pub(crate) fn cached_advert_for_test(
|
||||
author_npub: String,
|
||||
endpoint: OverlayEndpointAdvert,
|
||||
created_at_secs: u64,
|
||||
) -> CachedOverlayAdvert {
|
||||
CachedOverlayAdvert {
|
||||
author_npub: author_npub.clone(),
|
||||
advert: OverlayAdvert {
|
||||
identifier: ADVERT_IDENTIFIER.to_string(),
|
||||
version: ADVERT_VERSION,
|
||||
endpoints: vec![endpoint],
|
||||
signal_relays: None,
|
||||
stun_servers: None,
|
||||
},
|
||||
created_at: created_at_secs,
|
||||
valid_until_ms: now_ms().saturating_add(3_600_000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a cached advert directly into the in-memory cache. Used by
|
||||
/// unit tests to set up consumer-side state without needing live relays.
|
||||
pub(crate) async fn insert_advert_for_test(&self, npub: String, advert: CachedOverlayAdvert) {
|
||||
let mut cache = self.advert_cache.write().await;
|
||||
cache.insert(npub, advert);
|
||||
}
|
||||
}
|
||||
|
||||
+120
-1
@@ -354,9 +354,128 @@ fn random_txn_id() -> [u8; 12] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_private_overlay_candidate_ip;
|
||||
use super::{is_private_overlay_candidate_ip, parse_stun_binding_success};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
const STUN_MAGIC_COOKIE: u32 = 0x2112_a442;
|
||||
const TEST_TXN_ID: [u8; 12] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
|
||||
];
|
||||
|
||||
/// Build a STUN Binding Success header with the given message length and txn id.
|
||||
fn build_success_header(message_length: u16, txn_id: &[u8; 12]) -> Vec<u8> {
|
||||
let mut packet = Vec::with_capacity(20);
|
||||
packet.extend_from_slice(&0x0101u16.to_be_bytes()); // Binding Success
|
||||
packet.extend_from_slice(&message_length.to_be_bytes());
|
||||
packet.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
|
||||
packet.extend_from_slice(txn_id);
|
||||
packet
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stun_binding_success_rejects_truncated_header() {
|
||||
// Anything shorter than the 20-byte header must be rejected.
|
||||
for len in 0..20usize {
|
||||
let packet = vec![0u8; len];
|
||||
assert!(
|
||||
parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none(),
|
||||
"expected None for {}-byte packet",
|
||||
len
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stun_binding_success_rejects_bad_magic_cookie() {
|
||||
let mut packet = build_success_header(0, &TEST_TXN_ID);
|
||||
// Corrupt the magic cookie at bytes 4..8.
|
||||
packet[4..8].copy_from_slice(&0xdead_beefu32.to_be_bytes());
|
||||
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stun_binding_success_skips_unknown_attribute_type() {
|
||||
// Unknown attribute (type 0x00ff, 4-byte body) followed by a valid
|
||||
// XOR-MAPPED-ADDRESS. The parser should skip the unknown attr and
|
||||
// still return the mapped address from the second TLV.
|
||||
let mut packet = build_success_header(0, &TEST_TXN_ID);
|
||||
|
||||
// Unknown attribute: type=0x00ff, len=4, body=4 zero bytes.
|
||||
packet.extend_from_slice(&0x00ffu16.to_be_bytes());
|
||||
packet.extend_from_slice(&4u16.to_be_bytes());
|
||||
packet.extend_from_slice(&[0u8; 4]);
|
||||
|
||||
// XOR-MAPPED-ADDRESS for 192.0.2.1:1234.
|
||||
// Build the unxored body, then XOR with cookie/txn so the parser
|
||||
// recovers the original IP/port.
|
||||
let cookie = STUN_MAGIC_COOKIE.to_be_bytes();
|
||||
let xport = 1234u16 ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
|
||||
let xip = [192 ^ cookie[0], cookie[1], 2 ^ cookie[2], 1 ^ cookie[3]];
|
||||
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
|
||||
packet.extend_from_slice(&8u16.to_be_bytes()); // length
|
||||
packet.push(0x00); // reserved
|
||||
packet.push(0x01); // family IPv4
|
||||
packet.extend_from_slice(&xport.to_be_bytes());
|
||||
packet.extend_from_slice(&xip);
|
||||
|
||||
// Patch the message length (everything after the 20-byte header).
|
||||
let body_len = (packet.len() - 20) as u16;
|
||||
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
|
||||
|
||||
let mapped = parse_stun_binding_success(&packet, &TEST_TXN_ID)
|
||||
.expect("parser should skip unknown attr and find XOR-MAPPED-ADDRESS");
|
||||
assert_eq!(mapped.ip(), IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)));
|
||||
assert_eq!(mapped.port(), 1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stun_binding_success_rejects_truncated_xor_mapped_address() {
|
||||
// XOR-MAPPED-ADDRESS attribute with family=IPv4 but only 6 bytes of
|
||||
// body (need 8). Parser should silently ignore and return None.
|
||||
let mut packet = build_success_header(0, &TEST_TXN_ID);
|
||||
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
|
||||
packet.extend_from_slice(&6u16.to_be_bytes()); // declared length 6 (too short)
|
||||
packet.push(0x00); // reserved
|
||||
packet.push(0x01); // family IPv4
|
||||
packet.extend_from_slice(&[0u8; 4]); // truncated: port + partial IP only
|
||||
|
||||
let body_len = (packet.len() - 20) as u16;
|
||||
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
|
||||
|
||||
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stun_binding_success_rejects_length_overflow_attribute() {
|
||||
// Attribute declares length larger than what's actually present in
|
||||
// the buffer; parser must break out of the loop and return None
|
||||
// rather than panic or read past the end.
|
||||
let mut packet = build_success_header(0, &TEST_TXN_ID);
|
||||
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
|
||||
packet.extend_from_slice(&64u16.to_be_bytes()); // claims 64 bytes...
|
||||
packet.extend_from_slice(&[0u8; 4]); // ...but only 4 bytes follow
|
||||
|
||||
let body_len = (packet.len() - 20) as u16;
|
||||
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
|
||||
|
||||
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stun_binding_success_rejects_txn_id_mismatch() {
|
||||
// Valid header + valid XOR-MAPPED-ADDRESS, but txn id in the packet
|
||||
// does not match the expected one. Parser must reject.
|
||||
let other_txn: [u8; 12] = [0xff; 12];
|
||||
let mut packet = build_success_header(12, &other_txn);
|
||||
packet.extend_from_slice(&0x0020u16.to_be_bytes());
|
||||
packet.extend_from_slice(&8u16.to_be_bytes());
|
||||
packet.push(0x00);
|
||||
packet.push(0x01);
|
||||
packet.extend_from_slice(&[0u8; 6]);
|
||||
|
||||
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_overlay_candidate_filter_includes_rfc1918_and_ula() {
|
||||
assert!(is_private_overlay_candidate_ip(IpAddr::V4(Ipv4Addr::new(
|
||||
|
||||
@@ -219,7 +219,7 @@ impl Node {
|
||||
}
|
||||
|
||||
/// Increment decrypt failure counter and force-remove peer if threshold exceeded.
|
||||
fn handle_decrypt_failure(&mut self, node_addr: &crate::NodeAddr) {
|
||||
pub(in crate::node) fn handle_decrypt_failure(&mut self, node_addr: &crate::NodeAddr) {
|
||||
if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
let count = peer.increment_decrypt_failures();
|
||||
if count >= DECRYPT_FAILURE_THRESHOLD {
|
||||
|
||||
@@ -1207,7 +1207,7 @@ impl Node {
|
||||
///
|
||||
/// `caller` is a short label included in log lines so per-tick and
|
||||
/// startup sweeps are distinguishable in operator-facing logs.
|
||||
async fn run_open_discovery_sweep(
|
||||
pub(in crate::node) async fn run_open_discovery_sweep(
|
||||
&mut self,
|
||||
bootstrap: &std::sync::Arc<NostrDiscovery>,
|
||||
max_age_secs: Option<u64>,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Tests for the consecutive-decrypt-failure threshold force-removal path.
|
||||
//!
|
||||
//! Covers `Node::handle_decrypt_failure` (in `node/handlers/encrypted.rs`),
|
||||
//! which increments `ActivePeer::increment_decrypt_failures` on each AEAD
|
||||
//! verification failure and force-removes the peer once
|
||||
//! `DECRYPT_FAILURE_THRESHOLD` consecutive failures are observed. The
|
||||
//! threshold is a defensive signal against a peer whose session is
|
||||
//! desynchronized or under attack, so regression coverage of the wiring
|
||||
//! between counter, threshold, and peer eviction is security-relevant.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Drive a fully-promoted peer to the decrypt-failure threshold and verify
|
||||
/// it is removed from both `peers` and `peers_by_index`.
|
||||
///
|
||||
/// Setup uses the `make_completed_connection` harness so the peer has a
|
||||
/// real `our_index`/`transport_id`, ensuring `remove_active_peer` exercises
|
||||
/// the full `peers_by_index` cleanup path (not just the bare `peers` table).
|
||||
#[test]
|
||||
fn test_decrypt_failure_threshold_removes_peer() {
|
||||
// Threshold constant in node/handlers/encrypted.rs (kept in sync with
|
||||
// production code; see DECRYPT_FAILURE_THRESHOLD).
|
||||
const THRESHOLD: u32 = 20;
|
||||
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let link_id = LinkId::new(1);
|
||||
|
||||
// Build a fully-promoted active peer with our_index/transport_id set
|
||||
// so peers_by_index is populated by promote_connection.
|
||||
let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1_000);
|
||||
let node_addr = *identity.node_addr();
|
||||
|
||||
node.add_connection(conn).unwrap();
|
||||
node.promote_connection(link_id, identity, 2_000).unwrap();
|
||||
|
||||
// Sanity: peer is registered and indexed.
|
||||
assert_eq!(node.peer_count(), 1, "peer should be present after promote");
|
||||
let our_index = node
|
||||
.get_peer(&node_addr)
|
||||
.and_then(|p| p.our_index())
|
||||
.expect("promoted peer must have our_index");
|
||||
assert_eq!(
|
||||
node.peers_by_index.get(&(transport_id, our_index.as_u32())),
|
||||
Some(&node_addr),
|
||||
"peers_by_index must be populated after promote"
|
||||
);
|
||||
assert_eq!(
|
||||
node.get_peer(&node_addr)
|
||||
.unwrap()
|
||||
.consecutive_decrypt_failures(),
|
||||
0,
|
||||
"fresh peer's failure counter must start at zero"
|
||||
);
|
||||
|
||||
// Drive failures up to (but not including) the threshold; peer must
|
||||
// remain present and the counter must increase monotonically.
|
||||
for expected in 1..THRESHOLD {
|
||||
node.handle_decrypt_failure(&node_addr);
|
||||
let count = node
|
||||
.get_peer(&node_addr)
|
||||
.expect("peer must still be present below threshold")
|
||||
.consecutive_decrypt_failures();
|
||||
assert_eq!(
|
||||
count, expected,
|
||||
"counter should track failures pre-threshold"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
node.peer_count(),
|
||||
1,
|
||||
"peer must remain registered until threshold is reached"
|
||||
);
|
||||
|
||||
// The Nth failure crosses the threshold and triggers force-removal.
|
||||
node.handle_decrypt_failure(&node_addr);
|
||||
|
||||
assert!(
|
||||
node.get_peer(&node_addr).is_none(),
|
||||
"peer must be removed from peers table at threshold"
|
||||
);
|
||||
assert_eq!(
|
||||
node.peer_count(),
|
||||
0,
|
||||
"peer_count must be zero after eviction"
|
||||
);
|
||||
assert!(
|
||||
!node
|
||||
.peers_by_index
|
||||
.contains_key(&(transport_id, our_index.as_u32())),
|
||||
"peers_by_index entry must be cleaned up at threshold"
|
||||
);
|
||||
}
|
||||
@@ -1033,3 +1033,311 @@ async fn test_response_path_mtu_four_node_chain() {
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Open-Discovery Sweep — cache-injection unit test
|
||||
// ============================================================================
|
||||
|
||||
/// Pin the iterate-filter-queue contract of `run_open_discovery_sweep`.
|
||||
///
|
||||
/// Builds a `Node` with `nostr.policy = Open` and an empty peer list,
|
||||
/// then injects three cached adverts into a test `NostrDiscovery` and
|
||||
/// asserts the sweep:
|
||||
/// - queues a retry for an eligible (unknown, not-self) advert,
|
||||
/// - skips the advert whose author is our own node identity, and
|
||||
/// - skips the advert whose author is an already-connected peer.
|
||||
///
|
||||
/// Uses `NostrDiscovery::new_for_test()` and `insert_advert_for_test()`
|
||||
/// (both `#[cfg(test)]`-gated test escape hatches in
|
||||
/// `src/discovery/nostr/runtime.rs`) to populate the cache without
|
||||
/// requiring live relay subscriptions.
|
||||
#[tokio::test]
|
||||
async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
|
||||
use crate::config::NostrDiscoveryPolicy;
|
||||
use crate::discovery::nostr::{NostrDiscovery, OverlayEndpointAdvert, OverlayTransportKind};
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::transport::LinkId;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Build node with open-discovery enabled.
|
||||
let mut config = crate::Config::new();
|
||||
config.node.discovery.nostr.enabled = true;
|
||||
config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
|
||||
let mut node = crate::Node::new(config).unwrap();
|
||||
|
||||
// Identity of an already-connected peer; insert into node.peers
|
||||
// so the sweep's `self.peers.contains_key(&node_addr)` filter fires.
|
||||
let connected_identity = crate::Identity::generate();
|
||||
let connected_npub = crate::encode_npub(&connected_identity.pubkey());
|
||||
let connected_node_addr = *connected_identity.node_addr();
|
||||
let connected_peer_identity = crate::PeerIdentity::from_pubkey(connected_identity.pubkey());
|
||||
node.peers.insert(
|
||||
connected_node_addr,
|
||||
ActivePeer::new(connected_peer_identity, LinkId::new(1), 1_000),
|
||||
);
|
||||
|
||||
// Eligible peer: fresh identity not in node.peers / retry_pending.
|
||||
let eligible_identity = crate::Identity::generate();
|
||||
let eligible_npub = crate::encode_npub(&eligible_identity.pubkey());
|
||||
let eligible_node_addr = *eligible_identity.node_addr();
|
||||
|
||||
// Self filter: advert authored by node's own identity.
|
||||
let self_npub = crate::encode_npub(&node.identity().pubkey());
|
||||
let self_node_addr = *node.identity().node_addr();
|
||||
|
||||
// Build a NostrDiscovery test instance and inject the three adverts.
|
||||
let bootstrap = Arc::new(NostrDiscovery::new_for_test());
|
||||
let endpoint = OverlayEndpointAdvert {
|
||||
transport: OverlayTransportKind::Udp,
|
||||
addr: "203.0.113.7:2121".to_string(),
|
||||
};
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
for npub in [&eligible_npub, &connected_npub, &self_npub] {
|
||||
let advert =
|
||||
NostrDiscovery::cached_advert_for_test(npub.clone(), endpoint.clone(), now_secs);
|
||||
bootstrap.insert_advert_for_test(npub.clone(), advert).await;
|
||||
}
|
||||
|
||||
// Run the sweep.
|
||||
node.run_open_discovery_sweep(&bootstrap, Some(3_600), "test")
|
||||
.await;
|
||||
|
||||
// Eligible peer was queued.
|
||||
assert!(
|
||||
node.retry_pending.contains_key(&eligible_node_addr),
|
||||
"eligible advert should be queued for retry"
|
||||
);
|
||||
let queued = node.retry_pending.get(&eligible_node_addr).unwrap();
|
||||
assert_eq!(queued.peer_config.npub, eligible_npub);
|
||||
|
||||
// Connected-peer skip filter held.
|
||||
assert!(
|
||||
!node.retry_pending.contains_key(&connected_node_addr),
|
||||
"advert for already-connected peer must not be queued"
|
||||
);
|
||||
|
||||
// Self skip filter held.
|
||||
assert!(
|
||||
!node.retry_pending.contains_key(&self_node_addr),
|
||||
"advert authored by own node must not be queued"
|
||||
);
|
||||
|
||||
// Exactly one queued entry from the three injected adverts.
|
||||
assert_eq!(node.retry_pending.len(), 1);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-Attempt Timeout State Machine — IF-3-A
|
||||
// ============================================================================
|
||||
|
||||
/// Pin the per-attempt timeout sequence in `check_pending_lookups`.
|
||||
///
|
||||
/// Drives the state machine deterministically through the default
|
||||
/// `node.discovery.attempt_timeouts_secs = [1, 2, 4, 8]` sequence.
|
||||
/// Asserts:
|
||||
/// 1. **Sequence timing** — retries fire at the cumulative deadlines
|
||||
/// (t=1100ms, 3100ms, 7100ms) and unreachable at t=15100ms.
|
||||
/// 2. **Fresh `initiate_lookup` per attempt** — `req_initiated` counter
|
||||
/// increments by exactly one on each retry. The actual `request_id`
|
||||
/// is generated by `LookupRequest::generate(...)` via `rand::random()`
|
||||
/// inside `initiate_lookup` and is not stored on the originator
|
||||
/// side, so per-attempt freshness is verified indirectly: each
|
||||
/// `req_initiated` increment corresponds to one fresh
|
||||
/// `LookupRequest::generate` call.
|
||||
/// 3. **Final-timeout state transitions** — `pending_lookups` entry is
|
||||
/// removed, `discovery.resp_timed_out` counter ticks, queued packet
|
||||
/// is drained, and an ICMPv6 Destination Unreachable frame is
|
||||
/// emitted via the TUN sender.
|
||||
///
|
||||
/// Skipped: direct request_id capture (originator does not record its
|
||||
/// own request_ids; would require production instrumentation). The
|
||||
/// `req_initiated` counter is the strongest cleanly-observable signal
|
||||
/// that `initiate_lookup` ran fresh on each attempt.
|
||||
#[tokio::test]
|
||||
async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::node::handlers::discovery::PendingLookup;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::transport::LinkId;
|
||||
use std::sync::mpsc;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
// Default attempt_timeouts_secs is [1, 2, 4, 8]. Confirm so the test
|
||||
// cannot silently drift if the default changes.
|
||||
assert_eq!(
|
||||
node.config.node.discovery.attempt_timeouts_secs,
|
||||
vec![1, 2, 4, 8],
|
||||
"test pins the [1,2,4,8] default; update the test if the default changes"
|
||||
);
|
||||
|
||||
// Inject a TUN sender so `send_icmpv6_dest_unreachable` is observable.
|
||||
let (tun_tx, tun_rx) = mpsc::channel::<Vec<u8>>();
|
||||
node.tun_tx = Some(tun_tx);
|
||||
|
||||
// Build a target identity (the unreachable destination).
|
||||
let target_identity = Identity::generate();
|
||||
let target_addr = *target_identity.node_addr();
|
||||
|
||||
// Build a tree-peer that:
|
||||
// - has the target in its inbound bloom filter (so `may_reach` is true),
|
||||
// - declares us as its parent (so `is_tree_peer` returns true).
|
||||
// The peer has no Noise session, so `send_encrypted_link_message` will
|
||||
// fail at the wire-send step — but `initiate_lookup` already incremented
|
||||
// `req_initiated` and the failure is logged at `debug!`. The state-
|
||||
// machine bookkeeping we want to test runs to completion either way.
|
||||
let peer_identity_full = Identity::generate();
|
||||
let peer_addr = *peer_identity_full.node_addr();
|
||||
let peer_identity = crate::PeerIdentity::from_pubkey(peer_identity_full.pubkey());
|
||||
let mut peer = ActivePeer::new(peer_identity, LinkId::new(1), 0);
|
||||
let mut bloom = BloomFilter::new();
|
||||
bloom.insert(&target_addr);
|
||||
peer.update_filter(bloom, 1, 0);
|
||||
node.peers.insert(peer_addr, peer);
|
||||
|
||||
// Make the peer a tree-peer: install a peer declaration that names us
|
||||
// as its parent. `is_tree_peer` checks both directions — the child
|
||||
// direction (peer.parent_id == self.node_addr) is what we exercise.
|
||||
let our_addr = *node.node_addr();
|
||||
let peer_decl = crate::tree::ParentDeclaration::new(peer_addr, our_addr, 1, 0);
|
||||
let peer_coords = TreeCoordinate::from_addrs(vec![peer_addr, our_addr]).unwrap();
|
||||
node.tree_state_mut().update_peer(peer_decl, peer_coords);
|
||||
assert!(node.is_tree_peer(&peer_addr), "peer must be a tree peer");
|
||||
|
||||
// Queue an IPv6 packet for the target so the final-timeout drop +
|
||||
// ICMPv6 emission can be observed. Build a minimal valid IPv6 header
|
||||
// with a non-multicast, non-unspecified source so
|
||||
// `should_send_icmp_error` returns true.
|
||||
let mut ipv6_pkt = vec![0u8; 40];
|
||||
ipv6_pkt[0] = 0x60; // version 6
|
||||
ipv6_pkt[6] = 17; // next_header = UDP (not ICMPv6)
|
||||
ipv6_pkt[7] = 64; // hop limit
|
||||
// src = fd00::1 (non-multicast, non-unspecified)
|
||||
ipv6_pkt[8] = 0xfd;
|
||||
ipv6_pkt[23] = 0x01;
|
||||
// dst = target's IPv6 representation (not strictly required, just non-multicast)
|
||||
let target_ipv6 = crate::FipsAddress::from_node_addr(&target_addr).to_ipv6();
|
||||
ipv6_pkt[24..40].copy_from_slice(&target_ipv6.octets());
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back(ipv6_pkt);
|
||||
node.pending_tun_packets.insert(target_addr, queue);
|
||||
|
||||
// Inject a PendingLookup directly: attempt=1, last_sent_ms=0. This
|
||||
// mirrors the post-condition of a successful `maybe_initiate_lookup`
|
||||
// at t=0 without depending on wall-clock-derived `Self::now_ms()`.
|
||||
node.pending_lookups
|
||||
.insert(target_addr, PendingLookup::new(0));
|
||||
|
||||
let baseline_initiated = node.stats().discovery.req_initiated;
|
||||
let baseline_timed_out = node.stats().discovery.resp_timed_out;
|
||||
|
||||
// --- t = 1100ms: first retry deadline (1*1000) ---
|
||||
node.check_pending_lookups(1100).await;
|
||||
{
|
||||
let entry = node
|
||||
.pending_lookups
|
||||
.get(&target_addr)
|
||||
.expect("still pending");
|
||||
assert_eq!(entry.attempt, 2, "after retry #1, attempt should be 2");
|
||||
assert_eq!(entry.last_sent_ms, 1100);
|
||||
}
|
||||
assert_eq!(
|
||||
node.stats().discovery.req_initiated,
|
||||
baseline_initiated + 1,
|
||||
"retry #1 must invoke initiate_lookup exactly once"
|
||||
);
|
||||
|
||||
// --- t = 3100ms: second retry deadline (cumulative 1+2 = 3s) ---
|
||||
node.check_pending_lookups(3100).await;
|
||||
{
|
||||
let entry = node
|
||||
.pending_lookups
|
||||
.get(&target_addr)
|
||||
.expect("still pending");
|
||||
assert_eq!(entry.attempt, 3, "after retry #2, attempt should be 3");
|
||||
assert_eq!(entry.last_sent_ms, 3100);
|
||||
}
|
||||
assert_eq!(
|
||||
node.stats().discovery.req_initiated,
|
||||
baseline_initiated + 2,
|
||||
"retry #2 must invoke initiate_lookup exactly once more"
|
||||
);
|
||||
|
||||
// --- t = 7100ms: third retry deadline (cumulative 1+2+4 = 7s) ---
|
||||
node.check_pending_lookups(7100).await;
|
||||
{
|
||||
let entry = node
|
||||
.pending_lookups
|
||||
.get(&target_addr)
|
||||
.expect("still pending");
|
||||
assert_eq!(entry.attempt, 4, "after retry #3, attempt should be 4");
|
||||
assert_eq!(entry.last_sent_ms, 7100);
|
||||
}
|
||||
assert_eq!(
|
||||
node.stats().discovery.req_initiated,
|
||||
baseline_initiated + 3,
|
||||
"retry #3 must invoke initiate_lookup exactly once more"
|
||||
);
|
||||
|
||||
// --- Just-before-final: at t=15099ms the 8s window is not yet reached ---
|
||||
node.check_pending_lookups(15_099).await;
|
||||
assert!(
|
||||
node.pending_lookups.contains_key(&target_addr),
|
||||
"8s window not yet expired: pending_lookup must persist"
|
||||
);
|
||||
assert_eq!(
|
||||
node.stats().discovery.req_initiated,
|
||||
baseline_initiated + 3,
|
||||
"no new attempt before final deadline"
|
||||
);
|
||||
assert_eq!(
|
||||
node.stats().discovery.resp_timed_out,
|
||||
baseline_timed_out,
|
||||
"no timeout before final deadline"
|
||||
);
|
||||
|
||||
// --- t = 15100ms: final deadline (cumulative 1+2+4+8 = 15s) ---
|
||||
// Drain any TUN frames that may have leaked from earlier steps so the
|
||||
// post-final-timeout drain observes only the unreachable-emission output.
|
||||
while tun_rx.try_recv().is_ok() {}
|
||||
|
||||
node.check_pending_lookups(15_100).await;
|
||||
|
||||
// Pending lookup is dropped.
|
||||
assert!(
|
||||
!node.pending_lookups.contains_key(&target_addr),
|
||||
"final timeout must remove the pending_lookups entry"
|
||||
);
|
||||
// resp_timed_out counter ticked.
|
||||
assert_eq!(
|
||||
node.stats().discovery.resp_timed_out,
|
||||
baseline_timed_out + 1,
|
||||
"final timeout must increment discovery.resp_timed_out"
|
||||
);
|
||||
// No additional initiate_lookup on the timeout step.
|
||||
assert_eq!(
|
||||
node.stats().discovery.req_initiated,
|
||||
baseline_initiated + 3,
|
||||
"the final-timeout step must NOT call initiate_lookup"
|
||||
);
|
||||
// Queued packet was drained from pending_tun_packets.
|
||||
assert!(
|
||||
!node.pending_tun_packets.contains_key(&target_addr),
|
||||
"queued packets for the unreachable target must be drained"
|
||||
);
|
||||
|
||||
// ICMPv6 Destination Unreachable was emitted to the TUN sender.
|
||||
let icmp_frame = tun_rx
|
||||
.try_recv()
|
||||
.expect("ICMPv6 Destination Unreachable must be emitted on final timeout");
|
||||
assert!(
|
||||
icmp_frame.len() >= 48,
|
||||
"ICMPv6 frame must be at least IPv6 header (40) + ICMPv6 header (8)"
|
||||
);
|
||||
assert_eq!(icmp_frame[0] >> 4, 6, "must be IPv6");
|
||||
assert_eq!(icmp_frame[6], 58, "next_header must be IPPROTO_ICMPV6 (58)");
|
||||
assert_eq!(icmp_frame[40], 1, "ICMPv6 type 1 = Destination Unreachable");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod ble;
|
||||
mod bloom;
|
||||
mod bloom_poison;
|
||||
mod bootstrap;
|
||||
mod decrypt_failure;
|
||||
mod disconnect;
|
||||
mod discovery;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -201,6 +201,98 @@ fn test_routing_tree_fallback() {
|
||||
assert_eq!(result.unwrap().node_addr(), &peer_addr);
|
||||
}
|
||||
|
||||
/// Regression: bloom hit on a peer that is NOT strictly closer to dest
|
||||
/// than we are must fall through to greedy tree routing rather than
|
||||
/// returning None. Pinned by commit a859da7.
|
||||
///
|
||||
/// Pre-fix behavior: bloom candidates exist but `select_best_candidate`
|
||||
/// rejects them all under the self-distance check (peer dist >= my dist),
|
||||
/// and `find_next_hop` returned None — a NoRoute failure even though the
|
||||
/// tree had a valid greedy next hop.
|
||||
///
|
||||
/// Post-fix behavior: same scenario falls through to greedy tree routing
|
||||
/// and returns the tree-routing-selected next hop.
|
||||
#[test]
|
||||
fn test_routing_bloom_hit_not_closer_falls_through_to_tree() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let my_addr = *node.node_addr();
|
||||
|
||||
// tree_peer: child of self, on the path to dest (greedy tree pick).
|
||||
let tree_link = LinkId::new(1);
|
||||
let (tree_conn, tree_id) = make_completed_connection(&mut node, tree_link, transport_id, 1000);
|
||||
let tree_peer_addr = *tree_id.node_addr();
|
||||
node.add_connection(tree_conn).unwrap();
|
||||
node.promote_connection(tree_link, tree_id, 2000).unwrap();
|
||||
|
||||
// bloom_peer: also a child of self, but with a stale/false-positive
|
||||
// bloom hit for dest. Its tree distance to dest is NOT closer than
|
||||
// ours, so the self-distance check in select_best_candidate excludes
|
||||
// it — leaving zero viable bloom candidates.
|
||||
let bloom_link = LinkId::new(2);
|
||||
let (bloom_conn, bloom_id) =
|
||||
make_completed_connection(&mut node, bloom_link, transport_id, 1000);
|
||||
let bloom_peer_addr = *bloom_id.node_addr();
|
||||
node.add_connection(bloom_conn).unwrap();
|
||||
node.promote_connection(bloom_link, bloom_id, 2000).unwrap();
|
||||
|
||||
// Tree topology (we are root):
|
||||
// self ── tree_peer ── dest
|
||||
// └──── bloom_peer
|
||||
//
|
||||
// Distances to dest:
|
||||
// self : 2 (root → tree_peer → dest)
|
||||
// tree_peer : 1 (tree_peer → dest) ← greedy winner
|
||||
// bloom_peer : 3 (bloom_peer → root → tree_peer → dest) ← NOT closer than self
|
||||
let tree_peer_coords = TreeCoordinate::from_addrs(vec![tree_peer_addr, my_addr]).unwrap();
|
||||
node.tree_state_mut().update_peer(
|
||||
ParentDeclaration::new(tree_peer_addr, my_addr, 1, 1000),
|
||||
tree_peer_coords,
|
||||
);
|
||||
let bloom_peer_coords = TreeCoordinate::from_addrs(vec![bloom_peer_addr, my_addr]).unwrap();
|
||||
node.tree_state_mut().update_peer(
|
||||
ParentDeclaration::new(bloom_peer_addr, my_addr, 1, 1000),
|
||||
bloom_peer_coords,
|
||||
);
|
||||
|
||||
// Destination is a child of tree_peer in the tree.
|
||||
let dest = make_node_addr(99);
|
||||
let dest_coords = TreeCoordinate::from_addrs(vec![dest, tree_peer_addr, my_addr]).unwrap();
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
node.coord_cache_mut().insert(dest, dest_coords, now_ms);
|
||||
|
||||
// dest is in bloom_peer's filter only (the "bloom hit" candidate),
|
||||
// but bloom_peer's tree distance (3) is NOT strictly less than our
|
||||
// distance (2), so select_best_candidate yields no winner.
|
||||
// tree_peer has NO bloom entry for dest.
|
||||
let bloom_peer = node.get_peer_mut(&bloom_peer_addr).unwrap();
|
||||
let mut filter = BloomFilter::new();
|
||||
filter.insert(&dest);
|
||||
bloom_peer.update_filter(filter, 1, 3000);
|
||||
|
||||
// Pre-fix this returned None. Post-fix it falls through to greedy
|
||||
// tree routing and picks tree_peer (distance 1 < self distance 2).
|
||||
let result = node.find_next_hop(&dest);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"find_next_hop must fall through to tree routing when bloom \
|
||||
candidates exist but none are strictly closer than self"
|
||||
);
|
||||
let next_hop = result.unwrap().node_addr();
|
||||
assert_eq!(
|
||||
next_hop, &tree_peer_addr,
|
||||
"tree-routing winner expected (tree_peer), got {:?}",
|
||||
next_hop,
|
||||
);
|
||||
assert_ne!(
|
||||
next_hop, &bloom_peer_addr,
|
||||
"bloom_peer must be excluded by the self-distance check",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routing_tree_no_coords_in_cache() {
|
||||
let mut node = make_node();
|
||||
|
||||
@@ -1345,4 +1345,47 @@ mod tests {
|
||||
peer.reset_replay_suppressed();
|
||||
assert_eq!(peer.reset_replay_suppressed(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_decrypt_failures_monotonic() {
|
||||
let identity = make_peer_identity();
|
||||
let mut peer = ActivePeer::new(identity, LinkId::new(1), 1000);
|
||||
|
||||
// Initial count is zero
|
||||
assert_eq!(peer.consecutive_decrypt_failures(), 0);
|
||||
|
||||
// Each call returns a strictly increasing count
|
||||
let mut prev = 0u32;
|
||||
for expected in 1..=25u32 {
|
||||
let count = peer.increment_decrypt_failures();
|
||||
assert_eq!(count, expected, "increment must return monotonic count");
|
||||
assert!(count > prev, "count must strictly increase");
|
||||
assert_eq!(peer.consecutive_decrypt_failures(), count);
|
||||
prev = count;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_decrypt_failures_zeroes_counter() {
|
||||
let identity = make_peer_identity();
|
||||
let mut peer = ActivePeer::new(identity, LinkId::new(1), 1000);
|
||||
|
||||
// Drive counter up
|
||||
for _ in 0..7 {
|
||||
peer.increment_decrypt_failures();
|
||||
}
|
||||
assert_eq!(peer.consecutive_decrypt_failures(), 7);
|
||||
|
||||
// Reset zeroes it
|
||||
peer.reset_decrypt_failures();
|
||||
assert_eq!(peer.consecutive_decrypt_failures(), 0);
|
||||
|
||||
// Reset on zero is a no-op (still zero, no panic)
|
||||
peer.reset_decrypt_failures();
|
||||
assert_eq!(peer.consecutive_decrypt_failures(), 0);
|
||||
|
||||
// Counter resumes at 1 after reset
|
||||
assert_eq!(peer.increment_decrypt_failures(), 1);
|
||||
assert_eq!(peer.consecutive_decrypt_failures(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,9 +554,17 @@ fn get_mac_addr(interface: &str) -> Result<[u8; 6], TransportError> {
|
||||
|
||||
// ============================================================================
|
||||
// Unit tests
|
||||
//
|
||||
// The whole `socket_macos.rs` file is `#[cfg(target_os = "macos")]`-included
|
||||
// by `socket.rs`, so this `#[cfg(test)]` mod naturally only compiles on macOS.
|
||||
// The redundant `#[cfg(target_os = "macos")]` below is belt-and-suspenders:
|
||||
// it makes the macOS-only intent explicit so that any future refactor that
|
||||
// includes this file on additional targets won't silently activate macOS-
|
||||
// specific tests.
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_os = "macos")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -814,6 +822,127 @@ mod tests {
|
||||
}
|
||||
assert!(readable, "pipe read end should be readable after write");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// BpfHeader layout pin
|
||||
//
|
||||
// The `bpf_hdr` wire layout is fixed by the macOS kernel. If `BpfHeader`
|
||||
// ever drifts (e.g. someone adds a field, or the timestamp field type
|
||||
// changes), `parse_next_frame` will misread the kernel's frames. Pin
|
||||
// both the size and the per-field byte offsets so any such drift fails
|
||||
// at unit-test time rather than as runtime garbage MAC addresses.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_bpf_header_layout_matches_kernel() {
|
||||
// size_of pinned at type-define site too via `const _: () = assert!`,
|
||||
// but repeating here makes the failure mode obvious in test output.
|
||||
assert_eq!(std::mem::size_of::<BpfHeader>(), 20);
|
||||
// bh_hdrlen lives at offset 16 (4 + 4 + 4 + 4).
|
||||
let hdr = BpfHeader {
|
||||
bh_tstamp_sec: 0,
|
||||
bh_tstamp_usec: 0,
|
||||
bh_caplen: 0,
|
||||
bh_datalen: 0,
|
||||
bh_hdrlen: 0xABCD,
|
||||
_pad: 0,
|
||||
};
|
||||
let bytes: &[u8] =
|
||||
unsafe { std::slice::from_raw_parts(&hdr as *const BpfHeader as *const u8, 20) };
|
||||
assert_eq!(&bytes[16..18], &0xABCDu16.to_ne_bytes());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// parse_next_frame — additional malformed-header rejection cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_parse_next_frame_caplen_exceeds_remaining_buffer() {
|
||||
// Build a header that claims more captured data than the buffer
|
||||
// actually holds. parse_next_frame should reject (return None)
|
||||
// rather than read past the end.
|
||||
let hdr_size = std::mem::size_of::<BpfHeader>();
|
||||
let claimed_cap_len: usize = 200; // > what's actually in the buffer
|
||||
let hdr = BpfHeader {
|
||||
bh_tstamp_sec: 0,
|
||||
bh_tstamp_usec: 0,
|
||||
bh_caplen: claimed_cap_len as u32,
|
||||
bh_datalen: claimed_cap_len as u32,
|
||||
bh_hdrlen: hdr_size as u16,
|
||||
_pad: 0,
|
||||
};
|
||||
// Allocate only header + 32 bytes — far short of claimed cap_len.
|
||||
let mut buf = vec![0u8; hdr_size + 32];
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
&hdr as *const BpfHeader as *const u8,
|
||||
buf.as_mut_ptr(),
|
||||
hdr_size,
|
||||
);
|
||||
}
|
||||
let mut out_buf = vec![0u8; 1500];
|
||||
let mut offset = 0usize;
|
||||
// Truncated frame: returns None (skipped, not an error).
|
||||
assert!(parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf).is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ethernet-header construction round-trip
|
||||
//
|
||||
// Mirrors the byte layout that `send_to` lays down in front of the
|
||||
// payload, then runs that through `parse_next_frame` to confirm the
|
||||
// source MAC bytes survive the round trip. Pure data — no actual fd.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_ethernet_header_round_trip_via_parse() {
|
||||
// Hand-build the Ethernet header the way send_to() does.
|
||||
let dst_mac: [u8; 6] = [0xff; 6];
|
||||
let src_mac: [u8; 6] = [0x02, 0x00, 0x00, 0x12, 0x34, 0x56];
|
||||
let ethertype: u16 = 0x88B5; // local-experimental EtherType
|
||||
let payload: &[u8] = b"FIPS-frame-payload";
|
||||
|
||||
// Construct the in-buffer BPF frame the kernel would have written:
|
||||
// [bpf_hdr][dst_mac][src_mac][ethertype_be][payload]
|
||||
let cap_len = ETH_HDRLEN + payload.len();
|
||||
let hdr = BpfHeader {
|
||||
bh_tstamp_sec: 0,
|
||||
bh_tstamp_usec: 0,
|
||||
bh_caplen: cap_len as u32,
|
||||
bh_datalen: cap_len as u32,
|
||||
bh_hdrlen: std::mem::size_of::<BpfHeader>() as u16,
|
||||
_pad: 0,
|
||||
};
|
||||
let hdr_size = std::mem::size_of::<BpfHeader>();
|
||||
let total = bpf_wordalign(hdr_size + cap_len);
|
||||
let mut buf = vec![0u8; total];
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
&hdr as *const BpfHeader as *const u8,
|
||||
buf.as_mut_ptr(),
|
||||
hdr_size,
|
||||
);
|
||||
}
|
||||
let frame_start = hdr_size;
|
||||
buf[frame_start..frame_start + 6].copy_from_slice(&dst_mac);
|
||||
buf[frame_start + 6..frame_start + 12].copy_from_slice(&src_mac);
|
||||
buf[frame_start + 12..frame_start + 14].copy_from_slice(ðertype.to_be_bytes());
|
||||
buf[frame_start + ETH_HDRLEN..frame_start + ETH_HDRLEN + payload.len()]
|
||||
.copy_from_slice(payload);
|
||||
|
||||
// Parse it back.
|
||||
let mut out_buf = vec![0u8; 1500];
|
||||
let mut offset = 0usize;
|
||||
let (n, parsed_src) = parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf)
|
||||
.expect("Some")
|
||||
.expect("Ok");
|
||||
|
||||
// The 14-byte Ethernet header is stripped; only the payload survives.
|
||||
assert_eq!(n, payload.len());
|
||||
assert_eq!(&out_buf[..n], payload);
|
||||
// Source MAC is reported directly from bytes [6..12] of the frame.
|
||||
assert_eq!(parsed_src, src_mac);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the MTU of an interface by index.
|
||||
|
||||
+105
-2
@@ -361,6 +361,41 @@ impl TunDevice {
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS utun protocol family value for IPv6 (matches `<sys/socket.h>`
|
||||
/// `AF_INET6` on Darwin). Used as the 4-byte big-endian packet-info
|
||||
/// header prepended to every utun frame.
|
||||
#[cfg(target_os = "macos")]
|
||||
const UTUN_AF_INET6: u32 = 30;
|
||||
|
||||
/// Build the 4-byte big-endian utun packet-info header for an IPv6 frame.
|
||||
///
|
||||
/// utun devices on macOS require a 4-byte address-family prefix on every
|
||||
/// frame: a single big-endian `u32` carrying the protocol family. For
|
||||
/// IPv6 traffic (the only family FIPS sends) this is `AF_INET6 = 30`,
|
||||
/// which serializes as `[0x00, 0x00, 0x00, 0x1e]`.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[inline]
|
||||
fn utun_af_inet6_header() -> [u8; 4] {
|
||||
UTUN_AF_INET6.to_be_bytes()
|
||||
}
|
||||
|
||||
/// Parse the 4-byte big-endian utun packet-info header.
|
||||
///
|
||||
/// Returns the address-family value (`AF_INET6 = 30` for IPv6 frames),
|
||||
/// or `None` if the buffer is shorter than the 4-byte header. The `tun`
|
||||
/// crate's `Read` impl strips this transparently for us in the read
|
||||
/// path; this helper exists for round-trip testability with
|
||||
/// [`utun_af_inet6_header`] and for any future code path that reads
|
||||
/// from the dup'd fd directly.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[inline]
|
||||
fn parse_utun_af_prefix(buf: &[u8]) -> Option<u32> {
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
Some(u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]))
|
||||
}
|
||||
|
||||
/// Writer thread for TUN device.
|
||||
///
|
||||
/// Services a queue of outbound packets and writes them to the TUN device.
|
||||
@@ -413,10 +448,10 @@ impl TunWriter {
|
||||
#[cfg(target_os = "macos")]
|
||||
let write_result = {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
const AF_INET6_HEADER: [u8; 4] = [0, 0, 0, 30];
|
||||
let af_header = utun_af_inet6_header();
|
||||
let iov = [
|
||||
libc::iovec {
|
||||
iov_base: AF_INET6_HEADER.as_ptr() as *mut libc::c_void,
|
||||
iov_base: af_header.as_ptr() as *mut libc::c_void,
|
||||
iov_len: 4,
|
||||
},
|
||||
libc::iovec {
|
||||
@@ -1482,4 +1517,72 @@ mod tests {
|
||||
assert_eq!(per_flow_max_mss(&lookup, a.as_bytes(), 1360), 1143);
|
||||
assert_eq!(per_flow_max_mss(&lookup, b.as_bytes(), 1360), 1315);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// macOS utun packet-info header (AF_INET6 4-byte big-endian prefix)
|
||||
//
|
||||
// These tests are pure-data byte-buffer manipulation and require no
|
||||
// privilege, no actual TUN device, no system calls. They pin the wire
|
||||
// format that `TunWriter::run` emits ahead of every IPv6 frame on the
|
||||
// dup'd utun fd, and the inverse parse used for round-trip checking.
|
||||
// ========================================================================
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_utun_header {
|
||||
use super::super::{UTUN_AF_INET6, parse_utun_af_prefix, utun_af_inet6_header};
|
||||
|
||||
#[test]
|
||||
fn af_inet6_constant_matches_darwin() {
|
||||
// Darwin's <sys/socket.h> defines AF_INET6 = 30. If this ever
|
||||
// diverges, every utun write FIPS issues will be misclassified
|
||||
// by the kernel and dropped.
|
||||
assert_eq!(UTUN_AF_INET6, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_produces_big_endian_af_inet6() {
|
||||
// The kernel reads the 4-byte prefix as a big-endian u32.
|
||||
// 30 == 0x0000001e, so the wire bytes are [0, 0, 0, 0x1e].
|
||||
let header = utun_af_inet6_header();
|
||||
assert_eq!(header, [0x00, 0x00, 0x00, 0x1e]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_round_trips_through_parse() {
|
||||
let header = utun_af_inet6_header();
|
||||
let parsed = parse_utun_af_prefix(&header).expect("4 bytes is enough");
|
||||
assert_eq!(parsed, UTUN_AF_INET6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_short_buffer() {
|
||||
// Anything shorter than the 4-byte header is ill-formed.
|
||||
assert_eq!(parse_utun_af_prefix(&[]), None);
|
||||
assert_eq!(parse_utun_af_prefix(&[0x00]), None);
|
||||
assert_eq!(parse_utun_af_prefix(&[0x00, 0x00]), None);
|
||||
assert_eq!(parse_utun_af_prefix(&[0x00, 0x00, 0x00]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_minimum_header_with_trailing_payload() {
|
||||
// A real utun read returns header + IP packet concatenated.
|
||||
// The parser only consumes the first 4 bytes.
|
||||
let mut frame = utun_af_inet6_header().to_vec();
|
||||
frame.extend_from_slice(&[0x60; 40]); // dummy IPv6 header
|
||||
let parsed = parse_utun_af_prefix(&frame).expect("4 bytes is enough");
|
||||
assert_eq!(parsed, UTUN_AF_INET6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_garbage_bytes_returns_garbage_value_not_panic() {
|
||||
// A well-formed 4-byte buffer whose value is not AF_INET6
|
||||
// should parse successfully (returning the raw u32) without
|
||||
// panicking. Discriminating "expected" vs "unexpected" AF
|
||||
// values is the caller's responsibility.
|
||||
let buf = [0xde, 0xad, 0xbe, 0xef];
|
||||
let parsed = parse_utun_af_prefix(&buf).expect("4 bytes is enough");
|
||||
assert_eq!(parsed, 0xdeadbeef);
|
||||
assert_ne!(parsed, UTUN_AF_INET6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user