fix(expose): accept established flows back from boxnet — the input drop was eating the door's replies

Reproduced the drill's E failure on a live stack (Incus 6.0.4, container
box, same setup-host/box-firewall): tcpdump on boxnet shows the SYN
leaving masqueraded as the gateway and the box answering SYN/ACK
instantly — which then dies at the host's input hook. The inet-box input
chain dropped ALL boxnet input except DNS/DHCP, stateless: the reply to
the very connection the door opened. UFW hosts never had this hole
(before.rules accepts RELATED,ESTABLISHED); the nft fallback now matches
that semantics with a ct state established,related accept ahead of the
drop. Boxes still cannot INITIATE toward the host — a box-originated SYN
is a NEW flow, which is what the drop is for.

Also rebuild the chains on every run (add chain + flush + re-add) instead
of skip-if-present: the existence guard pinned every host to the rule set
of the release that first ran there, so an upgraded rule never landed.

Verified end-to-end on the repro stack: curl 127.0.0.1:18091 → HTTP 200;
box→host initiation still times out; DNS carve-out intact; non-exposed
port still dropped; --remove kills the door; re-expose returns 200.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-14 23:13:25 +00:00
parent 32bb203ddb
commit edf8309f99

View file

@ -18,16 +18,25 @@ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active
fi
else
# No UFW: protect the host's own sockets with a dedicated nft table.
# Guard on the CHAIN, not the table — the expose-snat section below also
# lives in this table, and on a UFW host it creates the table first; a later
# run without UFW must still install the input drop.
if ! nft list chain inet box input >/dev/null 2>&1; then
# Flush + rebuild rather than skip-if-present: a guard that only checks
# existence pins every host to the rule set of the release that FIRST ran
# here, and an upgraded rule never lands. 'add chain' with the same spec is
# a no-op, so this converges.
#
# The established,related accept is load-bearing for 'box expose' (#55): the
# door's traffic reaches the box masqueraded as the gateway, so the box's
# REPLY arrives here as input on boxnet — a stateless drop eats it and the
# door times out (drill-found). Boxes still cannot INITIATE toward the host:
# a box-originated SYN is a NEW flow, and NEW is what the drop is for.
# (UFW hosts get the same semantics from ufw's built-in RELATED,ESTABLISHED
# accept in before.rules — this branch must match it.)
nft add table inet box
nft 'add chain inet box input { type filter hook input priority -5 ; }'
nft flush chain inet box input
nft add rule inet box input iifname "$NET" ct state established,related accept
nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept
nft add rule inet box input iifname "$NET" tcp dport 53 accept
nft add rule inet box input iifname "$NET" drop
fi
fi
# --- Sibling isolation: a box must not reach another box --------------------
@ -83,11 +92,10 @@ if [ -e "/proc/sys/net/ipv4/conf/$NET/route_localnet" ]; then
else
echo "box-firewall: $NET does not exist yet — route_localnet not set; expose's loopback door stays dead until this script runs again" >&2
fi
if ! nft list chain inet box expose-snat >/dev/null 2>&1; then
nft add table inet box
nft "add chain inet box expose-snat { type nat hook postrouting priority 110 ; }"
nft add rule inet box expose-snat oifname "$NET" ip saddr 127.0.0.0/8 masquerade
fi
nft add table inet box
nft "add chain inet box expose-snat { type nat hook postrouting priority 110 ; }"
nft flush chain inet box expose-snat
nft add rule inet box expose-snat oifname "$NET" ip saddr 127.0.0.0/8 masquerade
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.
if command -v docker >/dev/null && iptables -L DOCKER-USER -n >/dev/null 2>&1; then