Two things: 1. The host scripts are first-class verbs now — 'box setup-host', 'box teardown-host [--purge-incus]', 'box migrate-host --box <n>'. Nobody should have to run ~/.local/share/box/host/<script>.sh; that read like an external script and exposed an install path. Each verb execs the installed script with its flags passed through (same pattern as 'box doctor'). README, doctor hints, and the uninstall section point at the verbs now. 2. The repo-runbook convention is '.box/', not '.claudebox/'. Renamed across docs and the templates' agent briefing; the briefing tells the agent to read either, and box-recipe.md notes the rename, so repos still shipping '.claudebox/' keep working through the transition. (Consuming repos rename their own folder — tracked separately.) Also widened the help command column for the longer verb names, and fixed one sed-casualty where a broad '.claudebox'→'.box' pass had turned the README's legacy user.claudebox tag into user.box.
1032 lines
44 KiB
Bash
Executable file
1032 lines
44 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# box — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
|
# The command surface is the CMDS table below: it is the single source of truth
|
|
# for what exists, what it looks like, what the help says, and what runs. The
|
|
# help cannot drift from the code, because it is rendered from the same rows.
|
|
set -euo pipefail
|
|
|
|
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
|
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
|
|
inst="" # the resolved Incus instance, set by the 'box' precondition
|
|
|
|
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
|
|
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
|
|
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The command table.
|
|
#
|
|
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message
|
|
#
|
|
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
|
|
#
|
|
# preconditions (comma-separated):
|
|
# box first positional is a box: resolve it, and REFUSE if the instance
|
|
# isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
|
|
# arg2 a second positional is required
|
|
# stopped the box must not be running
|
|
# confirm destructive: prompt unless --force
|
|
#
|
|
# action:
|
|
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
|
|
# fn:<function> call a shell function (it has real work to do)
|
|
#
|
|
# ok message: printed on success; {} = the box, {1} = the second positional.
|
|
#
|
|
# Adding a thin verb is one row. If a request can't be expressed as a row and
|
|
# doesn't enforce a box invariant, it is incus's job, not ours — that is
|
|
# what `box incus` is for.
|
|
CMDS=(
|
|
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
|
|
"templates^^^List the templates this install can mint^fn:cmd_templates^"
|
|
"list^[--json]^^List your boxes^fn:cmd_list^"
|
|
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
|
|
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
|
|
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
|
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
|
|
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
|
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:restore^restored {} to {1}"
|
|
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
|
|
"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 {}"
|
|
"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^"
|
|
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
|
|
"setup-host^^^One-time host setup: Incus, the boxnet stack, the profile, the firewall^fn:cmd_setup_host^"
|
|
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
|
|
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
|
|
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
|
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
|
|
)
|
|
|
|
cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
|
|
verbs() { local r; for r in "${CMDS[@]}"; do echo "${r%%^*}"; done; }
|
|
is_command() { cmd_row "$1" >/dev/null 2>&1; }
|
|
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
|
|
# error paths inside it — a global read would clobber the row being dispatched.
|
|
field() {
|
|
local r f_syn f_pre f_sum f_act f_ok
|
|
r="$(cmd_row "$1")" || return 1
|
|
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok <<<"$r"
|
|
case "$2" in
|
|
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
|
|
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
|
|
esac
|
|
}
|
|
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
|
|
|
|
# Nearest command by edit distance — a typo should point somewhere, not just fail.
|
|
suggest() {
|
|
verbs | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
|
|
function dist(a, b, la, lb, i, j, c, prev, cur) {
|
|
la = length(a); lb = length(b)
|
|
for (j = 0; j <= lb; j++) prev[j] = j
|
|
for (i = 1; i <= la; i++) {
|
|
cur[0] = i
|
|
for (j = 1; j <= lb; j++) {
|
|
c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1
|
|
cur[j] = prev[j] + 1
|
|
if (cur[j - 1] + 1 < cur[j]) cur[j] = cur[j - 1] + 1
|
|
if (prev[j - 1] + c < cur[j]) cur[j] = prev[j - 1] + c
|
|
}
|
|
for (j = 0; j <= lb; j++) prev[j] = cur[j]
|
|
}
|
|
return prev[lb]
|
|
}
|
|
BEGIN { best = 99 }
|
|
{ d = dist(w, $0); if (d < best) { best = d; hit = $0 } }
|
|
END { if (best <= 2) print hit }'
|
|
}
|
|
|
|
unknown_command() {
|
|
local hint; hint="$(suggest "$1")"
|
|
if [ -n "$hint" ]; then
|
|
echo "box: unknown command: $1 — did you mean '$hint'?" >&2
|
|
else
|
|
echo "box: unknown command: $1" >&2
|
|
fi
|
|
echo "try 'box help' for the command list." >&2
|
|
exit 2
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
|
|
|
|
USAGE
|
|
box <command> [<args>] [options]
|
|
|
|
COMMANDS
|
|
EOF
|
|
local r v sum
|
|
for r in "${CMDS[@]}"; do
|
|
IFS='^' read -r v _ _ sum _ _ <<<"$r"
|
|
printf ' %-13s %s\n' "$v" "$sum"
|
|
done
|
|
cat <<'EOF'
|
|
|
|
OPTIONS
|
|
--name <box> Name for the new box (new)
|
|
--template <t> Template to mint from (default: blank) (new)
|
|
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
|
|
--vm Force VM mode: the trust-less target (new)
|
|
--container Force container mode: weaker isolation, (new)
|
|
dev/test only. Default where /dev/kvm is absent.
|
|
--json Emit Incus JSON instead of a table (list, info)
|
|
--force, -f Delete without the confirmation prompt (rm)
|
|
--remote <r> Act on Incus remote <r> (any)
|
|
--help, -h Help; after a command, help for that command
|
|
--version, -V Print the box version
|
|
|
|
Options come after the command: 'box list --json', not 'box --json list'.
|
|
|
|
EXAMPLES
|
|
# mint a claude box and log in inside it — the tool never handles your token
|
|
box new --name work --template claude
|
|
box shell work # then: run 'claude', then /login
|
|
|
|
# log in once, reuse forever: checkpoint the authed box, clone from it
|
|
box snapshot work authed
|
|
box new --name feature --from work/authed
|
|
|
|
# the default: a blank box — same isolation, nobody home
|
|
box new --name scratch
|
|
box templates
|
|
|
|
# what have I got, and what can I clone?
|
|
box list
|
|
box info work
|
|
|
|
# run something without opening a shell
|
|
box exec work -- git -C project pull
|
|
|
|
# anything box doesn't wrap: boxes are plain Incus instances
|
|
box incus work -- config show
|
|
|
|
EXIT STATUS
|
|
0 ok
|
|
1 it went wrong (Incus failed, no such box, aborted at a prompt)
|
|
2 you asked wrong (unknown command, bad flag, destructive act without --force)
|
|
|
|
THE MODEL
|
|
A box carries NO credentials. You authenticate interactively inside it
|
|
('claude' then /login; 'gh auth login'); box never stores or injects a
|
|
secret. A box reaches the public internet and nothing else — there is no
|
|
inbound path. Destroying a box loses nothing you didn't push.
|
|
|
|
box owns a command when it must enforce something Incus cannot see: the
|
|
user.box=1 boundary, the isolation stack, or the creds-free snapshot
|
|
workflow. Everything else is Incus's job — and 'box incus' is the door.
|
|
|
|
Docs: https://github.com/heavy-duty/box
|
|
EOF
|
|
}
|
|
|
|
# The synopsis and the summary come from the table; only prose lives here, and
|
|
# only where a command has something to say beyond its summary.
|
|
help_cmd() {
|
|
echo "usage: $(synopsis_of "$1")"
|
|
echo
|
|
case "$1" in
|
|
new) cat <<'EOF'
|
|
Mint a box. Without --from, launches a fresh box from a template (default:
|
|
blank — bare Debian 13, nobody home; --template claude gets Claude Code
|
|
installed, creds-free, ~10 min cold). With --from, clones an existing box or
|
|
one of its snapshots — login state, git creds and clones carry over,
|
|
isolation is preserved, and the clone knows its template's user without
|
|
being told.
|
|
|
|
--name <box> Required. The box's name.
|
|
--template <t> Template to mint from; 'box templates' lists them.
|
|
A template sets image, user and resources — never
|
|
the network: every template gets the same isolation.
|
|
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
|
|
--vm | --container Force the mode. VM is the trust boundary and the
|
|
default wherever /dev/kvm exists; container mode
|
|
(security.nesting=true) is the fallback for hosts
|
|
without nested virt — weaker isolation, dev/test only.
|
|
|
|
Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK
|
|
environment variables override them at mint time (a small host shrinks a box
|
|
without editing a template it doesn't own).
|
|
|
|
box new --name scratch # blank, the default
|
|
box new --name work --template claude
|
|
box new --name feature --from work/authed
|
|
EOF
|
|
;;
|
|
templates) cat <<'EOF'
|
|
List the templates this install can mint, with their descriptions. A template
|
|
is a directory under templates/: a box.env (image, user, resources — parsed
|
|
against an allowlist, never sourced) and a user-data.yaml (cloud-init, passed
|
|
to Incus verbatim). Templates cannot touch the network or security flags —
|
|
the shared box-net profile is the placement contract, so every template gets
|
|
the same isolation.
|
|
|
|
box templates
|
|
box new --name scratch --template blank
|
|
EOF
|
|
;;
|
|
list) cat <<'EOF'
|
|
List the boxes box minted on this host: name, state, type, snapshot count.
|
|
Takes no box — for one box, that's 'box info <box>'.
|
|
|
|
--json Incus's JSON, straight through, for scripting.
|
|
|
|
box list
|
|
EOF
|
|
;;
|
|
info) cat <<'EOF'
|
|
Show one box: state, type, IP address, and — the reason this exists — the
|
|
labels of its snapshots, with the --from line to clone one.
|
|
|
|
--json Incus's JSON, straight through, for scripting.
|
|
|
|
box info work
|
|
EOF
|
|
;;
|
|
shell) cat <<'EOF'
|
|
Open an interactive shell in a running box as the 'claude' user. This is the
|
|
only entry path — there is no SSH and no inbound route to a box.
|
|
|
|
box shell work
|
|
EOF
|
|
;;
|
|
exec) cat <<'EOF'
|
|
Run a command inside a box as the 'claude' user. Everything after -- is passed
|
|
through untouched; the -- is required, or box will read your command's
|
|
flags as its own.
|
|
|
|
box exec work -- git -C project pull
|
|
box exec work -- claude --version
|
|
EOF
|
|
;;
|
|
tmux) cat <<'EOF'
|
|
A shell that survives you. 'shell' is a child of the exec connection — if your
|
|
terminal or SSH session drops, everything running in it is SIGHUP'd, and a
|
|
long Claude run dies with it. This attaches a tmux session instead
|
|
(new-session -A): created if new, reattached if it exists — so starting work
|
|
and resuming after a disconnect are the same command, with no state to
|
|
remember.
|
|
|
|
The session name (default: main) buys parallel streams in one box:
|
|
|
|
box tmux work # attach or create 'main'
|
|
box tmux work run-1 # a second, independent stream, same box
|
|
|
|
Detach with Ctrl-b d; 'exit' ends the session. For a plain shell with none of
|
|
tmux's semantics, 'box shell' is unchanged.
|
|
EOF
|
|
;;
|
|
snapshot) cat <<'EOF'
|
|
Checkpoint a box. Snapshots are how an authenticated box is reused: log in
|
|
once, snapshot, then 'new --from <box>/<label>' as often as you like. The
|
|
label defaults to manual-<epoch>; 'box info <box>' shows the labels you
|
|
have.
|
|
|
|
box snapshot work authed
|
|
EOF
|
|
;;
|
|
restore) cat <<'EOF'
|
|
Roll a box back to one of its snapshots, in place. Anything in the box since
|
|
that snapshot is lost. 'box info <box>' lists the labels.
|
|
|
|
box restore work authed
|
|
EOF
|
|
;;
|
|
rename) cat <<'EOF'
|
|
Rename a box. Incus cannot rename a running instance, so stop it first:
|
|
|
|
box down work
|
|
box rename work archive
|
|
box start archive
|
|
|
|
Snapshots and Claude auth follow the box; anything referring to the old name by
|
|
hand (a --from line, a script) does not.
|
|
EOF
|
|
;;
|
|
rm) cat <<'EOF'
|
|
Delete a box and every snapshot it has. This cannot be undone, so it asks for
|
|
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
|
|
(a script, a pipe), it refuses unless --force is given.
|
|
|
|
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 <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'
|
|
The door out. box wraps the box lifecycle and the isolation model, not
|
|
all of Incus — so when you need something it doesn't wrap, run Incus through
|
|
here and keep the safety rail that matters: the box name is resolved and
|
|
checked against the user.box=1 tag (or its legacy spelling), so you cannot aim it at an instance
|
|
box didn't mint.
|
|
|
|
Everything after -- is passed to incus verbatim. A literal {} is replaced with
|
|
the resolved instance name; with no {}, the instance is appended at the end.
|
|
The command that will run is echoed before it runs.
|
|
|
|
box incus work -- config show
|
|
box incus work -- config device add {} extra disk source=/data path=/data
|
|
|
|
Changing the profile, the network, a device or a security.* key can take a box
|
|
outside the isolation stack. box warns and then does as you asked — from
|
|
there, the trust boundary is yours to keep.
|
|
EOF
|
|
;;
|
|
doctor) cat <<'EOF'
|
|
Answer "is this host fit to mint boxes?" from ground truth, not config claims:
|
|
is the Incus daemon answering, is a dnsmasq actually serving boxnet, does
|
|
the kernel's bridge port say 'isolated on', is the resolver pinned or is a
|
|
host VPN's DNS leaking into boxes, can a box actually resolve names. Every
|
|
check exists because its fault has happened — most kill a cold mint with a
|
|
cloud-init error that names none of them.
|
|
|
|
--fix also revert what a drill run may have left behind
|
|
--pin-dns pin boxnet's resolver to public upstreams and re-test
|
|
(setup-host.sh now pins by default; this is the quick test)
|
|
|
|
box doctor
|
|
box doctor --fix
|
|
|
|
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
|
|
unless --fix or --pin-dns is given.
|
|
EOF
|
|
;;
|
|
setup-host) cat <<'EOF'
|
|
Prepare this host to mint boxes — one time. Installs Incus and builds the
|
|
isolation stack: the boxnet NAT bridge (resolver pinned), the box-isolate
|
|
ACL, the box-net profile, and the firewall rules, all re-applied at boot.
|
|
Idempotent — safe to re-run after a box upgrade to pick up stack changes.
|
|
|
|
If it has to add you to the incus-admin group it will say so and exit; log
|
|
back in (or 'sg incus-admin') and run it again.
|
|
|
|
box setup-host
|
|
EOF
|
|
;;
|
|
teardown-host) cat <<'EOF'
|
|
Remove the box host stack — all boxes, the boxnet/claudenet networks, the
|
|
ACLs, the profiles, and the firewall rules of BOTH name generations (current
|
|
and pre-0.4.0). Asks first.
|
|
|
|
box teardown-host # the stack; leaves Incus installed
|
|
box teardown-host --purge-incus # ...and uninstall Incus too
|
|
EOF
|
|
;;
|
|
migrate-host) cat <<'EOF'
|
|
Move a host from the pre-0.4.0 'claudebox' stack onto 'box'. Re-homing
|
|
preserves a box's authed state (no re-login) — it only re-tags and reassigns
|
|
the profile, then verifies the box works on its new network leg.
|
|
|
|
box migrate-host --box <name> # re-home one legacy box
|
|
box migrate-host --all-boxes # re-home every legacy box
|
|
box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
|
|
EOF
|
|
;;
|
|
status) cat <<'EOF'
|
|
Deprecated alias for 'box list'. It ignored the <box> argument it
|
|
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
|
|
still works, and forwards to 'list'.
|
|
EOF
|
|
;;
|
|
help) cat <<'EOF'
|
|
Print the general help, or the help for one command.
|
|
|
|
box help
|
|
box help rename
|
|
EOF
|
|
;;
|
|
*) field "$1" sum ;; # no prose: the table's summary is the help
|
|
esac
|
|
}
|
|
|
|
show_help() { # "" → general help
|
|
case "${1:-}" in
|
|
""|help) usage ;;
|
|
*) is_command "$1" || unknown_command "$1"; help_cmd "$1" ;;
|
|
esac
|
|
}
|
|
|
|
cmd="${1:-help}"; shift || true
|
|
case "$cmd" in
|
|
-h|--help) usage; exit 0 ;;
|
|
-V|--version) version; exit 0 ;;
|
|
-*) usage_error "options come after the command — try 'box <command> $cmd ...'" ;;
|
|
esac
|
|
|
|
args=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
|
|
--from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
|
|
--template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$2"; shift 2 ;;
|
|
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
|
|
--vm) mode=vm; shift ;;
|
|
--container) mode=container; shift ;;
|
|
--force|-f) force=1; shift ;;
|
|
--json) json=1; shift ;;
|
|
--help|-h) want_help=1; shift ;;
|
|
--version|-V) version; exit 0 ;;
|
|
--) shift; args+=("$@"); break ;;
|
|
# An unrecognized flag used to be swallowed as a positional — so a typo'd
|
|
# --labl silently became a snapshot's label. Say so instead.
|
|
-*)
|
|
# 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
|
|
# the host verbs delegate their flags to the scripts they wrap
|
|
case "$cmd" in setup-host|teardown-host|migrate-host) args+=("$1"); shift; continue ;; esac
|
|
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
|
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
|
fi
|
|
usage_error "unknown option: $1 (see 'box help $cmd')" ;;
|
|
*) args+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
|
|
|
|
# --- preconditions ---------------------------------------------------------
|
|
|
|
iname_of() { echo "$remote$1"; } # instance name = box name
|
|
|
|
# The boundary, enforced: a box is an Incus instance WE tagged. Anything else is
|
|
# somebody's VM, and box will not stop, rename or delete it by accident.
|
|
resolve_box() {
|
|
local box="$1" i tag
|
|
i="$(iname_of "$box")"
|
|
tag="$(incus config get "$i" user.box 2>/dev/null || true)"
|
|
# A pre-rename box carries user.claudebox=1 and nothing else. Snapshots of
|
|
# old boxes outlive the release that minted them — the legacy tag is honored
|
|
# forever, or an old box stops being a box at all.
|
|
[ "$tag" = "1" ] || tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
|
|
[ "$tag" = "1" ] || die "no such box: $box (see 'box list')"
|
|
echo "$i"
|
|
}
|
|
|
|
box_state() { incus list "$1" --format csv --columns s 2>/dev/null | head -n1; }
|
|
|
|
require_stopped() {
|
|
local i="$1" box="$2" st; st="$(box_state "$i")"
|
|
case "$st" in
|
|
STOPPED|Stopped|stopped) return 0 ;;
|
|
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: box down $box" ;;
|
|
esac
|
|
}
|
|
|
|
need_name() {
|
|
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
|
|
usage_error "usage: $(synopsis_of "$cmd")"
|
|
fi
|
|
}
|
|
|
|
need_arg2() {
|
|
if [ "${#args[@]}" -lt 2 ] || [ -z "${args[1]}" ]; then
|
|
usage_error "usage: $(synopsis_of "$cmd")"
|
|
fi
|
|
}
|
|
|
|
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
|
if [ "$force" -eq 1 ]; then return 0; fi
|
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
|
local reply
|
|
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
|
|
read -r reply
|
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
|
}
|
|
|
|
# --- commands with real work -----------------------------------------------
|
|
|
|
pick_mode() {
|
|
if [ "$mode" != auto ]; then echo "$mode"; return; fi
|
|
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
|
|
echo "box: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
|
|
echo container
|
|
fi
|
|
}
|
|
|
|
# Five minutes, not three: the first VM launch on a fresh pool unpacks the
|
|
# image into a pool volume and takes the coldest possible boot — measured
|
|
# live, an agent can need past the 3-minute mark exactly once per pool while
|
|
# every later boot answers in seconds. And when it still fails, ship the
|
|
# forensics: the VM's console says why, and the box is torn down by whoever
|
|
# called us before anyone can read it.
|
|
wait_agent() {
|
|
local n="$1" i clog
|
|
echo "box: waiting for instance agent..."
|
|
for i in $(seq 1 150); do
|
|
if incus exec "$n" -- true </dev/null >/dev/null 2>&1; then return; fi
|
|
if [ "$i" -eq 150 ]; then
|
|
# The console log is FULL of terminal escape sequences (boot messages,
|
|
# a firmware menu). Dumping it raw scrambles the operator's terminal —
|
|
# and doubly so when it lands in a log someone is tail -f'ing. Capture
|
|
# it to a file, STRIP everything but printable ASCII + tab/newline, and
|
|
# print only a short sanitized tail. Nothing raw ever reaches a terminal.
|
|
clog="/tmp/box-console-$n.log"
|
|
timeout -k 5 15 incus console "$n" --show-log </dev/null >"$clog.raw" 2>/dev/null || true
|
|
# Strip whole escape sequences FIRST (while the ESC byte is present), then
|
|
# drop any residual control bytes — otherwise 'tr' alone leaves the visible
|
|
# '[1m[37m' halves behind. Result is clean, readable text.
|
|
sed -E $'s/\x1b\\[[0-9;:?]*[ -/]*[@-~]//g; s/\x1b[()#][0-9A-Za-z]//g; s/\x1b[=>PX^_].*?(\x1b\\\\|\x07)//g; s/\x1b.//g' \
|
|
"$clog.raw" 2>/dev/null | tr -cd '\11\12\40-\176' >"$clog"
|
|
rm -f "$clog.raw"
|
|
echo "box: instance agent never came up after 5 minutes." >&2
|
|
echo "box: sanitized console log → $clog (last non-blank lines:)" >&2
|
|
grep -v '^[[:space:]]*$' "$clog" 2>/dev/null | tail -6 | sed 's/^/ /' >&2
|
|
# A box sitting at the GRUB/firmware menu is not slow — it never booted.
|
|
if grep -qiE 'GNU GRUB|Press enter to boot|UEFI Firmware Settings' "$clog" 2>/dev/null; then
|
|
echo "box: the VM is stuck at the GRUB/firmware menu — it is not booting." >&2
|
|
echo "box: this is the IMAGE, not box. Try re-pulling it (incus image delete …)" >&2
|
|
echo "box: or pin a known-good build/alias in the template's BOX_IMAGE." >&2
|
|
fi
|
|
die "agent unreachable (inspect live: incus console $n)"
|
|
fi
|
|
sleep 2
|
|
done
|
|
}
|
|
|
|
# A clone must not BE its source. Incus regenerates the MAC, but /etc/machine-id
|
|
# rides along inside the disk — and systemd derives its DHCP client identifier
|
|
# (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address,
|
|
# to the second on the lease timer. Every box cloned from one snapshot collided
|
|
# on the network, which is exactly the workflow box exists for (log in
|
|
# once, snapshot, clone forever).
|
|
#
|
|
# Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so
|
|
# the reset costs one reboot. Do it before handing the box over, never after.
|
|
reset_identity() {
|
|
local i="$1"
|
|
echo "box: giving the clone its own identity (machine-id, DHCP lease)..."
|
|
# Do NOT truncate machine-id and reboot: systemd needs a valid one to shut
|
|
# down cleanly, so the graceful stop hangs and the reboot never happens —
|
|
# leaving the clone on its source's identity, which is the bug we are here to
|
|
# fix. 'systemd-machine-id-setup' writes a fresh VALID id instead; in a VM it
|
|
# derives from the DMI product UUID, which Incus makes unique per instance.
|
|
incus exec "$i" -- sh -c '
|
|
rm -f /etc/machine-id /var/lib/dbus/machine-id
|
|
systemd-machine-id-setup >/dev/null 2>&1 || dbus-uuidgen > /etc/machine-id
|
|
ln -sf /etc/machine-id /var/lib/dbus/machine-id
|
|
test -s /etc/machine-id
|
|
' </dev/null || die "could not reset the clone's machine-id"
|
|
# The new id only takes effect at boot. Ask nicely, then insist — a clone that
|
|
# keeps its source's DHCP lease is worse than an unclean stop of a box that
|
|
# booted 30 seconds ago.
|
|
incus restart --timeout 60 "$i" >/dev/null 2>&1 || incus restart -f "$i"
|
|
wait_agent "$i"
|
|
}
|
|
|
|
# Templates set image, user, resources and cloud-init — NOTHING else. The
|
|
# box.env file is parsed against this allowlist, never sourced: sourcing would
|
|
# hand every template arbitrary bash execution on the HOST at mint time. And
|
|
# there is deliberately no key for a network or a security flag — the shared
|
|
# box-net profile is the placement contract, so no template can weaken
|
|
# isolation. 'blank' is a box with nobody home, not a box with the safety off.
|
|
load_template() {
|
|
local t="$1" dir line key val
|
|
dir="$root/templates/$t"
|
|
[ -d "$dir" ] || die "no such template: $t (see 'box templates')"
|
|
[ -f "$dir/box.env" ] || die "template '$t' has no box.env"
|
|
T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in ''|\#*) continue ;; esac
|
|
case "$line" in
|
|
*=*) key="${line%%=*}"; val="${line#*=}" ;;
|
|
*) die "template '$t': not a KEY=\"value\" line: $line" ;;
|
|
esac
|
|
val="${val#\"}"; val="${val%\"}"
|
|
case "$key" in
|
|
BOX_DESCRIPTION) T_DESC="$val" ;;
|
|
BOX_IMAGE) T_IMAGE="$val" ;;
|
|
BOX_USER) T_USER="$val" ;;
|
|
BOX_CPU) T_CPU="$val" ;;
|
|
BOX_MEMORY) T_MEMORY="$val" ;;
|
|
BOX_DISK) T_DISK="$val" ;;
|
|
*) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;;
|
|
esac
|
|
done <"$dir/box.env"
|
|
[ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required"
|
|
# Environment overrides beat the file — this is how a small host (or the
|
|
# drill) shrinks a box without editing a template it doesn't own.
|
|
T_CPU="${BOX_CPU:-${T_CPU:-4}}"
|
|
T_MEMORY="${BOX_MEMORY:-${T_MEMORY:-8GiB}}"
|
|
T_DISK="${BOX_DISK:-${T_DISK:-60GiB}}"
|
|
}
|
|
|
|
cmd_templates() {
|
|
local d t desc
|
|
echo "TEMPLATES"
|
|
for d in "$root/templates"/*/; do
|
|
t="$(basename "$d")"
|
|
desc="$(grep -m1 '^BOX_DESCRIPTION=' "$d/box.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"
|
|
printf ' %-10s %s\n' "$t" "$desc"
|
|
done
|
|
echo
|
|
echo "mint one: box new --name <box> --template <template>"
|
|
}
|
|
|
|
cmd_new() {
|
|
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
|
local instance; instance="$(iname_of "$name")"
|
|
if [ -n "$from" ]; then
|
|
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
|
|
local src="${from%%/*}" snap="" srcref
|
|
case "$from" in */*) snap="${from#*/}" ;; esac
|
|
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
|
incus copy "$srcref" "$instance"
|
|
incus start "$instance"
|
|
wait_agent "$instance"
|
|
reset_identity "$instance"
|
|
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
|
else
|
|
local t="${template:-blank}" m extra=()
|
|
load_template "$t"
|
|
m="$(pick_mode)"
|
|
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
|
|
# security.secureboot=false: Incus defaults VMs to secureboot ON, and a
|
|
# Debian cloud image whose shim is signed with a key the host's OVMF does
|
|
# not trust dies with "bad shim signature / prohibited by secure boot
|
|
# policy" and drops to the GRUB menu forever — the kernel never loads. It
|
|
# is not part of a throwaway box's threat model (the VM boundary is), and
|
|
# turning it off boots reliably across image rebuilds. Container mode has
|
|
# no firmware, so it does not apply there.
|
|
if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK" --config security.secureboot=false); else extra+=(--config security.nesting=true); fi
|
|
# The template's identity is stamped ONTO the instance: which template,
|
|
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
|
# knows what it is without ever consulting the template again.
|
|
incus launch "$T_IMAGE" "$instance" --profile box-net \
|
|
--config user.box=1 \
|
|
--config user.box.template="$t" \
|
|
--config user.box.user="$T_USER" \
|
|
--config limits.cpu="$T_CPU" \
|
|
--config limits.memory="$T_MEMORY" \
|
|
--config cloud-init.user-data="$(cat "$root/templates/$t/user-data.yaml")" \
|
|
"${extra[@]}"
|
|
wait_agent "$instance"
|
|
echo "box: waiting for phase-1 (cloud-init)..."
|
|
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
|
|
# A failed cloud-init used to print a screen of dots and the word "error",
|
|
# with nothing to act on — the box's own log holds the reason, and nobody
|
|
# was told it existed. Show it, and leave the box up to inspect.
|
|
# Every non-interactive exec pins stdin. With a TTY on stdin, 'incus exec'
|
|
# goes interactive — and when box's own output is redirected (a script, the
|
|
# drill), the session can wedge open after the remote command has exited,
|
|
# blocking forever on a websocket that will never close. Caught live: a
|
|
# mint stuck at 'status: done'. Only shell/exec/tmux may own the terminal.
|
|
# PYTHONUNBUFFERED: cloud-init's progress dots are block-buffered the
|
|
# moment stdout is not a tty — a redirected mint (a script, the drill)
|
|
# shows NOTHING for the whole install and then one burst at the end,
|
|
# which reads exactly like a hang. Unbuffered, the dots arrive as dots.
|
|
if ! incus exec "$instance" -- env PYTHONUNBUFFERED=1 cloud-init status --wait </dev/null; then
|
|
echo >&2
|
|
echo "box: cloud-init FAILED in $name. What it says:" >&2
|
|
incus exec "$instance" -- cloud-init status --long </dev/null 2>&1 | sed 's/^/ /' >&2
|
|
echo >&2
|
|
echo "box: the errors, from the box's log:" >&2
|
|
incus exec "$instance" -- sh -c \
|
|
"grep -iE '^(E:|Err:)|Temporary failure|Could not resolve|Unable to fetch' /var/log/cloud-init-output.log | tail -8" \
|
|
</dev/null 2>/dev/null | sed 's/^/ /' >&2
|
|
echo >&2
|
|
echo "box: '$name' is still up — inspect it, then delete it:" >&2
|
|
echo " box incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
|
|
echo " box rm $name" >&2
|
|
echo "box: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&2
|
|
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
|
|
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
|
fi
|
|
fi
|
|
# The login hint belongs to the claude template — read the EFFECTIVE
|
|
# template off the instance, so a clone of a claude box gets it too and a
|
|
# blank box is not told to run a binary it doesn't have.
|
|
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
|
|
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
|
|
if [ "$eff" = claude ]; then
|
|
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
|
|
else
|
|
echo "box: ready — 'box shell $name'."
|
|
fi
|
|
}
|
|
|
|
# Boxes are ordinary Incus instances tagged user.box=1 — that tag is the only
|
|
# thing that makes them ours, so every read below is filtered by it and we
|
|
# never report on (or touch) an instance box didn't mint. Pre-rename boxes
|
|
# carry user.claudebox=1 instead and are ours forever; a box can't hold both
|
|
# tags via any path we mint, but the dedupe costs nothing.
|
|
# Emits: name,state,type,snapshot-count — none of which can contain a comma or a
|
|
# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has
|
|
# several addresses and Incus quotes them across lines. It's fetched separately.)
|
|
boxes_csv() {
|
|
{
|
|
incus list ${remote:+"$remote"} "user.box=1" --format csv --columns nstS
|
|
incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
|
|
} 2>/dev/null | awk -F, '!seen[$1]++'
|
|
}
|
|
|
|
box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none.
|
|
incus list "$1" --format csv --columns 4 2>/dev/null \
|
|
| tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \
|
|
| grep . || echo "-"
|
|
}
|
|
|
|
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
|
|
short_type() {
|
|
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
|
virtual-machine|virtualmachine) echo VM ;;
|
|
container) echo CT ;;
|
|
*) echo "$1" ;;
|
|
esac
|
|
}
|
|
|
|
list_all() {
|
|
local rows; rows="$(boxes_csv)"
|
|
if [ -z "$rows" ]; then
|
|
echo "box: no boxes yet — create one with: box new --name work" >&2
|
|
return 0
|
|
fi
|
|
{
|
|
echo "NAME,STATE,TYPE,SNAPSHOTS"
|
|
while IFS=, read -r n s t snaps; do
|
|
[ -n "$n" ] || continue
|
|
echo "$n,${s:--},$(short_type "$t"),${snaps:-0}"
|
|
done <<<"$rows"
|
|
} | awk -F, '
|
|
{ for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } n = NR }
|
|
END { for (r = 1; r <= n; r++) { line = ""
|
|
for (i = 1; i <= 4; i++) line = line sprintf("%-*s ", w[i], cell[r, i])
|
|
sub(/ +$/, "", line); print line } }'
|
|
}
|
|
|
|
# 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong
|
|
# guess we can answer, not a surprise: point at the command that does want one.
|
|
cmd_list() {
|
|
if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
|
|
die "list takes no box — for one box, use: box info ${args[0]}"
|
|
fi
|
|
if [ "$json" -eq 1 ]; then
|
|
incus list ${remote:+"$remote"} "user.box=1" --format json
|
|
else
|
|
list_all
|
|
fi
|
|
}
|
|
|
|
cmd_info() {
|
|
local box="${args[0]}" row
|
|
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
|
|
|
row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
|
|
[ -n "$row" ] || die "no such box: $box (see 'box list')"
|
|
|
|
local state type snaps
|
|
IFS=, read -r _ state type snaps <<<"$row"
|
|
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
|
|
IPV4 "$(box_ipv4 "$inst")"
|
|
|
|
echo
|
|
case "${snaps:-0}" in
|
|
''|0)
|
|
echo "SNAPSHOTS (none)"
|
|
echo
|
|
echo "Take one: box snapshot $box authed"
|
|
return 0 ;;
|
|
esac
|
|
echo "SNAPSHOTS"
|
|
local first="" sname taken
|
|
while IFS=, read -r sname taken _; do
|
|
[ -n "$sname" ] || continue
|
|
[ -n "$first" ] || first="$sname"
|
|
printf ' %-14s%s\n' "$sname" "$taken"
|
|
done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
|
|
echo
|
|
echo "Clone one: box new --name <new> --from $box/${first:-<snapshot>}"
|
|
}
|
|
|
|
# Which user does a shell land in? The template stamped it on the instance at
|
|
# mint time (user.box.user), and 'incus copy' carries user.* keys — so a clone
|
|
# knows without consulting the template. Two subtleties, both from the audit:
|
|
# 'incus config get' prints EMPTY + exit 0 for an unset key (B4), hence ${u:-},
|
|
# never '||'; and a pre-rename box has no metadata but is always a Claude box,
|
|
# so the legacy tag maps to 'claude'. The root fallback is effectively
|
|
# unreachable (every template sets a user) — anything that truly needs root
|
|
# goes through the 'box incus' escape hatch.
|
|
box_user() {
|
|
local u
|
|
u="$(incus config get "$1" user.box.user 2>/dev/null || true)"
|
|
if [ -z "$u" ] && [ "$(incus config get "$1" user.claudebox 2>/dev/null || true)" = 1 ]; then
|
|
u=claude
|
|
fi
|
|
echo "${u:-root}"
|
|
}
|
|
|
|
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
|
|
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
|
|
|
|
# A shell is a child of the exec connection: drop the terminal and everything
|
|
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
|
|
# attaches when the session exists and creates it when it doesn't, so starting
|
|
# work and reattaching after a disconnect are the same command. 'shell' stays
|
|
# bare on purpose — two verbs, two contracts.
|
|
cmd_tmux() {
|
|
local session="${args[1]:-main}"
|
|
case "$session" in
|
|
*[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
|
|
esac
|
|
incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i tmux new-session -A -s "$session"
|
|
}
|
|
|
|
cmd_snapshot() {
|
|
local label="${args[1]:-manual-$(date +%s)}"
|
|
incus snapshot create "$inst" "$label"
|
|
echo "$label"
|
|
}
|
|
|
|
cmd_status() {
|
|
echo "box: 'status' is deprecated — use 'box list'." >&2
|
|
list_all
|
|
}
|
|
|
|
# The host-health checks live in drill/doctor.sh — grown by the drill, but
|
|
# every fault they diagnose (a wedged daemon, a dnsmasq that isn't serving,
|
|
# a VPN resolver boxes inherit, isolation off in the kernel) is a USER's
|
|
# fault first: each one has killed a cold mint or silently weakened a
|
|
# boundary. The install tree ships the whole repo, so delegate — one
|
|
# hardened script, two audiences.
|
|
cmd_doctor() {
|
|
local script="$root/drill/doctor.sh"
|
|
[ -f "$script" ] || die "doctor script not found at $script — re-run install.sh"
|
|
exec bash "$script" "${args[@]}"
|
|
}
|
|
|
|
# The host lifecycle scripts, as first-class verbs — nobody should have to know
|
|
# where the install tree keeps its scripts. Each execs the installed script
|
|
# with its flags passed through; the script owns its own behavior (setup-host's
|
|
# incus-admin re-login dance, teardown's confirmation, migrate's per-box work).
|
|
host_script() { # $1 = script basename under host/
|
|
local script="$root/host/$1"
|
|
[ -f "$script" ] || die "$1 not found at $script — re-run install.sh"
|
|
exec bash "$script" "${args[@]}"
|
|
}
|
|
cmd_setup_host() { host_script setup-host.sh; }
|
|
cmd_teardown_host() { host_script teardown-host.sh; }
|
|
cmd_migrate_host() { host_script migrate-host.sh; }
|
|
|
|
cmd_help() { show_help "${args[0]:-}"; }
|
|
|
|
# The escape hatch. The box is resolved and tag-checked; everything else is
|
|
# yours. {} is the instance name; without it, the instance goes last.
|
|
warn_isolation() {
|
|
case " $* " in
|
|
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
|
|
echo "box: warning: this can move the box off the isolation stack" >&2
|
|
echo "box: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
|
|
esac
|
|
}
|
|
|
|
cmd_incus() {
|
|
local rest=("${args[@]:1}") out=() a replaced=0
|
|
[ "${#rest[@]}" -gt 0 ] || usage_error "usage: $(synopsis_of incus)"
|
|
for a in "${rest[@]}"; do
|
|
case "$a" in
|
|
*"{}"*) out+=("${a//\{\}/$inst}"); replaced=1 ;;
|
|
*) out+=("$a") ;;
|
|
esac
|
|
done
|
|
[ "$replaced" -eq 1 ] || out+=("$inst")
|
|
warn_isolation "${out[@]}"
|
|
echo "box: incus ${out[*]}" >&2 # no magic: show what runs
|
|
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 --------------
|
|
|
|
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
|
IFS='^' read -r _ _ pre _ action ok <<<"$row"
|
|
|
|
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
|
|
case ",$pre," in *,arg2,*) need_arg2 ;; esac
|
|
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
|
|
case ",$pre," in *,confirm,*) confirm "delete $inst and all its snapshots" ;; esac
|
|
|
|
case "$action" in
|
|
fn:*)
|
|
"${action#fn:}"
|
|
;;
|
|
incus:*)
|
|
sub="${action#incus:}"
|
|
# word-split intentionally: a subcommand may carry a flag ("delete -f")
|
|
# shellcheck disable=SC2086
|
|
incus $sub "$inst" "${args[@]:1}"
|
|
if [ -n "$ok" ]; then
|
|
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
|
|
echo "box: $msg"
|
|
fi
|
|
;;
|
|
esac
|