From fee1929d101807b9d8335720f3b7a2ce0b58a382 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 21:20:36 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(setup-host):=20auto-pick=20a=20free=20?= =?UTF-8?q?subnet=20=E2=80=94=20nested=20box-in-box,=20zero=20flags=20(#80?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #86 shipped the refuse half of #80's fix #1; this completes the other half: "refuse … or automatically select a non-colliding subnet". The bare setup-host that used to stop an agent mid-drill inside a box now decides the subnet itself. choose_subnet is the one place that decision happens, before any mutation — BOX_GW, the bridge, the ACL carve-out, the firewall and the doctor's expectations all derive from its answer. Four deliberate cases: 1. explicit BOX_SUBNET — honored or refused, never silently overridden: a script that says 10.90 gets 10.90 or a loud stop. The claimant refusal and the bridge agree-gate are unchanged in spirit. 2. no pin, boxnet exists — converge to the bridge's own subnet: the bridge IS the pin (boxes hold leases on it). The old agree-gate refusal on a bare re-run against a moved bridge becomes convergence, announced when the bridge is off-default, silent on the plain default-host re-run. A FOREIGN claimant on the bridge's own subnet still refuses — that is #80's poisoned state, and converging would rebuild the blackouts; the refusal names the bridge move instead. 3. no pin, no bridge, 10.88.0.0/24 free — the default, as always. 4. no pin, no bridge, default claimed — the nested case (a drill or rehearsal inside a box, whose own uplink owns 10.88): scan 10.89.0.0/24 … 10.127.0.0/24 in order, take the first free candidate, announce the pick, the claimant and the BOX_SUBNET pin for scripts; refuse only when every candidate is claimed. The scan only ever runs bridge-less — an existing bridge is case 2, which precedes it. Pure over ip (via subnet_claimant and the bridge read), so test/cli.sh drives every case against canned tables with the shim ip. Co-Authored-By: Claude Fable 5 --- host/setup-host.sh | 146 +++++++++++++++++++++++++++++++++------------ 1 file changed, 109 insertions(+), 37 deletions(-) diff --git a/host/setup-host.sh b/host/setup-host.sh index ea0d636..dd3c992 100755 --- a/host/setup-host.sh +++ b/host/setup-host.sh @@ -45,15 +45,17 @@ else exit 1 fi -# --- The subnet, and the refusal to build on one something already owns ----- +# --- The subnet: never build on one something already owns ------------------ # (#80.) The stack's subnet was hardcoded, and running setup-host INSIDE a box # gave the guest a nested boxnet claiming the exact subnet and gateway of its # own uplink: the guest then held its gateway's address as a LOCAL address, # carried two connected routes for the subnet, and suffered intermittent, # self-recovering egress blackouts nobody could attribute — the host looked # clean the whole time. The flagship use case funnels agents toward doing -# exactly this (working on box, in a box), so the guard must refuse BEFORE -# any mutation, and name the way out (BOX_SUBNET). +# exactly this (working on box, in a box), so the decision must happen BEFORE +# any mutation. An explicit BOX_SUBNET is honored or refused, never overridden; +# with no pin, choose_subnet below converges on an existing bridge or picks a +# free /24 itself — a drill inside a box now just works, zero flags. # BOX_SUBNET must be a /24 with a zero host octet — a.b.c.0/24. Everything # the stack derives (the bridge address, the gateway carve-out, the firewall) @@ -93,40 +95,109 @@ subnet_claimant() { [ -n "$hit" ] && printf '%s\n' "$hit" } -BOX_SUBNET="${BOX_SUBNET:-10.88.0.0/24}" -if ! valid_subnet "$BOX_SUBNET"; then - echo "ERROR: BOX_SUBNET='$BOX_SUBNET' is not a sane subnet — the stack takes a" >&2 - echo " /24 with a zero host octet, e.g. BOX_SUBNET=10.89.0.0/24" >&2 - exit 1 -fi +# The one place the stack's subnet is decided. Four deliberate cases (#80's +# fix #1, completed — the refusal shipped first, this adds the auto-pick): +# 1. explicit BOX_SUBNET — use it; a foreign claimant or a disagreeing +# bridge still REFUSES. An operator's pin is never silently overridden: +# a script that says 10.90 gets 10.90 or a loud stop, never a surprise. +# 2. no pin, boxnet exists — converge to the bridge's own subnet: the +# bridge IS the pin (boxes hold leases on it; setup-host never +# re-addresses it). What used to be an agree-gate refusal on a bare +# re-run against a moved bridge is now plain convergence. A FOREIGN +# claimant on the bridge's own subnet still refuses — that is #80's +# poisoned state, and converging would rebuild on it. +# 3. no pin, no bridge, 10.88.0.0/24 free — the default, as always. +# 4. no pin, no bridge, default claimed — the nested case (a drill or +# rehearsal inside a box): scan 10.89.0.0/24 … 10.127.0.0/24 in order, +# take the first free candidate, and say so loudly; refuse only when +# EVERY candidate is claimed. The scan only ever runs bridge-less — +# an existing bridge is case 2, which precedes it. +# Prints the chosen subnet on stdout, explains itself on stderr, fails when +# it refuses. Everything downstream (BOX_GW, the bridge, the ACL carve-out, +# the firewall, the doctor's expectations) derives from the choice, which is +# why it happens here, before any of them. Pure over `ip` (via +# subnet_claimant and the bridge read), so test/cli.sh drives every case +# against canned tables with a shim ip. +choose_subnet() { + local pin="$1" have_gw have_sub hit cand b + # ('|| true': under pipefail, `ip … dev boxnet` on a fresh host — no such + # device — would kill the script here instead of answering "no bridge".) + have_gw="$(ip -4 -o addr show dev boxnet 2>/dev/null | awk '{ split($4, a, "/"); print a[1]; exit }' || true)" + have_sub="${have_gw:+${have_gw%.*}.0/24}" + + if [ -n "$pin" ]; then + if ! valid_subnet "$pin"; then + echo "ERROR: BOX_SUBNET='$pin' is not a sane subnet — the stack takes a" >&2 + echo " /24 with a zero host octet, e.g. BOX_SUBNET=10.89.0.0/24" >&2 + return 1 + fi + if hit="$(subnet_claimant "$pin")"; then + echo "ERROR: refusing to build boxnet on $pin — that subnet is already" >&2 + echo " claimed here by $hit." >&2 + echo " If that is this machine's uplink, you are INSIDE a box: a nested" >&2 + echo " stack on the guest's own subnet captures its gateway address and" >&2 + echo " blackholes its egress, intermittently (issue #80)." >&2 + echo " Nothing was changed. Drop the pin to let setup-host auto-pick a" >&2 + echo " free subnet, or pick one yourself: BOX_SUBNET= box setup-host" >&2 + return 1 + fi + if [ -n "$have_sub" ] && [ "$have_sub" != "$pin" ]; then + echo "ERROR: boxnet already exists on $have_sub and the target is $pin —" >&2 + echo " setup-host converges an existing bridge, it never re-addresses one." >&2 + echo " Re-run with the bridge's own subnet (a bare 'box setup-host'" >&2 + echo " converges on it automatically):" >&2 + echo " BOX_SUBNET=$have_sub box setup-host" >&2 + echo " (or move the bridge first: incus network set boxnet ipv4.address ${pin%.0/24}.1/24)" >&2 + return 1 + fi + printf '%s\n' "$pin" + return 0 + fi + + if [ -n "$have_sub" ]; then + if hit="$(subnet_claimant "$have_sub")"; then + echo "ERROR: boxnet lives on $have_sub, but that subnet is ALSO claimed here" >&2 + echo " by $hit — the #80 poisoned state. Converging would rebuild on it." >&2 + echo " Move the bridge off the claimed subnet first:" >&2 + echo " incus network set boxnet ipv4.address 10.89.0.1/24" >&2 + echo " then re-run: box setup-host" >&2 + return 1 + fi + if [ "$have_sub" != 10.88.0.0/24 ]; then + echo "boxnet already lives on $have_sub — converging to it." >&2 + echo "(pin it explicitly with BOX_SUBNET=$have_sub if you script this host)" >&2 + fi + printf '%s\n' "$have_sub" + return 0 + fi + + if ! hit="$(subnet_claimant 10.88.0.0/24)"; then + printf '10.88.0.0/24\n' + return 0 + fi + for b in {89..127}; do + cand="10.$b.0.0/24" + subnet_claimant "$cand" >/dev/null && continue + echo "10.88.0.0/24 is claimed here by $hit —" >&2 + echo "most likely this machine IS a box (a nested drill or rehearsal, issue #80)." >&2 + echo "auto-picked $cand for this stack instead." >&2 + echo "(pin it explicitly with BOX_SUBNET=$cand if you script this host)" >&2 + printf '%s\n' "$cand" + return 0 + done + echo "ERROR: refusing to build boxnet — 10.88.0.0/24 is already claimed here by" >&2 + echo " $hit, and so is every candidate through 10.127.0.0/24." >&2 + echo " If that first claimant is this machine's uplink, you are INSIDE a" >&2 + echo " box: a nested stack on the guest's own subnet captures its gateway" >&2 + echo " address and blackholes its egress, intermittently (issue #80)." >&2 + echo " Nothing was changed. Pick a free subnet yourself:" >&2 + echo " BOX_SUBNET= box setup-host" >&2 + return 1 +} + +BOX_SUBNET="$(choose_subnet "${BOX_SUBNET:-}")" || exit 1 BOX_GW="${BOX_SUBNET%.0/24}.1" -if hit="$(subnet_claimant "$BOX_SUBNET")"; then - echo "ERROR: refusing to build boxnet on $BOX_SUBNET — that subnet is already" >&2 - echo " claimed here by $hit." >&2 - echo " If that is this machine's uplink, you are INSIDE a box: a nested" >&2 - echo " stack on the guest's own subnet captures its gateway address and" >&2 - echo " blackholes its egress, intermittently (issue #80)." >&2 - echo " Nothing was changed. To build a nested stack anyway, pick a free" >&2 - echo " subnet: BOX_SUBNET=10.89.0.0/24 box setup-host" >&2 - exit 1 -fi - -# A bridge this script built before is the one claimant that is NOT a -# collision — but it must AGREE with the target: setup-host converges an -# existing bridge, it never re-addresses one (boxes hold leases on it). -# ('|| true': under pipefail, `ip … dev boxnet` on a fresh host — no such -# device — would kill the script right here instead of answering "no bridge".) -have_gw="$(ip -4 -o addr show dev boxnet 2>/dev/null | awk '{ split($4, a, "/"); print a[1]; exit }' || true)" -if [ -n "$have_gw" ] && [ "$have_gw" != "$BOX_GW" ]; then - echo "ERROR: boxnet already exists on ${have_gw%.*}.0/24 and the target is $BOX_SUBNET —" >&2 - echo " setup-host converges an existing bridge, it never re-addresses one." >&2 - echo " Re-run with the bridge's own subnet:" >&2 - echo " BOX_SUBNET=${have_gw%.*}.0/24 box setup-host" >&2 - echo " (or move the bridge first: incus network set boxnet ipv4.address $BOX_GW/24)" >&2 - exit 1 -fi - # apt, unattended-safe. install.sh now runs us without a human watching, and # a fresh cloud image has apt-daily/unattended-upgrades holding the dpkg lock # for the first minutes of its life — plain 'apt-get install' then waits on it @@ -229,8 +300,9 @@ fi # Isolated NAT network. IPv6 off: one less egress path to reason about. # The default is 10.88 — not 10.87: a pre-rename host may still carry # claudenet on 10.87 with legacy boxes attached — two bridges must not claim -# one subnet. BOX_SUBNET (validated and cleared by the #80 guard above) picks -# another /24; the gateway and every rule below derive from it. +# one subnet. BOX_SUBNET holds whatever choose_subnet decided above (an +# explicit pin, the existing bridge, the default, or an auto-picked free +# /24); the gateway and every rule below derive from it. incus network show boxnet >/dev/null 2>&1 || incus network create boxnet \ ipv4.address="$BOX_GW/24" ipv4.nat=true ipv6.address=none -- 2.45.2 From 5d893ba2e161cb6d3e792814cd7e645c2c1de174 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 21:20:51 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(cli):=20drive=20the=20four-case=20subn?= =?UTF-8?q?et=20decision=20=E2=80=94=20pin,=20converge,=20default,=20scan?= =?UTF-8?q?=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit choose_subnet is extracted with its helpers (the same awk seam as subnet_claimant) and driven case by case against canned tables under the shim ip: every pinned refusal unchanged (gateway-claimed, foreign interface, disagreeing bridge, garbage — and a clearing pin used verbatim, silently); bridge-present convergence to the bridge's own subnet with no scan (case 2 precedes case 4) and no announcement on the plain default re-run; the poisoned state (bridge AND uplink on one subnet) refusing rather than converging; a free default staying 10.88; the nested tables auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40 candidates claimed falling back to the old refusal, naming the end of the scan range and BOX_SUBNET. The driven whole-script fixtures move with the semantics: the refusal paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run is no longer a refusal — it is the auto-pick, proven end to end: Host ready, the announcement, and the bridge + ACL carve-out following the pick to 10.89). The decision-precedes-mutation line-order check now pins the choose_subnet call site. 339 → 370 checks. Co-Authored-By: Claude Fable 5 --- test/cli.sh | 132 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 119 insertions(+), 13 deletions(-) diff --git a/test/cli.sh b/test/cli.sh index 0c681ae..a287993 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -677,6 +677,101 @@ check "claimant: 10.8.0.0/24 does not prefix-match 10.88.x (the dot terminates)" 1 "" claim 10.8.0.0/24 "$D_INBOX" "$A_GUEST" rm -f "$CLMFN" +# --- choose_subnet: the four-case decision, driven case by case ------------- +# 1 explicit pin: honored or refused, never overridden. 2 no pin + bridge: +# converge to the bridge (the bridge IS the pin) — the scan never runs with a +# bridge present. 3 no pin, no bridge, default free: default. 4 default +# claimed: scan 10.89…10.127, first free wins, loudly; refuse when all claimed. +PICKFN="$(mktemp)" +awk '/^(valid_subnet|subnet_claimant|choose_subnet)\(\) \{/,/^\}/' \ + "$ROOT/host/setup-host.sh" > "$PICKFN" +check "choose_subnet: extracted with its helpers (guards the awk)" 0 "auto-picked" cat "$PICKFN" +check "choose_subnet: subnet_claimant came along" 0 "DEFAULT GATEWAY" cat "$PICKFN" +check "choose_subnet: the extracted functions are valid bash" 0 "" bash -n "$PICKFN" +pick() { # pick [boxnet-addr] + FAKE_IP4_DEFAULT="$2" FAKE_IP4_ADDRS="$3" FAKE_IP4_BOXNET="${4:-}" PATH="$SHIMDIR:$PATH" \ + bash -c ". '$PICKFN'; choose_subnet \"\$1\"" _ "$1" +} +pickout() { pick "$@" 2>/dev/null; } # stdout only: the choice itself +pickquiet() { [ -z "$(pick "$@" 2>&1 >/dev/null)" ]; } # stderr must be EMPTY +picknoscan(){ ! pick "$@" 2>&1 | grep -qF auto-picked; } + +# The bridge lines and the both-claimed / all-claimed address tables. +B_88='5: boxnet inet 10.88.0.1/24 scope global boxnet' +B_89='5: boxnet inet 10.89.0.1/24 scope global boxnet' +A_TWOCLAIM="$A_GUEST +3: virbr7 inet 10.89.0.7/24 brd 10.89.0.255 scope global virbr7" +A_ALLCLAIM="$(for b in $(seq 88 127); do + printf '%d: virbr%d inet 10.%d.0.7/24 brd 10.%d.0.255 scope global virbr%d\n' \ + "$((b - 85))" "$((b - 87))" "$b" "$b" "$((b - 87))" +done)" + +# Case 1 — the pin. Refusals identical in spirit to the pre-autopick gate. +check "pick: pinned + gw-in-subnet REFUSES, names issue #80" \ + 1 "issue #80" pick 10.88.0.0/24 "$D_INBOX" "$A_GUEST" +check "pick: pinned + foreign interface REFUSES, names it" \ + 1 "virbr7" pick 10.88.0.0/24 "$D_LAN" "$A_FOREIGN" +check "pick: a pinned refusal still names BOX_SUBNET" \ + 1 "BOX_SUBNET" pick 10.88.0.0/24 "$D_INBOX" "$A_GUEST" +check "pick: pinned against a disagreeing bridge REFUSES (never re-addresses)" \ + 1 "never re-addresses" pick 10.88.0.0/24 "$D_LAN" "$A_HOSTSTACK" "$B_89" +check "pick: a garbage pin is refused by name" \ + 1 "not a sane subnet" pick banana "$D_LAN" "$A_HOSTSTACK" +check "pick: a pin that clears the gate is used verbatim" \ + 0 "10.89.0.0/24" pickout 10.89.0.0/24 "$D_INBOX" "$A_GUEST" +check "pick: ...silently — a pin is the operator talking, not us" \ + 0 "" pickquiet 10.89.0.0/24 "$D_INBOX" "$A_GUEST" + +# Case 2 — no pin, a bridge: converge to ITS subnet. No refusal, no scan — +# even when the default is claimed (THIS machine: nested stack, uplink on +# 10.88, bridge remapped to 10.89 — the #80 workaround host, bare re-run). +check "pick: bridge present converges to the bridge's own subnet" \ + 0 "10.89.0.0/24" pickout "" "$D_INBOX" "$A_GUEST +$B_89" "$B_89" +check "pick: ...announcing the convergence (an off-default bridge is worth a line)" \ + 0 "converging" pick "" "$D_INBOX" "$A_GUEST +$B_89" "$B_89" +check "pick: ...and the scan never ran (case 2 precedes case 4)" \ + 0 "" picknoscan "" "$D_INBOX" "$A_GUEST +$B_89" "$B_89" +check "pick: bridge on the DEFAULT subnet converges silently (plain re-run)" \ + 0 "" pickquiet "" "$D_LAN" "$A_HOSTSTACK" "$B_88" +check "pick: ...to the default" \ + 0 "10.88.0.0/24" pickout "" "$D_LAN" "$A_HOSTSTACK" "$B_88" +# The poisoned state (#80 verbatim: bridge AND uplink both on 10.88) must not +# converge — rebuilding there re-arms the blackouts. Refuse, name the fix. +check "pick: a bridge on a FOREIGN-claimed subnet refuses (the poisoned state)" \ + 1 "poisoned" pick "" "$D_INBOX" "$A_GUEST +$B_88" "$B_88" +check "pick: ...naming the bridge move as the fix" \ + 1 "ipv4.address" pick "" "$D_INBOX" "$A_GUEST +$B_88" "$B_88" + +# Case 3 — no pin, no bridge, default free: the default, silently. +check "pick: a free default host gets 10.88.0.0/24" \ + 0 "10.88.0.0/24" pickout "" "$D_LAN" "" +check "pick: ...with no announcement" 0 "" pickquiet "" "$D_LAN" "" + +# Case 4 — no pin, no bridge, default claimed: the nested case. First free +# candidate wins, the announcement names the claimant and the pin. +check "pick: default claimed by the gateway auto-picks 10.89.0.0/24" \ + 0 "10.89.0.0/24" pickout "" "$D_INBOX" "$A_GUEST" +check "pick: ...saying so loudly" \ + 0 "auto-picked 10.89.0.0/24" pick "" "$D_INBOX" "$A_GUEST" +check "pick: ...naming WHY (the machine's own gateway = inside a box)" \ + 0 "DEFAULT GATEWAY" pick "" "$D_INBOX" "$A_GUEST" +check "pick: ...and how to pin it for scripts" \ + 0 "BOX_SUBNET=10.89.0.0/24" pick "" "$D_INBOX" "$A_GUEST" +check "pick: default AND 10.89 claimed skips to 10.90.0.0/24" \ + 0 "10.90.0.0/24" pickout "" "$D_INBOX" "$A_TWOCLAIM" +check "pick: every candidate claimed → the old refusal" \ + 1 "refusing to build boxnet" pick "" "$D_LAN" "$A_ALLCLAIM" +check "pick: ...naming the end of the scan range" \ + 1 "10.127.0.0/24" pick "" "$D_LAN" "$A_ALLCLAIM" +check "pick: ...and BOX_SUBNET as the way out" \ + 1 "BOX_SUBNET" pick "" "$D_LAN" "$A_ALLCLAIM" +rm -f "$PICKFN" + # --- the whole script, driven: refuse-before-mutation, converge, plumb-through SETUPSHIM="$(mktemp -d)" cat > "$SETUPSHIM/incus" <<'SHIM' @@ -712,19 +807,21 @@ runsetup() { # runsetup [VAR=val ...] — the real setup-host, under shims } W80="$(mktemp -d)" -# Refusal 1: the default gateway sits inside the target — the inside of a box. -check "setup-host: gw-in-subnet REFUSES and names issue #80" 1 "issue #80" \ - runsetup FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \ +# Refusal 1: an EXPLICIT pin on the subnet the default gateway sits inside — +# the inside of a box, and the operator said 10.88 out loud. A pin is never +# silently overridden, so this refuses exactly as it did pre-autopick. +check "setup-host: a pinned gw-claimed subnet REFUSES and names issue #80" 1 "issue #80" \ + runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \ FAKE_INCUS_LOG="$W80/g1.log" FAKE_SUDO_LOG="$W80/s1.log" check "setup-host: ...naming BOX_SUBNET as the way out" 1 "BOX_SUBNET" \ - runsetup FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" + runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" check "setup-host: the refusal made NO incus call (refuse precedes mutation)" 1 "" \ test -e "$W80/g1.log" check "setup-host: the refusal made NO sudo call either" 1 "" \ test -e "$W80/s1.log" -# Refusal 2: a foreign interface owns an address inside the target. -check "setup-host: a foreign interface in the subnet REFUSES" 1 "virbr7" \ - runsetup FAKE_IP4_DEFAULT="$D_LAN" FAKE_IP4_ADDRS="$A_FOREIGN" +# Refusal 2: a pin on a subnet a foreign interface owns an address inside. +check "setup-host: a pinned foreign-claimed subnet REFUSES" 1 "virbr7" \ + runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_LAN" FAKE_IP4_ADDRS="$A_FOREIGN" # Refusal 3: garbage BOX_SUBNET dies at the gate. check "setup-host: a garbage BOX_SUBNET is refused by name" 1 "not a sane subnet" \ runsetup BOX_SUBNET=banana @@ -751,16 +848,25 @@ check "setup-host: ...the bridge derives from BOX_SUBNET" 0 "" \ grep -qF 'network create boxnet ipv4.address=10.89.0.1/24' "$W80/g2.log" check "setup-host: ...and so does the ACL's gateway carve-out" 0 "" \ grep -qF 'destination: 10.89.0.1/32' "$W80/g2.log" -# ...which also proves the guard scans the TARGET subnet: the same tables that -# refused the default (gw 10.88.0.1) pass once BOX_SUBNET moves off it — the -# issue's workaround host, sanctioned. +# The nested case with ZERO flags — #80's tables, no pin, no bridge: the +# auto-pick must land the whole build on 10.89, announced, and every derived +# value must follow the pick, not the default. +check "setup-host: nested with no flags auto-picks and completes" 0 "Host ready" \ + runsetup FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \ + FAKE_INCUS_LOG="$W80/g3.log" FAKE_SUDO_LOG="$W80/s3.log" +check "setup-host: ...announcing the auto-pick" 0 "auto-picked 10.89.0.0/24" \ + runsetup FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" +check "setup-host: ...the bridge follows the pick" 0 "" \ + grep -qF 'network create boxnet ipv4.address=10.89.0.1/24' "$W80/g3.log" +check "setup-host: ...the ACL carve-out follows the pick" 0 "" \ + grep -qF 'destination: 10.89.0.1/32' "$W80/g3.log" rm -rf "$W80" "$SETUPSHIM" -# The guard must be the FIRST effective act — before the incus install, the +# The decision must be the FIRST effective act — before the incus install, the # usermod, every apt call. Line order, fail-closed on either grep missing. # shellcheck disable=SC2016 # the $-strings are literals in the target file -check "setup-host: the subnet guard precedes the first mutation" 0 "" bash -c ' - guard="$(grep -n "subnet_claimant \"\$BOX_SUBNET\"" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)" +check "setup-host: the subnet decision precedes the first mutation" 0 "" bash -c ' + guard="$(grep -n "^BOX_SUBNET=\"\$(choose_subnet " "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)" mut="$(grep -n "^if ! command -v incus" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)" [ -n "$guard" ] && [ -n "$mut" ] && [ "$guard" -lt "$mut" ]' # box-firewall follows the bridge, wherever BOX_SUBNET put it. -- 2.45.2 From ff94af93c3fcaefdc81daf98f65e59d5038c1c4e Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 21:20:51 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20the=20subnet=20auto-pick=20?= =?UTF-8?q?=E2=80=94=20help,=20README,=20changelog,=20doctor=20wording=20(?= =?UTF-8?q?#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit help setup-host and the README now lead with what a bare run does (free default → 10.88; existing bridge → converge; claimed default → auto-pick 10.89…10.127, announced) and demote BOX_SUBNET to what it is: the pin for scripted hosts, honored or refused, never overridden. The changelog names the drill/rehearsal payoff — nested box-in-box with zero flags. The doctor's this-machine #80 verdict stops saying "setup-host now refuses this" (it no longer does, it picks around it): a poisoned stack predates the fix or was pinned onto the uplink. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 30 +++++++++++++++++------------- bin/box | 23 ++++++++++++++--------- drill/doctor.sh | 3 ++- 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b118f..e66176c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ which records not just what changed but what each drill run proved. ### Added +- **`setup-host` auto-picks a free subnet — nested box-in-box with zero + flags** (#80, completing its fix #1: "refuse … or automatically select a + non-colliding subnet"). A bare `box setup-host` now decides the subnet + itself, in four deliberate cases: an explicit `BOX_SUBNET` is honored or + refused, never silently overridden (scripted hosts keep exact semantics); + an existing `boxnet` bridge is converged on as-is — the bridge IS the pin — + turning the old bare-re-run agree-gate refusal into plain convergence + (unless a foreigner *also* claims the bridge's subnet: that is #80's + poisoned state, and converging would rebuild on it, so it still refuses and + names the bridge move); a free `10.88.0.0/24` stays the default; and a + *claimed* default — the nested case: a drill or rehearsal running inside a + box, whose own uplink owns 10.88 — scans `10.89.0.0/24` … `10.127.0.0/24` + in order, takes the first free candidate, announces the pick and the + claimant loudly, and only refuses when every candidate is claimed. The + decision happens before any mutation, and everything downstream (the + bridge, `BOX_GW`, the ACL's gateway carve-out, the firewall, the doctor's + expectations) derives from it. - **`setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another** (#80) — run inside a box, `setup-host` used to build a nested `boxnet` on the exact subnet and gateway of the guest's own uplink: the guest then held diff --git a/README.md b/README.md index 8ef93b6..f0d82f3 100644 --- a/README.md +++ b/README.md @@ -129,19 +129,23 @@ re-apply at boot via `box-firewall.service` — no post-reboot ritual. If the host lacks `dnsmasq-base` (Debian cloud images skip Recommends): `sudo apt-get install -y dnsmasq-base`. -The stack's subnet is `10.88.0.0/24` by default; `BOX_SUBNET` picks another -`/24` (`BOX_SUBNET=10.89.0.0/24 box setup-host` — the bridge address, the -ACL's gateway carve-out and the firewall all derive from it). setup-host -**refuses to build on a subnet something already claims** — most tellingly -when this machine's own default gateway sits inside it, which means it is -being run *inside a box*: a nested `boxnet` on the guest's own uplink subnet -captures its gateway address and blackholes the guest's egress in -intermittent, maddening-to-attribute blackouts -([#80](https://github.com/heavy-duty/box/issues/80)). `BOX_SUBNET` is the -sanctioned way out for a nested or otherwise-conflicted install, and -`box doctor` recognizes the poisoned state (a gateway held as a local -address, duplicate uplink routes) on the machine it runs on and inside every -box it probes. +The stack's subnet is `10.88.0.0/24` when free. setup-host **never builds on +a subnet something else already claims** — most tellingly when this machine's +own default gateway sits inside it, which means it is being run *inside a +box*: a nested `boxnet` on the guest's own uplink subnet captures its gateway +address and blackholes the guest's egress in intermittent, +maddening-to-attribute blackouts +([#80](https://github.com/heavy-duty/box/issues/80)). Instead of refusing, a +bare `box setup-host` decides for itself: an existing `boxnet` bridge is +converged on as-is (the bridge is the pin — it is never re-addressed), and a +claimed default triggers an auto-pick of the first free `/24` from +`10.89.0.0/24` through `10.127.0.0/24`, announced loudly — so drills and +rehearsals *inside a box* work with zero flags. `BOX_SUBNET=` +pins the subnet explicitly for scripted hosts (the bridge address, the ACL's +gateway carve-out and the firewall all derive from it); a pin is honored or +refused, never silently overridden. `box doctor` recognizes the poisoned +state (a gateway held as a local address, duplicate uplink routes) on the +machine it runs on and inside every box it probes. A host still carrying the pre-0.4.0 stack: `box migrate-host --all-boxes` re-homes each legacy box onto `boxnet` (authed state preserved), and diff --git a/bin/box b/bin/box index 2618795..a669144 100755 --- a/bin/box +++ b/bin/box @@ -502,16 +502,21 @@ install.sh runs it for you, so this is for re-applying by hand. One run is enough. If it has to add you to the incus-admin group it re-runs itself under that group — no re-login, no second invocation. -The stack's subnet is 10.88.0.0/24 by default; BOX_SUBNET picks another /24 -(the bridge, the gateway carve-out and the firewall all derive from it). It -REFUSES, before touching anything, where the target subnet is already claimed -— most tellingly when this machine's own default gateway sits inside it, -i.e. when you are running setup-host INSIDE a box: a nested stack on the -guest's own uplink subnet captures its gateway address and blackholes its -egress, intermittently (issue #80). The sanctioned way to nest: +The stack's subnet: 10.88.0.0/24 when free; with an existing boxnet it +converges on the bridge's own subnet; and when the default is claimed by +something else — most tellingly this machine's own default gateway, i.e. +setup-host running INSIDE a box — it auto-picks the first free /24 from +10.89.0.0/24 through 10.127.0.0/24 and says so. A nested stack on the +guest's own uplink subnet would capture its gateway address and blackhole +its egress, intermittently (issue #80); the auto-pick is why a drill or +rehearsal inside a box now works with zero flags. BOX_SUBNET pins the +subnet explicitly (the bridge, the gateway carve-out and the firewall all +derive from it) — a pin is never overridden: setup-host REFUSES, before +touching anything, when the pinned subnet is claimed by a foreigner or +disagrees with an existing bridge. - box setup-host - BOX_SUBNET=10.89.0.0/24 box setup-host # nested, or a conflicted host + box setup-host # picks/converges by itself + BOX_SUBNET=10.90.0.0/24 box setup-host # scripted hosts: pin it Multi-user hosts: setup-host builds the stack once, for everyone. An admin then hands individual users the restricted tier with 'box grant ' — diff --git a/drill/doctor.sh b/drill/doctor.sh index 6a33185..982443d 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -129,7 +129,8 @@ if [ -n "$sig" ]; then while IFS= read -r line; do no "$line"; done <<<"$sig" inf "a box stack was built on a machine whose uplink already owns its subnet —" inf "run inside a box, that is issue #80: egress blacks out intermittently while" - inf "everything looks healthy. setup-host now refuses this; this machine already has it." + inf "everything looks healthy. setup-host now auto-picks a free subnet for the" + inf "nested case, so this stack predates the fix (or was pinned onto the uplink)." inf "fix: move the nested bridge off the uplink's subnet:" inf " sudo incus network set boxnet ipv4.address 10.89.0.1/24" inf " (or remove the nested stack: box teardown-host)" -- 2.45.2