From 5b229c03bf83e11e958543c93ef120e6f9ef034c Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 26 May 2026 19:54:21 +0000 Subject: [PATCH 1/2] =?UTF-8?q?node:=20skip=20Msg1=20=E2=86=92=20Msg2=20re?= =?UTF-8?q?ply=20when=20at=20max=5Fpeers=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the max_peers cap check in handle_msg1 forward, from the late check inside promote_connection (which fires after Msg2 has already been built and put on the wire) to an early position after identity verification but before index allocation and the Msg2 send. When the gate fires for a net-new identity, the Msg1 is silent-dropped — no response goes back to the peer, no AEAD compute or wire bytes are spent. Bypass preserved for known peers (reconnect / cross-connection): if the sender's NodeAddr is already in self.peers, or if a pending outbound connection is in flight to the same identity, the gate is skipped so legitimate maintenance traffic continues to work. The late check inside promote_connection is intentionally retained as defense-in-depth against future call sites or a disconnect racing between the early-gate decision and promotion. Wire-cost rationale: a 45 s tcpdump at saturation observed ~3.6 cap-denials/s steady-state, each previously paying the full Noise IK responder crypto + Msg2 (~104 B) on the wire before being rejected. The bigger value is cleaner peer-side semantics — the peer no longer sees a fake-completed handshake whose data frames subsequently fail decryption locally. Two new unit tests cover the cases: - handle_msg1_silent_drops_at_cap_for_new_peer drives a wire-pumped Msg1 from a fresh identity into a saturated node and asserts no Msg2 reaches the sender socket. Stash-verifies as FAIL on the pre-fix tree (Msg2 hits the wire) and PASS post-fix. - handle_msg1_admits_existing_peer_at_cap drives a Msg1 from an identity already in self.peers and asserts the gate does not evict it. This is a regression check (the no-gate tree behaves the same way here, but the test guards against an accidental future gate that breaks known-peer admit). --- CHANGELOG.md | 11 ++ src/node/handlers/handshake.rs | 29 +++++ src/node/tests/unit.rs | 224 +++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f15ac91..49ad3a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `handshake.rs:1114` is unchanged. Introduces a shared `Node::outbound_admission_check()` helper so the invariant is grep-able and unit-testable. +- Inbound `handle_msg1` now silent-drops at `node.limits.max_peers` + saturation *before* building/sending Msg2, instead of replying with + Msg2 and then rejecting at `promote_connection`. Adds an early cap + check positioned after identity verification (so the + reconnect / cross-connection bypass for known peers still fires) and + before index allocation + Msg2 wire send. The late cap check inside + `promote_connection` is intentionally retained as + defense-in-depth. Wire savings observed in a 45 s ops tcpdump at + saturation: ~3.6 cap-denials/s × Msg2 (~104 B + AEAD compute) each. + Bigger win is cleaner peer-side semantics — no fake-completed + handshake whose subsequent data frames fail decryption on this side. - Mesh-size estimator (`compute_mesh_size`) no longer double-counts the parent's bloom cardinality during the transient cache window after a local parent-switch. Symptom: `fipsctl show status` / fipstop displayed diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index ae2cc45..0f4dc26 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -207,6 +207,35 @@ impl Node { possible_restart = true; } + // Early cap check: at max_peers and this is a net-new identity? + // Bypass for known peers (reconnect / cross-connection) — admitting + // them doesn't grow peers.len(). This silent-drops the Msg1 before + // the Msg2 build/send and index allocation, avoiding wasted wire + // bytes and giving the peer cleaner semantics (no fake-completed + // handshake whose data frames subsequently fail decryption here). + // The late cap check inside promote_connection() is intentionally + // left in place as defense-in-depth. + if self.max_peers > 0 && self.peers.len() >= self.max_peers { + let is_known_active = self.peers.contains_key(&peer_node_addr); + let is_pending_outbound = self.connections.iter().any(|(_, conn)| { + conn.expected_identity() + .map(|id| *id.node_addr() == peer_node_addr) + .unwrap_or(false) + }); + if !is_known_active && !is_pending_outbound { + debug!( + peer = %self.peer_display_name(&peer_node_addr), + max = self.max_peers, + "Silent-dropping Msg1 at max_peers cap (early gate; no Msg2 sent)" + ); + // `link_id` was allocated above but `conn` is still a local + // (not yet inserted into self.connections / self.links / + // self.addr_to_link), so the local drop suffices. + self.msg1_rate_limiter.complete_handshake(); + return; + } + } + // Epoch-based restart detection and duplicate msg1 handling. // // If we fell through from the addr_to_link check above with diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index 7822727..4671625 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -1497,3 +1497,227 @@ fn nostr_discovery_outbound_admission_atomic_roundtrip() { "after recovery store: traversal initiator/responder must see true" ); } + +/// Sender-side helper: build a wire-format Msg1 from a fresh peer +/// identity targeting `node_b`, *and* send it on the wire over `socket_a` +/// to `addr_b`. Returns the sender's NodeAddr so the test can assert on +/// identity-keyed maps. +/// +/// Uses the same outbound-PeerConnection->Noise IK pattern as the +/// integration handshake tests, but inlined and unit-scoped. +async fn craft_and_send_msg1( + node_b: &Node, + sender_identity: &Identity, + socket_a: &tokio::net::UdpSocket, + addr_b: std::net::SocketAddr, + timestamp_ms: u64, +) -> NodeAddr { + use crate::node::wire::build_msg1; + use crate::utils::index::SessionIndex; + + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); + let sender_pubkey_id = PeerIdentity::from_pubkey_full(sender_identity.pubkey_full()); + let sender_node_addr = *sender_pubkey_id.node_addr(); + + let link_id = LinkId::new(0xDEAD_BEEF); + let mut conn = PeerConnection::outbound(link_id, peer_b_identity, timestamp_ms); + + let sender_keypair = sender_identity.keypair(); + let mut startup_epoch = [0u8; 8]; + rand::Rng::fill_bytes(&mut rand::rng(), &mut startup_epoch); + let noise_msg1 = conn + .start_handshake(sender_keypair, startup_epoch, timestamp_ms) + .expect("start_handshake should produce noise msg1"); + + let sender_index = SessionIndex::new(0x5151); + let wire_msg1 = build_msg1(sender_index, &noise_msg1); + + socket_a + .send_to(&wire_msg1, addr_b) + .await + .expect("sender_socket.send_to"); + sender_node_addr +} + +/// Helper: deliver a packet from `node`'s registered UDP transport to +/// `node.handle_msg1`. Returns Ok(()) on success or Err if the packet +/// was not received within `timeout`. +async fn pump_one_msg1_into_node( + node: &mut Node, + packet_rx: &mut crate::transport::PacketRx, + timeout_ms: u64, +) -> Result<(), &'static str> { + use tokio::time::{Duration, timeout}; + let packet = timeout(Duration::from_millis(timeout_ms), packet_rx.recv()) + .await + .map_err(|_| "timed out waiting for msg1 on packet_rx")? + .ok_or("packet channel closed")?; + node.handle_msg1(packet).await; + Ok(()) +} + +/// Verifies the early max_peers cap check in `handle_msg1` silent-drops +/// a Msg1 from a brand-new identity at saturation: no peer is admitted, +/// no Msg2 response goes back on the wire, and the msg1 rate-limiter +/// pending_count returns to baseline. +/// +/// Wire-observable Msg2 absence is the load-bearing discriminator. With +/// the early cap gate removed (stash-verify), the late gate inside +/// `promote_connection` still rejects the new identity — but only +/// *after* `handle_msg1` has already built the Msg2 frame and +/// `transport.send(...wire_msg2)` has put it on the wire. The +/// post-call wire-side poll catches that Msg2 (FAIL pre-fix; the +/// silent timeout is the PASS post-fix). +#[tokio::test] +async fn handle_msg1_silent_drops_at_cap_for_new_peer() { + use crate::config::UdpConfig; + use tokio::time::{Duration, timeout}; + + let mut node = make_node(); + node.set_max_peers(2); + inject_dummy_peers(&mut node, 2); + assert_eq!(node.peer_count(), 2, "precondition: at cap"); + + // === UDP transport setup for node_b (the unit under test) === + let transport_id_b = TransportId::new(1); + let udp_config = UdpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + mtu: Some(1280), + ..Default::default() + }; + let (packet_tx_b, mut packet_rx_b) = packet_channel(64); + let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b); + transport_b.start_async().await.unwrap(); + let addr_b = transport_b.local_addr().unwrap(); + node.transports + .insert(transport_id_b, TransportHandle::Udp(transport_b)); + + // === Sender-side socket === + let socket_a = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("bind sender socket"); + + let before_peers = node.peer_count(); + let before_pending = node.msg1_rate_limiter.pending_count(); + + // Fresh sender identity — never seen by `node`. + let sender = Identity::generate(); + let sender_node_addr = craft_and_send_msg1(&node, &sender, &socket_a, addr_b, 1000).await; + + // Sanity: new identity is not currently a peer. + assert!( + !node.peers.contains_key(&sender_node_addr), + "precondition: new sender not yet a peer" + ); + + // Pump the wire-arrived Msg1 into the node's handler. + pump_one_msg1_into_node(&mut node, &mut packet_rx_b, 1000) + .await + .expect("msg1 must reach packet_rx_b"); + + // Post-call state checks. + assert_eq!( + node.peer_count(), + before_peers, + "early cap gate must not adopt a new peer at saturation" + ); + assert!( + !node.peers.contains_key(&sender_node_addr), + "new sender must not appear in peers map" + ); + assert_eq!( + node.msg1_rate_limiter.pending_count(), + before_pending, + "rate limiter must rebalance: start_handshake() then \ + complete_handshake() before silent-drop return" + ); + + // Wire-observable discriminator: with the early gate in place, no + // Msg2 should come back. With the gate removed, Msg2 IS sent + // before promote_connection rejects. + let mut buf = [0u8; 2048]; + let recv = timeout(Duration::from_millis(300), socket_a.recv_from(&mut buf)).await; + let received_bytes = recv.ok().and_then(|inner| inner.ok()).map(|(n, _)| n); + assert!( + received_bytes.is_none(), + "Msg2 must NOT be sent in response when at max_peers cap; \ + observed {received_bytes:?} wire bytes — the fingerprint of \ + the late-gate path replying with Msg2 before rejecting" + ); +} + +/// Verifies the bypass: at saturation, an inbound Msg1 from an +/// *existing* peer's identity is not silent-dropped by the early cap +/// check (the gate would otherwise wedge legitimate +/// reconnect/restart/rekey traffic against an at-cap node). +/// +/// The cap-gate's `is_known_active = self.peers.contains_key(&peer_node_addr)` +/// branch admits this case; the downstream handling (restart-detect or +/// duplicate-msg1 resend) then runs per existing semantics. The +/// observable assertion here is the existing peer's continued +/// presence — the rate-limiter rebalance is the same in +/// bypass-admit and silent-drop, so this test isn't a discriminator +/// against the no-gate (stash) build; it's a regression check that the +/// gate doesn't accidentally evict known peers. +#[tokio::test] +async fn handle_msg1_admits_existing_peer_at_cap() { + use crate::config::UdpConfig; + + let mut node = make_node(); + node.set_max_peers(2); + + inject_dummy_peers(&mut node, 1); + + let existing_sender = Identity::generate(); + let existing_pid = PeerIdentity::from_pubkey_full(existing_sender.pubkey_full()); + let existing_node_addr = *existing_pid.node_addr(); + let existing_link_id = LinkId::new(7777); + { + use crate::peer::ActivePeer; + let peer = ActivePeer::new(existing_pid, existing_link_id, 0); + node.peers.insert(existing_node_addr, peer); + } + assert_eq!(node.peer_count(), 2, "precondition: at cap"); + + let transport_id_b = TransportId::new(1); + let udp_config = UdpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + mtu: Some(1280), + ..Default::default() + }; + let (packet_tx_b, mut packet_rx_b) = packet_channel(64); + let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b); + transport_b.start_async().await.unwrap(); + let addr_b = transport_b.local_addr().unwrap(); + node.transports + .insert(transport_id_b, TransportHandle::Udp(transport_b)); + + let socket_a = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("bind sender socket"); + + let before_pending = node.msg1_rate_limiter.pending_count(); + + let sender_node_addr = + craft_and_send_msg1(&node, &existing_sender, &socket_a, addr_b, 2000).await; + assert_eq!( + sender_node_addr, existing_node_addr, + "sanity: crafted msg1 carries the existing peer's NodeAddr" + ); + + pump_one_msg1_into_node(&mut node, &mut packet_rx_b, 1000) + .await + .expect("msg1 must reach packet_rx_b"); + + // Bypass must not evict the existing peer or grow peer count. + assert_eq!(node.peer_count(), 2, "peer count unchanged"); + assert!( + node.peers.contains_key(&existing_node_addr), + "existing peer must still be present after bypass-admitted msg1" + ); + assert_eq!( + node.msg1_rate_limiter.pending_count(), + before_pending, + "rate limiter must rebalance after the (bypass-admitted) handler returns" + ); +} From d575c1f9869d17e8a96f73453b9f4c0501b8c858 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 26 May 2026 20:34:42 +0000 Subject: [PATCH 2/2] testing: add admission-cap integration suite for inbound silent-drop gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New integration scenario verifying the early-gate silent-drop behavior of the inbound max_peers admission check at sustained scale, using the existing 5-node mesh topology with one node's node.limits.max_peers lowered to 1. This forces 2 of the cap'd node's 3 configured peers into a sustained denied state, and asserts via tcpdump that no Msg2 responses go back to those denied peers across a 60s capture window. A background load-driver restarts the denied peer containers every 15s to reset their auto-reconnect exponential backoff (5s base / 300s cap), producing fresh Msg1 bursts each cycle. Without this loop the gate fires ~3-4 times per denied peer in a 60s window; with restarts the observed rate is 15 per denied peer (~30 total firings), high enough that any Msg2 leakage would be caught with strong statistical confidence. Local run on this branch: cap'd node-c converged to peer_count=1 with node-b admitted; nodes d and e sustained-retried as denied; tcpdump captured 30 inbound Msg1 (len 84) packets from the denied pair and 0 outbound Msg2 (len 104) packets, with final peer_count unchanged. Files: testing/static/scripts/admission-cap-test.sh — new test script with inject-config subcommand (sets node.limits.max_peers) and a 3-phase test driver (converge, capture-with-load, per-peer assert) testing/ci-local.sh — register admission-cap as a new suite category (ADMISSION_SUITES), wire run_admission_cap function, add to run_suite dispatch, list_suites, and the default integration sweep Together with the existing unit-level coverage in src/node/tests/unit.rs (handle_msg1_silent_drops_at_cap_for_new_peer with mock-transport Msg2 discriminator, and handle_msg1_admits_existing_peer_at_cap as the bypass regression guard), the gate's silent-drop behavior is now verified both at single-firing wire-observable resolution and at sustained multi-firing cross-process scale. --- testing/ci-local.sh | 39 ++++ testing/static/scripts/admission-cap-test.sh | 205 +++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100755 testing/static/scripts/admission-cap-test.sh diff --git a/testing/ci-local.sh b/testing/ci-local.sh index 34661e5..c74701c 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -57,6 +57,7 @@ ONLY_SUITE="" # All integration suites matching ci.yml STATIC_SUITES=(static-mesh static-chain) REKEY_SUITES=(rekey rekey-accept-off rekey-outbound-only) +ADMISSION_SUITES=(admission-cap) # Each entry: "display-name scenario [--flag value ...]" CHAOS_SUITES=( "smoke-10 smoke-10" @@ -111,6 +112,9 @@ list_suites() { echo " Rekey:" for s in "${REKEY_SUITES[@]}"; do echo " $s"; done echo "" + echo " Admission cap:" + for s in "${ADMISSION_SUITES[@]}"; do echo " $s"; done + echo "" echo " Gateway:" for s in "${GATEWAY_SUITES[@]}"; do echo " $s"; done echo "" @@ -320,6 +324,34 @@ run_rekey() { record "rekey" $rc } +# Run the admission-cap integration test +# Verifies the inbound max_peers early-gate silent-drops at scale by +# lowering node.max_peers on one mesh node and asserting via tcpdump +# that no Msg2 responses go to the sustained-retrying denied peers. +run_admission_cap() { + local compose="testing/static/docker-compose.yml" + local rc=0 + + info "[admission-cap] Generating configs" + bash testing/static/scripts/generate-configs.sh mesh || { record "admission-cap" 1; return; } + bash testing/static/scripts/admission-cap-test.sh inject-config || { record "admission-cap" 1; return; } + + info "[admission-cap] Starting containers (mesh profile)" + docker compose -f "$compose" --profile mesh up -d || { record "admission-cap" 1; return; } + + info "[admission-cap] Running admission-cap test" + if bash testing/static/scripts/admission-cap-test.sh; then + rc=0 + else + rc=1 + info "[admission-cap] Collecting failure logs" + docker compose -f "$compose" --profile mesh logs --no-color 2>&1 | tail -100 + fi + + docker compose -f "$compose" --profile mesh down --volumes --remove-orphans 2>/dev/null + record "admission-cap" $rc +} + # Run a chaos scenario run_chaos() { local name="$1" @@ -568,6 +600,11 @@ run_integration() { run_rekey_accept_off run_rekey_outbound_only + # Admission cap (mesh profile, max_peers=1 on one node) + for _suite in "${ADMISSION_SUITES[@]}"; do + run_admission_cap + done + # Gateway run_gateway @@ -670,6 +707,8 @@ run_suite() { run_rekey_accept_off ;; rekey-outbound-only) run_rekey_outbound_only ;; + admission-cap) + run_admission_cap ;; gateway) run_gateway ;; acl-allowlist) diff --git a/testing/static/scripts/admission-cap-test.sh b/testing/static/scripts/admission-cap-test.sh new file mode 100755 index 0000000..c88448f --- /dev/null +++ b/testing/static/scripts/admission-cap-test.sh @@ -0,0 +1,205 @@ +#!/bin/bash +# Integration test for the inbound max_peers admission gate. +# +# Verifies the silent-drop behavior of the early-gate in handle_msg1 at +# scale, using the mesh topology with one node's node.max_peers lowered +# to 1. This forces 2 of node-c's 3 configured peers (b, d, e) into a +# sustained denied state, and asserts via tcpdump that no Msg2 responses +# go back to those denied peers across a 60s capture window. +# +# Tested behavior: +# - Denied peers DO arrive at the cap'd node (inbound FMP-IK Msg1, 84 B) +# - Cap'd node sends NO Msg2 responses (104 B) to denied peers +# - Cap'd node maintains exactly max_peers active sessions +# - Admitted peer's session stays healthy throughout the window +# +# Usage: +# ./admission-cap-test.sh Run the test (containers must be up) +# ./admission-cap-test.sh inject-config Inject node.max_peers into generated configs + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +CAP_NODE="${ADMISSION_CAP_NODE:-c}" +MAX_PEERS="${ADMISSION_MAX_PEERS:-1}" +CAPTURE_SECS="${ADMISSION_CAPTURE_SECS:-60}" +TOPOLOGY="mesh" +TOPO_FILE="$SCRIPT_DIR/../configs/topologies/$TOPOLOGY.yaml" + +# ── inject-config subcommand ───────────────────────────────────────── +# Inject node.max_peers into generated configs. Called separately by CI +# before building Docker images. +if [ "${1:-}" = "inject-config" ]; then + echo "Injecting node.limits.max_peers: $MAX_PEERS into node-$CAP_NODE ($TOPOLOGY topology)..." + cfg="$SCRIPT_DIR/../generated-configs/$TOPOLOGY/node-$CAP_NODE.yaml" + if [ ! -f "$cfg" ]; then + echo " Error: $cfg not found (run generate-configs.sh $TOPOLOGY first)" >&2 + exit 1 + fi + # Insert under node.limits (the actual config path per src/config/node.rs). + # Three cases: limits.max_peers already present (update), limits: present + # without max_peers (append), or no limits block (insert full subtree). + if grep -qE "^ max_peers:" "$cfg"; then + sed -i -E "s/^ max_peers: *[0-9]+/ max_peers: $MAX_PEERS/" "$cfg" + elif grep -qE "^ limits:" "$cfg"; then + sed -i "/^ limits:/a\\ + max_peers: $MAX_PEERS" "$cfg" + else + sed -i "/^node:/a\\ + limits:\\ + max_peers: $MAX_PEERS" "$cfg" + fi + echo " node-$CAP_NODE limits block:" + sed -n '/^ limits:/,/^ [a-z]/p' "$cfg" | head -5 + exit 0 +fi + +stamp() { date '+%H:%M:%S'; } +info() { echo "[$(stamp)] $*"; } +fail() { echo "[$(stamp)] FAIL: $*"; exit 1; } +pass() { echo "[$(stamp)] PASS: $*"; } + +# Extract docker_ip for a node from the topology file +node_ip() { + grep -A 5 "^ $1:" "$TOPO_FILE" \ + | grep -m1 'docker_ip:' \ + | sed 's/.*: *"*\([^"]*\)".*/\1/' +} + +# Extract npub for a node from the topology file +node_npub() { + grep -A 5 "^ $1:" "$TOPO_FILE" \ + | grep -m1 'npub:' \ + | sed 's/.*: *"*\([^"]*\)".*/\1/' +} + +# Extract configured peers list for a node from the topology file +node_peers() { + grep -A 5 "^ $1:" "$TOPO_FILE" \ + | grep -m1 'peers:' \ + | sed 's/.*\[\(.*\)\].*/\1/' \ + | tr -d ' ' \ + | tr ',' ' ' +} + +CAP_IP=$(node_ip "$CAP_NODE") +[ -n "$CAP_IP" ] || fail "could not resolve docker_ip for node-$CAP_NODE in $TOPO_FILE" +info "cap'd node: node-$CAP_NODE (ip $CAP_IP, max_peers=$MAX_PEERS)" + +# ── Phase 1: wait for convergence ──────────────────────────────────── +info "phase 1: wait for node-$CAP_NODE peer_count to reach $MAX_PEERS (90s timeout)" +deadline=$(($(date +%s) + 90)) +pc=0 +while [ "$(date +%s)" -lt "$deadline" ]; do + pc=$(docker exec fips-node-$CAP_NODE fipsctl show status 2>/dev/null \ + | grep -m1 peer_count | sed 's/.*: *//' | tr -d ',' || echo 0) + [ "$pc" = "$MAX_PEERS" ] && break + sleep 2 +done +[ "$pc" = "$MAX_PEERS" ] \ + || fail "node-$CAP_NODE peer_count=$pc after 90s, expected $MAX_PEERS" +info "node-$CAP_NODE converged: peer_count=$pc" + +# Identify admitted vs denied peers among configured peers +ADMITTED_NPUBS=$(docker exec fips-node-$CAP_NODE fipsctl show peers 2>/dev/null \ + | grep -oE 'npub1[a-z0-9]+' | sort -u || true) +DENIED="" +ADMITTED="" +for p in $(node_peers "$CAP_NODE"); do + npub=$(node_npub "$p") + if echo "$ADMITTED_NPUBS" | grep -q "$npub"; then + ADMITTED="$ADMITTED $p" + else + DENIED="$DENIED $p" + fi +done +ADMITTED=$(echo $ADMITTED | xargs) +DENIED=$(echo $DENIED | xargs) +info "admitted: ${ADMITTED:-}" +info "denied (sustained-retry): ${DENIED:-}" +[ -n "$DENIED" ] \ + || fail "no denied peers — test setup wrong (cap=$MAX_PEERS too high vs configured peers)" + +# ── Phase 2: capture wire traffic for CAPTURE_SECS seconds ─────────── +# Drives sustained load by restarting denied peer containers on a cadence +# during the capture window. Each restart resets the auto-reconnect +# exponential backoff (5s base / 300s cap), producing a fresh burst of +# Msg1s that exercises the silent-drop gate at meaningful rate. Without +# this loop the gate fires ~3-4 times per denied peer in a 60s window; +# with restarts every 15s we get ~30-50 firings across both denied peers. +info "phase 2: capture UDP/2121 on node-$CAP_NODE for ${CAPTURE_SECS}s, with denied-peer restart loop" +CAP_FILE=$(mktemp /tmp/admission-cap-pcap.XXXXXX.txt) +HELPER_IMAGE=$(docker inspect -f '{{.Config.Image}}' fips-node-$CAP_NODE 2>/dev/null) +[ -n "$HELPER_IMAGE" ] || fail "could not resolve helper image from fips-node-$CAP_NODE" + +# Background: cycle denied peers to reset their backoff and drive load. +( + elapsed=0 + while [ $elapsed -lt $((CAPTURE_SECS - 5)) ]; do + sleep 15 + elapsed=$((elapsed + 15)) + for n in $DENIED; do + docker restart "fips-node-$n" >/dev/null 2>&1 & + done + wait + info " [load-driver] restarted denied peers ($DENIED) at t+${elapsed}s" + done +) & +LOAD_PID=$! + +# Foreground: tcpdump capture for CAPTURE_SECS +docker run --rm --net=container:fips-node-$CAP_NODE \ + --cap-add NET_ADMIN --cap-add NET_RAW \ + --entrypoint sh "$HELPER_IMAGE" \ + -c "timeout $CAPTURE_SECS tcpdump -nn -i any 'udp port 2121' -l 2>&1 || true" \ + > "$CAP_FILE" 2>&1 + +# Reap load-driver if it's still running (should be ~done) +wait $LOAD_PID 2>/dev/null || true + +captured=$(wc -l < "$CAP_FILE") +info "captured $captured tcpdump lines → $CAP_FILE" + +# ── Phase 3: per-denied-peer wire-level assertion ──────────────────── +info "phase 3: per-denied-peer assertion (inbound Msg1 > 0, outbound Msg2 == 0)" +OVERALL=0 +TOTAL_MSG1_IN=0 +TOTAL_MSG2_OUT=0 +for n in $DENIED; do + n_ip=$(node_ip "$n") + # Inbound: src=n_ip:* → dst=cap_ip:2121; FMP-IK Msg1 wire size = 84 B + msg1_in=$(grep -cE "IP $n_ip\.[0-9]+ > $CAP_IP\.2121: UDP, length 84" "$CAP_FILE" || true) + # Outbound: src=cap_ip:2121 → dst=n_ip:*; FMP-IK Msg2 wire size = 104 B + msg2_out=$(grep -cE "IP $CAP_IP\.2121 > $n_ip\.[0-9]+: UDP, length 104" "$CAP_FILE" || true) + info " node-$n ($n_ip): inbound Msg1 (len 84) = $msg1_in, outbound Msg2 (len 104) = $msg2_out" + TOTAL_MSG1_IN=$((TOTAL_MSG1_IN + msg1_in)) + TOTAL_MSG2_OUT=$((TOTAL_MSG2_OUT + msg2_out)) + if [ "$msg1_in" -eq 0 ]; then + info " FAIL: expected inbound Msg1 retries from denied peer (peer not sustained-retrying?)" + OVERALL=1 + fi + if [ "$msg2_out" -gt 0 ]; then + info " FAIL: silent-drop gate leaked — expected 0 outbound Msg2, got $msg2_out" + OVERALL=1 + fi +done + +# ── Phase 4: cap'd node still at exactly max_peers ─────────────────── +pc_final=$(docker exec fips-node-$CAP_NODE fipsctl show status 2>/dev/null \ + | grep -m1 peer_count | sed 's/.*: *//' | tr -d ',' || echo 0) +info "node-$CAP_NODE final peer_count=$pc_final (expected $MAX_PEERS)" +[ "$pc_final" = "$MAX_PEERS" ] || OVERALL=1 + +if [ "$OVERALL" -eq 0 ]; then + pass "admission-cap: silent-drop gate verified at scale" + pass " denied peers: $(echo $DENIED | wc -w), capture: ${CAPTURE_SECS}s" + pass " total inbound Msg1 from denied: $TOTAL_MSG1_IN (sustained retries observed)" + pass " total outbound Msg2 to denied: $TOTAL_MSG2_OUT (silent-drop holds)" + rm -f "$CAP_FILE" + exit 0 +else + info "--- tcpdump capture tail (last 50 lines) ---" + tail -50 "$CAP_FILE" + fail "admission-cap: see failures above (capture preserved at $CAP_FILE)" +fi