2026-07-10 15:00:36 +00:00
|
|
|
#!/usr/bin/env bash
|
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.
2026-07-14 14:36:39 +00:00
|
|
|
# Apply the box host-firewall rules. Idempotent; runs as root.
|
|
|
|
|
# Invoked by setup-host.sh at install time and by box-firewall.service
|
2026-07-10 15:00:36 +00:00
|
|
|
# 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
|
|
|
|
|
|
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.
2026-07-14 14:36:39 +00:00
|
|
|
GW=10.88.0.1
|
|
|
|
|
NET=boxnet
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
ufw insert 1 deny in on "$NET"
|
|
|
|
|
ufw insert 1 allow in on "$NET" to "$GW" port 53 proto tcp
|
|
|
|
|
ufw insert 1 allow in on "$NET" to "$GW" port 53 proto udp
|
|
|
|
|
ufw insert 1 allow in on "$NET" to any port 67 proto udp
|
|
|
|
|
ufw route allow in on "$NET"
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
# No UFW: protect the host's own sockets with a dedicated nft table.
|
2026-07-14 23:13:25 +00:00
|
|
|
# Flush + rebuild rather than skip-if-present: a guard that only checks
|
|
|
|
|
# existence pins every host to the rule set of the release that FIRST ran
|
|
|
|
|
# here, and an upgraded rule never lands. 'add chain' with the same spec is
|
|
|
|
|
# a no-op, so this converges.
|
|
|
|
|
#
|
|
|
|
|
# The established,related accept is load-bearing for 'box expose' (#55): the
|
|
|
|
|
# door's traffic reaches the box masqueraded as the gateway, so the box's
|
|
|
|
|
# REPLY arrives here as input on boxnet — a stateless drop eats it and the
|
|
|
|
|
# door times out (drill-found). Boxes still cannot INITIATE toward the host:
|
|
|
|
|
# a box-originated SYN is a NEW flow, and NEW is what the drop is for.
|
|
|
|
|
# (UFW hosts get the same semantics from ufw's built-in RELATED,ESTABLISHED
|
|
|
|
|
# accept in before.rules — this branch must match it.)
|
|
|
|
|
nft add table inet box
|
|
|
|
|
nft 'add chain inet box input { type filter hook input priority -5 ; }'
|
|
|
|
|
nft flush chain inet box input
|
|
|
|
|
nft add rule inet box input iifname "$NET" ct state established,related accept
|
|
|
|
|
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
|
2026-07-10 15:00:36 +00:00
|
|
|
fi
|
|
|
|
|
|
2026-07-14 01:29:33 +00:00
|
|
|
# --- Sibling isolation: a box must not reach another box --------------------
|
|
|
|
|
#
|
|
|
|
|
# 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
|
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.
2026-07-14 14:36:39 +00:00
|
|
|
# boxnet's 10.88.0.0/24 sits inside it — so on paper box→box was already
|
2026-07-14 01:29:33 +00:00
|
|
|
# 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.
|
|
|
|
|
#
|
|
|
|
|
# Why: two boxes on one bridge are on the same L2 segment. Their frames are
|
|
|
|
|
# SWITCHED between bridge ports, never routed — so they never traverse the
|
|
|
|
|
# netfilter path where an L3 ACL lives. The ACL is not wrong, it simply never
|
|
|
|
|
# sees this traffic.
|
|
|
|
|
#
|
|
|
|
|
# The bridge family DOES see it. Its forward hook fires exactly when a frame is
|
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.
2026-07-14 14:36:39 +00:00
|
|
|
# passed from one bridge port to another — which, on boxnet, means box→box
|
2026-07-14 01:29:33 +00:00
|
|
|
# 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.
|
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.
2026-07-14 14:36:39 +00:00
|
|
|
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
|
2026-07-14 01:29:33 +00:00
|
|
|
fi
|
|
|
|
|
|
2026-07-14 22:55:10 +00:00
|
|
|
# --- The loopback door's missing half (box expose, #55) ----------------------
|
|
|
|
|
#
|
|
|
|
|
# 'box expose' publishes a box port on the host's 127.0.0.1 via an Incus
|
|
|
|
|
# NAT-mode proxy device. Incus installs the DNAT (prerouting + output hooks)
|
|
|
|
|
# and NOTHING else — its only SNAT is a hairpin rule for the box reaching its
|
|
|
|
|
# own exposure. A host-local `curl 127.0.0.1:<hport>` is therefore DNAT'd
|
|
|
|
|
# toward the box and then dies twice:
|
|
|
|
|
# · the kernel refuses to route a loopback-SOURCED packet out a
|
|
|
|
|
# non-loopback interface (a martian) unless route_localnet is set on the
|
|
|
|
|
# egress bridge;
|
|
|
|
|
# · even then, the box would reply to 127.0.0.1 — its OWN loopback —
|
|
|
|
|
# unless the source is rewritten to something it can answer.
|
|
|
|
|
# This is exactly the plumbing Docker installs on docker0 to make
|
|
|
|
|
# `-p 127.0.0.1:x:y` work: route_localnet=1 on the bridge, plus a masquerade
|
|
|
|
|
# of loopback-sourced traffic leaving it (the box then sees the gateway and
|
|
|
|
|
# replies through it). Scoped to boxnet only, never 'all'.
|
|
|
|
|
#
|
|
|
|
|
# route_localnet's known risk — it makes 127/8 a routable DESTINATION on the
|
|
|
|
|
# interface, so a box could aim frames at the host's loopback services — is
|
|
|
|
|
# covered by the ingress stance above: everything arriving on boxnet at the
|
|
|
|
|
# host is dropped except DNS/DHCP (UFW 'deny in' or the inet-box input chain),
|
|
|
|
|
# and that drop fires regardless of the destination address.
|
|
|
|
|
if [ -e "/proc/sys/net/ipv4/conf/$NET/route_localnet" ]; then
|
|
|
|
|
sysctl -qw "net.ipv4.conf.$NET.route_localnet=1"
|
|
|
|
|
else
|
|
|
|
|
echo "box-firewall: $NET does not exist yet — route_localnet not set; expose's loopback door stays dead until this script runs again" >&2
|
|
|
|
|
fi
|
2026-07-14 23:13:25 +00:00
|
|
|
nft add table inet box
|
|
|
|
|
nft "add chain inet box expose-snat { type nat hook postrouting priority 110 ; }"
|
|
|
|
|
nft flush chain inet box expose-snat
|
|
|
|
|
nft add rule inet box expose-snat oifname "$NET" ip saddr 127.0.0.0/8 masquerade
|
2026-07-14 22:55:10 +00:00
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.
|
|
|
|
|
if command -v docker >/dev/null && iptables -L DOCKER-USER -n >/dev/null 2>&1; then
|
|
|
|
|
iptables -C DOCKER-USER -i "$NET" -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -i "$NET" -j ACCEPT
|
|
|
|
|
iptables -C DOCKER-USER -o "$NET" -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -o "$NET" -j ACCEPT
|
|
|
|
|
fi
|