mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 08:14:42 +00:00
Merge branch 'master' into next
# Conflicts: # src/node/tests/discovery.rs # testing/ci-local.sh
This commit is contained in:
+84
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
generated-configs
|
||||
@@ -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).
|
||||
@@ -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
|
||||
Executable
+111
@@ -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"
|
||||
Executable
+247
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+372
@@ -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 "$@"
|
||||
Executable
+333
@@ -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 "$@"
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user