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
+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