Files
lab/migration/vrrp-wan-install

116 lines
5.4 KiB
Plaintext
Raw Permalink Normal View History

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
#!/bin/bash
# Install (or verify) the WAN-follows-VRRP mechanism on a VyOS router.
#
# This exists because on 2026-09-05 the sim's failover proof was obtained from
# scripts that had been hand-`sed`-ed in place: /config/vrrp-wan-health and
# -reconcile differed from git by an edited VIP, so the tested behaviour was not
# the committed behaviour and any reinstall would have silently reverted it.
# `--check` makes that class of drift a hard failure instead of a discovery.
#
# It installs ONLY the mechanism -- scripts, units, drop-in, settings. It never
# touches VyOS configuration: the `interfaces pppoe` node, `vif 53 disable` and
# the VRRP sync-group hooks are config and belong in the config model
# (labsim/sim-*.py for the sim, infra/vyos/subtrees/overrides.json for
# production), not in an installer.
#
# vrrp-wan-install --vip 192.168.1.1 [--host vyos@10.0.1.253]
# vrrp-wan-install --check [--host ...] # exits non-zero on any drift
#
# With no --host it operates on the local machine, so it can be scp'd to a
# router and run there.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VIP=""; HOST=""; MODE=install; PW="${VYOS_PW:-vyos}"
while [ $# -gt 0 ]; do
case "$1" in
--vip) VIP="$2"; shift 2 ;;
--host) HOST="$2"; shift 2 ;;
--check) MODE=check; shift ;;
*) echo "usage: $0 [--vip A.B.C.D] [--host user@ip] [--check]" >&2; exit 2 ;;
esac
done
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
-o LogLevel=ERROR -o ConnectTimeout=8 -o PreferredAuthentications=password)
run() { # run a command on the target
if [ -n "$HOST" ]; then timeout 60 sshpass -p "$PW" ssh "${SSH_OPTS[@]}" "$HOST" "$@"
else bash -c "$*"; fi
}
put() { # copy a file to the target
if [ -n "$HOST" ]; then timeout 60 sshpass -p "$PW" scp "${SSH_OPTS[@]}" "$1" "$HOST:$2" >/dev/null
else cp "$1" "$2"; fi
}
# script -> destination. take/release are hooks keepalived calls; both exec the
# reconciler, so there is one code path.
IPv6 was never HA, and the WAN becoming HA is what exposed it Reviewed the parked IPv6 task against the PPPoE-HA work of 2026-09-05/06. The gate that parked it ("WI-8 before IPv6") is cleared, but the same work invalidated the assumption the IPv6 design rested on. Verified on the live routers: vyos002 has no tun0, no he-tunnel-follow, no he-secrets, no VLAN 9 prefix and no route6 ::/0 -- only the pre-staged default-deny v6 firewall, which is correctly on both. Failover is now automatic and drill-proven, so every failover takes the whole v6 estate down for as long as vyos002 holds the VIP. Four things that came out of checking rather than reading: - PPPOE-HA.md's "tun0 survived untouched and IPv6 stayed up at 15.5ms" does not follow from its own premise and is corrected in place. The endpoint address is stable, but it MOVES to vyos002, which has nothing to decapsulate protocol 41. wan-drill had no IPv6 check at all, which is why nobody caught it. - A 22-second near-miss: vif53-pin-boot-disable bounced the 10 gig, he-tunnel- follow ticked once and saw the PPPoE address, and vyos-failover restored the route 22s before the second tick would have pointed HE at an address Vodafone reissues on every dial. - VyOS does NOT leave a tunnel down when its source-address is absent (the override's stated reason for leaving IPv6 single-homed). Measured in labsim: it commits rc=0 and brings the link UP -- a blackhole that attracts the v6 default route. The runtime gate is load-bearing, like the PPPoE gate. - The RA link-mtu was pinned at 1480 while the tunnel correctly drops to 1472 on the PPPoE path. Mechanism, mirroring PPPoE HA -- identical config on both, gated at runtime, no commit in the failover path: - vrrp-wan-reconcile: a v6 kernel plane. tun0 and radvd follow the VIP; radvd is stopped BEFORE the WAN goes so its farewell RA (router-lifetime 0) still has a path out. The WAN early-exits became if-blocks so the plane runs every tick. It deliberately does NOT call he-tunnel-follow: that would halve the hysteresis the near-miss above showed we depend on. - he-tunnel-follow: a master guard reading the same vrrp-wan.conf VIP, so the backup copy cannot point HE at its own idle PPPoE line, plus a stubbable HE_UPDATE_URL. - vrrp-wan-install carries both, so --check and the upgrade runbook cover IPv6. - wan-drill measures IPv6 in both timing loops and asserts zero HE API calls across a router failover. labsim finally has an HE endpoint, closing the gap the override itself cited as why this was never rehearsed. Both ISP islands already share the libvirt network, so that becomes the backbone and HE lives behind it on one address reachable over either WAN. Proven in the sim: backup tun=DOWN radvd=inactive, master tun=UP radvd=active, hysteresis then HE call then MTU 1480->1472, and VLAN 9 hosts autoconfiguring from the RA. The end-to-end v6 datapath is NOT yet proven -- inter-island transit crosses libvirt NAT and the return path is lost. Recorded as a KNOWN SIM GAP rather than papered over. The model change is staged, not merged: another agent runs pulumi up on that repo, and the gate must exist on vyos002 before the tunnel does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-06 14:30:44 +01:00
#
# he-tunnel-follow is part of the mechanism, not a separate thing: the reconciler
# owns tun0's link state and that script owns its source address and MTU. Listing
# it here is what makes `--check` catch drift on it and what makes the VyOS
# image-upgrade runbook reinstall it -- IPv6 was previously the one half of the
# WAN story that no installer knew about.
SCRIPTS="vrrp-wan-reconcile vrrp-wan-apply vrrp-wan-health vrrp-wan-guard vrrp-wan-take vrrp-wan-release he-tunnel-follow"
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
UNITS="vrrp-wan-reconcile.service vrrp-wan-reconcile.timer vrrp-wan-guard.service vrrp-wan-guard.timer"
GATE_DIR=/etc/systemd/system/ppp@pppoe0.service.d
GATE=$GATE_DIR/10-vrrp-wan-gate.conf
if [ "$MODE" = check ]; then
rc=0
for f in $SCRIPTS; do
local_sum=$(md5sum "$HERE/$f" | cut -d' ' -f1)
remote_sum=$(run "md5sum /config/$f 2>/dev/null | cut -d' ' -f1")
[ "$local_sum" = "$remote_sum" ] || { echo " DRIFT /config/$f"; rc=1; }
done
for f in $UNITS; do
local_sum=$(md5sum "$HERE/$f" | cut -d' ' -f1)
remote_sum=$(run "md5sum /etc/systemd/system/$f 2>/dev/null | cut -d' ' -f1")
[ "$local_sum" = "$remote_sum" ] || { echo " DRIFT /etc/systemd/system/$f"; rc=1; }
done
gate_sum=$(md5sum "$HERE/ppp-vrrp-gate.conf" | cut -d' ' -f1)
remote_gate=$(run "md5sum $GATE 2>/dev/null | cut -d' ' -f1")
[ "$gate_sum" = "$remote_gate" ] || { echo " DRIFT $GATE (a VyOS upgrade wipes /etc -- both routers would dial)"; rc=1; }
run "[ -r /config/vrrp-wan.conf ]" || { echo " MISSING /config/vrrp-wan.conf"; rc=1; }
IPv6 was never HA, and the WAN becoming HA is what exposed it Reviewed the parked IPv6 task against the PPPoE-HA work of 2026-09-05/06. The gate that parked it ("WI-8 before IPv6") is cleared, but the same work invalidated the assumption the IPv6 design rested on. Verified on the live routers: vyos002 has no tun0, no he-tunnel-follow, no he-secrets, no VLAN 9 prefix and no route6 ::/0 -- only the pre-staged default-deny v6 firewall, which is correctly on both. Failover is now automatic and drill-proven, so every failover takes the whole v6 estate down for as long as vyos002 holds the VIP. Four things that came out of checking rather than reading: - PPPOE-HA.md's "tun0 survived untouched and IPv6 stayed up at 15.5ms" does not follow from its own premise and is corrected in place. The endpoint address is stable, but it MOVES to vyos002, which has nothing to decapsulate protocol 41. wan-drill had no IPv6 check at all, which is why nobody caught it. - A 22-second near-miss: vif53-pin-boot-disable bounced the 10 gig, he-tunnel- follow ticked once and saw the PPPoE address, and vyos-failover restored the route 22s before the second tick would have pointed HE at an address Vodafone reissues on every dial. - VyOS does NOT leave a tunnel down when its source-address is absent (the override's stated reason for leaving IPv6 single-homed). Measured in labsim: it commits rc=0 and brings the link UP -- a blackhole that attracts the v6 default route. The runtime gate is load-bearing, like the PPPoE gate. - The RA link-mtu was pinned at 1480 while the tunnel correctly drops to 1472 on the PPPoE path. Mechanism, mirroring PPPoE HA -- identical config on both, gated at runtime, no commit in the failover path: - vrrp-wan-reconcile: a v6 kernel plane. tun0 and radvd follow the VIP; radvd is stopped BEFORE the WAN goes so its farewell RA (router-lifetime 0) still has a path out. The WAN early-exits became if-blocks so the plane runs every tick. It deliberately does NOT call he-tunnel-follow: that would halve the hysteresis the near-miss above showed we depend on. - he-tunnel-follow: a master guard reading the same vrrp-wan.conf VIP, so the backup copy cannot point HE at its own idle PPPoE line, plus a stubbable HE_UPDATE_URL. - vrrp-wan-install carries both, so --check and the upgrade runbook cover IPv6. - wan-drill measures IPv6 in both timing loops and asserts zero HE API calls across a router failover. labsim finally has an HE endpoint, closing the gap the override itself cited as why this was never rehearsed. Both ISP islands already share the libvirt network, so that becomes the backbone and HE lives behind it on one address reachable over either WAN. Proven in the sim: backup tun=DOWN radvd=inactive, master tun=UP radvd=active, hysteresis then HE call then MTU 1480->1472, and VLAN 9 hosts autoconfiguring from the RA. The end-to-end v6 datapath is NOT yet proven -- inter-island transit crosses libvirt NAT and the return path is lost. Recorded as a KNOWN SIM GAP rather than papered over. The model change is staged, not merged: another agent runs pulumi up on that repo, and the gate must exist on vyos002 before the tunnel does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-06 14:30:44 +01:00
# Secrets are placed by Pulumi (infra/vyos/secretsFile.ts), never by this
# installer, so check presence only -- there is no correct content to compare
# against and printing a diff of credentials would be worse than useless.
# Without it he-tunnel-follow cannot re-point the tunnel when the WAN falls
# back to PPPoE, which fails silently: IPv4 keeps working and IPv6 goes dark.
run "[ -r /config/he-secrets ]" || { echo " MISSING /config/he-secrets (IPv6 cannot follow a WAN change)"; rc=1; }
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
for t in vrrp-wan-reconcile.timer vrrp-wan-guard.timer; do
[ "$(run "systemctl is-enabled $t 2>/dev/null")" = enabled ] || { echo " NOT ENABLED $t"; rc=1; }
done
[ "$rc" -eq 0 ] && echo " vrrp-wan in sync"
exit "$rc"
fi
[ -n "$VIP" ] || { echo "--vip is required to install" >&2; exit 2; }
for f in $SCRIPTS; do
put "$HERE/$f" "/tmp/$f"
# root:vyattacfg 0775 -- vrrp-wan-apply enters config mode, which requires
# membership of vyattacfg.
run "sudo install -o root -g vyattacfg -m 0775 /tmp/$f /config/$f"
done
# Settings, with the VIP substituted. One file, read by BOTH the reconciler and
# the health check -- keepalived invokes the latter with no environment at all,
# so an Environment= line in the unit would be read by one and not the other.
sed "s|^VRRP_WAN_VIP=.*|VRRP_WAN_VIP=${VIP}|" "$HERE/vrrp-wan.conf" > /tmp/vrrp-wan.conf.gen
put /tmp/vrrp-wan.conf.gen /tmp/vrrp-wan.conf.gen
run "sudo install -o root -g vyattacfg -m 0664 /tmp/vrrp-wan.conf.gen /config/vrrp-wan.conf"
for f in $UNITS; do
put "$HERE/$f" "/tmp/$f"
run "sudo install -m 0644 /tmp/$f /etc/systemd/system/$f"
done
put "$HERE/ppp-vrrp-gate.conf" /tmp/ppp-vrrp-gate.conf
run "sudo mkdir -p $GATE_DIR && sudo install -m 0644 /tmp/ppp-vrrp-gate.conf $GATE"
run "sudo systemctl daemon-reload && sudo systemctl enable --now vrrp-wan-reconcile.timer vrrp-wan-guard.timer" >/dev/null 2>&1
echo " installed (vip=$VIP)"
run "sudo /config/vrrp-wan-reconcile --status"