2026-08-22 16:26:36 +01:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Apply -- or drift-check -- the labsim routing config on all four VMs.
|
|
|
|
|
#
|
|
|
|
|
# ./sim-net-apply.sh check what the VMs run vs what sim-net-config.py says
|
|
|
|
|
# ./sim-net-apply.sh apply push the generated config over the serial console
|
|
|
|
|
#
|
|
|
|
|
# `check` is the one you want most of the time. The whole failure mode this
|
|
|
|
|
# guards against is somebody (including me) fixing something on a VM over SSH
|
|
|
|
|
# and never writing it down, so the next rebuild silently loses it.
|
|
|
|
|
#
|
|
|
|
|
# Applied over the serial console rather than SSH because a freshly installed
|
|
|
|
|
# sim router holds the same addresses as its peer -- there is a window where it
|
|
|
|
|
# is not safely reachable over the network at all. See console-apply.py.
|
|
|
|
|
set -uo pipefail
|
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
|
ACTION="${1:-check}"
|
|
|
|
|
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
|
|
|
|
|
|
|
|
|
|
# role : vm : address : regex selecting the subtrees this generator owns
|
vyos: move PPPoE off the config plane onto a gated systemd unit
PPPoE HA could not work as written, and the reason is structural rather than a
bug: `set interfaces pppoe pppoe0 disable` and `delete` are handled identically
by interfaces_pppoe.py -- both UNLINK /etc/ppp/peers/pppoe0. That path is pppd's
own options file, so the resting state destroyed exactly what the promotion path
needed, and `ppp@pppoe0` restart-looped against it (observed: 47 restarts, zero
sessions at the access concentrator). It also made op-mode `connect interface
pppoe0` unusable, and put every failover behind a priority-322 commit where one
unrelated invalid node fails the whole thing -- which has already taken the
10 gig down once.
pppoe0 is now configured identically and ENABLED on both routers, so the peers
file always exists, and dialling is gated by a drop-in on the unit:
ConditionPathExists=/run/vrrp-wan/may-dial
ConditionPathExists=/etc/ppp/peers/pppoe0
/run is tmpfs, so the gate is shut at boot and neither box can dial before VRRP
has decided. That matters more than it first appears: with the node enabled,
interfaces_pppoe.py restarts ppp on EVERY commit touching the pppoe subtree when
the daemon is not running -- so the backup actively tries to dial whenever
anything commits. The gate is the only thing making that a no-op, which is why
vrrp-wan-reconcile now refuses to bless a box whose drop-in is missing: /etc is
per-image, and a VyOS upgrade would otherwise silently remove the protection.
may-dial is a LEASE, not a flag. ConditionPathExists is evaluated at start only
-- it can prevent a dial, never revoke one -- so a reconciler that stops running
while its box is demoted would keep the one ISP session for ever. The reconciler
renews the lease; a new 5s vrrp-wan-guard revokes it, and only ever revokes. It
fired correctly first time: "GUARD: lease stale (81s > 75s)".
Also: remove-then-stop on release (the file's absence blocks a NEW start that a
concurrent commit would trigger); a flap damper, because two routers that both
believe they hold the VIP will both dial and each dial kills the other's session
-- against a real ISP that is how an account gets rate-limited; and a guard on
`cfg` returning empty under commit-lock contention, which had already produced
one spurious "releasing" on a box that needed nothing.
GRACE 90 -> 180. accel-ppp's dead-peer budget is lcp-echo-interval(30) x
failure(3) = 90s, so the old value sat exactly on the boundary: a hard failover
into an AC that does not replace the stale session would fail its own check,
shed the VIPs, and leave both routers in FAULT.
The sim could not have tested any of this. Both routers now get the identical
WAN -- the secondary had none "because two PPPoE clients sharing one credential
is a different failure mode than anything production has", which is backwards:
that IS production. It also left the pair incomparable, ten NAT rules against
none. Safety now comes from resting state, not asymmetry.
Three more things the sim was hiding:
- the drift check's secondary regex omitted interfaces pppoe/bonding, nat
source and protocols failover, so it reported "in sync" for a box with no
WAN at all;
- the VRRP health-check and transition-script hooks existed on both live VMs
and in NEITHER generator -- the mechanism under test was pure undetected
drift;
- labsim-vyos's only default route was the libvirt-NAT scaffold, so every
"the LAN still has internet" verdict on it was answered by eth2 rather than
the WAN. --drop-scaffold applied; the earlier DHCP-failover proof is being
re-run because of it.
vrrp-wan-install ends the other half of that: the sim's previous proof came from
scripts hand-`sed`-ed in place, so the tested behaviour was not the committed
behaviour. `--check` now makes that a hard failure.
First green run: master holds both WANs, backup released, and the AC reports
exactly ONE session. sim-net-apply.sh check: all four in sync.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-05 18:50:16 +01:00
|
|
|
#
|
|
|
|
|
# primary and secondary now own the SAME subtrees. The secondary's used to omit
|
|
|
|
|
# `interfaces pppoe`, `interfaces bonding`, `nat source` and `protocols
|
|
|
|
|
# failover|static`, so `check` was blind to precisely the WAN config the
|
|
|
|
|
# failover mechanism depends on -- it reported "in sync" for a box that had no
|
|
|
|
|
# WAN at all.
|
2026-08-22 16:26:36 +01:00
|
|
|
TARGETS=(
|
labsim: default-deny firewall policy, proven in the sim
Policy: internal VLANs reach each other and the internet; the internet
initiates nothing inward. That was already the effect of the IPv4 ruleset, but
built as a blacklist -- default-action accept plus explicit drops per WAN
interface. Identical behaviour right up until a WAN is added, at which point it
is open and nothing looks wrong. This expresses it as a whitelist.
Two findings from the sim, both of which would have been outages in production:
`set` on a rule number is ADDITIVE. The sim already had a rule 10 carrying
inbound/outbound interface constraints; `set ... rule 10 state established`
ANDed onto it, producing a stateful-accept that applied to one interface pair
only. Return traffic from the internet then matched no rule and hit the default
drop, so LAN hosts could reach nothing outbound. The generator now deletes each
filter before rebuilding it, so the code owns the subtree. It is one commit, so
nftables is rebuilt atomically -- there is no window without a firewall.
DHCP lease renewal is unicast UDP to port 68 and conntrack does not reliably
cover it. Without an explicit rule the WAN keeps working until the lease
expires and then dies -- a delayed failure that looks nothing like a firewall
change. Also added a loopback accept for both families, absent from the v6
policy since it went default-deny.
Verified in labsim: inter-VLAN ok, LAN-to-internet ok, internet-to-router
dropped, and internet-to-LAN dropped with the drop counter incrementing by
exactly the packets sent, after routing the test through the router rather than
around it via the hypervisor.
Also extends the drift check to the firewall subtree, which it did not cover --
so it had been reporting "in sync" while that subtree was uncaptured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-22 22:25:55 +01:00
|
|
|
"primary:labsim-vyos:172.31.1.252:^set (protocols (bgp|failover|static)|policy (prefix-list|route-map)|nat source rule 1[12]0|interfaces (pppoe|bonding bond0 vif 5[13])|firewall (group interface-group LAN|ipv4|ipv6))"
|
vyos: move PPPoE off the config plane onto a gated systemd unit
PPPoE HA could not work as written, and the reason is structural rather than a
bug: `set interfaces pppoe pppoe0 disable` and `delete` are handled identically
by interfaces_pppoe.py -- both UNLINK /etc/ppp/peers/pppoe0. That path is pppd's
own options file, so the resting state destroyed exactly what the promotion path
needed, and `ppp@pppoe0` restart-looped against it (observed: 47 restarts, zero
sessions at the access concentrator). It also made op-mode `connect interface
pppoe0` unusable, and put every failover behind a priority-322 commit where one
unrelated invalid node fails the whole thing -- which has already taken the
10 gig down once.
pppoe0 is now configured identically and ENABLED on both routers, so the peers
file always exists, and dialling is gated by a drop-in on the unit:
ConditionPathExists=/run/vrrp-wan/may-dial
ConditionPathExists=/etc/ppp/peers/pppoe0
/run is tmpfs, so the gate is shut at boot and neither box can dial before VRRP
has decided. That matters more than it first appears: with the node enabled,
interfaces_pppoe.py restarts ppp on EVERY commit touching the pppoe subtree when
the daemon is not running -- so the backup actively tries to dial whenever
anything commits. The gate is the only thing making that a no-op, which is why
vrrp-wan-reconcile now refuses to bless a box whose drop-in is missing: /etc is
per-image, and a VyOS upgrade would otherwise silently remove the protection.
may-dial is a LEASE, not a flag. ConditionPathExists is evaluated at start only
-- it can prevent a dial, never revoke one -- so a reconciler that stops running
while its box is demoted would keep the one ISP session for ever. The reconciler
renews the lease; a new 5s vrrp-wan-guard revokes it, and only ever revokes. It
fired correctly first time: "GUARD: lease stale (81s > 75s)".
Also: remove-then-stop on release (the file's absence blocks a NEW start that a
concurrent commit would trigger); a flap damper, because two routers that both
believe they hold the VIP will both dial and each dial kills the other's session
-- against a real ISP that is how an account gets rate-limited; and a guard on
`cfg` returning empty under commit-lock contention, which had already produced
one spurious "releasing" on a box that needed nothing.
GRACE 90 -> 180. accel-ppp's dead-peer budget is lcp-echo-interval(30) x
failure(3) = 90s, so the old value sat exactly on the boundary: a hard failover
into an AC that does not replace the stale session would fail its own check,
shed the VIPs, and leave both routers in FAULT.
The sim could not have tested any of this. Both routers now get the identical
WAN -- the secondary had none "because two PPPoE clients sharing one credential
is a different failure mode than anything production has", which is backwards:
that IS production. It also left the pair incomparable, ten NAT rules against
none. Safety now comes from resting state, not asymmetry.
Three more things the sim was hiding:
- the drift check's secondary regex omitted interfaces pppoe/bonding, nat
source and protocols failover, so it reported "in sync" for a box with no
WAN at all;
- the VRRP health-check and transition-script hooks existed on both live VMs
and in NEITHER generator -- the mechanism under test was pure undetected
drift;
- labsim-vyos's only default route was the libvirt-NAT scaffold, so every
"the LAN still has internet" verdict on it was answered by eth2 rather than
the WAN. --drop-scaffold applied; the earlier DHCP-failover proof is being
re-run because of it.
vrrp-wan-install ends the other half of that: the sim's previous proof came from
scripts hand-`sed`-ed in place, so the tested behaviour was not the committed
behaviour. `--check` now makes that a hard failure.
First green run: master holds both WANs, backup released, and the AC reports
exactly ONE session. sim-net-apply.sh check: all four in sync.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-05 18:50:16 +01:00
|
|
|
"secondary:labsim-vyos2:172.31.1.253:^set (protocols (bgp|failover|static)|policy (prefix-list|route-map)|nat source rule 1[12]0|interfaces (pppoe|bonding bond0 vif 5[13])|firewall (group interface-group LAN|ipv4|ipv6))"
|
2026-08-22 16:26:36 +01:00
|
|
|
"isp-dhcp:labsim-isp-dhcp:192.168.122.136:^set (interfaces ethernet|nat source|service dhcp-server|firewall ipv4 forward|system host-name)"
|
|
|
|
|
"isp-pppoe:labsim-isp-pppoe:192.168.122.63:^set (interfaces ethernet|nat source|service pppoe-server|firewall ipv4 forward|system host-name)"
|
|
|
|
|
)
|
|
|
|
|
# Sim-only credential; these VMs hold nothing real and are not reachable from
|
|
|
|
|
# outside the hypervisor.
|
|
|
|
|
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
|
|
|
|
|
-o LogLevel=ERROR -o PreferredAuthentications=password -o ConnectTimeout=5)
|
|
|
|
|
live() { timeout 30 sshpass -p vyos ssh "${SSH_OPTS[@]}" "vyos@$1" \
|
|
|
|
|
"/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands" 2>/dev/null; }
|
vyos: move PPPoE off the config plane onto a gated systemd unit
PPPoE HA could not work as written, and the reason is structural rather than a
bug: `set interfaces pppoe pppoe0 disable` and `delete` are handled identically
by interfaces_pppoe.py -- both UNLINK /etc/ppp/peers/pppoe0. That path is pppd's
own options file, so the resting state destroyed exactly what the promotion path
needed, and `ppp@pppoe0` restart-looped against it (observed: 47 restarts, zero
sessions at the access concentrator). It also made op-mode `connect interface
pppoe0` unusable, and put every failover behind a priority-322 commit where one
unrelated invalid node fails the whole thing -- which has already taken the
10 gig down once.
pppoe0 is now configured identically and ENABLED on both routers, so the peers
file always exists, and dialling is gated by a drop-in on the unit:
ConditionPathExists=/run/vrrp-wan/may-dial
ConditionPathExists=/etc/ppp/peers/pppoe0
/run is tmpfs, so the gate is shut at boot and neither box can dial before VRRP
has decided. That matters more than it first appears: with the node enabled,
interfaces_pppoe.py restarts ppp on EVERY commit touching the pppoe subtree when
the daemon is not running -- so the backup actively tries to dial whenever
anything commits. The gate is the only thing making that a no-op, which is why
vrrp-wan-reconcile now refuses to bless a box whose drop-in is missing: /etc is
per-image, and a VyOS upgrade would otherwise silently remove the protection.
may-dial is a LEASE, not a flag. ConditionPathExists is evaluated at start only
-- it can prevent a dial, never revoke one -- so a reconciler that stops running
while its box is demoted would keep the one ISP session for ever. The reconciler
renews the lease; a new 5s vrrp-wan-guard revokes it, and only ever revokes. It
fired correctly first time: "GUARD: lease stale (81s > 75s)".
Also: remove-then-stop on release (the file's absence blocks a NEW start that a
concurrent commit would trigger); a flap damper, because two routers that both
believe they hold the VIP will both dial and each dial kills the other's session
-- against a real ISP that is how an account gets rate-limited; and a guard on
`cfg` returning empty under commit-lock contention, which had already produced
one spurious "releasing" on a box that needed nothing.
GRACE 90 -> 180. accel-ppp's dead-peer budget is lcp-echo-interval(30) x
failure(3) = 90s, so the old value sat exactly on the boundary: a hard failover
into an AC that does not replace the stale session would fail its own check,
shed the VIPs, and leave both routers in FAULT.
The sim could not have tested any of this. Both routers now get the identical
WAN -- the secondary had none "because two PPPoE clients sharing one credential
is a different failure mode than anything production has", which is backwards:
that IS production. It also left the pair incomparable, ten NAT rules against
none. Safety now comes from resting state, not asymmetry.
Three more things the sim was hiding:
- the drift check's secondary regex omitted interfaces pppoe/bonding, nat
source and protocols failover, so it reported "in sync" for a box with no
WAN at all;
- the VRRP health-check and transition-script hooks existed on both live VMs
and in NEITHER generator -- the mechanism under test was pure undetected
drift;
- labsim-vyos's only default route was the libvirt-NAT scaffold, so every
"the LAN still has internet" verdict on it was answered by eth2 rather than
the WAN. --drop-scaffold applied; the earlier DHCP-failover proof is being
re-run because of it.
vrrp-wan-install ends the other half of that: the sim's previous proof came from
scripts hand-`sed`-ed in place, so the tested behaviour was not the committed
behaviour. `--check` now makes that a hard failure.
First green run: master holds both WANs, backup released, and the AC reports
exactly ONE session. sim-net-apply.sh check: all four in sync.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-05 18:50:16 +01:00
|
|
|
# `vif 53 disable` is RUNTIME state, not config drift. The generators declare it
|
|
|
|
|
# on both routers as the safe resting state (only one box may hold the cloned
|
|
|
|
|
# MAC), and vrrp-wan-reconcile removes it on whichever box currently holds the
|
|
|
|
|
# VIP. Comparing it would therefore report drift on the master for ever, and a
|
|
|
|
|
# check that always cries wolf is a check nobody reads.
|
|
|
|
|
norm() { sed "s/'//g" | grep -v 'hw-id\|offload' \
|
|
|
|
|
| grep -v 'interfaces bonding bond0 vif 53 disable' | sort -u; }
|
2026-08-22 16:26:36 +01:00
|
|
|
|
|
|
|
|
rc=0
|
|
|
|
|
for t in "${TARGETS[@]}"; do
|
|
|
|
|
IFS=: read -r role vm addr rx <<<"$t"
|
|
|
|
|
"$HERE/sim-net-config.py" --role "$role" >"$WORK/$role.conf" 2>/dev/null || {
|
|
|
|
|
printf ' %-11s GENERATE FAILED\n' "$role"; rc=1; continue; }
|
|
|
|
|
|
|
|
|
|
if [ "$ACTION" = apply ]; then
|
|
|
|
|
printf ' %-11s applying to %s over console...\n' "$role" "$vm"
|
|
|
|
|
"$HERE/console-apply.py" --vm "$vm" --config "$WORK/$role.conf" || rc=1
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if ! live "$addr" >"$WORK/$role.live" || [ ! -s "$WORK/$role.live" ]; then
|
|
|
|
|
printf ' %-11s UNREACHABLE (%s)\n' "$role" "$addr"; rc=1; continue
|
|
|
|
|
fi
|
|
|
|
|
grep -E '^set ' "$WORK/$role.conf" | norm >"$WORK/$role.g"
|
|
|
|
|
grep -E "$rx" "$WORK/$role.live" | norm >"$WORK/$role.l"
|
|
|
|
|
if d="$(diff "$WORK/$role.g" "$WORK/$role.l")" && [ -z "$d" ]; then
|
|
|
|
|
printf ' %-11s in sync (%s commands)\n' "$role" "$(wc -l <"$WORK/$role.g")"
|
|
|
|
|
else
|
|
|
|
|
printf ' %-11s DRIFT — "<" only in code, ">" only on the VM:\n' "$role"
|
|
|
|
|
printf '%s\n' "$d" | sed 's/^/ /'
|
|
|
|
|
rc=1
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
exit $rc
|