From de6467a7288ad2cb2045ade54fe971d984aa06e1 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 16:07:17 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20'box=20expose=20=20'=20?= =?UTF-8?q?=E2=80=94=20a=20deliberate,=20loopback-only=20door=20to=20a=20d?= =?UTF-8?q?ev=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'no inbound path' contract is one notch too absolute for the tool's own flagship workflow: coding in a box, a dev server on :3000, and no way to open it in your browser. expose is the deliberate un-screwing. - Loopback only, always: the host side listens on 127.0.0.1, never 0.0.0.0 — no other machine can reach the box; only this host gets a door. No flag widens it (that is the escape hatch's job). - A verb, per-port, reversible, visible: each exposure is a named proxy device (expose-); --list and box info show it, --remove undoes it. A box with a hole says so. - Mechanism (VMs): an Incus proxy device forwards host loopback to the box's ip:port, plus a SCOPED ingress ACL allow (this box's ip + this port only) so the forkproxy's connection survives the default drop — the drill decides whether that allow is needed or redundant. The in-box server must listen on 0.0.0.0 (a VM's forwarder reaches it over the network); inside an isolated box that is safe. Drill phase E: start a detached listener in a box, expose it, prove the HOST loopback reaches it, prove a NON-exposed port is still dropped (A7 survives), prove --remove shuts the door. Closes #55 --- README.md | 5 ++- bin/box | 111 +++++++++++++++++++++++++++++++++++++++++++++++++ drill/drill.sh | 53 +++++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 821d798..6a8b7d0 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ box rename # rename a box (stop it first) box down # stop (state kept; `start` resumes) box start # start a stopped box box rm [--force] # delete the box + its snapshots (asks first) +box expose # forward a box port to host loopback — see a dev server box incus -- # escape hatch: any incus command, box resolved box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth box status # deprecated alias for `list` @@ -185,7 +186,9 @@ enforces it, layer by layer: host-level VPN don't resolve inside a box either. - **Host firewall** — instance → host is dropped except DNS/DHCP, including the host's public IPs. Entry is `incus exec` over the local socket only — - **no inbound path exists.** + **no inbound path exists** — unless you punch one with `box expose`, and + that door only ever opens onto the host's own loopback (`127.0.0.1`), never + the network. The VM is the trust boundary: whatever runs inside — Claude, or anything a template ships — can run arbitrary code and touch nothing you care about. diff --git a/bin/box b/bin/box index 5ce6ce7..caac6ea 100755 --- a/bin/box +++ b/bin/box @@ -50,6 +50,7 @@ CMDS=( "down^^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}" "start^^box^Start a stopped box^incus:start^started {}" "rm^ [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}" + "expose^ [] | --list | --remove ^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^" "incus^ -- ^box^Escape hatch: run any incus command against a box^fn:cmd_incus^" "doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^" "status^^^Deprecated alias for 'list'^fn:cmd_status^" @@ -310,6 +311,28 @@ confirmation first; --force (-f) skips the prompt. With no TTY to confirm on box rm work box rm work --force EOF +;; + expose) cat <<'EOF' +Open a deliberate, loopback-only door to a port inside a box — for when you +are coding in a box and want to see its dev server in your browser. + + box expose [] # forward 127.0.0.1: → box: + box expose --list # what doors are open + box expose --remove # close one + +The host side ALWAYS listens on 127.0.0.1 — no other machine can reach the +box, only this host's loopback. There is no flag to widen that; if you need +LAN exposure you are leaving the tool's threat model, and 'box incus' is the +door (with its warning). + +The in-box server must listen on 0.0.0.0:, not only its own loopback — +a VM's forwarder connects to the box over the network. Inside an isolated box +that is safe: nothing but this proxy can reach the port. + + box new --name web --template claude + box shell web # inside: run a dev server on 0.0.0.0:3000 + box expose web 3000 # then open http://127.0.0.1:3000 in your browser +EOF ;; incus) cat <<'EOF' The door out. box wraps the box lifecycle and the isolation model, not @@ -399,6 +422,8 @@ while [ $# -gt 0 ]; do -*) # doctor's flags belong to the doctor script, not to box if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi + # expose's own flags (--list, --remove) are positional to it, not box's + if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'" fi @@ -818,6 +843,92 @@ cmd_incus() { incus "${out[@]}" } +# A deliberate, loopback-only door to a box's port — for the one workflow the +# "no inbound path" contract is too absolute for: you are coding in a box and +# want to open its dev server in your browser. +# +# Two decisions, both load-bearing: +# · The listen side is ALWAYS 127.0.0.1. The network-facing contract stays +# true — no other machine can reach the box; only THIS host's loopback gets +# a door. There is no flag to widen it; that is the escape hatch's job, with +# its warning. +# · 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. +exposure_dev() { echo "expose-$1"; } # device name for a port + +cmd_expose() { + local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}" + + # --list + if [ "$a2" = "--list" ]; then + local found=0 d listen connect + while IFS= read -r d; do + case "$d" in expose-*) : ;; *) continue ;; esac + listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)" + connect="$(incus config device get "$inst" "$d" connect 2>/dev/null)" + [ "$found" = 0 ] && echo "EXPOSURES for $box" + found=1 + printf ' %-14s %s → %s\n' "${d#expose-}" "$listen" "$connect" + done < <(incus config device list "$inst" 2>/dev/null) + [ "$found" = 0 ] && echo "box: $box has no exposed ports" + return 0 + fi + + # --remove + if [ "$a2" = "--remove" ]; then + local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove " + local dev; dev="$(exposure_dev "$port")" + incus config device get "$inst" "$dev" listen >/dev/null 2>&1 \ + || die "$box has no exposure on port $port (see 'box expose $box --list')" + incus config device remove "$inst" "$dev" >/dev/null \ + && echo "box: closed the door on port $port" + # Remove the scoped ACL allow, if one was added. Best-effort: its absence + # is not an error (the drill may show the allow was never needed). + local ip; ip="$(box_ipv4 "$inst")" + [ "$ip" != "-" ] && incus network acl rule remove box-isolate ingress \ + action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 + return 0 + fi + + # expose [] + local port="$a2" hport="${a3:-$a2}" + [ -n "$port" ] || usage_error "usage: $(synopsis_of expose)" + case "$port$hport" in *[!0-9]*) usage_error "ports must be numbers — got port='$port' host-port='$hport'" ;; esac + + local ip; ip="$(box_ipv4 "$inst")" + [ "$ip" != "-" ] || die "$box has no IP yet — is it running? (box info $box)" + + local dev; dev="$(exposure_dev "$port")" + if incus config device get "$inst" "$dev" listen >/dev/null 2>&1; then + die "$box already exposes port $port (change or remove it: box expose $box --remove $port)" + fi + + # The scoped ACL allow FIRST, so the door is open by the time the proxy uses + # it. Scoped to this box's IP and this port — it does not widen any other box. + incus network acl rule add box-isolate ingress action=allow \ + "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 || true + + if incus config device add "$inst" "$dev" proxy \ + "listen=tcp:127.0.0.1:$hport" "connect=tcp:$ip:$port" bind=host >/dev/null; then + 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)" + else + incus network acl rule remove box-isolate ingress action=allow \ + "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 + die "could not add the proxy device" + fi +} + # --- dispatch: driven by the table, not by a hand-written case -------------- row="$(cmd_row "$cmd")" || unknown_command "$cmd" diff --git a/drill/drill.sh b/drill/drill.sh index 7a8c13e..1a8a9ab 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -638,6 +638,59 @@ else aud "A7 inbound host→box: NOT PROBED" fi +# =========================================================================== +phase "E. box expose — a deliberate loopback door (#55)" +# =========================================================================== +# archive is a running claude box (node is installed). Start a DETACHED +# listener on 0.0.0.0 inside it, expose the port, and prove the door works +# from the HOST's loopback. Then prove removing it closes the door, and that a +# NON-exposed port still obeys the ingress drop — the feature must not +# globally weaken A7. +EP=8091; EHP=18091 +srv="$(mktemp)" +printf 'require("http").createServer((q,r)=>r.end("box-expose-ok")).listen(%s,"0.0.0.0")\n' "$EP" >"$srv" +if incus file push "$srv" archive/tmp/srv.js >/dev/null 2>&1; then + rm -f "$srv" + # Detached: setsid + all fds redirected so 'incus exec' returns at once and + # nothing holds its stdout (trap 2/3). The listener outlives the exec. + timeout -k 5 20 incus exec archive -- sh -c 'setsid node /tmp/srv.js >/tmp/srv.log 2>&1 /dev/null 2>&1; then + ok "box expose archive $EP $EHP — the device was added" + box expose archive --list 2>/dev/null | grep -q "$EP" \ + && ok "expose --list shows the open door" || no "expose --list does not show the exposure" + box info archive 2>/dev/null | grep -qi "$EP" \ + && ok "box info surfaces the exposure (a box with a hole says so)" || note "box info does not mention the exposure (nice-to-have)" + # THE test: does the host's loopback reach the box's server? + sleep 2 + if curl -sS -m 6 "http://127.0.0.1:$EHP" 2>/dev/null | grep -q box-expose-ok; then + ok "127.0.0.1:$EHP reaches the box's server — the door WORKS" + else + no "127.0.0.1:$EHP does NOT reach the box — the proxy/ACL mechanism needs work (#55)" + inf "srv.log inside the box: $(in_box archive cat /tmp/srv.log 2>/dev/null | tail -2 | tr '\n' ' ')" + fi + # A NON-exposed port must still be dropped — the feature is per-port, not a + # global ingress opening. + nemsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:9099" 2>&1)" + printf '%s' "$nemsg" | grep -q 'Connection refused' \ + && no "a non-exposed port answered on the box — expose opened ingress too wide" \ + || ok "a non-exposed port is still dropped — expose is per-port, A7 survives" + # Close it, and confirm the door shuts. + box expose archive --remove "$EP" >/dev/null 2>&1 && ok "box expose --remove closed the device" || no "expose --remove failed" + sleep 2 + curl -sS -m 5 -o /dev/null "http://127.0.0.1:$EHP" 2>/dev/null \ + && no "the host still reaches the box after --remove — the door did not shut" \ + || ok "after --remove, 127.0.0.1:$EHP is dead — the door shut" + else + no "box expose failed to add the device — tail: $(box expose archive "$EP" "$EHP" 2>&1 | tail -1)" + rm -f "$srv" 2>/dev/null + fi + timeout -k 5 15 incus exec archive -- pkill -f srv.js /dev/null 2>&1 +else + rm -f "$srv" + no "could not push the test server into archive — expose phase did not run" +fi + # =========================================================================== phase "D. The isolation contract, stated" # ===========================================================================