feat!: claudebox becomes box — the Claude box is one template among several #52
16 changed files with 898 additions and 424 deletions
138
README.md
138
README.md
|
|
@ -1,8 +1,10 @@
|
|||
# claudebox
|
||||
# claudebox — ships the `box` CLI
|
||||
|
||||
A CLI to run **headless, trust-less Claude Code in throwaway VMs**. One command
|
||||
mints a fresh, network-isolated Incus box with Claude Code installed. The box is
|
||||
the product — you log in and work; destroying it loses nothing you didn't push.
|
||||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||
network-isolated Incus box from a **template**; the flagship template is
|
||||
`claude` — Debian 13 with Claude Code installed, the box this repo is named
|
||||
for. The box is the product — you log in and work; destroying it loses
|
||||
nothing you didn't push.
|
||||
|
||||
**Strictly creds-free.** A box ships with everything installed and **no**
|
||||
credentials — no Claude token, no git PAT, nothing. You authenticate
|
||||
|
|
@ -10,20 +12,34 @@ interactively *inside* the box. The tool never stores or injects a secret. That
|
|||
means there's nothing shared or committed, so it's safe for multiple operators
|
||||
out of the box.
|
||||
|
||||
**Templates set what's in the box, never what it can reach.** A template is
|
||||
image + user + resources + cloud-init; the network and every security flag
|
||||
live in a shared profile no template can touch, so `blank` is a box with
|
||||
nobody home — not a box with the safety off.
|
||||
|
||||
**The tool knows nothing about your projects.** You just `git clone` inside a
|
||||
box. A repo can ship an optional [`.claudebox/`](docs/claudebox-recipe.md)
|
||||
runbook that Claude Code reads and acts on — there is no `install` step and no
|
||||
host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the
|
||||
design rationale.
|
||||
|
||||
> **0.4.0 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host
|
||||
> stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and the
|
||||
> default template is `blank`. Existing boxes minted by any earlier version
|
||||
> keep working under every verb — their legacy tag is honored forever, and
|
||||
> their old `claudenet` (10.87) is left standing beside the new bridge. To
|
||||
> strip a host of both generations at once: `host/teardown-host.sh`, or
|
||||
> `drill/wipe.sh` for the scorched-earth version.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash
|
||||
```
|
||||
|
||||
Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your
|
||||
`PATH`. Re-run any time to upgrade. (No `git clone` needed.)
|
||||
Installs the tree to `~/.local/share/claudebox` and links `box` onto your
|
||||
`PATH`. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also
|
||||
retires the old `claudebox` symlink. (No `git clone` needed.)
|
||||
|
||||
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
||||
|
||||
|
|
@ -31,20 +47,20 @@ Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your
|
|||
~/.local/share/claudebox/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between)
|
||||
```
|
||||
|
||||
Idempotent. Installs Incus and creates the isolation stack: the `claudenet` NAT
|
||||
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
|
||||
bridge (sibling-name resolution off, resolver pinned to public upstreams —
|
||||
`BOX_DNS` overrides), the `claude-isolate` ACL (drops all RFC1918/CGNAT/
|
||||
link-local egress), the `claude-dev` profile (port-isolated NICs — boxes can't
|
||||
`BOX_DNS` overrides), the `box-isolate` ACL (drops all RFC1918/CGNAT/
|
||||
link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
|
||||
reach each other), and firewall rules blocking instance → host. All rules
|
||||
re-apply at boot via `claudebox-firewall.service` — no post-reboot ritual. If
|
||||
re-apply at boot via `box-firewall.service` — no post-reboot ritual. If
|
||||
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
||||
`sudo apt-get install -y dnsmasq-base`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
claudebox new --name work # mint a fresh, creds-free box (~10 min cold)
|
||||
claudebox shell work # enter as the claude user
|
||||
box new --name work --template claude # a creds-free Claude box (~10 min cold)
|
||||
box shell work # enter as the template's user
|
||||
```
|
||||
|
||||
Inside the box, authenticate as needed:
|
||||
|
|
@ -57,69 +73,93 @@ git clone https://github.com/you/project && cd project
|
|||
claude # if the repo has .claudebox/, Claude reads it and sets up
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
The claude box is one template among several. A template is a directory under
|
||||
`templates/`: a `box.env` (image, user, resources — parsed against a strict
|
||||
allowlist, never sourced) and a `user-data.yaml` (cloud-init, passed to Incus
|
||||
verbatim).
|
||||
|
||||
```sh
|
||||
box templates # list what this install can mint
|
||||
box new --name scratch # the DEFAULT template is blank: bare Debian,
|
||||
# same isolation, nobody home
|
||||
```
|
||||
|
||||
A template **cannot** name a network, a profile, or a `security.*` flag —
|
||||
there is no key for them. Every box launches with the shared `box-net`
|
||||
profile (the isolated NIC + root disk), so every template gets the identical
|
||||
trust boundary. Resources come from the template's `box.env`;
|
||||
`BOX_CPU` / `BOX_MEMORY` / `BOX_DISK` environment variables override them at
|
||||
mint time. The template's identity (name, user) is stamped onto the instance,
|
||||
so `shell`, `exec` and `tmux` land in the right user — and a clone still
|
||||
knows, because `incus copy` carries the metadata.
|
||||
|
||||
## Log in once, reuse via snapshots
|
||||
|
||||
Because every fresh box is creds-free, re-authenticating each time would be
|
||||
toil. Snapshot an authenticated box and clone from it instead:
|
||||
|
||||
```sh
|
||||
claudebox snapshot work authed # checkpoint after you've logged in
|
||||
claudebox new --name feature --from work/authed # clone the authed state into a new box
|
||||
box snapshot work authed # checkpoint after you've logged in
|
||||
box new --name feature --from work/authed # clone the authed state into a new box
|
||||
```
|
||||
|
||||
`--from` copies the whole box (Claude login, git creds, clones and all) while
|
||||
preserving isolation. You can also `claudebox new --name x --from work` to clone
|
||||
a box's live state, or roll a box back with `claudebox restore work authed`.
|
||||
preserving isolation. You can also `box new --name x --from work` to clone
|
||||
a box's live state, or roll a box back with `box restore work authed`.
|
||||
|
||||
Forgotten what you called a checkpoint? `claudebox info work` prints the box's
|
||||
Forgotten what you called a checkpoint? `box info work` prints the box's
|
||||
snapshot labels and the `--from` line to clone one.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
claudebox new --name <box> [--from <src>[/<snap>]] [--vm|--container] [--remote r]
|
||||
claudebox list # list your boxes
|
||||
claudebox info <box> # one box: state, IP, snapshot labels
|
||||
claudebox shell <box> # enter as the claude user
|
||||
claudebox exec <box> -- <cmd...> # run a command in the box
|
||||
claudebox tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||
claudebox snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||
claudebox restore <box> <snap> # roll back to a snapshot
|
||||
claudebox rename <box> <new> # rename a box (stop it first)
|
||||
claudebox down <box> # stop (state kept; `start` resumes)
|
||||
claudebox start <box> # start a stopped box
|
||||
claudebox rm <box> [--force] # delete the box + its snapshots (asks first)
|
||||
claudebox incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
||||
claudebox doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
||||
claudebox status # deprecated alias for `list`
|
||||
claudebox help [<command>] # full help, or one command's page
|
||||
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
|
||||
box templates # list the templates this install can mint
|
||||
box list # list your boxes
|
||||
box info <box> # one box: state, IP, snapshot labels
|
||||
box shell <box> # enter as the template's user
|
||||
box exec <box> -- <cmd...> # run a command in the box
|
||||
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||
box restore <box> <snap> # roll back to a snapshot
|
||||
box rename <box> <new> # rename a box (stop it first)
|
||||
box down <box> # stop (state kept; `start` resumes)
|
||||
box start <box> # start a stopped box
|
||||
box rm <box> [--force] # delete the box + its snapshots (asks first)
|
||||
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
||||
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
||||
box status # deprecated alias for `list`
|
||||
box help [<command>] # full help, or one command's page
|
||||
```
|
||||
|
||||
Every command takes `--help`, and options come after the command
|
||||
(`claudebox list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
|
||||
(`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
|
||||
wrong.
|
||||
|
||||
`new` fresh-launches from cloud-init, or with `--from` clones an existing box or
|
||||
snapshot. VM mode (`--vm`, the default where `/dev/kvm` exists) is the trust-less
|
||||
target; container mode (auto-fallback, `security.nesting=true`) is for hosts
|
||||
without nested virt — weaker isolation, dev/test only.
|
||||
`new` fresh-launches from a template (default: `claude`), or with `--from`
|
||||
clones an existing box or snapshot. VM mode (`--vm`, the default where
|
||||
`/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
|
||||
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
||||
dev/test only.
|
||||
|
||||
## Boxes are just Incus instances
|
||||
|
||||
A box is an ordinary Incus instance tagged `user.claudebox=1`. claudebox wraps
|
||||
the box lifecycle and the isolation model — not all of Incus. It owns a command
|
||||
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
||||
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and the isolation model — not all of Incus. It owns a command
|
||||
when it must enforce something Incus can't see: that tag (it will not stop,
|
||||
rename or delete an instance it didn't mint), the isolation stack, or the
|
||||
creds-free snapshot workflow. For everything else, there's the door:
|
||||
|
||||
```sh
|
||||
claudebox incus work -- config show # instance name appended
|
||||
claudebox incus work -- file push x.tar {}/tmp/ # or placed with {}
|
||||
box incus work -- config show # instance name appended
|
||||
box incus work -- file push x.tar {}/tmp/ # or placed with {}
|
||||
```
|
||||
|
||||
The box is resolved and tag-checked; the rest is passed to `incus` verbatim, and
|
||||
the command is echoed before it runs. If it can move the box off the isolation
|
||||
stack (profile, network, device, `security.*`), claudebox warns and proceeds —
|
||||
stack (profile, network, device, `security.*`), box warns and proceeds —
|
||||
the trust boundary is then yours to keep. See
|
||||
[docs/claudebox-design.md](docs/claudebox-design.md) for the rule and why the
|
||||
command surface is a table.
|
||||
|
|
@ -130,10 +170,10 @@ The contract: **a box reaches the public internet and nothing else.** Not the
|
|||
host, not your LAN, not another box, not even another box's *name*. What
|
||||
enforces it, layer by layer:
|
||||
|
||||
- **Dedicated NAT bridge** `claudenet`, IPv6 off. Every rule below is
|
||||
- **Dedicated NAT bridge** `boxnet`, IPv6 off. Every rule below is
|
||||
IPv4-only, so IPv6 would be an uncovered path — off is part of the
|
||||
contract, not a default.
|
||||
- **`claude-isolate` ACL** — drops all egress to private space (RFC1918,
|
||||
- **`box-isolate` ACL** — drops all egress to private space (RFC1918,
|
||||
CGNAT, link-local), with a single carve-out to the gateway so DNS works.
|
||||
- **Sibling isolation, at L2** — two boxes on one bridge are *switched*,
|
||||
never routed, so no L3 rule can separate them (learned the hard way; see
|
||||
|
|
@ -147,8 +187,8 @@ enforces it, layer by layer:
|
|||
the host's public IPs. Entry is `incus exec` over the local socket only —
|
||||
**no inbound path exists.**
|
||||
|
||||
The VM is the trust boundary: Claude can run arbitrary code inside and touch
|
||||
nothing you care about.
|
||||
The VM is the trust boundary: whatever runs inside — Claude, or anything a
|
||||
template ships — can run arbitrary code and touch nothing you care about.
|
||||
|
||||
### Measured, not claimed
|
||||
|
||||
|
|
@ -164,6 +204,8 @@ history, including every trap that fooled a run into a wrong verdict.
|
|||
```sh
|
||||
bash drill/doctor.sh # read-only: is this host healthy and the stack live?
|
||||
bash drill/drill.sh # FULL end-to-end — mutates the host; use a machine you own
|
||||
bash drill/wipe.sh # scorched earth: strip BOTH name generations, images and
|
||||
# (--purge-storage) the pool, so a run starts from bare
|
||||
```
|
||||
|
||||
The doctor reads ground truth, not config claims — the kernel's `isolated on`
|
||||
|
|
@ -184,7 +226,7 @@ host-executed script. See [docs/claudebox-recipe.md](docs/claudebox-recipe.md).
|
|||
```sh
|
||||
~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall
|
||||
~/.local/share/claudebox/host/teardown-host.sh --purge-incus # ...and Incus itself
|
||||
rm -rf ~/.local/share/claudebox ~/.local/bin/claudebox # the CLI
|
||||
rm -rf ~/.local/share/claudebox ~/.local/bin/box # the CLI
|
||||
```
|
||||
|
||||
## Non-goals
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.0
|
||||
0.4.0
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
||||
# 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=""; force=0; json=0; want_help=0
|
||||
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 "claudebox: $*" >&2; exit 1; } # 1 = it went wrong
|
||||
usage_error() { echo "claudebox: $*" >&2; echo "try 'claudebox help'." >&2; exit 2; } # 2 = you asked wrong
|
||||
version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
|
||||
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.
|
||||
|
|
@ -22,7 +22,7 @@ version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) (
|
|||
#
|
||||
# preconditions (comma-separated):
|
||||
# box first positional is a box: resolve it, and REFUSE if the instance
|
||||
# isn't tagged user.claudebox=1 — the boundary, enforced, not assumed
|
||||
# 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
|
||||
|
|
@ -34,13 +34,14 @@ version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) (
|
|||
# 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 claudebox invariant, it is incus's job, not ours — that is
|
||||
# what `claudebox incus` is for.
|
||||
# doesn't enforce a box invariant, it is incus's job, not ours — that is
|
||||
# what `box incus` is for.
|
||||
CMDS=(
|
||||
"new^--name <box> [--from <src>[/<snap>]] [--vm|--container]^^Mint a box: fresh from cloud-init, or --from an existing box/snapshot^fn:cmd_new^"
|
||||
"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 the claude user^fn:cmd_shell^"
|
||||
"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^"
|
||||
|
|
@ -52,7 +53,7 @@ CMDS=(
|
|||
"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^"
|
||||
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
||||
"help^[<command>]^^This help, or 'claudebox help <command>' for one command^fn:cmd_help^"
|
||||
"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; }
|
||||
|
|
@ -69,7 +70,7 @@ field() {
|
|||
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
|
||||
esac
|
||||
}
|
||||
synopsis_of() { local s; s="$(field "$1" syn)"; echo "claudebox $1${s:+ $s}"; }
|
||||
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() {
|
||||
|
|
@ -97,20 +98,20 @@ suggest() {
|
|||
unknown_command() {
|
||||
local hint; hint="$(suggest "$1")"
|
||||
if [ -n "$hint" ]; then
|
||||
echo "claudebox: unknown command: $1 — did you mean '$hint'?" >&2
|
||||
echo "box: unknown command: $1 — did you mean '$hint'?" >&2
|
||||
else
|
||||
echo "claudebox: unknown command: $1" >&2
|
||||
echo "box: unknown command: $1" >&2
|
||||
fi
|
||||
echo "try 'claudebox help' for the command list." >&2
|
||||
echo "try 'box help' for the command list." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
claudebox — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
|
||||
box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
|
||||
|
||||
USAGE
|
||||
claudebox <command> [<args>] [options]
|
||||
box <command> [<args>] [options]
|
||||
|
||||
COMMANDS
|
||||
EOF
|
||||
|
|
@ -123,6 +124,7 @@ 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)
|
||||
|
|
@ -131,28 +133,32 @@ OPTIONS
|
|||
--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 claudebox version
|
||||
--version, -V Print the box version
|
||||
|
||||
Options come after the command: 'claudebox list --json', not 'claudebox --json list'.
|
||||
Options come after the command: 'box list --json', not 'box --json list'.
|
||||
|
||||
EXAMPLES
|
||||
# mint a box and log in inside it — the tool never handles your token
|
||||
claudebox new --name work
|
||||
claudebox shell work # then: run 'claude', then /login
|
||||
# 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
|
||||
claudebox snapshot work authed
|
||||
claudebox new --name feature --from work/authed
|
||||
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?
|
||||
claudebox list
|
||||
claudebox info work
|
||||
box list
|
||||
box info work
|
||||
|
||||
# run something without opening a shell
|
||||
claudebox exec work -- git -C project pull
|
||||
box exec work -- git -C project pull
|
||||
|
||||
# anything claudebox doesn't wrap: boxes are plain Incus instances
|
||||
claudebox incus work -- config show
|
||||
# anything box doesn't wrap: boxes are plain Incus instances
|
||||
box incus work -- config show
|
||||
|
||||
EXIT STATUS
|
||||
0 ok
|
||||
|
|
@ -161,15 +167,15 @@ EXIT STATUS
|
|||
|
||||
THE MODEL
|
||||
A box carries NO credentials. You authenticate interactively inside it
|
||||
('claude' then /login; 'gh auth login'); claudebox never stores or injects a
|
||||
('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.
|
||||
|
||||
claudebox owns a command when it must enforce something Incus cannot see: the
|
||||
user.claudebox=1 boundary, the isolation stack, or the creds-free snapshot
|
||||
workflow. Everything else is Incus's job — and 'claudebox incus' is the door.
|
||||
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/claudebox
|
||||
Docs: https://github.com/heavy-duty/box
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -180,29 +186,51 @@ help_cmd() {
|
|||
echo
|
||||
case "$1" in
|
||||
new) cat <<'EOF'
|
||||
Mint a box. Without --from, launches a fresh Debian 13 box from cloud-init
|
||||
(~10 min cold) with Claude Code installed and NO credentials. With --from,
|
||||
clones an existing box or one of its snapshots — Claude login, git creds and
|
||||
clones carry over, isolation is preserved.
|
||||
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.
|
||||
|
||||
claudebox new --name work
|
||||
claudebox new --name feature --from work/authed
|
||||
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 claudebox minted on this host: name, state, type, snapshot count.
|
||||
Takes no box — for one box, that's 'claudebox info <box>'.
|
||||
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.
|
||||
|
||||
claudebox list
|
||||
box list
|
||||
EOF
|
||||
;;
|
||||
info) cat <<'EOF'
|
||||
|
|
@ -211,23 +239,23 @@ labels of its snapshots, with the --from line to clone one.
|
|||
|
||||
--json Incus's JSON, straight through, for scripting.
|
||||
|
||||
claudebox info work
|
||||
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.
|
||||
|
||||
claudebox shell work
|
||||
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 claudebox will read your command's
|
||||
through untouched; the -- is required, or box will read your command's
|
||||
flags as its own.
|
||||
|
||||
claudebox exec work -- git -C project pull
|
||||
claudebox exec work -- claude --version
|
||||
box exec work -- git -C project pull
|
||||
box exec work -- claude --version
|
||||
EOF
|
||||
;;
|
||||
tmux) cat <<'EOF'
|
||||
|
|
@ -240,35 +268,35 @@ remember.
|
|||
|
||||
The session name (default: main) buys parallel streams in one box:
|
||||
|
||||
claudebox tmux work # attach or create 'main'
|
||||
claudebox tmux work run-1 # a second, independent stream, same 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, 'claudebox shell' is unchanged.
|
||||
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>; 'claudebox info <box>' shows the labels you
|
||||
label defaults to manual-<epoch>; 'box info <box>' shows the labels you
|
||||
have.
|
||||
|
||||
claudebox snapshot work authed
|
||||
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. 'claudebox info <box>' lists the labels.
|
||||
that snapshot is lost. 'box info <box>' lists the labels.
|
||||
|
||||
claudebox restore work authed
|
||||
box restore work authed
|
||||
EOF
|
||||
;;
|
||||
rename) cat <<'EOF'
|
||||
Rename a box. Incus cannot rename a running instance, so stop it first:
|
||||
|
||||
claudebox down work
|
||||
claudebox rename work archive
|
||||
claudebox start archive
|
||||
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.
|
||||
|
|
@ -279,50 +307,50 @@ 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.
|
||||
|
||||
claudebox rm work
|
||||
claudebox rm work --force
|
||||
box rm work
|
||||
box rm work --force
|
||||
EOF
|
||||
;;
|
||||
incus) cat <<'EOF'
|
||||
The door out. claudebox wraps the box lifecycle and the isolation model, not
|
||||
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.claudebox=1 tag, so you cannot aim it at an instance
|
||||
claudebox didn't mint.
|
||||
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.
|
||||
|
||||
claudebox incus work -- config show
|
||||
claudebox incus work -- config device add {} extra disk source=/data path=/data
|
||||
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. claudebox warns and then does as you asked — from
|
||||
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 claudenet, does
|
||||
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 claudenet's resolver to public upstreams and re-test
|
||||
--pin-dns pin boxnet's resolver to public upstreams and re-test
|
||||
(setup-host.sh now pins by default; this is the quick test)
|
||||
|
||||
claudebox doctor
|
||||
claudebox doctor --fix
|
||||
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
|
||||
;;
|
||||
status) cat <<'EOF'
|
||||
Deprecated alias for 'claudebox list'. It ignored the <box> argument it
|
||||
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
|
||||
|
|
@ -330,8 +358,8 @@ EOF
|
|||
help) cat <<'EOF'
|
||||
Print the general help, or the help for one command.
|
||||
|
||||
claudebox help
|
||||
claudebox help rename
|
||||
box help
|
||||
box help rename
|
||||
EOF
|
||||
;;
|
||||
*) field "$1" sum ;; # no prose: the table's summary is the help
|
||||
|
|
@ -349,7 +377,7 @@ 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 'claudebox <command> $cmd ...'" ;;
|
||||
-*) usage_error "options come after the command — try 'box <command> $cmd ...'" ;;
|
||||
esac
|
||||
|
||||
args=()
|
||||
|
|
@ -357,6 +385,7 @@ 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 ;;
|
||||
|
|
@ -368,12 +397,12 @@ while [ $# -gt 0 ]; do
|
|||
# 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 claudebox
|
||||
# doctor's flags belong to the doctor script, not to box
|
||||
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
|
||||
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
||||
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
||||
fi
|
||||
usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;;
|
||||
usage_error "unknown option: $1 (see 'box help $cmd')" ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
|
@ -385,12 +414,16 @@ if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
|
|||
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 claudebox will not stop, rename or delete it by accident.
|
||||
# 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.claudebox 2>/dev/null || true)"
|
||||
[ "$tag" = "1" ] || die "no such box: $box (see 'claudebox list')"
|
||||
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"
|
||||
}
|
||||
|
||||
|
|
@ -400,7 +433,7 @@ 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: claudebox down $box" ;;
|
||||
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: box down $box" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
|
@ -420,7 +453,7 @@ 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 'claudebox: %s? this cannot be undone. [y/N] ' "$1"
|
||||
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
|
||||
}
|
||||
|
|
@ -430,17 +463,27 @@ confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
|||
pick_mode() {
|
||||
if [ "$mode" != auto ]; then echo "$mode"; return; fi
|
||||
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
|
||||
echo "claudebox: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
|
||||
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
|
||||
echo "claudebox: waiting for instance agent..."
|
||||
for i in $(seq 1 90); do
|
||||
if incus exec "$n" -- true >/dev/null 2>&1; then return; fi
|
||||
[ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)"
|
||||
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
|
||||
echo "box: instance agent never came up. The VM's console log:" >&2
|
||||
timeout -k 5 15 incus console "$n" --show-log 2>/dev/null | tail -15 | sed 's/^/ /' >&2
|
||||
die "agent unreachable after 5 minutes (incus console $n to inspect live)"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
|
@ -449,14 +492,14 @@ wait_agent() {
|
|||
# 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 claudebox exists for (log in
|
||||
# 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 "claudebox: giving the clone its own identity (machine-id, DHCP lease)..."
|
||||
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
|
||||
|
|
@ -467,7 +510,7 @@ reset_identity() {
|
|||
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
|
||||
' || die "could not reset the clone's 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.
|
||||
|
|
@ -475,10 +518,60 @@ reset_identity() {
|
|||
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"
|
||||
|
|
@ -486,49 +579,82 @@ cmd_new() {
|
|||
incus start "$instance"
|
||||
wait_agent "$instance"
|
||||
reset_identity "$instance"
|
||||
echo "claudebox: cloned $srcref — isolation and Claude auth carry over from the source."
|
||||
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
||||
else
|
||||
local m extra=(); m="$(pick_mode)"
|
||||
# shellcheck disable=SC2054 # "root,size=60GiB" is a single incus argument
|
||||
if [ "$m" = vm ]; then extra+=(--vm --device root,size=60GiB); else extra+=(--config security.nesting=true); fi
|
||||
incus launch images:debian/13/cloud "$instance" --profile claude-dev \
|
||||
--config user.claudebox=1 \
|
||||
--config cloud-init.user-data="$(cat "$root/cloud-init/user-data.yaml")" \
|
||||
local t="${template:-blank}" m extra=()
|
||||
load_template "$t"
|
||||
m="$(pick_mode)"
|
||||
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
|
||||
if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK"); 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 "claudebox: waiting for phase-1 (cloud-init)..."
|
||||
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.
|
||||
if ! incus exec "$instance" -- cloud-init status --wait; then
|
||||
# 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 "claudebox: cloud-init FAILED in $name. What it says:" >&2
|
||||
incus exec "$instance" -- cloud-init status --long 2>&1 | sed 's/^/ /' >&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 "claudebox: the errors, from the box's log:" >&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" \
|
||||
2>/dev/null | sed 's/^/ /' >&2
|
||||
</dev/null 2>/dev/null | sed 's/^/ /' >&2
|
||||
echo >&2
|
||||
echo "claudebox: '$name' is still up — inspect it, then delete it:" >&2
|
||||
echo " claudebox incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
|
||||
echo " claudebox rm $name" >&2
|
||||
echo "claudebox: 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: claudebox doctor" >&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
|
||||
echo "claudebox: ready — 'claudebox shell $name'. Log into Claude inside: run 'claude' then /login."
|
||||
# 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.claudebox=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 claudebox didn't mint.
|
||||
# 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.claudebox=1" --format csv --columns nstS
|
||||
{
|
||||
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.
|
||||
|
|
@ -549,7 +675,7 @@ short_type() {
|
|||
list_all() {
|
||||
local rows; rows="$(boxes_csv)"
|
||||
if [ -z "$rows" ]; then
|
||||
echo "claudebox: no boxes yet — create one with: claudebox new --name work" >&2
|
||||
echo "box: no boxes yet — create one with: box new --name work" >&2
|
||||
return 0
|
||||
fi
|
||||
{
|
||||
|
|
@ -569,10 +695,10 @@ list_all() {
|
|||
# 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: claudebox info ${args[0]}"
|
||||
die "list takes no box — for one box, use: box info ${args[0]}"
|
||||
fi
|
||||
if [ "$json" -eq 1 ]; then
|
||||
incus list ${remote:+"$remote"} "user.claudebox=1" --format json
|
||||
incus list ${remote:+"$remote"} "user.box=1" --format json
|
||||
else
|
||||
list_all
|
||||
fi
|
||||
|
|
@ -583,7 +709,7 @@ cmd_info() {
|
|||
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 'claudebox list')"
|
||||
[ -n "$row" ] || die "no such box: $box (see 'box list')"
|
||||
|
||||
local state type snaps
|
||||
IFS=, read -r _ state type snaps <<<"$row"
|
||||
|
|
@ -595,7 +721,7 @@ cmd_info() {
|
|||
''|0)
|
||||
echo "SNAPSHOTS (none)"
|
||||
echo
|
||||
echo "Take one: claudebox snapshot $box authed"
|
||||
echo "Take one: box snapshot $box authed"
|
||||
return 0 ;;
|
||||
esac
|
||||
echo "SNAPSHOTS"
|
||||
|
|
@ -606,11 +732,28 @@ cmd_info() {
|
|||
printf ' %-14s%s\n' "$sname" "$taken"
|
||||
done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
|
||||
echo
|
||||
echo "Clone one: claudebox new --name <new> --from $box/${first:-<snapshot>}"
|
||||
echo "Clone one: box new --name <new> --from $box/${first:-<snapshot>}"
|
||||
}
|
||||
|
||||
cmd_shell() { incus exec "$inst" -- sudo -u claude -i; }
|
||||
cmd_exec() { incus exec "$inst" -- sudo -u claude -i "${args[@]:1}"; }
|
||||
# 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'
|
||||
|
|
@ -622,7 +765,7 @@ cmd_tmux() {
|
|||
case "$session" in
|
||||
*[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
|
||||
esac
|
||||
incus exec "$inst" -- sudo -u claude -i tmux new-session -A -s "$session"
|
||||
incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i tmux new-session -A -s "$session"
|
||||
}
|
||||
|
||||
cmd_snapshot() {
|
||||
|
|
@ -632,7 +775,7 @@ cmd_snapshot() {
|
|||
}
|
||||
|
||||
cmd_status() {
|
||||
echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2
|
||||
echo "box: 'status' is deprecated — use 'box list'." >&2
|
||||
list_all
|
||||
}
|
||||
|
||||
|
|
@ -655,8 +798,8 @@ cmd_help() { show_help "${args[0]:-}"; }
|
|||
warn_isolation() {
|
||||
case " $* " in
|
||||
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
|
||||
echo "claudebox: warning: this can move the box off the isolation stack" >&2
|
||||
echo "claudebox: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -671,7 +814,7 @@ cmd_incus() {
|
|||
done
|
||||
[ "$replaced" -eq 1 ] || out+=("$inst")
|
||||
warn_isolation "${out[@]}"
|
||||
echo "claudebox: incus ${out[*]}" >&2 # no magic: show what runs
|
||||
echo "box: incus ${out[*]}" >&2 # no magic: show what runs
|
||||
incus "${out[@]}"
|
||||
}
|
||||
|
||||
|
|
@ -696,7 +839,7 @@ case "$action" in
|
|||
incus $sub "$inst" "${args[@]:1}"
|
||||
if [ -n "$ok" ]; then
|
||||
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
|
||||
echo "claudebox: $msg"
|
||||
echo "box: $msg"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
117
drill/doctor.sh
117
drill/doctor.sh
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# doctor.sh — is this host fit to mint boxes (and to drill), and if not, what
|
||||
# is wrong? Users reach it as 'claudebox doctor'; the drill runs it directly.
|
||||
# is wrong? Users reach it as 'box doctor'; the drill runs it directly.
|
||||
#
|
||||
# bash drill/doctor.sh # report
|
||||
# bash drill/doctor.sh --fix # report, then revert what the drill left behind
|
||||
|
|
@ -40,19 +40,19 @@ timeout 10 incus list >/dev/null 2>&1 || {
|
|||
exit 1
|
||||
}
|
||||
|
||||
head_ "Network — claudenet"
|
||||
if incus network show claudenet >/dev/null 2>&1; then
|
||||
head_ "Network — boxnet"
|
||||
if incus network show boxnet >/dev/null 2>&1; then
|
||||
# dns.mode=none is SHIPPED — it is what stops a box enumerating its siblings
|
||||
# through the gateway's dnsmasq. Its ABSENCE is the problem, not its presence.
|
||||
dns="$(incus network get claudenet dns.mode 2>/dev/null)"
|
||||
dns="$(incus network get boxnet dns.mode 2>/dev/null)"
|
||||
if [ "$dns" = none ]; then
|
||||
ok "dns.mode = none — a box cannot enumerate its siblings by name"
|
||||
else
|
||||
no "dns.mode = ${dns:-<unset>} — a box can RESOLVE its siblings' names and addresses"
|
||||
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
|
||||
[ "$FIX" = 1 ] && { incus network set claudenet dns.mode=none && inf "set: dns.mode=none"; }
|
||||
[ "$FIX" = 1 ] && { incus network set boxnet dns.mode=none && inf "set: dns.mode=none"; }
|
||||
fi
|
||||
inf "ipv4.address = $(incus network get claudenet ipv4.address 2>/dev/null)"
|
||||
inf "ipv4.address = $(incus network get boxnet ipv4.address 2>/dev/null)"
|
||||
# Incus reports the network as "Created" whether or not anything is actually
|
||||
# SERVING it. Kill the daemon uncleanly (a wedge, an OOM, a SIGKILL) and it
|
||||
# can come back without respawning this network's dnsmasq — the bridge is up,
|
||||
|
|
@ -60,75 +60,84 @@ if incus network show claudenet >/dev/null 2>&1; then
|
|||
# so it dies deep inside cloud-init with "Temporary failure resolving
|
||||
# deb.debian.org". Two cold mints and an hour of hunting went into learning
|
||||
# that Incus's own status does not cover this. Ask the process table instead.
|
||||
if pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1; then
|
||||
ok "a dnsmasq is serving claudenet (DHCP + DNS)"
|
||||
if pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1; then
|
||||
ok "a dnsmasq is serving boxnet (DHCP + DNS)"
|
||||
else
|
||||
no "NO dnsmasq is serving claudenet — the bridge is up and incus says 'Created', but nothing hands out leases"
|
||||
no "NO dnsmasq is serving boxnet — the bridge is up and incus says 'Created', but nothing hands out leases"
|
||||
inf "every box minted now gets no address, no DNS, and dies in cloud-init"
|
||||
inf "fix: timeout 60 incus delete -f <any boxes>; sudo systemctl restart incus"
|
||||
inf " (if it does not come back: teardown-host.sh, then re-run the drill)"
|
||||
[ "$FIX" = 1 ] && {
|
||||
inf "restarting incus to respawn it…"
|
||||
sudo systemctl restart incus && sleep 5
|
||||
pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1 \
|
||||
&& inf "reverted: dnsmasq is serving claudenet again" \
|
||||
pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1 \
|
||||
&& inf "reverted: dnsmasq is serving boxnet again" \
|
||||
|| inf "STILL missing — run teardown-host.sh and let the drill rebuild the network"
|
||||
}
|
||||
fi
|
||||
ipv6="$(incus network get claudenet ipv6.address 2>/dev/null)"
|
||||
ipv6="$(incus network get boxnet ipv6.address 2>/dev/null)"
|
||||
[ "$ipv6" = none ] && ok "ipv6.address = none (the isolation contract — every ACL rule is IPv4-only)" \
|
||||
|| no "ipv6.address = $ipv6 — IPv6 is on and NOT covered by any ACL rule"
|
||||
else
|
||||
inf "claudenet does not exist (a fresh host — setup-host.sh will create it)"
|
||||
inf "boxnet does not exist (a fresh host — setup-host.sh will create it)"
|
||||
fi
|
||||
|
||||
head_ "Firewall — the box-to-box drop"
|
||||
if sudo nft list table bridge claudebox >/dev/null 2>&1; then
|
||||
ok "nft bridge table 'claudebox' is present — boxes cannot reach each other"
|
||||
if sudo nft list table bridge box >/dev/null 2>&1; then
|
||||
ok "nft bridge table 'box' is present — boxes cannot reach each other"
|
||||
else
|
||||
no "the box-to-box drop is MISSING — boxes can reach each other"
|
||||
inf "an L3 ACL never sees frames switched between two ports of one bridge;"
|
||||
inf "the drop is an nft BRIDGE-family rule, and without it siblings are wide open."
|
||||
inf "fix: sudo /usr/local/sbin/claudebox-firewall"
|
||||
inf " (or: sudo systemctl restart claudebox-firewall.service)"
|
||||
inf "fix: sudo /usr/local/sbin/box-firewall"
|
||||
inf " (or: sudo systemctl restart box-firewall.service)"
|
||||
fi
|
||||
|
||||
head_ "Profile — claude-dev (the NIC is the isolation contract)"
|
||||
if incus profile show claude-dev >/dev/null 2>&1; then
|
||||
iso="$(incus profile device get claude-dev eth0 security.port_isolation 2>/dev/null)"
|
||||
if [ "$iso" = "true" ]; then
|
||||
ok "security.port_isolation = true — boxes cannot reach each other at L2"
|
||||
else
|
||||
no "security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
|
||||
inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2"
|
||||
inf "segment, so their frames are switched, never routed past the ACL."
|
||||
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
|
||||
fi
|
||||
for k in security.mac_filtering security.ipv4_filtering; do
|
||||
v="$(incus profile device get claude-dev eth0 "$k" 2>/dev/null)"
|
||||
if [ -z "$v" ]; then
|
||||
ok "$k unset (as shipped)"
|
||||
# box-net is the placement contract since the 0.4.0 rename; claude-dev is its
|
||||
# pre-rename ancestor and may linger while legacy boxes still reference it.
|
||||
# Check whichever exist — an unisolated NIC is a fault on either.
|
||||
PROFILES=""
|
||||
incus profile show box-net >/dev/null 2>&1 && PROFILES="box-net"
|
||||
incus profile show claude-dev >/dev/null 2>&1 && PROFILES="$PROFILES claude-dev"
|
||||
head_ "Profile — the NIC is the isolation contract"
|
||||
if [ -n "$PROFILES" ]; then
|
||||
for p in $PROFILES; do
|
||||
[ "$p" = claude-dev ] && inf "claude-dev is legacy (pre-rename boxes still reference it)"
|
||||
iso="$(incus profile device get "$p" eth0 security.port_isolation 2>/dev/null)"
|
||||
if [ "$iso" = "true" ]; then
|
||||
ok "$p: security.port_isolation = true — boxes cannot reach each other at L2"
|
||||
else
|
||||
no "$k = $v ← phase D left this behind. A box can fail to get on the network at all."
|
||||
[ "$FIX" = 1 ] && { incus profile device unset claude-dev eth0 "$k" && inf "reverted: $k unset"; }
|
||||
no "$p: security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
|
||||
inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2"
|
||||
inf "segment, so their frames are switched, never routed past the ACL."
|
||||
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
|
||||
fi
|
||||
for k in security.mac_filtering security.ipv4_filtering; do
|
||||
v="$(incus profile device get "$p" eth0 "$k" 2>/dev/null)"
|
||||
if [ -z "$v" ]; then
|
||||
ok "$p: $k unset (as shipped)"
|
||||
else
|
||||
no "$p: $k = $v ← phase D left this behind. A box can fail to get on the network at all."
|
||||
[ "$FIX" = 1 ] && { incus profile device unset "$p" eth0 "$k" && inf "reverted: $k unset"; }
|
||||
fi
|
||||
done
|
||||
done
|
||||
inf "cpu/mem: $(incus profile get claude-dev limits.cpu 2>/dev/null)/$(incus profile get claude-dev limits.memory 2>/dev/null) (the drill lowers these on a small host)"
|
||||
inf "resources are per-box since 0.4.0 (stamped from the template at mint; BOX_CPU/BOX_MEMORY override)"
|
||||
else
|
||||
inf "claude-dev does not exist (a fresh host)"
|
||||
inf "box-net does not exist (a fresh host — setup-host.sh will create it)"
|
||||
fi
|
||||
|
||||
head_ "ACL — claude-isolate"
|
||||
if incus network acl show claude-isolate >/dev/null 2>&1; then
|
||||
n="$(incus network acl show claude-isolate | grep -c 'action:' || true)"
|
||||
head_ "ACL — box-isolate"
|
||||
if incus network acl show box-isolate >/dev/null 2>&1; then
|
||||
n="$(incus network acl show box-isolate | grep -c 'action:' || true)"
|
||||
inf "$n rules"
|
||||
incus network acl show claude-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
|
||||
if incus network acl show claude-isolate | grep -q '@internal'; then
|
||||
incus network acl show box-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
|
||||
if incus network acl show box-isolate | grep -q '@internal'; then
|
||||
no "an @internal rule survived phase D"
|
||||
[ "$FIX" = 1 ] && { incus network acl rule remove claude-isolate egress action=drop destination=@internal && inf "reverted: @internal rule removed"; }
|
||||
[ "$FIX" = 1 ] && { incus network acl rule remove box-isolate egress action=drop destination=@internal && inf "reverted: @internal rule removed"; }
|
||||
fi
|
||||
else
|
||||
inf "claude-isolate does not exist (a fresh host)"
|
||||
inf "box-isolate does not exist (a fresh host)"
|
||||
fi
|
||||
|
||||
# Config is a claim; the bridge port is the fact. Incus can accept
|
||||
|
|
@ -141,9 +150,9 @@ for c in bridge /usr/sbin/bridge /sbin/bridge; do
|
|||
sudo "$c" -V >/dev/null 2>&1 && { BRIDGE="$c"; break; }
|
||||
done
|
||||
if [ -n "$BRIDGE" ]; then
|
||||
ports="$(sudo "$BRIDGE" -d link show 2>/dev/null | grep -A1 'master claudenet')"
|
||||
ports="$(sudo "$BRIDGE" -d link show 2>/dev/null | grep -A1 'master boxnet')"
|
||||
if [ -z "$ports" ]; then
|
||||
inf "no instance is attached to claudenet right now (mint a box to check the taps)"
|
||||
inf "no instance is attached to boxnet right now (mint a box to check the taps)"
|
||||
else
|
||||
printf '%s\n' "$ports" | sed 's/^/ /'
|
||||
if printf '%s' "$ports" | grep -q 'isolated on'; then
|
||||
|
|
@ -173,9 +182,9 @@ done
|
|||
head_ "Host resolver — a box's DNS is forwarded through this"
|
||||
hostns="$(grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | tr '\n' ' ')"
|
||||
inf "/etc/resolv.conf: ${hostns:-<none>}"
|
||||
raw="$(incus network get claudenet raw.dnsmasq 2>/dev/null | tr '\n' ';')"
|
||||
raw="$(incus network get boxnet raw.dnsmasq 2>/dev/null | tr '\n' ';')"
|
||||
if [ -n "$raw" ]; then
|
||||
ok "claudenet has a pinned resolver (raw.dnsmasq: $raw)"
|
||||
ok "boxnet has a pinned resolver (raw.dnsmasq: $raw)"
|
||||
inf "boxes do NOT inherit the host's resolver — good (issue #33)"
|
||||
else
|
||||
# 100.64.0.0/10 is CGNAT — which is exactly Tailscale's range.
|
||||
|
|
@ -191,8 +200,8 @@ else
|
|||
fi
|
||||
|
||||
if [ "$PIN" = 1 ]; then
|
||||
head_ "Pinning claudenet's resolver (issue #33)"
|
||||
if incus network set claudenet raw.dnsmasq "$(printf 'no-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n')" 2>/tmp/pin.err; then
|
||||
head_ "Pinning boxnet's resolver (issue #33)"
|
||||
if incus network set boxnet raw.dnsmasq "$(printf 'no-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n')" 2>/tmp/pin.err; then
|
||||
ok "set raw.dnsmasq: no-resolv + 1.1.1.1 + 8.8.8.8 — dnsmasq now ignores /etc/resolv.conf"
|
||||
inf "a box's DNS no longer depends on the host's VPN state, and MagicDNS is out of the path"
|
||||
inf "re-run the drill; if the cold mint now succeeds, issue #33 is confirmed and the fix belongs in setup-host.sh"
|
||||
|
|
@ -203,8 +212,10 @@ if [ "$PIN" = 1 ]; then
|
|||
fi
|
||||
|
||||
head_ "Can a box actually resolve DNS?"
|
||||
# Any box will do — the drill's names are not the only boxes on a host.
|
||||
probe="$(incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null \
|
||||
# Any box will do — the drill's names are not the only boxes on a host, and
|
||||
# a pre-rename box (legacy tag) is as good a probe as a new one.
|
||||
probe="$({ incus list "user.box=1" --format csv --columns ns 2>/dev/null
|
||||
incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null; } \
|
||||
| awk -F, '$2 == "RUNNING" { print $1; exit }')"
|
||||
if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
|
||||
# Stdin MUST be pinned to /dev/null: with a TTY on stdin, 'incus exec' goes
|
||||
|
|
@ -215,8 +226,8 @@ if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
|
|||
inf "its resolv.conf: $(timeout -k 5 20 incus exec "$probe" -- sh -c 'grep -m2 nameserver /etc/resolv.conf' </dev/null 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
# Routing is probed by ADDRESS against the public internet, NOT by pinging
|
||||
# the gateway: claudebox-firewall.sh drops everything from a box to the host
|
||||
# except DNS/DHCP, so ICMP to 10.87.0.1 fails BY DESIGN on a healthy host.
|
||||
# the gateway: box-firewall.sh drops everything from a box to the host
|
||||
# except DNS/DHCP, so ICMP to 10.88.0.1 fails BY DESIGN on a healthy host.
|
||||
# A gateway ping here is a check that can only ever lie.
|
||||
if timeout -k 5 25 incus exec "$probe" -- curl -sS -m 10 -o /dev/null https://1.1.1.1 </dev/null 2>/dev/null; then
|
||||
routing=1; ok "reaches 1.1.1.1 by address — egress routing is fine"
|
||||
|
|
|
|||
277
drill/drill.sh
277
drill/drill.sh
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# drill.sh — end-to-end drill for claudebox, against a real Incus.
|
||||
# drill.sh — end-to-end drill for box (the claudebox repo), against a real Incus.
|
||||
#
|
||||
# ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY host you can format.
|
||||
# It installs Incus, rewrites the host's firewall rules, installs a systemd
|
||||
|
|
@ -8,13 +8,13 @@
|
|||
#
|
||||
# bash drill/drill.sh # asks first
|
||||
# bash drill/drill.sh --yes # no prompt (CI, or you've read it)
|
||||
# bash drill/drill.sh --ref main # drill a different branch of claudebox
|
||||
# bash drill/drill.sh --ref main # drill a different branch of the repo
|
||||
# bash drill/drill.sh --keep-boxes # leave the boxes up to poke at
|
||||
#
|
||||
# Four phases:
|
||||
# A. Incus semantics — the assumptions claudebox is built on, probed directly.
|
||||
# A. Incus semantics — the assumptions box is built on, probed directly.
|
||||
# These were only ever verified against a stub.
|
||||
# B. The claudebox surface — the whole CLI, end to end, including the boundary.
|
||||
# B. The box surface — the whole CLI, end to end, including the boundary.
|
||||
# C. Isolation baseline — does the trust boundary actually hold? (#15 section A)
|
||||
# D. Hardening rehearsal — #16's proposed changes, applied live and re-probed
|
||||
# (#15 section B). FAILs here are design vetoes, not code bugs.
|
||||
|
|
@ -60,7 +60,7 @@ aud() { audit+=("$*"); } # an answer for the #15 audit
|
|||
wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
|
||||
local b="$1" _i
|
||||
for _i in $(seq 1 60); do
|
||||
claudebox exec "$b" -- true >/dev/null 2>&1 && return 0
|
||||
box exec "$b" -- true >/dev/null 2>&1 && return 0
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
|
|
@ -69,7 +69,7 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
|
|||
# Read from inside a box WITHOUT ever hanging the drill.
|
||||
#
|
||||
# Two traps, both hit for real:
|
||||
# · 'claudebox exec' becomes 'sudo -u claude -i' — a LOGIN zsh (oh-my-zsh and
|
||||
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
|
||||
# all). Fine for a person, needless machinery for a probe.
|
||||
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
||||
# inheriting the exec session's stdout keeps the substitution open forever,
|
||||
|
|
@ -86,20 +86,20 @@ in_box() {
|
|||
return "$rc"
|
||||
}
|
||||
|
||||
# The box's address ON CLAUDENET. Three ways to get this wrong, all of them hit:
|
||||
# The box's address ON BOXNET. Three ways to get this wrong, all of them hit:
|
||||
# · 'incus list' name filters are NOT regexes ("^b$" silently matches nothing)
|
||||
# · its CSV quotes a multi-address box across lines
|
||||
# · and the interface is NOT called eth0. The PROFILE names the device eth0,
|
||||
# but inside a VM guest predictable naming renames it enp5s0. Six runs of
|
||||
# A3 "not probed" were this, not the network.
|
||||
# So: read it from inside the box, and select by SUBNET (10.87.x, what claudenet
|
||||
# So: read it from inside the box, and select by SUBNET (10.88.x, what boxnet
|
||||
# hands out) rather than by interface name — docker0 (172.17.x) is the decoy,
|
||||
# and the NIC's name is the guest's business, not ours.
|
||||
claudenet_ip() {
|
||||
boxnet_ip() {
|
||||
local b="$1" ip _i
|
||||
for _i in $(seq 1 15); do
|
||||
ip="$(in_box "$b" ip -4 -o addr show scope global \
|
||||
| awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.87\./) { split($(i+1), a, "/"); print a[1]; exit } }')"
|
||||
| awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.88\./) { split($(i+1), a, "/"); print a[1]; exit } }')"
|
||||
[ -n "$ip" ] && { printf '%s\n' "$ip"; return 0; }
|
||||
sleep 2
|
||||
done
|
||||
|
|
@ -137,15 +137,29 @@ box_pings() { # box_pings <box> <ip> → 0 if it answers ICMP
|
|||
timeout -k 5 20 incus exec "$1" -- ping -c1 -W2 "$2" >/dev/null 2>&1 </dev/null
|
||||
}
|
||||
|
||||
# Mint with a heartbeat. box new's own narration lands in the log; a dot every
|
||||
# 5s on the drill's terminal proves the run is ALIVE — a silent multi-minute
|
||||
# mint is indistinguishable from a wedge, and that ambiguity has cost whole
|
||||
# evenings. The log line says where to watch the real progress.
|
||||
mint_box() { # mint_box <log> <box-new args...> → box new's exit code
|
||||
local log="$1"; shift
|
||||
inf "watch it live in another terminal: tail -f $log"
|
||||
box new "$@" >"$log" 2>&1 </dev/null &
|
||||
local pid=$!
|
||||
while kill -0 "$pid" 2>/dev/null; do printf '.'; sleep 5; done
|
||||
printf '\n'
|
||||
wait "$pid"
|
||||
}
|
||||
|
||||
# --- stage 1: consent, install, then re-enter inside the incus-admin group ---
|
||||
if [ "${IN_GROUP:-0}" != 1 ]; then
|
||||
if [ "$YES" -ne 1 ]; then
|
||||
cat <<EOF
|
||||
This will, ON THIS HOST ($(hostname)):
|
||||
· install Incus and a systemd unit
|
||||
· create a network (claudenet), an ACL, and a profile
|
||||
· create a network (boxnet), an ACL, and a profile
|
||||
· rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain)
|
||||
· create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy
|
||||
· create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl
|
||||
· mutate the network and profile mid-run to rehearse the #16 hardening
|
||||
Only do this on a machine you can format.
|
||||
EOF
|
||||
|
|
@ -155,13 +169,13 @@ EOF
|
|||
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
|
||||
fi
|
||||
|
||||
phase "Installing claudebox ($REPO@$REF)"
|
||||
phase "Installing box ($REPO@$REF)"
|
||||
CLAUDEBOX_REPO="$REPO" CLAUDEBOX_REF="$REF" \
|
||||
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \
|
||||
|| { echo "install failed"; exit 1; }
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
phase "Host setup (Incus, claudenet, ACL, profile, firewall)"
|
||||
phase "Host setup (Incus, boxnet, ACL, profile, firewall)"
|
||||
# setup-host.sh installs nftables itself when neither nft nor UFW exists
|
||||
# (a stock Debian 13 cloud image ships neither). This guard is a tripwire:
|
||||
# if it fires, that fix regressed.
|
||||
|
|
@ -214,27 +228,33 @@ KEEP="${KEEP:-0}"
|
|||
# DNS-enumeration leak), so it is no longer "dirt" from a rehearsal — do not
|
||||
# revert it. Only the vetoed NIC filtering counts as leftover.
|
||||
dirty=""
|
||||
[ -n "$(incus profile device get claude-dev eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty ipv4_filtering"
|
||||
[ -n "$(incus profile device get claude-dev eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty mac_filtering"
|
||||
for p in box-net claude-dev; do
|
||||
[ -n "$(incus profile device get "$p" eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty $p:ipv4_filtering"
|
||||
[ -n "$(incus profile device get "$p" eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty $p:mac_filtering"
|
||||
done
|
||||
if [ -n "$dirty" ]; then
|
||||
note "this host carries the VETOED NIC filtering from an old rehearsal:$dirty — reverting"
|
||||
incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1
|
||||
incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1
|
||||
for p in box-net claude-dev; do
|
||||
incus profile device unset "$p" eth0 security.mac_filtering >/dev/null 2>&1
|
||||
incus profile device unset "$p" eth0 security.ipv4_filtering >/dev/null 2>&1
|
||||
done
|
||||
fi
|
||||
|
||||
inf "clearing anything a previous run left behind…"
|
||||
# One name at a time — 'incus delete -f a b c' aborts at the first MISSING name,
|
||||
# which is how run 2 inherited run 1's boxes and cascaded five false FAILs.
|
||||
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours; do
|
||||
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do
|
||||
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1
|
||||
done
|
||||
if incus network show claudenet >/dev/null 2>&1; then
|
||||
timeout -k 5 30 incus network unset claudenet dns.mode >/dev/null 2>&1
|
||||
fi
|
||||
if incus profile show claude-dev >/dev/null 2>&1; then
|
||||
timeout -k 5 30 incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1
|
||||
timeout -k 5 30 incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1
|
||||
if incus network show boxnet >/dev/null 2>&1; then
|
||||
timeout -k 5 30 incus network unset boxnet dns.mode >/dev/null 2>&1
|
||||
fi
|
||||
for p in box-net claude-dev; do
|
||||
if incus profile show "$p" >/dev/null 2>&1; then
|
||||
timeout -k 5 30 incus profile device unset "$p" eth0 security.mac_filtering >/dev/null 2>&1
|
||||
timeout -k 5 30 incus profile device unset "$p" eth0 security.ipv4_filtering >/dev/null 2>&1
|
||||
fi
|
||||
done
|
||||
left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')"
|
||||
[ -n "$left" ] && inf "instances still on this host (not ours, left alone): $left"
|
||||
|
||||
|
|
@ -242,48 +262,51 @@ inf "running setup-host.sh (in-group pass: network, ACL, profile, firewall)…"
|
|||
if ! timeout -k 10 300 ~/.local/share/claudebox/host/setup-host.sh; then
|
||||
echo "drill: setup-host.sh failed or timed out (>5 min)." >&2
|
||||
echo " it should take seconds on a host that already has incus. usual causes:" >&2
|
||||
echo " · instances still attached to claudenet while its ACLs are reconfigured" >&2
|
||||
echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
|
||||
echo " incus list" >&2
|
||||
echo " · the firewall unit not completing" >&2
|
||||
echo " systemctl status claudebox-firewall.service --no-pager" >&2
|
||||
echo " systemctl status box-firewall.service --no-pager" >&2
|
||||
echo " · the incus daemon wedged by an earlier aborted run" >&2
|
||||
echo " systemctl status incus --no-pager; journalctl -u incus -n 30 --no-pager" >&2
|
||||
exit 1
|
||||
fi
|
||||
inf "host setup complete"
|
||||
|
||||
# A real server has room for the production profile (8GiB/4cpu), and drilling the
|
||||
# real profile is worth more than drilling a shrunken one. Only shrink if we must.
|
||||
# A real server has room for the claude template's resources (8GiB/4cpu), and
|
||||
# drilling the real numbers is worth more than drilling shrunken ones. Only
|
||||
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
|
||||
# template at mint — a profile edit no longer reaches them; the supported
|
||||
# override is the BOX_* environment, which every 'box new' below inherits.
|
||||
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
|
||||
if [ "$ram" -lt 20 ]; then
|
||||
incus profile set claude-dev limits.memory=3GiB limits.cpu=2
|
||||
note "host has ${ram}GiB RAM — lowered claude-dev to 3GiB/2cpu for the drill (production profile is 8GiB/4cpu, and that is what was NOT drilled)"
|
||||
export BOX_MEMORY=3GiB BOX_CPU=2
|
||||
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude template's 8GiB/4cpu is what was NOT drilled)"
|
||||
else
|
||||
inf "host has ${ram}GiB RAM — drilling the production profile (8GiB/4cpu) unchanged"
|
||||
inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged"
|
||||
fi
|
||||
|
||||
KVM=0; [ -e /dev/kvm ] && KVM=1
|
||||
[ "$KVM" = 1 ] && inf "/dev/kvm present — boxes will be VMs (the real trust boundary)" \
|
||||
|| note "NO /dev/kvm on this host — claudebox will fall back to CONTAINER mode, so this run does NOT validate the VM trust boundary"
|
||||
|| note "NO /dev/kvm on this host — box will fall back to CONTAINER mode, so this run does NOT validate the VM trust boundary"
|
||||
|
||||
# ===========================================================================
|
||||
phase "A. Incus semantics — the assumptions claudebox is built on"
|
||||
phase "A. Incus semantics — the assumptions box is built on"
|
||||
# ===========================================================================
|
||||
incus launch images:debian/13 cbprobe --config user.claudebox=1 >/dev/null 2>&1
|
||||
incus launch images:debian/13 cbprobe --config user.box=1 >/dev/null 2>&1
|
||||
incus launch images:debian/13 cbnotours >/dev/null 2>&1 # untagged: not ours
|
||||
sleep 3
|
||||
|
||||
# A1 — the tag read. #13 puts this on the path of EVERY box command.
|
||||
t="$(incus config get cbprobe user.claudebox 2>&1)"
|
||||
[ "$t" = "1" ] && ok "config get user.claudebox → '1'" \
|
||||
|| no "config get user.claudebox → '$t' (expected '1'; every box command would fail closed)"
|
||||
t="$(incus config get cbprobe user.box 2>&1)"
|
||||
[ "$t" = "1" ] && ok "config get user.box → '1'" \
|
||||
|| no "config get user.box → '$t' (expected '1'; every box command would fail closed)"
|
||||
|
||||
# A2 — the list filter, and that it EXCLUDES an instance we didn't mint
|
||||
f="$(incus list user.claudebox=1 --format csv --columns nstS 2>&1)"
|
||||
f="$(incus list user.box=1 --format csv --columns nstS 2>&1)"
|
||||
if echo "$f" | grep -q '^cbprobe,' && ! echo "$f" | grep -q '^cbnotours,'; then
|
||||
ok "list filter user.claudebox=1 selects ours, excludes theirs"
|
||||
ok "list filter user.box=1 selects ours, excludes theirs"
|
||||
else
|
||||
no "list filter user.claudebox=1 is wrong — got: $(echo "$f" | tr '\n' ' ')"
|
||||
no "list filter user.box=1 is wrong — got: $(echo "$f" | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# A3 — four fields, no commas/newlines to mangle the awk table
|
||||
|
|
@ -340,36 +363,80 @@ incus delete -f cbcopy >/dev/null 2>&1
|
|||
incus delete -f cbprobe cbnotours >/dev/null 2>&1
|
||||
|
||||
# ===========================================================================
|
||||
phase "B. The claudebox surface"
|
||||
phase "B. The box surface"
|
||||
# ===========================================================================
|
||||
# Compare against the installed tree's VERSION file, not a hardcoded number —
|
||||
# a pinned literal here would fail the drill on every release.
|
||||
expected="$(cat "$HOME/.local/share/claudebox/VERSION" 2>/dev/null || echo '?')"
|
||||
v="$(claudebox --version 2>&1)"
|
||||
case "$v" in *"$expected"*) ok "claudebox --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
||||
v="$(box --version 2>&1)"
|
||||
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
||||
|
||||
# The drill must not require an empty host: operator boxes tagged
|
||||
# user.claudebox=1 are legitimate tenants, and the teardown below deliberately
|
||||
# user.box=1 (or the legacy tag) are legitimate tenants, and the teardown below deliberately
|
||||
# refuses to touch them. The empty-host message is only TESTABLE when the host
|
||||
# is actually empty — on a shared host, skip it instead of failing it.
|
||||
tenants="$(incus list user.claudebox=1 --format csv --columns n 2>/dev/null | tr '\n' ' ')"
|
||||
tenants="$({ incus list user.box=1 --format csv --columns n 2>/dev/null
|
||||
incus list user.claudebox=1 --format csv --columns n 2>/dev/null; } | sort -u | tr '\n' ' ')"
|
||||
if [ -n "${tenants% }" ]; then
|
||||
inf "host already has claudebox boxes (${tenants% }) — the empty-host message cannot be tested this run"
|
||||
inf "host already has boxes (${tenants% }) — the empty-host message cannot be tested this run"
|
||||
else
|
||||
claudebox list >/dev/null 2>&1 && claudebox list 2>&1 | grep -q 'no boxes yet' \
|
||||
box list >/dev/null 2>&1 && box list 2>&1 | grep -q 'no boxes yet' \
|
||||
&& ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong"
|
||||
fi
|
||||
|
||||
printf '\n minting a box (cold, ~10 min)…\n'
|
||||
# --- templates: the mint surface is itself a surface to test ----------------
|
||||
box templates 2>/dev/null | grep -q '^ blank' && box templates 2>/dev/null | grep -q '^ claude' \
|
||||
&& ok "templates: lists blank and claude" || no "templates listing is missing a shipped template"
|
||||
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
||||
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
|
||||
# The one rule that keeps templates honest: no key can name a network. Plant a
|
||||
# bad template in the installed tree (the drill owns this host), expect the
|
||||
# parser to reject it BY NAME, remove it.
|
||||
badt="$HOME/.local/share/claudebox/templates/cbdrill-bad"
|
||||
mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml"
|
||||
box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \
|
||||
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \
|
||||
|| no "a box.env key outside the allowlist was ACCEPTED — a template could weaken isolation"
|
||||
rm -rf "$badt"
|
||||
|
||||
printf '\n minting a blank box (the DEFAULT template — no tooling, fast)…\n'
|
||||
t0=$SECONDS
|
||||
if claudebox new --name drill >/tmp/new.log 2>&1; then
|
||||
ok "claudebox new --name drill ($((SECONDS - t0))s)"
|
||||
if mint_box /tmp/mint-tpl.log --name tpl; then
|
||||
ok "box new --name tpl, no --template ($((SECONDS - t0))s)"
|
||||
tt="$(incus config get tpl user.box.template 2>/dev/null)"
|
||||
[ "$tt" = blank ] && ok "the default template is blank (user.box.template=blank)" \
|
||||
|| no "default template is '${tt:-<unset>}' — expected blank"
|
||||
[ "$(incus config get tpl user.box.user 2>/dev/null)" = dev ] \
|
||||
&& ok "template user stamped on the instance (user.box.user=dev)" || no "user.box.user not stamped"
|
||||
incus config show tpl 2>/dev/null | grep -q '^- box-net' \
|
||||
&& ok "blank box launched with the box-net profile — same placement contract" \
|
||||
|| no "blank box is NOT on box-net — a template picked its own placement?!"
|
||||
u="$(timeout -k 5 30 box exec tpl -- whoami </dev/null 2>/dev/null | tr -d '[:space:]')"
|
||||
[ "$u" = dev ] && ok "exec lands in the template's user ($u) — nothing hardcodes claude" \
|
||||
|| no "exec landed in '${u:-<nothing>}', expected dev"
|
||||
timeout -k 5 30 box exec tpl -- sh -lc 'command -v claude' </dev/null >/dev/null 2>&1 \
|
||||
&& no "the blank box has claude installed — 'blank' is not blank" \
|
||||
|| ok "blank box has no claude — nobody home, as designed"
|
||||
box_pings tpl 1.1.1.1 && ok "blank box reaches the internet (same egress as any template)" \
|
||||
|| no "blank box has NO egress — isolation parity broken"
|
||||
in_box tpl getent hosts deb.debian.org >/dev/null 2>&1 \
|
||||
&& ok "blank box resolves public names (pinned resolver serves every template)" \
|
||||
|| no "blank box cannot resolve — DNS parity broken"
|
||||
box rm tpl --force >/dev/null 2>&1 && ok "blank box removed" || no "could not remove the blank box"
|
||||
else
|
||||
no "claudebox new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')"
|
||||
no "blank mint FAILED — tail: $(tail -3 /tmp/mint-tpl.log | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
printf '\n minting a claude box (cold, ~10 min)…\n'
|
||||
t0=$SECONDS
|
||||
if mint_box /tmp/mint-drill.log --name drill --template claude; then
|
||||
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
|
||||
else
|
||||
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
|
||||
echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1
|
||||
fi
|
||||
|
||||
typ="$(claudebox list | awk '$1 == "drill" { print $3 }')"
|
||||
typ="$(box list | awk '$1 == "drill" { print $3 }')"
|
||||
if [ "$KVM" = 1 ]; then
|
||||
[ "$typ" = VM ] && ok "the box is a VM — the trust boundary is real" \
|
||||
|| no "the box is '$typ' but /dev/kvm exists — it should have been a VM"
|
||||
|
|
@ -377,83 +444,83 @@ else
|
|||
note "the box is '$typ' (no /dev/kvm on this host)"
|
||||
fi
|
||||
|
||||
claudebox info drill | grep -q '^IPV4' && ok "info shows an IPv4" || no "info has no IPV4 row"
|
||||
claudebox info drill | grep -q 'SNAPSHOTS (none)' && ok "info: no snapshots yet, offers to take one" || no "info snapshot-empty state wrong"
|
||||
box info drill | grep -q '^IPV4' && ok "info shows an IPv4" || no "info has no IPV4 row"
|
||||
box info drill | grep -q 'SNAPSHOTS (none)' && ok "info: no snapshots yet, offers to take one" || no "info snapshot-empty state wrong"
|
||||
|
||||
if claudebox exec drill -- claude --version >/dev/null 2>&1; then
|
||||
if box exec drill -- claude --version >/dev/null 2>&1; then
|
||||
ok "Claude Code is installed in the box"
|
||||
elif timeout 30 claudebox exec drill -- bash -lc 'claude --version' >/dev/null 2>&1; then
|
||||
no "'claude' is installed but NOT on exec's PATH — repo bug: the help promises 'claudebox exec work -- claude --version'"
|
||||
inf "PATH as exec sees it: $(timeout 30 claudebox exec drill -- printenv PATH 2>/dev/null)"
|
||||
elif timeout 30 box exec drill -- bash -lc 'claude --version' >/dev/null 2>&1; then
|
||||
no "'claude' is installed but NOT on exec's PATH — repo bug: the help promises 'box exec work -- claude --version'"
|
||||
inf "PATH as exec sees it: $(timeout 30 box exec drill -- printenv PATH 2>/dev/null)"
|
||||
else
|
||||
no "'claude --version' failed inside the box"
|
||||
# diag output must skip the hatch's own 'claudebox: incus exec …' announce lines
|
||||
hatch_out() { timeout 30 claudebox incus drill -- exec {} -- "$@" 2>&1 | grep -v '^claudebox:' | tail -1 | cut -c1-120; }
|
||||
# diag output must skip the hatch's own 'box: incus exec …' announce lines
|
||||
hatch_out() { timeout 30 box incus drill -- exec {} -- "$@" 2>&1 | grep -v '^box:' | tail -1 | cut -c1-120; }
|
||||
inf "cloud-init: $(hatch_out cloud-init status)"
|
||||
inf "binary runs? $(hatch_out sudo -u claude /home/claude/.local/bin/claude --version)"
|
||||
inf "exec PATH: $(timeout 30 claudebox exec drill -- printenv PATH 2>/dev/null | tail -1)"
|
||||
inf "exec PATH: $(timeout 30 box exec drill -- printenv PATH 2>/dev/null | tail -1)"
|
||||
fi
|
||||
claudebox exec drill -- gh --version >/dev/null 2>&1 \
|
||||
box exec drill -- gh --version >/dev/null 2>&1 \
|
||||
&& ok "the GitHub CLI is installed in the box (PR #5)" || no "'gh --version' failed inside the box"
|
||||
|
||||
# --- the snapshot → clone workflow, which is the whole point of the tool ---
|
||||
claudebox snapshot drill authed 2>&1 | grep -q authed && ok "snapshot drill authed" || no "snapshot failed"
|
||||
claudebox info drill | grep -q 'authed' && ok "info lists the snapshot label" || no "info does not show the label"
|
||||
claudebox info drill | grep -q -- '--from drill/authed' && ok "info prints the --from line to clone it" || no "info lacks the --from hint"
|
||||
box snapshot drill authed 2>&1 | grep -q authed && ok "snapshot drill authed" || no "snapshot failed"
|
||||
box info drill | grep -q 'authed' && ok "info lists the snapshot label" || no "info does not show the label"
|
||||
box info drill | grep -q -- '--from drill/authed' && ok "info prints the --from line to clone it" || no "info lacks the --from hint"
|
||||
|
||||
# --- the boundary: an instance claudebox did NOT mint ----------------------
|
||||
# --- the boundary: an instance box did NOT mint ----------------------
|
||||
incus launch images:debian/13 payroll >/dev/null 2>&1 # somebody else's instance
|
||||
sleep 2
|
||||
claudebox down payroll 2>&1 | grep -q 'no such box' && ok "boundary: 'down' refuses an untagged instance" || no "boundary: 'down' touched an instance claudebox didn't mint!"
|
||||
claudebox rm payroll --force 2>&1 | grep -q 'no such box' && ok "boundary: 'rm' refuses an untagged instance" || no "boundary: 'rm' would DELETE a foreign instance!"
|
||||
claudebox incus payroll -- config show 2>&1 | grep -q 'no such box' && ok "boundary: the escape hatch refuses it too" || no "boundary: the hatch reached a foreign instance!"
|
||||
box down payroll 2>&1 | grep -q 'no such box' && ok "boundary: 'down' refuses an untagged instance" || no "boundary: 'down' touched an instance box didn't mint!"
|
||||
box rm payroll --force 2>&1 | grep -q 'no such box' && ok "boundary: 'rm' refuses an untagged instance" || no "boundary: 'rm' would DELETE a foreign instance!"
|
||||
box incus payroll -- config show 2>&1 | grep -q 'no such box' && ok "boundary: the escape hatch refuses it too" || no "boundary: the hatch reached a foreign instance!"
|
||||
incus list payroll --format csv --columns ns | grep -q '^payroll,RUNNING' && ok "…and payroll is still running, untouched" || no "payroll was harmed — the boundary leaked"
|
||||
incus delete -f payroll >/dev/null 2>&1
|
||||
|
||||
# --- rename, and its precondition -----------------------------------------
|
||||
claudebox rename drill archive 2>&1 | grep -qi 'RUNNING' && ok "rename refuses a running box, and says how to fix it" || no "rename did not refuse a running box"
|
||||
claudebox down drill >/dev/null 2>&1 && ok "down drill" || no "down failed"
|
||||
claudebox rename drill archive 2>&1 | grep -q 'renamed drill to archive' && ok "rename drill → archive (stopped)" || no "rename failed on a stopped box"
|
||||
claudebox list | grep -q '^archive' && ok "list shows the new name" || no "list still shows the old name"
|
||||
claudebox info archive | grep -q authed && ok "the snapshot followed the rename" || no "snapshot lost across the rename"
|
||||
box rename drill archive 2>&1 | grep -qi 'RUNNING' && ok "rename refuses a running box, and says how to fix it" || no "rename did not refuse a running box"
|
||||
box down drill >/dev/null 2>&1 && ok "down drill" || no "down failed"
|
||||
box rename drill archive 2>&1 | grep -q 'renamed drill to archive' && ok "rename drill → archive (stopped)" || no "rename failed on a stopped box"
|
||||
box list | grep -q '^archive' && ok "list shows the new name" || no "list still shows the old name"
|
||||
box info archive | grep -q authed && ok "the snapshot followed the rename" || no "snapshot lost across the rename"
|
||||
|
||||
# --- clone from a snapshot of a renamed box --------------------------------
|
||||
printf '\n cloning from the snapshot…\n'
|
||||
if claudebox new --name clone --from archive/authed >/tmp/clone.log 2>&1; then
|
||||
if mint_box /tmp/mint-clone.log --name clone --from archive/authed; then
|
||||
ok "new --from archive/authed (clone of a snapshot of a renamed box)"
|
||||
claudebox exec clone -- true >/dev/null 2>&1 && ok "the clone is alive and enterable" || no "the clone is not enterable"
|
||||
box exec clone -- true >/dev/null 2>&1 && ok "the clone is alive and enterable" || no "the clone is not enterable"
|
||||
else
|
||||
no "clone FAILED — tail: $(tail -3 /tmp/clone.log | tr '\n' ' ')"
|
||||
no "clone FAILED — tail: $(tail -3 /tmp/mint-clone.log | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# --- the escape hatch ------------------------------------------------------
|
||||
claudebox incus archive -- config show 2>/dev/null | grep -q 'user.claudebox' && ok "hatch: 'incus archive -- config show', instance appended" || no "hatch passthrough failed"
|
||||
h="$(claudebox incus archive -- config device add {} scratch disk source=/tmp path=/mnt/scratch 2>&1)"
|
||||
box incus archive -- config show 2>/dev/null | grep -q 'user.box' && ok "hatch: 'incus archive -- config show', instance appended" || no "hatch passthrough failed"
|
||||
h="$(box incus archive -- config device add {} scratch disk source=/tmp path=/mnt/scratch 2>&1)"
|
||||
echo "$h" | grep -q 'isolation stack' && ok "hatch warns when a command can break isolation" || no "hatch did not warn on a device add"
|
||||
claudebox incus archive -- config device remove {} scratch >/dev/null 2>&1
|
||||
box incus archive -- config device remove {} scratch >/dev/null 2>&1
|
||||
|
||||
# --- rm, and the guard that did not used to exist --------------------------
|
||||
claudebox rm clone </dev/null 2>&1 | grep -q 'refusing' && ok "rm with no TTY and no --force refuses (exit 2)" || no "rm destroyed a box with no confirmation!"
|
||||
claudebox rm clone --force 2>&1 | grep -q 'removed' && ok "rm --force removes the clone" || no "rm --force failed"
|
||||
box rm clone </dev/null 2>&1 | grep -q 'refusing' && ok "rm with no TTY and no --force refuses (exit 2)" || no "rm destroyed a box with no confirmation!"
|
||||
box rm clone --force 2>&1 | grep -q 'removed' && ok "rm --force removes the clone" || no "rm --force failed"
|
||||
|
||||
# --- the CLI contract ------------------------------------------------------
|
||||
claudebox lst 2>&1 | grep -q "did you mean 'list'" && ok "typo → did-you-mean, exit 2" || no "unknown command not suggested"
|
||||
claudebox list archive 2>&1 | grep -q 'claudebox info archive' && ok "'list <box>' points at info" || no "'list <box>' does not point at info"
|
||||
claudebox snapshot archive --labl x 2>&1 | grep -q 'unknown option' && ok "typo'd flag rejected (not swallowed as a label)" || no "unknown flag was swallowed"
|
||||
box lst 2>&1 | grep -q "did you mean 'list'" && ok "typo → did-you-mean, exit 2" || no "unknown command not suggested"
|
||||
box list archive 2>&1 | grep -q 'box info archive' && ok "'list <box>' points at info" || no "'list <box>' does not point at info"
|
||||
box snapshot archive --labl x 2>&1 | grep -q 'unknown option' && ok "typo'd flag rejected (not swallowed as a label)" || no "unknown flag was swallowed"
|
||||
|
||||
# ===========================================================================
|
||||
phase "C. Isolation baseline — does the boundary actually hold? (#15 section A)"
|
||||
# ===========================================================================
|
||||
claudebox start archive >/dev/null 2>&1
|
||||
box start archive >/dev/null 2>&1
|
||||
wait_box archive && ok "archive is back up (agent answering)" \
|
||||
|| no "archive did not come back within 2 min of start"
|
||||
|
||||
# Sibling isolation needs a sibling. Clone from the snapshot — fast, no cold mint.
|
||||
printf '\n cloning a peer for the sibling probes…\n'
|
||||
if claudebox new --name peer --from archive/authed >/tmp/peer.log 2>&1 && wait_box peer; then
|
||||
if mint_box /tmp/mint-peer.log --name peer --from archive/authed && wait_box peer; then
|
||||
ok "peer minted from archive/authed and answering"
|
||||
else
|
||||
no "peer clone failed or never answered — tail: $(tail -3 /tmp/peer.log | tr '\n' ' ')"
|
||||
no "peer clone failed or never answered — tail: $(tail -3 /tmp/mint-peer.log | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# C1 — public egress (#15 A1; resolving the hostname also proves A5, gateway DNS)
|
||||
|
|
@ -472,10 +539,10 @@ fi
|
|||
# nothing serves and read refused-vs-dropped — refused would mean the box's
|
||||
# packet reached the host's stack, which is the thing the firewall must prevent.
|
||||
# (No background listener: one less process to leak, one less way to wedge.)
|
||||
hv="$(box_probe archive http://10.87.0.1:8099)"
|
||||
hv="$(box_probe archive http://10.88.0.1:8099)"
|
||||
case "$hv" in
|
||||
reachable|refused)
|
||||
no "THE BOX'S PACKETS REACH THE HOST on 10.87.0.1:8099 [$hv] — the firewall rules are not holding"
|
||||
no "THE BOX'S PACKETS REACH THE HOST on 10.88.0.1:8099 [$hv] — the firewall rules are not holding"
|
||||
aud "A2 box→host: FAIL — $hv (the packet reached the host's stack)" ;;
|
||||
dropped)
|
||||
ok "box → host is blocked (no path to the machine's sockets)"
|
||||
|
|
@ -500,8 +567,8 @@ esac
|
|||
# port answers the question just as well (refused = the packet arrived), and
|
||||
# the listener was what kept wedging the run. Ping corroborates: if the two
|
||||
# disagree, say so rather than pick one.
|
||||
PEER_IP="$(claudenet_ip peer)"
|
||||
ARCH_IP_PRE="$(claudenet_ip archive)"
|
||||
PEER_IP="$(boxnet_ip peer)"
|
||||
ARCH_IP_PRE="$(boxnet_ip archive)"
|
||||
if [ -n "$PEER_IP" ] && [ "$PEER_IP" = "$ARCH_IP_PRE" ]; then
|
||||
# Guard, because this actually happened: a clone inherited its source's
|
||||
# machine-id, hence its DHCP lease, hence its ADDRESS. Probing "archive →
|
||||
|
|
@ -525,8 +592,8 @@ elif [ -n "$PEER_IP" ]; then
|
|||
aud "A3 sibling: BLOCKED — tcp dropped + no icmp reply (security.port_isolation)"
|
||||
fi
|
||||
else
|
||||
no "could not read peer's claudenet address — the sibling probe never ran"
|
||||
aud "A3 sibling: NOT PROBED (no 10.87.x address on peer)"
|
||||
no "could not read peer's boxnet address — the sibling probe never ran"
|
||||
aud "A3 sibling: NOT PROBED (no 10.88.x address on peer)"
|
||||
fi
|
||||
|
||||
# C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host
|
||||
|
|
@ -542,13 +609,13 @@ else
|
|||
fi
|
||||
|
||||
# C6 — IPv6 off (#15 A6): every ACL rule is IPv4-only; off is the only cover.
|
||||
[ "$(incus network get claudenet ipv6.address 2>/dev/null)" = none ] \
|
||||
&& { ok "claudenet ipv6.address = none (the IPv4-only ACLs have no uncovered path)"; aud "A6 ipv6: none, as contract requires"; } \
|
||||
|| { no "claudenet has IPv6 enabled — and not one ACL rule covers IPv6"; aud "A6 ipv6: ENABLED and uncovered"; }
|
||||
[ "$(incus network get boxnet ipv6.address 2>/dev/null)" = none ] \
|
||||
&& { ok "boxnet ipv6.address = none (the IPv4-only ACLs have no uncovered path)"; aud "A6 ipv6: none, as contract requires"; } \
|
||||
|| { no "boxnet has IPv6 enabled — and not one ACL rule covers IPv6"; aud "A6 ipv6: ENABLED and uncovered"; }
|
||||
|
||||
# C7 — inbound, host → box (#15 A7): the ACL's default ingress drop. Same
|
||||
# listener-free logic, run from the host this time.
|
||||
ARCH_IP="$(claudenet_ip archive)"
|
||||
ARCH_IP="$(boxnet_ip archive)"
|
||||
if [ -n "$ARCH_IP" ]; then
|
||||
hmsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" 2>&1)"; hrc=$?
|
||||
if [ "$hrc" -eq 0 ]; then hv=reachable
|
||||
|
|
@ -567,7 +634,7 @@ if [ -n "$ARCH_IP" ]; then
|
|||
aud "A7 inbound host→box: INCONCLUSIVE ($hv)" ;;
|
||||
esac
|
||||
else
|
||||
no "could not read archive's claudenet address — the inbound probe never ran"
|
||||
no "could not read archive's boxnet address — the inbound probe never ran"
|
||||
aud "A7 inbound host→box: NOT PROBED"
|
||||
fi
|
||||
|
||||
|
|
@ -576,7 +643,7 @@ phase "D. The isolation contract, stated"
|
|||
# ===========================================================================
|
||||
# Phase D used to REHEARSE the hardening on a throwaway host, because nobody
|
||||
# knew whether it would work. That question is settled: the hardening now ships
|
||||
# in setup-host.sh and claudebox-firewall.sh, so phase C tests the real thing
|
||||
# in setup-host.sh and box-firewall.sh, so phase C tests the real thing
|
||||
# and there is nothing left to rehearse. What the rehearsal established, kept
|
||||
# here so it is not re-litigated:
|
||||
#
|
||||
|
|
@ -607,15 +674,15 @@ fi
|
|||
# ===========================================================================
|
||||
if [ "$KEEP" = 1 ]; then
|
||||
phase "Boxes left up (--keep-boxes)"
|
||||
claudebox list
|
||||
box list
|
||||
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
|
||||
else
|
||||
# every name the drill can have left, whatever branch a partial run took
|
||||
for n in drill clone archive peer; do claudebox rm "$n" --force >/dev/null 2>&1; done
|
||||
for n in drill clone archive peer tpl; do box rm "$n" --force >/dev/null 2>&1; done
|
||||
# Assert OUR boxes are gone — not that the host is empty. The rm loop above
|
||||
# already embodies the discipline (only names the drill minted); demanding
|
||||
# 'no boxes yet' here would flag any pre-existing operator box as a failure.
|
||||
leftover="$(claudebox list 2>/dev/null | grep -E '^(drill|clone|archive|peer)([[:space:]]|$)' || true)"
|
||||
leftover="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer|tpl)([[:space:]]|$)' || true)"
|
||||
[ -z "$leftover" ] && ok "teardown: every box the drill minted is gone" \
|
||||
|| no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')"
|
||||
fi
|
||||
|
|
@ -633,7 +700,7 @@ if [ "${#audit[@]}" -gt 0 ]; then
|
|||
fi
|
||||
|
||||
echo
|
||||
inf "this host still has Incus, claudenet, the ACL, the profile and the firewall rules"
|
||||
inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules"
|
||||
inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)."
|
||||
inf "to undo: ~/.local/share/claudebox/host/teardown-host.sh [--purge-incus]"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
|
|
|||
147
drill/wipe.sh
Normal file
147
drill/wipe.sh
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
#!/usr/bin/env bash
|
||||
# wipe.sh — scorched earth for drill hosts. Remove EVERY trace of box and of
|
||||
# pre-0.4.0 claudebox, so the next drill run starts from a truly bare host and
|
||||
# its verdict means something.
|
||||
#
|
||||
# bash drill/wipe.sh # asks first; KEEPS cached images (an
|
||||
# # image is upstream's artifact — wiping
|
||||
# # it buys nothing but a re-download)
|
||||
# bash drill/wipe.sh --yes # no prompt
|
||||
# bash drill/wipe.sh --purge-storage # also delete the 'default' storage
|
||||
# # pool (and the images inside it), so
|
||||
# # setup-host exercises its bootstrap (#29)
|
||||
#
|
||||
# What teardown-host.sh does NOT cover, this does: instances the drill names
|
||||
# but never tagged, instances of either tag generation, cached images, and
|
||||
# (opt-in) the storage pool. teardown is the polite uninstall; this is the
|
||||
# reset button for the staging server.
|
||||
#
|
||||
# NOT 'set -e': on a wipe, a step that finds nothing to remove is success,
|
||||
# not failure. Every removal states what it did; silence is never trusted
|
||||
# (the exit-code lesson, again).
|
||||
set -u
|
||||
|
||||
YES=0; PURGE_STORAGE=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--yes|-y) YES=1; shift ;;
|
||||
--purge-storage) PURGE_STORAGE=1; shift ;;
|
||||
-h|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "wipe: unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf 'wipe: %s\n' "$*"; }
|
||||
|
||||
if [ "$YES" -ne 1 ]; then
|
||||
cat <<EOF
|
||||
This wipes EVERY trace of box/claudebox from this host ($(hostname)):
|
||||
· every instance tagged user.box=1 or user.claudebox=1
|
||||
· every instance the drill has ever named (drill, clone, archive, peer,
|
||||
payroll, cbprobe, cbcopy, cbnotours, tpl)
|
||||
· networks boxnet + claudenet, ACLs box-isolate + claude-isolate
|
||||
· profiles box-net + claude-dev
|
||||
· firewall units, scripts and nft tables of BOTH name generations
|
||||
$( [ "$PURGE_STORAGE" = 1 ] && echo " · the 'default' storage pool AND its cached images (--purge-storage)" \
|
||||
|| echo " · (cached images are KEPT — the next mint stays fast; --purge-storage removes them with the pool)" )
|
||||
Uncommitted work inside any box is LOST. Only do this on a drill host.
|
||||
EOF
|
||||
[ -t 0 ] || { echo "wipe: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
|
||||
printf 'Continue? [y/N] '
|
||||
read -r reply
|
||||
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
|
||||
fi
|
||||
|
||||
command -v incus >/dev/null || { say "incus is not installed — nothing box-shaped can exist; only firewall crumbs checked."; }
|
||||
|
||||
if command -v incus >/dev/null; then
|
||||
# --- instances: both tags, then every name the drill has ever used --------
|
||||
# One delete at a time — a multi-name 'incus delete' aborts at the first
|
||||
# missing name (drill trap 5).
|
||||
for tag in "user.box=1" "user.claudebox=1"; do
|
||||
for i in $(incus list "$tag" -f csv -c n 2>/dev/null); do
|
||||
timeout -k 5 60 incus delete -f "$i" >/dev/null 2>&1 \
|
||||
&& say "deleted instance $i ($tag)" || say "instance $i: delete FAILED — look at it by hand"
|
||||
done
|
||||
done
|
||||
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do
|
||||
incus info "$n" >/dev/null 2>&1 || continue
|
||||
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1 \
|
||||
&& say "deleted untagged drill instance $n" || say "instance $n: delete FAILED — look at it by hand"
|
||||
done
|
||||
|
||||
# --- profiles, networks, ACLs — both generations ---------------------------
|
||||
for p in box-net claude-dev; do
|
||||
incus profile delete "$p" >/dev/null 2>&1 && say "deleted profile $p"
|
||||
done
|
||||
for net in boxnet claudenet; do
|
||||
incus network delete "$net" >/dev/null 2>&1 && say "deleted network $net"
|
||||
done
|
||||
for acl in box-isolate claude-isolate; do
|
||||
incus network acl delete "$acl" >/dev/null 2>&1 && say "deleted ACL $acl"
|
||||
done
|
||||
|
||||
# --- cached images: NOT wiped by default -----------------------------------
|
||||
# An image is upstream's artifact, content-addressed by fingerprint — not a
|
||||
# drill artifact. Deleting it buys zero cleanliness and costs the next mint
|
||||
# a full re-download. It only goes when the pool it lives in goes.
|
||||
if [ "$PURGE_STORAGE" = 1 ]; then
|
||||
# --- the pool (opt-in): lets setup-host's bootstrap run for real ---------
|
||||
# Images live in the pool and block its deletion — they go first.
|
||||
for f in $(incus image list -f csv -c f 2>/dev/null); do
|
||||
incus image delete "$f" >/dev/null 2>&1 && say "deleted image $f"
|
||||
done
|
||||
incus profile device remove default root >/dev/null 2>&1 && say "removed default profile's root device"
|
||||
if incus storage delete default >/dev/null 2>&1; then
|
||||
say "deleted storage pool 'default' — setup-host will rebuild it (btrfs where it can)"
|
||||
else
|
||||
incus storage show default >/dev/null 2>&1 \
|
||||
&& say "pool 'default' NOT deleted — something still uses it: incus storage volume list default" \
|
||||
|| say "no 'default' pool existed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- firewall: units, scripts, nft tables, UFW and Docker crumbs -------------
|
||||
for unit in box-firewall claudebox-firewall; do
|
||||
sudo systemctl disable --now "$unit.service" >/dev/null 2>&1 && say "disabled $unit.service"
|
||||
sudo rm -f "/etc/systemd/system/$unit.service" "/usr/local/sbin/$unit"
|
||||
done
|
||||
sudo systemctl daemon-reload
|
||||
for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
|
||||
# shellcheck disable=SC2086 # the table spec is two words by design
|
||||
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
|
||||
done
|
||||
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
for net in boxnet claudenet; do
|
||||
while sudo ufw status numbered | grep -q "on $net"; do
|
||||
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
|
||||
done
|
||||
done
|
||||
fi
|
||||
if command -v docker >/dev/null; then
|
||||
for net in boxnet claudenet; do
|
||||
sudo iptables -D DOCKER-USER -i "$net" -j ACCEPT 2>/dev/null && say "removed DOCKER-USER -i $net"
|
||||
sudo iptables -D DOCKER-USER -o "$net" -j ACCEPT 2>/dev/null && say "removed DOCKER-USER -o $net"
|
||||
done
|
||||
fi
|
||||
|
||||
# --- verdict: assert the ABSENCE, don't trust the removals' exit codes -------
|
||||
left=""
|
||||
if command -v incus >/dev/null; then
|
||||
for tag in "user.box=1" "user.claudebox=1"; do
|
||||
[ -n "$(incus list "$tag" -f csv -c n 2>/dev/null)" ] && left="$left instances($tag)"
|
||||
done
|
||||
for net in boxnet claudenet; do incus network show "$net" >/dev/null 2>&1 && left="$left $net"; done
|
||||
for p in box-net claude-dev; do incus profile show "$p" >/dev/null 2>&1 && left="$left $p"; done
|
||||
fi
|
||||
for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
|
||||
# shellcheck disable=SC2086
|
||||
sudo nft list table $t >/dev/null 2>&1 && left="$left nft:${t// /-}"
|
||||
done
|
||||
if [ -n "$left" ]; then
|
||||
say "NOT clean — still present:$left"
|
||||
exit 1
|
||||
fi
|
||||
say "clean — no trace of box or claudebox remains. The drill will rebuild everything."
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
[Unit]
|
||||
Description=Re-apply claudebox host firewall rules
|
||||
Description=Re-apply box host firewall rules
|
||||
# After docker so DOCKER-USER exists; still fine if docker is absent.
|
||||
After=network.target incus.service docker.service
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/claudebox-firewall
|
||||
ExecStart=/usr/local/sbin/box-firewall
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
# Apply the claudebox host-firewall rules. Idempotent; runs as root.
|
||||
# Invoked by setup-host.sh at install time and by claudebox-firewall.service
|
||||
# Apply the box host-firewall rules. Idempotent; runs as root.
|
||||
# Invoked by setup-host.sh at install time and by box-firewall.service
|
||||
# at every boot (UFW rules persist on their own; the nft fallback table and
|
||||
# Docker's DOCKER-USER rules are runtime-only and need re-applying).
|
||||
set -euo pipefail
|
||||
|
||||
GW=10.87.0.1
|
||||
NET=claudenet
|
||||
GW=10.88.0.1
|
||||
NET=boxnet
|
||||
|
||||
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
if ! ufw status | grep "on $NET" | grep -q "DENY"; then
|
||||
|
|
@ -18,12 +18,12 @@ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active
|
|||
fi
|
||||
else
|
||||
# No UFW: protect the host's own sockets with a dedicated nft table.
|
||||
if ! nft list table inet claudebox >/dev/null 2>&1; then
|
||||
nft add table inet claudebox
|
||||
nft 'add chain inet claudebox input { type filter hook input priority -5 ; }'
|
||||
nft add rule inet claudebox input iifname "$NET" udp dport '{ 53, 67 }' accept
|
||||
nft add rule inet claudebox input iifname "$NET" tcp dport 53 accept
|
||||
nft add rule inet claudebox input iifname "$NET" drop
|
||||
if ! nft list table inet box >/dev/null 2>&1; then
|
||||
nft add table inet box
|
||||
nft 'add chain inet box input { type filter hook input priority -5 ; }'
|
||||
nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept
|
||||
nft add rule inet box input iifname "$NET" tcp dport 53 accept
|
||||
nft add rule inet box input iifname "$NET" drop
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ fi
|
|||
#
|
||||
# This is the ONE rule that makes "isolated even from each other" true, and it
|
||||
# is not the one anyone expected. The Incus ACL drops egress to 10.0.0.0/8, and
|
||||
# claudenet's 10.87.0.0/24 sits inside it — so on paper box→box was already
|
||||
# boxnet's 10.88.0.0/24 sits inside it — so on paper box→box was already
|
||||
# blocked twice over (the ingress default is drop as well). It was not: a live
|
||||
# probe found box A's SYN arriving at box B and B answering with a RST.
|
||||
#
|
||||
|
|
@ -41,16 +41,16 @@ fi
|
|||
# sees this traffic.
|
||||
#
|
||||
# The bridge family DOES see it. Its forward hook fires exactly when a frame is
|
||||
# passed from one bridge port to another — which, on claudenet, means box→box
|
||||
# passed from one bridge port to another — which, on boxnet, means box→box
|
||||
# and nothing else: frames addressed to the gateway are delivered locally (the
|
||||
# INPUT hook), and so is anything being routed out to the internet. So dropping
|
||||
# every forwarded frame on this bridge isolates the boxes from one another and
|
||||
# costs them nothing else. DHCP and ARP still work: they are broadcast, and the
|
||||
# local delivery to dnsmasq happens on INPUT, not FORWARD.
|
||||
if ! nft list table bridge claudebox >/dev/null 2>&1; then
|
||||
nft add table bridge claudebox
|
||||
nft "add chain bridge claudebox forward { type filter hook forward priority -200 ; policy accept ; }"
|
||||
nft add rule bridge claudebox forward meta ibrname "$NET" meta obrname "$NET" drop
|
||||
if ! nft list table bridge box >/dev/null 2>&1; then
|
||||
nft add table bridge box
|
||||
nft "add chain bridge box forward { type filter hook forward priority -200 ; policy accept ; }"
|
||||
nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop
|
||||
fi
|
||||
|
||||
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-time host setup: install Incus, create the isolated network + ACL and
|
||||
# the claude-dev profile. Idempotent. Ubuntu 24.04 / Debian 13.
|
||||
# the box-net profile. Idempotent. Ubuntu 24.04 / Debian 13.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
|
@ -56,21 +56,23 @@ PRESEED
|
|||
fi
|
||||
|
||||
# Isolated NAT network. IPv6 off: one less egress path to reason about.
|
||||
incus network show claudenet >/dev/null 2>&1 || incus network create claudenet \
|
||||
ipv4.address=10.87.0.1/24 ipv4.nat=true ipv6.address=none
|
||||
# 10.88, not 10.87: a pre-rename host may still carry claudenet on 10.87 with
|
||||
# legacy boxes attached — two bridges must not claim one subnet.
|
||||
incus network show boxnet >/dev/null 2>&1 || incus network create boxnet \
|
||||
ipv4.address=10.88.0.1/24 ipv4.nat=true ipv6.address=none
|
||||
|
||||
# ACL: default egress allow (internet), explicit drops for private space.
|
||||
# Gateway carve-out first so instance DNS (dnsmasq on 10.87.0.1) survives.
|
||||
if ! incus network acl show claude-isolate >/dev/null 2>&1; then
|
||||
incus network acl create claude-isolate
|
||||
incus network acl rule add claude-isolate egress action=allow destination=10.87.0.1/32
|
||||
incus network acl rule add claude-isolate egress action=drop destination=10.0.0.0/8
|
||||
incus network acl rule add claude-isolate egress action=drop destination=172.16.0.0/12
|
||||
incus network acl rule add claude-isolate egress action=drop destination=192.168.0.0/16
|
||||
incus network acl rule add claude-isolate egress action=drop destination=169.254.0.0/16
|
||||
incus network acl rule add claude-isolate egress action=drop destination=100.64.0.0/10
|
||||
# Gateway carve-out first so instance DNS (dnsmasq on 10.88.0.1) survives.
|
||||
if ! incus network acl show box-isolate >/dev/null 2>&1; then
|
||||
incus network acl create box-isolate
|
||||
incus network acl rule add box-isolate egress action=allow destination=10.88.0.1/32
|
||||
incus network acl rule add box-isolate egress action=drop destination=10.0.0.0/8
|
||||
incus network acl rule add box-isolate egress action=drop destination=172.16.0.0/12
|
||||
incus network acl rule add box-isolate egress action=drop destination=192.168.0.0/16
|
||||
incus network acl rule add box-isolate egress action=drop destination=169.254.0.0/16
|
||||
incus network acl rule add box-isolate egress action=drop destination=100.64.0.0/10
|
||||
fi
|
||||
incus network set claudenet security.acls=claude-isolate \
|
||||
incus network set boxnet security.acls=box-isolate \
|
||||
security.acls.default.egress.action=allow \
|
||||
security.acls.default.ingress.action=drop
|
||||
|
||||
|
|
@ -80,7 +82,7 @@ incus network set claudenet security.acls=claude-isolate \
|
|||
# from inside one box resolved another's name and address. Connection blocked,
|
||||
# reconnaissance wide open. dns.mode=none stops it registering instance records;
|
||||
# forwarding for public names is unaffected (verified live).
|
||||
incus network set claudenet dns.mode=none
|
||||
incus network set boxnet dns.mode=none
|
||||
|
||||
# A box's resolver must not be a function of the host's VPN posture (#33).
|
||||
# The bridge's dnsmasq forwards to whatever sits in the HOST's /etc/resolv.conf
|
||||
|
|
@ -93,11 +95,11 @@ incus network set claudenet dns.mode=none
|
|||
# lever — the bridge has no first-class upstream key. Verified live on the
|
||||
# drill host: pin applied, box resolves, cold mint survives.
|
||||
BOX_DNS="${BOX_DNS:-1.1.1.1 8.8.8.8}"
|
||||
incus network set claudenet raw.dnsmasq \
|
||||
incus network set boxnet raw.dnsmasq \
|
||||
"$(printf 'no-resolv\n'; for s in $BOX_DNS; do printf 'server=%s\n' "$s"; done)"
|
||||
|
||||
# Sibling isolation itself is NOT an ACL rule — an L3 ACL never sees frames
|
||||
# switched between two ports of one bridge. It lives in claudebox-firewall.sh
|
||||
# switched between two ports of one bridge. It lives in box-firewall.sh
|
||||
# as an nftables bridge-family rule. See the comment there; it is the reason
|
||||
# boxes cannot reach each other.
|
||||
|
||||
|
|
@ -107,41 +109,45 @@ incus network set claudenet raw.dnsmasq \
|
|||
|
||||
# --- Firewall coexistence ---------------------------------------------------
|
||||
# Hosts running UFW (INPUT drop) and/or Docker (FORWARD drop) silently eat
|
||||
# claudenet traffic. Punch minimal, ordered holes; the Incus ACL still layers
|
||||
# boxnet traffic. Punch minimal, ordered holes; the Incus ACL still layers
|
||||
# on top. The trailing deny also blocks instance -> host's own (public) IPs,
|
||||
# which the RFC1918-only ACL cannot express. Rules live in
|
||||
# claudebox-firewall.sh; a boot-time systemd unit re-applies the runtime-only
|
||||
# box-firewall.sh; a boot-time systemd unit re-applies the runtime-only
|
||||
# parts (nft table, DOCKER-USER) after every reboot.
|
||||
# The no-UFW path drives nft directly, and a stock Debian 13 cloud image ships
|
||||
# neither nftables nor UFW — install the dependency we are about to use.
|
||||
if ! command -v ufw >/dev/null 2>&1 && ! command -v nft >/dev/null 2>&1; then
|
||||
sudo apt-get install -y nftables
|
||||
fi
|
||||
sudo install -m 755 "$here/host/claudebox-firewall.sh" /usr/local/sbin/claudebox-firewall
|
||||
sudo install -m 644 "$here/host/claudebox-firewall.service" /etc/systemd/system/
|
||||
sudo install -m 755 "$here/host/box-firewall.sh" /usr/local/sbin/box-firewall
|
||||
sudo install -m 644 "$here/host/box-firewall.service" /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable claudebox-firewall.service
|
||||
sudo systemctl enable box-firewall.service
|
||||
# RESTART, not 'enable --now'. The unit is RemainAfterExit, so once it has run
|
||||
# it stays "active" forever — and 'enable --now' does nothing to an active unit.
|
||||
# Re-running setup-host after upgrading claudebox therefore installed the new
|
||||
# Re-running setup-host after upgrading the tool therefore installed the new
|
||||
# rules to /usr/local/sbin and never applied them: the host kept the old
|
||||
# firewall, silently, and the box→box hole stayed open through a release that
|
||||
# claimed to close it. Restart re-runs the script, which is idempotent by design.
|
||||
sudo systemctl restart claudebox-firewall.service
|
||||
sudo systemctl restart box-firewall.service
|
||||
|
||||
# Profile
|
||||
if ! incus profile show claude-dev >/dev/null 2>&1; then
|
||||
incus profile create claude-dev
|
||||
# Profile — box-net, the placement contract: the isolated NIC and the root
|
||||
# disk, nothing a template controls (resources are stamped per-instance from
|
||||
# the template at mint time). A legacy claude-dev profile is left alone:
|
||||
# Incus refuses to delete an in-use profile, and pre-rename boxes reference
|
||||
# it until their last one is gone — teardown-host removes it then.
|
||||
if ! incus profile show box-net >/dev/null 2>&1; then
|
||||
incus profile create box-net
|
||||
fi
|
||||
incus profile edit claude-dev < "$here/profiles/claude-dev.yaml"
|
||||
incus profile edit box-net < "$here/profiles/box-net.yaml"
|
||||
|
||||
# The sibling drop is the one rule whose absence is invisible: everything keeps
|
||||
# working, and boxes can simply reach each other. Assert it landed.
|
||||
if sudo nft list table bridge claudebox >/dev/null 2>&1; then
|
||||
echo "Isolation: box-to-box drop is live (nft bridge table 'claudebox')."
|
||||
if sudo nft list table bridge box >/dev/null 2>&1; then
|
||||
echo "Isolation: box-to-box drop is live (nft bridge table 'box')."
|
||||
else
|
||||
echo "WARNING: the box-to-box drop is NOT active — boxes can reach each other." >&2
|
||||
echo " check: sudo /usr/local/sbin/claudebox-firewall ; sudo nft list table bridge claudebox" >&2
|
||||
echo " check: sudo /usr/local/sbin/box-firewall ; sudo nft list table bridge box" >&2
|
||||
fi
|
||||
|
||||
echo "Host ready. Launch with: claudebox new --name <box>"
|
||||
echo "Host ready. Launch with: box new --name <box>"
|
||||
|
|
|
|||
|
|
@ -1,57 +1,75 @@
|
|||
#!/usr/bin/env bash
|
||||
# Reverse everything host/setup-host.sh created: all claudebox instances, the
|
||||
# claudenet network + ACL, the claude-dev profile, and the firewall rules.
|
||||
# Reverse everything host/setup-host.sh created — and everything its pre-0.4.0
|
||||
# ancestor created, so one teardown cleans a host of any generation: all boxes
|
||||
# (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev
|
||||
# profiles, and both generations of firewall units and nft tables.
|
||||
# Usage: ./host/teardown-host.sh [--purge-incus]
|
||||
# --purge-incus also apt-purge Incus itself (skipped if non-claudebox
|
||||
# --purge-incus also apt-purge Incus itself (skipped if non-box
|
||||
# instances still exist on this host)
|
||||
set -euo pipefail
|
||||
|
||||
purge=false
|
||||
[ "${1:-}" = "--purge-incus" ] && purge=true
|
||||
|
||||
echo "This removes ALL claudebox instances (uncommitted work in them is lost),"
|
||||
echo "the claudenet network/ACL/profile, and the claudebox firewall rules."
|
||||
echo "This removes ALL boxes (uncommitted work in them is lost), the"
|
||||
echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
|
||||
echo "(both current and pre-0.4.0 names)."
|
||||
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
|
||||
read -rp "Continue? [y/N] " a
|
||||
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
|
||||
|
||||
# Instances
|
||||
for i in $(incus list "user.claudebox=1" -f csv -c n || true); do
|
||||
echo "deleting instance $i"
|
||||
incus delete -f "$i"
|
||||
# Instances — both tag generations, one delete at a time (a multi-name
|
||||
# 'incus delete' aborts at the first missing name).
|
||||
for tag in "user.box=1" "user.claudebox=1"; do
|
||||
for i in $(incus list "$tag" -f csv -c n 2>/dev/null || true); do
|
||||
echo "deleting instance $i"
|
||||
incus delete -f "$i"
|
||||
done
|
||||
done
|
||||
|
||||
incus profile delete claude-dev 2>/dev/null || true
|
||||
incus network delete claudenet 2>/dev/null || true
|
||||
incus network acl delete claude-isolate 2>/dev/null || true
|
||||
incus profile delete box-net 2>/dev/null || true
|
||||
incus profile delete claude-dev 2>/dev/null || true # legacy, pre-0.4.0
|
||||
incus network delete boxnet 2>/dev/null || true
|
||||
incus network delete claudenet 2>/dev/null || true # legacy, pre-0.4.0
|
||||
incus network acl delete box-isolate 2>/dev/null || true
|
||||
incus network acl delete claude-isolate 2>/dev/null || true # legacy
|
||||
|
||||
# Boot-persistence unit
|
||||
# Boot-persistence units — both generations
|
||||
sudo systemctl disable --now box-firewall.service 2>/dev/null || true
|
||||
sudo systemctl disable --now claudebox-firewall.service 2>/dev/null || true
|
||||
sudo rm -f /etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall
|
||||
sudo rm -f /etc/systemd/system/box-firewall.service /usr/local/sbin/box-firewall \
|
||||
/etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Firewall crumbs — UFW rules mentioning claudenet (numbers shift after each
|
||||
# delete, so re-scan and remove the first match until none remain)
|
||||
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
|
||||
# each delete, so re-scan and remove the first match until none remain)
|
||||
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
while sudo ufw status numbered | grep -q "on claudenet"; do
|
||||
n="$(sudo ufw status numbered | grep -m1 "on claudenet" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||
sudo ufw --force delete "$n"
|
||||
for net in boxnet claudenet; do
|
||||
while sudo ufw status numbered | grep -q "on $net"; do
|
||||
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||
sudo ufw --force delete "$n"
|
||||
done
|
||||
done
|
||||
fi
|
||||
sudo nft delete table inet claudebox 2>/dev/null || true
|
||||
sudo nft delete table inet box 2>/dev/null || true
|
||||
sudo nft delete table bridge box 2>/dev/null || true
|
||||
sudo nft delete table inet claudebox 2>/dev/null || true # legacy
|
||||
sudo nft delete table bridge claudebox 2>/dev/null || true # legacy
|
||||
if command -v docker >/dev/null; then
|
||||
sudo iptables -D DOCKER-USER -i claudenet -j ACCEPT 2>/dev/null || true
|
||||
sudo iptables -D DOCKER-USER -o claudenet -j ACCEPT 2>/dev/null || true
|
||||
for net in boxnet claudenet; do
|
||||
sudo iptables -D DOCKER-USER -i "$net" -j ACCEPT 2>/dev/null || true
|
||||
sudo iptables -D DOCKER-USER -o "$net" -j ACCEPT 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
if $purge; then
|
||||
remaining="$(incus list -f csv 2>/dev/null | wc -l)"
|
||||
if [ "$remaining" -gt 0 ]; then
|
||||
echo "NOTE: $remaining non-claudebox instance(s) remain on this host — leaving Incus installed."
|
||||
echo "NOTE: $remaining non-box instance(s) remain on this host — leaving Incus installed."
|
||||
else
|
||||
sudo apt-get purge -y incus
|
||||
sudo apt-get autoremove -y
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Teardown complete. (Your ~/.local/bin/claudebox symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)"
|
||||
echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)"
|
||||
|
|
|
|||
25
install.sh
25
install.sh
|
|
@ -4,7 +4,7 @@ set -euo pipefail
|
|||
# claudebox installer — intended for: curl -fsSL .../install.sh | bash
|
||||
#
|
||||
# Downloads the claudebox repo tarball, installs the whole tree under
|
||||
# $DEST, and puts a `claudebox` symlink on PATH via $BINDIR.
|
||||
# $DEST, and puts a `box` symlink on PATH via $BINDIR.
|
||||
|
||||
REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
|
||||
REF="${CLAUDEBOX_REF:-main}"
|
||||
|
|
@ -26,7 +26,7 @@ trap cleanup EXIT
|
|||
|
||||
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
|
||||
|
||||
log "installing claudebox ($REPO@$REF)"
|
||||
log "installing box (the claudebox repo) ($REPO@$REF)"
|
||||
log "downloading $URL"
|
||||
curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \
|
||||
|| die "failed to download $URL"
|
||||
|
|
@ -38,7 +38,7 @@ tar -xzf "$TMPDIR/claudebox.tar.gz" -C "$TMPDIR" \
|
|||
# GitHub archives extract to a single top-level dir like claudebox-<ref>/
|
||||
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)"
|
||||
[ -n "$EXTRACTED" ] || die "could not find extracted claudebox-* directory in archive"
|
||||
[ -f "$EXTRACTED/bin/claudebox" ] || die "archive does not contain bin/claudebox — is $REPO@$REF correct?"
|
||||
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
|
||||
|
||||
# --- atomically replace $DEST ---------------------------------------------
|
||||
log "installing into $DEST"
|
||||
|
|
@ -46,12 +46,19 @@ rm -rf "$DEST"
|
|||
mkdir -p "$(dirname "$DEST")"
|
||||
mv "$EXTRACTED" "$DEST"
|
||||
|
||||
chmod +x "$DEST/bin/claudebox"
|
||||
chmod +x "$DEST/bin/box"
|
||||
|
||||
# --- put claudebox on PATH -------------------------------------------------
|
||||
# --- put box on PATH -------------------------------------------------------
|
||||
mkdir -p "$BINDIR"
|
||||
ln -sf "$DEST/bin/claudebox" "$BINDIR/claudebox"
|
||||
log "linked $BINDIR/claudebox -> $DEST/bin/claudebox"
|
||||
ln -sf "$DEST/bin/box" "$BINDIR/box"
|
||||
log "linked $BINDIR/box -> $DEST/bin/box"
|
||||
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
|
||||
# cannot dangle at the old bin path forever. Old BOXES keep working — the CLI
|
||||
# honors their legacy tag — it is only the old command name that retires.
|
||||
if [ -L "$BINDIR/claudebox" ]; then
|
||||
rm -f "$BINDIR/claudebox"
|
||||
log "removed the old claudebox symlink — the command is 'box' now (your existing boxes keep working)"
|
||||
fi
|
||||
|
||||
# --- PATH check ------------------------------------------------------------
|
||||
case ":$PATH:" in
|
||||
|
|
@ -65,8 +72,8 @@ esac
|
|||
|
||||
# --- environment check -----------------------------------------------------
|
||||
if ! command -v incus >/dev/null 2>&1; then
|
||||
warn "incus was not found. claudebox needs Incus on the host."
|
||||
warn "incus was not found. box needs Incus on the host."
|
||||
warn " run the one-time host setup: $DEST/host/setup-host.sh"
|
||||
fi
|
||||
|
||||
log "done — try: claudebox new --name test"
|
||||
log "done — try: box new --name test"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
name: claude-dev
|
||||
description: Trust-less claudebox (resources + isolated NIC)
|
||||
config:
|
||||
limits.cpu: "4"
|
||||
limits.memory: 8GiB
|
||||
# The placement contract. Every box, whatever its template, launches with
|
||||
# exactly this profile: the isolated NIC and the root disk — and NOTHING a
|
||||
# template controls. Resources (limits.*) are per-instance, stamped from the
|
||||
# template's box.env at mint time; they do not belong here, because a profile
|
||||
# a template could point away from is a network a template could escape.
|
||||
name: box-net
|
||||
description: The box placement contract — isolated NIC + root disk, nothing else
|
||||
config: {}
|
||||
devices:
|
||||
eth0:
|
||||
type: nic
|
||||
network: claudenet
|
||||
network: boxnet
|
||||
name: eth0
|
||||
# Boxes must not reach each other. This is the mechanism that actually does
|
||||
# it: the kernel bridge's port-isolation flag, which stops two isolated
|
||||
|
|
@ -15,7 +18,7 @@ devices:
|
|||
# It is not an ACL rule, and it cannot be. Incus ACLs are L3/L4, and two
|
||||
# boxes on one bridge are on the same L2 segment — their frames are switched
|
||||
# between ports and never traverse the netfilter path an ACL lives on. That
|
||||
# is why the ACL's drop on 10.0.0.0/8 (which contains claudenet) and its
|
||||
# is why the ACL's drop on 10.0.0.0/8 (which contains boxnet) and its
|
||||
# default ingress drop BOTH looked airtight while box→box was wide open: a
|
||||
# live probe found box A's SYN arriving at box B and B answering with a RST.
|
||||
security.port_isolation: "true"
|
||||
9
templates/blank/box.env
Normal file
9
templates/blank/box.env
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# The blank template — a box with nobody home, not a box with the safety off.
|
||||
# Same image, same isolation, same entry path as every other template; the
|
||||
# only thing missing is the tooling. Start here to build a new template.
|
||||
BOX_DESCRIPTION="Bare Debian 13 — same isolation, no tooling"
|
||||
BOX_IMAGE="images:debian/13/cloud"
|
||||
BOX_USER="dev"
|
||||
BOX_CPU="2"
|
||||
BOX_MEMORY="2GiB"
|
||||
BOX_DISK="20GiB"
|
||||
9
templates/blank/user-data.yaml
Normal file
9
templates/blank/user-data.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#cloud-config
|
||||
# The whole template: create the user and stop. BOX_USER in box.env must
|
||||
# match the name here — the file is passed to Incus verbatim.
|
||||
users:
|
||||
- name: dev
|
||||
shell: /bin/bash
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
lock_passwd: true
|
||||
package_update: false
|
||||
12
templates/claude/box.env
Normal file
12
templates/claude/box.env
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# The claude template — Debian 13 + Claude Code, creds-free.
|
||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||
# key for a network or a security flag, on purpose — the shared box-net
|
||||
# profile is the placement contract and no template can weaken it.
|
||||
# BOX_USER must match the user user-data.yaml creates (the cloud-init file is
|
||||
# passed to Incus verbatim, so the duplication is deliberate and by hand).
|
||||
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free (the original claudebox)"
|
||||
BOX_IMAGE="images:debian/13/cloud"
|
||||
BOX_USER="claude"
|
||||
BOX_CPU="4"
|
||||
BOX_MEMORY="8GiB"
|
||||
BOX_DISK="60GiB"
|
||||
|
|
@ -13,7 +13,7 @@ write_files:
|
|||
# You are running inside a claudebox
|
||||
|
||||
A claudebox is a trust-less, network-isolated, ephemeral VM created by the
|
||||
`claudebox` CLI. Keep this context in mind:
|
||||
`box` CLI (template: claude). Keep this context in mind:
|
||||
|
||||
- **Creds-free by default.** The box starts with no Claude and no git
|
||||
credentials. If you need to authenticate Claude, the operator runs `/login`
|
||||
|
|
@ -23,7 +23,7 @@ write_files:
|
|||
- **Isolated.** The box reaches the public internet but nothing on the host or
|
||||
local network. There is no inbound path.
|
||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
||||
removed; the operator persists work via git push and via `claudebox snapshot`.
|
||||
removed; the operator persists work via git push and via `box snapshot`.
|
||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
||||
`.claudebox/` folder, read it as your setup runbook — how to install
|
||||
dependencies, start services, template environment files, seed data, and
|
||||
Loading…
Reference in a new issue