Files
fips/testing/nat/scripts/nat-test.sh
T
Johnathan Corgan 965de26239 testing: make ci-local.sh preemption-safe with per-run docker isolation
A CI worker may preempt an in-flight ci-local.sh run (SIGTERM, then SIGKILL
after a grace period) to restart on a newer commit. For that kill to be safe,
the script must clean up after itself and never let a dying run collide with
its restart. It previously had no signal handling, shared the default compose
project name across runs, and tore down each suite only at the suite end.

- Derive a per-run id (honoring FIPS_CI_RUN_ID, else short-sha+random) and
  namespace every docker resource to it: a fipsci_<run>_<suite> compose project
  per suite and per parallel chaos child, and per-run image tags
  (fips-test:<run>, fips-test-app:<run>) retagged to :latest only after both
  builds succeed so :latest never points at a half-built image.
- Install a bounded, idempotent teardown trap on SIGTERM/SIGINT (+ EXIT): reap
  parallel chaos children, then force-remove this run's docker resources via
  the new ci-cleanup.sh, wrapped in timeout so a stuck down cannot wedge it.
- Exit 143 (SIGTERM) / 130 (SIGINT), distinct from 0 (pass) / 1 (failed), so a
  preempting worker tells a cancelled run from a real failure.
- Add ci-cleanup.sh (also ci-local.sh --reap): force-removes leftover CI
  resources by the com.corganlabs.fips-ci=1 label and the fipsci_ project
  prefix, robust to however a prior run died.
- Label every per-suite docker resource so the label sweep reaps it after a
  SIGKILL regardless of network name: direct docker run/network resources, the
  sidecar compose services, and every per-suite compose network (acl-allowlist,
  boringtun, firewall, nat, static, both tor suites, and the chaos generator
  template). Parametrize the static/sidecar compose image refs so the per-run
  tags are honored.
- Give each parallel chaos child a unique /24 from 10.30.x (a new --subnet
  override on the sim CLI, assigned per-child in ci-local.sh) so parallel
  children never collide on a shared docker subnet, and a chaos net can never
  span a fixed-subnet suite (sidecar/static in 172.20.x). 10.30.x sits outside
  docker's default-address-pool range, so an auto-assigned net cannot land on
  it either; node IPs derive from the subnet, so no scenario config changes.
2026-06-29 14:23:46 +00:00

441 lines
14 KiB
Bash
Executable File

