release: box 0.5.0 — codex+grok templates, migrate-host, box expose #56

Merged
dan-claude-bot merged 23 commits from integration/0.5.0 into main 2026-07-15 00:04:54 +00:00
2 changed files with 67 additions and 10 deletions
Showing only changes of commit 32bb203ddb - Show all commits

39
bin/box
View file

@ -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.

View file

@ -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