Merge branch 'master' into next

# Conflicts:
#	src/node/tests/discovery.rs
#	testing/ci-local.sh
This commit is contained in:
Johnathan Corgan
2026-05-03 23:47:16 +00:00
51 changed files with 4107 additions and 44 deletions
+5
View File
@@ -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
+102 -1
View File
@@ -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'
+76
View File
@@ -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:"
+182
View File
@@ -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: <key> = <value> 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:"
+48
View File
@@ -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: |
@@ -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'
)
}
+46
View File
@@ -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() {
+370
View File
@@ -1105,3 +1105,373 @@ pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::pr
_ => super::protocol::Response::error(format!("unknown command: {}", command)),
}
}
#[cfg(test)]
mod tests {
//! Schema-stability snapshot tests for all 18 control-socket query
//! handlers.
//!
//! Each handler is invoked against a deterministically-constructed
//! `Node` (fixed identity seed, empty peer/link/transport/cache
//! state). The resulting JSON is normalized — fields whose values
//! depend on wall-clock, PID, build environment, or filesystem
//! layout are replaced with the literal string `"<redacted>"` —
//! and compared against versioned fixtures under
//! `src/control/snapshots/`.
//!
//! The point is to catch accidental schema drift (renames, type
//! changes, dropped fields) in the operator-facing wire format.
//! Empty-state snapshots are sufficient because every top-level
//! key still appears, and per-element shapes inside `[]` arrays
//! are covered by the dispatcher contract test plus serde
//! derives elsewhere.
//!
//! ## Updating snapshots
//!
//! When a schema change is intentional, regenerate fixtures by
//! deleting the relevant `.json` files (or the whole
//! `snapshots/` directory) and re-running this test. Missing
//! fixtures are written from the current output rather than
//! failing — the next run then enforces the new shape. Review
//! the resulting diff before committing.
//!
//! ## Determinism
//!
//! The `Node` is built via `Node::with_identity` from a fixed
//! 32-byte seed (`[0xAB; 32]`), so `npub`, `node_addr`, and
//! `ipv6_addr` are stable across runs and machines.
//! Time-dependent scalars are redacted in `normalize_value` —
//! see the `VOLATILE_KEYS` list there for the exact set.
//! Empty arrays/maps are intrinsically stable and need no
//! redaction.
//!
//! Schnorr signatures are non-deterministic, but the only
//! signature surfaced by these handlers is `declaration_signed:
//! bool` (a flag, not the signature itself), so no redaction is
//! needed for that.
use super::*;
use crate::config::Config;
use crate::identity::Identity;
use crate::node::Node;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
/// 32-byte seed for the deterministic test identity.
/// Any non-zero secret-key-shaped value works; 0xAB-fill is just
/// readable in hex.
const TEST_SEED: [u8; 32] = [0xAB; 32];
/// Fields whose value is environment-, time-, or build-dependent
/// and therefore must be redacted before comparison. Matched by
/// JSON key name anywhere in the document.
const VOLATILE_KEYS: &[&str] = &[
// Process / build environment
"version",
"pid",
"exe_path",
"control_socket",
"tun_name",
// Filesystem layout (ACL, hosts, etc.)
"allow_file",
"deny_file",
// Wall-clock derived
"uptime_secs",
"started_at_ms",
"session_start_ms",
"authenticated_at_ms",
"last_seen_ms",
"last_activity_ms",
"last_recv_ms",
"created_at_ms",
"initiated_ms",
"last_sent_ms",
"age_ms",
"last_used_ms",
"idle_ms",
"first_seen_secs_ago",
"last_contact_secs_ago",
];
/// Build a Node with a fixed identity, default config, and empty
/// runtime state (no peers, links, sessions, transports, or cache
/// entries). This keeps every per-element list empty and every
/// scalar deterministic modulo `VOLATILE_KEYS`.
fn build_test_node() -> Node {
let identity =
Identity::from_secret_bytes(&TEST_SEED).expect("test seed is a valid secret key");
let config = Config::new();
Node::with_identity(identity, config).expect("default config is valid")
}
/// Recursively walk a JSON value, replacing the value of any key
/// listed in `VOLATILE_KEYS` with the literal string
/// `"<redacted>"`. Array elements are recursed into.
fn normalize_value(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, v) in map.iter_mut() {
if VOLATILE_KEYS.contains(&key.as_str()) {
*v = Value::String("<redacted>".to_string());
} else {
normalize_value(v);
}
}
}
Value::Array(items) => {
for item in items.iter_mut() {
normalize_value(item);
}
}
_ => {}
}
}
/// Wrap a handler value in the on-the-wire `Response` envelope so
/// the snapshot reflects exactly what a control-socket client
/// receives. Pretty-printed and sorted-keyed for readable diffs.
fn render(value: Value) -> String {
let mut wrapped = json!({ "status": "ok", "data": value });
normalize_value(&mut wrapped);
let sorted = sort_object_keys(&wrapped);
serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
}
/// Same as `render` but takes a `Response` directly (for handlers
/// that return `Response`, not `Value`).
fn render_response(resp: super::super::protocol::Response) -> String {
let value = serde_json::to_value(&resp).expect("response always serializes");
let mut value = value;
normalize_value(&mut value);
let sorted = sort_object_keys(&value);
serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
}
/// Recursively sort object keys for stable diff-friendly output.
/// `serde_json::Value` preserves insertion order; handlers don't
/// guarantee any particular emit order, so normalize here.
fn sort_object_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted: Map<String, Value> = Map::new();
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
sorted.insert(key.clone(), sort_object_keys(&map[key]));
}
Value::Object(sorted)
}
Value::Array(items) => Value::Array(items.iter().map(sort_object_keys).collect()),
other => other.clone(),
}
}
fn snapshot_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("control")
.join("snapshots")
}
/// Compare `actual` against the on-disk fixture for `name`. If the
/// fixture does not exist, write it (first-run convention) and
/// pass. Any subsequent mismatch fails with an inline diff hint.
fn assert_snapshot(name: &str, actual: &str) {
let path = snapshot_dir().join(format!("{name}.json"));
if !path.exists() {
std::fs::create_dir_all(path.parent().unwrap())
.expect("failed to create snapshots dir");
std::fs::write(&path, actual).expect("failed to write new snapshot");
// Newly written: nothing to compare. Subsequent runs enforce.
return;
}
let expected = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read snapshot {}: {e}", path.display()));
// Normalize line endings: Windows checkouts with core.autocrlf=true
// convert fixture files to CRLF; the in-memory JSON output is LF.
let expected = expected.replace("\r\n", "\n");
// Tolerate trailing newline differences from editors.
if expected.trim_end() != actual.trim_end() {
panic!(
"snapshot mismatch for {name}\n\
fixture: {}\n\
-- expected --\n{expected}\n\
-- actual --\n{actual}\n\
-- end --\n\
If the schema change is intentional, delete the fixture \
and re-run to regenerate.",
path.display()
);
}
}
// ---- 18 handler snapshot tests --------------------------------------
#[test]
fn snapshot_show_status() {
let node = build_test_node();
assert_snapshot("show_status", &render(show_status(&node)));
}
#[test]
fn snapshot_show_acl() {
let node = build_test_node();
assert_snapshot("show_acl", &render(show_acl(&node)));
}
#[test]
fn snapshot_show_peers() {
let node = build_test_node();
assert_snapshot("show_peers", &render(show_peers(&node)));
}
#[test]
fn snapshot_show_links() {
let node = build_test_node();
assert_snapshot("show_links", &render(show_links(&node)));
}
#[test]
fn snapshot_show_tree() {
let node = build_test_node();
assert_snapshot("show_tree", &render(show_tree(&node)));
}
#[test]
fn snapshot_show_sessions() {
let node = build_test_node();
assert_snapshot("show_sessions", &render(show_sessions(&node)));
}
#[test]
fn snapshot_show_bloom() {
let node = build_test_node();
assert_snapshot("show_bloom", &render(show_bloom(&node)));
}
#[test]
fn snapshot_show_mmp() {
let node = build_test_node();
assert_snapshot("show_mmp", &render(show_mmp(&node)));
}
#[test]
fn snapshot_show_cache() {
let node = build_test_node();
assert_snapshot("show_cache", &render(show_cache(&node)));
}
#[test]
fn snapshot_show_connections() {
let node = build_test_node();
assert_snapshot("show_connections", &render(show_connections(&node)));
}
#[test]
fn snapshot_show_transports() {
let node = build_test_node();
assert_snapshot("show_transports", &render(show_transports(&node)));
}
#[test]
fn snapshot_show_routing() {
let node = build_test_node();
assert_snapshot("show_routing", &render(show_routing(&node)));
}
#[test]
fn snapshot_show_identity_cache() {
let node = build_test_node();
assert_snapshot("show_identity_cache", &render(show_identity_cache(&node)));
}
#[test]
fn snapshot_show_stats_list() {
// Static — no Node needed.
assert_snapshot("show_stats_list", &render(show_stats_list()));
}
#[test]
fn snapshot_show_stats_history() {
let node = build_test_node();
// Pin the empty-history series shape for one node-level metric.
let params = json!({ "metric": "mesh_size", "window": "10s", "granularity": "1s" });
let resp = show_stats_history(&node, Some(&params));
assert_snapshot("show_stats_history", &render_response(resp));
}
#[test]
fn snapshot_show_stats_all_history() {
let node = build_test_node();
// Empty-history all-node series; small window keeps the
// per-series `values` arrays short and stable.
let params = json!({ "window": "10s", "granularity": "1s" });
let resp = show_stats_all_history(&node, Some(&params));
assert_snapshot("show_stats_all_history", &render_response(resp));
}
#[test]
fn snapshot_show_stats_peers() {
let node = build_test_node();
assert_snapshot("show_stats_peers", &render(show_stats_peers(&node)));
}
#[test]
fn snapshot_show_stats_history_all_peers() {
let node = build_test_node();
// No peers tracked → empty `peers: []` envelope. Per-peer
// `values` shape is exercised once a real peer is wired in;
// here we only pin the envelope.
let params = json!({ "metric": "srtt_ms", "window": "10s", "granularity": "1s" });
let resp = show_stats_history_all_peers(&node, Some(&params));
assert_snapshot("show_stats_history_all_peers", &render_response(resp));
}
/// Sanity check: every handler advertised in `dispatch` is also
/// covered by a snapshot test above. If a new handler is added
/// without a matching snapshot, this test fails.
#[test]
fn dispatch_covers_all_snapshotted_handlers() {
let expected = [
"show_status",
"show_acl",
"show_peers",
"show_links",
"show_tree",
"show_sessions",
"show_bloom",
"show_mmp",
"show_cache",
"show_connections",
"show_transports",
"show_routing",
"show_identity_cache",
"show_stats_list",
"show_stats_history",
"show_stats_all_history",
"show_stats_peers",
"show_stats_history_all_peers",
];
assert_eq!(expected.len(), 18, "expected exactly 18 query handlers");
let node = build_test_node();
for cmd in expected {
// Each must dispatch successfully (status == "ok") with
// minimal params. Handlers requiring params get them.
let params = match cmd {
"show_stats_history" => Some(json!({
"metric": "mesh_size", "window": "10s", "granularity": "1s"
})),
"show_stats_all_history" => Some(json!({ "window": "10s", "granularity": "1s" })),
"show_stats_history_all_peers" => Some(json!({
"metric": "srtt_ms", "window": "10s", "granularity": "1s"
})),
_ => None,
};
let resp = dispatch(&node, cmd, params.as_ref());
assert_eq!(
resp.status, "ok",
"dispatch({cmd}) returned status={} message={:?}",
resp.status, resp.message
);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"data": {
"allow_all": false,
"allow_entries": [],
"allow_file": "<redacted>",
"allow_file_entries": [],
"default_decision": "allow",
"deny_all": false,
"deny_entries": [],
"deny_file": "<redacted>",
"deny_file_entries": [],
"effective_mode": "default_open",
"enforcement_active": false
},
"status": "ok"
}
+24
View File
@@ -0,0 +1,24 @@
{
"data": {
"is_leaf_only": false,
"leaf_dependent_count": 0,
"leaf_dependents": [],
"own_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"peer_filters": [],
"sequence": 0,
"stats": {
"accepted": 0,
"debounce_suppressed": 0,
"decode_error": 0,
"fill_exceeded": 0,
"invalid": 0,
"non_v1": 0,
"received": 0,
"send_failed": 0,
"sent": 0,
"stale": 0,
"unknown_peer": 0
}
},
"status": "ok"
}
+12
View File
@@ -0,0 +1,12 @@
{
"data": {
"avg_age_ms": 0,
"count": 0,
"default_ttl_ms": 300000,
"entries": [],
"expired": 0,
"fill_ratio": 0.0,
"max_entries": 50000
},
"status": "ok"
}
@@ -0,0 +1,6 @@
{
"data": {
"connections": []
},
"status": "ok"
}
@@ -0,0 +1,8 @@
{
"data": {
"count": 0,
"entries": [],
"max_entries": 10000
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"links": []
},
"status": "ok"
}
+7
View File
@@ -0,0 +1,7 @@
{
"data": {
"peers": [],
"sessions": []
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"peers": []
},
"status": "ok"
}
+65
View File
@@ -0,0 +1,65 @@
{
"data": {
"congestion": {
"ce_forwarded": 0,
"ce_received": 0,
"congestion_detected": 0,
"kernel_drop_events": 0
},
"coord_cache_entries": 0,
"discovery": {
"req_backoff_suppressed": 0,
"req_bloom_miss": 0,
"req_decode_error": 0,
"req_deduplicated": 0,
"req_duplicate": 0,
"req_fallback_forwarded": 0,
"req_forward_rate_limited": 0,
"req_forwarded": 0,
"req_initiated": 0,
"req_no_tree_peer": 0,
"req_received": 0,
"req_target_is_us": 0,
"req_ttl_exhausted": 0,
"resp_accepted": 0,
"resp_decode_error": 0,
"resp_forwarded": 0,
"resp_identity_miss": 0,
"resp_proof_failed": 0,
"resp_received": 0,
"resp_timed_out": 0
},
"error_signals": {
"coords_required": 0,
"mtu_exceeded": 0,
"path_broken": 0
},
"forwarding": {
"decode_error_bytes": 0,
"decode_error_packets": 0,
"delivered_bytes": 0,
"delivered_packets": 0,
"drop_mtu_exceeded_bytes": 0,
"drop_mtu_exceeded_packets": 0,
"drop_no_route_bytes": 0,
"drop_no_route_packets": 0,
"drop_send_error_bytes": 0,
"drop_send_error_packets": 0,
"forwarded_bytes": 0,
"forwarded_packets": 0,
"originated_bytes": 0,
"originated_packets": 0,
"received_bytes": 0,
"received_packets": 0,
"ttl_exhausted_bytes": 0,
"ttl_exhausted_packets": 0
},
"identity_cache_entries": 0,
"pending_lookups": [],
"pending_tun_destinations": 0,
"pending_tun_packets": 0,
"recent_requests": 0,
"retries": []
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"sessions": []
},
"status": "ok"
}
@@ -0,0 +1,180 @@
{
"data": {
"granularity_seconds": 1,
"peer": null,
"series": [
{
"granularity_seconds": 1,
"metric": "mesh_size",
"unit": "nodes",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "tree_depth",
"unit": "hops",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "peer_count",
"unit": "peers",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "parent_switches",
"unit": "events/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "bytes_in",
"unit": "bytes/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "bytes_out",
"unit": "bytes/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "packets_in",
"unit": "packets/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "packets_out",
"unit": "packets/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "loss_rate",
"unit": "fraction",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "active_sessions",
"unit": "sessions",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
}
],
"window_seconds": 10
},
"status": "ok"
}
@@ -0,0 +1,20 @@
{
"data": {
"granularity_seconds": 1,
"metric": "mesh_size",
"unit": "nodes",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"status": "ok"
}
@@ -0,0 +1,10 @@
{
"data": {
"granularity_seconds": 1,
"metric": "srtt_ms",
"peers": [],
"unit": "ms",
"window_seconds": 10
},
"status": "ok"
}
@@ -0,0 +1,95 @@
{
"data": {
"fast_ring_seconds": 3600,
"metrics": [
{
"name": "mesh_size",
"scope": "node",
"unit": "nodes"
},
{
"name": "tree_depth",
"scope": "node",
"unit": "hops"
},
{
"name": "peer_count",
"scope": "node",
"unit": "peers"
},
{
"name": "parent_switches",
"scope": "node",
"unit": "events/s"
},
{
"name": "bytes_in",
"scope": "node",
"unit": "bytes/s"
},
{
"name": "bytes_out",
"scope": "node",
"unit": "bytes/s"
},
{
"name": "packets_in",
"scope": "node",
"unit": "packets/s"
},
{
"name": "packets_out",
"scope": "node",
"unit": "packets/s"
},
{
"name": "loss_rate",
"scope": "node",
"unit": "fraction"
},
{
"name": "active_sessions",
"scope": "node",
"unit": "sessions"
},
{
"name": "srtt_ms",
"scope": "peer",
"unit": "ms"
},
{
"name": "loss_rate",
"scope": "peer",
"unit": "fraction"
},
{
"name": "bytes_in",
"scope": "peer",
"unit": "bytes/s"
},
{
"name": "bytes_out",
"scope": "peer",
"unit": "bytes/s"
},
{
"name": "packets_in",
"scope": "peer",
"unit": "packets/s"
},
{
"name": "packets_out",
"scope": "peer",
"unit": "packets/s"
},
{
"name": "ecn_ce",
"scope": "peer",
"unit": "events/s"
}
],
"peer_retention_seconds": 86400,
"slow_ring_minutes": 1440
},
"status": "ok"
}
@@ -0,0 +1,7 @@
{
"data": {
"count": 0,
"peers": []
},
"status": "ok"
}
+52
View File
@@ -0,0 +1,52 @@
{
"data": {
"connection_count": 0,
"control_socket": "<redacted>",
"effective_ipv6_mtu": 1203,
"estimated_mesh_size": null,
"exe_path": "<redacted>",
"forwarding": {
"decode_error_bytes": 0,
"decode_error_packets": 0,
"delivered_bytes": 0,
"delivered_packets": 0,
"drop_mtu_exceeded_bytes": 0,
"drop_mtu_exceeded_packets": 0,
"drop_no_route_bytes": 0,
"drop_no_route_packets": 0,
"drop_send_error_bytes": 0,
"drop_send_error_packets": 0,
"forwarded_bytes": 0,
"forwarded_packets": 0,
"originated_bytes": 0,
"originated_packets": 0,
"received_bytes": 0,
"received_packets": 0,
"ttl_exhausted_bytes": 0,
"ttl_exhausted_packets": 0
},
"ipv6_addr": "fd1b:4788:b7ab:7a43:6a61:1fc5:9fb1:e34c",
"is_leaf_only": false,
"link_count": 0,
"node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"npub": "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r",
"peer_count": 0,
"pid": "<redacted>",
"session_count": 0,
"sparklines": {
"bytes_in": [],
"bytes_out": [],
"loss_rate": [],
"mesh_size": [],
"peer_count": [],
"tree_depth": []
},
"state": "created",
"transport_count": 0,
"tun_name": "<redacted>",
"tun_state": "disabled",
"uptime_secs": "<redacted>",
"version": "<redacted>"
},
"status": "ok"
}
@@ -0,0 +1,6 @@
{
"data": {
"transports": []
},
"status": "ok"
}
+36
View File
@@ -0,0 +1,36 @@
{
"data": {
"declaration_sequence": 1,
"declaration_signed": true,
"depth": 0,
"is_root": true,
"my_coords": [
"1b4788b7ab7a436a611fc59fb1e34c6e"
],
"my_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"parent": "1b4788b7ab7a436a611fc59fb1e34c6e",
"parent_display_name": "1b4788b7...",
"peer_tree_count": 0,
"peers": [],
"root": "1b4788b7ab7a436a611fc59fb1e34c6e",
"stats": {
"accepted": 0,
"addr_mismatch": 0,
"ancestry_changed": 0,
"decode_error": 0,
"flap_dampened": 0,
"loop_detected": 0,
"parent_losses": 0,
"parent_switched": 0,
"parent_switches": 0,
"rate_limited": 0,
"received": 0,
"send_failed": 0,
"sent": 0,
"sig_failed": 0,
"stale": 0,
"unknown_peer": 0
}
},
"status": "ok"
}
+66
View File
@@ -1100,3 +1100,69 @@ impl NostrDiscovery {
Ok(())
}
}
#[cfg(test)]
impl NostrDiscovery {
/// Build a minimal `NostrDiscovery` for unit tests. No relay client is
/// connected and no background tasks are spawned; only the in-memory
/// `advert_cache` and `npub` are usable. Intended for cache-injection
/// tests of consumers (e.g. `Node::run_open_discovery_sweep`).
pub(crate) fn new_for_test() -> Self {
let keys = nostr::Keys::generate();
let pubkey = keys.public_key();
let npub = pubkey.to_bech32().expect("bech32 encode");
let client = Client::builder()
.signer(keys.clone())
.opts(ClientOptions::new().autoconnect(false))
.build();
let config = NostrDiscoveryConfig::default();
let offer_slots = Arc::new(Semaphore::new(config.max_concurrent_incoming_offers));
let (event_tx, event_rx) = mpsc::unbounded_channel();
Self {
client,
keys,
pubkey,
npub,
config,
advert_cache: RwLock::new(HashMap::new()),
local_advert: RwLock::new(None),
current_advert_event_id: RwLock::new(None),
pending_answers: Mutex::new(HashMap::new()),
active_initiators: Mutex::new(HashSet::new()),
seen_sessions: Mutex::new(HashMap::new()),
offer_slots,
event_tx,
event_rx: Mutex::new(event_rx),
notify_task: Mutex::new(None),
advertise_task: Mutex::new(None),
}
}
/// Build a `CachedOverlayAdvert` for tests with a single endpoint and
/// a generous validity window (one hour from `now_ms()`).
pub(crate) fn cached_advert_for_test(
author_npub: String,
endpoint: OverlayEndpointAdvert,
created_at_secs: u64,
) -> CachedOverlayAdvert {
CachedOverlayAdvert {
author_npub: author_npub.clone(),
advert: OverlayAdvert {
identifier: ADVERT_IDENTIFIER.to_string(),
version: ADVERT_VERSION,
endpoints: vec![endpoint],
signal_relays: None,
stun_servers: None,
},
created_at: created_at_secs,
valid_until_ms: now_ms().saturating_add(3_600_000),
}
}
/// Insert a cached advert directly into the in-memory cache. Used by
/// unit tests to set up consumer-side state without needing live relays.
pub(crate) async fn insert_advert_for_test(&self, npub: String, advert: CachedOverlayAdvert) {
let mut cache = self.advert_cache.write().await;
cache.insert(npub, advert);
}
}
+120 -1
View File
@@ -354,9 +354,128 @@ fn random_txn_id() -> [u8; 12] {
#[cfg(test)]
mod tests {
use super::is_private_overlay_candidate_ip;
use super::{is_private_overlay_candidate_ip, parse_stun_binding_success};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
const STUN_MAGIC_COOKIE: u32 = 0x2112_a442;
const TEST_TXN_ID: [u8; 12] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
];
/// Build a STUN Binding Success header with the given message length and txn id.
fn build_success_header(message_length: u16, txn_id: &[u8; 12]) -> Vec<u8> {
let mut packet = Vec::with_capacity(20);
packet.extend_from_slice(&0x0101u16.to_be_bytes()); // Binding Success
packet.extend_from_slice(&message_length.to_be_bytes());
packet.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
packet.extend_from_slice(txn_id);
packet
}
#[test]
fn parse_stun_binding_success_rejects_truncated_header() {
// Anything shorter than the 20-byte header must be rejected.
for len in 0..20usize {
let packet = vec![0u8; len];
assert!(
parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none(),
"expected None for {}-byte packet",
len
);
}
}
#[test]
fn parse_stun_binding_success_rejects_bad_magic_cookie() {
let mut packet = build_success_header(0, &TEST_TXN_ID);
// Corrupt the magic cookie at bytes 4..8.
packet[4..8].copy_from_slice(&0xdead_beefu32.to_be_bytes());
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
}
#[test]
fn parse_stun_binding_success_skips_unknown_attribute_type() {
// Unknown attribute (type 0x00ff, 4-byte body) followed by a valid
// XOR-MAPPED-ADDRESS. The parser should skip the unknown attr and
// still return the mapped address from the second TLV.
let mut packet = build_success_header(0, &TEST_TXN_ID);
// Unknown attribute: type=0x00ff, len=4, body=4 zero bytes.
packet.extend_from_slice(&0x00ffu16.to_be_bytes());
packet.extend_from_slice(&4u16.to_be_bytes());
packet.extend_from_slice(&[0u8; 4]);
// XOR-MAPPED-ADDRESS for 192.0.2.1:1234.
// Build the unxored body, then XOR with cookie/txn so the parser
// recovers the original IP/port.
let cookie = STUN_MAGIC_COOKIE.to_be_bytes();
let xport = 1234u16 ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
let xip = [192 ^ cookie[0], cookie[1], 2 ^ cookie[2], 1 ^ cookie[3]];
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
packet.extend_from_slice(&8u16.to_be_bytes()); // length
packet.push(0x00); // reserved
packet.push(0x01); // family IPv4
packet.extend_from_slice(&xport.to_be_bytes());
packet.extend_from_slice(&xip);
// Patch the message length (everything after the 20-byte header).
let body_len = (packet.len() - 20) as u16;
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
let mapped = parse_stun_binding_success(&packet, &TEST_TXN_ID)
.expect("parser should skip unknown attr and find XOR-MAPPED-ADDRESS");
assert_eq!(mapped.ip(), IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)));
assert_eq!(mapped.port(), 1234);
}
#[test]
fn parse_stun_binding_success_rejects_truncated_xor_mapped_address() {
// XOR-MAPPED-ADDRESS attribute with family=IPv4 but only 6 bytes of
// body (need 8). Parser should silently ignore and return None.
let mut packet = build_success_header(0, &TEST_TXN_ID);
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
packet.extend_from_slice(&6u16.to_be_bytes()); // declared length 6 (too short)
packet.push(0x00); // reserved
packet.push(0x01); // family IPv4
packet.extend_from_slice(&[0u8; 4]); // truncated: port + partial IP only
let body_len = (packet.len() - 20) as u16;
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
}
#[test]
fn parse_stun_binding_success_rejects_length_overflow_attribute() {
// Attribute declares length larger than what's actually present in
// the buffer; parser must break out of the loop and return None
// rather than panic or read past the end.
let mut packet = build_success_header(0, &TEST_TXN_ID);
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
packet.extend_from_slice(&64u16.to_be_bytes()); // claims 64 bytes...
packet.extend_from_slice(&[0u8; 4]); // ...but only 4 bytes follow
let body_len = (packet.len() - 20) as u16;
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
}
#[test]
fn parse_stun_binding_success_rejects_txn_id_mismatch() {
// Valid header + valid XOR-MAPPED-ADDRESS, but txn id in the packet
// does not match the expected one. Parser must reject.
let other_txn: [u8; 12] = [0xff; 12];
let mut packet = build_success_header(12, &other_txn);
packet.extend_from_slice(&0x0020u16.to_be_bytes());
packet.extend_from_slice(&8u16.to_be_bytes());
packet.push(0x00);
packet.push(0x01);
packet.extend_from_slice(&[0u8; 6]);
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
}
#[test]
fn private_overlay_candidate_filter_includes_rfc1918_and_ula() {
assert!(is_private_overlay_candidate_ip(IpAddr::V4(Ipv4Addr::new(
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -1207,7 +1207,7 @@ impl Node {
///
/// `caller` is a short label included in log lines so per-tick and
/// startup sweeps are distinguishable in operator-facing logs.
async fn run_open_discovery_sweep(
pub(in crate::node) async fn run_open_discovery_sweep(
&mut self,
bootstrap: &std::sync::Arc<NostrDiscovery>,
max_age_secs: Option<u64>,
+93
View File
@@ -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"
);
}
+308
View File
@@ -1033,3 +1033,311 @@ async fn test_response_path_mtu_four_node_chain() {
cleanup_nodes(&mut nodes).await;
}
// ============================================================================
// Open-Discovery Sweep — cache-injection unit test
// ============================================================================
/// Pin the iterate-filter-queue contract of `run_open_discovery_sweep`.
///
/// Builds a `Node` with `nostr.policy = Open` and an empty peer list,
/// then injects three cached adverts into a test `NostrDiscovery` and
/// asserts the sweep:
/// - queues a retry for an eligible (unknown, not-self) advert,
/// - skips the advert whose author is our own node identity, and
/// - skips the advert whose author is an already-connected peer.
///
/// Uses `NostrDiscovery::new_for_test()` and `insert_advert_for_test()`
/// (both `#[cfg(test)]`-gated test escape hatches in
/// `src/discovery/nostr/runtime.rs`) to populate the cache without
/// requiring live relay subscriptions.
#[tokio::test]
async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
use crate::config::NostrDiscoveryPolicy;
use crate::discovery::nostr::{NostrDiscovery, OverlayEndpointAdvert, OverlayTransportKind};
use crate::peer::ActivePeer;
use crate::transport::LinkId;
use std::sync::Arc;
// Build node with open-discovery enabled.
let mut config = crate::Config::new();
config.node.discovery.nostr.enabled = true;
config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
let mut node = crate::Node::new(config).unwrap();
// Identity of an already-connected peer; insert into node.peers
// so the sweep's `self.peers.contains_key(&node_addr)` filter fires.
let connected_identity = crate::Identity::generate();
let connected_npub = crate::encode_npub(&connected_identity.pubkey());
let connected_node_addr = *connected_identity.node_addr();
let connected_peer_identity = crate::PeerIdentity::from_pubkey(connected_identity.pubkey());
node.peers.insert(
connected_node_addr,
ActivePeer::new(connected_peer_identity, LinkId::new(1), 1_000),
);
// Eligible peer: fresh identity not in node.peers / retry_pending.
let eligible_identity = crate::Identity::generate();
let eligible_npub = crate::encode_npub(&eligible_identity.pubkey());
let eligible_node_addr = *eligible_identity.node_addr();
// Self filter: advert authored by node's own identity.
let self_npub = crate::encode_npub(&node.identity().pubkey());
let self_node_addr = *node.identity().node_addr();
// Build a NostrDiscovery test instance and inject the three adverts.
let bootstrap = Arc::new(NostrDiscovery::new_for_test());
let endpoint = OverlayEndpointAdvert {
transport: OverlayTransportKind::Udp,
addr: "203.0.113.7:2121".to_string(),
};
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for npub in [&eligible_npub, &connected_npub, &self_npub] {
let advert =
NostrDiscovery::cached_advert_for_test(npub.clone(), endpoint.clone(), now_secs);
bootstrap.insert_advert_for_test(npub.clone(), advert).await;
}
// Run the sweep.
node.run_open_discovery_sweep(&bootstrap, Some(3_600), "test")
.await;
// Eligible peer was queued.
assert!(
node.retry_pending.contains_key(&eligible_node_addr),
"eligible advert should be queued for retry"
);
let queued = node.retry_pending.get(&eligible_node_addr).unwrap();
assert_eq!(queued.peer_config.npub, eligible_npub);
// Connected-peer skip filter held.
assert!(
!node.retry_pending.contains_key(&connected_node_addr),
"advert for already-connected peer must not be queued"
);
// Self skip filter held.
assert!(
!node.retry_pending.contains_key(&self_node_addr),
"advert authored by own node must not be queued"
);
// Exactly one queued entry from the three injected adverts.
assert_eq!(node.retry_pending.len(), 1);
}
// ============================================================================
// Per-Attempt Timeout State Machine — IF-3-A
// ============================================================================
/// Pin the per-attempt timeout sequence in `check_pending_lookups`.
///
/// Drives the state machine deterministically through the default
/// `node.discovery.attempt_timeouts_secs = [1, 2, 4, 8]` sequence.
/// Asserts:
/// 1. **Sequence timing** — retries fire at the cumulative deadlines
/// (t=1100ms, 3100ms, 7100ms) and unreachable at t=15100ms.
/// 2. **Fresh `initiate_lookup` per attempt** — `req_initiated` counter
/// increments by exactly one on each retry. The actual `request_id`
/// is generated by `LookupRequest::generate(...)` via `rand::random()`
/// inside `initiate_lookup` and is not stored on the originator
/// side, so per-attempt freshness is verified indirectly: each
/// `req_initiated` increment corresponds to one fresh
/// `LookupRequest::generate` call.
/// 3. **Final-timeout state transitions** — `pending_lookups` entry is
/// removed, `discovery.resp_timed_out` counter ticks, queued packet
/// is drained, and an ICMPv6 Destination Unreachable frame is
/// emitted via the TUN sender.
///
/// Skipped: direct request_id capture (originator does not record its
/// own request_ids; would require production instrumentation). The
/// `req_initiated` counter is the strongest cleanly-observable signal
/// that `initiate_lookup` ran fresh on each attempt.
#[tokio::test]
async fn test_check_pending_lookups_default_sequence_unreachable() {
use crate::bloom::BloomFilter;
use crate::node::handlers::discovery::PendingLookup;
use crate::peer::ActivePeer;
use crate::transport::LinkId;
use std::sync::mpsc;
let mut node = make_node();
// Default attempt_timeouts_secs is [1, 2, 4, 8]. Confirm so the test
// cannot silently drift if the default changes.
assert_eq!(
node.config.node.discovery.attempt_timeouts_secs,
vec![1, 2, 4, 8],
"test pins the [1,2,4,8] default; update the test if the default changes"
);
// Inject a TUN sender so `send_icmpv6_dest_unreachable` is observable.
let (tun_tx, tun_rx) = mpsc::channel::<Vec<u8>>();
node.tun_tx = Some(tun_tx);
// Build a target identity (the unreachable destination).
let target_identity = Identity::generate();
let target_addr = *target_identity.node_addr();
// Build a tree-peer that:
// - has the target in its inbound bloom filter (so `may_reach` is true),
// - declares us as its parent (so `is_tree_peer` returns true).
// The peer has no Noise session, so `send_encrypted_link_message` will
// fail at the wire-send step — but `initiate_lookup` already incremented
// `req_initiated` and the failure is logged at `debug!`. The state-
// machine bookkeeping we want to test runs to completion either way.
let peer_identity_full = Identity::generate();
let peer_addr = *peer_identity_full.node_addr();
let peer_identity = crate::PeerIdentity::from_pubkey(peer_identity_full.pubkey());
let mut peer = ActivePeer::new(peer_identity, LinkId::new(1), 0);
let mut bloom = BloomFilter::new();
bloom.insert(&target_addr);
peer.update_filter(bloom, 1, 0);
node.peers.insert(peer_addr, peer);
// Make the peer a tree-peer: install a peer declaration that names us
// as its parent. `is_tree_peer` checks both directions — the child
// direction (peer.parent_id == self.node_addr) is what we exercise.
let our_addr = *node.node_addr();
let peer_decl = crate::tree::ParentDeclaration::new(peer_addr, our_addr, 1, 0);
let peer_coords = TreeCoordinate::from_addrs(vec![peer_addr, our_addr]).unwrap();
node.tree_state_mut().update_peer(peer_decl, peer_coords);
assert!(node.is_tree_peer(&peer_addr), "peer must be a tree peer");
// Queue an IPv6 packet for the target so the final-timeout drop +
// ICMPv6 emission can be observed. Build a minimal valid IPv6 header
// with a non-multicast, non-unspecified source so
// `should_send_icmp_error` returns true.
let mut ipv6_pkt = vec![0u8; 40];
ipv6_pkt[0] = 0x60; // version 6
ipv6_pkt[6] = 17; // next_header = UDP (not ICMPv6)
ipv6_pkt[7] = 64; // hop limit
// src = fd00::1 (non-multicast, non-unspecified)
ipv6_pkt[8] = 0xfd;
ipv6_pkt[23] = 0x01;
// dst = target's IPv6 representation (not strictly required, just non-multicast)
let target_ipv6 = crate::FipsAddress::from_node_addr(&target_addr).to_ipv6();
ipv6_pkt[24..40].copy_from_slice(&target_ipv6.octets());
let mut queue = std::collections::VecDeque::new();
queue.push_back(ipv6_pkt);
node.pending_tun_packets.insert(target_addr, queue);
// Inject a PendingLookup directly: attempt=1, last_sent_ms=0. This
// mirrors the post-condition of a successful `maybe_initiate_lookup`
// at t=0 without depending on wall-clock-derived `Self::now_ms()`.
node.pending_lookups
.insert(target_addr, PendingLookup::new(0));
let baseline_initiated = node.stats().discovery.req_initiated;
let baseline_timed_out = node.stats().discovery.resp_timed_out;
// --- t = 1100ms: first retry deadline (1*1000) ---
node.check_pending_lookups(1100).await;
{
let entry = node
.pending_lookups
.get(&target_addr)
.expect("still pending");
assert_eq!(entry.attempt, 2, "after retry #1, attempt should be 2");
assert_eq!(entry.last_sent_ms, 1100);
}
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 1,
"retry #1 must invoke initiate_lookup exactly once"
);
// --- t = 3100ms: second retry deadline (cumulative 1+2 = 3s) ---
node.check_pending_lookups(3100).await;
{
let entry = node
.pending_lookups
.get(&target_addr)
.expect("still pending");
assert_eq!(entry.attempt, 3, "after retry #2, attempt should be 3");
assert_eq!(entry.last_sent_ms, 3100);
}
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 2,
"retry #2 must invoke initiate_lookup exactly once more"
);
// --- t = 7100ms: third retry deadline (cumulative 1+2+4 = 7s) ---
node.check_pending_lookups(7100).await;
{
let entry = node
.pending_lookups
.get(&target_addr)
.expect("still pending");
assert_eq!(entry.attempt, 4, "after retry #3, attempt should be 4");
assert_eq!(entry.last_sent_ms, 7100);
}
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 3,
"retry #3 must invoke initiate_lookup exactly once more"
);
// --- Just-before-final: at t=15099ms the 8s window is not yet reached ---
node.check_pending_lookups(15_099).await;
assert!(
node.pending_lookups.contains_key(&target_addr),
"8s window not yet expired: pending_lookup must persist"
);
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 3,
"no new attempt before final deadline"
);
assert_eq!(
node.stats().discovery.resp_timed_out,
baseline_timed_out,
"no timeout before final deadline"
);
// --- t = 15100ms: final deadline (cumulative 1+2+4+8 = 15s) ---
// Drain any TUN frames that may have leaked from earlier steps so the
// post-final-timeout drain observes only the unreachable-emission output.
while tun_rx.try_recv().is_ok() {}
node.check_pending_lookups(15_100).await;
// Pending lookup is dropped.
assert!(
!node.pending_lookups.contains_key(&target_addr),
"final timeout must remove the pending_lookups entry"
);
// resp_timed_out counter ticked.
assert_eq!(
node.stats().discovery.resp_timed_out,
baseline_timed_out + 1,
"final timeout must increment discovery.resp_timed_out"
);
// No additional initiate_lookup on the timeout step.
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 3,
"the final-timeout step must NOT call initiate_lookup"
);
// Queued packet was drained from pending_tun_packets.
assert!(
!node.pending_tun_packets.contains_key(&target_addr),
"queued packets for the unreachable target must be drained"
);
// ICMPv6 Destination Unreachable was emitted to the TUN sender.
let icmp_frame = tun_rx
.try_recv()
.expect("ICMPv6 Destination Unreachable must be emitted on final timeout");
assert!(
icmp_frame.len() >= 48,
"ICMPv6 frame must be at least IPv6 header (40) + ICMPv6 header (8)"
);
assert_eq!(icmp_frame[0] >> 4, 6, "must be IPv6");
assert_eq!(icmp_frame[6], 58, "next_header must be IPPROTO_ICMPV6 (58)");
assert_eq!(icmp_frame[40], 1, "ICMPv6 type 1 = Destination Unreachable");
}
+1
View File
@@ -10,6 +10,7 @@ mod ble;
mod bloom;
mod bloom_poison;
mod bootstrap;
mod decrypt_failure;
mod disconnect;
mod discovery;
#[cfg(target_os = "linux")]
+92
View File
@@ -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();
+43
View File
@@ -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);
}
}
+129
View File
@@ -554,9 +554,17 @@ fn get_mac_addr(interface: &str) -> Result<[u8; 6], TransportError> {
// ============================================================================
// Unit tests
//
// The whole `socket_macos.rs` file is `#[cfg(target_os = "macos")]`-included
// by `socket.rs`, so this `#[cfg(test)]` mod naturally only compiles on macOS.
// The redundant `#[cfg(target_os = "macos")]` below is belt-and-suspenders:
// it makes the macOS-only intent explicit so that any future refactor that
// includes this file on additional targets won't silently activate macOS-
// specific tests.
// ============================================================================
#[cfg(test)]
#[cfg(target_os = "macos")]
mod tests {
use super::*;
@@ -814,6 +822,127 @@ mod tests {
}
assert!(readable, "pipe read end should be readable after write");
}
// -----------------------------------------------------------------------
// BpfHeader layout pin
//
// The `bpf_hdr` wire layout is fixed by the macOS kernel. If `BpfHeader`
// ever drifts (e.g. someone adds a field, or the timestamp field type
// changes), `parse_next_frame` will misread the kernel's frames. Pin
// both the size and the per-field byte offsets so any such drift fails
// at unit-test time rather than as runtime garbage MAC addresses.
// -----------------------------------------------------------------------
#[test]
fn test_bpf_header_layout_matches_kernel() {
// size_of pinned at type-define site too via `const _: () = assert!`,
// but repeating here makes the failure mode obvious in test output.
assert_eq!(std::mem::size_of::<BpfHeader>(), 20);
// bh_hdrlen lives at offset 16 (4 + 4 + 4 + 4).
let hdr = BpfHeader {
bh_tstamp_sec: 0,
bh_tstamp_usec: 0,
bh_caplen: 0,
bh_datalen: 0,
bh_hdrlen: 0xABCD,
_pad: 0,
};
let bytes: &[u8] =
unsafe { std::slice::from_raw_parts(&hdr as *const BpfHeader as *const u8, 20) };
assert_eq!(&bytes[16..18], &0xABCDu16.to_ne_bytes());
}
// -----------------------------------------------------------------------
// parse_next_frame — additional malformed-header rejection cases
// -----------------------------------------------------------------------
#[test]
fn test_parse_next_frame_caplen_exceeds_remaining_buffer() {
// Build a header that claims more captured data than the buffer
// actually holds. parse_next_frame should reject (return None)
// rather than read past the end.
let hdr_size = std::mem::size_of::<BpfHeader>();
let claimed_cap_len: usize = 200; // > what's actually in the buffer
let hdr = BpfHeader {
bh_tstamp_sec: 0,
bh_tstamp_usec: 0,
bh_caplen: claimed_cap_len as u32,
bh_datalen: claimed_cap_len as u32,
bh_hdrlen: hdr_size as u16,
_pad: 0,
};
// Allocate only header + 32 bytes — far short of claimed cap_len.
let mut buf = vec![0u8; hdr_size + 32];
unsafe {
std::ptr::copy_nonoverlapping(
&hdr as *const BpfHeader as *const u8,
buf.as_mut_ptr(),
hdr_size,
);
}
let mut out_buf = vec![0u8; 1500];
let mut offset = 0usize;
// Truncated frame: returns None (skipped, not an error).
assert!(parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf).is_none());
}
// -----------------------------------------------------------------------
// Ethernet-header construction round-trip
//
// Mirrors the byte layout that `send_to` lays down in front of the
// payload, then runs that through `parse_next_frame` to confirm the
// source MAC bytes survive the round trip. Pure data — no actual fd.
// -----------------------------------------------------------------------
#[test]
fn test_ethernet_header_round_trip_via_parse() {
// Hand-build the Ethernet header the way send_to() does.
let dst_mac: [u8; 6] = [0xff; 6];
let src_mac: [u8; 6] = [0x02, 0x00, 0x00, 0x12, 0x34, 0x56];
let ethertype: u16 = 0x88B5; // local-experimental EtherType
let payload: &[u8] = b"FIPS-frame-payload";
// Construct the in-buffer BPF frame the kernel would have written:
// [bpf_hdr][dst_mac][src_mac][ethertype_be][payload]
let cap_len = ETH_HDRLEN + payload.len();
let hdr = BpfHeader {
bh_tstamp_sec: 0,
bh_tstamp_usec: 0,
bh_caplen: cap_len as u32,
bh_datalen: cap_len as u32,
bh_hdrlen: std::mem::size_of::<BpfHeader>() as u16,
_pad: 0,
};
let hdr_size = std::mem::size_of::<BpfHeader>();
let total = bpf_wordalign(hdr_size + cap_len);
let mut buf = vec![0u8; total];
unsafe {
std::ptr::copy_nonoverlapping(
&hdr as *const BpfHeader as *const u8,
buf.as_mut_ptr(),
hdr_size,
);
}
let frame_start = hdr_size;
buf[frame_start..frame_start + 6].copy_from_slice(&dst_mac);
buf[frame_start + 6..frame_start + 12].copy_from_slice(&src_mac);
buf[frame_start + 12..frame_start + 14].copy_from_slice(&ethertype.to_be_bytes());
buf[frame_start + ETH_HDRLEN..frame_start + ETH_HDRLEN + payload.len()]
.copy_from_slice(payload);
// Parse it back.
let mut out_buf = vec![0u8; 1500];
let mut offset = 0usize;
let (n, parsed_src) = parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf)
.expect("Some")
.expect("Ok");
// The 14-byte Ethernet header is stripped; only the payload survives.
assert_eq!(n, payload.len());
assert_eq!(&out_buf[..n], payload);
// Source MAC is reported directly from bytes [6..12] of the frame.
assert_eq!(parsed_src, src_mac);
}
}
/// Get the MTU of an interface by index.
+105 -2
View File
@@ -361,6 +361,41 @@ impl TunDevice {
}
}
/// macOS utun protocol family value for IPv6 (matches `<sys/socket.h>`
/// `AF_INET6` on Darwin). Used as the 4-byte big-endian packet-info
/// header prepended to every utun frame.
#[cfg(target_os = "macos")]
const UTUN_AF_INET6: u32 = 30;
/// Build the 4-byte big-endian utun packet-info header for an IPv6 frame.
///
/// utun devices on macOS require a 4-byte address-family prefix on every
/// frame: a single big-endian `u32` carrying the protocol family. For
/// IPv6 traffic (the only family FIPS sends) this is `AF_INET6 = 30`,
/// which serializes as `[0x00, 0x00, 0x00, 0x1e]`.
#[cfg(target_os = "macos")]
#[inline]
fn utun_af_inet6_header() -> [u8; 4] {
UTUN_AF_INET6.to_be_bytes()
}
/// Parse the 4-byte big-endian utun packet-info header.
///
/// Returns the address-family value (`AF_INET6 = 30` for IPv6 frames),
/// or `None` if the buffer is shorter than the 4-byte header. The `tun`
/// crate's `Read` impl strips this transparently for us in the read
/// path; this helper exists for round-trip testability with
/// [`utun_af_inet6_header`] and for any future code path that reads
/// from the dup'd fd directly.
#[cfg(target_os = "macos")]
#[inline]
fn parse_utun_af_prefix(buf: &[u8]) -> Option<u32> {
if buf.len() < 4 {
return None;
}
Some(u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]))
}
/// Writer thread for TUN device.
///
/// Services a queue of outbound packets and writes them to the TUN device.
@@ -413,10 +448,10 @@ impl TunWriter {
#[cfg(target_os = "macos")]
let write_result = {
use std::os::unix::io::AsRawFd;
const AF_INET6_HEADER: [u8; 4] = [0, 0, 0, 30];
let af_header = utun_af_inet6_header();
let iov = [
libc::iovec {
iov_base: AF_INET6_HEADER.as_ptr() as *mut libc::c_void,
iov_base: af_header.as_ptr() as *mut libc::c_void,
iov_len: 4,
},
libc::iovec {
@@ -1482,4 +1517,72 @@ mod tests {
assert_eq!(per_flow_max_mss(&lookup, a.as_bytes(), 1360), 1143);
assert_eq!(per_flow_max_mss(&lookup, b.as_bytes(), 1360), 1315);
}
// ========================================================================
// macOS utun packet-info header (AF_INET6 4-byte big-endian prefix)
//
// These tests are pure-data byte-buffer manipulation and require no
// privilege, no actual TUN device, no system calls. They pin the wire
// format that `TunWriter::run` emits ahead of every IPv6 frame on the
// dup'd utun fd, and the inverse parse used for round-trip checking.
// ========================================================================
#[cfg(target_os = "macos")]
mod macos_utun_header {
use super::super::{UTUN_AF_INET6, parse_utun_af_prefix, utun_af_inet6_header};
#[test]
fn af_inet6_constant_matches_darwin() {
// Darwin's <sys/socket.h> defines AF_INET6 = 30. If this ever
// diverges, every utun write FIPS issues will be misclassified
// by the kernel and dropped.
assert_eq!(UTUN_AF_INET6, 30);
}
#[test]
fn encode_produces_big_endian_af_inet6() {
// The kernel reads the 4-byte prefix as a big-endian u32.
// 30 == 0x0000001e, so the wire bytes are [0, 0, 0, 0x1e].
let header = utun_af_inet6_header();
assert_eq!(header, [0x00, 0x00, 0x00, 0x1e]);
}
#[test]
fn encode_round_trips_through_parse() {
let header = utun_af_inet6_header();
let parsed = parse_utun_af_prefix(&header).expect("4 bytes is enough");
assert_eq!(parsed, UTUN_AF_INET6);
}
#[test]
fn parse_rejects_short_buffer() {
// Anything shorter than the 4-byte header is ill-formed.
assert_eq!(parse_utun_af_prefix(&[]), None);
assert_eq!(parse_utun_af_prefix(&[0x00]), None);
assert_eq!(parse_utun_af_prefix(&[0x00, 0x00]), None);
assert_eq!(parse_utun_af_prefix(&[0x00, 0x00, 0x00]), None);
}
#[test]
fn parse_accepts_minimum_header_with_trailing_payload() {
// A real utun read returns header + IP packet concatenated.
// The parser only consumes the first 4 bytes.
let mut frame = utun_af_inet6_header().to_vec();
frame.extend_from_slice(&[0x60; 40]); // dummy IPv6 header
let parsed = parse_utun_af_prefix(&frame).expect("4 bytes is enough");
assert_eq!(parsed, UTUN_AF_INET6);
}
#[test]
fn parse_garbage_bytes_returns_garbage_value_not_panic() {
// A well-formed 4-byte buffer whose value is not AF_INET6
// should parse successfully (returning the raw u32) without
// panicking. Discriminating "expected" vs "unexpected" AF
// values is the caller's responsibility.
let buf = [0xde, 0xad, 0xbe, 0xef];
let parsed = parse_utun_af_prefix(&buf).expect("4 bytes is enough");
assert_eq!(parsed, 0xdeadbeef);
assert_ne!(parsed, UTUN_AF_INET6);
}
}
}
+84 -1
View File
@@ -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
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
generated-configs
+111
View File
@@ -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).
+52
View File
@@ -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
+111
View File
@@ -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" <<EOF
node-a $NPUB_A
node-b $NPUB_B
EOF
}
echo "Generating firewall fixtures..."
rm -rf "$GENERATED_DIR"
# ── node-a ────────────────────────────────────────────────────────────
write_file "$GENERATED_DIR/node-a/fips.yaml" <<EOF
node:
identity:
persistent: true
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
transports:
udp:
bind_addr: "0.0.0.0:2121"
peers:
- npub: "$NPUB_B"
alias: "node-b"
addresses:
- transport: udp
addr: "172.32.0.11:2121"
connect_policy: auto_connect
EOF
write_file "$GENERATED_DIR/node-a/fips.key" <<EOF
$KEY_A
EOF
# ── node-b ────────────────────────────────────────────────────────────
write_file "$GENERATED_DIR/node-b/fips.yaml" <<EOF
node:
identity:
persistent: true
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
transports:
udp:
bind_addr: "0.0.0.0:2121"
peers:
- npub: "$NPUB_A"
alias: "node-a"
addresses:
- transport: udp
addr: "172.32.0.10:2121"
connect_policy: auto_connect
EOF
write_file "$GENERATED_DIR/node-b/fips.key" <<EOF
$KEY_B
EOF
# ── node-b drop-in: allow inbound TCP/22 (Case d) ─────────────────────
# The simplest possible operator-supplied allowance, matching the
# fips.nft header example. The test asserts this rule unblocks an
# otherwise-DROP'd TCP/22 SYN.
write_file "$GENERATED_DIR/node-b/fips.d/services.nft" <<'EOF'
tcp dport 22 accept
EOF
write_hosts_file node-a
write_hosts_file node-b
echo "Firewall fixtures written to $GENERATED_DIR"
+247
View File
@@ -0,0 +1,247 @@
#!/bin/bash
# Integration test for the fips0 nftables baseline (packaging/common/fips.nft).
#
# Asserts the four behaviors documented in the fips.nft header:
# (a) unallowed inbound on fips0 → DROP
# (b) outbound-initiated reply → conntrack established/related ACCEPT
# (c) ICMPv6 echo-request → ACCEPT
# (d) drop-in allowlisted port → ACCEPT
#
# fips-firewall.service activation: the unit's ExecStart is
# `/usr/sbin/nft -f /etc/fips/fips.nft`. The test image does not run
# systemd, so this script invokes the same nft command directly inside
# the container after fips0 is up. The full deb-install harness covers
# the systemd unit-enablement path separately.
#
# Usage: ./test.sh [--skip-build] [--keep-up]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TESTING_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
GENERATE_CONFIGS="$SCRIPT_DIR/generate-configs.sh"
CONTAINER_A="fips-fw-container-a"
CONTAINER_B="fips-fw-container-b"
NPUB_A="npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
NPUB_B="npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
# Port not present in any drop-in. Used for case (a) to assert DROP.
UNALLOWED_PORT=8000
# Port present in node-b's fips.d drop-in. Used for case (d) to assert ACCEPT.
ALLOWED_PORT=22
# Port that node-a listens on for the conntrack reply test (case b).
OUTBOUND_TARGET_PORT=8000
SKIP_BUILD=false
KEEP_UP=false
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=true; shift ;;
--keep-up) KEEP_UP=true; shift ;;
*) echo "Unknown option: $1" >&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 `<npub>.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"
+103
View File
@@ -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"
+19 -2
View File
@@ -11,7 +11,7 @@ SCENARIO="${1:?usage: generate-configs.sh <cone|symmetric|lan> [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" <<EOF
NPUB_A=$npub_a
NPUB_B=$npub_b
+372
View File
@@ -0,0 +1,372 @@
#!/bin/bash
#
# Nostr overlay advert publish/consume integration test.
#
# Exercises the round-trip:
# Phase 1: A publishes overlay advert; B subscribes; B observes A's advert;
# B dials A.
# Phase 2: B publishes; A subscribes; reverse direction. (Both directions
# are validated together via the bidirectional `peers` count.)
# Phase 3: A malformed Kind-37195 advert event is published directly to
# the relay; both consumers must reject it (parse error path)
# without crashing — asserted via process liveness.
#
# UDP transport for v0.3.0 baseline. Tor / TCP variants out of scope here.
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"
WAIT_LIB="$ROOT_DIR/testing/lib/wait-converge.sh"
PROFILE="nostr-publish-consume"
SCENARIO="$PROFILE"
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
NODE_A="fips-nat-nostr-pub-a"
NODE_B="fips-nat-nostr-pub-b"
RELAY_HOST="172.31.10.30"
RELAY_PORT=7777
RELAY_CONTAINER="fips-nat-relay"
# shellcheck disable=SC1090
source "$WAIT_LIB"
cleanup() {
"${COMPOSE[@]}" --profile "$PROFILE" down -v --remove-orphans \
>/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: <timeout — frame sent but no ack>")
# 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 "$@"
+333
View File
@@ -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 "$@"
+14 -2
View File
@@ -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]
+37
View File
@@ -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
+206 -33
View File
@@ -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:<port> 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