Commit graph

6 commits

Author SHA1 Message Date
dan-claude-bot
dc09d54cbe fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `ufw status | grep -q "Status: active"`, and
"Status: active" is the FIRST line ufw prints: grep -q matches it and
exits immediately, closing the pipe while ufw is still writing the rest
of the table, so ufw dies of SIGPIPE. grep returned 0, but under the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.

`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.

Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.

Closes #102

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:17:42 +00:00
dan-claude-bot
ca1a759f3a box-firewall: converge the UFW carve-out off the live bridge; fail closed at boot
The UFW block was create-once: gated on 'a DENY on boxnet exists', it
pinned every UFW host to the gateway of the FIRST run. A bridge remapped
off a colliding subnet (#80's escape hatch) kept its stale
'allow ... to <old-gw> port 53' and never gained the live gateway's, so
box->gateway DNS died at box's own deny — the exact stale-carve-out
failure #86 rewrote the ACL path to prevent, left standing in the UFW
path (#86 review, item 1). The allows now converge on every run: DNS
allows aimed anywhere but the live gateway are deleted, then the live
set is ensured (ufw skips existing rules, so a fresh host gets the
identical five rules and a re-run is a no-op).

And the boot fallback GW=10.88.0.1 is gone (#86 review, item 2): with
the bridge not yet addressed when the service ran, a BOX_SUBNET host got
its carve-out built — and, once converging, converged — for the WRONG
gateway, a latent DNS drop. Fail closed instead: an unaddressed bridge
leaves the persisted UFW rules exactly as they are (they survive boots
on their own, and nothing else in this script needs the gateway — the
nft carve-out is interface-scoped) and says so on stderr; the next
setup-host run or service restart converges them once the bridge is up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:45:21 +00:00
dan-claude-bot
b429f5b65c feat(setup-host): refuse a claimed subnet, and BOX_SUBNET to move off one (#80)
Run inside a box, setup-host built a nested boxnet claiming the exact
subnet and gateway of the guest's own uplink: the guest then held
10.88.0.1 as a LOCAL address while its default route pointed at 10.88.0.1
as its GATEWAY, carried two connected routes for the subnet, and suffered
intermittent, self-recovering egress blackouts (~24-36 s, roughly hourly)
that no host-side check could attribute. The flagship use case funnels
agents toward exactly this — working on box, in a box — so the guard, not
the operator, has to catch it.

setup-host now scans the target subnet BEFORE any mutation: the default
route's gateway inside it (the smoking gun — that is this machine's own
uplink), or any non-boxnet interface holding an address in it, refuses
with the way out named (BOX_SUBNET). A prior boxnet owning the subnet is
the one claimant that is NOT a collision — the legitimate re-run — and an
existing bridge on a DIFFERENT subnet refuses too: setup-host converges a
bridge, it never re-addresses one under live leases.

BOX_SUBNET=<a.b.c.0/24> (validated at the gate, alongside BOX_DNS) moves
the whole stack from one place:
  · the bridge address derives (ipv4.address=<gw>/24)
  · the ACL's gateway carve-out derives — and the ruleset is now converged
    via 'network acl edit', not created once: the #80 escape hatch (moving
    the bridge) left the old /32 behind, stranding box DNS inside the
    10.0.0.0/8 drop, which presents as a dead resolver, never as a stale
    ACL
  · box-firewall reads the gateway off the live bridge ('|| true' because
    under pipefail an absent bridge would kill the script instead of
    answering "no bridge yet")
  · the drill, multiuser rehearsal and migrate-host probes derive the
    prefix from 'incus network get boxnet ipv4.address' instead of
    hardcoding 10.88 — a BOX_SUBNET host must not fail its own rehearsals

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:46:58 +00:00
edf8309f99 fix(expose): accept established flows back from boxnet — the input drop was eating the door's replies
Reproduced the drill's E failure on a live stack (Incus 6.0.4, container
box, same setup-host/box-firewall): tcpdump on boxnet shows the SYN
leaving masqueraded as the gateway and the box answering SYN/ACK
instantly — which then dies at the host's input hook. The inet-box input
chain dropped ALL boxnet input except DNS/DHCP, stateless: the reply to
the very connection the door opened. UFW hosts never had this hole
(before.rules accepts RELATED,ESTABLISHED); the nft fallback now matches
that semantics with a ct state established,related accept ahead of the
drop. Boxes still cannot INITIATE toward the host — a box-originated SYN
is a NEW flow, which is what the drop is for.

Also rebuild the chains on every run (add chain + flush + re-add) instead
of skip-if-present: the existence guard pinned every host to the rule set
of the release that first ran there, so an upgraded rule never landed.

Verified end-to-end on the repro stack: curl 127.0.0.1:18091 → HTTP 200;
box→host initiation still times out; DNS carve-out intact; non-exposed
port still dropped; --remove kills the door; re-expose returns 200.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:13:25 +00:00
32bb203ddb fix(expose): install the loopback door's missing half — route_localnet + masquerade on boxnet
The drill's E phase moved one layer down: the device now adds, but
127.0.0.1:<hport> never reaches the box. Incus's NAT-mode proxy installs
only the DNAT (prerouting + output); a loopback-sourced packet then dies
twice — the kernel refuses to route it out a non-loopback interface
without route_localnet on the bridge, and the box would reply to its OWN
127.0.0.1 without a masquerade. This is the exact plumbing Docker
installs on docker0 for '-p 127.0.0.1y'.

box-firewall.sh now sets route_localnet=1 on boxnet and masquerades
loopback-sourced traffic leaving it (chain expose-snat, table inet box).
route_localnet's known risk — 127/8 becomes a routable destination on
the bridge — is covered by the existing iifname-boxnet input drop, which
fires regardless of destination address. The no-UFW guard now checks the
input CHAIN, not the table, since expose-snat shares the table.

expose warns (root-free, via /proc) when the host firewall predates this
plumbing instead of handing over a door that silently does not answer,
and 'box info' now lists open exposures — the drill's nice-to-have: a
box with a hole says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:55:10 +00:00
claude-hdb
5defd40bca 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
Renamed from host/claudebox-firewall.sh (Browse further)