mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-06 19:43:10 +00:00
Merge master into next (inbound admission cap silent-drop; XX-adapted gate at handle_msg3)
Carries the inbound max_peers admission-cap silent-drop work from
master across the IK→XX structural boundary.
On master (Noise IK), the gate fires at handle_msg1 after the peer's
identity is extracted from the IK msg1 payload, before Msg2 is built
and sent. Wire savings of Msg2 (~104 B) plus the responder crypto
per cap-denied attempt motivate the gate placement.
On next (Noise XX), peer identity is not learned until msg3, by
which point Msg1, Msg2, and Msg3 have all crossed the wire. The IK
gate's wire-savings rationale does not apply on XX. The equivalent
gate is placed in handle_msg3 after the peer's static-key + signature
verification (identity now known) and before promote_connection
(ActivePeer construction, peers_by_index insert, link transition).
The value on XX is local CPU / allocation savings and cleaner peer-
side semantics: no fake-promotion whose subsequent data frames fail
decryption on this side, replaced by a silent drop the peer's
existing auto-reconnect backoff handles cleanly.
Bypass logic is the same as the IK version: known-active peers
(reconnect / cross-connection / restart) and pending-outbound peers
skip the cap so admitting them doesn't grow peers.len(). The late
cap check inside promote_connection is intentionally retained as
defense-in-depth.
Cleanup ordering for the gate's silent-drop path corrects a latent
issue in the adjacent msg3-processing-failure path: our_index is
captured before the connection is removed so the allocator slot is
actually freed (the pre-existing path's get-after-remove returns
None, leaking the index).
The two IK-shaped unit tests at handle_msg1 are marked #[ignore] on
this branch with reason. Their wire-bytes discriminator ("Msg2 must
NOT be sent at cap") is structurally false on XX, and the XX-shaped
equivalents need full three-message-handshake test scaffolding that
isn't authored here. XX-adapted unit + integration test coverage for
the handle_msg3 gate is a follow-up.
CHANGELOG [Unreleased] entry adjusted to describe the handle_msg3
placement on this branch (preserving the master-side framing on the
mainline-merge half of the entry would have been misleading on this
branch).
Three conflicts auto-resolved: the structural handshake.rs span
(~253 lines, IK identity-tied logic discarded on this branch's XX
msg1 path), and three textual ci-local.sh conflicts (admission-cap
suite list / dispatch added alongside the existing mixed-profile
suite list / dispatch).
This commit is contained in:
@@ -248,6 +248,20 @@ with v0.3.x peers.
|
||||
`handshake.rs:1114` is unchanged. Introduces a shared
|
||||
`Node::outbound_admission_check()` helper so the invariant is
|
||||
grep-able and unit-testable.
|
||||
- Inbound `handle_msg3` now silent-drops at `node.limits.max_peers`
|
||||
saturation *before* promotion, instead of replying with the rest
|
||||
of the handshake-completion path and then rejecting at
|
||||
`promote_connection`. Adds an early cap check positioned after the
|
||||
peer's static-key + signature verification (so identity is known
|
||||
and the reconnect / cross-connection bypass for known peers fires)
|
||||
and before `ActivePeer` construction. The late cap check inside
|
||||
`promote_connection` is intentionally retained as defense-in-depth.
|
||||
On this branch's XX handshake there is no wire-bytes saving (Msg1,
|
||||
Msg2, Msg3 have all crossed the wire by the time identity is known);
|
||||
the value is local CPU / allocation savings (no `ActivePeer` build,
|
||||
no `peers_by_index` insert, no link transition) and cleaner peer-
|
||||
side semantics — no fake-promotion 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
|
||||
|
||||
@@ -194,7 +194,14 @@ impl Node {
|
||||
};
|
||||
|
||||
// XX: identity is NOT learned from msg1 (only ephemeral exchange).
|
||||
// Identity will be learned from msg3 in handle_msg3.
|
||||
// Identity will be learned from msg3 in handle_msg3. The IK-protocol
|
||||
// version of this branch (on the maint+master lineage) carries the
|
||||
// post-identity restart-detection, rekey dual-init handling, ACL
|
||||
// check, and max_peers cap check here — none of which have an
|
||||
// equivalent placement at XX msg1 because peer identity is still
|
||||
// unknown at this point. The XX-equivalent admission gate is placed
|
||||
// in handle_msg3 after the peer's static key + signature have been
|
||||
// verified, before promote_connection is called.
|
||||
|
||||
// Allocate our session index
|
||||
let our_index = match self.index_allocator.allocate() {
|
||||
@@ -904,6 +911,50 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early cap check: at max_peers and this is a net-new identity?
|
||||
//
|
||||
// On IK (maint/master) the equivalent gate fires at handle_msg1
|
||||
// before Msg2 is built, saving the wire bytes of Msg2 and the
|
||||
// responder crypto. On XX (here) identity isn't known until
|
||||
// msg3 has been received, by which point Msg1+Msg2+Msg3 have
|
||||
// all crossed the wire — so this gate saves no wire bytes. Its
|
||||
// value is local CPU + allocation savings (no ActivePeer
|
||||
// build, no peers_by_index insert, no link transition) and
|
||||
// cleaner peer-side semantics (no fake-promotion-then-tear-
|
||||
// down).
|
||||
//
|
||||
// Bypass for known peers (reconnect / cross-connection) —
|
||||
// admitting them doesn't grow peers.len(). 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 Msg3 at max_peers cap (early gate; no promotion)"
|
||||
);
|
||||
// Capture our_index before removing the connection so
|
||||
// we can return the allocator slot — mirrors the
|
||||
// failure-cleanup intent of the msg3-processing-
|
||||
// failure path above, with the get-then-remove
|
||||
// ordering corrected.
|
||||
let our_idx_to_free = self.connections.get(&link_id).and_then(|c| c.our_index());
|
||||
self.connections.remove(&link_id);
|
||||
self.remove_link(&link_id);
|
||||
if let Some(idx) = our_idx_to_free {
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let our_index = our_index.unwrap_or(header.receiver_idx);
|
||||
|
||||
// Identity-based restart/rekey detection.
|
||||
|
||||
@@ -1529,3 +1529,223 @@ 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(())
|
||||
}
|
||||
|
||||
// Two IK-protocol-specific unit tests for the inbound-msg1 cap-check
|
||||
// landed on maint+master with the IK-side admission-gate fix. They
|
||||
// don't apply on this branch's XX handshake: the equivalent gate
|
||||
// here lives in handle_msg3 (peer identity isn't known until then),
|
||||
// and the wire-bytes discriminator that the IK tests rely on
|
||||
// ("Msg2 must NOT be sent at cap") is structurally false on XX
|
||||
// because Msg2 is the responder's ephemeral exchange and is sent
|
||||
// unconditionally before identity is known.
|
||||
//
|
||||
// XX-equivalent tests (handle_msg3_silent_drops_at_cap_for_new_peer
|
||||
// and handle_msg3_admits_existing_peer_at_cap) need a full
|
||||
// XX three-message handshake pump rather than the single-Msg1 pump
|
||||
// the IK tests use, plus an assertion shape based on promotion-or-not
|
||||
// rather than wire-bytes presence. Authoring deferred to follow-up
|
||||
// alongside the rest of the XX-adapted gate hardening work.
|
||||
#[tokio::test]
|
||||
#[ignore = "IK-specific assertion shape; XX equivalent at handle_msg3 deferred"]
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// IK-specific bypass-admit regression check; on this branch's XX
|
||||
/// handshake the equivalent semantic lives in handle_msg3 (identity
|
||||
/// not known until then). Ignored alongside its sibling above;
|
||||
/// XX-shaped equivalent deferred to follow-up.
|
||||
#[tokio::test]
|
||||
#[ignore = "IK-specific assertion shape; XX equivalent at handle_msg3 deferred"]
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
@@ -345,6 +349,34 @@ run_mixed_profile() {
|
||||
record "mixed-profile" $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"
|
||||
@@ -596,6 +628,11 @@ run_integration() {
|
||||
# Mixed-profile (Full + NonRouting + Leaf)
|
||||
run_mixed_profile
|
||||
|
||||
# Admission cap (mesh profile, max_peers=1 on one node)
|
||||
for _suite in "${ADMISSION_SUITES[@]}"; do
|
||||
run_admission_cap
|
||||
done
|
||||
|
||||
# Gateway
|
||||
run_gateway
|
||||
|
||||
@@ -700,6 +737,8 @@ run_suite() {
|
||||
run_rekey_outbound_only ;;
|
||||
mixed-profile)
|
||||
run_mixed_profile ;;
|
||||
admission-cap)
|
||||
run_admission_cap ;;
|
||||
gateway)
|
||||
run_gateway ;;
|
||||
acl-allowlist)
|
||||
|
||||
Executable
+205
@@ -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:-<none>}"
|
||||
info "denied (sustained-retry): ${DENIED:-<none>}"
|
||||
[ -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
|
||||
Reference in New Issue
Block a user