diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2688725 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Keep snapshot test fixtures LF-only across all platforms so that +# Windows checkouts with core.autocrlf=true don't convert them to CRLF +# (which would mismatch the actual JSON serialization output during +# snapshot comparison). +src/control/snapshots/*.json text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d27397..e5ebed1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,11 @@ jobs: - name: Install system dependencies (Linux only) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev + run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev nftables + + - name: Validate fips.nft syntax (Linux only) + if: runner.os == 'Linux' + run: sudo nft -c -f packaging/common/fips.nft - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -250,6 +254,35 @@ jobs: - name: Run unit tests run: cargo nextest run --all --profile ci +# ───────────────────────────────────────────────────────────────────────────── +# Job 2d – PowerShell lint (Windows packaging scripts) +# +# Runs PSScriptAnalyzer against the operator-facing installer/build +# scripts shipped in the Windows ZIP package. Settings live in +# packaging/windows/PSScriptAnalyzerSettings.psd1 (each suppressed rule +# is documented there). Pre-installed on windows-latest runners; no +# Install-Module step needed. +# ───────────────────────────────────────────────────────────────────────────── + windows-lint: + name: PowerShell lint (Windows packaging) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Run PSScriptAnalyzer + shell: pwsh + run: | + $results = Invoke-ScriptAnalyzer ` + -Path packaging/windows/*.ps1 ` + -Settings packaging/windows/PSScriptAnalyzerSettings.psd1 + if ($results) { + $results | Format-Table -AutoSize + Write-Error "PSScriptAnalyzer found $($results.Count) issue(s)" + exit 1 + } else { + Write-Host "PSScriptAnalyzer: no issues" + } + # ───────────────────────────────────────────────────────────────────────────── # Job 3 – Integration tests (static mesh + chaos simulation) # @@ -288,6 +321,9 @@ jobs: topology: rekey-outbound-only - suite: acl-allowlist type: acl-allowlist + # ── Firewall baseline (fips0 nftables default-deny) ──────────── + - suite: firewall + type: firewall # ── Outbound LAN gateway integration test ────────────────────── - suite: gateway type: gateway @@ -343,6 +379,21 @@ jobs: - suite: nat-lan type: nat scenario: lan + # ── Nostr overlay advert publish/consume round-trip ───────────── + # Two FIPS daemons + the existing strfry relay; covers Phase 1 + # (A→B publish/consume), Phase 2 (B→A reverse), and Phase 3 + # (malformed advert injected to relay; consumers must reject + # without crashing). UDP transport baseline for v0.3.0. + - suite: nostr-publish-consume + type: nostr-publish-consume + # ── STUN fault-injection ─────────────────────────────────────── + # One FIPS daemon + a netns-sharing shim that injects tc/iptables + # faults against UDP egress to the in-lab STUN server. Three + # phases: 100% drop, ~5s delay then clear, then full STUN + # container kill. Asserts the daemon notices each fault, + # recovers from delay, and never panics. + - suite: stun-faults + type: stun-faults # ── Real-deb install across target distros ───────────────────── # Boots a privileged systemd container per distro, runs # `apt install ./fips_*.deb` with the locally-built package, @@ -535,6 +586,22 @@ jobs: run: | docker compose -f testing/acl-allowlist/docker-compose.yml down --volumes --remove-orphans + # ── Firewall baseline integration test ───────────────────────────────── + - name: Run firewall baseline integration test + if: matrix.type == 'firewall' + run: bash testing/firewall/test.sh --skip-build --keep-up + + - name: Collect logs on failure (firewall) + if: matrix.type == 'firewall' && failure() + run: | + docker compose -f testing/firewall/docker-compose.yml logs --no-color + docker exec fips-fw-container-b nft list table inet fips || true + + - name: Stop containers (firewall) + if: matrix.type == 'firewall' && always() + run: | + docker compose -f testing/firewall/docker-compose.yml down --volumes --remove-orphans + # ── Chaos simulation ─────────────────────────────────────────────────── - name: Install Python deps (chaos) if: matrix.type == 'chaos' @@ -584,6 +651,40 @@ jobs: --profile cone --profile symmetric --profile lan \ down --volumes --remove-orphans + # ── Nostr overlay advert publish/consume ─────────────────────────── + - name: Run Nostr publish/consume test + if: matrix.type == 'nostr-publish-consume' + run: bash testing/nat/scripts/nostr-relay-test.sh + + - name: Collect logs on failure (nostr-publish-consume) + if: matrix.type == 'nostr-publish-consume' && failure() + run: | + docker compose -f testing/nat/docker-compose.yml \ + --profile nostr-publish-consume logs --no-color | tail -300 + + - name: Stop containers (nostr-publish-consume) + if: matrix.type == 'nostr-publish-consume' && always() + run: | + docker compose -f testing/nat/docker-compose.yml \ + --profile nostr-publish-consume down --volumes --remove-orphans + + # ── STUN fault-injection ─────────────────────────────────────────── + - name: Run STUN fault-injection test + if: matrix.type == 'stun-faults' + run: bash testing/nat/scripts/stun-faults-test.sh + + - name: Collect logs on failure (stun-faults) + if: matrix.type == 'stun-faults' && failure() + run: | + docker compose -f testing/nat/docker-compose.yml \ + --profile stun-faults logs --no-color | tail -300 + + - name: Stop containers (stun-faults) + if: matrix.type == 'stun-faults' && always() + run: | + docker compose -f testing/nat/docker-compose.yml \ + --profile stun-faults down --volumes --remove-orphans + # ── Outbound LAN gateway integration test ────────────────────────── - name: Generate configs (gateway) if: matrix.type == 'gateway' diff --git a/.github/workflows/package-macos.yml b/.github/workflows/package-macos.yml index c6f27b6..60ad1ef 100644 --- a/.github/workflows/package-macos.yml +++ b/.github/workflows/package-macos.yml @@ -109,6 +109,82 @@ jobs: echo "pkg=$PKG_FILE" >> "$GITHUB_OUTPUT" + - name: Verify .pkg structural correctness + shell: bash + run: | + set -euo pipefail + PKG="${{ steps.macos-assets.outputs.pkg }}" + EXPAND_DIR="$(mktemp -d)/expanded" + PAYLOAD_DIR="$(mktemp -d)/payload" + fail=0 + + echo "==> Verifying $PKG" + + # 1) Flat-package expansion + if pkgutil --expand "$PKG" "$EXPAND_DIR"; then + echo "PASS: pkgutil --expand" + else + echo "FAIL: pkgutil --expand" + fail=1 + fi + + # Extract the cpio.gz Payload so we can inspect installed file layout + PAYLOAD_FILE="$(find "$EXPAND_DIR" -name Payload -type f | head -n 1)" + if [[ -z "$PAYLOAD_FILE" ]]; then + echo "FAIL: no Payload file inside expanded pkg" + fail=1 + else + mkdir -p "$PAYLOAD_DIR" + (cd "$PAYLOAD_DIR" && gzip -dc "$PAYLOAD_FILE" | cpio -i --quiet) + echo "PASS: extracted Payload to $PAYLOAD_DIR" + fi + + # 2) Binary at canonical install path (./usr/local/bin/fips inside payload) + BIN_PATH="$PAYLOAD_DIR/usr/local/bin/fips" + if [[ -f "$BIN_PATH" ]]; then + echo "PASS: binary present at usr/local/bin/fips" + else + echo "FAIL: binary missing at usr/local/bin/fips" + echo " fallback search:" + find "$PAYLOAD_DIR" -name fips -type f -print || true + fail=1 + fi + for extra in fipsctl fipstop; do + if [[ -f "$PAYLOAD_DIR/usr/local/bin/$extra" ]]; then + echo "PASS: binary present at usr/local/bin/$extra" + else + echo "FAIL: binary missing at usr/local/bin/$extra" + fail=1 + fi + done + + # 3) LaunchDaemon plist at canonical location + PLIST_PATH="$PAYLOAD_DIR/Library/LaunchDaemons/com.fips.daemon.plist" + if [[ -f "$PLIST_PATH" ]]; then + echo "PASS: plist present at Library/LaunchDaemons/com.fips.daemon.plist" + else + echo "FAIL: plist missing at Library/LaunchDaemons/com.fips.daemon.plist" + echo " fallback search:" + find "$PAYLOAD_DIR" -name '*.plist' -print || true + fail=1 + fi + + # 4) plutil -lint on the plist + if [[ -f "$PLIST_PATH" ]]; then + if plutil -lint "$PLIST_PATH"; then + echo "PASS: plutil -lint" + else + echo "FAIL: plutil -lint" + fail=1 + fi + fi + + if [[ "$fail" -ne 0 ]]; then + echo "==> .pkg verification FAILED" + exit 1 + fi + echo "==> .pkg verification PASSED" + - name: SHA-256 hash run: | echo "==> macOS release asset:" diff --git a/.github/workflows/package-openwrt.yml b/.github/workflows/package-openwrt.yml index 15fccaf..c7758e4 100644 --- a/.github/workflows/package-openwrt.yml +++ b/.github/workflows/package-openwrt.yml @@ -200,6 +200,188 @@ jobs: LLVM_STRIP: llvm-strip run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }} + - name: Install shellcheck (if missing) + shell: bash + run: | + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck + fi + shellcheck --version + + - name: Lint shipped shell scripts + shell: bash + run: | + set -euo pipefail + FILES_DIR=packaging/openwrt-ipk/files + # Scripts shipped inside the .ipk. The init scripts use the OpenWrt + # `#!/bin/sh /etc/rc.common` shebang; tell shellcheck to treat them + # as POSIX sh and silence the unrecognized-shebang warning (SC1008). + # SC2317 (unreachable command) fires on rc.common's externally-invoked + # start_service/stop_service/reload_service hooks. + TARGETS=( + "$FILES_DIR/etc/init.d/fips" + "$FILES_DIR/etc/init.d/fips-gateway" + "$FILES_DIR/etc/fips/firewall.sh" + "$FILES_DIR/etc/hotplug.d/net/99-fips" + "$FILES_DIR/etc/uci-defaults/90-fips-setup" + ) + fail=0 + for f in "${TARGETS[@]}"; do + if [ ! -f "$f" ]; then + echo "FAIL: missing $f" + fail=1 + continue + fi + echo "==> shellcheck $f" + if shellcheck --shell=sh --exclude=SC1008,SC2317,SC2034,SC3043,SC2086,SC2089,SC2090 "$f"; then + echo " PASS" + else + echo " FAIL" + fail=1 + fi + done + if [ "$fail" -ne 0 ]; then + echo "shellcheck FAILED" + exit 1 + fi + echo "shellcheck PASS (${#TARGETS[@]} scripts)" + + - name: Sysctl drop-in syntax check + shell: bash + run: | + set -euo pipefail + FILES_DIR=packaging/openwrt-ipk/files + TARGETS=( + "$FILES_DIR/etc/sysctl.d/fips-gateway.conf" + "$FILES_DIR/etc/sysctl.d/fips-bridge.conf" + ) + fail=0 + for conf in "${TARGETS[@]}"; do + if [ ! -f "$conf" ]; then + echo "FAIL: missing $conf" + fail=1 + continue + fi + echo "==> validating $conf" + lineno=0 + file_ok=1 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + # Skip comments and blank lines. + case "$line" in + ''|\#*) continue ;; + esac + # Match: = where key is dotted lower-id and value is + # an integer (sysctl drop-ins shipped here are all numeric toggles). + if ! [[ "$line" =~ ^[a-z0-9_.-]+[[:space:]]*=[[:space:]]*-?[0-9]+[[:space:]]*$ ]]; then + echo " FAIL line $lineno: $line" + file_ok=0 + fi + done < "$conf" + if [ "$file_ok" -eq 1 ]; then + echo " PASS" + else + fail=1 + fi + done + if [ "$fail" -ne 0 ]; then + echo "sysctl drop-in syntax check FAILED" + exit 1 + fi + echo "sysctl drop-in syntax check PASS" + + - name: Verify ipk structural integrity + shell: bash + run: | + set -euo pipefail + IPK="dist/${{ env.PACKAGE_FILENAME }}" + if [ ! -f "$IPK" ]; then + echo "FAIL: produced ipk not found at $IPK" + exit 1 + fi + echo "==> file type:" + file "$IPK" + + # OpenWrt .ipk = tar.gz containing debian-binary + control.tar.gz + + # data.tar.gz (NOT an ar archive like Debian's .deb). + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + + tar -xzf "$IPK" -C "$WORK" + echo "==> top-level entries:" + ls -la "$WORK" + + # Top-level structural assertions. + fail=0 + for entry in debian-binary control.tar.gz data.tar.gz; do + if [ ! -f "$WORK/$entry" ]; then + echo "FAIL: missing top-level $entry" + fail=1 + else + echo " PASS top-level: $entry" + fi + done + if [ "$fail" -ne 0 ]; then exit 1; fi + + # debian-binary content sanity. + dbin_content=$(cat "$WORK/debian-binary" | tr -d '[:space:]') + if [ "$dbin_content" != "2.0" ]; then + echo "FAIL: debian-binary content is '$dbin_content' (expected 2.0)" + exit 1 + fi + echo " PASS debian-binary content: 2.0" + + # Inspect data.tar.gz contents. + DATA_LIST="$WORK/data.list" + tar -tzf "$WORK/data.tar.gz" > "$DATA_LIST" + echo "==> data.tar.gz entry count: $(wc -l < "$DATA_LIST")" + + # Required filesystem entries inside data.tar.gz. Entries are + # produced with a leading "./" by build-ipk.sh. + REQUIRED=( + ./usr/bin/fips + ./usr/bin/fipsctl + ./usr/bin/fipstop + ./usr/bin/fips-gateway + ./etc/init.d/fips + ./etc/init.d/fips-gateway + ./etc/fips/fips.yaml + ./etc/fips/firewall.sh + ./etc/dnsmasq.d/fips.conf + ./etc/sysctl.d/fips-gateway.conf + ./etc/sysctl.d/fips-bridge.conf + ./etc/hotplug.d/net/99-fips + ./etc/uci-defaults/90-fips-setup + ./lib/upgrade/keep.d/fips + ) + for path in "${REQUIRED[@]}"; do + if grep -Fxq "$path" "$DATA_LIST"; then + echo " PASS data: $path" + else + echo " FAIL data: missing $path" + fail=1 + fi + done + + # Inspect control.tar.gz: must contain control file + maintainer scripts. + CTRL_LIST="$WORK/control.list" + tar -tzf "$WORK/control.tar.gz" > "$CTRL_LIST" + echo "==> control.tar.gz entry count: $(wc -l < "$CTRL_LIST")" + for path in ./control ./conffiles ./postinst ./prerm; do + if grep -Fxq "$path" "$CTRL_LIST"; then + echo " PASS control: $path" + else + echo " FAIL control: missing $path" + fail=1 + fi + done + + if [ "$fail" -ne 0 ]; then + echo "ipk structural verification FAILED" + exit 1 + fi + echo "ipk structural verification PASS" + - name: SHA-256 hashes run: | echo "==> Binaries:" diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml index 5cd742c..5b98792 100644 --- a/.github/workflows/package-windows.yml +++ b/.github/workflows/package-windows.yml @@ -86,6 +86,54 @@ jobs: -Version "${{ needs.determine-versioning.outputs.package_version }}" ` -NoBuild + - name: Verify ZIP structural correctness + shell: pwsh + run: | + $zip = Get-ChildItem deploy\fips-*-windows-*.zip | Select-Object -First 1 + if (-not $zip) { Write-Error "No ZIP artifact found in deploy\"; exit 1 } + Write-Host "Verifying: $($zip.Name)" + + $extractDir = "verify-extract" + if (Test-Path $extractDir) { Remove-Item -Recurse -Force $extractDir } + Expand-Archive -Path $zip.FullName -DestinationPath $extractDir + + # Expected top-level files (flat ZIP, no wrapper directory). + # Source of truth: packaging/windows/build-zip.ps1 + $expected = @( + "fips.exe", + "fipsctl.exe", + "fipstop.exe", + "fips.yaml", + "hosts", + "install-service.ps1", + "uninstall-service.ps1", + "README.txt" + ) + + $missing = @() + foreach ($f in $expected) { + $path = Join-Path $extractDir $f + if (Test-Path -LiteralPath $path -PathType Leaf) { + Write-Host "PASS: $f" + } else { + Write-Host "FAIL: $f" + $missing += $f + } + } + + Write-Host "" + Write-Host "ZIP contents:" + Get-ChildItem -Path $extractDir -Recurse | ForEach-Object { + Write-Host " $($_.FullName.Substring((Resolve-Path $extractDir).Path.Length + 1))" + } + + if ($missing.Count -gt 0) { + Write-Error "Missing expected files: $($missing -join ', ')" + exit 1 + } + Write-Host "" + Write-Host "All expected files present." + - name: SHA-256 hash shell: pwsh run: | diff --git a/packaging/windows/PSScriptAnalyzerSettings.psd1 b/packaging/windows/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..a26f49d --- /dev/null +++ b/packaging/windows/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,25 @@ +# PSScriptAnalyzer settings for FIPS Windows packaging scripts. +# +# Starts from the default ruleset and excludes a small set of rules that +# do not apply to operator-facing installer scripts. Each exclusion lists +# why it was suppressed and which scripts justify it. + +@{ + # Skip Information-level findings; gate CI on Warning and Error only. + Severity = @('Error', 'Warning') + + ExcludeRules = @( + # Console output to the operator is intentional in build-zip.ps1, + # install-service.ps1, and uninstall-service.ps1. These scripts + # are run interactively by humans installing/packaging FIPS, and + # Write-Host gives them feedback at the terminal. Switching to + # Write-Output would conflate progress text with the (non-existent) + # script return value. + 'PSAvoidUsingWriteHost', + + # Copy-Item, New-Item, and similar cmdlet calls in the installer + # use positional parameters (source, destination) for readability + # and to mirror cp/mv conventions Unix-familiar operators expect. + 'PSAvoidUsingPositionalParameters' + ) +} diff --git a/src/config/node.rs b/src/config/node.rs index b6cea3e..6e3b0c9 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -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() { diff --git a/src/control/queries.rs b/src/control/queries.rs index 5736408..092a34e 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -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 `""` — + //! 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 + /// `""`. 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("".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 = 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 + ); + } + } +} diff --git a/src/control/snapshots/show_acl.json b/src/control/snapshots/show_acl.json new file mode 100644 index 0000000..b395099 --- /dev/null +++ b/src/control/snapshots/show_acl.json @@ -0,0 +1,16 @@ +{ + "data": { + "allow_all": false, + "allow_entries": [], + "allow_file": "", + "allow_file_entries": [], + "default_decision": "allow", + "deny_all": false, + "deny_entries": [], + "deny_file": "", + "deny_file_entries": [], + "effective_mode": "default_open", + "enforcement_active": false + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_bloom.json b/src/control/snapshots/show_bloom.json new file mode 100644 index 0000000..1fdb6c8 --- /dev/null +++ b/src/control/snapshots/show_bloom.json @@ -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" +} \ No newline at end of file diff --git a/src/control/snapshots/show_cache.json b/src/control/snapshots/show_cache.json new file mode 100644 index 0000000..91ffc9f --- /dev/null +++ b/src/control/snapshots/show_cache.json @@ -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" +} \ No newline at end of file diff --git a/src/control/snapshots/show_connections.json b/src/control/snapshots/show_connections.json new file mode 100644 index 0000000..0df3739 --- /dev/null +++ b/src/control/snapshots/show_connections.json @@ -0,0 +1,6 @@ +{ + "data": { + "connections": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_identity_cache.json b/src/control/snapshots/show_identity_cache.json new file mode 100644 index 0000000..a1ae97a --- /dev/null +++ b/src/control/snapshots/show_identity_cache.json @@ -0,0 +1,8 @@ +{ + "data": { + "count": 0, + "entries": [], + "max_entries": 10000 + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_links.json b/src/control/snapshots/show_links.json new file mode 100644 index 0000000..bb6d0a9 --- /dev/null +++ b/src/control/snapshots/show_links.json @@ -0,0 +1,6 @@ +{ + "data": { + "links": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_mmp.json b/src/control/snapshots/show_mmp.json new file mode 100644 index 0000000..938f8b2 --- /dev/null +++ b/src/control/snapshots/show_mmp.json @@ -0,0 +1,7 @@ +{ + "data": { + "peers": [], + "sessions": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_peers.json b/src/control/snapshots/show_peers.json new file mode 100644 index 0000000..b7eeab4 --- /dev/null +++ b/src/control/snapshots/show_peers.json @@ -0,0 +1,6 @@ +{ + "data": { + "peers": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_routing.json b/src/control/snapshots/show_routing.json new file mode 100644 index 0000000..8be6c56 --- /dev/null +++ b/src/control/snapshots/show_routing.json @@ -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" +} \ No newline at end of file diff --git a/src/control/snapshots/show_sessions.json b/src/control/snapshots/show_sessions.json new file mode 100644 index 0000000..1cc8471 --- /dev/null +++ b/src/control/snapshots/show_sessions.json @@ -0,0 +1,6 @@ +{ + "data": { + "sessions": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_stats_all_history.json b/src/control/snapshots/show_stats_all_history.json new file mode 100644 index 0000000..49631dc --- /dev/null +++ b/src/control/snapshots/show_stats_all_history.json @@ -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" +} \ No newline at end of file diff --git a/src/control/snapshots/show_stats_history.json b/src/control/snapshots/show_stats_history.json new file mode 100644 index 0000000..30fd47f --- /dev/null +++ b/src/control/snapshots/show_stats_history.json @@ -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" +} \ No newline at end of file diff --git a/src/control/snapshots/show_stats_history_all_peers.json b/src/control/snapshots/show_stats_history_all_peers.json new file mode 100644 index 0000000..bf9dabb --- /dev/null +++ b/src/control/snapshots/show_stats_history_all_peers.json @@ -0,0 +1,10 @@ +{ + "data": { + "granularity_seconds": 1, + "metric": "srtt_ms", + "peers": [], + "unit": "ms", + "window_seconds": 10 + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_stats_list.json b/src/control/snapshots/show_stats_list.json new file mode 100644 index 0000000..93b30a1 --- /dev/null +++ b/src/control/snapshots/show_stats_list.json @@ -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" +} \ No newline at end of file diff --git a/src/control/snapshots/show_stats_peers.json b/src/control/snapshots/show_stats_peers.json new file mode 100644 index 0000000..1ee5354 --- /dev/null +++ b/src/control/snapshots/show_stats_peers.json @@ -0,0 +1,7 @@ +{ + "data": { + "count": 0, + "peers": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_status.json b/src/control/snapshots/show_status.json new file mode 100644 index 0000000..7351178 --- /dev/null +++ b/src/control/snapshots/show_status.json @@ -0,0 +1,52 @@ +{ + "data": { + "connection_count": 0, + "control_socket": "", + "effective_ipv6_mtu": 1203, + "estimated_mesh_size": null, + "exe_path": "", + "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": "", + "session_count": 0, + "sparklines": { + "bytes_in": [], + "bytes_out": [], + "loss_rate": [], + "mesh_size": [], + "peer_count": [], + "tree_depth": [] + }, + "state": "created", + "transport_count": 0, + "tun_name": "", + "tun_state": "disabled", + "uptime_secs": "", + "version": "" + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_transports.json b/src/control/snapshots/show_transports.json new file mode 100644 index 0000000..81f500f --- /dev/null +++ b/src/control/snapshots/show_transports.json @@ -0,0 +1,6 @@ +{ + "data": { + "transports": [] + }, + "status": "ok" +} \ No newline at end of file diff --git a/src/control/snapshots/show_tree.json b/src/control/snapshots/show_tree.json new file mode 100644 index 0000000..97eea5f --- /dev/null +++ b/src/control/snapshots/show_tree.json @@ -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" +} \ No newline at end of file diff --git a/src/discovery/nostr/runtime.rs b/src/discovery/nostr/runtime.rs index cba7b83..6dd4844 100644 --- a/src/discovery/nostr/runtime.rs +++ b/src/discovery/nostr/runtime.rs @@ -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); + } +} diff --git a/src/discovery/nostr/stun.rs b/src/discovery/nostr/stun.rs index 83f1045..c51743b 100644 --- a/src/discovery/nostr/stun.rs +++ b/src/discovery/nostr/stun.rs @@ -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 { + 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( diff --git a/src/node/handlers/encrypted.rs b/src/node/handlers/encrypted.rs index 179ca91..96dff61 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -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 { diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 8627654..2192919 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -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, max_age_secs: Option, diff --git a/src/node/tests/decrypt_failure.rs b/src/node/tests/decrypt_failure.rs new file mode 100644 index 0000000..6da703d --- /dev/null +++ b/src/node/tests/decrypt_failure.rs @@ -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" + ); +} diff --git a/src/node/tests/discovery.rs b/src/node/tests/discovery.rs index 96945fc..7d19866 100644 --- a/src/node/tests/discovery.rs +++ b/src/node/tests/discovery.rs @@ -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::>(); + 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"); +} diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index 193b6bf..cc91557 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -10,6 +10,7 @@ mod ble; mod bloom; mod bloom_poison; mod bootstrap; +mod decrypt_failure; mod disconnect; mod discovery; #[cfg(target_os = "linux")] diff --git a/src/node/tests/routing.rs b/src/node/tests/routing.rs index 838d209..0fb4449 100644 --- a/src/node/tests/routing.rs +++ b/src/node/tests/routing.rs @@ -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(); diff --git a/src/peer/active.rs b/src/peer/active.rs index 3feb8dd..e9568e7 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -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); + } } diff --git a/src/transport/ethernet/socket_macos.rs b/src/transport/ethernet/socket_macos.rs index 3ae3671..4f24fdc 100644 --- a/src/transport/ethernet/socket_macos.rs +++ b/src/transport/ethernet/socket_macos.rs @@ -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::(), 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::(); + 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::() as u16, + _pad: 0, + }; + let hdr_size = std::mem::size_of::(); + 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. diff --git a/src/upper/tun.rs b/src/upper/tun.rs index ebfda83..0d01dcb 100644 --- a/src/upper/tun.rs +++ b/src/upper/tun.rs @@ -361,6 +361,41 @@ impl TunDevice { } } +/// macOS utun protocol family value for IPv6 (matches `` +/// `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 { + 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 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); + } + } } diff --git a/testing/ci-local.sh b/testing/ci-local.sh index 1f8f7e9..061981f 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -17,7 +17,8 @@ # Integration suites (default coverage): # static-mesh, static-chain, rekey, rekey-accept-off, # rekey-outbound-only, mixed-profile, gateway, -# acl-allowlist, nat-cone, nat-symmetric, nat-lan, +# acl-allowlist, firewall, nat-cone, nat-symmetric, nat-lan, +# nostr-publish-consume, stun-faults, # chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh, # chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent, # chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability, @@ -73,7 +74,10 @@ CHAOS_SUITES=( SIDECAR_SUITES=(sidecar) GATEWAY_SUITES=(gateway) ACL_SUITES=(acl-allowlist) +FIREWALL_SUITES=(firewall) NAT_SUITES=(cone symmetric lan) +NOSTR_RELAY_SUITES=(nostr-publish-consume) +STUN_FAULTS_SUITES=(stun-faults) DNS_RESOLVER_SUITES=(dns-resolver) DEB_INSTALL_SUITES=(deb-install) TOR_SUITES=(tor-socks5 tor-directory) @@ -111,9 +115,18 @@ list_suites() { echo " ACL allowlist:" for s in "${ACL_SUITES[@]}"; do echo " $s"; done echo "" + echo " Firewall baseline:" + for s in "${FIREWALL_SUITES[@]}"; do echo " $s"; done + echo "" echo " NAT scenarios:" for s in "${NAT_SUITES[@]}"; do echo " nat-$s"; done echo "" + echo " Nostr publish/consume:" + for s in "${NOSTR_RELAY_SUITES[@]}"; do echo " $s"; done + echo "" + echo " STUN fault-injection:" + for s in "${STUN_FAULTS_SUITES[@]}"; do echo " $s"; done + echo "" echo " Chaos scenarios:" for entry in "${CHAOS_SUITES[@]}"; do read -ra parts <<< "$entry" @@ -177,6 +190,20 @@ record() { run_build() { stage "Stage 1: Build" + info "sudo nft -c -f packaging/common/fips.nft (nftables ruleset syntax check)" + if command -v nft &>/dev/null; then + if sudo nft -c -f packaging/common/fips.nft 2>&1; then + record "nft-syntax" 0 + else + record "nft-syntax" 1 + return 1 + fi + else + info "nftables not installed; install with 'apt install nftables' to validate fips.nft" + record "nft-syntax" 1 + return 1 + fi + info "cargo build --release" if cargo build --release 2>&1; then record "build" 0 @@ -446,6 +473,16 @@ run_acl_allowlist() { fi } +# Run firewall baseline integration test +run_firewall() { + info "[firewall] Running integration test" + if bash testing/firewall/test.sh --skip-build 2>&1; then + record "firewall" 0 + else + record "firewall" 1 + fi +} + # Run a NAT scenario (cone, symmetric, lan) run_nat() { local scenario="$1" @@ -457,6 +494,33 @@ run_nat() { fi } +# Run the Nostr overlay advert publish/consume integration test. +# Two FIPS daemons + the existing strfry relay; exercises Phase 1 +# (A→B publish/consume), Phase 2 (B→A reverse), and Phase 3 (malformed +# advert injected directly to the relay; consumer-liveness assertion). +run_nostr_publish_consume() { + info "[nostr-publish-consume] Running Nostr publish/consume test" + if bash testing/nat/scripts/nostr-relay-test.sh 2>&1; then + record "nostr-publish-consume" 0 + else + record "nostr-publish-consume" 1 + fi +} + +# Run the STUN fault-injection integration test. +# One FIPS daemon + a netns-sharing shim that injects tc/iptables faults +# against UDP egress to the STUN service. Three phases: drop, delay, +# kill. Asserts the daemon detects each fault, recovers from delay, and +# never panics. +run_stun_faults() { + info "[stun-faults] Running STUN fault-injection test" + if bash testing/nat/scripts/stun-faults-test.sh 2>&1; then + record "stun-faults" 0 + else + record "stun-faults" 1 + fi +} + # Run dns-resolver harness (multi-distro + e2e scenarios) run_dns_resolver() { info "[dns-resolver] Running multi-distro test (slow — builds per-distro images)" @@ -536,11 +600,24 @@ run_integration() { # ACL allowlist run_acl_allowlist + # Firewall baseline + run_firewall + # NAT scenarios (sequential — each owns its compose project) for scenario in "${NAT_SUITES[@]}"; do run_nat "$scenario" done + # Nostr publish/consume (sequential — shares the NAT compose project) + for _suite in "${NOSTR_RELAY_SUITES[@]}"; do + run_nostr_publish_consume + done + + # STUN fault-injection (sequential — shares the NAT compose project) + for _suite in "${STUN_FAULTS_SUITES[@]}"; do + run_stun_faults + done + # Chaos scenarios (parallel, throttled) if [[ "$SKIP_CHAOS" != true ]]; then info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)" @@ -625,8 +702,14 @@ run_suite() { run_gateway ;; acl-allowlist) run_acl_allowlist ;; + firewall) + run_firewall ;; nat-cone|nat-symmetric|nat-lan) run_nat "${suite#nat-}" ;; + nostr-publish-consume) + run_nostr_publish_consume ;; + stun-faults) + run_stun_faults ;; chaos-*) local chaos_name="${suite#chaos-}" local found=false diff --git a/testing/deb-install/test.sh b/testing/deb-install/test.sh index f01cce0..a17680c 100755 --- a/testing/deb-install/test.sh +++ b/testing/deb-install/test.sh @@ -279,6 +279,48 @@ DOCKERFILE fail "fips-dns.service not enabled after install" fi + # ── nftables firewall baseline (v0.3.0) ────────────────────────── + # The fips-firewall.service unit ships installed but DISABLED by + # default; operators opt in explicitly. The fips.nft ruleset is a + # dpkg conffile, and /etc/fips/fips.d/ is a drop-in directory the + # ruleset includes via a glob. None of these are exercised by the + # service-start path below — verify them as static install state. + if docker exec "$name" test -f /lib/systemd/system/fips-firewall.service; then + pass "fips-firewall.service unit installed at /lib/systemd/system/" + else + fail "fips-firewall.service unit missing from /lib/systemd/system/" + fi + # is-enabled prints 'disabled' (and exits non-zero) for an + # installed-but-not-enabled unit; capture stdout, don't gate on rc. + fw_state=$(docker exec "$name" systemctl is-enabled fips-firewall.service 2>/dev/null || true) + if [ "$fw_state" = "disabled" ]; then + pass "fips-firewall.service disabled by default (opt-in)" + else + fail "fips-firewall.service unexpected state: '$fw_state' (expected 'disabled')" + fi + if docker exec "$name" test -f /etc/fips/fips.nft && \ + docker exec "$name" dpkg-query -W -f='${Conffiles}\n' fips 2>/dev/null \ + | grep -q '/etc/fips/fips.nft'; then + pass "/etc/fips/fips.nft installed and registered as dpkg conffile" + else + fail "/etc/fips/fips.nft missing or not a registered conffile" + fi + if docker exec "$name" test -d /etc/fips/fips.d; then + fwd_mode=$(docker exec "$name" stat -c '%a %U:%G' /etc/fips/fips.d 2>/dev/null) + if [ "$fwd_mode" = "755 root:root" ]; then + pass "/etc/fips/fips.d/ drop-in dir present (755 root:root)" + else + fail "/etc/fips/fips.d/ wrong mode/owner: '$fwd_mode' (expected '755 root:root')" + fi + else + fail "/etc/fips/fips.d/ drop-in directory missing" + fi + if docker exec "$name" grep -qF 'include "/etc/fips/fips.d/*.nft"' /etc/fips/fips.nft; then + pass "fips.nft includes drop-in glob /etc/fips/fips.d/*.nft" + else + fail "fips.nft missing drop-in include for /etc/fips/fips.d/*.nft" + fi + # Start the services as a simulated boot. (On a real system, # they'd come up on next reboot.) docker exec "$name" systemctl start fips.service 2>&1 || true diff --git a/testing/firewall/.gitignore b/testing/firewall/.gitignore new file mode 100644 index 0000000..31315f6 --- /dev/null +++ b/testing/firewall/.gitignore @@ -0,0 +1 @@ +generated-configs diff --git a/testing/firewall/README.md b/testing/firewall/README.md new file mode 100644 index 0000000..1da268d --- /dev/null +++ b/testing/firewall/README.md @@ -0,0 +1,111 @@ +# Firewall Baseline Test + +End-to-end exercise of the production fips0 nftables baseline at +`packaging/common/fips.nft`. Closes the v0.3.0 audit gap that the +default-deny + conntrack + drop-in semantics had no integration coverage. + +## What this exercises + +The `fips.nft` baseline polices ONLY the fips0 mesh interface and +implements default-deny inbound. This suite asserts the four behaviors +documented in the file's header are actually true on a live mesh: + +- **(a)** Unallowed inbound on fips0 is **dropped** +- **(b)** Outbound-initiated flows get their reply via the + `ct state established,related accept` rule +- **(c)** ICMPv6 echo-request is **accepted** (ping6 reachability) +- **(d)** A drop-in `.nft` file under `/etc/fips/fips.d/` adds an + allowlisted port and that port is **accepted** + +A drop-counter check after case (a) confirms the connection was +actively DROP'd by the fips chain (not silently unrouted). + +## Topology + +Two FIPS nodes peered over UDP on a Docker bridge network: + +| Container | Hostname | docker IPv4 | Firewall | +|-------------------------|----------|---------------|----------| +| `fips-fw-container-a` | `host-a` | 172.32.0.10 | none (probe) | +| `fips-fw-container-b` | `host-b` | 172.32.0.11 | `fips.nft` + drop-in | + +`node-b` mounts the production `packaging/common/fips.nft` read-only at +`/etc/fips/fips.nft`, plus a drop-in at `/etc/fips/fips.d/services.nft` +containing `tcp dport 22 accept`. `node-a` is unfirewalled and serves +as the probe origin. + +Both containers run the unified test image's `default` mode, which +starts dnsmasq + sshd (port 22) + iperf3 + python http.server on +port 8000 + the FIPS daemon. + +## fips-firewall.service activation + +The production unit's ExecStart is: + +```text +ExecStart=/usr/sbin/nft -f /etc/fips/fips.nft +``` + +The unified test image does not run systemd, so `test.sh` invokes the +same `nft -f` command directly inside `node-b` after fips0 is up and +peering has converged. The deb-install harness covers the systemd +unit-enablement path under real systemd separately. + +## Run + +Build the Linux binaries and test image: + +```bash +./testing/scripts/build.sh --no-docker +``` + +Run the suite: + +```bash +./testing/firewall/test.sh +``` + +`test.sh` regenerates fixtures automatically before starting Docker. +Use `--skip-build` to reuse the existing release binaries. Use +`--keep-up` to leave the containers running for inspection. + +## Expected output shape + +```text +=== Generating firewall fixtures +=== Starting firewall harness +=== Waiting for fips0 on both nodes +=== Waiting for peer convergence +=== Resolving fips0 addresses + node-a: fd97:... + node-b: fd97:... +=== Activating fips-firewall on fips-fw-container-b +PASS: fips-fw-container-b: fips.nft baseline + drop-in loaded +=== Case (c): ICMPv6 echo-request to firewalled node +PASS: (c) ICMPv6 ping node-a → node-b accepted +=== Case (a): unallowed inbound TCP/8000 from node-a → node-b +PASS: (a) inbound TCP/8000 blocked (curl rc=28) +=== Case (b): node-b initiates outbound TCP, expects reply via conntrack +PASS: (b) outbound from node-b got HTTP 200 via conntrack reply path +=== Case (d): drop-in allowlisted TCP/22 from node-a → node-b +PASS: (d) drop-in allowlisted TCP/22 reachable +=== Drop counter incremented (case a should have ticked it) +PASS: drop counter = N (case a was actually dropped, not just unrouted) +=== Firewall integration test passed +``` + +## Inspect the loaded ruleset + +```bash +docker exec fips-fw-container-b nft list table inet fips +``` + +## Stop and clean up + +```bash +docker compose -f testing/firewall/docker-compose.yml down +``` + +## Generated fixture location + +`testing/firewall/generated-configs/` (gitignored). diff --git a/testing/firewall/docker-compose.yml b/testing/firewall/docker-compose.yml new file mode 100644 index 0000000..2b1b1c1 --- /dev/null +++ b/testing/firewall/docker-compose.yml @@ -0,0 +1,52 @@ +networks: + fw-net: + driver: bridge + ipam: + config: + - subnet: 172.32.0.0/24 + +x-fips-common: &fips-common + build: + context: ../docker + image: fips-test:latest + entrypoint: ["/usr/local/bin/entrypoint.sh"] + cap_add: + - NET_ADMIN + - NET_RAW + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + restart: "no" + environment: + - FIPS_TEST_MODE=default + - RUST_LOG=info,fips::node=debug + +services: + service-a: + <<: *fips-common + container_name: fips-fw-container-a + hostname: host-a + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-a/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-a/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-a/fips.key:/etc/fips/fips.key:ro + networks: + fw-net: + ipv4_address: 172.32.0.10 + + service-b: + <<: *fips-common + container_name: fips-fw-container-b + hostname: host-b + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-b/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-b/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-b/fips.key:/etc/fips/fips.key:ro + - ../../packaging/common/fips.nft:/etc/fips/fips.nft:ro + - ./generated-configs/node-b/fips.d:/etc/fips/fips.d:ro + networks: + fw-net: + ipv4_address: 172.32.0.11 diff --git a/testing/firewall/generate-configs.sh b/testing/firewall/generate-configs.sh new file mode 100755 index 0000000..1fca1de --- /dev/null +++ b/testing/firewall/generate-configs.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Generate fixtures for the firewall integration test. +# +# Two FIPS nodes (a, b). node-b mounts the production fips.nft baseline +# plus a single drop-in (.nft) under /etc/fips/fips.d/ that allows TCP +# port 22 inbound — the test asserts this is honored. node-a is a +# probe-only node with no firewall. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GENERATED_DIR="$SCRIPT_DIR/generated-configs" + +# Deterministic test identities (mirrors the acl-allowlist style). +NPUB_A="npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" +KEY_A="0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + +NPUB_B="npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le" +KEY_B="b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0" + +write_file() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" +} + +write_hosts_file() { + local node="$1" + write_file "$GENERATED_DIR/$node/hosts" <&2; exit 1 ;; + esac +done + +cleanup() { + if [ "$KEEP_UP" = false ]; then + docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT + +log() { + echo "=== $*" +} + +pass() { + echo "PASS: $*" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Wait for fips0 to exist and have a global IPv6 address inside container. +wait_for_fips0() { + local container="$1" + local timeout="${2:-30}" + for _ in $(seq 1 "$timeout"); do + if docker exec "$container" ip -6 addr show fips0 2>/dev/null \ + | grep -qE 'inet6 fd[0-9a-f]+:'; then + return 0 + fi + sleep 1 + done + fail "$container fips0 did not come up within ${timeout}s" +} + +# Wait for the peer count on a container to reach the expected value. +wait_for_peers_exact() { + local container="$1" + local expected_count="$2" + local timeout="${3:-30}" + for _ in $(seq 1 "$timeout"); do + local count + count=$(docker exec "$container" fipsctl show peers 2>/dev/null \ + | python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null || echo 0) + if [ "$count" -eq "$expected_count" ]; then + return 0 + fi + sleep 1 + done + fail "$container did not reach $expected_count connected peers in ${timeout}s" +} + +# Resolve `.fips` inside a container and print the AAAA answer. +resolve_fips_addr() { + local container="$1" + local npub="$2" + docker exec "$container" getent ahostsv6 "${npub}.fips" \ + | awk '{print $1; exit}' +} + +# Activate the fips firewall baseline inside a container. Mirrors the +# fips-firewall.service ExecStart. +activate_firewall() { + local container="$1" + docker exec "$container" nft -f /etc/fips/fips.nft + # Sanity: the table must now exist. + if ! docker exec "$container" nft list table inet fips >/dev/null 2>&1; then + fail "$container: inet fips table not present after nft -f" + fi +} + +# Verify default-policy and key chain rules look right. +assert_baseline_loaded() { + local container="$1" + local listing + listing="$(docker exec "$container" nft list table inet fips)" + # Default-deny is achieved via the trailing `counter drop` (chain + # policy is `accept` for return-on-non-fips0 to work safely). + if ! printf '%s' "$listing" | grep -q 'counter packets'; then + fail "$container: counter drop rule missing from inet fips" + fi + if ! printf '%s' "$listing" | grep -q 'iifname != "fips0" return'; then + fail "$container: non-fips0 early return rule missing" + fi + if ! printf '%s' "$listing" | grep -q 'ct state established,related accept'; then + fail "$container: conntrack established,related rule missing" + fi + if ! printf '%s' "$listing" | grep -q 'icmpv6 type echo-request accept'; then + fail "$container: ICMPv6 echo-request rule missing" + fi + if ! printf '%s' "$listing" | grep -q 'tcp dport 22 accept'; then + fail "$container: drop-in tcp dport 22 rule missing (fips.d not included?)" + fi + pass "$container: fips.nft baseline + drop-in loaded" +} + +# ──────────────────────────────────────────────────────────────────────── + +if [ "$SKIP_BUILD" = false ]; then + log "Building Linux test binaries" + "$TESTING_DIR/scripts/build.sh" --no-docker +fi + +log "Generating firewall fixtures" +"$GENERATE_CONFIGS" + +log "Starting firewall harness" +docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true +docker compose -f "$COMPOSE_FILE" up -d --build + +log "Waiting for fips0 on both nodes" +wait_for_fips0 "$CONTAINER_A" 40 +wait_for_fips0 "$CONTAINER_B" 40 + +log "Waiting for peer convergence" +wait_for_peers_exact "$CONTAINER_A" 1 40 +wait_for_peers_exact "$CONTAINER_B" 1 40 + +log "Resolving fips0 addresses" +ADDR_A="$(resolve_fips_addr "$CONTAINER_A" "$NPUB_A")" +ADDR_B="$(resolve_fips_addr "$CONTAINER_B" "$NPUB_B")" +[ -z "$ADDR_A" ] && fail "could not resolve node-a fips0 address" +[ -z "$ADDR_B" ] && fail "could not resolve node-b fips0 address" +echo " node-a: $ADDR_A" +echo " node-b: $ADDR_B" + +log "Activating fips-firewall on $CONTAINER_B" +activate_firewall "$CONTAINER_B" +assert_baseline_loaded "$CONTAINER_B" + +# ── (c) Pre-firewall sanity: confirm both ports are reachable BEFORE ─ +# the firewall is up would be ideal, but we activated already to +# keep the test deterministic. Instead we run case (c) ICMPv6 +# first, since it's the most basic reachability check. + +log "Case (c): ICMPv6 echo-request to firewalled node" +if docker exec "$CONTAINER_A" ping6 -c 3 -W 5 "$ADDR_B" >/dev/null 2>&1; then + pass "(c) ICMPv6 ping node-a → node-b accepted" +else + fail "(c) ICMPv6 ping node-a → node-b should succeed but was dropped" +fi + +# ── (a) Unallowed inbound is dropped ─────────────────────────────────── +log "Case (a): unallowed inbound TCP/${UNALLOWED_PORT} from node-a → node-b" +# python3 http.server is already listening on :: per entrypoint default mode. +# Use curl --max-time 5 — must time out (exit 28) or otherwise fail. +set +e +docker exec "$CONTAINER_A" curl -6 --silent --output /dev/null \ + --max-time 5 "http://[${ADDR_B}]:${UNALLOWED_PORT}/" +RC=$? +set -e +if [ "$RC" -eq 0 ]; then + fail "(a) connection to ${UNALLOWED_PORT} succeeded but should have been DROP'd (rc=0)" +fi +pass "(a) inbound TCP/${UNALLOWED_PORT} blocked (curl rc=$RC)" + +# ── (b) Outbound-initiated flow + conntrack reply ────────────────────── +log "Case (b): node-b initiates outbound TCP, expects reply via conntrack" +# node-b → node-a:8000 on the fips overlay. node-a has http.server on +# [::]:8000 and is NOT firewalled, so this is purely a test of node-b's +# outbound + ct state established,related path on the way back. +set +e +docker exec "$CONTAINER_B" curl -6 --silent --max-time 5 \ + --output /dev/null --write-out '%{http_code}' \ + "http://[${ADDR_A}]:${OUTBOUND_TARGET_PORT}/" >/tmp/fw_b_rc 2>/dev/null +RC=$? +set -e +HTTP_CODE="$(cat /tmp/fw_b_rc 2>/dev/null || true)" +rm -f /tmp/fw_b_rc +if [ "$RC" -ne 0 ]; then + fail "(b) outbound from node-b failed (curl rc=$RC, http=$HTTP_CODE) — conntrack reply path broken" +fi +if [ "$HTTP_CODE" != "200" ]; then + fail "(b) outbound returned http=$HTTP_CODE (expected 200) — reply blocked?" +fi +pass "(b) outbound from node-b got HTTP $HTTP_CODE via conntrack reply path" + +# ── (d) Drop-in allowlisted port accepted ────────────────────────────── +log "Case (d): drop-in allowlisted TCP/${ALLOWED_PORT} from node-a → node-b" +# nc -zv -w3: zero-I/O scan, verbose, 3-second timeout. Exit 0 = port +# open and reachable. The container's sshd is listening on [::]:22 by +# default per the test entrypoint. +if docker exec "$CONTAINER_A" nc -6 -z -v -w 3 "$ADDR_B" "$ALLOWED_PORT" 2>&1 \ + | grep -qE 'succeeded|open'; then + pass "(d) drop-in allowlisted TCP/${ALLOWED_PORT} reachable" +else + fail "(d) drop-in allowlisted TCP/${ALLOWED_PORT} should be reachable but was blocked" +fi + +# ── Drop-counter sanity ──────────────────────────────────────────────── +log "Drop counter incremented (case a should have ticked it)" +DROP_PKTS="$(docker exec "$CONTAINER_B" nft list table inet fips \ + | awk '/counter packets/ {print $3; exit}')" +if [ -z "${DROP_PKTS:-}" ] || [ "$DROP_PKTS" -lt 1 ]; then + fail "drop counter is $DROP_PKTS — case (a) should have produced drops" +fi +pass "drop counter = $DROP_PKTS (case a was actually dropped, not just unrouted)" + +log "Firewall integration test passed" diff --git a/testing/nat/docker-compose.yml b/testing/nat/docker-compose.yml index 79ff16a..b7948e3 100644 --- a/testing/nat/docker-compose.yml +++ b/testing/nat/docker-compose.yml @@ -232,3 +232,106 @@ services: networks: shared-lan: ipv4_address: 172.31.10.11 + + # ── Nostr publish/consume profile ────────────────────────────────────── + # Two FIPS daemons + the existing strfry relay, exercising the overlay + # advert publish → relay → consumer round-trip end-to-end. Both nodes + # share the same LAN bridge as the relay (no NAT in the way) so the + # focus of the test is the Nostr discovery layer rather than NAT + # traversal mechanics. Phase 3 (malformed advert) is driven by a + # one-shot publish from the test runner via the relay's WebSocket. + nostr-pub-a: + <<: *fips-common + profiles: ["nostr-publish-consume"] + container_name: fips-nat-nostr-pub-a + hostname: fips-nat-nostr-pub-a + depends_on: + - relay + - stun + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/nostr-publish-consume/node-a.yaml:/etc/fips/fips.yaml:ro + networks: + shared-lan: + ipv4_address: 172.31.10.20 + + nostr-pub-b: + <<: *fips-common + profiles: ["nostr-publish-consume"] + container_name: fips-nat-nostr-pub-b + hostname: fips-nat-nostr-pub-b + depends_on: + - relay + - stun + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/nostr-publish-consume/node-b.yaml:/etc/fips/fips.yaml:ro + networks: + shared-lan: + ipv4_address: 172.31.10.21 + + # ── STUN fault-injection profile ─────────────────────────────────────── + # One FIPS daemon + a netns-sharing shim that injects tc/iptables faults + # against UDP egress to the STUN service. The runner script drives the + # shim via `docker exec` (Approach A) — no scripted timing inside the + # shim itself. Three phases: + # 1. drop — 100% UDP egress drop to STUN; assert daemon notices the + # observation timeout and retries. + # 2. delay — ~5s netem delay; assert daemon recovers and STUN succeeds + # again once the rule is removed. + # 3. kill — `docker stop fips-nat-stun`; assert daemon stays up and + # continues to handle "STUN unreachable" gracefully. + # The shim shares the daemon's network namespace so `tc qdisc add dev + # eth0 ...` operates on the daemon's egress path. The shim therefore + # has its own NET_ADMIN cap; the daemon already has one for TUN. + stun-fault-node: + <<: *fips-common + profiles: ["stun-faults"] + container_name: fips-nat-stun-fault-node + hostname: fips-nat-stun-fault-node + depends_on: + - relay + - stun + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/stun-faults/stun-fault-node.yaml:/etc/fips/fips.yaml:ro + networks: + shared-lan: + ipv4_address: 172.31.10.50 + + # Fault-free peer that publishes a valid overlay advert, so the + # fault-node's NAT-traversal attempt actually reaches + # observe_traversal_addresses() (the STUN client). Without this peer the + # daemon would abort with "no overlay advert" and never generate the + # STUN egress that the shim's tc/iptables rules are meant to drop. + # Intentionally has NO fault shim sharing its netns; runs cleanly. + stun-fault-peer: + <<: *fips-common + profiles: ["stun-faults"] + container_name: fips-nat-stun-fault-peer + hostname: fips-nat-stun-fault-peer + depends_on: + - relay + - stun + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/stun-faults/stun-fault-peer.yaml:/etc/fips/fips.yaml:ro + networks: + shared-lan: + ipv4_address: 172.31.10.51 + + stun-fault-shim: + image: fips-test:latest + profiles: ["stun-faults"] + container_name: fips-nat-stun-fault-shim + depends_on: + - stun-fault-node + cap_add: + - NET_ADMIN + - NET_RAW + network_mode: "service:stun-fault-node" + restart: "no" + entrypoint: + - /bin/sh + - -c + - "exec sleep infinity" diff --git a/testing/nat/scripts/generate-configs.sh b/testing/nat/scripts/generate-configs.sh index 594bb1f..b307d95 100755 --- a/testing/nat/scripts/generate-configs.sh +++ b/testing/nat/scripts/generate-configs.sh @@ -11,7 +11,7 @@ SCENARIO="${1:?usage: generate-configs.sh [mesh-name]}" MESH_NAME="${2:-nat-lab-$(date +%s)-$$}" case "$SCENARIO" in - cone|symmetric|lan) ;; + cone|symmetric|lan|nostr-publish-consume|stun-faults) ;; *) echo "Unknown scenario: $SCENARIO" >&2 exit 1 @@ -30,7 +30,8 @@ npub_b="$(echo "$keys_b" | awk -F= '/^npub=/{print $2}')" relay_addr="ws://172.31.254.30:7777" stun_addr="stun:172.31.254.40:3478" -if [ "$SCENARIO" = "lan" ]; then +if [ "$SCENARIO" = "lan" ] || [ "$SCENARIO" = "nostr-publish-consume" ] \ + || [ "$SCENARIO" = "stun-faults" ]; then relay_addr="ws://172.31.10.30:7777" stun_addr="stun:172.31.10.40:3478" fi @@ -125,6 +126,22 @@ EOF write_config "$OUTPUT_DIR/$SCENARIO/node-a.yaml" "$nsec_a" "$peer_block_a" write_config "$OUTPUT_DIR/$SCENARIO/node-b.yaml" "$nsec_b" "$peer_block_b" +# stun-faults runs two real FIPS daemons: +# stun-fault-node (key "a") — target of tc/iptables faults via the shim +# stun-fault-peer (key "b") — fault-free peer that publishes a valid +# overlay advert so the fault-node's +# traversal actually invokes the STUN client +# Mutual peering ensures both sides advertise; without a real advert the +# fault-node would abort at "no overlay advert" and never generate STUN +# egress. The shim's netem/iptables rules can then meaningfully drop +# the STUN UDP traffic during Phase 1. +if [ "$SCENARIO" = "stun-faults" ]; then + write_config "$OUTPUT_DIR/$SCENARIO/stun-fault-node.yaml" \ + "$nsec_a" "$peer_block_a" + write_config "$OUTPUT_DIR/$SCENARIO/stun-fault-peer.yaml" \ + "$nsec_b" "$peer_block_b" +fi + cat > "$OUTPUT_DIR/$SCENARIO/npubs.env" </dev/null 2>&1 || true +} + +trap 'echo ""; echo "nostr-relay-test interrupted"; cleanup; exit 130' INT TERM + +require_docker_daemon() { + if ! docker info >/dev/null 2>&1; then + echo "Docker daemon is not reachable; cannot run nostr-relay-test" >&2 + exit 1 + fi +} + +require_test_image() { + if ! docker image inspect fips-test:latest >/dev/null 2>&1; then + echo "fips-test:latest not found; building test image" + "$BUILD_SCRIPT" + fi +} + +dump_diagnostics() { + echo "" + echo "=== nostr publish/consume diagnostics ===" + for c in "$NODE_A" "$NODE_B" "$RELAY_CONTAINER"; do + echo "" + echo "--- $c: logs (last 80) ---" + docker logs "$c" 2>&1 | tail -80 || true + done + for c in "$NODE_A" "$NODE_B"; do + echo "" + echo "--- $c: fipsctl show peers ---" + docker exec "$c" fipsctl show peers 2>&1 || true + echo "--- $c: fipsctl show links ---" + docker exec "$c" fipsctl show links 2>&1 || true + done +} + +# Publish a malformed Kind-37195 (overlay-advert) event directly to the +# relay. The event is signed with a fresh ephemeral keypair (so the +# relay accepts it on the wire) but its `content` is gibberish that +# cannot deserialize as OverlayAdvert. Both consumer daemons must log a +# parse error and stay alive. +publish_malformed_advert() { + local relay_host="$1" + local relay_port="$2" + + docker exec "$NODE_A" python3 - "$relay_host" "$relay_port" <<'PY' +import base64 +import hashlib +import json +import os +import socket +import struct +import sys +import time + +# ── Minimal secp256k1 BIP-340 (Schnorr) signer using only stdlib. ────── +# Reference: BIP-340, secp256k1 group order n / curve params. +P = 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_FFFFFC2F +N = 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_BAAEDCE6_AF48A03B_BFD25E8C_D0364141 +G = ( + 0x79BE667E_F9DCBBAC_55A06295_CE870B07_029BFCDB_2DCE28D9_59F2815B_16F81798, + 0x483ADA77_26A3C465_5DA4FBFC_0E1108A8_FD17B448_A6855419_9C47D08F_FB10D4B8, +) + + +def inv(a, m=P): + return pow(a, -1, m) + + +def point_add(a, b): + if a is None: + return b + if b is None: + return a + if a[0] == b[0] and (a[1] != b[1] or a[1] == 0): + return None + if a == b: + m = (3 * a[0] * a[0]) * inv(2 * a[1]) % P + else: + m = (b[1] - a[1]) * inv(b[0] - a[0]) % P + x = (m * m - a[0] - b[0]) % P + y = (m * (a[0] - x) - a[1]) % P + return (x, y) + + +def scalar_mul(k, point=G): + result = None + addend = point + while k: + if k & 1: + result = point_add(result, addend) + addend = point_add(addend, addend) + k >>= 1 + return result + + +def lift_x(x): + if x >= P: + return None + y_sq = (pow(x, 3, P) + 7) % P + y = pow(y_sq, (P + 1) // 4, P) + if pow(y, 2, P) != y_sq: + return None + return (x, y if y % 2 == 0 else P - y) + + +def tagged_hash(tag, data): + th = hashlib.sha256(tag.encode()).digest() + return hashlib.sha256(th + th + data).digest() + + +def schnorr_sign(msg32, secret): + d0 = int.from_bytes(secret, "big") + if not (1 <= d0 < N): + raise ValueError("invalid secret key") + P_pub = scalar_mul(d0) + d = d0 if P_pub[1] % 2 == 0 else N - d0 + t = (d ^ int.from_bytes(tagged_hash("BIP0340/aux", os.urandom(32)), "big")) + t_bytes = t.to_bytes(32, "big") + rand = tagged_hash( + "BIP0340/nonce", + t_bytes + P_pub[0].to_bytes(32, "big") + msg32, + ) + k0 = int.from_bytes(rand, "big") % N + if k0 == 0: + raise ValueError("nonce gen failed") + R = scalar_mul(k0) + k = k0 if R[1] % 2 == 0 else N - k0 + e = int.from_bytes( + tagged_hash( + "BIP0340/challenge", + R[0].to_bytes(32, "big") + P_pub[0].to_bytes(32, "big") + msg32, + ), + "big", + ) % N + s = (k + e * d) % N + return R[0].to_bytes(32, "big") + s.to_bytes(32, "big") + + +def xonly_pubkey(secret): + d0 = int.from_bytes(secret, "big") + P_pub = scalar_mul(d0) + return P_pub[0].to_bytes(32, "big") + + +# ── Build the malformed Kind-37195 event ─────────────────────────────── +secret = os.urandom(32) +# Ensure 1 <= d < N +while int.from_bytes(secret, "big") == 0 or int.from_bytes(secret, "big") >= N: + secret = os.urandom(32) + +pubkey = xonly_pubkey(secret).hex() +created_at = int(time.time()) +kind = 37195 +tags = [ + ["d", "fips-overlay-v1"], + ["app", "fips.nat.lab.v1"], +] +content = "this-is-not-a-valid-overlay-advert-{garbage}" + +# Nostr event id = sha256(json([0, pubkey, created_at, kind, tags, content])) +serialized = json.dumps( + [0, pubkey, created_at, kind, tags, content], + separators=(",", ":"), + ensure_ascii=False, +) +event_id = hashlib.sha256(serialized.encode("utf-8")).digest() +sig = schnorr_sign(event_id, secret).hex() + +event = { + "id": event_id.hex(), + "pubkey": pubkey, + "created_at": created_at, + "kind": kind, + "tags": tags, + "content": content, + "sig": sig, +} + +msg = json.dumps(["EVENT", event]) +print(f"publishing malformed advert id={event['id']} pubkey={pubkey}") + +# ── Minimal stdlib WebSocket client (RFC 6455) ──────────────────────── +relay_host = sys.argv[1] +relay_port = int(sys.argv[2]) + +sock = socket.create_connection((relay_host, relay_port), timeout=10) +key_b64 = base64.b64encode(os.urandom(16)).decode() +handshake = ( + f"GET / HTTP/1.1\r\n" + f"Host: {relay_host}:{relay_port}\r\n" + f"Upgrade: websocket\r\n" + f"Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key_b64}\r\n" + f"Sec-WebSocket-Version: 13\r\n\r\n" +) +sock.sendall(handshake.encode()) + +resp = b"" +sock.settimeout(5) +while b"\r\n\r\n" not in resp: + chunk = sock.recv(4096) + if not chunk: + break + resp += chunk +if b" 101 " not in resp.split(b"\r\n", 1)[0]: + print("websocket handshake failed:", resp[:200], file=sys.stderr) + raise SystemExit(2) + +# Build a single masked text frame (FIN=1, opcode=1). +payload = msg.encode("utf-8") +mask = os.urandom(4) +masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + +frame = bytearray([0x81]) # FIN + text +plen = len(payload) +if plen < 126: + frame.append(0x80 | plen) +elif plen < 65536: + frame.append(0x80 | 126) + frame += struct.pack("!H", plen) +else: + frame.append(0x80 | 127) + frame += struct.pack("!Q", plen) +frame += mask + masked +sock.sendall(bytes(frame)) + +# Read the relay's OK/NOTICE response (best-effort). +sock.settimeout(3) +try: + reply = sock.recv(4096) + print("relay reply:", reply[:200]) +except socket.timeout: + print("relay reply: ") + +# Polite close (opcode 0x88 = close), then drop. +try: + sock.sendall(bytes([0x88, 0x80]) + os.urandom(4)) +except OSError: + pass +sock.close() +print("malformed advert published") +PY +} + +assert_process_alive() { + local container="$1" + if ! docker exec "$container" pidof fips >/dev/null 2>&1; then + echo "fips daemon NOT running in $container after malformed advert" >&2 + return 1 + fi + echo " $container: fips daemon still alive after malformed advert" +} + +assert_no_panic() { + local container="$1" + local logs + logs="$(docker logs "$container" 2>&1 || true)" + if grep -Eq "panicked at|RUST_BACKTRACE|fatal runtime error" <<<"$logs"; then + echo "panic detected in $container logs" >&2 + return 1 + fi +} + +run_test() { + echo "=== nostr-relay-test: phase 1 + 2 ===" + cleanup + "$GENERATE_SCRIPT" "$SCENARIO" + + "${COMPOSE[@]}" --profile "$PROFILE" up -d --build --force-recreate + + # Phase 1 + Phase 2 together: each side publishes its own advert, + # subscribes for the other's, then dials. Bidirectional success + # (peer count == 1 on both nodes) proves both directions of the + # publish/consume round-trip. + echo "" + echo "--- waiting for bidirectional advert observation + dial ---" + if ! wait_for_peers "$NODE_A" 1 60; then + dump_diagnostics + return 1 + fi + if ! wait_for_peers "$NODE_B" 1 60; then + dump_diagnostics + return 1 + fi + + # shellcheck disable=SC1090 + source "$NAT_DIR/generated-configs/$SCENARIO/npubs.env" + echo " NPUB_A=$NPUB_A" + echo " NPUB_B=$NPUB_B" + + # Sanity: traffic actually flows (TUN-level reachability). + if ! docker exec "$NODE_A" ping6 -c 3 -W 5 "${NPUB_B}.fips" >/dev/null; then + echo "ping6 A->B failed" >&2 + dump_diagnostics + return 1 + fi + if ! docker exec "$NODE_B" ping6 -c 3 -W 5 "${NPUB_A}.fips" >/dev/null; then + echo "ping6 B->A failed" >&2 + dump_diagnostics + return 1 + fi + + echo "" + echo "=== nostr-relay-test: phase 3 (malformed advert) ===" + publish_malformed_advert "$RELAY_HOST" "$RELAY_PORT" + + # Give consumers a moment to ingest and reject. + sleep 5 + + assert_process_alive "$NODE_A" || { dump_diagnostics; return 1; } + assert_process_alive "$NODE_B" || { dump_diagnostics; return 1; } + assert_no_panic "$NODE_A" || { dump_diagnostics; return 1; } + assert_no_panic "$NODE_B" || { dump_diagnostics; return 1; } + + # Existing peer link must still be healthy (consumer didn't tear + # down on a bad advert). + if ! docker exec "$NODE_A" ping6 -c 3 -W 5 "${NPUB_B}.fips" >/dev/null; then + echo "ping6 A->B failed AFTER malformed-advert injection" >&2 + dump_diagnostics + return 1 + fi + + cleanup + echo "nostr-relay-test passed" +} + +main() { + require_docker_daemon + require_test_image + run_test +} + +main "$@" diff --git a/testing/nat/scripts/stun-faults-test.sh b/testing/nat/scripts/stun-faults-test.sh new file mode 100755 index 0000000..edaa555 --- /dev/null +++ b/testing/nat/scripts/stun-faults-test.sh @@ -0,0 +1,333 @@ +#!/bin/bash +# +# STUN fault-injection integration test. +# +# Cycles the daemon through three failure modes against the existing +# in-lab STUN server, asserting graceful behavior at each step: +# +# Phase 1 (drop) — 100% UDP egress drop to STUN; daemon's STUN +# observation must time out, the daemon must log the +# fallback path, and it must NOT crash. +# Phase 2 (delay) — ~5s netem delay added; rule cleared mid-phase so the +# next attempt succeeds. Asserts recovery. +# Phase 3 (kill) — STUN container fully stopped. Daemon must continue +# running, surface a STUN-unreachable signal in its +# logs / state, and not panic. +# +# Fault-injection mechanism (Approach A): faults are driven from this +# script via `docker exec` into a netns-sharing shim sidecar +# (`fips-nat-stun-fault-shim`). The shim shares the daemon's network +# namespace so `tc qdisc add dev eth0 …` rules apply to the daemon's +# egress. tc netem is preferred; falls back to iptables if tc/netem is +# unavailable in the kernel. No long-running timing logic lives in the +# shim itself; the script is the orchestrator. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NAT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ROOT_DIR="$(cd "$NAT_DIR/../.." && pwd)" +BUILD_SCRIPT="$ROOT_DIR/testing/scripts/build.sh" +GENERATE_SCRIPT="$SCRIPT_DIR/generate-configs.sh" + +PROFILE="stun-faults" +SCENARIO="$PROFILE" +COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml") +NODE="fips-nat-stun-fault-node" +PEER="fips-nat-stun-fault-peer" +SHIM="fips-nat-stun-fault-shim" +STUN_CONTAINER="fips-nat-stun" +STUN_HOST="172.31.10.40" +STUN_PORT=3478 +DEV="eth0" + +cleanup() { + # Best-effort tc/iptables cleanup before tearing the stack down. + docker exec "$SHIM" tc qdisc del dev "$DEV" root 2>/dev/null || true + docker exec "$SHIM" iptables -D OUTPUT -p udp -d "$STUN_HOST" \ + --dport "$STUN_PORT" -j DROP 2>/dev/null || true + "${COMPOSE[@]}" --profile "$PROFILE" down -v --remove-orphans \ + >/dev/null 2>&1 || true +} + +trap 'echo ""; echo "stun-faults-test interrupted"; cleanup; exit 130' INT TERM + +require_docker_daemon() { + if ! docker info >/dev/null 2>&1; then + echo "Docker daemon is not reachable; cannot run stun-faults-test" >&2 + exit 1 + fi +} + +require_test_image() { + if ! docker image inspect fips-test:latest >/dev/null 2>&1; then + echo "fips-test:latest not found; building test image" + "$BUILD_SCRIPT" + fi +} + +dump_diagnostics() { + echo "" + echo "=== stun-faults diagnostics ===" + for c in "$NODE" "$PEER" "$SHIM" "$STUN_CONTAINER"; do + echo "" + echo "--- $c: logs (last 80) ---" + docker logs "$c" 2>&1 | tail -80 || true + done + echo "" + echo "--- $SHIM: tc qdisc state ---" + docker exec "$SHIM" tc qdisc show dev "$DEV" 2>&1 || true + echo "" + echo "--- $SHIM: iptables OUTPUT ---" + docker exec "$SHIM" iptables -vnL OUTPUT 2>&1 || true + echo "" + echo "--- $NODE: fipsctl show status ---" + docker exec "$NODE" fipsctl show status 2>&1 || true + echo "" + echo "--- $NODE: fipsctl show peers ---" + docker exec "$NODE" fipsctl show peers 2>&1 || true +} + +# Apply a UDP-egress drop rule to STUN. Tries tc netem first (so the +# daemon's send_to() calls themselves silently disappear); falls back to +# iptables if netem isn't available. +apply_drop() { + if docker exec "$SHIM" tc qdisc add dev "$DEV" root \ + handle 1: prio 2>/dev/null \ + && docker exec "$SHIM" tc qdisc add dev "$DEV" parent 1:3 \ + handle 30: netem loss 100% 2>/dev/null \ + && docker exec "$SHIM" tc filter add dev "$DEV" protocol ip \ + parent 1:0 prio 3 u32 match ip dst "${STUN_HOST}/32" \ + match ip protocol 17 0xff flowid 1:3 2>/dev/null; then + echo " drop: tc netem loss 100% applied to ${STUN_HOST}" + FAULT_MODE=tc + return 0 + fi + # Cleanup any partial tc state before falling back. + docker exec "$SHIM" tc qdisc del dev "$DEV" root 2>/dev/null || true + docker exec "$SHIM" iptables -I OUTPUT -p udp -d "$STUN_HOST" \ + --dport "$STUN_PORT" -j DROP + echo " drop: iptables DROP applied (tc netem unavailable)" + FAULT_MODE=iptables +} + +clear_drop() { + if [[ "${FAULT_MODE:-}" == "tc" ]]; then + docker exec "$SHIM" tc qdisc del dev "$DEV" root 2>/dev/null || true + elif [[ "${FAULT_MODE:-}" == "iptables" ]]; then + docker exec "$SHIM" iptables -D OUTPUT -p udp -d "$STUN_HOST" \ + --dport "$STUN_PORT" -j DROP 2>/dev/null || true + fi + FAULT_MODE="" + echo " drop: cleared" +} + +apply_delay() { + docker exec "$SHIM" tc qdisc add dev "$DEV" root netem delay 5000ms 2>/dev/null \ + || { echo " delay: tc netem unavailable, skipping" >&2; return 1; } + echo " delay: tc netem 5000ms applied" +} + +clear_delay() { + docker exec "$SHIM" tc qdisc del dev "$DEV" root 2>/dev/null || true + echo " delay: cleared" +} + +assert_process_alive() { + if ! docker exec "$NODE" pidof fips >/dev/null 2>&1; then + echo "fips daemon NOT running in $NODE" >&2 + return 1 + fi + echo " $NODE: fips daemon alive" +} + +assert_no_panic() { + local logs + logs="$(docker logs "$NODE" 2>&1 || true)" + if grep -Eq "panicked at|RUST_BACKTRACE|fatal runtime error" <<<"$logs"; then + echo "panic detected in $NODE logs" >&2 + return 1 + fi +} + +# Look for STUN-related fault evidence in the daemon's logs. The +# nostr/stun module emits "stun observation failed, falling back to +# LAN-only addresses" at debug when STUN times out. Also accept the +# generic bootstrap "timed out waiting for" / "no address for" / any +# log line containing both "stun" and ("timed out" | "fail" | "fallback" +# | "unreachable"). +assert_stun_fault_observed() { + local since="$1" # seconds back from now + local logs + logs="$(docker logs --since "${since}s" "$NODE" 2>&1 || true)" + if grep -Eiq 'stun.*(timed? ?out|fail|fallback|unreachable|no address)' <<<"$logs"; then + echo " $NODE: STUN fault evidence observed in logs" + return 0 + fi + echo "no STUN fault evidence in $NODE logs (last ${since}s)" >&2 + echo "--- recent log tail ---" >&2 + echo "$logs" | tail -40 >&2 + return 1 +} + +# Look for STUN observation success (debug-level) since N seconds ago. +assert_stun_success_observed() { + local since="$1" + local logs + logs="$(docker logs --since "${since}s" "$NODE" 2>&1 || true)" + if grep -Eiq 'STUN observation succeeded|STUN observed' <<<"$logs"; then + echo " $NODE: STUN success observed in logs" + return 0 + fi + echo "no STUN success evidence in $NODE logs (last ${since}s)" >&2 + return 1 +} + +# Pre-flight: with no fault injected, the fault-node must (a) discover +# the peer's overlay advert via the relay, and (b) successfully invoke +# the STUN client at least once. If either is missing, the rest of the +# test would only show the "no overlay advert" path — i.e. a setup bug, +# not a real fault-evidence miss. Polls up to `timeout_secs` for a +# "traversal: initiator STUN observed" or "STUN observation succeeded" +# log line in the fault-node. +preflight_assert_stun_active() { + local timeout_secs="${1:-45}" + local deadline=$(( SECONDS + timeout_secs )) + while (( SECONDS < deadline )); do + local logs + logs="$(docker logs "$NODE" 2>&1 || true)" + if grep -Eq 'traversal: initiator STUN observed|STUN observation succeeded' \ + <<<"$logs"; then + echo " $NODE: pre-flight STUN observation confirmed" + return 0 + fi + sleep 2 + done + echo "pre-flight FAIL: $NODE never invoked STUN within ${timeout_secs}s" >&2 + echo "(likely cause: peer advert not yet published, or peer config wrong)" >&2 + echo "--- $NODE recent log tail ---" >&2 + docker logs "$NODE" 2>&1 | tail -40 >&2 || true + echo "--- $PEER recent log tail ---" >&2 + docker logs "$PEER" 2>&1 | tail -40 >&2 || true + return 1 +} + +run_test() { + echo "=== stun-faults-test: setup ===" + cleanup + "$GENERATE_SCRIPT" "$SCENARIO" + "${COMPOSE[@]}" --profile "$PROFILE" up -d --build --force-recreate + + # Give the daemons time to come up. Both fault-node and fault-peer + # need to start, publish their adverts to the relay, and discover + # each other before the fault-node will reach the STUN client. + echo "" + echo "--- waiting for daemons to start ---" + sleep 10 + + if ! docker exec "$NODE" pidof fips >/dev/null 2>&1; then + dump_diagnostics + echo "fips daemon failed to start in $NODE" >&2 + return 1 + fi + if ! docker exec "$PEER" pidof fips >/dev/null 2>&1; then + dump_diagnostics + echo "fips daemon failed to start in $PEER" >&2 + return 1 + fi + + # Phase 0 / pre-flight: assert that with NO fault injected, the + # fault-node successfully reaches the STUN client at least once. + # Without this guard, a Phase-1 fault-evidence miss could be either + # the real bug we're testing OR a setup bug (e.g., missing advert). + echo "" + echo "=== Phase 0: pre-flight — confirm STUN baseline (no faults) ===" + if ! preflight_assert_stun_active 45; then + dump_diagnostics + return 1 + fi + + # Sanity dump: show the recent STUN-related lines for the operator. + docker logs "$NODE" 2>&1 | grep -Ei 'stun|traversal' | tail -10 || true + + # IMPORTANT: STUN observation is event-driven, not periodic. The + # daemon calls observe_traversal_addresses() once per fresh traversal + # attempt; once the resulting reflexive address is cached, the next + # observation does not happen until advert_refresh_secs (30 min by + # default). To force a fresh STUN attempt during each phase, restart + # the PEER container — fault-node sees the peer disconnect and + # retries traversal (auto_connect with backoff), which re-invokes + # observe_traversal_addresses() under the fault. + # + # Restarting fault-node itself does NOT work: the shim shares + # fault-node's network namespace (network_mode: service:...), so a + # fault-node restart wipes the tc/iptables rules the shim applied. + # Restarting the peer leaves fault-node's netns + shim faults intact. + + echo "" + echo "=== Phase 1: drop 100% UDP egress to STUN (restart peer under fault) ===" + apply_drop + docker restart "$PEER" >/dev/null + local phase_start=$SECONDS + # Wait long enough for fault-node to detect peer loss and retry. + # Auto-connect backoff is exponential 5s base; first retry ~5s after + # detection, second ~10s. Allow ~25s. + sleep 25 + local phase_elapsed=$(( SECONDS - phase_start + 4 )) + + assert_process_alive || { dump_diagnostics; return 1; } + assert_no_panic || { dump_diagnostics; return 1; } + assert_stun_fault_observed "$phase_elapsed" || { + dump_diagnostics + return 1 + } + clear_drop + + echo "" + echo "=== Phase 2: delay 5000ms then clear (peer restart for clean STUN) ===" + if apply_delay; then + docker restart "$PEER" >/dev/null + # Slow STUN should eventually succeed under 5s delay. + sleep 12 + clear_delay + sleep 10 + else + echo " Phase 2 skipped (no tc netem available); proceeding to Phase 3" + fi + + assert_process_alive || { dump_diagnostics; return 1; } + assert_no_panic || { dump_diagnostics; return 1; } + # Recovery assertion: STUN must succeed at least once after the rule + # is removed. + if ! assert_stun_success_observed 30; then + echo "Phase 2 recovery assertion failed (no STUN success after delay clear)" >&2 + dump_diagnostics + return 1 + fi + + echo "" + echo "=== Phase 3: kill STUN container, restart peer, assert survival ===" + docker stop "$STUN_CONTAINER" >/dev/null + docker restart "$PEER" >/dev/null + local p3_start=$SECONDS + sleep 25 + local p3_elapsed=$(( SECONDS - p3_start + 4 )) + + assert_process_alive || { dump_diagnostics; return 1; } + assert_no_panic || { dump_diagnostics; return 1; } + assert_stun_fault_observed "$p3_elapsed" || { + dump_diagnostics + return 1 + } + + cleanup + echo "stun-faults-test passed" +} + +main() { + require_docker_daemon + require_test_image + run_test +} + +main "$@" diff --git a/testing/static/configs/topologies/gateway.yaml b/testing/static/configs/topologies/gateway.yaml index 7a1c68e..b94e26b 100644 --- a/testing/static/configs/topologies/gateway.yaml +++ b/testing/static/configs/topologies/gateway.yaml @@ -1,6 +1,14 @@ # Gateway Integration Test Topology # -# Two FIPS nodes: gateway (a) and server (b), directly peered. +# Three FIPS nodes: +# a (gw-gateway) — gateway with LAN interface +# b (gw-server) — first mesh destination (LAN client #1 target) +# c (gw-server-2) — second mesh destination (LAN client #2 target) +# +# Node `a` is directly peered with both `b` and `c`. Two distinct mesh +# destinations are required so the gateway-test multi-client phase can +# allocate distinct virtual-IP mappings (one per LAN client). +# # A non-FIPS client container connects via the gateway's LAN interface. # # Uses deterministic key derivation (mesh-name: gateway-test). @@ -8,8 +16,12 @@ nodes: a: docker_ip: "172.20.0.10" - peers: [b] + peers: [b, c] b: docker_ip: "172.20.0.11" peers: [a] + + c: + docker_ip: "172.20.0.12" + peers: [a] diff --git a/testing/static/docker-compose.yml b/testing/static/docker-compose.yml index 3b9046e..3cdb9ac 100644 --- a/testing/static/docker-compose.yml +++ b/testing/static/docker-compose.yml @@ -562,6 +562,21 @@ services: fips-net: ipv4_address: 172.20.0.11 + # Second mesh destination — gives gw-client-2 a distinct npub to target + # so the gateway allocates a separate virtual-IP mapping per LAN client. + # Mirrors gw-server; not on gateway-lan. + gw-server-2: + <<: *fips-common + profiles: ["gateway"] + container_name: fips-gw-server-2 + hostname: gw-server-2 + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/gateway/node-c.yaml:/etc/fips/fips.yaml:ro + networks: + fips-net: + ipv4_address: 172.20.0.12 + gw-client: image: fips-test-app:latest profiles: ["gateway"] @@ -580,3 +595,25 @@ services: restart: "no" env_file: - ./generated-configs/npubs.env + + # Second LAN client — exercises concurrent multi-client mappings. + # Same image and gateway-lan attachment as + # gw-client; the gateway must allocate a distinct virtual IP for it. + gw-client-2: + image: fips-test-app:latest + profiles: ["gateway"] + container_name: fips-gw-client-2 + hostname: gw-client-2 + cap_add: + - NET_ADMIN + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + volumes: + - ./configs/gateway-resolv.conf:/etc/resolv.conf:ro + networks: + gateway-lan: + ipv4_address: 172.20.1.21 + ipv6_address: fd02::21 + restart: "no" + env_file: + - ./generated-configs/npubs.env diff --git a/testing/static/scripts/gateway-test.sh b/testing/static/scripts/gateway-test.sh index afd8e21..c8ec53e 100755 --- a/testing/static/scripts/gateway-test.sh +++ b/testing/static/scripts/gateway-test.sh @@ -22,7 +22,9 @@ ENV_FILE="$GENERATED_DIR/npubs.env" GATEWAY="fips-gw-gateway" SERVER="fips-gw-server" +SERVER2="fips-gw-server-2" CLIENT="fips-gw-client" +CLIENT2="fips-gw-client-2" # ── inject-config subcommand ───────────────────────────────────────────── @@ -58,6 +60,20 @@ cfg['gateway'] = { 'proto': 'tcp', 'target': '[fd02::20]:8080', }, + # 6B: second TCP forward — exercises multiple simultaneous TCP + # rules sharing the same LAN backend on a different listen port. + { + 'listen_port': 18082, + 'proto': 'tcp', + 'target': '[fd02::20]:8081', + }, + # 6A: UDP forward — exercises the runtime UDP DNAT path (rule + # shape + conntrack handling) end-to-end. + { + 'listen_port': 18081, + 'proto': 'udp', + 'target': '[fd02::20]:8081', + }, ], } @@ -100,10 +116,11 @@ check() { echo "=== FIPS Gateway Integration Test ===" echo "" -# Phase 1: Wait for mesh convergence (gateway ↔ server) +# Phase 1: Wait for mesh convergence (gateway ↔ server, gateway ↔ server-2) echo "Phase 1: Mesh convergence" -wait_for_peers "$GATEWAY" 1 30 || true +wait_for_peers "$GATEWAY" 2 30 || true wait_for_peers "$SERVER" 1 30 || true +wait_for_peers "$SERVER2" 1 30 || true # Phase 2: Wait for gateway DNS to respond echo "" @@ -130,35 +147,114 @@ fi echo "" echo "Phase 3: Client network setup" docker exec "$CLIENT" ip -6 route add fd01::/112 via fd02::10 2>/dev/null || true -echo " Added route fd01::/112 via fd02::10" +echo " Added route fd01::/112 via fd02::10 on $CLIENT" +docker exec "$CLIENT2" ip -6 route add fd01::/112 via fd02::10 2>/dev/null || true +echo " Added route fd01::/112 via fd02::10 on $CLIENT2" -# Phase 4: DNS resolution test — resolve server npub from client +# Phase 4: DNS resolution test — resolve server npub from both clients, +# exercising concurrent multi-client mappings. echo "" echo "Phase 4: DNS resolution" VIRTUAL_IP=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" @fd02::10 2>/dev/null | head -1) if [ -n "$VIRTUAL_IP" ] && echo "$VIRTUAL_IP" | grep -q "fd01"; then - check "Resolve ${NPUB_B:0:20}...fips → $VIRTUAL_IP" 0 + check "Resolve ${NPUB_B:0:20}...fips on $CLIENT → $VIRTUAL_IP" 0 else - check "Resolve ${NPUB_B:0:20}...fips (got: '$VIRTUAL_IP')" 1 + check "Resolve ${NPUB_B:0:20}...fips on $CLIENT (got: '$VIRTUAL_IP')" 1 fi -# Phase 5: End-to-end HTTP test +VIRTUAL_IP_2=$(docker exec "$CLIENT2" dig +short AAAA "${NPUB_C}.fips" @fd02::10 2>/dev/null | head -1) +if [ -n "$VIRTUAL_IP_2" ] && echo "$VIRTUAL_IP_2" | grep -q "fd01"; then + check "Resolve ${NPUB_C:0:20}...fips on $CLIENT2 → $VIRTUAL_IP_2" 0 +else + check "Resolve ${NPUB_C:0:20}...fips on $CLIENT2 (got: '$VIRTUAL_IP_2')" 1 +fi + +# Both clients must receive distinct virtual-IP mappings — this is the +# core multi-client invariant: each LAN client gets its own pool entry. +if [ -n "$VIRTUAL_IP" ] && [ -n "$VIRTUAL_IP_2" ] && [ "$VIRTUAL_IP" != "$VIRTUAL_IP_2" ]; then + check "Distinct virtual IPs per client ($VIRTUAL_IP vs $VIRTUAL_IP_2)" 0 +else + check "Distinct virtual IPs per client (got: '$VIRTUAL_IP' vs '$VIRTUAL_IP_2')" 1 +fi + +# Verify gateway show_mappings reports both client mappings. Mapping +# allocation happens in the DNS response path, but the gateway control +# socket serves a snapshot that is refreshed on a 10s tick (see +# src/bin/fips-gateway.rs tick interval). Poll up to 15s so at least +# one post-allocation snapshot tick is guaranteed to land. +ACTIVE_COUNT="error" +# Control socket protocol is line-delimited JSON ({"command": "..."}); +# bare "show_mappings" returns an "invalid request" error response with +# no data field and the parse below counts that as 0 mappings. +for _ in $(seq 1 15); do + GW_MAPPINGS=$(docker exec "$GATEWAY" bash -c \ + 'echo "{\"command\":\"show_mappings\"}" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' || echo "") + ACTIVE_COUNT=$(echo "$GW_MAPPINGS" \ + | python3 -c "import sys,json; r=json.load(sys.stdin); print(len(r.get('data',{}).get('mappings',[])))" 2>/dev/null || echo "error") + if [ "$ACTIVE_COUNT" = "2" ]; then + break + fi + sleep 1 +done +if [ "$ACTIVE_COUNT" = "2" ]; then + check "Gateway reports 2 active mappings (multi-client)" 0 +else + check "Gateway active mapping count (got: $ACTIVE_COUNT)" 1 +fi + +# Phase 5: End-to-end HTTP test from both clients in parallel echo "" echo "Phase 5: HTTP through gateway" -# Use --resolve to bind the .fips hostname to the virtual IP for curl -if [ -n "$VIRTUAL_IP" ]; then - RESPONSE=$(docker exec "$CLIENT" curl -6 -s --max-time 10 \ - --resolve "${NPUB_B}.fips:8000:[$VIRTUAL_IP]" \ - "http://${NPUB_B}.fips:8000/" 2>&1) || true +# Use --resolve to bind the .fips hostname to the virtual IP for curl. +# Run both client requests concurrently to exercise simultaneous flows +# through distinct NAT mappings. +RESP_FILE=$(mktemp) +RESP_FILE_2=$(mktemp) +trap 'rm -f "$RESP_FILE" "$RESP_FILE_2"' EXIT +if [ -n "$VIRTUAL_IP" ]; then + docker exec "$CLIENT" curl -6 -s --max-time 10 \ + --resolve "${NPUB_B}.fips:8000:[$VIRTUAL_IP]" \ + "http://${NPUB_B}.fips:8000/" >"$RESP_FILE" 2>&1 & + PID1=$! +else + PID1="" +fi + +if [ -n "$VIRTUAL_IP_2" ]; then + docker exec "$CLIENT2" curl -6 -s --max-time 10 \ + --resolve "${NPUB_C}.fips:8000:[$VIRTUAL_IP_2]" \ + "http://${NPUB_C}.fips:8000/" >"$RESP_FILE_2" 2>&1 & + PID2=$! +else + PID2="" +fi + +[ -n "$PID1" ] && wait "$PID1" || true +[ -n "$PID2" ] && wait "$PID2" || true + +RESPONSE=$(cat "$RESP_FILE") +RESPONSE_2=$(cat "$RESP_FILE_2") + +if [ -n "$VIRTUAL_IP" ]; then if echo "$RESPONSE" | grep -q "Fuck IPs"; then - check "HTTP GET ${NPUB_B:0:20}...fips:8000" 0 + check "HTTP GET from $CLIENT" 0 else - check "HTTP GET (response: '${RESPONSE:0:80}')" 1 + check "HTTP GET from $CLIENT (response: '${RESPONSE:0:80}')" 1 fi else - check "HTTP GET (skipped — no virtual IP)" 1 + check "HTTP GET from $CLIENT (skipped — no virtual IP)" 1 +fi + +if [ -n "$VIRTUAL_IP_2" ]; then + if echo "$RESPONSE_2" | grep -q "Fuck IPs"; then + check "HTTP GET from $CLIENT2" 0 + else + check "HTTP GET from $CLIENT2 (response: '${RESPONSE_2:0:80}')" 1 + fi +else + check "HTTP GET from $CLIENT2 (skipped — no virtual IP)" 1 fi # Phase 6: Verify NAT state on gateway @@ -172,34 +268,74 @@ else check "nftables DNAT rules" 1 fi -# Phase 7: Inbound port forwarding (TASK-2026-0061) +# Phase 7: Inbound port forwarding — UDP and a second simultaneous TCP forward. # -# Mesh peer (gw-server) → gw-gateway fips0:18080 → DNAT → [fd02::20]:8080 -# (gw-client LAN HTTP server). Exercises the DNAT rule + LAN-side +# Three forwards exercised: +# tcp 18080 → [fd02::20]:8080 (original — single TCP rule) +# tcp 18082 → [fd02::20]:8081 (6B — second TCP rule, multiple forwards) +# udp 18081 → [fd02::20]:8081 (6A — UDP DNAT runtime path) +# +# Mesh peer (gw-server) hits each gw-gateway fips0: rule, which +# DNATs into the LAN-side gw-client. Exercises the DNAT rules + LAN-side # masquerade installed by set_port_forwards(). echo "" -echo "Phase 7: Inbound port forward" +echo "Phase 7: Inbound port forwards" -# Confirm the port-forward DNAT rule is present on the gateway. The -# distinctive listen port (18080) identifies our rule regardless of how -# nft renders the l4proto/dport predicates. +# Confirm all three port-forward DNAT rules are present on the gateway. +# The distinctive listen ports identify our rules regardless of how nft +# renders the l4proto/dport predicates. if echo "$NFT_RULES" | grep -q "18080"; then check "nftables port-forward DNAT rule (tcp 18080)" 0 else check "nftables port-forward DNAT rule (tcp 18080)" 1 fi +if echo "$NFT_RULES" | grep -q "18082"; then + check "nftables port-forward DNAT rule (tcp 18082)" 0 +else + check "nftables port-forward DNAT rule (tcp 18082)" 1 +fi +if echo "$NFT_RULES" | grep -q "18081"; then + check "nftables port-forward DNAT rule (udp 18081)" 0 +else + check "nftables port-forward DNAT rule (udp 18081)" 1 +fi -# Start a marker HTTP server on the LAN-side client (fd02::20:8080). +# Start marker HTTP servers on the LAN-side client. +# :8080 → "inbound-forward-ok" (target of tcp 18080) +# :8081 → "inbound-forward-ok-2" (target of tcp 18082) # `docker exec -d` is required; `docker exec bash -c 'cmd &'` doesn't # keep the child alive past the exec session, even with nohup. -docker exec "$CLIENT" sh -c \ - 'mkdir -p /tmp/inbound && echo "inbound-forward-ok" > /tmp/inbound/index.html && pkill -f "http.server 8080" 2>/dev/null || true' \ - >/dev/null 2>&1 || true +docker exec "$CLIENT" sh -c ' + mkdir -p /tmp/inbound /tmp/inbound2 + echo "inbound-forward-ok" > /tmp/inbound/index.html + echo "inbound-forward-ok-2" > /tmp/inbound2/index.html + pkill -f "http.server 8080" 2>/dev/null || true + pkill -f "http.server 8081" 2>/dev/null || true + pkill -f "udp_echo.py" 2>/dev/null || true +' >/dev/null 2>&1 || true docker exec -d "$CLIENT" python3 -m http.server 8080 --bind :: --directory /tmp/inbound \ >/dev/null 2>&1 || true -# Give the server a moment to bind. +docker exec -d "$CLIENT" python3 -m http.server 8081 --bind :: --directory /tmp/inbound2 \ + >/dev/null 2>&1 || true + +# Start a UDP echo server on the LAN-side client at [::]:8081/udp. +# This is the target of the udp 18081 forward. Stash the script as a +# named file (`udp_echo.py`) so the cleanup pkill above can find it. +docker exec "$CLIENT" sh -c 'cat > /tmp/udp_echo.py <<'\''PYEOF'\'' +import socket, sys +s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) +s.bind(("::", 8081)) +while True: + data, addr = s.recvfrom(2048) + s.sendto(b"udp-forward-ok:" + data, addr) +PYEOF' >/dev/null 2>&1 || true +docker exec -d "$CLIENT" python3 /tmp/udp_echo.py >/dev/null 2>&1 || true + +# Give the servers a moment to bind. for _ in 1 2 3 4 5; do - if docker exec "$CLIENT" ss -6lnt 2>/dev/null | grep -q ':8080'; then + TCP_READY=$(docker exec "$CLIENT" ss -6lnt 2>/dev/null | grep -cE ':8080|:8081' || true) + UDP_READY=$(docker exec "$CLIENT" ss -6lnu 2>/dev/null | grep -c ':8081' || true) + if [ "$TCP_READY" -ge 2 ] && [ "$UDP_READY" -ge 1 ]; then break fi sleep 1 @@ -215,16 +351,53 @@ if [ -z "$GW_MESH_IP" ]; then else echo " Gateway mesh IPv6: $GW_MESH_IP" - # From the mesh side (gw-server), fetch through the forward rule. + # From the mesh side (gw-server), fetch through each TCP forward. FWD_RESPONSE=$(docker exec "$SERVER" curl -6 -s --max-time 10 \ "http://[${GW_MESH_IP}]:18080/" 2>&1) || true - if echo "$FWD_RESPONSE" | grep -q "inbound-forward-ok"; then - check "Inbound HTTP via port forward 18080 → [fd02::20]:8080" 0 + # 8080 backend serves "inbound-forward-ok" (no -2 suffix) — distinct + # from the 8081 backend so a misrouted response would be detectable. + if echo "$FWD_RESPONSE" | grep -qE '^inbound-forward-ok$'; then + check "Inbound HTTP via TCP forward 18080 → [fd02::20]:8080" 0 else - check "Inbound HTTP via port forward (response: '${FWD_RESPONSE:0:80}')" 1 + check "Inbound HTTP via TCP forward 18080 (response: '${FWD_RESPONSE:0:80}')" 1 + fi + + FWD_RESPONSE_2=$(docker exec "$SERVER" curl -6 -s --max-time 10 \ + "http://[${GW_MESH_IP}]:18082/" 2>&1) || true + if echo "$FWD_RESPONSE_2" | grep -q "inbound-forward-ok-2"; then + check "Inbound HTTP via TCP forward 18082 → [fd02::20]:8081 (6B)" 0 + else + check "Inbound HTTP via TCP forward 18082 (response: '${FWD_RESPONSE_2:0:80}')" 1 + fi + + # 6A: UDP forward. Send a probe via a one-shot Python client on + # gw-server; the LAN-side echo server prepends "udp-forward-ok:". + UDP_RESPONSE=$(docker exec "$SERVER" python3 -c " +import socket, sys +s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) +s.settimeout(5) +s.sendto(b'ping-via-udp-fwd', ('${GW_MESH_IP}', 18081)) +try: + data, _ = s.recvfrom(2048) + sys.stdout.write(data.decode('utf-8', 'replace')) +except Exception as e: + sys.stdout.write('ERR: ' + str(e)) +" 2>&1) || true + if echo "$UDP_RESPONSE" | grep -q "udp-forward-ok:ping-via-udp-fwd"; then + check "Inbound UDP via forward 18081 → [fd02::20]:8081 (6A)" 0 + else + check "Inbound UDP via forward 18081 (response: '${UDP_RESPONSE:0:80}')" 1 fi fi +# Cleanup: stop the LAN-side responders so Phase 8's pool-reclamation +# wait isn't interfered with by lingering sessions. +docker exec "$CLIENT" sh -c ' + pkill -f "http.server 8080" 2>/dev/null || true + pkill -f "http.server 8081" 2>/dev/null || true + pkill -f "udp_echo.py" 2>/dev/null || true +' >/dev/null 2>&1 || true + # Phase 8: TTL expiration and pool reclamation echo "" echo "Phase 8: TTL expiration and pool reclamation" @@ -239,7 +412,7 @@ sleep 25 # Query gateway control socket for mapping count MAPPING_COUNT=$(docker exec "$GATEWAY" bash -c \ - 'echo "show_mappings" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' \ + 'echo "{\"command\":\"show_mappings\"}" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' \ | python3 -c "import sys,json; r=json.load(sys.stdin); print(len(r.get('data',{}).get('mappings',[])))" 2>/dev/null || echo "error") if [ "$MAPPING_COUNT" = "0" ]; then check "Mapping reclaimed after TTL+grace" 0