Merge pull request #91 from dan-claude-bot/feat/subnet-autopick

feat(setup-host): auto-pick a free subnet — nested box-in-box with zero flags (#80)
This commit is contained in:
Daniel Marin 2026-07-18 23:15:22 +01:00 committed by GitHub
commit 88db8cab04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 278 additions and 73 deletions

View file

@ -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

View file

@ -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=<a.b.c.0/24>`
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

23
bin/box
View file

@ -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 <user>' —

View file

@ -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)"

View file

@ -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=<a.b.c.0/24> 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=<a.b.c.0/24> 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

View file

@ -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 <pin> <default-route> <addrs> [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.