fix(drill): the NIC inside a VM is enp5s0, not eth0 — read by subnet instead

A3, the one probe the whole audit exists for, has never fired in six
runs. It was never the network: the profile names the DEVICE eth0, but
inside a VM guest predictable naming renames it enp5s0, so every address
lookup — first the '(eth0)' CSV match, then 'ip addr show dev eth0' —
was hunting an interface that does not exist. I fixed that symptom twice
without ever questioning the assumption underneath it.

Read the address from inside the box and select by SUBNET (10.87.x, what
claudenet hands out) rather than by interface name. docker0's 172.17.x
is the decoy; the NIC's name is the guest's business, not ours.

A3 also gains a guard it should have had from the start: if the peer and
the source hold the SAME address, refuse to probe. That is not
hypothetical — clones were inheriting their source's machine-id, hence
its DHCP lease, hence its address, so 'archive → peer' was archive
probing itself and would have reported a cheerful 'reachable' as an
isolation failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-13 23:32:22 +00:00
parent d8731b4772
commit 6899fc3626
2 changed files with 39 additions and 21 deletions

View file

@ -54,11 +54,18 @@ Read this before adding a probe. Every one of these cost a run.
and it is why the drill now runs **no listener anywhere**. It does not need and it is why the drill now runs **no listener anywhere**. It does not need
one: `curl` exit `7` (refused) means the packet *arrived*, `28` (timeout) one: `curl` exit `7` (refused) means the packet *arrived*, `28` (timeout)
means it was *dropped*. A closed port answers the question. means it was *dropped*. A closed port answers the question.
4. **`incus list` name filters are not regexes.** `incus list "^peer$"` matches 4. **The box's address is hard to read, and every way of getting it wrong was
nothing and returns empty — silently. This is how A3 went unprobed for tried.** (a) `incus list` name filters are **not regexes** — `incus list
three runs. Read addresses from inside the box (`ip -4 -o addr show dev "^peer$"` silently matches nothing. (b) Its CSV quotes a multi-address box
eth0`), not out of `incus list` CSV (which also quotes multi-address boxes across lines. (c) **The interface is not `eth0`.** The *profile* names the
across lines). device `eth0`, but inside a **VM guest** predictable naming renames it
**`enp5s0`** — so `ip addr show dev eth0` finds nothing either. That is the
real reason A3 went unprobed for six runs, through two "fixes" of mine that
never questioned the interface name. Read it from inside the box and select
by **subnet** (`10.87.x`), not by interface name: docker0 (`172.17.x`) is
the decoy, and the NIC's name is the guest's business.
*Lesson: when the same probe fails three different ways, stop patching the
probe and go look at the thing itself.*
5. **`incus delete -f a b c` aborts at the first MISSING name.** One interrupted 5. **`incus delete -f a b c` aborts at the first MISSING name.** One interrupted
run then poisons the next: stale boxes survive cleanup and cascade into run then poisons the next: stale boxes survive cleanup and cascade into
half a dozen unrelated FAILs. Delete one name at a time. half a dozen unrelated FAILs. Delete one name at a time.

View file

@ -86,17 +86,20 @@ in_box() {
return "$rc" return "$rc"
} }
eth0_ip() { # the box's address on claudenet — eth0 exactly; a box running # The box's address ON CLAUDENET. Three ways to get this wrong, all of them hit:
# docker has several addresses, so never just "the first IP". # · 'incus list' name filters are NOT regexes ("^b$" silently matches nothing)
# ('incus list' name filters are NOT regexes — the anchored # · its CSV quotes a multi-address box across lines
# "^b$" form matched nothing, which is how A3 went unprobed for # · and the interface is NOT called eth0. The PROFILE names the device eth0,
# three runs — and its CSV quotes multi-address boxes across # but inside a VM guest predictable naming renames it enp5s0. Six runs of
# lines. Reading 'ip -o' inside the box is unambiguous.) # A3 "not probed" were this, not the network.
# Retries: the agent answers before DHCP hands out the address. # So: read it from inside the box, and select by SUBNET (10.87.x, what claudenet
# hands out) rather than by interface name — docker0 (172.17.x) is the decoy,
# and the NIC's name is the guest's business, not ours.
claudenet_ip() {
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 dev eth0 \ ip="$(in_box "$b" ip -4 -o addr show scope global \
| awk '{ for (i = 1; i < NF; i++) if ($i == "inet") { split($(i+1), a, "/"); print a[1]; exit } }')" | awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.87\./) { 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
@ -457,9 +460,17 @@ 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="$(eth0_ip peer)" PEER_IP="$(claudenet_ip peer)"
if [ -n "$PEER_IP" ]; then ARCH_IP_PRE="$(claudenet_ip archive)"
inf "probing archive → peer ($PEER_IP), no listener: refused means it arrived, timeout means it was dropped" if [ -n "$PEER_IP" ] && [ "$PEER_IP" = "$ARCH_IP_PRE" ]; then
# Guard, because this actually happened: a clone inherited its source's
# machine-id, hence its DHCP lease, hence its ADDRESS. Probing "archive →
# peer" was archive probing itself, and would have reported a cheerful
# "reachable" as a sibling-isolation failure. Never let A3 answer this.
no "archive and peer hold the SAME address ($PEER_IP) — the clone did not get its own identity; A3 cannot be probed"
aud "A3 sibling: NOT PROBED — clone/source IP collision (see the clone-identity fix)"
elif [ -n "$PEER_IP" ]; then
inf "probing archive ($ARCH_IP_PRE) → peer ($PEER_IP), no listener: refused means it arrived, timeout means it was dropped"
rc="$(box_curl archive "http://$PEER_IP:8088")" rc="$(box_curl archive "http://$PEER_IP:8088")"
v="$(verdict "$rc")" v="$(verdict "$rc")"
timeout -k 5 30 incus exec archive -- ping -c1 -W2 "$PEER_IP" >/dev/null 2>&1 </dev/null timeout -k 5 30 incus exec archive -- ping -c1 -W2 "$PEER_IP" >/dev/null 2>&1 </dev/null
@ -482,8 +493,8 @@ if [ -n "$PEER_IP" ]; then
aud "A3 sibling: INCONCLUSIVE (curl exit $rc, ping exit $png)" ;; aud "A3 sibling: INCONCLUSIVE (curl exit $rc, ping exit $png)" ;;
esac esac
else else
no "could not read peer's eth0 address — the sibling probe never ran" no "could not read peer's claudenet address — the sibling probe never ran"
aud "A3 sibling: NOT PROBED (no eth0 address on peer)" aud "A3 sibling: NOT PROBED (no 10.87.x address on peer)"
fi fi
# C5 — DNS enumeration (#15 A4): #12 predicts this LEAKS today. Either way it # C5 — DNS enumeration (#15 A4): #12 predicts this LEAKS today. Either way it
@ -505,7 +516,7 @@ fi
# 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="$(eth0_ip archive)" ARCH_IP="$(claudenet_ip archive)"
if [ -n "$ARCH_IP" ]; then if [ -n "$ARCH_IP" ]; then
curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" >/dev/null 2>&1 curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" >/dev/null 2>&1
hv="$(verdict $?)" hv="$(verdict $?)"
@ -521,7 +532,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 eth0 address — the inbound probe never ran" no "could not read archive's claudenet address — the inbound probe never ran"
aud "A7 inbound host→box: NOT PROBED" aud "A7 inbound host→box: NOT PROBED"
fi fi