From 364fed7c076d2017c90c9d045d4b903fe5fcb699 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sat, 26 Sep 2026 18:59:06 +0000 Subject: [PATCH] Probe the gateway's inbound forwards with no mapping and assert the rewrite The inbound port-forward probes ran while the DNS mapping to gw-server from Phase 4 was still live. Each mapping installs an SNAT rule matching only the mesh address, ahead of the LAN masquerade, so that rule took gw-server's inbound flows and the probes passed whether or not the LAN masquerade worked. The probes also passed on any response, so a flow rewritten by the LAN masquerade could not be told from one rewritten by a mapping's SNAT rule. Phase 7 now checks only the port-forward rules, and the probes move to a new Phase 8b, after Phase 8 has reclaimed the mapping. Phase 8b first checks that the control socket reports no mapping to gw-server and that the kernel's table holds no SNAT rule. If that gate fails, the probes are recorded as failed rather than skipped silently, so a reclamation failure cannot leave the forwards passing through the SNAT rule. After the probes, Phase 8b reads the gateway's conntrack table and checks, for each of the three forwards, that the reply goes to the gateway's LAN address. A mapping's SNAT sends it to a pool address instead, and no rewrite at all to gw-server's own mesh address, so each case reds with the address it found. The self-test's conntrack and proxy neighbour inputs are now taken verbatim from real gateway suite runs: a healthy run's table after the inbound probes, a mapping's SNAT entry, and a wrong-interface run's unreplied entries and neighbour entries. Inputs that need two situations at once join lines from different runs. --- testing/static/scripts/gateway-test.sh | 402 ++++++++++++++++++------- 1 file changed, 292 insertions(+), 110 deletions(-) diff --git a/testing/static/scripts/gateway-test.sh b/testing/static/scripts/gateway-test.sh index 18216fe0..c116821d 100755 --- a/testing/static/scripts/gateway-test.sh +++ b/testing/static/scripts/gateway-test.sh @@ -192,6 +192,71 @@ print(devs.pop()) ' "$@" } +# How many mappings have mesh_addr $1, from a show_mappings response. Fails +# on an error response or one without a mappings list, so a failed query +# cannot read as zero mappings. +server_mapped() { + python3 -c ' +import ipaddress, json, sys +want = ipaddress.ip_address(sys.argv[1]) +try: + r = json.load(sys.stdin) +except ValueError: + sys.exit(1) +if not isinstance(r, dict) or r.get("status") != "ok": + sys.exit(1) +data = r.get("data") +if not isinstance(data, dict) or not isinstance(data.get("mappings"), list): + sys.exit(1) +hits = 0 +for m in data["mappings"]: + try: + if ipaddress.ip_address(m["mesh_addr"]) == want: + hits += 1 + except (KeyError, TypeError, ValueError): + sys.exit(1) +print(hits) +' "$@" +} + +# The reply destination of the conntrack entries for PROTO $1 to port $2, from +# `conntrack -L -f ipv6`. Of each line's two tuples the first is the original +# direction and the second the reply, so the reply destination is the second +# dst=. It shows which rule rewrote the flow's source: the gateway's LAN +# address for the LAN masquerade, a pool address for a mapping's SNAT, or the +# sender's own address for no rewrite. Fails when no entry matches or the +# matching entries disagree. +reply_dst() { + python3 -c ' +import ipaddress, sys +proto, dport = sys.argv[1], sys.argv[2] +found = set() +for line in sys.stdin: + f = line.split() + if not f or f[0] != proto: + continue + dports = [t[6:] for t in f if t.startswith("dport=")] + dsts = [t[4:] for t in f if t.startswith("dst=")] + if not dports or dports[0] != dport: + continue + try: + found.add(ipaddress.ip_address(dsts[1])) + except (IndexError, ValueError): + sys.exit(1) +if len(found) != 1: + sys.exit(1) +print(found.pop()) +' "$@" +} + +# Succeeds when $1 and $2 are the same IPv6 address in any written form. +same_addr() { + python3 -c ' +import ipaddress, sys +sys.exit(0 if ipaddress.ip_address(sys.argv[1]) == ipaddress.ip_address(sys.argv[2]) else 1) +' "$@" +} + # ── Reader self-test ───────────────────────────────────────────────────── # Run one reader on a canned input and compare its status and output with @@ -210,9 +275,12 @@ gw_case() { return 1 } -# Feed every reader canned tool output and check its answers. The inputs -# follow each tool's printed format; the ip -o addr lines follow a capture, -# the others are written from the tools' documented formats. +# Feed every reader canned tool output and check its answers. The ip -o addr +# lines follow a capture. The ip -6 neigh show proxy and conntrack -L lines +# are verbatim output of those tools from gateway suite runs on 2026-09-23 +# and 2026-09-26; an input that needs two situations at once joins lines +# from different runs. The nft, show_gateway and show_mappings inputs are +# written from the documented formats. gw_selftest() { local fails=0 local addr_eth0 addr_eth1 addr_claimed addr_at nft_lan nft_nolan @@ -269,12 +337,56 @@ gw_selftest() { gw_case "masq_iface: no LAN masquerade" 1 "" "$nft_nolan" masq_iface || fails=$((fails + 1)) gw_case "masq_iface: empty input" 1 "" "" masq_iface || fails=$((fails + 1)) + # Captured lines: one run's entries were on eth0 and a wrong-interface + # run's on eth1. The mixed inputs join lines from the two captures, since + # no single run holds entries on both devices. + local nd_one0='fd01::1 dev eth0 proxy ' nd_one1='fd01::1 dev eth1 proxy ' + local nd_two1='fd01::2 dev eth1 proxy ' + gw_case "proxy_dev: captured entries on eth0" 0 eth0 \ + $'fd01::1 dev eth0 proxy \nfd01::2 dev eth0 proxy ' proxy_dev fd01::1 || fails=$((fails + 1)) gw_case "proxy_dev: entry on eth0 after another on eth1" 0 eth0 \ - $'fd01::2 dev eth1 proxy\nfd01::1 dev eth0 proxy' proxy_dev fd01::1 || fails=$((fails + 1)) - gw_case "proxy_dev: no entry" 1 "" 'fd01::2 dev eth1 proxy' proxy_dev fd01::1 || fails=$((fails + 1)) + "$nd_two1"$'\n'"$nd_one0" proxy_dev fd01::1 || fails=$((fails + 1)) + gw_case "proxy_dev: no entry" 1 "" "$nd_two1" proxy_dev fd01::1 || fails=$((fails + 1)) gw_case "proxy_dev: empty input" 1 "" "" proxy_dev fd01::1 || fails=$((fails + 1)) gw_case "proxy_dev: entry on two devices" 1 "" \ - $'fd01::1 dev eth0 proxy\nfd01::1 dev eth1 proxy' proxy_dev fd01::1 || fails=$((fails + 1)) + "$nd_one0"$'\n'"$nd_one1" proxy_dev fd01::1 || fails=$((fails + 1)) + + local maps_one + maps_one='{"status":"ok","data":{"mappings":[{"virtual_ip":"fd01::1","mesh_addr":"fd3c:9a51:7e02:4b18::2","node_addr":"0a1b2c3d4e5f60718293a4b5c6d7e8f9","dns_name":"npub1example.fips","state":"active","sessions":0,"age_secs":3,"last_ref_secs":3}]}}' + gw_case "server_mapped: one mapping to the server" 0 1 "$maps_one" \ + server_mapped fd3c:9a51:7e02:4b18:0:0:0:2 || fails=$((fails + 1)) + gw_case "server_mapped: a mapping to another node" 0 0 "$maps_one" \ + server_mapped fd3c:9a51:7e02:4b18::3 || fails=$((fails + 1)) + gw_case "server_mapped: no mappings" 0 0 '{"status":"ok","data":{"mappings":[]}}' \ + server_mapped fd3c:9a51:7e02:4b18::2 || fails=$((fails + 1)) + gw_case "server_mapped: error response" 1 "" '{"status":"error","message":"gateway not yet initialized"}' \ + server_mapped fd3c:9a51:7e02:4b18::2 || fails=$((fails + 1)) + gw_case "server_mapped: ok response without data" 1 "" '{"status":"ok"}' \ + server_mapped fd3c:9a51:7e02:4b18::2 || fails=$((fails + 1)) + gw_case "server_mapped: empty input" 1 "" "" server_mapped fd3c:9a51:7e02:4b18::2 || fails=$((fails + 1)) + + # Captured lines. ct_masq is one healthy run's whole table after the + # probes; ct_snat is a mapping's SNAT entry and ct_unreplied a + # wrong-interface run's entry, whose reply tuple is not rewritten. + # ct_other is one line of ct_masq, and ct_split joins the SNAT line with + # a masquerade line, since no single run holds both for one port. + local srv=fda3:bc52:6504:aa72:71ca:376a:9249:ef0c + local ct_masq ct_snat ct_unreplied ct_other ct_split ct_masq80 + ct_masq80='tcp 6 119 TIME_WAIT src=fda3:bc52:6504:aa72:71ca:376a:9249:ef0c dst=fd8d:4f49:3df7:6e1d:171e:c08d:f45f:97f3 sport=48192 dport=18080 src=fd02::20 dst=fd02::10 sport=8080 dport=48192 [ASSURED] mark=0 use=1' + ct_other='tcp 6 119 TIME_WAIT src=fda3:bc52:6504:aa72:71ca:376a:9249:ef0c dst=fd8d:4f49:3df7:6e1d:171e:c08d:f45f:97f3 sport=48486 dport=18082 src=fd02::20 dst=fd02::10 sport=8081 dport=48486 [ASSURED] mark=0 use=1' + ct_masq='udp 17 29 src=fda3:bc52:6504:aa72:71ca:376a:9249:ef0c dst=fd8d:4f49:3df7:6e1d:171e:c08d:f45f:97f3 sport=57965 dport=18081 src=fd02::20 dst=fd02::10 sport=8081 dport=57965 mark=0 use=1'$'\n'"$ct_masq80"$'\n'"$ct_other" + ct_snat='tcp 6 119 TIME_WAIT src=fda3:bc52:6504:aa72:71ca:376a:9249:ef0c dst=fd8d:4f49:3df7:6e1d:171e:c08d:f45f:97f3 sport=48130 dport=18080 src=fd02::20 dst=fd01::1 sport=8080 dport=48130 [ASSURED] mark=0 use=1' + ct_unreplied='udp 17 24 src=fda3:bc52:6504:aa72:71ca:376a:9249:ef0c dst=fd8d:4f49:3df7:6e1d:171e:c08d:f45f:97f3 sport=57286 dport=18081 [UNREPLIED] src=fd02:0:0:1::20 dst=fda3:bc52:6504:aa72:71ca:376a:9249:ef0c sport=8081 dport=57286 mark=0 use=1' + ct_split="$ct_snat"$'\n'"$ct_masq80" + gw_case "reply_dst: masquerade" 0 fd02::10 "$ct_masq" reply_dst tcp 18080 || fails=$((fails + 1)) + gw_case "reply_dst: mapping SNAT" 0 fd01::1 "$ct_snat" reply_dst tcp 18080 || fails=$((fails + 1)) + gw_case "reply_dst: udp, replied" 0 fd02::10 "$ct_masq" reply_dst udp 18081 || fails=$((fails + 1)) + gw_case "reply_dst: udp, unreplied, no rewrite" 0 "$srv" "$ct_unreplied" reply_dst udp 18081 || fails=$((fails + 1)) + gw_case "reply_dst: only another port's entry" 1 "" "$ct_other" reply_dst tcp 18080 || fails=$((fails + 1)) + gw_case "reply_dst: empty input" 1 "" "" reply_dst tcp 18080 || fails=$((fails + 1)) + gw_case "reply_dst: entries disagree" 1 "" "$ct_split" reply_dst tcp 18080 || fails=$((fails + 1)) + gw_case "same_addr: two forms of one address" 0 "" "" same_addr fd02:0:0:0::10 fd02::10 || fails=$((fails + 1)) + gw_case "same_addr: different addresses" 1 "" "" same_addr fd01::1 fd02::10 || fails=$((fails + 1)) echo " selftest: $fails case(s) failed" if [ "$fails" -eq 0 ]; then @@ -604,18 +716,21 @@ else check "Proxy NDP entry for '$VIRTUAL_IP' on the LAN interface '$LAN_IF' (none found once)" 1 fi -# Phase 7: Inbound port forwarding — UDP and a second simultaneous TCP forward. +# Phase 7: Inbound port-forward rules — UDP and a second simultaneous TCP +# forward. # -# Three forwards exercised: +# Three forwards configured: # 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(). +# Checks the DNAT rules and the LAN-side masquerade that set_port_forwards() +# installs. The traffic through them is Phase 8b's: while the Phase 4 +# mapping to gw-server is live, its SNAT rule matches gw-server's inbound +# flows before the LAN masquerade does, so probes sent here would pass +# without the masquerade. echo "" -echo "Phase 7: Inbound port forwards" +echo "Phase 7: Inbound port-forward rules" # Confirm all three port-forward DNAT rules are present on the gateway. # The distinctive listen ports identify our rules regardless of how nft @@ -648,104 +763,6 @@ else check "LAN masquerade on the LAN interface '$LAN_IF' (no single LAN masquerade rule)" 1 fi -# 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 /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 -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 - 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 -done - -# Derive the gateway's mesh IPv6 (fd00::/8 address assigned to fips0). -GW_MESH_IP=$(docker exec "$GATEWAY" bash -c \ - "ip -6 -o addr show fips0 | awk '/inet6 fd/ {print \$4}' | cut -d/ -f1 | head -1" \ - 2>/dev/null || echo "") - -if [ -z "$GW_MESH_IP" ]; then - check "Gateway fips0 IPv6 address" 1 -else - echo " Gateway mesh IPv6: $GW_MESH_IP" - - # 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 - # 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 → [${GW_CLIENT_LAN}]:8080" 0 - else - 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 → [${GW_CLIENT_LAN}]: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 → [${GW_CLIENT_LAN}]: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" @@ -784,6 +801,171 @@ else check "Mapping reclaimed (count: $MAPPING_COUNT)" 1 fi +# Phase 8b: Inbound port forwards through the LAN masquerade +# +# Mesh peer (gw-server) hits each gw-gateway fips0: rule, which DNATs +# into the LAN-side gw-client, and the LAN masquerade rewrites the source to +# the gateway's LAN address. Runs after Phase 8 has reclaimed the mapping to +# gw-server, because a live mapping's SNAT rule matches the same flows first +# and would do the rewrite instead. Runs before Phase 9 kills the daemon. +# +# The gate reads both the control socket's mappings, a snapshot refreshed +# on the pool tick, and the kernel's table, which is what decides the rule +# that matches. A zero SNAT count needs a successful listing; Phase 11 reads +# the same pattern expecting one rule per mapping. +echo "" +echo "Phase 8b: Inbound port forwards through the LAN masquerade" +SERVER_MESH=$(docker exec "$SERVER" bash -c \ + "ip -6 -o addr show fips0 | awk '/inet6 fd/ {print \$4}' | cut -d/ -f1 | head -1" \ + 2>/dev/null || echo "") +if [ -n "$SERVER_MESH" ] && SERVER_MAPS=$(docker exec "$GATEWAY" bash -c \ + 'echo "{\"command\":\"show_mappings\"}" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' \ + | server_mapped "$SERVER_MESH"); then + : +else + SERVER_MAPS=error +fi +GATE_NFT_RC=0 +GATE_NFT=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>&1) || GATE_NFT_RC=$? +GATE_SNAT=$(grep -cE "saddr [0-9a-f:]+ .*snat" <<< "$GATE_NFT" || true) +GATE_VALUES="server mesh '$SERVER_MESH', mappings to it $SERVER_MAPS, nft rc $GATE_NFT_RC, SNAT rules $GATE_SNAT" +if [ -n "$SERVER_MESH" ] && [ "$SERVER_MAPS" = "0" ] && [ "$GATE_NFT_RC" -eq 0 ] && [ "$GATE_SNAT" -eq 0 ]; then + check "No mapping or SNAT rule to $SERVER before the probes ($GATE_VALUES)" 0 + GATE_OK=true +else + check "No mapping or SNAT rule to $SERVER before the probes ($GATE_VALUES)" 1 + GATE_OK=false +fi + +if [ "$GATE_OK" = true ]; then + # 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 /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 + 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 + 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 + done + + # Derive the gateway's mesh IPv6 (fd00::/8 address assigned to fips0). + GW_MESH_IP=$(docker exec "$GATEWAY" bash -c \ + "ip -6 -o addr show fips0 | awk '/inet6 fd/ {print \$4}' | cut -d/ -f1 | head -1" \ + 2>/dev/null || echo "") + + if [ -z "$GW_MESH_IP" ]; then + check "Gateway fips0 IPv6 address" 1 + else + echo " Gateway mesh IPv6: $GW_MESH_IP" + + # 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 + # 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 → [${GW_CLIENT_LAN}]:8080" 0 + else + 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 → [${GW_CLIENT_LAN}]: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 → [${GW_CLIENT_LAN}]:8081 (6A)" 0 + else + check "Inbound UDP via forward 18081 (response: '${UDP_RESPONSE:0:80}')" 1 + fi + fi + + # A response shows only that some rule rewrote the flow. The reply + # destination in the gateway's conntrack entry shows which: the LAN + # masquerade sends the reply to the gateway's LAN address, a mapping's + # SNAT to a pool address, and no rewrite to gw-server's mesh address. + # conntrack lists the reply tuple of an unreplied entry too. + if CT_TABLE=$(docker exec "$GATEWAY" conntrack -L -f ipv6 2>/dev/null); then + CT_OK=true + else + CT_OK=false + CT_TABLE="" + fi + for fwd in tcp:18080 tcp:18082 udp:18081; do + fwd_proto="${fwd%%:*}" + fwd_port="${fwd#*:}" + found="" + if [ "$CT_OK" = true ] && found=$(reply_dst "$fwd_proto" "$fwd_port" <<< "$CT_TABLE") \ + && same_addr "$found" "$GW_DNS"; then + check "Reply to $fwd_proto $fwd_port goes to $found, the gateway's LAN address $GW_DNS" 0 + else + check "Reply to $fwd_proto $fwd_port goes to '$found' (conntrack read $CT_OK), expected the gateway's LAN address $GW_DNS" 1 + fi + done + + # Stop the LAN-side responders; no later phase uses them. + 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 +else + check "Inbound HTTP via TCP forward 18080 (skipped: gate)" 1 + check "Inbound HTTP via TCP forward 18082 (skipped: gate)" 1 + check "Inbound UDP via forward 18081 (skipped: gate)" 1 + check "Reply to tcp 18080 goes to the gateway's LAN address (skipped: gate)" 1 + check "Reply to tcp 18082 goes to the gateway's LAN address (skipped: gate)" 1 + check "Reply to udp 18081 goes to the gateway's LAN address (skipped: gate)" 1 +fi + # Phase 9: SERVFAIL when daemon DNS is down echo "" echo "Phase 9: SERVFAIL when daemon DNS is down"