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.
This commit is contained in:
Johnathan Corgan
2026-09-26 18:59:43 +00:00
parent 65b92ed777
commit 364fed7c07
+232 -50
View File
@@ -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:<port> 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,6 +763,81 @@ else
check "LAN masquerade on the LAN interface '$LAN_IF' (no single LAN masquerade rule)" 1
fi
# Phase 8: TTL expiration and pool reclamation
echo ""
echo "Phase 8: TTL expiration and pool reclamation"
# Flush conntrack so stale sessions from Phase 5 don't keep the mapping alive.
docker exec "$GATEWAY" conntrack -F 2>/dev/null || true
# Config uses ttl=5, pool_grace_period=5. Pool tick interval is 10s, so:
# tick 1 (~10s): TTL expired → Draining (sessions=0 after flush)
# tick 2 (~20s): grace expired → freed
# Wait 25s to ensure two full tick cycles have passed.
echo " Waiting 25s for TTL + grace period to expire (two tick cycles)..."
sleep 25
# Query gateway control socket for mapping count.
#
# The expected value here is zero, so the reader must not be able to
# produce a zero from a failed query: an error response carries no `data`
# field, and `r.get('data',{}).get('mappings',[])` would report that as
# zero mappings and pass this check without the gateway having answered.
# The same hazard is documented at the show_mappings poll above, which is
# safe only because it waits for a positive "2". Require the key to exist
# and exit non-zero if it does not, so the `|| echo "error"` fallback
# fires and the check reds.
MAPPING_COUNT=$(docker exec "$GATEWAY" bash -c \
'echo "{\"command\":\"show_mappings\"}" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' \
| python3 -c "
import sys, json
r = json.load(sys.stdin)
data = r.get('data')
if not isinstance(data, dict) or not isinstance(data.get('mappings'), list):
sys.exit(1)
print(len(data['mappings']))
" 2>/dev/null || echo "error")
if [ "$MAPPING_COUNT" = "0" ]; then
check "Mapping reclaimed after TTL+grace" 0
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:<port> 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)
@@ -738,50 +928,42 @@ except Exception as e:
fi
fi
# Cleanup: stop the LAN-side responders so Phase 8's pool-reclamation
# wait isn't interfered with by lingering sessions.
# 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
# Phase 8: TTL expiration and pool reclamation
echo ""
echo "Phase 8: TTL expiration and pool reclamation"
# Flush conntrack so stale sessions from Phase 5 don't keep the mapping alive.
docker exec "$GATEWAY" conntrack -F 2>/dev/null || true
# Config uses ttl=5, pool_grace_period=5. Pool tick interval is 10s, so:
# tick 1 (~10s): TTL expired → Draining (sessions=0 after flush)
# tick 2 (~20s): grace expired → freed
# Wait 25s to ensure two full tick cycles have passed.
echo " Waiting 25s for TTL + grace period to expire (two tick cycles)..."
sleep 25
# Query gateway control socket for mapping count.
#
# The expected value here is zero, so the reader must not be able to
# produce a zero from a failed query: an error response carries no `data`
# field, and `r.get('data',{}).get('mappings',[])` would report that as
# zero mappings and pass this check without the gateway having answered.
# The same hazard is documented at the show_mappings poll above, which is
# safe only because it waits for a positive "2". Require the key to exist
# and exit non-zero if it does not, so the `|| echo "error"` fallback
# fires and the check reds.
MAPPING_COUNT=$(docker exec "$GATEWAY" bash -c \
'echo "{\"command\":\"show_mappings\"}" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' \
| python3 -c "
import sys, json
r = json.load(sys.stdin)
data = r.get('data')
if not isinstance(data, dict) or not isinstance(data.get('mappings'), list):
sys.exit(1)
print(len(data['mappings']))
" 2>/dev/null || echo "error")
if [ "$MAPPING_COUNT" = "0" ]; then
check "Mapping reclaimed after TTL+grace" 0
else
check "Mapping reclaimed (count: $MAPPING_COUNT)" 1
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