From 32bb203ddb1b7115cfc4012aac43eb8207841812 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 22:55:10 +0000 Subject: [PATCH] =?UTF-8?q?fix(expose):=20install=20the=20loopback=20door'?= =?UTF-8?q?s=20missing=20half=20=E2=80=94=20route=5Flocalnet=20+=20masquer?= =?UTF-8?q?ade=20on=20boxnet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drill's E phase moved one layer down: the device now adds, but 127.0.0.1: never reaches the box. Incus's NAT-mode proxy installs only the DNAT (prerouting + output); a loopback-sourced packet then dies twice — the kernel refuses to route it out a non-loopback interface without route_localnet on the bridge, and the box would reply to its OWN 127.0.0.1 without a masquerade. This is the exact plumbing Docker installs on docker0 for '-p 127.0.0.1:x:y'. box-firewall.sh now sets route_localnet=1 on boxnet and masquerades loopback-sourced traffic leaving it (chain expose-snat, table inet box). route_localnet's known risk — 127/8 becomes a routable destination on the bridge — is covered by the existing iifname-boxnet input drop, which fires regardless of destination address. The no-UFW guard now checks the input CHAIN, not the table, since expose-snat shares the table. expose warns (root-free, via /proc) when the host firewall predates this plumbing instead of handing over a door that silently does not answer, and 'box info' now lists open exposures — the drill's nice-to-have: a box with a hole says so. Co-Authored-By: Claude Fable 5 --- bin/box | 39 ++++++++++++++++++++++++++++++--------- host/box-firewall.sh | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/bin/box b/bin/box index 706af4a..b55fd68 100755 --- a/bin/box +++ b/bin/box @@ -830,6 +830,15 @@ cmd_info() { printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \ IPV4 "$(box_ipv4 "$inst")" + # A box with a hole says so — an exposure visible only to --list is a hole + # info would deny. One line per open door. + local d listen + while IFS= read -r d; do + case "$d" in expose-*) : ;; *) continue ;; esac + listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)" + printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}" + done < <(incus config device list "$inst" 2>/dev/null) + echo case "${snaps:-0}" in ''|0) @@ -957,15 +966,19 @@ cmd_incus() { # · Each exposure is a named proxy device (expose-), so 'box info' and # --list can see it and --remove can undo it. A box with a hole says so. # -# Mechanism (VMs): an Incus 'proxy' device forwards host 127.0.0.1: -# to the box's :. Because a VM's forkproxy connects over the network -# to the guest, the in-box server must listen on 0.0.0.0 (not just its own -# loopback) — inside an isolated box that is safe: boxnet + port-isolation + -# the ingress drop mean only this proxy can reach it. And that ingress drop is -# exactly what would eat the forkproxy's connection, so expose adds a SCOPED -# ACL allow (this box's ip + this port only) beside the device. Whether the -# proxy needs that allow, or bypasses the ACL, is settled by the drill, not -# here — the allow is scoped and harmless if redundant. +# Mechanism (VMs): an Incus 'proxy' device in NAT mode DNATs host +# 127.0.0.1: to the box's :. The traffic rides the +# network into the guest, so the in-box server must listen on 0.0.0.0 (not +# just its own loopback) — inside an isolated box that is safe: boxnet + +# port-isolation + the ingress drop mean only this door can reach it. Three +# pieces beside the device itself, each one a drill-found absence: +# · a SCOPED ACL allow (this box's ip + this port only) — the ingress drop +# that makes A7 true would eat the DNAT'd packet; +# · route_localnet + a loopback masquerade on the host (box-firewall.sh) — +# Incus installs only the DNAT, and a loopback-sourced packet can neither +# leave the host nor be answered without them; +# · a static ipv4.address pin on the NIC — NAT mode refuses to start +# without one (see below). exposure_dev() { echo "expose-$1"; } # device name for a port cmd_expose() { @@ -1061,6 +1074,14 @@ cmd_expose() { rm -f "$err" echo "box: 127.0.0.1:$hport → $box:$port" echo "box: (the in-box server must listen on 0.0.0.0:$port, not only its own loopback)" + # The device alone is not the door: the DNAT'd loopback packet also needs + # route_localnet on the bridge (box-firewall.sh installs it, with the + # masquerade). Readable without root — warn instead of handing over a + # door that silently does not answer. + if [ "$(cat /proc/sys/net/ipv4/conf/boxnet/route_localnet 2>/dev/null)" != 1 ]; then + echo "box: WARNING — route_localnet is off on boxnet, so this door will NOT answer." >&2 + echo "box: the host firewall predates expose — apply it: sudo /usr/local/sbin/box-firewall" >&2 + fi else # NEVER swallow incus's reason — the first cut of this verb died with a bare # "could not add the proxy device" and told the drill nothing. diff --git a/host/box-firewall.sh b/host/box-firewall.sh index 9bb2ca7..5fda5f5 100644 --- a/host/box-firewall.sh +++ b/host/box-firewall.sh @@ -18,7 +18,10 @@ 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. - if ! nft list table inet box >/dev/null 2>&1; then + # 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 nft add table inet box nft 'add chain inet box input { type filter hook input priority -5 ; }' nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept @@ -53,6 +56,39 @@ if ! nft list table bridge box >/dev/null 2>&1; then nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop fi +# --- The loopback door's missing half (box expose, #55) ---------------------- +# +# 'box expose' publishes a box port on the host's 127.0.0.1 via an Incus +# NAT-mode proxy device. Incus installs the DNAT (prerouting + output hooks) +# and NOTHING else — its only SNAT is a hairpin rule for the box reaching its +# own exposure. A host-local `curl 127.0.0.1:` is therefore DNAT'd +# toward the box and then dies twice: +# · the kernel refuses to route a loopback-SOURCED packet out a +# non-loopback interface (a martian) unless route_localnet is set on the +# egress bridge; +# · even then, the box would reply to 127.0.0.1 — its OWN loopback — +# unless the source is rewritten to something it can answer. +# This is exactly the plumbing Docker installs on docker0 to make +# `-p 127.0.0.1:x:y` work: route_localnet=1 on the bridge, plus a masquerade +# of loopback-sourced traffic leaving it (the box then sees the gateway and +# replies through it). Scoped to boxnet only, never 'all'. +# +# route_localnet's known risk — it makes 127/8 a routable DESTINATION on the +# interface, so a box could aim frames at the host's loopback services — is +# covered by the ingress stance above: everything arriving on boxnet at the +# host is dropped except DNS/DHCP (UFW 'deny in' or the inet-box input chain), +# and that drop fires regardless of the destination address. +if [ -e "/proc/sys/net/ipv4/conf/$NET/route_localnet" ]; then + sysctl -qw "net.ipv4.conf.$NET.route_localnet=1" +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 + # 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 iptables -C DOCKER-USER -i "$NET" -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -i "$NET" -j ACCEPT