#!/bin/bash
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"
TOPOLOGY_SCRIPT="$SCRIPT_DIR/setup-topology.sh"
WAIT_LIB="$ROOT_DIR/testing/lib/wait-converge.sh"
SCENARIO="${1:-all}"
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
# Optional extra compose-file overlay chain (colon-separated paths), e.g.
# trace-RUST_LOG overrides supplied by the mesh-lab harness when
# FIPS_MESH_LAB_TRACE=1. Paths are interpreted relative to the repo root
# (ROOT_DIR) unless absolute. The mesh-lab harness sets this env var to
# `testing/mesh-lab/compose-trace-nat.yml` for nat-lan trace runs.
if [ -n "${FIPS_NAT_EXTRA_COMPOSE:-}" ]; then
IFS=':' read -ra _NAT_EXTRA <<< "${FIPS_NAT_EXTRA_COMPOSE}"
for _f in "${_NAT_EXTRA[@]}"; do
case "$_f" in
/*) COMPOSE+=(-f "$_f") ;;
*) COMPOSE+=(-f "$ROOT_DIR/$_f") ;;
esac
done
fi
source "$WAIT_LIB"
cleanup() {
"${COMPOSE[@]}" --profile cone --profile symmetric --profile lan \
down -v --remove-orphans >/dev/null 2>&1 || true
}
helper_tcpdump_image() {
docker inspect -f '{{.Config.Image}}' fips-nat-router-a 2>/dev/null || echo nat-nat-a
}
dump_container_state() {
local container="$1"
echo ""
echo "--- $container: logs (last 80) ---"
docker logs "$container" 2>&1 | tail -80 || true
}
send_stun_probe() {
local container="$1"
local stun_host="$2"
local stun_port="$3"
docker exec "$container" python3 - "$stun_host" "$stun_port" <<'PY' 2>&1 || true
import os
import socket
import struct
import sys
host = sys.argv[1]
port = int(sys.argv[2])
txn_id = os.urandom(12)
request = struct.pack("!HHI", 0x0001, 0, 0x2112A442) + txn_id
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(2.0)
sock.sendto(request, (host, port))
try:
data, remote = sock.recvfrom(2048)
except socket.timeout:
print(f"stun timeout waiting for {host}:{port}")
raise SystemExit(1)
if len(data) < 20:
print(f"short stun response from {remote}: {len(data)} bytes")
raise SystemExit(1)
msg_type, msg_len, cookie = struct.unpack("!HHI", data[:8])
if msg_type != 0x0101 or cookie != 0x2112A442 or data[8:20] != txn_id:
print(f"unexpected stun response from {remote}: type=0x{msg_type:04x} len={msg_len} cookie=0x{cookie:08x}")
raise SystemExit(1)
print(f"stun binding success from {remote[0]}:{remote[1]}")
PY
}
dump_fips_state() {
local container="$1"
local relay_host="${2:-172.31.254.30}"
local relay_port="${3:-7777}"
local stun_host="${4:-172.31.254.40}"
local stun_port="${5:-3478}"
dump_container_state "$container"
echo ""
echo "--- $container: UDP sockets ---"
docker exec "$container" sh -lc 'ss -H -uanp 2>/dev/null || ss -H -uan 2>/dev/null || netstat -anu 2>/dev/null' 2>&1 || true
echo ""
echo "--- $container: fipsctl show status ---"
docker exec "$container" fipsctl show status 2>&1 || true
echo ""
echo "--- $container: fipsctl show peers ---"
docker exec "$container" fipsctl show peers 2>&1 || true
echo ""
echo "--- $container: fipsctl show links ---"
docker exec "$container" fipsctl show links 2>&1 || true
echo ""
echo "--- $container: relay reachability ---"
docker exec "$container" sh -lc "nc -vz -w5 ${relay_host} ${relay_port}" 2>&1 || true
echo ""
echo "--- $container: stun reachability ---"
send_stun_probe "$container" "$stun_host" "$stun_port"
}
dump_node_udp_probe() {
local node="$1"
local stun_host="${2:-172.31.254.40}"
local stun_port="${3:-3478}"
echo ""
echo "--- $node: UDP sockets (pre-capture) ---"
docker exec "$node" sh -lc 'ss -H -uanp 2>/dev/null || ss -H -uan 2>/dev/null || netstat -anu 2>/dev/null' 2>&1 || true
echo ""
echo "--- $node: UDP routes to STUN and peer WANs ---"
docker exec "$node" sh -lc 'for ip in 172.31.254.40 172.31.254.10 172.31.254.11; do ip route get "$ip"; done' 2>&1 || true
local capture_file
capture_file="$(mktemp)"
docker exec "$node" sh -lc "timeout 8 tcpdump -ni eth0 'udp and not port 53' -c 80" \
>"$capture_file" 2>&1 &
local tcpdump_pid=$!
sleep 1
echo ""
echo "--- $node: UDP active probe ---"
echo "probe: ${node} -> ${stun_host}:${stun_port}/udp (STUN binding request)"
send_stun_probe "$node" "$stun_host" "$stun_port"
wait "$tcpdump_pid" || true
echo ""
echo "--- $node: UDP tcpdump during active probe ---"
cat "$capture_file"
rm -f "$capture_file"
echo ""
echo "--- $node: UDP sockets (post-capture) ---"
docker exec "$node" sh -lc 'ss -H -uanp 2>/dev/null || ss -H -uan 2>/dev/null || netstat -anu 2>/dev/null' 2>&1 || true
}
dump_router_udp_probe() {
local router="$1"
local source_node="$2"
local stun_host="${3:-172.31.254.40}"
local stun_port="${4:-3478}"
echo ""
echo "--- $router: UDP conntrack/state (before probe) ---"
docker exec "$router" sh -lc 'conntrack -L -p udp 2>/dev/null || echo "conntrack unavailable"' 2>&1 || true
echo ""
echo "--- $router: UDP counters (before probe) ---"
docker exec "$router" sh -lc 'iptables -vnL FORWARD; echo; iptables -t nat -vnL POSTROUTING' 2>&1 || true
echo ""
echo "--- $router: UDP routes to STUN and peer WANs ---"
docker exec "$router" sh -lc 'for ip in 172.31.254.40 172.31.254.10 172.31.254.11; do ip route get "$ip"; done' 2>&1 || true
local capture_file
capture_file="$(mktemp)"
docker exec "$router" sh -lc "timeout 8 tcpdump -ni any 'udp and not port 53' -c 80" \
>"$capture_file" 2>&1 &
local tcpdump_pid=$!
sleep 1
echo ""
echo "--- $router: UDP active probe ---"
echo "probe: ${source_node} -> ${stun_host}:${stun_port}/udp (STUN binding request)"
send_stun_probe "$source_node" "$stun_host" "$stun_port"
wait "$tcpdump_pid" || true
echo ""
echo "--- $router: UDP tcpdump during active probe ---"
cat "$capture_file"
rm -f "$capture_file"
echo ""
echo "--- $router: UDP counters (after probe) ---"
docker exec "$router" sh -lc 'iptables -vnL FORWARD; echo; iptables -t nat -vnL POSTROUTING' 2>&1 || true
echo ""
echo "--- $router: UDP conntrack/state (after probe) ---"
docker exec "$router" sh -lc 'conntrack -L -p udp 2>/dev/null || echo "conntrack unavailable"' 2>&1 || true
}
dump_stun_udp_probe() {
local source_node="$1"
local stun_host="${2:-172.31.254.40}"
local stun_port="${3:-3478}"
local helper_image
helper_image="$(helper_tcpdump_image)"
local capture_file
capture_file="$(mktemp)"
docker run --rm --label com.corganlabs.fips-ci=1 --net=container:fips-nat-stun --cap-add NET_ADMIN --cap-add NET_RAW \
--entrypoint sh "$helper_image" \
-lc "timeout 8 tcpdump -ni any 'udp and not port 53' -c 80" \
>"$capture_file" 2>&1 &
local tcpdump_pid=$!
sleep 1
echo ""
echo "--- fips-nat-stun: UDP active probe ---"
echo "probe: ${source_node} -> ${stun_host}:${stun_port}/udp (STUN binding request)"
send_stun_probe "$source_node" "$stun_host" "$stun_port"
wait "$tcpdump_pid" || true
echo ""
echo "--- fips-nat-stun: UDP tcpdump during active probe ---"
cat "$capture_file"
rm -f "$capture_file"
}
dump_cone_diagnostics() {
echo ""
echo "=== cone diagnostics ==="
dump_fips_state fips-nat-cone-a 172.31.254.30 7777 172.31.254.40 3478
dump_node_udp_probe fips-nat-cone-a
dump_fips_state fips-nat-cone-b 172.31.254.30 7777 172.31.254.40 3478
dump_node_udp_probe fips-nat-cone-b
dump_container_state fips-nat-router-a
dump_router_udp_probe fips-nat-router-a fips-nat-cone-a
dump_container_state fips-nat-router-b
dump_router_udp_probe fips-nat-router-b fips-nat-cone-b
dump_container_state fips-nat-relay
dump_stun_udp_probe fips-nat-cone-a
dump_stun_udp_probe fips-nat-cone-b
dump_container_state fips-nat-stun
}
dump_symmetric_diagnostics() {
echo ""
echo "=== symmetric diagnostics ==="
dump_fips_state fips-nat-symmetric-a 172.31.254.30 7777 172.31.254.40 3478
dump_fips_state fips-nat-symmetric-b 172.31.254.30 7777 172.31.254.40 3478
dump_container_state fips-nat-router-a
dump_container_state fips-nat-router-b
dump_container_state fips-nat-relay
dump_container_state fips-nat-stun
}
dump_lan_diagnostics() {
echo ""
echo "=== lan diagnostics ==="
dump_fips_state fips-nat-lan-a 172.31.10.30 7777 172.31.10.40 3478
dump_fips_state fips-nat-lan-b 172.31.10.30 7777 172.31.10.40 3478
dump_container_state fips-nat-relay
dump_container_state fips-nat-stun
}
trap 'echo ""; echo "NAT test interrupted"; cleanup; exit 130' INT TERM
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
}
require_docker_daemon() {
if ! docker info >/dev/null 2>&1; then
echo "Docker daemon is not reachable; cannot run NAT lab harness" >&2
exit 1
fi
}
assert_peer_path() {
local container="$1"
local expected_transport="$2"
local expected_prefix="$3"
docker exec "$container" fipsctl show peers \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
peers = [p for p in data.get('peers', []) if p.get('connectivity') == 'connected']
if not peers:
raise SystemExit(1)
peer = peers[0]
transport = peer.get('transport_type', '')
addr = peer.get('transport_addr', '')
if transport != sys.argv[1]:
raise SystemExit(f'transport mismatch: expected {sys.argv[1]!r}, got {transport!r}')
if not addr.startswith(sys.argv[2]):
raise SystemExit(f'addr mismatch: expected prefix {sys.argv[2]!r}, got {addr!r}')
" "$expected_transport" "$expected_prefix"
}
assert_link_path() {
local container="$1"
local expected_prefix="$2"
docker exec "$container" fipsctl show links \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
links = data.get('links', [])
if not links:
raise SystemExit(1)
addr = links[0].get('remote_addr', '')
if not addr.startswith(sys.argv[1]):
raise SystemExit(f'link addr mismatch: expected prefix {sys.argv[1]!r}, got {addr!r}')
" "$expected_prefix"
}
require_bootstrap_activity() {
local container="$1"
local logs
logs="$(docker logs "$container" 2>&1 || true)"
if ! grep -Eq "bootstrap failed|Started Nostr( UDP)? NAT traversal attempt" <<<"$logs"; then
echo "Expected bootstrap activity in ${container} logs" >&2
return 1
fi
}
ping_peer() {
local container="$1"
local npub="$2"
docker exec "$container" ping6 -c 3 -W 5 "${npub}.fips" >/dev/null
}
run_cone() {
echo "=== NAT lab: cone ==="
cleanup
"$GENERATE_SCRIPT" cone
"${COMPOSE[@]}" --profile cone up -d --build --force-recreate
"$TOPOLOGY_SCRIPT" cone
wait_for_peers fips-nat-cone-a 1 45 || {
dump_cone_diagnostics
return 1
}
wait_for_peers fips-nat-cone-b 1 45 || {
dump_cone_diagnostics
return 1
}
assert_peer_path fips-nat-cone-a udp 172.31.254.
assert_peer_path fips-nat-cone-b udp 172.31.254.
assert_link_path fips-nat-cone-a 172.31.254.
assert_link_path fips-nat-cone-b 172.31.254.
# shellcheck disable=SC1090
source "$NAT_DIR/generated-configs/cone/npubs.env"
ping_peer fips-nat-cone-a "$NPUB_B"
ping_peer fips-nat-cone-b "$NPUB_A"
cleanup
}
run_symmetric() {
echo "=== NAT lab: symmetric fallback ==="
cleanup
NAT_MODE_A=symmetric NAT_MODE_B=symmetric "$GENERATE_SCRIPT" symmetric
NAT_MODE_A=symmetric NAT_MODE_B=symmetric "${COMPOSE[@]}" --profile symmetric up -d --build --force-recreate
"$TOPOLOGY_SCRIPT" symmetric
wait_for_peers fips-nat-symmetric-a 1 60 || {
dump_symmetric_diagnostics
return 1
}
wait_for_peers fips-nat-symmetric-b 1 60 || {
dump_symmetric_diagnostics
return 1
}
assert_peer_path fips-nat-symmetric-a tcp 172.31.254.11:
assert_peer_path fips-nat-symmetric-b tcp 172.31.254.10:
assert_link_path fips-nat-symmetric-a 172.31.254.11:
assert_link_path fips-nat-symmetric-b 172.31.254.10:
require_bootstrap_activity fips-nat-symmetric-a
require_bootstrap_activity fips-nat-symmetric-b
# shellcheck disable=SC1090
source "$NAT_DIR/generated-configs/symmetric/npubs.env"
ping_peer fips-nat-symmetric-a "$NPUB_B"
ping_peer fips-nat-symmetric-b "$NPUB_A"
cleanup
}
run_lan() {
echo "=== NAT lab: lan preference ==="
cleanup
"$GENERATE_SCRIPT" lan
"${COMPOSE[@]}" --profile lan up -d --build --force-recreate
wait_for_peers fips-nat-lan-a 1 45 || {
dump_lan_diagnostics
return 1
}
wait_for_peers fips-nat-lan-b 1 45 || {
dump_lan_diagnostics
return 1
}
assert_peer_path fips-nat-lan-a udp 172.31.10.
assert_peer_path fips-nat-lan-b udp 172.31.10.
assert_link_path fips-nat-lan-a 172.31.10.
assert_link_path fips-nat-lan-b 172.31.10.
# shellcheck disable=SC1090
source "$NAT_DIR/generated-configs/lan/npubs.env"
ping_peer fips-nat-lan-a "$NPUB_B"
ping_peer fips-nat-lan-b "$NPUB_A"
# Skip the final teardown when the mesh-lab harness wraps this
# script: it needs to docker-logs the containers before teardown,
# and will run its own cleanup after capture. Failure paths above
# already leave containers up via the bare `return 1` so the
# harness can capture stall-state evidence.
if [ -z "${FIPS_NAT_SKIP_FINAL_CLEANUP:-}" ]; then
cleanup
fi
}
main() {
require_docker_daemon
require_test_image
case "$SCENARIO" in
all)
run_cone
run_symmetric
run_lan
;;
cone)
run_cone
;;
symmetric)
run_symmetric
;;
lan)
run_lan
;;
*)
echo "Usage: $0 [all|cone|symmetric|lan]" >&2
exit 1
;;
esac
echo "NAT lab scenarios passed"
}
main "$@"