box had no CI and no unit tests — only the live-host drill. Mirror rig's CI: one `check` job = globstar `shellcheck -x` over bin/* and **/*.sh, then `bash test/cli.sh`. The suite is dependency-free and runs non-root with no Incus: the full CLI contract; install.sh's DEST/BINDIR branch driven functionally against a shim `id` (both tiers + the BOX_HOME/BOX_BIN overrides); the root-only a+rX and #66's confirm/no-op flow grep-guarded; tmux asserted in every template. Pre-existing repo shellcheck findings (bin/box SC2034/SC2015/ SC2020, and file-level SC2015 idioms in doctor.sh/wipe.sh/migrate-host.sh) were resolved — real fixes where behaviour allows, reasoned disables otherwise — so the new CI is green over the whole repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1151 lines
52 KiB
Bash
Executable file
1151 lines
52 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
|
|
cpu=""; memory=""; disk=""
|
|
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>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--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>.
|
|
--cpu <n> CPUs for this mint (limits.cpu, verbatim to Incus).
|
|
--memory <size> RAM for this mint, e.g. 3GiB (limits.memory).
|
|
--disk <size> Root disk size, e.g. 20GiB. VM mode only — a
|
|
container's root rides the storage pool.
|
|
--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 resolve most-specific-first: these flags, then BOX_CPU /
|
|
BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the
|
|
template's box.env, then defaults. Flags shape a fresh mint only — a --from
|
|
clone carries its source's resources. Resources are all a flag can touch:
|
|
there is no flag for a network or a security key, on purpose.
|
|
|
|
box new --name scratch # blank, the default
|
|
box new --name work --template claude
|
|
box new --name lean --template claude --cpu 2 --memory 3GiB
|
|
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;
|
|
install.sh runs it for you, so this is for re-applying by hand.
|
|
|
|
One run is enough. If it has to add you to the incus-admin group it re-runs
|
|
itself under that group — no re-login, no second invocation.
|
|
|
|
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 ;;
|
|
--cpu) [ $# -ge 2 ] || usage_error "--cpu needs a value"; cpu="$2"; shift 2 ;;
|
|
--memory) [ $# -ge 2 ] || usage_error "--memory needs a value"; memory="$2"; shift 2 ;;
|
|
--disk) [ $# -ge 2 ] || usage_error "--disk needs a value"; disk="$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 that never boots is NOT a slow box, and the console says which
|
|
# failure it is. Each of these cost hours to diagnose by hand once; the
|
|
# box that hits them next should be told the answer, not the symptom.
|
|
if grep -qiE 'Failed to decompress kernel|efi_stub_entry\(\) failed' "$clog" 2>/dev/null; then
|
|
echo "box: THE KERNEL WOULD NOT DECOMPRESS — the cached image is corrupt." >&2
|
|
echo "box: (a truncated/bad image download does exactly this). Re-pull it:" >&2
|
|
echo "box: incus image list # find the fingerprint" >&2
|
|
echo "box: incus image delete <fingerprint> # the next mint re-downloads" >&2
|
|
elif grep -qiE 'bad shim signature|prohibited by secure boot' "$clog" 2>/dev/null; then
|
|
echo "box: SECURE BOOT rejected the kernel — but box mints VMs with" >&2
|
|
echo "box: security.secureboot=false, so this box predates that fix or was" >&2
|
|
echo "box: created by hand. Re-mint it with a current box." >&2
|
|
elif 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 never booted." >&2
|
|
echo "box: this is the IMAGE, not box. Re-pull it (incus image delete …)," >&2
|
|
echo "box: or pin a known-good build 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%\"}"
|
|
# T_DESC is parsed for symmetry with the other BOX_* keys, but cmd_templates
|
|
# re-reads BOX_DESCRIPTION straight from the file (a box is listed without
|
|
# ever loading its template), so the parsed value here is never read. Keep the
|
|
# row — deleting it would turn box.env's own key into an "unknown key" error at
|
|
# mint time. (SC2034 disabled for the branch below; the directive must sit on
|
|
# the whole case, not an individual arm.)
|
|
# shellcheck disable=SC2034
|
|
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"
|
|
# Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still
|
|
# dies (which is what we want) — but it reads as an if-then-else it is not, so
|
|
# spell the guard out (SC2015).
|
|
if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then
|
|
die "template '$t': BOX_IMAGE and BOX_USER are required"
|
|
fi
|
|
# Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
|
|
# > BOX_* environment (how a small host or the drill shrinks every box it
|
|
# mints) > the template's file > defaults. Values pass to Incus verbatim —
|
|
# its units, its validation; box adds no parser of its own. Resources only:
|
|
# there is still no flag for a network or a security.* key, on purpose.
|
|
T_CPU="${cpu:-${BOX_CPU:-${T_CPU:-4}}}"
|
|
T_MEMORY="${memory:-${BOX_MEMORY:-${T_MEMORY:-8GiB}}}"
|
|
T_DISK="${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)"
|
|
[ -z "$cpu$memory$disk" ] || usage_error "--cpu/--memory/--disk shape a fresh mint; a clone carries its source's resources ('box incus' can change them afterwards)"
|
|
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
|
|
# Root size is a VM launch concern; a container's root rides the pool. Say
|
|
# so instead of silently dropping an explicit --disk.
|
|
[ "$m" = vm ] || [ -z "$disk" ] || echo "box: note — --disk applies to VM mode only; this container's root rides the pool" >&2
|
|
# 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 "-"
|
|
}
|
|
|
|
# The box's address ON BOXNET — which is NOT the same as "its first address".
|
|
# A box running docker also carries 172.17.0.1 (docker0), and Incus happily
|
|
# lists that FIRST. box_ipv4() hands you the decoy, and pointing anything at it
|
|
# is pointing at the wrong interface: 'box expose' did exactly that until Incus
|
|
# refused with `Connect IP "172.17.0.1" must be one of the instance's static
|
|
# IPv4 addresses`. The drill has known this trap since run 4; the CLI had not.
|
|
# Derive the prefix from the network rather than hardcoding it.
|
|
box_net_ip() {
|
|
local pfx
|
|
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)"
|
|
[ -n "$pfx" ] || return 1
|
|
incus list "$1" --format csv --columns 4 2>/dev/null \
|
|
| tr -d '"' | tr ' ,' '\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
|
|
}
|
|
|
|
# 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")"
|
|
|
|
# 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)
|
|
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 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() {
|
|
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_net_ip "$inst" || true)"
|
|
[ -n "$ip" ] && incus network acl rule remove box-isolate ingress \
|
|
action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
|
# If that was the last door, unpin the static address it required. Only
|
|
# then — other exposures still lean on the pin. Best-effort, like the ACL.
|
|
if ! incus config device list "$inst" 2>/dev/null | grep -q '^expose-'; then
|
|
incus config device unset "$inst" eth0 ipv4.address >/dev/null 2>&1
|
|
fi
|
|
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_net_ip "$inst")" \
|
|
|| die "$box has no boxnet address 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
|
|
|
|
# A VM's proxy device must be NAT mode — Incus supports proxy on containers in
|
|
# both modes, but on VMs "NAT mode only" (the userspace forkproxy is a
|
|
# container thing). NAT mode DNATs host:port → instance:port in netfilter, and
|
|
# it needs the host to be the instance's gateway, which boxnet makes true.
|
|
#
|
|
# And NAT mode needs a STATIC address. Incus resolves connect=0.0.0.0 to the
|
|
# NIC's ipv4.address — the device config, not the lease — and refuses when it
|
|
# is unset: `Instance has no static IPv4 address assigned to be used as the
|
|
# connect IP` (the 0.5.0 drill). The first cut pinned an address but the
|
|
# WRONG one (box_ipv4's docker0 decoy); the second cut removed the pin
|
|
# instead of correcting it. Third cut: pin the box's current BOXNET lease.
|
|
# Same address the box already holds, so nothing about its networking moves —
|
|
# the lease just becomes official. Left in place across exposures; unpinned
|
|
# when the last door closes.
|
|
local err; err="$(mktemp)"
|
|
if [ -z "$(incus config device get "$inst" eth0 ipv4.address 2>/dev/null)" ]; then
|
|
# override copies the profile NIC into the instance with the key set; if a
|
|
# local eth0 already exists, override refuses and set is the right verb.
|
|
if ! incus config device override "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err" \
|
|
&& ! incus config device set "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err"; then
|
|
echo "box: could not pin $box's boxnet address ($ip) as static — the NAT proxy requires one:" >&2
|
|
sed 's/^/ /' "$err" >&2; rm -f "$err"
|
|
incus network acl rule remove box-isolate ingress action=allow \
|
|
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
|
die "expose failed"
|
|
fi
|
|
fi
|
|
# connect=0.0.0.0 is deliberate: Incus resolves it to the instance's static
|
|
# IPv4 (the pin above). Naming an address here would work too, but 0.0.0.0
|
|
# cannot repeat the docker0 mistake — there is nothing to get wrong.
|
|
if incus config device add "$inst" "$dev" proxy \
|
|
"listen=tcp:127.0.0.1:$hport" "connect=tcp:0.0.0.0:$port" \
|
|
bind=host nat=true >/dev/null 2>"$err"; then
|
|
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.
|
|
echo "box: incus refused the proxy device:" >&2
|
|
sed 's/^/ /' "$err" >&2; rm -f "$err"
|
|
incus network acl rule remove box-isolate ingress action=allow \
|
|
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
|
die "expose failed"
|
|
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
|