forked from heavy-duty/box
feat: 'box expose <box> <port>' — a deliberate, loopback-only door to a dev server
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-<port>); --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
This commit is contained in:
parent
9b3522e4ee
commit
de6467a728
3 changed files with 168 additions and 1 deletions
|
|
@ -128,6 +128,7 @@ box rename <box> <new> # rename a box (stop it first)
|
||||||
box down <box> # stop (state kept; `start` resumes)
|
box down <box> # stop (state kept; `start` resumes)
|
||||||
box start <box> # start a stopped box
|
box start <box> # start a stopped box
|
||||||
box rm <box> [--force] # delete the box + its snapshots (asks first)
|
box rm <box> [--force] # delete the box + its snapshots (asks first)
|
||||||
|
box expose <box> <port> # forward a box port to host loopback — see a dev server
|
||||||
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
||||||
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
||||||
box status # deprecated alias for `list`
|
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-level VPN don't resolve inside a box either.
|
||||||
- **Host firewall** — instance → host is dropped except DNS/DHCP, including
|
- **Host firewall** — instance → host is dropped except DNS/DHCP, including
|
||||||
the host's public IPs. Entry is `incus exec` over the local socket only —
|
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
|
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.
|
template ships — can run arbitrary code and touch nothing you care about.
|
||||||
|
|
|
||||||
111
bin/box
111
bin/box
|
|
@ -50,6 +50,7 @@ CMDS=(
|
||||||
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
||||||
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
||||||
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
|
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
|
||||||
|
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
|
||||||
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
"incus^<box> -- <args...>^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^"
|
"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^"
|
"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
|
||||||
box rm work --force
|
box rm work --force
|
||||||
EOF
|
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 <box> <port> [<host-port>] # forward 127.0.0.1:<host-port> → box:<port>
|
||||||
|
box expose <box> --list # what doors are open
|
||||||
|
box expose <box> --remove <port> # 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:<port>, 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'
|
incus) cat <<'EOF'
|
||||||
The door out. box wraps the box lifecycle and the isolation model, not
|
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
|
# doctor's flags belong to the doctor script, not to box
|
||||||
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
|
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
|
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
||||||
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
||||||
fi
|
fi
|
||||||
|
|
@ -818,6 +843,92 @@ cmd_incus() {
|
||||||
incus "${out[@]}"
|
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-<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.
|
||||||
|
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 <port>
|
||||||
|
if [ "$a2" = "--remove" ]; then
|
||||||
|
local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove <port>"
|
||||||
|
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 <port> [<host-port>]
|
||||||
|
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 --------------
|
# --- dispatch: driven by the table, not by a hand-written case --------------
|
||||||
|
|
||||||
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
||||||
|
|
|
||||||
|
|
@ -638,6 +638,59 @@ else
|
||||||
aud "A7 inbound host→box: NOT PROBED"
|
aud "A7 inbound host→box: NOT PROBED"
|
||||||
fi
|
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 &' </dev/null
|
||||||
|
sleep 3
|
||||||
|
if box expose archive "$EP" "$EHP" >/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 >/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"
|
phase "D. The isolation contract, stated"
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue