From b8b1bb03a01305d9ca342ce11cb6046e8e99139f Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 3 May 2026 18:17:39 +0000 Subject: [PATCH] Cover UDP forward, multi-forward, and multi-client paths in gateway-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the gateway integration suite with three previously unexercised runtime paths. All three share testing/static/scripts/gateway-test.sh and testing/static/docker-compose.yml so they land as one commit. 6A — UDP port forwarding runtime path. Add udp 18081 -> [fd02::20]:8081 to inject_gateway_config() and a phase-7 case where gw-client runs an inline Python UDP echo server bound [::]:8081 and gw-server sends a UDP probe to [GW_MESH_IP]:18081 via inline python3, asserting the echoed payload prefix. The config layer already accepted proto: udp (test_port_forwards_same_port_different_proto_ok) but the UDP NAT rule shape and conntrack handling differ from TCP and were unverified. Uses Python rather than socat because fips-test:latest does not ship socat; nc -u IPv6 round-trip semantics are messier than a Python one-liner. 6B — Second simultaneous TCP forward. Add tcp 18082 -> [fd02::20]:8081 alongside the existing 18080 forward. Phase 7 now greps the daemon's nft DNAT table for all three rules (18080, 18082, 18081) and runs HTTP fetches through both TCP forwards with distinct backend payloads (inbound-forward-ok vs inbound-forward-ok-2) so a misrouted response fails the assertion. 11A — Concurrent multi-client flows. Add gw-client-2 service to docker-compose mirroring gw-client (IPv6 fd02::21, IPv4 172.20.1.21). Phase 3 sets the fd01::/112 route on both. Phase 4 issues DNS lookups from both, asserts they receive distinct virtual IPs, and queries the gateway control socket (show_mappings) to confirm exactly 2 active mappings (5-attempt retry loop tolerates snapshot-publish lag). Phase 5 launches both curl requests concurrently as background processes, asserts each response. Validates concurrent NAT mappings, pool contention, proxy NDP under simultaneous LAN-client traffic — all real-world deployment shape that was not pinned. Phase 8 reclamation timing unchanged (TTL=5s, grace=5s, 25s wait covers both mapping ticks generously even with a slight stagger). --- .../static/configs/topologies/gateway.yaml | 16 +- testing/static/docker-compose.yml | 37 +++ testing/static/scripts/gateway-test.sh | 239 +++++++++++++++--- 3 files changed, 257 insertions(+), 35 deletions(-) diff --git a/testing/static/configs/topologies/gateway.yaml b/testing/static/configs/topologies/gateway.yaml index 7a1c68e..b94e26b 100644 --- a/testing/static/configs/topologies/gateway.yaml +++ b/testing/static/configs/topologies/gateway.yaml @@ -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] diff --git a/testing/static/docker-compose.yml b/testing/static/docker-compose.yml index 85da0cf..fbc40c0 100644 --- a/testing/static/docker-compose.yml +++ b/testing/static/docker-compose.yml @@ -512,6 +512,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"] @@ -530,3 +545,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 diff --git a/testing/static/scripts/gateway-test.sh b/testing/static/scripts/gateway-test.sh index afd8e21..c8ec53e 100755 --- a/testing/static/scripts/gateway-test.sh +++ b/testing/static/scripts/gateway-test.sh @@ -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: 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