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
|
host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the
|
||||||
design rationale.
|
design rationale.
|
||||||
|
|
||||||
> **0.4.0 renamed the CLI** from `claudebox` to `box` — a clean cut, no shim.
|
> **0.4.0 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host
|
||||||
> Existing boxes minted by any earlier version keep working under every verb
|
> stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and the
|
||||||
> (their legacy tag is honored forever); only the old command name retired.
|
> 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
|
## 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)
|
~/.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 —
|
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
|
link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
|
||||||
reach each other), and firewall rules blocking instance → host. All rules
|
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):
|
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
||||||
`sudo apt-get install -y dnsmasq-base`.
|
`sudo apt-get install -y dnsmasq-base`.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```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
|
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).
|
verbatim).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
box templates # list what this install can mint
|
box templates # list what this install can mint
|
||||||
box new --name scratch --template blank # bare Debian: same isolation, no tooling
|
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 —
|
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
|
host, not your LAN, not another box, not even another box's *name*. What
|
||||||
enforces it, layer by layer:
|
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
|
IPv4-only, so IPv6 would be an uncovered path — off is part of the
|
||||||
contract, not a default.
|
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.
|
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*,
|
- **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
|
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
|
```sh
|
||||||
bash drill/doctor.sh # read-only: is this host healthy and the stack live?
|
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/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`
|
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
|
# doesn't enforce a box invariant, it is incus's job, not ours — that is
|
||||||
# what `box incus` is for.
|
# what `box incus` is for.
|
||||||
CMDS=(
|
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^"
|
"templates^^^List the templates this install can mint^fn:cmd_templates^"
|
||||||
"list^[--json]^^List your boxes^fn:cmd_list^"
|
"list^[--json]^^List your boxes^fn:cmd_list^"
|
||||||
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
|
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
|
||||||
|
|
@ -124,7 +124,7 @@ EOF
|
||||||
|
|
||||||
OPTIONS
|
OPTIONS
|
||||||
--name <box> Name for the new box (new)
|
--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)
|
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
|
||||||
--vm Force VM mode: the trust-less target (new)
|
--vm Force VM mode: the trust-less target (new)
|
||||||
--container Force container mode: weaker isolation, (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'.
|
Options come after the command: 'box list --json', not 'box --json list'.
|
||||||
|
|
||||||
EXAMPLES
|
EXAMPLES
|
||||||
# mint a box and log in inside it — the tool never handles your token
|
# mint a claude box and log in inside it — the tool never handles your token
|
||||||
box new --name work
|
box new --name work --template claude
|
||||||
box shell work # then: run 'claude', then /login
|
box shell work # then: run 'claude', then /login
|
||||||
|
|
||||||
# log in once, reuse forever: checkpoint the authed box, clone from it
|
# log in once, reuse forever: checkpoint the authed box, clone from it
|
||||||
box snapshot work authed
|
box snapshot work authed
|
||||||
box new --name feature --from work/authed
|
box new --name feature --from work/authed
|
||||||
|
|
||||||
# a box with nobody home — same isolation, no Claude
|
# the default: a blank box — same isolation, nobody home
|
||||||
box new --name scratch --template blank
|
box new --name scratch
|
||||||
box templates
|
box templates
|
||||||
|
|
||||||
# what have I got, and what can I clone?
|
# what have I got, and what can I clone?
|
||||||
|
|
@ -187,10 +187,11 @@ help_cmd() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
new) cat <<'EOF'
|
new) cat <<'EOF'
|
||||||
Mint a box. Without --from, launches a fresh box from a template (default:
|
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
|
blank — bare Debian 13, nobody home; --template claude gets Claude Code
|
||||||
cold). With --from, clones an existing box or one of its snapshots — login
|
installed, creds-free, ~10 min cold). With --from, clones an existing box or
|
||||||
state, git creds and clones carry over, isolation is preserved, and the
|
one of its snapshots — login state, git creds and clones carry over,
|
||||||
clone knows its template's user without being told.
|
isolation is preserved, and the clone knows its template's user without
|
||||||
|
being told.
|
||||||
|
|
||||||
--name <box> Required. The box's name.
|
--name <box> Required. The box's name.
|
||||||
--template <t> Template to mint from; 'box templates' lists them.
|
--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
|
environment variables override them at mint time (a small host shrinks a box
|
||||||
without editing a template it doesn't own).
|
without editing a template it doesn't own).
|
||||||
|
|
||||||
box new --name work
|
box new --name scratch # blank, the default
|
||||||
box new --name scratch --template blank
|
box new --name work --template claude
|
||||||
box new --name feature --from work/authed
|
box new --name feature --from work/authed
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
|
|
@ -331,14 +332,14 @@ EOF
|
||||||
;;
|
;;
|
||||||
doctor) cat <<'EOF'
|
doctor) cat <<'EOF'
|
||||||
Answer "is this host fit to mint boxes?" from ground truth, not config claims:
|
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
|
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
|
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
|
check exists because its fault has happened — most kill a cold mint with a
|
||||||
cloud-init error that names none of them.
|
cloud-init error that names none of them.
|
||||||
|
|
||||||
--fix also revert what a drill run may have left behind
|
--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)
|
(setup-host.sh now pins by default; this is the quick test)
|
||||||
|
|
||||||
box doctor
|
box doctor
|
||||||
|
|
@ -570,7 +571,7 @@ cmd_new() {
|
||||||
reset_identity "$instance"
|
reset_identity "$instance"
|
||||||
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
||||||
else
|
else
|
||||||
local t="${template:-claude}" m extra=()
|
local t="${template:-blank}" m extra=()
|
||||||
load_template "$t"
|
load_template "$t"
|
||||||
m="$(pick_mode)"
|
m="$(pick_mode)"
|
||||||
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
|
# 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"
|
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [ -z "$from" ] && [ "${template:-claude}" != claude ]; then
|
# The login hint belongs to the claude template — read the EFFECTIVE
|
||||||
echo "box: ready — 'box shell $name'."
|
# template off the instance, so a clone of a claude box gets it too and a
|
||||||
else
|
# 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."
|
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
|
||||||
|
else
|
||||||
|
echo "box: ready — 'box shell $name'."
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,19 +40,19 @@ timeout 10 incus list >/dev/null 2>&1 || {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
head_ "Network — claudenet"
|
head_ "Network — boxnet"
|
||||||
if incus network show claudenet >/dev/null 2>&1; then
|
if incus network show boxnet >/dev/null 2>&1; then
|
||||||
# dns.mode=none is SHIPPED — it is what stops a box enumerating its siblings
|
# 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.
|
# 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
|
if [ "$dns" = none ]; then
|
||||||
ok "dns.mode = none — a box cannot enumerate its siblings by name"
|
ok "dns.mode = none — a box cannot enumerate its siblings by name"
|
||||||
else
|
else
|
||||||
no "dns.mode = ${dns:-<unset>} — a box can RESOLVE its siblings' names and addresses"
|
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"
|
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
|
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
|
# 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
|
# 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,
|
# 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
|
# 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
|
# 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.
|
# 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
|
if pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1; then
|
||||||
ok "a dnsmasq is serving claudenet (DHCP + DNS)"
|
ok "a dnsmasq is serving boxnet (DHCP + DNS)"
|
||||||
else
|
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 "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 "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)"
|
inf " (if it does not come back: teardown-host.sh, then re-run the drill)"
|
||||||
[ "$FIX" = 1 ] && {
|
[ "$FIX" = 1 ] && {
|
||||||
inf "restarting incus to respawn it…"
|
inf "restarting incus to respawn it…"
|
||||||
sudo systemctl restart incus && sleep 5
|
sudo systemctl restart incus && sleep 5
|
||||||
pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1 \
|
pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1 \
|
||||||
&& inf "reverted: dnsmasq is serving claudenet again" \
|
&& inf "reverted: dnsmasq is serving boxnet again" \
|
||||||
|| inf "STILL missing — run teardown-host.sh and let the drill rebuild the network"
|
|| inf "STILL missing — run teardown-host.sh and let the drill rebuild the network"
|
||||||
}
|
}
|
||||||
fi
|
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)" \
|
[ "$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"
|
|| no "ipv6.address = $ipv6 — IPv6 is on and NOT covered by any ACL rule"
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
head_ "Firewall — the box-to-box drop"
|
head_ "Firewall — the box-to-box drop"
|
||||||
if sudo nft list table bridge claudebox >/dev/null 2>&1; then
|
if sudo nft list table bridge box >/dev/null 2>&1; then
|
||||||
ok "nft bridge table 'claudebox' is present — boxes cannot reach each other"
|
ok "nft bridge table 'box' is present — boxes cannot reach each other"
|
||||||
else
|
else
|
||||||
no "the box-to-box drop is MISSING — boxes can reach each other"
|
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 "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 "the drop is an nft BRIDGE-family rule, and without it siblings are wide open."
|
||||||
inf "fix: sudo /usr/local/sbin/claudebox-firewall"
|
inf "fix: sudo /usr/local/sbin/box-firewall"
|
||||||
inf " (or: sudo systemctl restart claudebox-firewall.service)"
|
inf " (or: sudo systemctl restart box-firewall.service)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# box-net is the placement contract since the 0.4.0 rename; claude-dev is its
|
# 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)"
|
inf "box-net does not exist (a fresh host — setup-host.sh will create it)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
head_ "ACL — claude-isolate"
|
head_ "ACL — box-isolate"
|
||||||
if incus network acl show claude-isolate >/dev/null 2>&1; then
|
if incus network acl show box-isolate >/dev/null 2>&1; then
|
||||||
n="$(incus network acl show claude-isolate | grep -c 'action:' || true)"
|
n="$(incus network acl show box-isolate | grep -c 'action:' || true)"
|
||||||
inf "$n rules"
|
inf "$n rules"
|
||||||
incus network acl show claude-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
|
incus network acl show box-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
|
||||||
if incus network acl show claude-isolate | grep -q '@internal'; then
|
if incus network acl show box-isolate | grep -q '@internal'; then
|
||||||
no "an @internal rule survived phase D"
|
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
|
fi
|
||||||
else
|
else
|
||||||
inf "claude-isolate does not exist (a fresh host)"
|
inf "box-isolate does not exist (a fresh host)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Config is a claim; the bridge port is the fact. Incus can accept
|
# 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; }
|
sudo "$c" -V >/dev/null 2>&1 && { BRIDGE="$c"; break; }
|
||||||
done
|
done
|
||||||
if [ -n "$BRIDGE" ]; then
|
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
|
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
|
else
|
||||||
printf '%s\n' "$ports" | sed 's/^/ /'
|
printf '%s\n' "$ports" | sed 's/^/ /'
|
||||||
if printf '%s' "$ports" | grep -q 'isolated on'; then
|
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"
|
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' ' ')"
|
hostns="$(grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | tr '\n' ' ')"
|
||||||
inf "/etc/resolv.conf: ${hostns:-<none>}"
|
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
|
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)"
|
inf "boxes do NOT inherit the host's resolver — good (issue #33)"
|
||||||
else
|
else
|
||||||
# 100.64.0.0/10 is CGNAT — which is exactly Tailscale's range.
|
# 100.64.0.0/10 is CGNAT — which is exactly Tailscale's range.
|
||||||
|
|
@ -200,8 +200,8 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$PIN" = 1 ]; then
|
if [ "$PIN" = 1 ]; then
|
||||||
head_ "Pinning claudenet's resolver (issue #33)"
|
head_ "Pinning boxnet'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
|
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"
|
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 "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"
|
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' ' ')"
|
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
|
# 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
|
# the gateway: box-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.
|
# 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.
|
# 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
|
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"
|
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"
|
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)
|
# · 'incus list' name filters are NOT regexes ("^b$" silently matches nothing)
|
||||||
# · its CSV quotes a multi-address box across lines
|
# · its CSV quotes a multi-address box across lines
|
||||||
# · and the interface is NOT called eth0. The PROFILE names the device eth0,
|
# · 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
|
# but inside a VM guest predictable naming renames it enp5s0. Six runs of
|
||||||
# A3 "not probed" were this, not the network.
|
# 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,
|
# 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.
|
# and the NIC's name is the guest's business, not ours.
|
||||||
claudenet_ip() {
|
boxnet_ip() {
|
||||||
local b="$1" ip _i
|
local b="$1" ip _i
|
||||||
for _i in $(seq 1 15); do
|
for _i in $(seq 1 15); do
|
||||||
ip="$(in_box "$b" ip -4 -o addr show scope global \
|
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; }
|
[ -n "$ip" ] && { printf '%s\n' "$ip"; return 0; }
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
@ -143,9 +143,9 @@ if [ "${IN_GROUP:-0}" != 1 ]; then
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
This will, ON THIS HOST ($(hostname)):
|
This will, ON THIS HOST ($(hostname)):
|
||||||
· install Incus and a systemd unit
|
· 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)
|
· 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
|
· mutate the network and profile mid-run to rehearse the #16 hardening
|
||||||
Only do this on a machine you can format.
|
Only do this on a machine you can format.
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -161,7 +161,7 @@ EOF
|
||||||
|| { echo "install failed"; exit 1; }
|
|| { echo "install failed"; exit 1; }
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
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
|
# 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:
|
# (a stock Debian 13 cloud image ships neither). This guard is a tripwire:
|
||||||
# if it fires, that fix regressed.
|
# if it fires, that fix regressed.
|
||||||
|
|
@ -229,11 +229,11 @@ fi
|
||||||
inf "clearing anything a previous run left behind…"
|
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,
|
# 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.
|
# 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
|
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1
|
||||||
done
|
done
|
||||||
if incus network show claudenet >/dev/null 2>&1; then
|
if incus network show boxnet >/dev/null 2>&1; then
|
||||||
timeout -k 5 30 incus network unset claudenet dns.mode >/dev/null 2>&1
|
timeout -k 5 30 incus network unset boxnet dns.mode >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
for p in box-net claude-dev; do
|
for p in box-net claude-dev; do
|
||||||
if incus profile show "$p" >/dev/null 2>&1; then
|
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
|
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 "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 " 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 " incus list" >&2
|
||||||
echo " · the firewall unit not completing" >&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 " · the incus daemon wedged by an earlier aborted run" >&2
|
||||||
echo " systemctl status incus --no-pager; journalctl -u incus -n 30 --no-pager" >&2
|
echo " systemctl status incus --no-pager; journalctl -u incus -n 30 --no-pager" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -370,10 +370,53 @@ else
|
||||||
&& ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong"
|
&& ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong"
|
||||||
fi
|
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
|
t0=$SECONDS
|
||||||
if box new --name drill >/tmp/new.log 2>&1; then
|
if box new --name tpl >/tmp/tpl.log 2>&1; then
|
||||||
ok "box new --name drill ($((SECONDS - t0))s)"
|
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
|
else
|
||||||
no "box new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')"
|
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
|
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
|
# 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.
|
# 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.)
|
# (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
|
case "$hv" in
|
||||||
reachable|refused)
|
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)" ;;
|
aud "A2 box→host: FAIL — $hv (the packet reached the host's stack)" ;;
|
||||||
dropped)
|
dropped)
|
||||||
ok "box → host is blocked (no path to the machine's sockets)"
|
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
|
# 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
|
# the listener was what kept wedging the run. Ping corroborates: if the two
|
||||||
# disagree, say so rather than pick one.
|
# disagree, say so rather than pick one.
|
||||||
PEER_IP="$(claudenet_ip peer)"
|
PEER_IP="$(boxnet_ip peer)"
|
||||||
ARCH_IP_PRE="$(claudenet_ip archive)"
|
ARCH_IP_PRE="$(boxnet_ip archive)"
|
||||||
if [ -n "$PEER_IP" ] && [ "$PEER_IP" = "$ARCH_IP_PRE" ]; then
|
if [ -n "$PEER_IP" ] && [ "$PEER_IP" = "$ARCH_IP_PRE" ]; then
|
||||||
# Guard, because this actually happened: a clone inherited its source's
|
# Guard, because this actually happened: a clone inherited its source's
|
||||||
# machine-id, hence its DHCP lease, hence its ADDRESS. Probing "archive →
|
# 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)"
|
aud "A3 sibling: BLOCKED — tcp dropped + no icmp reply (security.port_isolation)"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
no "could not read peer's claudenet address — the sibling probe never ran"
|
no "could not read peer's boxnet address — the sibling probe never ran"
|
||||||
aud "A3 sibling: NOT PROBED (no 10.87.x address on peer)"
|
aud "A3 sibling: NOT PROBED (no 10.88.x address on peer)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host
|
# C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host
|
||||||
|
|
@ -552,13 +595,13 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# C6 — IPv6 off (#15 A6): every ACL rule is IPv4-only; off is the only cover.
|
# 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 ] \
|
[ "$(incus network get boxnet 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"; } \
|
&& { ok "boxnet 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"; }
|
|| { 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
|
# C7 — inbound, host → box (#15 A7): the ACL's default ingress drop. Same
|
||||||
# listener-free logic, run from the host this time.
|
# listener-free logic, run from the host this time.
|
||||||
ARCH_IP="$(claudenet_ip archive)"
|
ARCH_IP="$(boxnet_ip archive)"
|
||||||
if [ -n "$ARCH_IP" ]; then
|
if [ -n "$ARCH_IP" ]; then
|
||||||
hmsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" 2>&1)"; hrc=$?
|
hmsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" 2>&1)"; hrc=$?
|
||||||
if [ "$hrc" -eq 0 ]; then hv=reachable
|
if [ "$hrc" -eq 0 ]; then hv=reachable
|
||||||
|
|
@ -577,7 +620,7 @@ if [ -n "$ARCH_IP" ]; then
|
||||||
aud "A7 inbound host→box: INCONCLUSIVE ($hv)" ;;
|
aud "A7 inbound host→box: INCONCLUSIVE ($hv)" ;;
|
||||||
esac
|
esac
|
||||||
else
|
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"
|
aud "A7 inbound host→box: NOT PROBED"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -586,7 +629,7 @@ phase "D. The isolation contract, stated"
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Phase D used to REHEARSE the hardening on a throwaway host, because nobody
|
# 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
|
# 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
|
# and there is nothing left to rehearse. What the rehearsal established, kept
|
||||||
# here so it is not re-litigated:
|
# 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"
|
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
|
||||||
else
|
else
|
||||||
# every name the drill can have left, whatever branch a partial run took
|
# 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
|
# 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
|
# already embodies the discipline (only names the drill minted); demanding
|
||||||
# 'no boxes yet' here would flag any pre-existing operator box as a failure.
|
# '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" \
|
[ -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' ' ')"
|
|| no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')"
|
||||||
fi
|
fi
|
||||||
|
|
@ -643,7 +686,7 @@ if [ "${#audit[@]}" -gt 0 ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
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 "(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]"
|
inf "to undo: ~/.local/share/claudebox/host/teardown-host.sh [--purge-incus]"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$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]
|
[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 docker so DOCKER-USER exists; still fine if docker is absent.
|
||||||
After=network.target incus.service docker.service
|
After=network.target incus.service docker.service
|
||||||
Wants=network.target
|
Wants=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/usr/local/sbin/claudebox-firewall
|
ExecStart=/usr/local/sbin/box-firewall
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Apply the claudebox host-firewall rules. Idempotent; runs as root.
|
# Apply the box host-firewall rules. Idempotent; runs as root.
|
||||||
# Invoked by setup-host.sh at install time and by claudebox-firewall.service
|
# 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
|
# 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).
|
# Docker's DOCKER-USER rules are runtime-only and need re-applying).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
GW=10.87.0.1
|
GW=10.88.0.1
|
||||||
NET=claudenet
|
NET=boxnet
|
||||||
|
|
||||||
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
|
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
|
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
|
fi
|
||||||
else
|
else
|
||||||
# No UFW: protect the host's own sockets with a dedicated nft table.
|
# No UFW: protect the host's own sockets with a dedicated nft table.
|
||||||
if ! nft list table inet claudebox >/dev/null 2>&1; then
|
if ! nft list table inet box >/dev/null 2>&1; then
|
||||||
nft add table inet claudebox
|
nft add table inet box
|
||||||
nft 'add chain inet claudebox input { type filter hook input priority -5 ; }'
|
nft 'add chain inet box input { type filter hook input priority -5 ; }'
|
||||||
nft add rule inet claudebox input iifname "$NET" udp dport '{ 53, 67 }' accept
|
nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept
|
||||||
nft add rule inet claudebox input iifname "$NET" tcp dport 53 accept
|
nft add rule inet box input iifname "$NET" tcp dport 53 accept
|
||||||
nft add rule inet claudebox input iifname "$NET" drop
|
nft add rule inet box input iifname "$NET" drop
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ fi
|
||||||
#
|
#
|
||||||
# This is the ONE rule that makes "isolated even from each other" true, and it
|
# 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
|
# 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
|
# 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.
|
# probe found box A's SYN arriving at box B and B answering with a RST.
|
||||||
#
|
#
|
||||||
|
|
@ -41,16 +41,16 @@ fi
|
||||||
# sees this traffic.
|
# sees this traffic.
|
||||||
#
|
#
|
||||||
# The bridge family DOES see it. Its forward hook fires exactly when a frame is
|
# 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
|
# 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
|
# 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
|
# 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
|
# costs them nothing else. DHCP and ARP still work: they are broadcast, and the
|
||||||
# local delivery to dnsmasq happens on INPUT, not FORWARD.
|
# local delivery to dnsmasq happens on INPUT, not FORWARD.
|
||||||
if ! nft list table bridge claudebox >/dev/null 2>&1; then
|
if ! nft list table bridge box >/dev/null 2>&1; then
|
||||||
nft add table bridge claudebox
|
nft add table bridge box
|
||||||
nft "add chain bridge claudebox forward { type filter hook forward priority -200 ; policy accept ; }"
|
nft "add chain bridge box forward { type filter hook forward priority -200 ; policy accept ; }"
|
||||||
nft add rule bridge claudebox forward meta ibrname "$NET" meta obrname "$NET" drop
|
nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.
|
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.
|
||||||
|
|
@ -56,21 +56,23 @@ PRESEED
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Isolated NAT network. IPv6 off: one less egress path to reason about.
|
# Isolated NAT network. IPv6 off: one less egress path to reason about.
|
||||||
incus network show claudenet >/dev/null 2>&1 || incus network create claudenet \
|
# 10.88, not 10.87: a pre-rename host may still carry claudenet on 10.87 with
|
||||||
ipv4.address=10.87.0.1/24 ipv4.nat=true ipv6.address=none
|
# 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.
|
# ACL: default egress allow (internet), explicit drops for private space.
|
||||||
# Gateway carve-out first so instance DNS (dnsmasq on 10.87.0.1) survives.
|
# Gateway carve-out first so instance DNS (dnsmasq on 10.88.0.1) survives.
|
||||||
if ! incus network acl show claude-isolate >/dev/null 2>&1; then
|
if ! incus network acl show box-isolate >/dev/null 2>&1; then
|
||||||
incus network acl create claude-isolate
|
incus network acl create box-isolate
|
||||||
incus network acl rule add claude-isolate egress action=allow destination=10.87.0.1/32
|
incus network acl rule add box-isolate egress action=allow destination=10.88.0.1/32
|
||||||
incus network acl rule add claude-isolate egress action=drop destination=10.0.0.0/8
|
incus network acl rule add box-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 box-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 box-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 box-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
|
incus network acl rule add box-isolate egress action=drop destination=100.64.0.0/10
|
||||||
fi
|
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.egress.action=allow \
|
||||||
security.acls.default.ingress.action=drop
|
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,
|
# from inside one box resolved another's name and address. Connection blocked,
|
||||||
# reconnaissance wide open. dns.mode=none stops it registering instance records;
|
# reconnaissance wide open. dns.mode=none stops it registering instance records;
|
||||||
# forwarding for public names is unaffected (verified live).
|
# 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).
|
# 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
|
# 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
|
# lever — the bridge has no first-class upstream key. Verified live on the
|
||||||
# drill host: pin applied, box resolves, cold mint survives.
|
# drill host: pin applied, box resolves, cold mint survives.
|
||||||
BOX_DNS="${BOX_DNS:-1.1.1.1 8.8.8.8}"
|
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)"
|
"$(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
|
# 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
|
# as an nftables bridge-family rule. See the comment there; it is the reason
|
||||||
# boxes cannot reach each other.
|
# boxes cannot reach each other.
|
||||||
|
|
||||||
|
|
@ -107,27 +109,27 @@ incus network set claudenet raw.dnsmasq \
|
||||||
|
|
||||||
# --- Firewall coexistence ---------------------------------------------------
|
# --- Firewall coexistence ---------------------------------------------------
|
||||||
# Hosts running UFW (INPUT drop) and/or Docker (FORWARD drop) silently eat
|
# 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,
|
# on top. The trailing deny also blocks instance -> host's own (public) IPs,
|
||||||
# which the RFC1918-only ACL cannot express. Rules live in
|
# 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.
|
# parts (nft table, DOCKER-USER) after every reboot.
|
||||||
# The no-UFW path drives nft directly, and a stock Debian 13 cloud image ships
|
# 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.
|
# 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
|
if ! command -v ufw >/dev/null 2>&1 && ! command -v nft >/dev/null 2>&1; then
|
||||||
sudo apt-get install -y nftables
|
sudo apt-get install -y nftables
|
||||||
fi
|
fi
|
||||||
sudo install -m 755 "$here/host/claudebox-firewall.sh" /usr/local/sbin/claudebox-firewall
|
sudo install -m 755 "$here/host/box-firewall.sh" /usr/local/sbin/box-firewall
|
||||||
sudo install -m 644 "$here/host/claudebox-firewall.service" /etc/systemd/system/
|
sudo install -m 644 "$here/host/box-firewall.service" /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
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
|
# 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.
|
# 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
|
# 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
|
# 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.
|
# 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
|
# Profile — box-net, the placement contract: the isolated NIC and the root
|
||||||
# disk, nothing a template controls (resources are stamped per-instance from
|
# 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
|
# The sibling drop is the one rule whose absence is invisible: everything keeps
|
||||||
# working, and boxes can simply reach each other. Assert it landed.
|
# working, and boxes can simply reach each other. Assert it landed.
|
||||||
if sudo nft list table bridge claudebox >/dev/null 2>&1; then
|
if sudo nft list table bridge box >/dev/null 2>&1; then
|
||||||
echo "Isolation: box-to-box drop is live (nft bridge table 'claudebox')."
|
echo "Isolation: box-to-box drop is live (nft bridge table 'box')."
|
||||||
else
|
else
|
||||||
echo "WARNING: the box-to-box drop is NOT active — boxes can reach each other." >&2
|
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
|
fi
|
||||||
|
|
||||||
echo "Host ready. Launch with: box new --name <box>"
|
echo "Host ready. Launch with: box new --name <box>"
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,75 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Reverse everything host/setup-host.sh created: all claudebox instances, the
|
# Reverse everything host/setup-host.sh created — and everything its pre-0.4.0
|
||||||
# claudenet network + ACL, the claude-dev profile, and the firewall rules.
|
# 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]
|
# 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)
|
# instances still exist on this host)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
purge=false
|
purge=false
|
||||||
[ "${1:-}" = "--purge-incus" ] && purge=true
|
[ "${1:-}" = "--purge-incus" ] && purge=true
|
||||||
|
|
||||||
echo "This removes ALL claudebox instances (uncommitted work in them is lost),"
|
echo "This removes ALL boxes (uncommitted work in them is lost), the"
|
||||||
echo "the claudenet network/ACL/profile, and the claudebox firewall rules."
|
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)."
|
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
|
||||||
read -rp "Continue? [y/N] " a
|
read -rp "Continue? [y/N] " a
|
||||||
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
|
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
|
||||||
|
|
||||||
# Instances
|
# Instances — both tag generations, one delete at a time (a multi-name
|
||||||
for i in $(incus list "user.claudebox=1" -f csv -c n || true); do
|
# 'incus delete' aborts at the first missing name).
|
||||||
echo "deleting instance $i"
|
for tag in "user.box=1" "user.claudebox=1"; do
|
||||||
incus delete -f "$i"
|
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
|
done
|
||||||
|
|
||||||
incus profile delete box-net 2>/dev/null || true
|
incus profile delete box-net 2>/dev/null || true
|
||||||
incus profile delete claude-dev 2>/dev/null || true # legacy, pre-rename
|
incus profile delete claude-dev 2>/dev/null || true # legacy, pre-0.4.0
|
||||||
incus network delete claudenet 2>/dev/null || true
|
incus network delete boxnet 2>/dev/null || true
|
||||||
incus network acl delete claude-isolate 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 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
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
# Firewall crumbs — UFW rules mentioning claudenet (numbers shift after each
|
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
|
||||||
# delete, so re-scan and remove the first match until none remain)
|
# 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
|
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
|
for net in boxnet claudenet; do
|
||||||
n="$(sudo ufw status numbered | grep -m1 "on claudenet" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
while sudo ufw status numbered | grep -q "on $net"; do
|
||||||
sudo ufw --force delete "$n"
|
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||||
|
sudo ufw --force delete "$n"
|
||||||
|
done
|
||||||
done
|
done
|
||||||
fi
|
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
|
if command -v docker >/dev/null; then
|
||||||
sudo iptables -D DOCKER-USER -i claudenet -j ACCEPT 2>/dev/null || true
|
for net in boxnet claudenet; do
|
||||||
sudo iptables -D DOCKER-USER -o claudenet -j ACCEPT 2>/dev/null || true
|
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
|
fi
|
||||||
|
|
||||||
if $purge; then
|
if $purge; then
|
||||||
remaining="$(incus list -f csv 2>/dev/null | wc -l)"
|
remaining="$(incus list -f csv 2>/dev/null | wc -l)"
|
||||||
if [ "$remaining" -gt 0 ]; then
|
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
|
else
|
||||||
sudo apt-get purge -y incus
|
sudo apt-get purge -y incus
|
||||||
sudo apt-get autoremove -y
|
sudo apt-get autoremove -y
|
||||||
fi
|
fi
|
||||||
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