Files
fips/testing/static/scripts/gateway-test.sh
Johnathan Corgan 0c52763f5f feat(packaging): gate gateway port forwards in the mesh firewall
The packaged mesh firewall had one chain, on the input hook. A
fips-gateway port forward is a DNAT in prerouting, so its traffic is
routed through the forward hook and never met that chain or its
fips.d drop-ins. With the firewall loaded, every mesh node that could
reach the gateway reached every forward, and the only way to restrict
one was a separate table loaded by hand, which no test exercised.

fips.nft now has a second chain in the inet fips table, on the forward
hook. It returns for traffic not arriving on the mesh interface,
accepts established and related packets, includes drop-ins from
/etc/fips/fips.d/forward/*.nft, and then accepts the remaining
port-forwarded connections with a counter. Forwards stay open to the
mesh by default, so nothing changes on upgrade; a forward is
restricted by a drop-in that accepts the allowed mesh addresses for
its target address and port and drops the rest. A missing forward/
directory is fine.

The static gateway suite gains a phase that loads the tree's fips.nft
on the gateway with the drop-in taken from the deployment how-to, and
checks that the allowed node reaches the TCP and UDP forwards, the
other node is refused on both with each refusal counted by its own
drop rule, and a forward with no drop-in line stays open. A packaging
test checks the chain's rules and their order on every target.

The how-to, the firewall guides, the troubleshooting step, the design
and reference documents and the hosting tutorial describe the new
chain and drop-in directory in place of the hand-loaded table, and say
what happens to a hand-loaded table or a kept, edited fips.nft.
2026-10-23 14:37:08 +00:00

3177 lines
146 KiB
Bash
Executable File

#!/bin/bash
# Gateway integration test: non-FIPS LAN client reaches mesh HTTP server.
#
# Topology:
# gw-client (non-FIPS) → gw-gateway (fips + fips-gateway) → gw-server (fips + http)
#
# Usage:
# ./scripts/gateway-test.sh [inject-config | selftest]
#
# Subcommands:
# inject-config — post-process generated configs to add the gateway section
# selftest — check the output readers against canned input
# (no args) — run the test (containers must be running)
set -e
trap 'echo ""; echo "Test interrupted"; exit 130' INT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../lib/wait-converge.sh"
GENERATED_DIR="$SCRIPT_DIR/../generated-configs${FIPS_CI_NAME_SUFFIX:-}"
ENV_FILE="$GENERATED_DIR/npubs.env"
GATEWAY="fips-gw-gateway${FIPS_CI_NAME_SUFFIX:-}"
SERVER="fips-gw-server${FIPS_CI_NAME_SUFFIX:-}"
SERVER2="fips-gw-server-2${FIPS_CI_NAME_SUFFIX:-}"
CLIENT="fips-gw-client${FIPS_CI_NAME_SUFFIX:-}"
CLIENT2="fips-gw-client-2${FIPS_CI_NAME_SUFFIX:-}"
# LAN-side IPv6 addressing. run_gateway claims a per-run /64 and exports
# FIPS_GW_LAN6_PREFIX; unset (standalone / GitHub) these render the base
# compose's fd02:: addresses, byte-identical to before. GW_DNS is the gateway's
# LAN address (nameserver + route next-hop); GW_CLIENT_LAN is gw-client's LAN
# address (inbound port-forward target). fd01::/112 (the virtual pool) is NOT
# claimed and stays literal below.
GW_LAN6_PREFIX="${FIPS_GW_LAN6_PREFIX:-fd02}"
GW_DNS="${GW_LAN6_PREFIX}::10"
GW_CLIENT_LAN="${GW_LAN6_PREFIX}::20"
# ── inject-config subcommand ─────────────────────────────────────────────
inject_gateway_config() {
local config_file="$GENERATED_DIR/gateway/node-a.yaml"
if [ ! -f "$config_file" ]; then
echo "Error: $config_file not found. Run generate-configs.sh gateway first." >&2
return 1
fi
echo "Injecting gateway config into $config_file"
# Opening with 'w' truncates in place and keeps the inode, which the
# container's single-file bind mount of this file needs.
python3 - "$config_file" "$GW_CLIENT_LAN" <<'PYEOF' || return 1
import sys, yaml
path, client = sys.argv[1:3]
with open(path) as f:
cfg = yaml.safe_load(f)
cfg['gateway'] = {
'enabled': True,
'pool': 'fd01::/112',
# A placeholder. Docker does not promise which interface the LAN
# network gets, so the gateway container's entrypoint replaces this with
# the interface holding the gateway's LAN address before fips-gateway
# starts. The LAN-side masquerade and the proxy NDP entries use it.
'lan_interface': 'eth1',
'dns': {
'listen': '[::]:53',
'ttl': 5,
},
'pool_grace_period': 5,
'port_forwards': [
{
'listen_port': 18080,
'proto': 'tcp',
'target': f'[{client}]: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': f'[{client}]:8081',
},
# 6A: UDP forward — exercises the runtime UDP DNAT path (rule
# shape + conntrack handling) end-to-end.
{
'listen_port': 18081,
'proto': 'udp',
'target': f'[{client}]:8081',
},
],
}
with open(path, 'w') as f:
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)
PYEOF
echo " ✓ Gateway config injected"
return 0
}
# ── Readers ──────────────────────────────────────────────────────────────
#
# Each reader parses one tool's output on stdin and prints a single answer.
# A reader that cannot answer exits non-zero and prints nothing, rather than
# printing a default a check could mistake for an answer. Addresses are
# compared as addresses, never as strings: ip prints fd02:0:0:0::10 as
# fd02::10. Arguments reach Python through sys.argv.
# The interface holding address $1, from `ip -6 -o addr show`. Fails when no
# interface holds it or more than one does.
lan_iface() {
python3 -c '
import ipaddress, sys
want = ipaddress.ip_address(sys.argv[1])
holders = set()
for line in sys.stdin:
f = line.split()
if len(f) < 4 or f[2] != "inet6":
continue
try:
addr = ipaddress.ip_interface(f[3]).ip
except ValueError:
continue
if addr == want:
holders.add(f[1].split("@")[0])
if len(holders) != 1:
sys.exit(1)
print(holders.pop())
' "$@"
}
# The gateway's lan_interface, from a show_gateway response.
gw_iface() {
python3 -c '
import json, sys
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):
sys.exit(1)
name = data.get("lan_interface")
if not isinstance(name, str) or not name:
sys.exit(1)
print(name)
'
}
# The output interface of the LAN masquerade (the one rule matching
# iifname "fips0" and masquerading), from `nft list table inet fips_gateway`.
masq_iface() {
python3 -c '
import re, sys
found = []
for line in sys.stdin:
if "iifname \"fips0\"" not in line or not re.search(r"\bmasquerade\b", line):
continue
m = re.search(r"\boifname \"([^\"]+)\"", line)
found.append(m.group(1) if m else "")
if len(found) != 1 or not found[0]:
sys.exit(1)
print(found[0])
'
}
# How many per-mapping SNAT rules come before the LAN masquerade, from
# `nft list table inet fips_gateway`. NAT statements are terminal, so a SNAT
# listed first takes an inbound forwarded flow from that mapping's peer and
# the masquerade never runs; the right answer is 0. Fails when the listing
# does not hold exactly one LAN masquerade.
snat_before_masq() {
python3 -c '
import re, sys
snat = 0
before = None
masq = 0
for line in sys.stdin:
if "iifname \"fips0\"" in line and re.search(r"\bmasquerade\b", line):
masq += 1
before = snat
elif re.search(r"\bsaddr [0-9a-f:]+ .*\bsnat\b", line):
snat += 1
if masq != 1:
sys.exit(1)
print(before)
'
}
# The SNAT target of the one rule whose source match is mesh address $1, from
# `nft list table inet fips_gateway`. Fails when no rule or more than one
# matches.
snat_to() {
python3 -c '
import ipaddress, re, sys
want = ipaddress.ip_address(sys.argv[1])
found = []
for line in sys.stdin:
m = re.search(r"\bsaddr ([0-9a-f:]+) .*\bsnat\b.*?\bto \[?([0-9a-f:]+)", line)
if not m:
continue
try:
if ipaddress.ip_address(m.group(1)) == want:
found.append(ipaddress.ip_address(m.group(2)))
except ValueError:
sys.exit(1)
if len(found) != 1:
sys.exit(1)
print(found[0])
' "$@"
}
# The device of the proxy neighbour entry for address $1, from
# `ip -6 neigh show proxy`, whose lines read `ADDR dev DEV proxy`. Fails when
# no entry matches or matching entries name different devices.
proxy_dev() {
python3 -c '
import ipaddress, sys
want = ipaddress.ip_address(sys.argv[1])
devs = set()
for line in sys.stdin:
f = line.split()
if len(f) < 3 or "dev" not in f[1:-1]:
continue
try:
addr = ipaddress.ip_address(f[0])
except ValueError:
continue
if addr == want:
devs.add(f[f.index("dev", 1) + 1])
if len(devs) != 1:
sys.exit(1)
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())
' "$@"
}
# The reply source of the conntrack entries for PROTO $1 to port $2, from
# `conntrack -L -f ipv6`: the second src= of each line. For a flow DNATed to a
# mesh node it is that node's mesh address; for a flow the gateway answered
# itself it is the address the flow was sent to. Fails when no entry matches
# or the matching entries disagree.
reply_src() {
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=")]
srcs = [t[4:] for t in f if t.startswith("src=")]
if not dports or dports[0] != dport:
continue
try:
found.add(ipaddress.ip_address(srcs[1]))
except (IndexError, ValueError):
sys.exit(1)
if len(found) != 1:
sys.exit(1)
print(found.pop())
' "$@"
}
# The set of input interfaces on the DNAT rules in chain prerouting whose
# destination lies in pool $1, comma-joined and sorted, from `nft list table
# inet fips_gateway`. Port forward DNATs, whose destination is the gateway's
# own address, are not counted, nor are the output chain's DNATs of the
# gateway host's own traffic, which match no interface. Fails when no such
# rule exists or one has no iifname match.
dnat_iifs() {
python3 -c '
import ipaddress, re, sys
pool = ipaddress.ip_network(sys.argv[1])
found = set()
lines = 0
chain = None
for line in sys.stdin:
c = re.match(r"\s*chain (\S+) \{", line)
if c:
chain = c.group(1)
continue
if chain != "prerouting" or not re.search(r"\bdnat\b", line):
continue
m = re.search(r"\bip6 daddr ([0-9a-f:]+)\b", line)
if not m:
continue
try:
if ipaddress.ip_address(m.group(1)) not in pool:
continue
except ValueError:
sys.exit(1)
lines += 1
i = re.search(r"\biifname \"([^\"]+)\"", line)
if not i:
sys.exit(1)
found.add(i.group(1))
if not lines:
sys.exit(1)
print(",".join(sorted(found)))
' "$@"
}
# The mesh address the DNAT rule for virtual IP $1 in chain $2 (default
# prerouting) translates to, from `nft list table inet fips_gateway`. Fails
# when no rule or more than one matches in that chain.
dnat_to() {
python3 -c '
import ipaddress, re, sys
want = ipaddress.ip_address(sys.argv[1])
want_chain = sys.argv[2] if len(sys.argv) > 2 else "prerouting"
found = []
chain = None
for line in sys.stdin:
c = re.match(r"\s*chain (\S+) \{", line)
if c:
chain = c.group(1)
continue
if chain != want_chain:
continue
m = re.search(r"\bdaddr ([0-9a-f:]+) .*\bdnat\b.*?\bto \[?([0-9a-f:]+)", line)
if not m:
continue
try:
if ipaddress.ip_address(m.group(1)) == want:
found.append(ipaddress.ip_address(m.group(2)))
except ValueError:
sys.exit(1)
if len(found) != 1:
sys.exit(1)
print(found[0])
' "$@"
}
# How many DNAT rules in chain $1 have a destination in pool $2, from `nft
# list table inet fips_gateway`. Fails when the listing has no chain $1, so a
# failed listing cannot read as zero rules.
dnat_count() {
python3 -c '
import ipaddress, re, sys
want_chain, pool = sys.argv[1], ipaddress.ip_network(sys.argv[2])
chain = None
seen = False
count = 0
for line in sys.stdin:
c = re.match(r"\s*chain (\S+) \{", line)
if c:
chain = c.group(1)
seen = seen or chain == want_chain
continue
if chain != want_chain or not re.search(r"\bdnat\b", line):
continue
m = re.search(r"\bip6 daddr ([0-9a-f:]+)\b", line)
try:
if m and ipaddress.ip_address(m.group(1)) in pool:
count += 1
except ValueError:
sys.exit(1)
if not seen:
sys.exit(1)
print(count)
' "$@"
}
# Prints "ok" when the table holds the two rules that keep other interfaces
# off the mesh, from `nft -j list table inet fips_gateway`, given the LAN
# interface $1 and the pool $2: in chain forward, a drop of traffic into
# fips0 from any interface but the LAN unless it is established or related;
# in chain raw_prerouting, a drop of traffic to the pool from any interface
# but the LAN and lo. Fails otherwise. The ct state match is accepted in the
# form nft 1.0.9 prints for the rule (negation of the established,related
# set) and as an explicit mask compared with zero.
lan_drops_json() {
python3 -c '
import ipaddress, json, sys
lan, pool = sys.argv[1], ipaddress.ip_network(sys.argv[2])
try:
doc = json.load(sys.stdin)
except ValueError:
sys.exit(1)
rules = [e["rule"] for e in doc.get("nftables", []) if isinstance(e, dict) and "rule" in e]
def matches(rule):
return [x["match"] for x in rule.get("expr", []) if isinstance(x, dict) and "match" in x]
def drops(rule):
return any(isinstance(x, dict) and "drop" in x for x in rule.get("expr", []))
def meta(m, key, op, value):
return m.get("left") == {"meta": {"key": key}} and m.get("op") == op and m.get("right") == value
def flags(v):
if isinstance(v, str):
return {v}
if isinstance(v, list) and all(isinstance(x, str) for x in v):
return set(v)
if isinstance(v, dict) and isinstance(v.get("set"), list):
return set(v["set"])
return None
def not_est_rel(m):
ct = {"ct": {"key": "state"}}
want = {"established", "related"}
if m.get("op") == "!" and m.get("left") == ct:
return flags(m.get("right")) == want
left = m.get("left")
if m.get("op") == "==" and m.get("right") == 0 and isinstance(left, dict) and isinstance(left.get("&"), list):
a = left["&"]
return len(a) == 2 and a[0] == ct and flags(a[1]) == want
return False
def forward_drop(rule):
ms = matches(rule)
return (rule.get("chain") == "forward" and drops(rule)
and any(meta(m, "oifname", "==", "fips0") for m in ms)
and any(meta(m, "iifname", "!=", lan) for m in ms)
and any(not_est_rel(m) for m in ms))
def pool_drop(rule):
ms = matches(rule)
def prefix(m):
r = m.get("right")
if m.get("op") != "==" or m.get("left") != {"payload": {"protocol": "ip6", "field": "daddr"}}:
return False
if not isinstance(r, dict) or "prefix" not in r:
return False
try:
net = ipaddress.ip_network("%s/%s" % (r["prefix"]["addr"], r["prefix"]["len"]))
except (KeyError, ValueError):
return False
return net == pool
return (rule.get("chain") == "raw_prerouting" and drops(rule)
and any(meta(m, "iifname", "!=", lan) for m in ms)
and any(meta(m, "iifname", "!=", "lo") for m in ms)
and any(prefix(m) for m in ms))
if any(forward_drop(r) for r in rules) and any(pool_drop(r) for r in rules):
print("ok")
else:
sys.exit(1)
' "$@"
}
# Prints "ok" when chain raw_prerouting holds the drop of packets from a
# mapped mesh address, from `nft -j list table inet fips_gateway`: a lookup of
# the source in set fips_mesh_sources, for packets arriving on neither fips0
# nor lo. Fails otherwise.
forged_drop_json() {
python3 -c '
import json, sys
try:
doc = json.load(sys.stdin)
except ValueError:
sys.exit(1)
def ok(rule):
if rule.get("chain") != "raw_prerouting":
return False
exprs = rule.get("expr", [])
ms = [x["match"] for x in exprs if isinstance(x, dict) and "match" in x]
def has(left, op, right):
return any(m.get("left") == left and m.get("op") == op and m.get("right") == right for m in ms)
return (has({"meta": {"key": "iifname"}}, "!=", "fips0")
and has({"meta": {"key": "iifname"}}, "!=", "lo")
and has({"payload": {"protocol": "ip6", "field": "saddr"}}, "==", "@fips_mesh_sources")
and any(isinstance(x, dict) and "drop" in x for x in exprs))
rules = [e["rule"] for e in doc.get("nftables", []) if isinstance(e, dict) and "rule" in e]
if any(ok(r) for r in rules):
print("ok")
else:
sys.exit(1)
'
}
# Succeeds when the set listing from `nft -j list set inet fips_gateway
# fips_mesh_sources` holds address $1.
set_holds() {
python3 -c '
import ipaddress, json, sys
want = ipaddress.ip_address(sys.argv[1])
try:
doc = json.load(sys.stdin)
except ValueError:
sys.exit(1)
for e in doc.get("nftables", []):
if isinstance(e, dict) and isinstance(e.get("set"), dict):
for x in e["set"].get("elem", []):
try:
if isinstance(x, str) and ipaddress.ip_address(x) == want:
sys.exit(0)
except ValueError:
pass
sys.exit(1)
' "$@"
}
# The reply-tuple destination port of the conntrack entry for UDP to address
# $1 port $2, and whether it has seen a reply ("unreplied" or "replied"),
# from `conntrack -L -f ipv6`. Fails unless exactly one entry matches.
udp_entry() {
python3 -c '
import ipaddress, sys
dst, dport = ipaddress.ip_address(sys.argv[1]), sys.argv[2]
found = []
for line in sys.stdin:
f = line.split()
if not f or f[0] != "udp":
continue
dsts = [t[4:] for t in f if t.startswith("dst=")]
dports = [t[6:] for t in f if t.startswith("dport=")]
try:
if len(dsts) < 2 or len(dports) < 2 or ipaddress.ip_address(dsts[0]) != dst or dports[0] != dport:
continue
except ValueError:
continue
found.append((dports[1], "unreplied" if "[UNREPLIED]" in f else "replied"))
if len(found) != 1:
sys.exit(1)
print(*found[0])
' "$@"
}
# The table handle and the packet counters of the gateway's drop rules, from
# `nft -j list table inet fips_gateway`, as "HANDLE POOL FORWARD FORGED":
# the raw pool drop, the forward drop and the raw forged-source drop. FORGED
# is "none" when the table has no forged-source drop. Every rebuild deletes
# and recreates the table, which resets its counters and changes its handle,
# so two readings compare only when their handles agree. Fails when the
# table, the pool drop or the forward drop is missing.
drop_counters() {
python3 -c '
import json, sys
try:
doc = json.load(sys.stdin)
except ValueError:
sys.exit(1)
entries = [e for e in doc.get("nftables", []) if isinstance(e, dict)]
tables = [e["table"] for e in entries if "table" in e]
if len(tables) != 1 or "handle" not in tables[0]:
sys.exit(1)
def packets(rule):
for x in rule.get("expr", []):
if isinstance(x, dict) and isinstance(x.get("counter"), dict):
return x["counter"].get("packets")
return None
def has(rule, key):
return any(isinstance(x, dict) and key in x for x in rule.get("expr", []))
def saddr_lookup(rule):
for x in rule.get("expr", []):
m = x.get("match") if isinstance(x, dict) else None
if isinstance(m, dict) and m.get("left") == {"payload": {"protocol": "ip6", "field": "saddr"}} \
and isinstance(m.get("right"), str) and m["right"].startswith("@"):
return True
return False
pool = forward = None
forged = "none"
for e in entries:
r = e.get("rule")
if not isinstance(r, dict) or not has(r, "drop"):
continue
if r.get("chain") == "forward":
forward = packets(r)
elif r.get("chain") == "raw_prerouting" and saddr_lookup(r):
forged = packets(r)
elif r.get("chain") == "raw_prerouting":
pool = packets(r)
if not isinstance(pool, int) or not isinstance(forward, int) or forged is None:
sys.exit(1)
print(tables[0]["handle"], pool, forward, forged)
'
}
# The table handle and the packet counter of the input chain's reject of
# unmapped pool traffic, from `nft -j list table inet fips_gateway`, as
# "HANDLE PACKETS". Every rebuild recreates the table, resetting the counter
# and changing the handle, so two readings compare only when their handles
# agree. Fails when the table or a counted reject in chain input is missing.
pool_reject() {
python3 -c '
import json, sys
try:
doc = json.load(sys.stdin)
except ValueError:
sys.exit(1)
entries = [e for e in doc.get("nftables", []) if isinstance(e, dict)]
tables = [e["table"] for e in entries if "table" in e]
if len(tables) != 1 or "handle" not in tables[0]:
sys.exit(1)
found = []
for e in entries:
r = e.get("rule")
if not isinstance(r, dict) or r.get("chain") != "input":
continue
exprs = [x for x in r.get("expr", []) if isinstance(x, dict)]
if not any("reject" in x for x in exprs):
continue
for x in exprs:
if isinstance(x.get("counter"), dict) and isinstance(x["counter"].get("packets"), int):
found.append(x["counter"]["packets"])
if len(found) != 1:
sys.exit(1)
print(tables[0]["handle"], found[0])
'
}
# The packet counter of the drop rule for destination port $2 of protocol $1
# in the mesh firewall's forward chain, from
# `nft -j list chain inet fips forward`. Fails when the chain has no counted
# drop for that port, or more than one.
fwd_drop() {
python3 -c '
import json, sys
proto, port = sys.argv[1], int(sys.argv[2])
try:
doc = json.load(sys.stdin)
except ValueError:
sys.exit(1)
dport = {"payload": {"protocol": proto, "field": "dport"}}
found = []
for e in doc.get("nftables", []):
r = e.get("rule") if isinstance(e, dict) else None
if not isinstance(r, dict) or r.get("table") != "fips" or r.get("chain") != "forward":
continue
exprs = [x for x in r.get("expr", []) if isinstance(x, dict)]
if not any("drop" in x for x in exprs):
continue
if not any(isinstance(x.get("match"), dict) and x["match"].get("op") == "=="
and x["match"].get("left") == dport and x["match"].get("right") == port
for x in exprs):
continue
counts = [x["counter"].get("packets") for x in exprs if isinstance(x.get("counter"), dict)]
if len(counts) != 1 or not isinstance(counts[0], int):
sys.exit(1)
found.append(counts[0])
if len(found) != 1:
sys.exit(1)
print(found[0])
' "$@"
}
# 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
# the expected ones. A case expecting failure also requires empty output.
# Usage: gw_case LABEL WANT_RC WANT_OUT INPUT READER [ARGS...]
gw_case() {
local label="$1" want_rc="$2" want_out="$3" input="$4"
shift 4
local out rc
if out=$("$@" <<< "$input" 2>/dev/null); then rc=0; else rc=$?; fi
if [ "$rc" -eq "$want_rc" ] && [ "$out" = "$want_out" ]; then
echo " selftest $label ... OK"
return 0
fi
echo " selftest $label ... FAIL (rc $rc, output '$out'; expected rc $want_rc, output '$want_out')"
return 1
}
# 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
addr_eth0='1: lo inet6 ::1/128 scope host \ valid_lft forever preferred_lft forever
2: fips0 inet6 fd3c:9a51:7e02:4b18::1/8 scope global \ valid_lft forever preferred_lft forever
2: fips0 inet6 fe80::5c2a:91ff:fe3b:1d7e/64 scope link \ valid_lft forever preferred_lft forever
40: eth0 inet6 fd02::10/64 scope global nodad \ valid_lft forever preferred_lft forever
40: eth0 inet6 fe80::42:acff:fe13:3/64 scope link \ valid_lft forever preferred_lft forever
42: eth1 inet6 fe80::42:acff:fe12:2/64 scope link \ valid_lft forever preferred_lft forever'
addr_eth1='1: lo inet6 ::1/128 scope host \ valid_lft forever preferred_lft forever
2: fips0 inet6 fd3c:9a51:7e02:4b18::1/8 scope global \ valid_lft forever preferred_lft forever
2: fips0 inet6 fe80::5c2a:91ff:fe3b:1d7e/64 scope link \ valid_lft forever preferred_lft forever
40: eth0 inet6 fe80::42:acff:fe12:2/64 scope link \ valid_lft forever preferred_lft forever
42: eth1 inet6 fd02::10/64 scope global nodad \ valid_lft forever preferred_lft forever
42: eth1 inet6 fe80::42:acff:fe13:3/64 scope link \ valid_lft forever preferred_lft forever'
addr_claimed='1: lo inet6 ::1/128 scope host \ valid_lft forever preferred_lft forever
40: eth0 inet6 fe80::42:acff:fe12:2/64 scope link \ valid_lft forever preferred_lft forever
42: eth1 inet6 fd02:0:0:5::10/64 scope global nodad \ valid_lft forever preferred_lft forever'
addr_at='1: lo inet6 ::1/128 scope host \ valid_lft forever preferred_lft forever
6: eth0@if5 inet6 fe80::42:acff:fe12:2/64 scope link \ valid_lft forever preferred_lft forever
8: eth1@if7 inet6 fd02::10/64 scope global nodad \ valid_lft forever preferred_lft forever'
gw_case "lan_iface: LAN on eth0" 0 eth0 "$addr_eth0" lan_iface fd02::10 || fails=$((fails + 1))
gw_case "lan_iface: LAN on eth1" 0 eth1 "$addr_eth1" lan_iface fd02::10 || fails=$((fails + 1))
gw_case "lan_iface: claimed prefix" 0 eth1 "$addr_claimed" lan_iface fd02:0:0:5::10 || fails=$((fails + 1))
gw_case "lan_iface: first claimed /64 printed short" 0 eth0 "$addr_eth0" lan_iface fd02:0:0:0::10 || fails=$((fails + 1))
gw_case "lan_iface: name with @ifN suffix" 0 eth1 "$addr_at" lan_iface fd02::10 || fails=$((fails + 1))
gw_case "lan_iface: no holder" 1 "" "$addr_claimed" lan_iface fd02::10 || fails=$((fails + 1))
gw_case "lan_iface: empty input" 1 "" "" lan_iface fd02::10 || fails=$((fails + 1))
gw_case "gw_iface: ok response" 0 eth0 \
'{"status":"ok","data":{"pool_cidr":"fd01::/112","lan_interface":"eth0"}}' gw_iface || fails=$((fails + 1))
gw_case "gw_iface: error response" 1 "" \
'{"status":"error","message":"gateway not yet initialized"}' gw_iface || fails=$((fails + 1))
gw_case "gw_iface: empty input" 1 "" "" gw_iface || fails=$((fails + 1))
nft_lan='table inet fips_gateway {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "eth0" meta nfproto ipv6 ip6 daddr fd01::1 dnat ip6 to fd3c:9a51:7e02:4b18::2
iifname "fips0" meta nfproto ipv6 meta l4proto tcp tcp dport 18080 dnat ip6 to [fd02::20]:8080
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
iifname "eth0" oifname "fips0" masquerade
meta nfproto ipv6 ip6 saddr fd3c:9a51:7e02:4b18::2 snat ip6 to fd01::1
iifname "fips0" oifname "eth0" meta nfproto ipv6 masquerade
}
}'
nft_nolan=$(grep -v 'iifname "fips0" oifname' <<< "$nft_lan")
gw_case "masq_iface: LAN masquerade on eth0" 0 eth0 "$nft_lan" masq_iface || fails=$((fails + 1))
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))
# nft_lan lists the SNAT ahead of the LAN masquerade, the order that lets
# a mapped peer's inbound forward bypass the masquerade. nft_fixed moves
# the masquerade ahead of it, and nft_dup adds a second SNAT for the same
# mesh address.
local masq_line nft_fixed nft_dup
masq_line=$(grep 'iifname "fips0" oifname' <<< "$nft_lan")
nft_fixed=$(awk -v m="$masq_line" '$0 == m {next} /saddr .* snat/ {print m} {print}' <<< "$nft_lan")
nft_dup=$(awk '/saddr .* snat/ {print; sub(/fd01::1/, "fd01::2")} {print}' <<< "$nft_lan")
gw_case "snat_before_masq: SNAT listed first" 0 1 "$nft_lan" snat_before_masq || fails=$((fails + 1))
gw_case "snat_before_masq: masquerade listed first" 0 0 "$nft_fixed" snat_before_masq || fails=$((fails + 1))
gw_case "snat_before_masq: no LAN masquerade" 1 "" "$nft_nolan" snat_before_masq || fails=$((fails + 1))
gw_case "snat_before_masq: empty input" 1 "" "" snat_before_masq || fails=$((fails + 1))
gw_case "snat_to: mesh address, short form" 0 fd01::1 "$nft_lan" \
snat_to fd3c:9a51:7e02:4b18::2 || fails=$((fails + 1))
gw_case "snat_to: mesh address, long form" 0 fd01::1 "$nft_lan" \
snat_to fd3c:9a51:7e02:4b18:0:0:0:2 || fails=$((fails + 1))
gw_case "snat_to: masquerade listed first" 0 fd01::1 "$nft_fixed" \
snat_to fd3c:9a51:7e02:4b18::2 || fails=$((fails + 1))
gw_case "snat_to: another mesh address" 1 "" "$nft_lan" \
snat_to fd3c:9a51:7e02:4b18::3 || fails=$((fails + 1))
gw_case "snat_to: two rules for one mesh address" 1 "" "$nft_dup" \
snat_to fd3c:9a51:7e02:4b18::2 || fails=$((fails + 1))
gw_case "snat_to: empty input" 1 "" "" snat_to fd3c:9a51:7e02:4b18::2 || fails=$((fails + 1))
# dnat_iifs reads only the mapping DNATs, whose destination is in the
# pool; the port-forward DNAT in nft_lan matches iifname "fips0" and is
# not counted. nft_noiif holds a mapping DNAT without an iifname match.
local nft_noiif nft_two
nft_noiif=$(sed 's/iifname "eth0" meta nfproto ipv6 ip6 daddr/meta nfproto ipv6 ip6 daddr/' <<< "$nft_lan")
nft_two=$(awk '/daddr fd01::1 dnat/ {print; sub(/fd01::1/, "fd01::2"); sub(/"eth0"/, "\"eth9\"")} {print}' <<< "$nft_lan")
gw_case "dnat_iifs: mapping DNAT on eth0 beside a port forward" 0 eth0 "$nft_lan" \
dnat_iifs fd01::/112 || fails=$((fails + 1))
gw_case "dnat_iifs: mapping DNATs on two interfaces" 0 eth0,eth9 "$nft_two" \
dnat_iifs fd01::/112 || fails=$((fails + 1))
gw_case "dnat_iifs: mapping DNAT without iifname" 1 "" "$nft_noiif" \
dnat_iifs fd01::/112 || fails=$((fails + 1))
gw_case "dnat_iifs: no mapping DNAT" 1 "" "$nft_nolan" dnat_iifs fd02::/64 || fails=$((fails + 1))
gw_case "dnat_iifs: empty input" 1 "" "" dnat_iifs fd01::/112 || fails=$((fails + 1))
gw_case "dnat_to: virtual IP" 0 fd3c:9a51:7e02:4b18::2 "$nft_lan" \
dnat_to fd01:0:0:0::1 || fails=$((fails + 1))
gw_case "dnat_to: no rule" 1 "" "$nft_lan" dnat_to fd01::9 || fails=$((fails + 1))
# nft_out adds the output chain's DNAT of the gateway host's own traffic,
# which matches no interface, and the host masquerade. dnat_iifs and the
# default dnat_to read only prerouting; dnat_to with a chain argument
# reads that chain. nft_out_noiif drops prerouting's iifname match, which
# dnat_iifs must still catch beside the output DNAT. nft_out_two names a
# second mesh address for the virtual IP in prerouting only.
local nft_out nft_out_noiif nft_out_two
nft_out=$(awk '
/^\tchain postrouting/ {
print "\tchain output {"
print "\t\ttype nat hook output priority dstnat; policy accept;"
print "\t\tmeta nfproto ipv6 ip6 daddr fd01::1 dnat ip6 to fd3c:9a51:7e02:4b18::2"
print "\t}"
print ""
}
{print}
/iifname "eth0" oifname "fips0" masquerade/ {
print "\t\tmeta nfproto ipv6 oifname \"fips0\" ip6 daddr @fips_mesh_sources masquerade"
}' <<< "$nft_lan")
nft_out_noiif=${nft_out//iifname \"eth0\" meta nfproto ipv6 ip6 daddr/meta nfproto ipv6 ip6 daddr}
nft_out_two=$(awk '/iifname "eth0" .*daddr fd01::1 dnat/ {print; sub(/::2$/, "::3")} {print}' <<< "$nft_out")
gw_case "dnat_iifs: output DNAT without iifname not counted" 0 eth0 "$nft_out" \
dnat_iifs fd01::/112 || fails=$((fails + 1))
gw_case "dnat_iifs: prerouting DNAT without iifname beside an output DNAT" 1 "" "$nft_out_noiif" \
dnat_iifs fd01::/112 || fails=$((fails + 1))
gw_case "dnat_to: prerouting beside an output DNAT" 0 fd3c:9a51:7e02:4b18::2 "$nft_out" \
dnat_to fd01::1 || fails=$((fails + 1))
gw_case "dnat_to: output chain" 0 fd3c:9a51:7e02:4b18::2 "$nft_out" \
dnat_to fd01::1 output || fails=$((fails + 1))
gw_case "dnat_to: no output chain" 1 "" "$nft_lan" dnat_to fd01::1 output || fails=$((fails + 1))
gw_case "dnat_to: two prerouting rules, one output rule" 0 fd3c:9a51:7e02:4b18::2 "$nft_out_two" \
dnat_to fd01::1 output || fails=$((fails + 1))
gw_case "dnat_to: two prerouting rules" 1 "" "$nft_out_two" dnat_to fd01::1 || fails=$((fails + 1))
gw_case "dnat_count: prerouting" 0 1 "$nft_out" dnat_count prerouting fd01::/112 || fails=$((fails + 1))
gw_case "dnat_count: output" 0 1 "$nft_out" dnat_count output fd01::/112 || fails=$((fails + 1))
gw_case "dnat_count: prerouting with two rules" 0 2 "$nft_out_two" \
dnat_count prerouting fd01::/112 || fails=$((fails + 1))
gw_case "dnat_count: no output chain" 1 "" "$nft_lan" dnat_count output fd01::/112 || fails=$((fails + 1))
gw_case "dnat_count: empty input" 1 "" "" dnat_count prerouting fd01::/112 || fails=$((fails + 1))
gw_case "masq_iface: beside the host masquerade" 0 eth0 "$nft_out" masq_iface || fails=$((fails + 1))
# The forward and raw chains as `nft -j` 1.0.9 printed a rebuild of the
# gateway's table in a network namespace, trimmed to the two drops.
# json_mask names the wrong ct states, and json_nolo lacks the lo
# exemption.
local json_ok json_mask json_nolo json_alt
json_ok='{"nftables": [{"table": {"family": "inet", "name": "fips_gateway", "handle": 8}}, {"rule": {"family": "inet", "table": "fips_gateway", "chain": "forward", "handle": 6, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "nfproto"}}, "right": "ipv6"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "fips0"}}, {"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "eth0"}}, {"match": {"op": "!", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"counter": {"packets": 2, "bytes": 160}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "fips_gateway", "chain": "raw_prerouting", "handle": 5, "expr": [{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "eth0"}}, {"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "lo"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "daddr"}}, "right": {"prefix": {"addr": "fd01::", "len": 112}}}}, {"counter": {"packets": 3, "bytes": 240}}, {"drop": null}]}}]}'
json_mask=$(sed 's/\["established", "related"\]/["new"]/' <<< "$json_ok")
json_nolo=$(sed 's/"right": "lo"/"right": "eth9"/' <<< "$json_ok")
json_alt=$(sed 's/{"op": "!", "left": {"ct": {"key": "state"}}, "right": \["established", "related"\]}/{"op": "==", "left": {"\&": [{"ct": {"key": "state"}}, ["related", "established"]]}, "right": 0}/' <<< "$json_ok")
gw_case "lan_drops_json: both drops" 0 ok "$json_ok" lan_drops_json eth0 fd01::/112 || fails=$((fails + 1))
gw_case "lan_drops_json: mask and compare form" 0 ok "$json_alt" lan_drops_json eth0 fd01::/112 || fails=$((fails + 1))
gw_case "lan_drops_json: wrong ct state" 1 "" "$json_mask" lan_drops_json eth0 fd01::/112 || fails=$((fails + 1))
gw_case "lan_drops_json: no lo exemption" 1 "" "$json_nolo" lan_drops_json eth0 fd01::/112 || fails=$((fails + 1))
gw_case "lan_drops_json: another LAN interface" 1 "" "$json_ok" lan_drops_json eth1 fd01::/112 || fails=$((fails + 1))
gw_case "lan_drops_json: empty input" 1 "" "" lan_drops_json eth0 fd01::/112 || fails=$((fails + 1))
gw_case "drop_counters: table without a forged-source drop" 0 "8 3 2 none" "$json_ok" \
drop_counters || fails=$((fails + 1))
gw_case "drop_counters: empty input" 1 "" "" drop_counters || fails=$((fails + 1))
# The forged-source drop and the mesh-source set as `nft -j` 1.0.9
# printed them for a rebuild in a network namespace.
local json_forged json_forged_nolo json_both set_json
json_forged='{"nftables": [{"table": {"family": "inet", "name": "fips_gateway", "handle": 9}}, {"rule": {"family": "inet", "table": "fips_gateway", "chain": "raw_prerouting", "handle": 7, "expr": [{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "fips0"}}, {"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "lo"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "saddr"}}, "right": "@fips_mesh_sources"}}, {"counter": {"packets": 4, "bytes": 320}}, {"drop": null}]}}]}'
json_forged_nolo=$(sed 's/"right": "lo"/"right": "eth9"/' <<< "$json_forged")
json_both=$(python3 -c '
import json, sys
a, b = json.loads(sys.argv[1]), json.loads(sys.argv[2])
a["nftables"] += [e for e in b["nftables"] if "rule" in e]
print(json.dumps(a))
' "$json_ok" "$json_forged")
set_json='{"nftables": [{"metainfo": {"version": "1.0.9"}}, {"set": {"family": "inet", "name": "fips_mesh_sources", "table": "fips_gateway", "type": "ipv6_addr", "handle": 5, "elem": ["fd02::63", "fd3c:9a51:7e02:4b18::2"]}}]}'
gw_case "forged_drop_json: the drop" 0 ok "$json_forged" forged_drop_json || fails=$((fails + 1))
gw_case "forged_drop_json: no lo exemption" 1 "" "$json_forged_nolo" forged_drop_json || fails=$((fails + 1))
gw_case "forged_drop_json: empty input" 1 "" "" forged_drop_json || fails=$((fails + 1))
gw_case "drop_counters: with a forged-source drop" 0 "8 3 2 4" "$json_both" \
drop_counters || fails=$((fails + 1))
gw_case "set_holds: a member, long form" 0 "" "$set_json" \
set_holds fd3c:9a51:7e02:4b18:0:0:0:2 || fails=$((fails + 1))
gw_case "set_holds: not a member" 1 "" "$set_json" set_holds fd02::64 || fails=$((fails + 1))
gw_case "set_holds: empty input" 1 "" "" set_holds fd02::63 || fails=$((fails + 1))
# conntrack -L lines in the kernel's format for a LAN client's UDP flow
# to a virtual IP, DNAT'd and masqueraded, before and after a reply.
local ct_udp_unreplied ct_udp_replied
ct_udp_unreplied='udp 17 29 src=fd02::20 dst=fd01::7 sport=40000 dport=9 [UNREPLIED] src=fd3c:9a51:7e02:4b18::2 dst=fd3c:9a51:7e02:4b18::1 sport=9 dport=61234 mark=0 use=1'
ct_udp_replied='udp 17 29 src=fd02::20 dst=fd01::7 sport=40000 dport=9 src=fd3c:9a51:7e02:4b18::2 dst=fd3c:9a51:7e02:4b18::1 sport=9 dport=61234 mark=0 use=1'
gw_case "udp_entry: unreplied" 0 "61234 unreplied" "$ct_udp_unreplied" \
udp_entry fd01:0:0:0::7 9 || fails=$((fails + 1))
gw_case "udp_entry: replied" 0 "61234 replied" "$ct_udp_replied" udp_entry fd01::7 9 || fails=$((fails + 1))
gw_case "udp_entry: another port" 1 "" "$ct_udp_replied" udp_entry fd01::7 10 || fails=$((fails + 1))
gw_case "udp_entry: two entries" 1 "" "$ct_udp_replied"$'\n'"$ct_udp_unreplied" \
udp_entry fd01::7 9 || 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 \
"$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 "" \
"$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))
# Written in the kernel's format for the gateway host's own flow to a
# virtual IP: ct_host translated to the mapped node and masqueraded, and
# ct_local answered by a listener on the gateway itself.
local ct_host ct_local
ct_host='tcp 6 119 TIME_WAIT src=fd02::10 dst=fd01::3 sport=41000 dport=8009 src=fd3c:9a51:7e02:4b18::2 dst=fd3c:9a51:7e02:4b18::1 sport=8009 dport=41000 [ASSURED] mark=0 use=1'
ct_local='tcp 6 119 TIME_WAIT src=fd02::10 dst=fd01::3 sport=41001 dport=8009 src=fd01::3 dst=fd02::10 sport=8009 dport=41001 [ASSURED] mark=0 use=1'
gw_case "reply_src: translated to the mapped node" 0 fd3c:9a51:7e02:4b18::2 "$ct_host" \
reply_src tcp 8009 || fails=$((fails + 1))
gw_case "reply_src: answered on the gateway" 0 fd01::3 "$ct_local" reply_src tcp 8009 || fails=$((fails + 1))
gw_case "reply_src: entries disagree" 1 "" "$ct_host"$'\n'"$ct_local" \
reply_src tcp 8009 || fails=$((fails + 1))
gw_case "reply_src: another port" 1 "" "$ct_host" reply_src tcp 8000 || fails=$((fails + 1))
gw_case "reply_src: empty input" 1 "" "" reply_src tcp 8009 || fails=$((fails + 1))
gw_case "reply_dst: translated and masqueraded" 0 fd3c:9a51:7e02:4b18::1 "$ct_host" \
reply_dst tcp 8009 || fails=$((fails + 1))
# The input chain's reject, written from the documented `nft -j` format;
# json_reject_none has the chain but no reject in it.
local json_reject json_reject_none
json_reject='{"nftables": [{"table": {"family": "inet", "name": "fips_gateway", "handle": 11}}, {"rule": {"family": "inet", "table": "fips_gateway", "chain": "input", "handle": 4, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "nfproto"}}, "right": "ipv6"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "daddr"}}, "right": {"prefix": {"addr": "fd01::", "len": 112}}}}, {"match": {"op": "==", "left": {"meta": {"key": "l4proto"}}, "right": "ipv6-icmp"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "icmpv6", "field": "type"}}, "right": "nd-neighbor-solicit"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "fips_gateway", "chain": "input", "handle": 5, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "nfproto"}}, "right": "ipv6"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "daddr"}}, "right": {"prefix": {"addr": "fd01::", "len": 112}}}}, {"counter": {"packets": 6, "bytes": 480}}, {"reject": {"type": "icmpx", "expr": "port-unreachable"}}]}}]}'
json_reject_none=${json_reject//\{\"reject\": \{\"type\": \"icmpx\", \"expr\": \"port-unreachable\"\}\}/\{\"drop\": null\}}
gw_case "pool_reject: the counted reject" 0 "11 6" "$json_reject" pool_reject || fails=$((fails + 1))
gw_case "pool_reject: no reject in chain input" 1 "" "$json_reject_none" pool_reject || fails=$((fails + 1))
gw_case "pool_reject: a table without the input chain" 1 "" "$json_ok" pool_reject || fails=$((fails + 1))
gw_case "pool_reject: empty input" 1 "" "" pool_reject || fails=$((fails + 1))
# The mesh firewall's forward chain with the how-to's drop-in for one TCP
# and one UDP forward, as `nft -j list chain inet fips forward` printed it
# on nftables 1.1.3 with counters edited in; json_fwd_nodrop is the same
# chain with no drop-in.
local json_fwd json_fwd_nodrop
json_fwd='{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"chain": {"family": "inet", "table": "fips", "name": "forward", "handle": 2, "type": "filter", "hook": "forward", "prio": 0, "policy": "accept"}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 18, "expr": [{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "fips0"}}, {"return": null}]}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 19, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 20, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "daddr"}}, "right": "fd02::20"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 8080}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "saddr"}}, "right": "fd97::a"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 21, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "daddr"}}, "right": "fd02::20"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 8080}}, {"counter": {"packets": 3, "bytes": 240}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 22, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "daddr"}}, "right": "fd02::20"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "udp", "field": "dport"}}, "right": 8081}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "saddr"}}, "right": "fd97::a"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 23, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "daddr"}}, "right": "fd02::20"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "udp", "field": "dport"}}, "right": 8081}}, {"counter": {"packets": 1, "bytes": 64}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 24, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "status"}}, "right": "dnat"}}, {"counter": {"packets": 0, "bytes": 0}}, {"accept": null}]}}]}'
json_fwd_nodrop='{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"chain": {"family": "inet", "table": "fips", "name": "forward", "handle": 2, "type": "filter", "hook": "forward", "prio": 0, "policy": "accept"}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 18, "expr": [{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "fips0"}}, {"return": null}]}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 19, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "fips", "chain": "forward", "handle": 24, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "status"}}, "right": "dnat"}}, {"counter": {"packets": 0, "bytes": 0}}, {"accept": null}]}}]}'
gw_case "fwd_drop: tcp drop counter" 0 3 "$json_fwd" fwd_drop tcp 8080 || fails=$((fails + 1))
gw_case "fwd_drop: udp drop counter" 0 1 "$json_fwd" fwd_drop udp 8081 || fails=$((fails + 1))
gw_case "fwd_drop: no drop for that protocol" 1 "" "$json_fwd" fwd_drop udp 8080 || fails=$((fails + 1))
gw_case "fwd_drop: chain without drop-ins" 1 "" "$json_fwd_nodrop" fwd_drop tcp 8080 || fails=$((fails + 1))
gw_case "fwd_drop: empty input" 1 "" "" fwd_drop tcp 8080 || 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
return 0
fi
return 1
}
if [ "${1:-}" = "selftest" ]; then
if gw_selftest; then exit 0; else exit 1; fi
fi
if [ "${1:-}" = "inject-config" ]; then
inject_gateway_config || exit 1
exit 0
fi
# ── Main test ────────────────────────────────────────────────────────────
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found. Run generate-configs.sh gateway first." >&2
exit 1
fi
# shellcheck source=../generated-configs/npubs.env
source "$ENV_FILE"
PASSED=0
FAILED=0
check() {
local label="$1"
local result="$2"
if [ "$result" -eq 0 ]; then
echo " $label ... OK"
PASSED=$((PASSED + 1))
else
echo " $label ... FAIL"
FAILED=$((FAILED + 1))
fi
}
# Record one check that the running gateway's lan_interface is the interface
# holding its LAN address, derived here from the container's addresses
# independently of the entrypoint. Sets LAN_IF to the derived name, or to
# empty when no single interface holds the address. Needs no set -e: callers
# may run it where set -e is suspended.
lan_agree() {
local label="$1" reported="" derived=""
for _ in $(seq 1 30); do
if reported=$(docker exec "$GATEWAY" bash -c \
'echo "{\"command\":\"show_gateway\"}" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' \
| gw_iface); then
break
fi
reported=""
sleep 1
done
if derived=$(docker exec "$GATEWAY" ip -6 -o addr show 2>/dev/null | lan_iface "$GW_DNS"); then
:
else
derived=""
fi
LAN_IF="$derived"
if [ -z "$derived" ]; then
check "$label: no single interface holds $GW_DNS" 1
elif [ -z "$reported" ]; then
check "$label: gateway did not report its lan_interface (derived $derived)" 1
elif [ "$derived" != "$reported" ]; then
check "$label: derived $derived, gateway $reported" 1
else
check "$label: gateway uses $derived, which holds $GW_DNS" 0
fi
return 0
}
# Start the LAN-side responders the inbound port forwards reach, on gw-client,
# and wait briefly for them to bind. Used by phases 8b and 8c.
gw_responders_start() {
# 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
}
# Stop the responders gw_responders_start started.
gw_responders_stop() {
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
}
echo "=== FIPS Gateway Integration Test ==="
echo ""
# Phase 0: the readers the later phases rely on, against canned input.
echo "Phase 0: Reader self-test"
if gw_selftest; then
check "Reader self-test" 0
else
check "Reader self-test" 1
fi
echo ""
# Phase 1: Wait for mesh convergence (gateway ↔ server, gateway ↔ server-2)
echo "Phase 1: Mesh convergence"
wait_for_peers "$GATEWAY" 2 30 || true
wait_for_peers "$SERVER" 1 30 || true
wait_for_peers "$SERVER2" 1 30 || true
# Phase 1b: LAN interface
#
# A wrong lan_interface that exists passes the gateway's startup check, and
# the LAN masquerade and the proxy NDP entries then go on the wrong interface.
# The gateway container's entrypoint derives the interface holding the LAN
# address at every container start and writes it into the gateway's config.
# This checks the result against an independent derivation from outside. An
# empty LAN_IF afterwards makes every later interface check fail.
echo ""
echo "Phase 1b: LAN interface"
LAN_IF=""
lan_agree "LAN interface"
# Phase 2: Wait for gateway DNS to respond
echo ""
echo "Phase 2: Gateway DNS readiness"
DNS_READY=false
for i in $(seq 1 30); do
# Try resolving the server's npub via the gateway DNS from the client.
# Match fd01:: specifically (the pool prefix) to avoid false-positive
# matches on error messages containing fd02::10.
local_result=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" @${GW_DNS} 2>/dev/null || true)
if echo "$local_result" | grep -q "^fd01::"; then
echo " Gateway DNS responding after ${i}s"
DNS_READY=true
break
fi
sleep 1
done
if [ "$DNS_READY" != true ]; then
echo " WARNING: Gateway DNS did not respond within 30s, continuing anyway"
fi
# The gateway names its conntrack source once at startup, before the DNS
# resolver starts, so by now the line is in the log. Ask the gateway's own
# namespace which source it should have found: the proc file when it exists,
# and otherwise the netlink dump, which the container's NET_ADMIN allows. A
# failed `docker logs` reds the check rather than counting as zero lines.
if docker exec "$GATEWAY" test -e /proc/net/nf_conntrack; then
EXPECT_SRC=proc
else
EXPECT_SRC=netlink
fi
if GW_START_LOG=$(docker logs "$GATEWAY" 2>&1); then
SRC_PROC=$(grep -cF 'Conntrack source: proc; session pinning is on' <<< "$GW_START_LOG" || true)
SRC_NETLINK=$(grep -cF 'Conntrack source: netlink; session pinning is on' <<< "$GW_START_LOG" || true)
SRC_NONE=$(grep -cF 'No conntrack source is readable; session pinning is off' <<< "$GW_START_LOG" || true)
case "$EXPECT_SRC" in
proc) SRC_HIT=$SRC_PROC ;;
*) SRC_HIT=$SRC_NETLINK ;;
esac
SRC_ALL=$((SRC_PROC + SRC_NETLINK + SRC_NONE))
SRC_OK=$([ "$SRC_HIT" -eq 1 ] && [ "$SRC_ALL" -eq 1 ] && echo 0 || echo 1)
check "Conntrack source line at startup (expect $EXPECT_SRC; proc lines $SRC_PROC, netlink lines $SRC_NETLINK, none lines $SRC_NONE)" "$SRC_OK"
else
check "Conntrack source line at startup (docker logs failed)" 1
fi
# Phase 3: Client network setup — route virtual IP pool via gateway
echo ""
echo "Phase 3: Client network setup"
docker exec "$CLIENT" ip -6 route add fd01::/112 via ${GW_DNS} 2>/dev/null || true
echo " Added route fd01::/112 via ${GW_DNS} on $CLIENT"
docker exec "$CLIENT2" ip -6 route add fd01::/112 via ${GW_DNS} 2>/dev/null || true
echo " Added route fd01::/112 via ${GW_DNS} on $CLIENT2"
# 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" @${GW_DNS} 2>/dev/null | head -1)
if [ -n "$VIRTUAL_IP" ] && echo "$VIRTUAL_IP" | grep -q "fd01"; then
check "Resolve ${NPUB_B:0:20}...fips on $CLIENT → $VIRTUAL_IP" 0
else
check "Resolve ${NPUB_B:0:20}...fips on $CLIENT (got: '$VIRTUAL_IP')" 1
fi
VIRTUAL_IP_2=$(docker exec "$CLIENT2" dig +short AAAA "${NPUB_C}.fips" @${GW_DNS} 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.
# 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 from $CLIENT" 0
else
check "HTTP GET from $CLIENT (response: '${RESPONSE:0:80}')" 1
fi
else
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
echo ""
echo "Phase 6: Gateway NAT state"
# Check that nftables rules were created
NFT_RULES=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null || echo "")
if echo "$NFT_RULES" | grep -q "dnat"; then
check "nftables DNAT rules present" 0
else
check "nftables DNAT rules" 1
fi
# Phase 5's GET left a TCP conntrack entry to the first virtual IP, which
# stays in the table in TIME_WAIT well past two ticks. The gateway must count
# it. Poll about once a second for 25 tries, which spans two 10s ticks, and
# read the count with a parser that cannot turn a failed query into a number:
# an error response has no `data`, and the parser exits non-zero on it.
if [ -n "$VIRTUAL_IP" ]; then
VIP_SESSIONS=error
for _ in $(seq 1 25); do
VIP_SESSIONS=$(docker exec "$GATEWAY" bash -c \
'echo "{\"command\":\"show_mappings\"}" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' \
| VIP="$VIRTUAL_IP" python3 -c "
import os, 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)
hits = [m for m in data['mappings'] if m.get('virtual_ip') == os.environ['VIP']]
if len(hits) != 1 or not isinstance(hits[0].get('sessions'), int):
sys.exit(1)
print(hits[0]['sessions'])
" 2>/dev/null || echo "error")
if [ "$VIP_SESSIONS" != error ] && [ "$VIP_SESSIONS" -ge 1 ]; then
break
fi
sleep 1
done
if [ "$VIP_SESSIONS" != error ] && [ "$VIP_SESSIONS" -ge 1 ]; then
check "Gateway counts a session to $VIRTUAL_IP (sessions $VIP_SESSIONS, source $EXPECT_SRC)" 0
else
check "Gateway counts a session to $VIRTUAL_IP (sessions $VIP_SESSIONS, source $EXPECT_SRC)" 1
echo " Kernel conntrack entries to $VIRTUAL_IP:"
docker exec "$GATEWAY" conntrack -L -f ipv6 -d "$VIRTUAL_IP" 2>&1 | sed 's/^/ /' || true
fi
else
check "Gateway counts a session (skipped — no virtual IP)" 1
fi
# The mapping's proxy neighbour entry must be on the LAN interface, or LAN
# hosts without a static route cannot reach the virtual IP. Phase 3's static
# routes bypass neighbour resolution, so nothing else would notice.
if [ -n "$VIRTUAL_IP" ] && [ -n "$LAN_IF" ] \
&& PROXY_IF=$(docker exec "$GATEWAY" ip -6 neigh show proxy 2>/dev/null | proxy_dev "$VIRTUAL_IP"); then
if [ "$PROXY_IF" = "$LAN_IF" ]; then
check "Proxy NDP entry for $VIRTUAL_IP on $PROXY_IF, the LAN interface" 0
else
check "Proxy NDP entry for $VIRTUAL_IP on $PROXY_IF, but the LAN interface is $LAN_IF" 1
fi
else
check "Proxy NDP entry for '$VIRTUAL_IP' on the LAN interface '$LAN_IF' (none found once)" 1
fi
# Only traffic arriving on the LAN interface is translated onto the mesh:
# every prerouting mapping DNAT and the LAN fips0 masquerade match iifname
# LAN_IF (the gateway host's own traffic is Phase 6d's), and the
# forward and raw chains drop other interfaces' traffic into fips0 and to the
# pool. The drops are read from `nft -j`, whose fields do not depend on how
# one nft version renders the ct state match.
if [ -n "$LAN_IF" ] && P6_IIFS=$(dnat_iifs fd01::/112 <<< "$NFT_RULES"); then
if [ "$P6_IIFS" = "$LAN_IF" ]; then
check "Mapping DNATs match iifname $LAN_IF only" 0
else
check "Mapping DNATs match iifname '$P6_IIFS', expected $LAN_IF" 1
fi
else
check "Mapping DNATs carry an iifname match (LAN '$LAN_IF', none read)" 1
fi
if [ -n "$LAN_IF" ] && grep -qE "^[[:space:]]*iifname \"$LAN_IF\" oifname \"fips0\" masquerade\$" <<< "$NFT_RULES"; then
check "fips0 masquerade matches iifname $LAN_IF" 0
else
check "fips0 masquerade matches iifname '$LAN_IF'" 1
fi
if [ -n "$LAN_IF" ] && P6_DROPS=$(docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>/dev/null \
| lan_drops_json "$LAN_IF" fd01::/112); then
check "Forward drop and raw pool drop for interfaces other than $LAN_IF ($P6_DROPS)" 0
else
check "Forward drop and raw pool drop for interfaces other than '$LAN_IF'" 1
docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>&1 | head -c 4000 | sed 's/^/ /' || true
echo ""
fi
# Packets from a mapped mesh address are dropped unless they arrive on fips0
# or lo, and the set the drop looks up holds the live mappings' addresses.
if P6_FORGED=$(docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>/dev/null | forged_drop_json); then
check "Forged-source drop in raw_prerouting ($P6_FORGED)" 0
else
check "Forged-source drop in raw_prerouting" 1
fi
P6_SERVER2=$(docker exec "$SERVER2" bash -c \
"ip -6 -o addr show fips0 | awk '/inet6 fd/ {print \$4}' | cut -d/ -f1 | head -1" \
2>/dev/null || echo "")
P6_SET_OK=1
for _ in $(seq 1 10); do
docker exec "$CLIENT2" dig +short AAAA "${NPUB_C}.fips" @${GW_DNS} >/dev/null 2>&1 || true
if [ -n "$P6_SERVER2" ] && docker exec "$GATEWAY" nft -j list set inet fips_gateway fips_mesh_sources \
2>/dev/null | set_holds "$P6_SERVER2"; then
P6_SET_OK=0
break
fi
sleep 0.5
done
check "fips_mesh_sources holds $SERVER2 mesh address '$P6_SERVER2'" "$P6_SET_OK"
# Phase 6b: a reply forged from the LAN does not count as the node's
#
# gw-client opens a UDP flow to the virtual IP of gw-server's mapping, port
# 9, where nothing listens, so the entry stays unreplied. It then sends one
# datagram from gw-server's mesh address to the flow's masqueraded port on
# the gateway, as a reply would come. The gateway must drop it before
# connection tracking: the entry stays unreplied (primary), and the
# forged-source drop's counter rose (control). A rebuild between the two
# counter reads resets the counter and changes the table handle, so the
# case is retried.
echo ""
echo "Phase 6b: Forged replies from the LAN"
P6B_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 "")
P6B_GW=$(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 "")
P6B_CLIENT_IF=$(docker exec "$CLIENT" ip -6 -o addr show 2>/dev/null | lan_iface "$GW_CLIENT_LAN" || echo "")
P6B_DONE=false
if [ -z "$P6B_MESH" ] || [ -z "$P6B_GW" ] || [ -z "$P6B_CLIENT_IF" ]; then
check "Forged reply setup (server mesh '$P6B_MESH', gateway '$P6B_GW', client interface '$P6B_CLIENT_IF')" 1
else
for P6B_TRY in 1 2 3; do
P6B_VIP=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" @${GW_DNS} 2>/dev/null \
| grep -m1 "^fd01::" || true)
[ -n "$P6B_VIP" ] || { echo " try $P6B_TRY: no virtual IP"; continue; }
docker exec "$CLIENT" python3 -c '
import socket, sys
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.bind(("::", 40009))
s.sendto(b"probe", (sys.argv[1], 9))
' "$P6B_VIP" >/dev/null 2>&1 || true
sleep 1
P6B_ENTRY=$(docker exec "$GATEWAY" conntrack -L -f ipv6 2>/dev/null | udp_entry "$P6B_VIP" 9 || echo "")
read -r P6B_PORT P6B_STATE <<< "$P6B_ENTRY"
if [ "$P6B_STATE" != unreplied ]; then
echo " try $P6B_TRY: no unreplied entry for $P6B_VIP port 9 ('$P6B_ENTRY')"
continue
fi
P6B_BEFORE=$(docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>/dev/null | drop_counters || echo "")
docker exec "$CLIENT" sh -c "
ip -6 addr add $P6B_MESH/128 dev $P6B_CLIENT_IF nodad 2>/dev/null
ip -6 route add $P6B_GW/128 via $GW_DNS 2>/dev/null
true" >/dev/null 2>&1
docker exec "$CLIENT" python3 -c '
import socket, sys
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.bind((sys.argv[1], 9))
s.sendto(b"forged", (sys.argv[2], int(sys.argv[3])))
' "$P6B_MESH" "$P6B_GW" "$P6B_PORT" >/dev/null 2>&1 || true
sleep 1
P6B_AFTER_ENTRY=$(docker exec "$GATEWAY" conntrack -L -f ipv6 2>/dev/null | udp_entry "$P6B_VIP" 9 || echo "")
P6B_AFTER=$(docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>/dev/null | drop_counters || echo "")
docker exec "$CLIENT" sh -c "
ip -6 route del $P6B_GW/128 via $GW_DNS 2>/dev/null
ip -6 addr del $P6B_MESH/128 dev $P6B_CLIENT_IF 2>/dev/null
true" >/dev/null 2>&1
read -r P6B_H0 _ _ P6B_F0 <<< "$P6B_BEFORE"
read -r P6B_H1 _ _ P6B_F1 <<< "$P6B_AFTER"
if [ -z "$P6B_BEFORE" ] || [ -z "$P6B_AFTER" ] || [ "$P6B_H0" != "$P6B_H1" ]; then
echo " try $P6B_TRY: table rebuilt during the case ('$P6B_BEFORE' then '$P6B_AFTER'), retrying"
continue
fi
P6B_DONE=true
break
done
if [ "$P6B_DONE" = true ]; then
read -r _ P6B_STATE_AFTER <<< "$P6B_AFTER_ENTRY"
if [ "$P6B_STATE_AFTER" = unreplied ]; then
check "A reply forged from the LAN leaves the flow to $P6B_VIP unreplied" 0
else
check "A reply forged from the LAN reached conntrack (entry '$P6B_AFTER_ENTRY')" 1
fi
if [ "$P6B_F0" != none ] && [ "$P6B_F1" -gt "$P6B_F0" ]; then
check "The forged-source drop counted it ($P6B_F0 -> $P6B_F1)" 0
else
check "The forged-source drop did not count it ($P6B_F0 -> $P6B_F1)" 1
fi
else
check "Forged reply case (no conclusive try in 3)" 1
fi
fi
# Phase 6c: hosts on the gateway's other interfaces cannot use it
#
# A network namespace inside the privileged gateway container stands in for a
# host on another interface (fips-net carries no IPv6). It connects to the
# virtual IP of a live mapping and, directly, to that node's mesh address.
# Both must fail. The controls are the raw pool drop's counter for the first
# and the forward drop's for the second; every rebuild recreates the table and
# resets its counters, so a reading counts only when the table handle did not
# change across the probe, and a changed handle retries the probe.
echo ""
echo "Phase 6c: Non-LAN interfaces cannot use the gateway"
P6C_NS=gwext
SERVER2_MESH=$(docker exec "$SERVER2" bash -c \
"ip -6 -o addr show fips0 | awk '/inet6 fd/ {print \$4}' | cut -d/ -f1 | head -1" \
2>/dev/null || echo "")
P6C_SETUP=false
if docker exec "$GATEWAY" sh -c "
ip netns add $P6C_NS &&
ip link add gwext0 type veth peer name gwext1 &&
ip link set gwext1 netns $P6C_NS &&
ip -6 addr add fd03::1/64 dev gwext0 nodad &&
ip link set gwext0 up &&
ip netns exec $P6C_NS ip link set lo up &&
ip netns exec $P6C_NS ip -6 addr add fd03::2/64 dev gwext1 nodad &&
ip netns exec $P6C_NS ip link set gwext1 up &&
ip netns exec $P6C_NS ip -6 route add default via fd03::1 dev gwext1
" >/dev/null 2>&1; then
P6C_SETUP=true
fi
if [ "$P6C_SETUP" != true ] || [ -z "$SERVER2_MESH" ]; then
check "Non-LAN namespace and $SERVER2 mesh address (setup $P6C_SETUP, mesh '$SERVER2_MESH')" 1
else
P6C_VIP_DONE=false
P6C_MESH_DONE=false
for P6C_TRY in 1 2 3; do
# Re-resolve so the probe meets a live mapping, and gate on its rules.
P6C_VIP=$(docker exec "$CLIENT2" dig +short AAAA "${NPUB_C}.fips" @${GW_DNS} 2>/dev/null \
| grep -m1 "^fd01::" || true)
P6C_GATE=false
for _ in $(seq 1 10); do
P6C_NFT=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null || true)
if [ -n "$P6C_VIP" ] && P6C_TO=$(dnat_to "$P6C_VIP" <<< "$P6C_NFT") \
&& same_addr "$P6C_TO" "$SERVER2_MESH" \
&& P6C_SNAT=$(snat_to "$SERVER2_MESH" <<< "$P6C_NFT") \
&& same_addr "$P6C_SNAT" "$P6C_VIP"; then
P6C_GATE=true
break
fi
sleep 0.5
done
if [ "$P6C_GATE" != true ]; then
echo " try $P6C_TRY: no DNAT and SNAT for '$P6C_VIP' to $SERVER2_MESH"
continue
fi
P6C_BEFORE=$(docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>/dev/null | drop_counters || echo "")
P6C_OUT_VIP=$(docker exec "$GATEWAY" ip netns exec $P6C_NS \
curl -6 -s --max-time 3 "http://[$P6C_VIP]:8000/" 2>&1 || true)
P6C_OUT_MESH=$(docker exec "$GATEWAY" ip netns exec $P6C_NS \
curl -6 -s --max-time 3 "http://[$SERVER2_MESH]:8000/" 2>&1 || true)
P6C_AFTER=$(docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>/dev/null | drop_counters || echo "")
read -r P6C_H0 P6C_POOL0 P6C_FWD0 _ <<< "$P6C_BEFORE"
read -r P6C_H1 P6C_POOL1 P6C_FWD1 _ <<< "$P6C_AFTER"
if [ -z "$P6C_BEFORE" ] || [ -z "$P6C_AFTER" ] || [ "$P6C_H0" != "$P6C_H1" ]; then
echo " try $P6C_TRY: table rebuilt during the probe ('$P6C_BEFORE' then '$P6C_AFTER'), retrying"
continue
fi
P6C_VIP_DONE=true
P6C_MESH_DONE=true
break
done
if [ "$P6C_VIP_DONE" = true ]; then
if ! grep -q "Fuck IPs" <<< "$P6C_OUT_VIP" && [ "$P6C_POOL1" -gt "$P6C_POOL0" ]; then
check "Non-LAN host cannot reach $P6C_VIP (pool drop $P6C_POOL0 -> $P6C_POOL1)" 0
else
check "Non-LAN host reached $P6C_VIP or the pool drop did not count it (response '${P6C_OUT_VIP:0:40}', pool drop $P6C_POOL0 -> $P6C_POOL1)" 1
fi
if ! grep -q "Fuck IPs" <<< "$P6C_OUT_MESH" && [ "$P6C_FWD1" -gt "$P6C_FWD0" ]; then
check "Non-LAN host cannot reach $SERVER2_MESH through fips0 (forward drop $P6C_FWD0 -> $P6C_FWD1)" 0
else
check "Non-LAN host reached $SERVER2_MESH or the forward drop did not count it (response '${P6C_OUT_MESH:0:40}', forward drop $P6C_FWD0 -> $P6C_FWD1)" 1
fi
else
check "Non-LAN probe to a virtual IP (no conclusive try in 3)" 1
check "Non-LAN probe to a mesh address (no conclusive try in 3)" 1
fi
fi
docker exec "$GATEWAY" sh -c "ip link del gwext0 2>/dev/null; ip netns del $P6C_NS 2>/dev/null" \
>/dev/null 2>&1 || true
# Phase 6d: the gateway host's own connections to a virtual IP
#
# Locally generated packets pass the output hook, not prerouting, and every
# pool address is local to the gateway (`local <pool> dev lo`), so only the
# output chain's DNAT keeps the gateway host's own connection to a virtual IP
# from reaching the gateway itself. A decoy listener on the gateway, on the
# same port, stands in for a router service such as dropbear: without the
# DNAT it answers. Both fetches bind their source to the gateway's LAN
# address, as a router's own connections often are, so the host masquerade
# must rewrite it to the fips0 address or gw-server has no route back. The
# gate is the prerouting DNAT, which exists without the output rules, so the
# body check runs, and can fail, on a gateway without them. The mapping this
# phase makes is reclaimed with the others in Phase 8.
echo ""
echo "Phase 6d: The gateway host's own connections to a virtual IP"
P6D_PORT=8009
P6D_TOKEN="p6d-$$-$RANDOM$RANDOM"
P6D_SRV_DIR="/tmp/$P6D_TOKEN-server"
P6D_GW_DIR="/tmp/$P6D_TOKEN-decoy"
P6D_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 "")
P6D_GW=$(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 "")
P6D_SUM=""
P6D_READY=false
if docker exec "$SERVER" sh -c "
mkdir '$P6D_SRV_DIR' &&
echo '$P6D_TOKEN' > '$P6D_SRV_DIR/token' &&
head -c 262144 /dev/urandom > '$P6D_SRV_DIR/big'
" >/dev/null 2>&1; then
P6D_SUM=$(docker exec "$SERVER" sha256sum "$P6D_SRV_DIR/big" 2>/dev/null | awk '{print $1}' || echo "")
fi
P6D_DECOY=false
if docker exec "$GATEWAY" sh -c "
mkdir '$P6D_GW_DIR' &&
echo gateway-decoy > '$P6D_GW_DIR/token' &&
echo gateway-decoy > '$P6D_GW_DIR/big'
" >/dev/null 2>&1; then
P6D_DECOY=true
fi
# Each server writes its own pid, so cleanup kills exactly it.
docker exec -d "$SERVER" sh -c \
"echo \$\$ > '$P6D_SRV_DIR.pid'; exec python3 -m http.server $P6D_PORT -b :: -d '$P6D_SRV_DIR'" \
>/dev/null 2>&1 || true
docker exec -d "$GATEWAY" sh -c \
"echo \$\$ > '$P6D_GW_DIR.pid'; exec python3 -m http.server $P6D_PORT -b :: -d '$P6D_GW_DIR'" \
>/dev/null 2>&1 || true
P6D_LISTENING=false
for _ in $(seq 1 10); do
if docker exec "$SERVER" ss -6lnt 2>/dev/null | grep -q ":$P6D_PORT " \
&& docker exec "$GATEWAY" ss -6lnt 2>/dev/null | grep -q ":$P6D_PORT "; then
P6D_LISTENING=true
break
fi
sleep 0.5
done
if [ -z "$P6D_MESH" ] || [ -z "$P6D_GW" ] || [ -z "$P6D_SUM" ] || [ "$P6D_DECOY" != true ] \
|| [ "$P6D_LISTENING" != true ]; then
check "Gateway-host setup (server mesh '$P6D_MESH', gateway mesh '$P6D_GW', checksum '$P6D_SUM', decoy $P6D_DECOY, listening $P6D_LISTENING)" 1
else
P6D_READY=true
P6D_GATE=false
for P6D_TRY in 1 2 3; do
# The gateway's DNS listens on [::]:53, so the gateway host can ask
# it at its own LAN address, as a router's resolver would forward.
P6D_VIP=$(docker exec "$GATEWAY" dig +short AAAA "${NPUB_B}.fips" "@$GW_DNS" 2>/dev/null \
| grep -m1 "^fd01::" || true)
for _ in $(seq 1 10); do
P6D_NFT=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null || true)
if [ -n "$P6D_VIP" ] && P6D_TO=$(dnat_to "$P6D_VIP" <<< "$P6D_NFT") \
&& same_addr "$P6D_TO" "$P6D_MESH"; then
P6D_GATE=true
break
fi
sleep 0.5
done
if [ "$P6D_GATE" = true ]; then
break
fi
echo " try $P6D_TRY: no prerouting DNAT of '$P6D_VIP' to $P6D_MESH"
done
if [ "$P6D_GATE" != true ]; then
check "Gateway host resolves ${NPUB_B:0:20}...fips to a mapped virtual IP (no conclusive try in 3)" 1
else
P6D_BODY=$(docker exec "$GATEWAY" curl -6 -s --max-time 5 --interface "$GW_DNS" \
"http://[$P6D_VIP]:$P6D_PORT/token" 2>&1 || true)
P6D_GOT=$(docker exec "$GATEWAY" sh -c \
"curl -6 -s --max-time 10 --interface '$GW_DNS' 'http://[$P6D_VIP]:$P6D_PORT/big' | sha256sum" \
2>/dev/null | awk '{print $1}' || echo "")
if [ "$P6D_BODY" = "$P6D_TOKEN" ]; then
check "Gateway host reaches $SERVER through $P6D_VIP (token returned)" 0
elif [ "$P6D_BODY" = gateway-decoy ]; then
check "Gateway host's connection to $P6D_VIP reached the gateway itself (body 'gateway-decoy')" 1
else
check "Gateway host's connection to $P6D_VIP (body '${P6D_BODY:0:60}', expected the token)" 1
fi
if [ "$P6D_GOT" = "$P6D_SUM" ]; then
check "Gateway host fetches 256 KiB from $SERVER through $P6D_VIP intact" 0
else
check "Gateway host's 256 KiB fetch through $P6D_VIP (sha256 '${P6D_GOT:0:16}', expected '${P6D_SUM:0:16}')" 1
fi
P6D_NFT=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null || true)
if P6D_OUT_TO=$(dnat_to "$P6D_VIP" output <<< "$P6D_NFT") && same_addr "$P6D_OUT_TO" "$P6D_MESH"; then
check "Output DNAT translates $P6D_VIP to $SERVER's mesh address" 0
else
check "Output DNAT of $P6D_VIP to $P6D_MESH (read '${P6D_OUT_TO:-none}')" 1
fi
P6D_CT=$(docker exec "$GATEWAY" conntrack -L -f ipv6 2>/dev/null || true)
if P6D_RDST=$(reply_dst tcp "$P6D_PORT" <<< "$P6D_CT") && same_addr "$P6D_RDST" "$P6D_GW"; then
check "The gateway host's flow is masqueraded to its fips0 address $P6D_GW" 0
else
check "The gateway host's flow reply destination '${P6D_RDST:-none}', expected its fips0 address $P6D_GW" 1
fi
if P6D_RSRC=$(reply_src tcp "$P6D_PORT" <<< "$P6D_CT") && same_addr "$P6D_RSRC" "$P6D_MESH"; then
check "The gateway host's flow is answered by $SERVER's mesh address" 0
else
check "The gateway host's flow reply source '${P6D_RSRC:-none}', expected $P6D_MESH" 1
fi
if [ "${FIPS_GW_DEBUG:-}" = 1 ]; then
grep -E "dport=$P6D_PORT\b" <<< "$P6D_CT" | sed 's/^/ /' || true
fi
fi
fi
# Phase 6e: a pool address with no mapping is rejected
#
# Every pool address is local to the gateway, and a mapping's DNAT rewrites
# the destination before the input hook, so whatever reaches input still
# addressed to the pool has no mapping: an expired one, or an address written
# by hand. The input chain rejects it, from the gateway host and from the LAN
# alike, rather than let it reach a listener on the gateway, here 6d's decoy.
# A rejected connection is refused at once (curl exit 7), and the reject's
# counter rises. A mapped virtual IP still reaches its node from the LAN, and
# a unicast neighbour solicitation for a proxied virtual IP, which a LAN client
# sends to check the neighbour and which connection tracking leaves untracked
# and so untranslated, is still answered. Uses 6d's servers, so it runs only
# when their setup succeeded, and is a failure, never a skip, otherwise.
echo ""
echo "Phase 6e: Pool addresses without a mapping are rejected"
if [ "$P6D_READY" != true ]; then
check "Unmapped pool address setup (Phase 6d's servers are not running)" 1
else
P6E_NFT=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null || true)
P6E_ADDR=""
for P6E_CAND in fd01::fffe fd01::fffd fd01::fffc; do
if [ -n "$P6E_NFT" ] && ! dnat_to "$P6E_CAND" <<< "$P6E_NFT" >/dev/null \
&& ! dnat_to "$P6E_CAND" output <<< "$P6E_NFT" >/dev/null; then
P6E_ADDR="$P6E_CAND"
break
fi
done
if [ -z "$P6E_ADDR" ]; then
check "An unmapped pool address to probe (none of three candidates, or no listing)" 1
else
P6E_DONE=false
for P6E_TRY in 1 2 3; do
P6E_BEFORE=$(docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>/dev/null | pool_reject || echo "")
P6E_GW_RC=0
P6E_GW_BODY=$(docker exec "$GATEWAY" curl -6 -s --max-time 5 --interface "$GW_DNS" \
"http://[$P6E_ADDR]:$P6D_PORT/token" 2>/dev/null) || P6E_GW_RC=$?
P6E_LAN_RC=0
P6E_LAN_BODY=$(docker exec "$CLIENT" curl -6 -s --max-time 5 \
"http://[$P6E_ADDR]:$P6D_PORT/token" 2>/dev/null) || P6E_LAN_RC=$?
P6E_AFTER=$(docker exec "$GATEWAY" nft -j list table inet fips_gateway 2>/dev/null | pool_reject || echo "")
read -r P6E_H0 P6E_N0 <<< "$P6E_BEFORE"
read -r P6E_H1 P6E_N1 <<< "$P6E_AFTER"
if [ -n "$P6E_BEFORE" ] && [ -n "$P6E_AFTER" ] && [ "$P6E_H0" != "$P6E_H1" ]; then
echo " try $P6E_TRY: table rebuilt during the probe ('$P6E_BEFORE' then '$P6E_AFTER'), retrying"
continue
fi
P6E_DONE=true
break
done
if [ "$P6E_DONE" != true ]; then
check "Unmapped pool address probe (no conclusive try in 3)" 1
else
for P6E_WHO in "gateway host:$P6E_GW_RC:$P6E_GW_BODY" "LAN client:$P6E_LAN_RC:$P6E_LAN_BODY"; do
P6E_LABEL="${P6E_WHO%%:*}"
P6E_REST="${P6E_WHO#*:}"
P6E_RC="${P6E_REST%%:*}"
P6E_BODY="${P6E_REST#*:}"
if [ "$P6E_BODY" = gateway-decoy ]; then
check "The $P6E_LABEL's connection to unmapped $P6E_ADDR reached the gateway itself (body 'gateway-decoy')" 1
elif [ "$P6E_RC" = 7 ] && [ -z "$P6E_BODY" ]; then
check "The $P6E_LABEL's connection to unmapped $P6E_ADDR is refused" 0
else
check "The $P6E_LABEL's connection to unmapped $P6E_ADDR (curl exit $P6E_RC, body '${P6E_BODY:0:40}'; expected refused)" 1
fi
done
if [ -n "$P6E_BEFORE" ] && [ -n "$P6E_AFTER" ] && [ "$P6E_N1" -ge $((P6E_N0 + 2)) ]; then
check "The pool reject counted both ($P6E_N0 -> $P6E_N1)" 0
else
check "The pool reject counted both (read '$P6E_BEFORE' then '$P6E_AFTER')" 1
fi
fi
fi
# A mapped virtual IP still reaches its node from the LAN.
P6E_VIP=""
P6E_GATE=false
for _ in $(seq 1 10); do
P6E_VIP=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" "@$GW_DNS" 2>/dev/null \
| grep -m1 "^fd01::" || true)
P6E_NFT=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null || true)
if [ -n "$P6E_VIP" ] && P6E_TO=$(dnat_to "$P6E_VIP" <<< "$P6E_NFT") && same_addr "$P6E_TO" "$P6D_MESH"; then
P6E_GATE=true
break
fi
sleep 0.5
done
if [ "$P6E_GATE" != true ]; then
check "LAN client resolves ${NPUB_B:0:20}...fips to a mapped virtual IP (got '$P6E_VIP')" 1
else
P6E_MAPPED=$(docker exec "$CLIENT" curl -6 -s --max-time 5 "http://[$P6E_VIP]:$P6D_PORT/token" 2>&1 || true)
if [ "$P6E_MAPPED" = "$P6D_TOKEN" ]; then
check "A LAN client still reaches $SERVER through mapped $P6E_VIP" 0
else
check "A LAN client's fetch through mapped $P6E_VIP (body '${P6E_MAPPED:0:40}', expected the token)" 1
fi
# The client sends the solicitation itself, as neighbour
# unreachability detection does: to the virtual IP, hop limit 255.
# Any advertisement for the virtual IP answers this probe: the
# client routes the pool via $GW_DNS, so its kernel resolves only
# that next hop and never solicits the virtual IP itself.
if [ -n "$LAN_IF" ] && P6E_PROXY=$(docker exec "$GATEWAY" ip -6 neigh show proxy 2>/dev/null \
| proxy_dev "$P6E_VIP") && [ "$P6E_PROXY" = "$LAN_IF" ]; then
P6E_NS=$(docker exec "$CLIENT" python3 -c '
import ipaddress, select, socket, struct, sys, time
vip = sys.argv[1]
target = ipaddress.IPv6Address(vip).packed
s = socket.socket(socket.AF_INET6, socket.SOCK_RAW, socket.IPPROTO_ICMPV6)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_UNICAST_HOPS, 255)
ns = struct.pack("!BBHI", 135, 0, 0, 0) + target
for _ in range(3):
s.sendto(ns, (vip, 0))
end = time.time() + 1
while time.time() < end:
ready, _, _ = select.select([s], [], [], max(0, end - time.time()))
if not ready:
break
data, _ = s.recvfrom(2048)
if len(data) >= 24 and data[0] == 136 and data[8:24] == target:
print("answered")
sys.exit(0)
print("unanswered")
' "$P6E_VIP" 2>&1 || true)
if [ "$P6E_NS" = answered ]; then
check "A unicast neighbour solicitation for $P6E_VIP is answered" 0
else
check "A unicast neighbour solicitation for $P6E_VIP ('${P6E_NS:0:60}', expected answered)" 1
fi
else
check "Proxy NDP entry for $P6E_VIP on the LAN interface '$LAN_IF' (none found)" 1
fi
fi
fi
docker exec "$SERVER" sh -c \
"kill \$(cat '$P6D_SRV_DIR.pid') 2>/dev/null; rm -rf '$P6D_SRV_DIR' '$P6D_SRV_DIR.pid'" \
>/dev/null 2>&1 || true
docker exec "$GATEWAY" sh -c \
"kill \$(cat '$P6D_GW_DIR.pid') 2>/dev/null; rm -rf '$P6D_GW_DIR' '$P6D_GW_DIR.pid'" \
>/dev/null 2>&1 || true
# Phase 7: Inbound port-forward rules — UDP and a second simultaneous TCP
# forward.
#
# 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)
#
# Checks the DNAT rules and the LAN-side masquerade that set_port_forwards()
# installs, and that the masquerade is listed ahead of every per-mapping
# SNAT. NAT statements are terminal, so a SNAT listed first would take an
# inbound forwarded flow from that mapping's peer and the target would see a
# pool address. Phase 6's listing holds Phase 4's live mappings, so it has
# SNAT rules to order against. The traffic through the forwards is Phase
# 8b's, with no mapping to gw-server, and Phase 8c's, with one.
echo ""
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
# 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
# The LAN masquerade must name the interface Phase 1b derived. An empty
# LAN_IF or a reader that found no single rule fails, so a failed listing
# cannot pass as two empty strings.
if [ -n "$LAN_IF" ] && MASQ_IF=$(masq_iface <<< "$NFT_RULES"); then
if [ "$MASQ_IF" = "$LAN_IF" ]; then
check "LAN masquerade on $MASQ_IF, the LAN interface $LAN_IF" 0
else
check "LAN masquerade on $MASQ_IF, but the LAN interface is $LAN_IF" 1
fi
else
check "LAN masquerade on the LAN interface '$LAN_IF' (no single LAN masquerade rule)" 1
fi
# At least one SNAT must be listed, so an empty or reclaimed table cannot
# pass by having nothing to order.
ORDER_SNAT=$(grep -cE "saddr [0-9a-f:]+ .*snat" <<< "$NFT_RULES" || true)
if SNAT_FIRST=$(snat_before_masq <<< "$NFT_RULES"); then
:
else
SNAT_FIRST=error
fi
if [ "$SNAT_FIRST" = "0" ] && [ "$ORDER_SNAT" -ge 1 ]; then
check "LAN masquerade listed ahead of all $ORDER_SNAT SNAT rules" 0
else
check "LAN masquerade listed ahead of every SNAT rule (SNAT rules before it: $SNAT_FIRST, SNAT rules listed: $ORDER_SNAT)" 1
fi
# Phase 8: TTL expiration and pool reclamation
echo ""
echo "Phase 8: TTL expiration and pool reclamation"
# Config uses ttl=5, pool_grace_period=5, and the pool ticks every 10s. A
# mapping drains at the first tick that finds it with no sessions and more
# than ttl since it was last referenced, and is freed at the first tick more
# than the grace period after that, which is the next one. A DNS answer for a
# name references its mapping, so a mapping answered within ttl of the next
# tick drains a tick later. Measured on core-vm on 2026-10-21: Phase 6e's
# query for NPUB_B came 0.5s before the flush, and in 8 of 22 runs that
# mapping was freed a tick after the other.
#
# So flush conntrack first, so stale sessions from Phase 5 neither keep the
# mappings alive nor reference them again, then answer both names. Each
# mapping's last reference is then its answer, before the time taken below,
# and the tick frees it at most ttl plus two tick periods, 25s, after that,
# whatever the tick's phase. A gateway that frees a mapping a tick late
# frees it more than 25s after its answer.
#
# The wait is timed to the millisecond from when both answers are back, and
# its limit is on when a read begins: a read that begins past the limit and
# still finds a mapping fails the phase. The read's own length, which grows
# under load, then never counts against the gateway, and reads begin every
# 0.2s plus that length. The limit is the 25s bound plus 0.5s for the tick
# to publish what it freed, which took under 0.05s in the runs measured on
# core-vm. Timed in whole seconds with a 1s poll, the wait passed a gateway
# that freed a mapping a tick late in one of three runs.
#
# Two things are polled. The gateway's mapping count is what this phase
# checks. The kernel's SNAT rules are what Phase 8b's gate reads next: the
# tick publishes the control socket's snapshot before its removals reach
# the NAT worker, so a count of zero can be read while the rules are still
# in the table.
docker exec "$GATEWAY" conntrack -F 2>/dev/null || true
docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" "@$GW_DNS" >/dev/null 2>&1 || true
docker exec "$CLIENT2" dig +short AAAA "${NPUB_C}.fips" "@$GW_DNS" >/dev/null 2>&1 || true
RECLAIM_T0_MS=$(date +%s%3N)
RECLAIM_LIMIT_MS=25500
# The gateway's mapping count from its control socket, or "error".
#
# 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.
gw_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"
}
# The number of SNAT rules in the gateway's table, counted as Phase 8b's
# gate counts them, or "error" when the table cannot be listed: a listing
# that failed holds no rules and must not read as zero.
gw_snat_count() {
local nft
nft=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>&1) || { echo "error"; return 0; }
grep -cE "saddr [0-9a-f:]+ .*snat" <<< "$nft" || true
}
# Milliseconds as seconds with one decimal, for the log.
ms_secs() { printf '%d.%d' $(($1 / 1000)) $(($1 % 1000 / 100)); }
echo " Answered both names at $(date -u -d "@$((RECLAIM_T0_MS / 1000))" +%H:%M:%S).$(printf '%03d' $((RECLAIM_T0_MS % 1000))) UTC; waiting up to $(ms_secs "$RECLAIM_LIMIT_MS")s for TTL + grace period to expire..."
RECLAIM_SNAT="not read"
while :; do
RECLAIM_READ_MS=$(( $(date +%s%3N) - RECLAIM_T0_MS ))
MAPPING_COUNT=$(gw_mapping_count)
if [ "$MAPPING_COUNT" = "0" ]; then
RECLAIM_SNAT=$(gw_snat_count)
[ "$RECLAIM_SNAT" = "0" ] && break
fi
[ "$RECLAIM_READ_MS" -le "$RECLAIM_LIMIT_MS" ] || break
sleep 0.2
done
echo " Read begun after $(ms_secs "$RECLAIM_READ_MS")s: mappings $MAPPING_COUNT, SNAT rules $RECLAIM_SNAT"
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. This is the case with no mapping to gw-server:
# it runs after Phase 8 has reclaimed the Phase 4 mapping, and the gate below
# confirms none is left. Phase 8c covers the case with a live mapping. 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 "")
# The gateway's mesh IPv6 (the fd00::/8 address on fips0), which phases 8b and
# 8c both probe whatever 8b's gate decides.
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 [ -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
gw_responders_start
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; Phase 8c starts its own.
gw_responders_stop
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 8c: Inbound port forward from a peer with a live mapping
#
# A LAN client resolves gw-server first, so the gateway holds a mapping and a
# SNAT rule for gw-server's mesh address, and gw-server then reaches tcp
# 18080. The LAN masquerade must still take the flow, so the reply goes to
# the gateway's LAN address, not to the mapping's pool address.
#
# Timing. On correct code the probe is masqueraded, so no conntrack entry
# names the virtual IP and nothing pins the new mapping. The pool drains it on
# the first tick more than the TTL (5s) after the dig and frees it on the next
# tick more than the grace (5s) later; with the 10s tick the rule can be gone
# about 15s after the dig. So the responders start and conntrack is flushed
# before the dig, and right after the gate a GET from gw-client to the
# virtual IP leaves an entry that names it, which pins the mapping from the
# next tick on. Only the dig, the gate poll and that GET sit inside the 15s.
# Do not add a step that can take seconds between the dig and the pin.
#
# The SNAT rule is read again after the probe. No DNS query happens in
# between, so a rule present at both ends was present during the probe;
# without that check, a mapping reclaimed early would let the reply check
# pass with no SNAT to compete with.
echo ""
echo "Phase 8c: Inbound port forward from a peer with a live mapping"
P8C_MISSING=""
[ -n "$GW_MESH_IP" ] || P8C_MISSING="gateway mesh address"
[ -n "$SERVER_MESH" ] || P8C_MISSING="${P8C_MISSING:+$P8C_MISSING and }$SERVER mesh address"
P8C_GATE=false
if [ -n "$P8C_MISSING" ]; then
check "Mapping and SNAT rule to $SERVER before the probe (skipped: no $P8C_MISSING)" 1
else
# Outside the window: the responders, and a flush so Phase 8b's entries
# for tcp 18080, whose reply goes to $GW_DNS, cannot answer for this probe.
gw_responders_start
docker exec "$GATEWAY" conntrack -F 2>/dev/null || true
# The window opens here.
P8C_T0=$SECONDS
P8C_VIP=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" @${GW_DNS} 2>/dev/null \
| grep -m1 "^fd01::" || true)
P8C_SNAT=""
if [ -n "$P8C_VIP" ]; then
while :; do
if P8C_SNAT=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null \
| snat_to "$SERVER_MESH") && same_addr "$P8C_SNAT" "$P8C_VIP"; then
P8C_GATE=true
break
fi
if [ $((SECONDS - P8C_T0)) -ge 5 ]; then
break
fi
sleep 0.5
done
fi
P8C_GATE_VALUES="dig '$P8C_VIP', SNAT target '$P8C_SNAT', $((SECONDS - P8C_T0))s after the dig"
if [ "$P8C_GATE" = true ]; then
check "Mapping and SNAT rule to $SERVER before the probe ($P8C_GATE_VALUES)" 0
else
check "Mapping and SNAT rule to $SERVER before the probe ($P8C_GATE_VALUES)" 1
fi
fi
if [ "$P8C_GATE" = true ]; then
# Pin the mapping: this GET's conntrack entry names the virtual IP.
P8C_PIN=$(docker exec "$CLIENT" curl -6 -s --max-time 3 "http://[$P8C_VIP]:8000/" 2>&1) || true
if echo "$P8C_PIN" | grep -q "Fuck IPs"; then
check "GET from $CLIENT to $P8C_VIP pins the mapping ($((SECONDS - P8C_T0))s after the dig)" 0
else
check "GET from $CLIENT to $P8C_VIP pins the mapping ($((SECONDS - P8C_T0))s after the dig, response: '${P8C_PIN:0:80}')" 1
fi
P8C_RESPONSE=$(docker exec "$SERVER" curl -6 -s --max-time 5 \
"http://[${GW_MESH_IP}]:18080/" 2>&1) || true
if echo "$P8C_RESPONSE" | grep -qE '^inbound-forward-ok$'; then
check "Inbound HTTP via TCP forward 18080 with a live mapping to $SERVER" 0
else
check "Inbound HTTP via TCP forward 18080 with a live mapping (response: '${P8C_RESPONSE:0:80}')" 1
fi
# The window closes here.
P8C_AFTER=""
if P8C_AFTER=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null \
| snat_to "$SERVER_MESH") && same_addr "$P8C_AFTER" "$P8C_VIP"; then
check "SNAT rule to $SERVER still present after the probe ($((SECONDS - P8C_T0))s after the dig)" 0
else
check "SNAT rule to $SERVER gone after the probe (target '$P8C_AFTER', $((SECONDS - P8C_T0))s after the dig); the reply check below proves nothing on this run" 1
fi
# The probe's entry does not depend on the mapping still existing.
P8C_FOUND=""
if P8C_CT=$(docker exec "$GATEWAY" conntrack -L -f ipv6 2>/dev/null) \
&& P8C_FOUND=$(reply_dst tcp 18080 <<< "$P8C_CT") \
&& same_addr "$P8C_FOUND" "$GW_DNS"; then
check "Reply to tcp 18080 with a live mapping goes to $P8C_FOUND, the gateway's LAN address $GW_DNS" 0
else
check "Reply to tcp 18080 with a live mapping goes to '$P8C_FOUND', expected the gateway's LAN address $GW_DNS" 1
fi
else
P8C_SKIP="skipped: ${P8C_MISSING:+no $P8C_MISSING}"
[ -n "$P8C_MISSING" ] || P8C_SKIP="skipped: gate"
check "GET from $CLIENT pins the mapping ($P8C_SKIP)" 1
check "Inbound HTTP via TCP forward 18080 with a live mapping ($P8C_SKIP)" 1
check "SNAT rule to $SERVER still present after the probe ($P8C_SKIP)" 1
check "Reply to tcp 18080 with a live mapping goes to the gateway's LAN address ($P8C_SKIP)" 1
fi
if [ -z "$P8C_MISSING" ]; then
gw_responders_stop
fi
# Phase 8d: Port forwards under the packaged mesh firewall
#
# Loads the tree's packaging/common/fips.nft on the gateway with the forward
# drop-in that docs/how-to/deploy-gateway.md gives, taken from the document
# and given this topology's addresses and ports: gw-server may use the tcp
# 18080 and udp 18081 forwards, and no other mesh node may. tcp 18082 has no
# drop-in line, so it stays open to the whole mesh. gw-server-2 checks that
# first, which also shows it can reach the gateway before its refused probes'
# timeouts are read as refusals. Each refused probe must also show on its own
# drop rule's counter, so a refusal is told apart from a probe that never
# arrived. The table, the drop-in and the responders are removed at the end
# whatever the result, and the removal is checked, so Phases 9 to 12 run as
# before.
echo ""
echo "Phase 8d: Port forwards under the packaged mesh firewall"
P8D_ROOT="$SCRIPT_DIR/../../.."
P8D_NFT="$P8D_ROOT/packaging/common/fips.nft"
P8D_HOWTO="$P8D_ROOT/docs/how-to/deploy-gateway.md"
P8D_DROPIN=/etc/fips/fips.d/forward/port-forwards.nft
SERVER2_MESH=$(docker exec "$SERVER2" bash -c \
"ip -6 -o addr show fips0 | awk '/inet6 fd/ {print \$4}' | cut -d/ -f1 | head -1" \
2>/dev/null || echo "")
P8D_MISSING=""
[ -n "$GW_MESH_IP" ] || P8D_MISSING="gateway mesh address"
[ -n "$SERVER_MESH" ] || P8D_MISSING="${P8D_MISSING:+$P8D_MISSING and }$SERVER mesh address"
[ -n "$SERVER2_MESH" ] || P8D_MISSING="${P8D_MISSING:+$P8D_MISSING and }$SERVER2 mesh address"
# The how-to's drop-in, from its marker line to the end of its fenced block,
# with the example peer, target address and target ports replaced. Each line
# the probes rely on must be there after the replacement: a failed extraction
# is a failed check, not a skip.
P8D_RULES=$(sed -n '/^# \/etc\/fips\/fips\.d\/forward\/port-forwards\.nft$/,/^```$/p' \
"$P8D_HOWTO" 2>/dev/null | sed '$d' | sed \
-e "s/fd97:1111:2222:3333:4444:5555:6666:7777/$SERVER_MESH/g" \
-e "s/fd12:3456::10/$GW_CLIENT_LAN/g" \
-e 's/ tcp dport 80 / tcp dport 8080 /g' \
-e 's/ udp dport 53 / udp dport 8081 /g') || P8D_RULES=""
P8D_EXTRACT=0
for want in \
"ip6 daddr $GW_CLIENT_LAN tcp dport 8080 ip6 saddr { $SERVER_MESH } accept" \
"ip6 daddr $GW_CLIENT_LAN tcp dport 8080 counter drop" \
"ip6 daddr $GW_CLIENT_LAN udp dport 8081 ip6 saddr { $SERVER_MESH } accept" \
"ip6 daddr $GW_CLIENT_LAN udp dport 8081 counter drop"; do
if ! grep -qF -- "$want" <<< "$P8D_RULES"; then
echo " Drop-in taken from $P8D_HOWTO has no line: $want"
P8D_EXTRACT=1
fi
done
check "Forward drop-in taken from the gateway how-to for $SERVER on tcp 18080 and udp 18081" "$P8D_EXTRACT"
P8D_LOADED=false
if [ -n "$P8D_MISSING" ]; then
check "Packaged fips.nft with the forward drop-in loads (skipped: no $P8D_MISSING)" 1
elif [ "$P8D_EXTRACT" -ne 0 ]; then
check "Packaged fips.nft with the forward drop-in loads (skipped: no drop-in)" 1
else
P8D_RC1=0
P8D_RC2=0
P8D_ERR=""
if docker cp "$P8D_NFT" "$GATEWAY:/etc/fips/fips.nft" >/dev/null 2>&1 \
&& docker exec "$GATEWAY" mkdir -p /etc/fips/fips.d/forward \
&& printf '%s\n' "$P8D_RULES" \
| docker exec -i "$GATEWAY" sh -c "cat > $P8D_DROPIN"; then
# Start, then reload, as fips-firewall.service would.
P8D_ERR=$(docker exec "$GATEWAY" nft -f /etc/fips/fips.nft 2>&1) || P8D_RC1=$?
P8D_ERR2=$(docker exec "$GATEWAY" nft -f /etc/fips/fips.nft 2>&1) || P8D_RC2=$?
P8D_ERR="$P8D_ERR$P8D_ERR2"
else
P8D_RC1=copy
fi
if [ "$P8D_RC1" = 0 ] && [ "$P8D_RC2" = 0 ]; then
check "Packaged fips.nft with the forward drop-in loads, then reloads" 0
P8D_LOADED=true
else
check "Packaged fips.nft with the forward drop-in loads, then reloads (exit $P8D_RC1, $P8D_RC2: ${P8D_ERR:0:200})" 1
fi
fi
if [ "$P8D_LOADED" = true ]; then
# Phase 8c leaves tcp 18080 entries; an established entry would be
# accepted ahead of the drop-in.
docker exec "$GATEWAY" conntrack -F >/dev/null 2>&1 || true
gw_responders_start
P8D_JSON=$(docker exec "$GATEWAY" nft -j list chain inet fips forward 2>/dev/null) || P8D_JSON=""
P8D_TCP0=$(fwd_drop tcp 8080 <<< "$P8D_JSON") || P8D_TCP0=""
P8D_UDP0=$(fwd_drop udp 8081 <<< "$P8D_JSON") || P8D_UDP0=""
# The forward with no drop-in line, from the node the drop-in refuses.
P8D_OPEN=$(docker exec "$SERVER2" curl -6 -s --max-time 10 \
"http://[${GW_MESH_IP}]:18082/" 2>&1) || true
if echo "$P8D_OPEN" | grep -q "inbound-forward-ok-2"; then
check "TCP forward 18082, with no drop-in line, open to $SERVER2" 0
else
check "TCP forward 18082, with no drop-in line, open to $SERVER2 (response: '${P8D_OPEN:0:80}')" 1
fi
P8D_TCP_OK=$(docker exec "$SERVER" curl -6 -s --max-time 10 \
"http://[${GW_MESH_IP}]:18080/" 2>&1) || true
if echo "$P8D_TCP_OK" | grep -qE '^inbound-forward-ok$'; then
check "TCP forward 18080 allowed from $SERVER" 0
else
check "TCP forward 18080 allowed from $SERVER (response: '${P8D_TCP_OK:0:80}')" 1
fi
# A one-shot UDP client: prints the reply, or TIMEOUT.
P8D_UDP_PROBE='
import socket, sys
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.settimeout(float(sys.argv[2]))
s.sendto(b"ping-under-firewall", (sys.argv[1], 18081))
try:
data, _ = s.recvfrom(2048)
sys.stdout.write(data.decode("utf-8", "replace"))
except socket.timeout:
sys.stdout.write("TIMEOUT")
except Exception as e:
sys.stdout.write("ERR: " + str(e))
'
P8D_UDP_OK=$(docker exec "$SERVER" python3 -c "$P8D_UDP_PROBE" "$GW_MESH_IP" 5 2>&1) || true
if [ "$P8D_UDP_OK" = "udp-forward-ok:ping-under-firewall" ]; then
check "UDP forward 18081 allowed from $SERVER" 0
else
check "UDP forward 18081 allowed from $SERVER (response: '${P8D_UDP_OK:0:80}')" 1
fi
# Refused: a dropped SYN leaves curl waiting until --max-time (exit 28).
P8D_TCP_RC=0
P8D_TCP_NO=$(docker exec "$SERVER2" curl -6 -s --max-time 3 \
"http://[${GW_MESH_IP}]:18080/" 2>&1) || P8D_TCP_RC=$?
if [ "$P8D_TCP_RC" -eq 28 ] && ! echo "$P8D_TCP_NO" | grep -q "inbound-forward-ok"; then
check "TCP forward 18080 refused to $SERVER2 (curl timed out)" 0
else
check "TCP forward 18080 refused to $SERVER2 (curl exit $P8D_TCP_RC, response: '${P8D_TCP_NO:0:80}')" 1
fi
P8D_UDP_NO=$(docker exec "$SERVER2" python3 -c "$P8D_UDP_PROBE" "$GW_MESH_IP" 3 2>&1) || true
if [ "$P8D_UDP_NO" = "TIMEOUT" ]; then
check "UDP forward 18081 refused to $SERVER2 (no reply in 3s)" 0
else
check "UDP forward 18081 refused to $SERVER2 (response: '${P8D_UDP_NO:0:80}')" 1
fi
# Each refusal must be the drop-in's: its drop rule counted it.
P8D_JSON=$(docker exec "$GATEWAY" nft -j list chain inet fips forward 2>/dev/null) || P8D_JSON=""
P8D_TCP1=$(fwd_drop tcp 8080 <<< "$P8D_JSON") || P8D_TCP1=""
P8D_UDP1=$(fwd_drop udp 8081 <<< "$P8D_JSON") || P8D_UDP1=""
if [ -n "$P8D_TCP0" ] && [ -n "$P8D_TCP1" ] && [ "$((P8D_TCP1 - P8D_TCP0))" -ge 1 ]; then
check "Forward chain's tcp 8080 drop counted the refused probe ($P8D_TCP0 -> $P8D_TCP1)" 0
else
check "Forward chain's tcp 8080 drop counted the refused probe (read '$P8D_TCP0' -> '$P8D_TCP1')" 1
fi
if [ -n "$P8D_UDP0" ] && [ -n "$P8D_UDP1" ] && [ "$((P8D_UDP1 - P8D_UDP0))" -ge 1 ]; then
check "Forward chain's udp 8081 drop counted the refused probe ($P8D_UDP0 -> $P8D_UDP1)" 0
else
check "Forward chain's udp 8081 drop counted the refused probe (read '$P8D_UDP0' -> '$P8D_UDP1')" 1
fi
else
for skipped in "TCP forward 18082 open to $SERVER2" "TCP forward 18080 allowed from $SERVER" \
"UDP forward 18081 allowed from $SERVER" "TCP forward 18080 refused to $SERVER2" \
"UDP forward 18081 refused to $SERVER2" "Forward chain's tcp 8080 drop counted the refused probe" \
"Forward chain's udp 8081 drop counted the refused probe"; do
check "$skipped (skipped: not loaded)" 1
done
fi
# Always, after any result above: remove what this phase added and confirm
# it is gone.
docker exec "$GATEWAY" nft delete table inet fips >/dev/null 2>&1 || true
docker exec "$GATEWAY" rm -rf /etc/fips/fips.d /etc/fips/fips.nft >/dev/null 2>&1 || true
gw_responders_stop
P8D_TABLES_RC=0
P8D_TABLES=$(docker exec "$GATEWAY" nft list tables 2>&1) || P8D_TABLES_RC=$?
if [ "$P8D_TABLES_RC" -eq 0 ] && ! grep -qx "table inet fips" <<< "$P8D_TABLES" \
&& docker exec "$GATEWAY" test ! -e /etc/fips/fips.d; then
check "Mesh firewall table and drop-ins removed from the gateway" 0
else
check "Mesh firewall table and drop-ins removed from the gateway (nft rc $P8D_TABLES_RC: ${P8D_TABLES:0:120})" 1
fi
# Phase 9: SERVFAIL when daemon DNS is down
echo ""
echo "Phase 9: SERVFAIL when daemon DNS is down"
# Kill the fips daemon inside the gateway container (gateway stays running)
docker exec "$GATEWAY" pkill -f "^fips --config" 2>/dev/null || true
sleep 2
# Gateway upstream timeout is 5s, so dig must wait longer than that.
SERVFAIL_RESULT=$(docker exec "$CLIENT" dig +short +tries=1 +time=8 AAAA "test-servfail.fips" @${GW_DNS} 2>&1 || true)
SERVFAIL_STATUS=$(docker exec "$CLIENT" dig +tries=1 +time=8 AAAA "test-servfail.fips" @${GW_DNS} 2>&1 | grep -c "SERVFAIL" || true)
if [ "$SERVFAIL_STATUS" -ge 1 ]; then
check "SERVFAIL when daemon DNS is down" 0
else
check "SERVFAIL when daemon DNS down (got: '${SERVFAIL_RESULT:0:80}')" 1
fi
# Phase 10: Cleanup verification (nftables removed on shutdown)
echo ""
echo "Phase 10: Cleanup on shutdown"
# fips-gateway is PID 1 (exec in entrypoint), so SIGTERM stops the container.
# Verify cleanup by checking container logs for the shutdown sequence.
docker stop --time=10 "$GATEWAY" >/dev/null 2>&1 || true
sleep 1
LOGS=$(docker logs --tail=20 "$GATEWAY" 2>&1)
if echo "$LOGS" | grep -q "shutdown complete"; then
check "Gateway shutdown completed cleanly" 0
else
check "Gateway shutdown (no completion message in logs)" 1
fi
# ── Long-lived gateway restart, shared by phases 11 and 12 ───────────────
# Start the stopped gateway with mappings that outlive the phase, and gate on
# its readiness. A failure is recorded through check under the label prefix
# $1 and returns 1, and the caller ends its phase. On success it sets:
# GW_STARTED the container's start time, for `docker logs --since`, since
# the log still holds every earlier phase
# GW_T0 epoch seconds at the start
# GW_BASELINE allocations after the readiness probe, which allocates
# GW_PROBE the address the readiness probe got for NPUB_B
gw_long_lived_start() {
local prefix="$1"
local config_file="$GENERATED_DIR/gateway/node-a.yaml"
local expect_rev
expect_rev=$(git -C "$SCRIPT_DIR" rev-parse --short=10 HEAD)
# Rewrite in place (same inode): the container sees the host file through
# a single-file bind mount, which a replace-by-rename would leave behind.
python3 - "$config_file" <<'PYEOF'
import sys, yaml
path = sys.argv[1]
with open(path, "r+") as f:
cfg = yaml.safe_load(f)
cfg["gateway"]["dns"]["ttl"] = 1800
cfg["gateway"]["pool_grace_period"] = 1800
f.seek(0)
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)
f.truncate()
PYEOF
docker start "$GATEWAY" >/dev/null
GW_STARTED=$(docker inspect -f '{{.State.StartedAt}}' "$GATEWAY")
GW_T0=$(date -u +%s)
echo " Gateway started at $GW_STARTED (expect rev $expect_rev)"
local seen_ttl seen_grace
seen_ttl=$(docker exec "$GATEWAY" grep -c "ttl: 1800" /etc/fips/fips.yaml || true)
seen_grace=$(docker exec "$GATEWAY" grep -c "pool_grace_period: 1800" /etc/fips/fips.yaml || true)
if [ "$seen_ttl" -ge 1 ] && [ "$seen_grace" -ge 1 ]; then
check "$prefix: container sees ttl 1800 and grace 1800" 0
else
check "$prefix: container config rewrite (ttl: $seen_ttl, grace: $seen_grace)" 1
return 1
fi
# Readiness is a hard gate here, unlike phases 1 and 2.
if wait_for_peers "$GATEWAY" 2 60; then
check "$prefix: gateway peers after restart" 0
else
check "$prefix: gateway peers after restart" 1
return 1
fi
local probe
GW_PROBE=""
for _ in $(seq 1 60); do
probe=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" @${GW_DNS} 2>/dev/null || true)
GW_PROBE=$(grep -m1 "^fd01::" <<< "$probe" || true)
if [ -n "$GW_PROBE" ]; then
break
fi
sleep 1
done
if [ -n "$GW_PROBE" ]; then
check "$prefix: gateway DNS answers after restart" 0
else
check "$prefix: gateway DNS answers after restart" 1
return 1
fi
# The entrypoint re-derives the LAN interface at this start; a mismatch
# reds this check alone, since nothing below depends on the interface.
lan_agree "$prefix: LAN interface after restart"
# fips-gateway reads the entrypoint's copy, not the mounted file checked
# above, and the checks below need its ttl and grace.
local copy_ttl copy_grace
copy_ttl=$(docker exec "$GATEWAY" grep -c "ttl: 1800" /etc/fips/gateway.yaml 2>/dev/null || true)
copy_grace=$(docker exec "$GATEWAY" grep -c "pool_grace_period: 1800" /etc/fips/gateway.yaml 2>/dev/null || true)
copy_ttl=${copy_ttl:-0}
copy_grace=${copy_grace:-0}
if [ "$copy_ttl" -ge 1 ] && [ "$copy_grace" -ge 1 ]; then
check "$prefix: gateway config copy has ttl 1800 and grace 1800" 0
else
check "$prefix: gateway config copy (ttl: $copy_ttl, grace: $copy_grace)" 1
return 1
fi
sleep 1
local started_log rev_lines
started_log=$(docker logs --timestamps --since "$GW_STARTED" "$GATEWAY" 2>&1)
# The co-resident daemon logs its own "(rev ...) starting" line, so
# anchor on the gateway's name.
rev_lines=$(grep -cE "fips-gateway [^ ]+ \(rev ${expect_rev}\) starting" <<< "$started_log" || true)
if [ "$rev_lines" -eq 1 ]; then
check "$prefix: startup line reads rev ${expect_rev}) with no -dirty" 0
else
check "$prefix: startup line for rev ${expect_rev} (found $rev_lines)" 1
return 1
fi
# The container's /var/run is not tmpfs, so the gateway keeps no pool
# state: it must say so once at this start and still answer, which the
# probe above showed. The cross-restart path itself needs a tmpfs and is
# not exercised here.
local tmpfs_warn
# The level token sits between colour codes in the container's log.
tmpfs_warn=$(sed 's/\x1b\[[0-9;]*m//g' <<< "$started_log" | grep -cE " WARN .*Pool state not saved.*not on tmpfs" || true)
if [ "$tmpfs_warn" -eq 1 ]; then
check "$prefix: one warning that pool state is not kept off tmpfs" 0
else
check "$prefix: warnings that pool state is not kept off tmpfs (found $tmpfs_warn, expected 1)" 1
fi
GW_BASELINE=$(grep -c "Allocated virtual IP" <<< "$started_log" || true)
return 0
}
# The pool's compiled-in admission limits. A new name is refused past
# MAPPING_CEILING live mappings, and past a burst of MAPPING_BURST new names
# are admitted at MAPPING_RATE per second, so phases that create many
# mappings retry the rate limit's refusals.
POOL_RS="$SCRIPT_DIR/../../../src/gateway/pool.rs"
# A compiled-in constant from pool.rs, or nothing if the line is not found.
pool_const() {
sed -nE "s/^pub const $1: [a-z0-9]+ = ([0-9]+);$/\1/p" "$POOL_RS"
}
POOL_CEILING=$(pool_const MAPPING_CEILING)
POOL_BURST=$(pool_const MAPPING_BURST)
POOL_RATE=$(pool_const MAPPING_RATE)
# Per-name retry bound for the driver, in seconds: a fixed 30 s. A bound
# sized from the rate (workers x refill interval x 5) is 2 s at 10/s, shorter
# than two of the driver's 1 to 1.5 s retry pauses, and never above 20 s for
# any rate of 1/s or more; 30 s lets a name wait through many pauses. No run
# has had a name reach it. Empty when the rate could not be read, which the
# phases report.
gw_retry_bound() {
[ -n "$POOL_RATE" ] && [ "$POOL_RATE" -gt 0 ] || return 0
echo 30
return 0
}
# Install the AAAA driver in the client: 4 closed-loop workers, one fresh
# socket per query, counts printed as key=value. Given a retry bound in
# seconds, a name answered SERVFAIL or not answered is asked again after a
# pause of 1 to 1.5 s until it is answered or the bound has passed since its
# first query; the exit status is 1 if any name was left unplaced. Without a
# bound each name is asked once and the exit status is 0.
gw_install_driver() {
docker exec -i "$CLIENT" sh -c 'cat > /tmp/gw_driver.py' <<'PYEOF'
import random, socket, struct, sys, threading, time
server = sys.argv[1]
bound = float(sys.argv[2]) if len(sys.argv) > 2 else None
names = [n.strip() for n in sys.stdin if n.strip()]
lock = threading.Lock()
counts = {"answered": 0, "servfail": 0, "timeout": 0, "other": 0,
"retries": 0, "unplaced": 0}
def query(name):
qid = random.getrandbits(16)
pkt = struct.pack(">HHHHHH", qid, 0x0100, 1, 0, 0, 0)
for label in (name + ".fips").split("."):
raw = label.encode()
pkt += bytes([len(raw)]) + raw
pkt += b"\x00" + struct.pack(">HH", 28, 1)
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.settimeout(6)
try:
s.sendto(pkt, (server, 53))
while True:
data, _ = s.recvfrom(4096)
if len(data) >= 12 and struct.unpack(">H", data[:2])[0] == qid:
break
except socket.timeout:
return "timeout"
finally:
s.close()
flags, _, ancount = struct.unpack(">HHH", data[2:8])
rcode = flags & 0xF
if rcode == 2:
return "servfail"
if rcode == 0 and ancount > 0:
return "answered"
return "other"
def place(name):
first = time.monotonic()
while True:
outcome = query(name)
with lock:
counts[outcome] += 1
if bound is None or outcome == "answered":
return
if outcome == "other" or time.monotonic() - first > bound:
with lock:
counts["unplaced"] += 1
return
with lock:
counts["retries"] += 1
time.sleep(1 + random.random() * 0.5)
def worker():
while True:
with lock:
if not names:
return
name = names.pop()
place(name)
threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(" ".join(f"{k}={v}" for k, v in counts.items()))
sys.exit(1 if counts["unplaced"] else 0)
PYEOF
}
# Phase 11: NAT rebuild past the default netlink socket limits
#
# Every change rebuilds the whole fips_gateway table in one netlink batch.
# With the default socket buffers that batch failed from about 105 mappings
# (the acks overflowed the receive buffer, after the commit) and past about
# 313 (the batch overflowed the send buffer, and nothing was committed).
# Drive 400 new names through a gateway whose mappings outlive the phase and
# judge the result on the kernel's own table, which is what a lost rebuild
# leaves wrong, not on the daemon's debug-level success line. The names go
# through the pool's rate limit, so the driver retries its refusals. Runs
# after phase 10, so the gateway container is stopped when it starts, and it
# leaves it stopped.
echo ""
echo "Phase 11: NAT rebuild past default socket limits"
NATBIG_NAMES=400
NATBIG_CAP=180
NATBIG_SETTLE=30
natbig_now() {
date -u +%s
}
natbig_slice() {
NATBIG_LOG=$(docker logs --timestamps --since "$NATBIG_STARTED" "$GATEWAY" 2>&1)
}
natbig_allocated() {
natbig_slice
NATBIG_ALLOCATED=$(grep -c "Allocated virtual IP" <<< "$NATBIG_LOG" || true)
}
# Rules the kernel holds right now. A failed listing is recorded through
# NATBIG_RC, never read as a table with no rules.
natbig_kernel() {
NATBIG_RC=0
NATBIG_NFT=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>&1) || NATBIG_RC=$?
NATBIG_DNAT=$(dnat_count prerouting fd01::/112 <<< "$NATBIG_NFT" || echo -1)
NATBIG_ODNAT=$(dnat_count output fd01::/112 <<< "$NATBIG_NFT" || echo -1)
NATBIG_SNAT=$(grep -cE "saddr [0-9a-f:]+ .*snat" <<< "$NATBIG_NFT" || true)
NATBIG_MASQ=$(grep -c "masquerade" <<< "$NATBIG_NFT" || true)
}
natbig_phase() {
gw_long_lived_start "NAT batch" || return 0
NATBIG_STARTED="$GW_STARTED"
local t0="$GW_T0"
local baseline="$GW_BASELINE"
local target=$((baseline + NATBIG_NAMES))
echo " Baseline allocations after readiness: $baseline; target $target"
if [ "$baseline" -ge 1 ]; then
check "NAT batch: readiness probe allocated (baseline $baseline)" 0
else
check "NAT batch: readiness probe allocated (baseline $baseline)" 1
return 0
fi
# Names: real keys, since the daemon parses each one as a public key.
local names_file have_names
names_file=$(mktemp)
docker exec "$GATEWAY" bash -c \
"for i in \$(seq 1 $NATBIG_NAMES); do fipsctl keygen --stdout; done" \
| grep '^npub1' >"$names_file" || true
have_names=$(wc -l <"$names_file")
if [ "$have_names" -lt "$NATBIG_NAMES" ]; then
check "NAT batch: generated $NATBIG_NAMES names (got $have_names)" 1
rm -f "$names_file"
return 0
fi
local retry_bound
retry_bound=$(gw_retry_bound)
if [ -z "$retry_bound" ]; then
check "NAT batch: MAPPING_RATE read from pool.rs ('$POOL_RATE')" 1
rm -f "$names_file"
return 0
fi
gw_install_driver
local remaining out rc=0
remaining=$((NATBIG_CAP - ($(natbig_now) - t0)))
# timeout reads 0 as no limit and refuses a negative duration.
if [ "$remaining" -le 0 ]; then
check "NAT batch: setup exceeded ${NATBIG_CAP}s cap" 1
rm -f "$names_file"
return 0
fi
out=$(docker exec -i "$CLIENT" timeout "$remaining" \
python3 /tmp/gw_driver.py "$GW_DNS" "$retry_bound" <"$names_file" 2>&1) || rc=$?
rm -f "$names_file"
echo " [$(($(natbig_now) - t0))s] sent $NATBIG_NAMES names: $out (rc=$rc)"
natbig_allocated
if [ "$rc" -eq 0 ] && [ "$NATBIG_ALLOCATED" -eq "$target" ]; then
check "NAT batch: $NATBIG_ALLOCATED live mappings allocated" 0
else
check "NAT batch: live mappings allocated ($NATBIG_ALLOCATED of $target, rc $rc)" 1
fi
# Settle on the kernel: wait until it holds a DNAT rule per allocation,
# or the cap expires. Reaching the cap decides nothing by itself; the
# checks below do.
local settle=0
natbig_kernel
while [ "$NATBIG_DNAT" -ne "$NATBIG_ALLOCATED" ] && [ "$settle" -lt "$NATBIG_SETTLE" ]; do
sleep 1
settle=$((settle + 1))
natbig_kernel
done
# An error logged just after the last commit still counts.
sleep 2
natbig_kernel
natbig_allocated
local nat_fail
nat_fail=$(grep -c "Failed to add NAT rules" <<< "$NATBIG_LOG" || true)
echo " [$(($(natbig_now) - t0))s] allocated=$NATBIG_ALLOCATED nat_add_fail=$nat_fail" \
"table_rc=$NATBIG_RC dnat=$NATBIG_DNAT output_dnat=$NATBIG_ODNAT snat=$NATBIG_SNAT" \
"masquerade=$NATBIG_MASQ settle=${settle}s"
if [ "$nat_fail" -gt 0 ]; then
grep "Failed to add NAT rules" <<< "$NATBIG_LOG" | sed 's/\x1b\[[0-9;]*m//g' \
| sed -n '1p;$p' | sed 's/^/ /'
fi
if [ "$nat_fail" -eq 0 ]; then
check "NAT batch: no NAT rebuild failed" 0
else
check "NAT batch: NAT rebuilds failed ($nat_fail)" 1
fi
if [ "$NATBIG_RC" -eq 0 ]; then
check "NAT batch: nft lists the fips_gateway table" 0
else
check "NAT batch: nft list table failed (rc $NATBIG_RC)" 1
fi
if [ "$NATBIG_DNAT" -eq "$NATBIG_ALLOCATED" ] && [ "$NATBIG_ODNAT" -eq "$NATBIG_ALLOCATED" ] \
&& [ "$NATBIG_SNAT" -eq "$NATBIG_ALLOCATED" ]; then
check "NAT batch: kernel holds a prerouting DNAT, output DNAT and SNAT rule per mapping ($NATBIG_DNAT)" 0
else
check "NAT batch: kernel rules (prerouting dnat $NATBIG_DNAT, output dnat $NATBIG_ODNAT, snat $NATBIG_SNAT, allocated $NATBIG_ALLOCATED)" 1
fi
if [ "$NATBIG_MASQ" -eq 3 ]; then
check "NAT batch: LAN fips0, host fips0 and port-forward LAN masquerades present" 0
else
check "NAT batch: masquerade rules ($NATBIG_MASQ, expected 3: LAN fips0, host fips0, port-forward LAN)" 1
fi
docker stop --time=10 "$GATEWAY" >/dev/null 2>&1 || true
echo " Phase time: $(($(natbig_now) - t0))s"
}
natbig_phase
# Phase 12: Pool admission limits
#
# The pool holds at most MAPPING_CEILING live mappings, and past a burst of
# MAPPING_BURST admits new names at MAPPING_RATE per second; the constants are
# read from src/gateway/pool.rs. Restart the gateway with mappings that
# outlive the phase, make the readiness probe's mapping carry traffic, fill
# the pool to the ceiling through the rate limit with names nobody uses, then
# ask for 20 more new names: each replaces the oldest unused mapping, while
# the mapping that carries traffic is kept and still resolves. Reports rebuild
# and tick durations on the way up and the shutdown duration at the ceiling.
# Runs after phase 11, so the gateway container is stopped when it starts,
# and it leaves it stopped.
echo ""
echo "Phase 12: Pool admission limits"
LIMITS_EXTRA=20
# Sized from both trees: with the limits the fill takes about
# (ceiling - burst) / rate seconds plus retry pauses, and without them a
# measurement run reached ceiling + 20 mappings far sooner.
LIMITS_CAP=300
# Shutdown took 2.4 s at 2000 mappings in a measurement run.
LIMITS_STOP_TIME=30
limits_now() {
date -u +%s
}
limits_slice() {
SLICE=$(docker logs --timestamps --since "$STARTED" "$GATEWAY" 2>&1)
}
# Figures below read lines a grep on the slice has already selected, so the
# log-string guard sees each daemon-log literal. The Python matches no log
# text itself: it takes the daemon's own timestamp and key=value fields.
LIMITS_PY_FIELDS='
import re, sys, statistics
from datetime import datetime, timezone
ANSI = re.compile(r"\x1b\[[0-9;]*m")
FIELD = re.compile(r"\b(\w+)=(\S+)")
def parse(line):
parts = ANSI.sub("", line.rstrip("\n")).split(" ", 2)
whole, _, frac = parts[1].rstrip("Z").partition(".")
t = datetime.strptime(whole, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
t = t.timestamp() + float("0." + (frac or "0"))
return t, dict(FIELD.findall(parts[2] if len(parts) > 2 else ""))
'
limits_phase() {
local ceiling="$POOL_CEILING" burst="$POOL_BURST" rate="$POOL_RATE"
if [ -n "$ceiling" ] && [ -n "$burst" ] && [ -n "$rate" ] && [ "$rate" -gt 0 ] \
&& [ "$ceiling" -gt 1 ]; then
check "Limits: read ceiling $ceiling, burst $burst, rate $rate/s from pool.rs" 0
else
check "Limits: constants in pool.rs (ceiling '$ceiling', burst '$burst', rate '$rate')" 1
return 0
fi
local retry_bound
retry_bound=$(gw_retry_bound)
gw_long_lived_start "Limits" || return 0
STARTED="$GW_STARTED"
local t0="$GW_T0"
local baseline="$GW_BASELINE"
if [ "$baseline" -eq 1 ]; then
check "Limits: the readiness probe holds the only mapping" 0
else
check "Limits: mappings after readiness ($baseline, expected 1)" 1
return 0
fi
# The probe's mapping carries traffic: one GET through it, then wait for
# the tick that sees the reply.
docker exec "$CLIENT" curl -6 -s --max-time 5 "http://[$GW_PROBE]:8000/" >/dev/null 2>&1 || true
local used_seen=1
for _ in $(seq 1 25); do
limits_slice
# Field names and values are separated by colour codes in the log.
if sed 's/\x1b\[[0-9;]*m//g' <<< "$SLICE" | grep "Mapping carried traffic" \
| grep -qF "virtual_ip=$GW_PROBE"; then
used_seen=0
break
fi
sleep 1
done
check "Limits: the probe's mapping $GW_PROBE carried traffic" "$used_seen"
# Names: real keys, since the daemon parses each one as a public key.
local fill=$((ceiling - baseline))
local total=$((fill + LIMITS_EXTRA))
local names_file have_names
names_file=$(mktemp)
docker exec "$GATEWAY" bash -c \
"for i in \$(seq 1 $total); do fipsctl keygen --stdout; done" \
| grep '^npub1' >"$names_file" || true
have_names=$(wc -l <"$names_file")
if [ "$have_names" -lt "$total" ]; then
check "Limits: generated $total names (got $have_names)" 1
rm -f "$names_file"
return 0
fi
gw_install_driver
# Fill: exactly ceiling - 1 new names, each retried through the rate
# limit's refusals. A name left unplaced, or the cap firing, fails.
local remaining out rc=0
remaining=$((LIMITS_CAP - ($(limits_now) - t0)))
if [ "$remaining" -le 0 ]; then
check "Limits: setup exceeded ${LIMITS_CAP}s cap" 1
rm -f "$names_file"
return 0
fi
out=$(sed -n "1,${fill}p" "$names_file" | docker exec -i "$CLIENT" timeout "$remaining" \
python3 /tmp/gw_driver.py "$GW_DNS" "$retry_bound" 2>&1) || rc=$?
echo " [$(($(limits_now) - t0))s] fill of $fill names (retry bound ${retry_bound}s): $out (rc=$rc)"
if [ "$rc" -eq 0 ]; then
check "Limits: fill placed all $fill names" 0
else
check "Limits: fill driver failed (rc $rc)" 1
fi
sleep 1
limits_slice
local rate_refused_fill
rate_refused_fill=$(grep -c "new-mapping rate limit reached" <<< "$SLICE" || true)
# Past the ceiling: each name is retried through the rate limit and must
# be answered, replacing an unused mapping.
rc=0
remaining=$((LIMITS_CAP - ($(limits_now) - t0)))
if [ "$remaining" -le 0 ]; then
check "Limits: fill exceeded ${LIMITS_CAP}s cap" 1
rm -f "$names_file"
return 0
fi
out=$(sed -n "$((fill + 1)),${total}p" "$names_file" | docker exec -i "$CLIENT" \
timeout "$remaining" python3 /tmp/gw_driver.py "$GW_DNS" "$retry_bound" 2>&1) || rc=$?
rm -f "$names_file"
echo " [$(($(limits_now) - t0))s] $LIMITS_EXTRA names past the ceiling: $out (rc=$rc)"
local post_answered
post_answered=$(sed -nE 's/.*answered=([0-9]+).*/\1/p' <<< "$out")
if [ "$rc" -eq 0 ] && [ "${post_answered:-0}" -eq "$LIMITS_EXTRA" ]; then
check "Limits: all $LIMITS_EXTRA names past the ceiling were answered" 0
else
check "Limits: names past the ceiling (answered '${post_answered}', rc $rc)" 1
fi
local probe
probe=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" @${GW_DNS} 2>/dev/null || true)
if [ "$(grep -m1 "^fd01::" <<< "$probe" || true)" = "$GW_PROBE" ]; then
check "Limits: NPUB_B still resolves to $GW_PROBE at the ceiling" 0
else
check "Limits: NPUB_B at the ceiling (got '${probe:0:60}', had $GW_PROBE)" 1
fi
sleep 1
limits_slice
local allocated reclaimed ceiling_refused rate_refused nat_fail nat_rm_fail ndp_fail evicted
allocated=$(grep -c "Allocated virtual IP" <<< "$SLICE" || true)
evicted=$(grep -c "Evicted never-used mapping" <<< "$SLICE" || true)
reclaimed=$(grep -c "Reclaimed virtual IP" <<< "$SLICE" || true)
ceiling_refused=$(grep -c "live-mapping ceiling reached" <<< "$SLICE" || true)
rate_refused=$(grep -c "new-mapping rate limit reached" <<< "$SLICE" || true)
nat_fail=$(grep -c "Failed to add NAT rules" <<< "$SLICE" || true)
nat_rm_fail=$(grep -c "Failed to remove NAT rules" <<< "$SLICE" || true)
ndp_fail=$(grep -c "Failed to add proxy NDP" <<< "$SLICE" || true)
echo " Slice counts: allocated=$allocated evicted=$evicted reclaimed=$reclaimed" \
"ceiling_refused=$ceiling_refused rate_refused=$rate_refused_fill/$rate_refused" \
"nat_add_fail=$nat_fail nat_remove_fail=$nat_rm_fail ndp_fail=$ndp_fail"
if [ "$allocated" -eq $((ceiling + LIMITS_EXTRA)) ]; then
check "Limits: allocations equal the ceiling plus $LIMITS_EXTRA ($allocated)" 0
else
check "Limits: allocations $allocated, expected $((ceiling + LIMITS_EXTRA))" 1
fi
if [ "$evicted" -eq "$LIMITS_EXTRA" ]; then
check "Limits: $LIMITS_EXTRA unused mappings replaced at the ceiling" 0
else
check "Limits: unused mappings replaced ($evicted, expected $LIMITS_EXTRA)" 1
fi
local maps_json live used_probe
maps_json=""
for _ in $(seq 1 15); do
maps_json=$(docker exec "$GATEWAY" bash -c \
'echo "{\"command\":\"show_mappings\"}" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' || echo "")
# The response for a full pool is too long for an argument.
read -r live used_probe < <(python3 -c '
import ipaddress, json, sys
try:
r = json.load(sys.stdin)
ms = r["data"]["mappings"]
probe = ipaddress.ip_address(sys.argv[1])
used = [m.get("used") for m in ms if ipaddress.ip_address(m["virtual_ip"]) == probe]
print(len(ms), "true" if used == [True] else "false")
except Exception:
print("error error")
' "$GW_PROBE" <<< "$maps_json")
[ "$live" = "$ceiling" ] && [ "$used_probe" = true ] && break
sleep 1
done
if [ "$live" = "$ceiling" ] && [ "$used_probe" = true ]; then
check "Limits: $ceiling live mappings with $GW_PROBE marked used" 0
else
check "Limits: show_mappings reports $live live, $GW_PROBE used '$used_probe' (expected $ceiling, true)" 1
fi
if [ "$allocated" -gt 0 ]; then
if [ "$reclaimed" -eq 0 ]; then
check "Limits: no reclaims during the phase" 0
else
check "Limits: reclaims during the phase ($reclaimed)" 1
fi
else
check "Limits: allocations present in the slice (0)" 1
fi
if [ "$ceiling_refused" -eq 0 ]; then
check "Limits: no refusal at the ceiling" 0
else
check "Limits: refusals at the ceiling ($ceiling_refused, expected 0)" 1
fi
# The rate limit must have refused during the fill, so its count is a
# live signal for the counts above.
if [ "$rate_refused_fill" -ge 1 ]; then
check "Limits: the rate limit refused during the fill ($rate_refused_fill)" 0
else
check "Limits: rate refusals during the fill (0)" 1
fi
if [ $((nat_fail + nat_rm_fail + ndp_fail)) -eq 0 ]; then
check "Limits: no NAT or proxy NDP failure lines" 0
else
check "Limits: failure lines (nat add $nat_fail, remove $nat_rm_fail, ndp $ndp_fail)" 1
fi
# Creations in any 10 s window of the fill can be at most a full burst
# plus 10 s of refill. The bound is exact, not approximate: the bucket
# holds at most burst whole tokens at the window's first creation and
# gains one per 1/rate s, so one more creation needs the daemon's log
# timestamps to lag its clock by a full token interval (100 ms at 10/s)
# more at one end of the window than the other. Do not loosen it for skew.
local bound=$((burst + 10 * rate)) most
most=$(grep "Allocated virtual IP" <<< "$SLICE" | python3 -c "$LIMITS_PY_FIELDS
b, fill = int(sys.argv[1]), int(sys.argv[2])
ts = sorted(parse(l)[0] for l in sys.stdin)[b:b + fill]
most = j = 0
for i in range(len(ts)):
while ts[i] - ts[j] > 10:
j += 1
most = max(most, i - j + 1)
print(most)
" "$baseline" "$fill" || true)
if [ -n "$most" ] && [ "$most" -le "$bound" ]; then
check "Limits: at most $most creations in any 10 s of the fill (bound $bound)" 0
else
check "Limits: creations in a 10 s window of the fill ('$most', bound $bound)" 1
fi
local added_timed tick_timed
added_timed=$(grep "Added DNAT/SNAT rules" <<< "$SLICE" | grep -c "elapsed_us" || true)
tick_timed=$(grep "Pool tick" <<< "$SLICE" | grep -c "tick_us" || true)
if [ "$added_timed" -ge 1 ] && [ "$tick_timed" -ge 1 ]; then
check "Limits: rebuild and tick timing lines present" 0
else
check "Limits: timing lines (rebuild $added_timed, tick $tick_timed)" 1
fi
echo " --- Rebuild duration (elapsed_us over the adds in the 20 counts ending at each) ---"
local targets=("$ceiling") rebuild_report
[ "$ceiling" -gt 500 ] && targets=(500 "$ceiling")
rebuild_report=$(grep "Added DNAT/SNAT rules" <<< "$SLICE" | python3 -c "$LIMITS_PY_FIELDS
by_n = {}
for l in sys.stdin:
_, f = parse(l)
if 'error' in f or 'elapsed_us' not in f:
continue
by_n[int(f['mappings'])] = int(f['elapsed_us'])
missing = 0
for t in map(int, sys.argv[1:]):
xs = [by_n[n] for n in range(t - 19, t + 1) if n in by_n]
if not xs:
missing += 1
print(f' rebuild {t}: no successful add line with mappings in [{t - 19}, {t}]')
continue
print(f' rebuild {t}: n={len(xs)} median={statistics.median(xs):.0f}us max={max(xs)}us')
print(f'REBUILD_MISSING={missing}')
" "${targets[@]}" || true)
echo "$rebuild_report" | grep -v '^REBUILD_MISSING='
if echo "$rebuild_report" | grep -q '^REBUILD_MISSING=0$'; then
check "Limits: a successful add line near ${targets[*]} mappings" 0
else
check "Limits: a successful add line near ${targets[*]} mappings" 1
fi
echo " --- Ticks as they fell ---"
grep "Pool tick" <<< "$SLICE" | python3 -c "$LIMITS_PY_FIELDS
for l in sys.stdin:
_, f = parse(l)
print(f\" tick: mappings={f.get('mappings')} read_us={f.get('read_us')} tick_us={f.get('tick_us')}\")
" || true
echo " --- Shutdown at the ceiling ---"
docker stop --time="$LIMITS_STOP_TIME" "$GATEWAY" >/dev/null 2>&1 || true
limits_slice
local shut_lines
shut_lines=$( { grep "fips-gateway shutting down" <<< "$SLICE"; \
grep "fips-gateway shutdown complete" <<< "$SLICE"; } || true)
if [ "$(echo "$shut_lines" | grep -c . || true)" -eq 2 ]; then
echo "$shut_lines" | python3 -c "$LIMITS_PY_FIELDS
ts = [parse(l)[0] for l in sys.stdin]
print(f' shutdown duration: {ts[1] - ts[0]:.3f}s')
" || true
check "Limits: gateway shutdown completed within ${LIMITS_STOP_TIME}s" 0
else
check "Limits: gateway shutdown completion line present" 1
fi
echo " Phase time: $(($(limits_now) - t0))s"
}
limits_phase
# Stops sent while fips and fips-gateway are still starting. These run their
# own containers and keep their own tally; a failure there, or a script that
# cannot run, counts as one failure here.
echo ""
echo "Start-up stop cases"
startup_rc=0
bash "$SCRIPT_DIR/startup-stop-test.sh" || startup_rc=$?
check "Start-up stop: daemon and gateway cases" "$startup_rc"
echo ""
echo "=== Results: $PASSED passed, $FAILED failed ==="
[ "$FAILED" -eq 0 ] && exit 0 || exit 1