fix(drill): stop poisoning the host, and add a doctor to prove it #32

Merged
dan-claude-bot merged 1 commit from drill/doctor into main 2026-07-14 00:33:31 +00:00
4 changed files with 189 additions and 1 deletions

View file

@ -25,6 +25,12 @@ and profile mutations stay applied with them).
Exit 0 means every check passed. Roughly 20 minutes, most of it the cold box.
**Something wrong with the host?** `bash drill/doctor.sh` — it reports whether
the host is fit to drill (network, profile, ACL, leftover boxes, whether a box
can still resolve DNS), and `--fix` reverts what an aborted run left behind.
The drill mutates the host in phase D; an aborted run can leave a network that
mints boxes with **no DNS**.
**Iterating on the drill?** Read [RUNS.md](RUNS.md) first — it is the run log:
what the audit has answered so far, the bugs the drill has found in claudebox,
the traps this script has already fallen into (every one cost a run), how to

View file

@ -92,9 +92,23 @@ Read this before adding a probe. Every one of these cost a run.
flip, in a different costume: **check that the thing you are measuring with
still works before you trust what it tells you.**
10. **The drill mutates the host, and those mutations outlive an aborted run.**
Phase D sets `dns.mode=none` and NIC filtering. If the run dies before
reverting them, **every box minted afterwards has no DNS** — cloud-init
fails with `Temporary failure resolving deb.debian.org` — and the next run
reports that breakage as a *finding*. This is the worst failure mode in the
whole list: a poisoned host does not fail honestly, it produces confident
wrong answers. Hence the `trap`-armed revert, the verified (not
`/dev/null`-ed) unset, the refusal to start on a dirty host, and
`doctor.sh`.
## Diagnosing a stall
The drill narrates every long step. If it goes quiet, open a second terminal:
**Start here: `bash drill/doctor.sh`** — it answers "what state is this host
actually in?" (network, profile, ACL, leftover boxes, and whether a box can
still resolve DNS), and `--fix` reverts what the drill left behind.
If the drill goes quiet mid-run, open a second terminal:
```sh
# what is actually running / blocked?
@ -135,6 +149,7 @@ No listener is needed, and none should be started: see trap 3.
| 4 | hung at C4 | trap 2 again, this time via `claudebox exec` in a command substitution |
| 5 | stalled in host setup | trap 6 — silence through apt/sudo |
| 6 | stalled in `setup-host.sh` | trap 8 — cleanup ran *after* setup. Recovering the host exposed **two real claudebox bugs**: `setup-host` deadlocks the incus daemon when re-run with boxes up (#26), and clones inherit their source's machine-id → same DHCP lease → **two boxes, one IP** (#27) |
| 8 | aborted: cold mint failed | `cloud-init status: error`**the box had no DNS at all**. Run 7's phase-D `dns.mode=none` survived the run and poisoned the host. Trap 10, and the reason `doctor.sh` exists |
| 7 | 41/49 | the clone-identity fix could not reboot (systemd needs a valid machine-id to shut down cleanly), so it never took effect → the IP collision persisted → the box lost networking → **phase D reported a false design veto against #16**. Trap 9. Also found: `dir` storage makes every clone a full disk copy (#29) |
**The instrument has been less reliable than the thing it measures.** Four of

124
drill/doctor.sh Executable file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# doctor.sh — is this host in a fit state to drill, and if not, what is wrong?
#
# bash drill/doctor.sh # report
# bash drill/doctor.sh --fix # report, then revert what the drill left behind
#
# The drill MUTATES the host in phase D (dns.mode, NIC filtering, ACL rules) to
# rehearse the #16 hardening. If a run aborts before it reverts them, those
# mutations outlive it — and the next run mints boxes on a broken network. That
# is not a hypothetical: it is how a box came up with no DNS at all
# ("Temporary failure resolving deb.debian.org" in cloud-init), and how a false
# design veto against #16 got posted from a poisoned baseline.
#
# This script is the answer to "what state is the host actually in?" — the
# question that kept getting answered by hand.
set -u
FIX=0
[ "${1:-}" = "--fix" ] && FIX=1
bad=0
ok() { printf ' \033[32mOK\033[0m %s\n' "$*"; }
no() { printf ' \033[31mDIRTY\033[0m %s\n' "$*"; bad=$((bad + 1)); }
inf() { printf ' %s\n' "$*"; }
head_() { printf '\n\033[1m%s\033[0m\n' "$*"; }
command -v incus >/dev/null || { echo "doctor: incus is not installed on this host."; exit 1; }
timeout 10 incus list >/dev/null 2>&1 || {
echo "doctor: the incus daemon is not answering (see issue #26 for recovery):"
echo " sudo pkill -9 -f 'incusd shutdown'"
echo " sudo systemctl stop incus.service incus.socket"
echo " sudo systemctl reset-failed incus.service incus.socket"
echo " sudo systemctl start incus.socket incus.service"
exit 1
}
head_ "Network — claudenet"
if incus network show claudenet >/dev/null 2>&1; then
dns="$(incus network get claudenet dns.mode 2>/dev/null)"
if [ -z "$dns" ] || [ "$dns" = managed ]; then
ok "dns.mode = ${dns:-<unset, i.e. managed>}"
else
no "dns.mode = $dns ← the drill's phase D left this behind. Boxes minted now get NO working DNS."
[ "$FIX" = 1 ] && { incus network unset claudenet dns.mode && inf "reverted: dns.mode unset"; }
fi
inf "ipv4.address = $(incus network get claudenet ipv4.address 2>/dev/null)"
ipv6="$(incus network get claudenet ipv6.address 2>/dev/null)"
[ "$ipv6" = none ] && ok "ipv6.address = none (the isolation contract — every ACL rule is IPv4-only)" \
|| no "ipv6.address = $ipv6 — IPv6 is on and NOT covered by any ACL rule"
else
inf "claudenet does not exist (a fresh host — setup-host.sh will create it)"
fi
head_ "Profile — claude-dev (the NIC is the isolation contract)"
if incus profile show claude-dev >/dev/null 2>&1; then
for k in security.mac_filtering security.ipv4_filtering; do
v="$(incus profile device get claude-dev eth0 "$k" 2>/dev/null)"
if [ -z "$v" ]; then
ok "$k unset (as shipped)"
else
no "$k = $v ← phase D left this behind. A box can fail to get on the network at all."
[ "$FIX" = 1 ] && { incus profile device unset claude-dev eth0 "$k" && inf "reverted: $k unset"; }
fi
done
inf "cpu/mem: $(incus profile get claude-dev limits.cpu 2>/dev/null)/$(incus profile get claude-dev limits.memory 2>/dev/null) (the drill lowers these on a small host)"
else
inf "claude-dev does not exist (a fresh host)"
fi
head_ "ACL — claude-isolate"
if incus network acl show claude-isolate >/dev/null 2>&1; then
n="$(incus network acl show claude-isolate | grep -c 'action:' || true)"
inf "$n rules"
incus network acl show claude-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
if incus network acl show claude-isolate | grep -q '@internal'; then
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"; }
fi
else
inf "claude-isolate does not exist (a fresh host)"
fi
head_ "Instances"
left="$(incus list --format csv --columns ns 2>/dev/null)"
[ -z "$left" ] && inf "(none)" || printf ' %s\n' "$left"
for b in drill clone archive peer payroll cbprobe cbcopy cbnotours; do
if incus config show "$b" >/dev/null 2>&1; then
no "leftover drill box: $b"
[ "$FIX" = 1 ] && { timeout 60 incus delete -f "$b" >/dev/null 2>&1 && inf "reverted: deleted $b"; }
fi
done
head_ "Can a box actually resolve DNS?"
probe=""
for b in drill archive peer clone; do
incus config show "$b" >/dev/null 2>&1 && { probe="$b"; break; }
done
if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
inf "probing inside '$probe' (the cheapest test of a poisoned network):"
inf "resolv.conf: $(timeout 20 incus exec "$probe" -- sh -c 'grep -m2 nameserver /etc/resolv.conf' 2>/dev/null | tr '\n' ' ')"
if timeout 25 incus exec "$probe" -- getent hosts deb.debian.org >/dev/null 2>&1; then
ok "$probe resolves deb.debian.org"
else
no "$probe CANNOT resolve deb.debian.org — this is what breaks cloud-init on every new box"
fi
timeout 20 incus exec "$probe" -- ping -c1 -W2 10.87.0.1 >/dev/null 2>&1 \
&& ok "$probe reaches the gateway (10.87.0.1) — so it is DNS, not routing" \
|| no "$probe cannot even reach the gateway"
else
inf "no box to probe with (mint one, or run without --fix after a run)"
fi
head_ "Verdict"
if [ "$bad" -eq 0 ]; then
printf ' \033[32mclean\033[0m — this host is fit to drill.\n\n'
exit 0
fi
printf ' \033[31m%s problem(s)\033[0m — this host is NOT fit to drill.\n' "$bad"
if [ "$FIX" = 1 ]; then
printf ' reverted what could be reverted; re-run doctor to confirm.\n\n'
else
printf ' run: bash drill/doctor.sh --fix\n\n'
fi
exit 1

View file

@ -202,6 +202,22 @@ KEEP="${KEEP:-0}"
# filtering) in place, so setup would be converging against a moving target.
# Take the boxes down and revert the mutations FIRST; then the host is a
# clean-ish slate and setup-host is the no-op it should be.
# A host still carrying a previous run's phase-D mutations mints boxes with no
# DNS, and then reports the resulting breakage as a finding. Refuse to run.
dirty=""
[ -n "$(incus network get claudenet dns.mode 2>/dev/null)" ] && dirty="dns.mode"
[ -n "$(incus profile device get claude-dev eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty ipv4_filtering"
[ -n "$(incus profile device get claude-dev eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty mac_filtering"
if [ -n "$dirty" ]; then
note "this host still carries a previous run's phase-D mutations:$dirty — reverting them now"
incus network unset claudenet dns.mode >/dev/null 2>&1
incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1
incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1
incus network acl rule remove claude-isolate egress action=drop destination=@internal >/dev/null 2>&1
still="$(incus network get claudenet dns.mode 2>/dev/null)"
[ -n "$still" ] && { echo "drill: could not revert dns.mode ('$still'). run: bash drill/doctor.sh --fix" >&2; exit 1; }
fi
inf "clearing anything a previous run left behind…"
# One name at a time — 'incus delete -f a b c' aborts at the first MISSING name,
# which is how run 2 inherited run 1's boxes and cascaded five false FAILs.
@ -542,6 +558,21 @@ else
aud "A7 inbound host→box: NOT PROBED"
fi
# The mutations below OUTLIVE the run if it dies: dns.mode=none leaves every
# box minted afterwards with NO DNS at all (cloud-init then fails with
# "Temporary failure resolving deb.debian.org"), and NIC filtering can stop a
# box getting on the network. A poisoned host does not fail the NEXT run
# honestly — it produces confident, wrong answers, which is how a false design
# veto against #16 got posted. So arm the revert BEFORE making the first
# mutation, and let it fire on any exit, including Ctrl-C.
revert_phase_d() {
incus network unset claudenet dns.mode >/dev/null 2>&1
incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1
incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1
incus network acl rule remove claude-isolate egress action=drop destination=@internal >/dev/null 2>&1
}
trap 'revert_phase_d' EXIT INT TERM
# ===========================================================================
phase "D. Hardening rehearsal — #16's changes, applied live (#15 section B)"
# ===========================================================================
@ -634,6 +665,18 @@ fi
fi # end of the BASELINE_OK guard around phase D
# Revert now, and CHECK it — the old code fired these into /dev/null and moved
# on, so a failed unset was indistinguishable from a successful one.
revert_phase_d
d="$(incus network get claudenet dns.mode 2>/dev/null)"
f="$(incus profile device get claude-dev eth0 security.ipv4_filtering 2>/dev/null)"
if [ -z "$d" ] && [ -z "$f" ]; then
ok "phase D reverted: dns.mode and NIC filtering are back to shipped defaults"
else
no "PHASE D DID NOT REVERT (dns.mode='$d' ipv4_filtering='$f') — this host will poison the next run"
inf "fix it with: bash drill/doctor.sh --fix"
fi
# ===========================================================================
if [ "$KEEP" = 1 ]; then
phase "Boxes left up (--keep-boxes)"