From 5defd40bca0821b7c5919bb9890ae68f6a30c032 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 14:36:39 +0000 Subject: [PATCH] feat!: rename the host stack too, default to blank, drill the templates, add wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the rename, per operator direction — the divergence is reversed and the cut is complete: - Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry claudenet on 10.87, two bridges must not claim one subnet), box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh, service}. teardown-host now strips BOTH name generations, so one script uninstalls a host of any age. - Default template is blank: 'box new --name x' mints bare Debian; the claude box is '--template claude'. The login hint follows the EFFECTIVE template read off the instance, so clones of claude boxes still get it and blank boxes are not told to run a binary they lack. - The drill validates templates: listing, unknown-template refusal, the allowlist rejecting BOX_NETWORK by name, and a full blank mint — default resolves to blank, metadata stamped, box-net placement, exec lands in 'dev', no claude binary, and isolation parity (egress + pinned DNS) on the same contract as every template. - drill/wipe.sh: scorched earth for drill hosts. Both tag generations, every drill-named instance, networks/ACLs/profiles/firewall of both generations, cached images, and (--purge-storage) the default pool. Ends by asserting the ABSENCE of every artifact rather than trusting the removals' exit codes. --- README.md | 29 ++-- bin/box | 42 +++--- drill/doctor.sh | 62 ++++---- drill/drill.sh | 103 +++++++++---- drill/wipe.sh | 142 ++++++++++++++++++ ...-firewall.service => box-firewall.service} | 4 +- ...{claudebox-firewall.sh => box-firewall.sh} | 32 ++-- host/setup-host.sh | 52 ++++--- host/teardown-host.sh | 65 +++++--- 9 files changed, 374 insertions(+), 157 deletions(-) create mode 100644 drill/wipe.sh rename host/{claudebox-firewall.service => box-firewall.service} (68%) rename host/{claudebox-firewall.sh => box-firewall.sh} (68%) diff --git a/README.md b/README.md index 9bd10dd..821d798 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,13 @@ 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 renamed the CLI** from `claudebox` to `box` — a clean cut, no shim. -> Existing boxes minted by any earlier version keep working under every verb -> (their legacy tag is honored forever); only the old command name retired. +> **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 @@ -43,19 +47,19 @@ retires the old `claudebox` symlink. (No `git clone` needed.) ~/.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/ +`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 -box new --name work # mint a fresh, creds-free claude box (~10 min cold) +box new --name work --template claude # a creds-free Claude box (~10 min cold) box shell work # enter as the template's user ``` @@ -77,8 +81,9 @@ 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 --template blank # bare Debian: same isolation, no tooling +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 — @@ -165,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 @@ -199,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` diff --git a/bin/box b/bin/box index 2f1ecdf..24c24bb 100755 --- a/bin/box +++ b/bin/box @@ -37,7 +37,7 @@ version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root) # doesn't enforce a box invariant, it is incus's job, not ours — that is # what `box incus` is for. CMDS=( - "new^--name [--template ] [--from [/]] [--vm|--container]^^Mint a box from a template (default: claude), or --from an existing box/snapshot^fn:cmd_new^" + "new^--name [--template ] [--from [/]] [--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^ [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^" @@ -124,7 +124,7 @@ EOF OPTIONS --name Name for the new box (new) - --template Template to mint from (default: claude) (new) + --template Template to mint from (default: blank) (new) --from [/] Clone from box , or from its snapshot (new) --vm Force VM mode: the trust-less target (new) --container Force container mode: weaker isolation, (new) @@ -138,16 +138,16 @@ OPTIONS 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 - box new --name work + # mint a claude box and log in inside it — the tool never handles your token + box new --name work --template claude box shell work # then: run 'claude', then /login # log in once, reuse forever: checkpoint the authed box, clone from it box snapshot work authed box new --name feature --from work/authed - # a box with nobody home — same isolation, no Claude - box new --name scratch --template blank + # the default: a blank box — same isolation, nobody home + box new --name scratch box templates # what have I got, and what can I clone? @@ -187,10 +187,11 @@ help_cmd() { case "$1" in new) cat <<'EOF' Mint a box. Without --from, launches a fresh box from a template (default: -claude — Debian 13 with Claude Code installed and NO credentials, ~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. +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 Required. The box's name. --template Template to mint from; 'box templates' lists them. @@ -206,8 +207,8 @@ 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 work - box new --name scratch --template blank + box new --name scratch # blank, the default + box new --name work --template claude box new --name feature --from work/authed EOF ;; @@ -331,14 +332,14 @@ 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) box doctor @@ -570,7 +571,7 @@ cmd_new() { reset_identity "$instance" echo "box: cloned $srcref — isolation and auth state carry over from the source." else - local t="${template:-claude}" m extra=() + local t="${template:-blank}" m extra=() load_template "$t" m="$(pick_mode)" # shellcheck disable=SC2054 # "root,size=..." is a single incus argument @@ -609,10 +610,15 @@ cmd_new() { die "cloud-init failed — the box is incomplete, so refusing to hand it over" fi fi - if [ -z "$from" ] && [ "${template:-claude}" != claude ]; then - echo "box: ready — 'box shell $name'." - else + # 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 } diff --git a/drill/doctor.sh b/drill/doctor.sh index f71d61b..7a1974d 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -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:-} — 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,37 +60,37 @@ 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 ; 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 # box-net is the placement contract since the 0.4.0 rename; claude-dev is its @@ -127,17 +127,17 @@ else 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 @@ -150,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 @@ -182,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:-}" -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. @@ -200,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" @@ -226,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 | 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; then routing=1; ok "reaches 1.1.1.1 by address — egress routing is fine" diff --git a/drill/drill.sh b/drill/drill.sh index 376d517..799d438 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -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 @@ -143,9 +143,9 @@ if [ "${IN_GROUP:-0}" != 1 ]; then cat </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 +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 @@ -248,10 +248,10 @@ 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 @@ -370,10 +370,53 @@ else && 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 box new --name drill >/tmp/new.log 2>&1; then - ok "box new --name drill ($((SECONDS - t0))s)" +if box new --name tpl >/tmp/tpl.log 2>&1; 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:-}' — 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 | tr -d '[:space:]')" + [ "$u" = dev ] && ok "exec lands in the template's user ($u) — nothing hardcodes claude" \ + || no "exec landed in '${u:-}', expected dev" + timeout -k 5 30 box exec tpl -- sh -lc 'command -v claude' /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 "blank mint FAILED — tail: $(tail -3 /tmp/tpl.log | tr '\n' ' ')" +fi + +printf '\n minting a claude box (cold, ~10 min)…\n' +t0=$SECONDS +if box new --name drill --template claude >/tmp/new.log 2>&1; then + ok "box new --name drill --template claude ($((SECONDS - t0))s)" else no "box new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')" echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1 @@ -482,10 +525,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)" @@ -510,8 +553,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 → @@ -535,8 +578,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 @@ -552,13 +595,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 @@ -577,7 +620,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 @@ -586,7 +629,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: # @@ -621,11 +664,11 @@ if [ "$KEEP" = 1 ]; then 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 box 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="$(box 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 @@ -643,7 +686,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 ] diff --git a/drill/wipe.sh b/drill/wipe.sh new file mode 100644 index 0000000..d16e6d5 --- /dev/null +++ b/drill/wipe.sh @@ -0,0 +1,142 @@ +#!/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 +# bash drill/wipe.sh --yes # no prompt +# bash drill/wipe.sh --purge-storage # also delete cached images AND the +# # 'default' storage pool, so setup-host +# # exercises its pool 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 <&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: the pool's other tenants ------------------------------- + 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 + + # --- the pool itself (opt-in): lets setup-host's bootstrap run for real ---- + if [ "$PURGE_STORAGE" = 1 ]; then + 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." diff --git a/host/claudebox-firewall.service b/host/box-firewall.service similarity index 68% rename from host/claudebox-firewall.service rename to host/box-firewall.service index bac03e0..9b78370 100644 --- a/host/claudebox-firewall.service +++ b/host/box-firewall.service @@ -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 diff --git a/host/claudebox-firewall.sh b/host/box-firewall.sh similarity index 68% rename from host/claudebox-firewall.sh rename to host/box-firewall.sh index 0e08869..9bb2ca7 100644 --- a/host/claudebox-firewall.sh +++ b/host/box-firewall.sh @@ -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. diff --git a/host/setup-host.sh b/host/setup-host.sh index 8a283bb..3e28d43 100755 --- a/host/setup-host.sh +++ b/host/setup-host.sh @@ -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,27 +109,27 @@ 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 — box-net, the placement contract: the isolated NIC and the root # disk, nothing a template controls (resources are stamped per-instance from @@ -141,11 +143,11 @@ 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: box new --name " diff --git a/host/teardown-host.sh b/host/teardown-host.sh index 152fcdb..091b94e 100755 --- a/host/teardown-host.sh +++ b/host/teardown-host.sh @@ -1,58 +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 box-net 2>/dev/null || true -incus profile delete claude-dev 2>/dev/null || true # legacy, pre-rename -incus network delete claudenet 2>/dev/null || true -incus network acl delete claude-isolate 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.)"