forked from heavy-duty/box
fix(expose): install the loopback door's missing half — route_localnet + masquerade on boxnet
The drill's E phase moved one layer down: the device now adds, but
127.0.0.1:<hport> 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❌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 <noreply@anthropic.com>
This commit is contained in:
parent
44b9d512db
commit
32bb203ddb
2 changed files with 67 additions and 10 deletions
39
bin/box
39
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-<port>), 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:<hostport>
|
||||
# to the box's <ip>:<port>. 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:<hostport> to the box's <ip>:<port>. 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.
|
||||
|
|
|
|||
|
|
@ -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:<hport>` 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue