feat!: rename the host stack too, default to blank, drill the templates, add wipe
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.
This commit is contained in:
parent
c11f3d7552
commit
5defd40bca
9 changed files with 374 additions and 157 deletions
29
README.md
29
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`
|
||||
|
|
|
|||
42
bin/box
42
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 <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: claude), 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^"
|
||||
|
|
@ -124,7 +124,7 @@ EOF
|
|||
|
||||
OPTIONS
|
||||
--name <box> Name for the new box (new)
|
||||
--template <t> Template to mint from (default: claude) (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)
|
||||
|
|
@ -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 <box> Required. The box's name.
|
||||
--template <t> 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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 <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
|
||||
|
||||
# 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:-<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.
|
||||
|
|
@ -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 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"
|
||||
|
|
|
|||
103
drill/drill.sh
103
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 <<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
|
||||
|
|
@ -161,7 +161,7 @@ EOF
|
|||
|| { 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.
|
||||
|
|
@ -229,11 +229,11 @@ 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
|
||||
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:-<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 "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 ]
|
||||
|
|
|
|||
142
drill/wipe.sh
Normal file
142
drill/wipe.sh
Normal file
|
|
@ -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 <<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
|
||||
· every cached Incus image (the next mint re-downloads)
|
||||
$( [ "$PURGE_STORAGE" = 1 ] && echo " · the 'default' storage pool (--purge-storage)" )
|
||||
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: 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."
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 <box>"
|
||||
|
|
|
|||
|
|
@ -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.)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue