Files
lab/migration/vrrp-wan-reconcile

279 lines
14 KiB
Plaintext
Raw Normal View History

vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
#!/bin/sh
# Make the WAN match VRRP mastership. Idempotent; safe to run every 30s and on
# every VRRP transition.
#
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
# Two WANs, two different control planes, for a reason:
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
#
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
# bond0.53 (10 gig, DHCP) -- CONFIG plane. Its lease is bound to a cloned MAC
# (f0:9f:c2:12:9b:4f, the old USG's), and only VyOS
# config can move a MAC. One commit per failover.
# pppoe0 (Vodafone) -- SYSTEMD plane. Gated by a drop-in on
# ppp@pppoe0; see migration/ppp-vrrp-gate.conf.
# No commit, no config lock, no `save`.
#
# PPPoE used to be on the config plane too, via `set interfaces pppoe pppoe0
# disable`. That could not work: `disable` unlinks /etc/ppp/peers/pppoe0, which
# is pppd's options file, so the promotion path deleted the very thing it needed
# and left the unit restart-looping (observed: 47 restarts, zero sessions at the
# access concentrator).
#
# Why a reconciler and not just transition scripts: VyOS delivers
# `transition-script` through keepalived-fifo.py, and on 2026-09-02 that helper
# logged NOTHING for a promotion while Keepalived_vrrp logged all six instances
# entering MASTER. A router held every VIP with no WAN -- the outage, recreated
# by the mechanism meant to prevent it. Scripts give speed; the timer gives
# correctness.
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
#
# vrrp-wan-reconcile reconcile once
# vrrp-wan-reconcile --status what it thinks, changing nothing
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
CONF=/config/vrrp-wan.conf
[ -r "$CONF" ] && . "$CONF"
VIP="${VRRP_WAN_VIP:-192.168.1.1}"
WAN_VIF="${WAN_VIF:-53}"
FLAP_MAX="${FLAP_MAX:-6}"
FLAP_WINDOW="${FLAP_WINDOW:-600}"
FLAP_HOLDOFF="${FLAP_HOLDOFF:-900}"
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
STATE=/run/vrrp-wan
LOCK=/run/vrrp-wan.lock
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
APPLY=/config/vrrp-wan-apply
DROPIN=/etc/systemd/system/ppp@pppoe0.service.d/10-vrrp-wan-gate.conf
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
V6_TUNNEL="${V6_TUNNEL:-tun0}"
RADVD_CONF="${RADVD_CONF:-/run/radvd/radvd.conf}"
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
cfg() { /opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands 2>/dev/null; }
holds_vip() { ip -4 -o addr show 2>/dev/null | grep -q " ${VIP}/"; }
wan_up() { ip -4 addr show "bond0.${WAN_VIF}" 2>/dev/null | grep -q 'inet '; }
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
ppp_up() { ip -4 addr show pppoe0 2>/dev/null | grep -q 'inet '; }
ppp_active() { systemctl is-active --quiet ppp@pppoe0 2>/dev/null; }
lease_age() { s=$(stat -c %Y "$STATE/may-dial" 2>/dev/null) || return 1
echo $(( $(date +%s) - s )); }
# NOTE: there is deliberately no ppp_disabled(). pppoe0 is now ENABLED in config
# on both routers, so such a test would be permanently false and the backup
# early-exit below would never fire -- entering config mode every 30s for ever,
# committing nothing. That exact shape was already live on the sim secondary,
# whose /tmp/vrrp-wan-commit.log read "No configuration changes to commit" while
# the script reported success.
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
wan_disabled(){ cfg | grep -q "vif ${WAN_VIF} disable"; }
if [ "${1:-}" = "--status" ]; then
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
printf 'vip=%s holds_vip=%s wan_disabled=%s wan_up=%s ppp_up=%s ppp_active=%s may_dial=%s lease_age=%s dropin=%s role=%s tun=%s radvd=%s\n' \
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
"$VIP" "$(holds_vip && echo yes || echo no)" \
"$(wan_disabled && echo yes || echo no)" \
"$(wan_up && echo yes || echo no)" \
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
"$(ppp_up && echo yes || echo no)" \
"$(ppp_active && echo yes || echo no)" \
"$([ -f "$STATE/may-dial" ] && echo yes || echo no)" \
"$(lease_age 2>/dev/null || echo -)" \
"$([ -f "$DROPIN" ] && echo yes || echo MISSING)" \
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
"$(cat "$STATE/role" 2>/dev/null || echo unset)" \
"$(ip -br link show "$V6_TUNNEL" 2>/dev/null | awk '{print $2}' || echo absent)" \
"$(systemctl is-active radvd 2>/dev/null || echo inactive)"
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
exit 0
fi
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
# One writer. The lock fd MUST be closed for children (`9>&-` on every call):
# entering VyOS config mode spawns a long-lived unionfs-fuse for the session
# which INHERITS the descriptor and never releases it, so from the first commit
# onward every later run lost the flock and exited 0 having done nothing. That
# is how a demoted router kept the WAN.
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
exec 9>"$LOCK"
flock -n 9 || exit 0
mkdir -p "$STATE"
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
# Reap config sessions whose owning process is gone. VyOS leaves a unionfs mount
# per `configure`, one of them holds the commit lock, and after that EVERY commit
# fails -- including the manual one you try in order to fix it.
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
for d in /opt/vyatta/config/tmp/new_config_*; do
[ -d "$d" ] || continue
pid=${d##*_}
kill -0 "$pid" 2>/dev/null && continue
umount -l "$d" 2>/dev/null
rm -rf "$d" 2>/dev/null
done
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
# `show configuration commands` has been observed returning EMPTY transiently
# under commit-lock contention. Every grep against it then reads false, which on
# the master path looks like "the WAN is disabled" and triggers a pointless
# commit -- one such spurious "releasing" was logged on a box where both WANs
# were already in the right state. A real config is ~500 lines; refuse to act on
# a suspiciously short one.
if [ "$(cfg | wc -l)" -lt 50 ]; then
logger -t vrrp-wan "config read returned <50 lines; skipping this tick"
exit 0
fi
# --- PPPoE: the systemd plane ---------------------------------------------
ppp_dial() {
vrrp-wan: a flap holdoff must not tear down a live WAN session ppp_dial() checked the flap holdoff and returned BEFORE renewing /run/vrrp-wan/may-dial. That lease is what vrrp-wan-guard expires after LEASE_TTL, so tripping the damper stopped the renew and the guard hung up pppoe0 on the MASTER ~80s later. A damper meant to suppress repeated DIALS was tearing down a working WAN instead. Observed in labsim, end to end: DIAL FLAP: >=6 attempts in 600s -- holding off 900s GUARD: lease stale (81s > 75s) -- hanging up pppoe0 An established session now outranks every check below it: ppp_active renews the lease and returns first. Everything after it only decides whether to start a NEW session. Two supporting fixes for how that storm started. The dial attempts were all no-ops because /etc/ppp/peers/pppoe0 was missing, and nothing said so -- systemd logs "skipped because of an unmet condition check" exactly once and the gate looks identical to a healthy backup. ppp_dial() now reports it, and distinguishes "configured but not rendered" (re-commit the subtree) from "no pppoe0 in config at all", which is what a reboot leaves behind when a commit was never saved. That is precisely how the sim secondary lost its WAN. Also `cat | wc -l` rather than `wc -l < file`: redirections are applied left to right, so the missing-file error escapes the 2>/dev/null on every first-ever dial. Harness: T11 copied-then-removed instead of mv, and verifies the restore -- losing that file strands a router permanently, which cost a debugging session. preflight now refuses to run if either router lacks the peers file or the pppoe0 config, since every failover result would otherwise be a false negative blamed on the ISP. New T12 forges a 900s holdoff against a live session and asserts it survives.
2026-09-06 00:04:41 +01:00
# An ESTABLISHED session outranks every guard below, and this must be the
# first thing here. `may-dial` is a lease the guard expires after
# LEASE_TTL, so any early `return 1` before this renew silently hands the
# guard a live session to kill.
#
# Observed in labsim: the flap damper tripped, returned early, the lease
# went stale at 81s > 75s and the guard hung up pppoe0 ON THE MASTER --
# a damper meant to suppress repeat DIALS tore down a working WAN instead.
# Everything below only decides whether to start a NEW session.
if ppp_active; then
touch "$STATE/may-dial"
return 0
fi
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
# Refuse to bless a box whose gate is missing. /etc is per-image, so a VyOS
# upgrade silently drops the drop-in -- and without it BOTH routers dial on
# the next commit that touches the pppoe subtree. Failing closed turns a
# silent loss of protection into "PPPoE never comes up", the safe direction.
if [ ! -f "$DROPIN" ]; then
logger -t vrrp-wan "REFUSING to dial: gate drop-in $DROPIN is missing (VyOS upgrade?)"
return 1
fi
vrrp-wan: a flap holdoff must not tear down a live WAN session ppp_dial() checked the flap holdoff and returned BEFORE renewing /run/vrrp-wan/may-dial. That lease is what vrrp-wan-guard expires after LEASE_TTL, so tripping the damper stopped the renew and the guard hung up pppoe0 on the MASTER ~80s later. A damper meant to suppress repeated DIALS was tearing down a working WAN instead. Observed in labsim, end to end: DIAL FLAP: >=6 attempts in 600s -- holding off 900s GUARD: lease stale (81s > 75s) -- hanging up pppoe0 An established session now outranks every check below it: ppp_active renews the lease and returns first. Everything after it only decides whether to start a NEW session. Two supporting fixes for how that storm started. The dial attempts were all no-ops because /etc/ppp/peers/pppoe0 was missing, and nothing said so -- systemd logs "skipped because of an unmet condition check" exactly once and the gate looks identical to a healthy backup. ppp_dial() now reports it, and distinguishes "configured but not rendered" (re-commit the subtree) from "no pppoe0 in config at all", which is what a reboot leaves behind when a commit was never saved. That is precisely how the sim secondary lost its WAN. Also `cat | wc -l` rather than `wc -l < file`: redirections are applied left to right, so the missing-file error escapes the 2>/dev/null on every first-ever dial. Harness: T11 copied-then-removed instead of mv, and verifies the restore -- losing that file strands a router permanently, which cost a debugging session. preflight now refuses to run if either router lacks the peers file or the pppoe0 config, since every failover result would otherwise be a false negative blamed on the ISP. New T12 forges a 900s holdoff against a live session and asserts it survives.
2026-09-06 00:04:41 +01:00
# The peers file is pppd's options file AND the gate's second condition, so
# without it this box silently never dials: systemd logs "skipped because of
# an unmet condition check" once and nothing else complains. Only a commit
# that touches the pppoe subtree re-renders it.
#
# Seen in labsim: config applied but never `save`d, the router rebooted, and
# came back with no pppoe0 node at all -- so no peers file, and a master that
# dialled every tick into silence. Say so loudly rather than looking healthy.
if [ ! -f /etc/ppp/peers/pppoe0 ]; then
if cfg | grep -q "interfaces pppoe pppoe0 source-interface"; then
logger -t vrrp-wan "CANNOT dial: pppoe0 is configured but /etc/ppp/peers/pppoe0 is missing -- re-commit the pppoe subtree to re-render it"
else
logger -t vrrp-wan "CANNOT dial: no pppoe0 in config (did a reboot revert an unsaved commit?)"
fi
return 1
fi
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
now=$(date +%s)
if [ -f "$STATE/holdoff" ] && [ "$now" -lt "$(cat "$STATE/holdoff" 2>/dev/null || echo 0)" ]; then
return 1
fi
touch "$STATE/may-dial" # renew the lease every tick
# Trim the dial log to the window, then decide.
if [ -f "$STATE/dials" ]; then
awk -v c="$((now - FLAP_WINDOW))" '$1 > c' "$STATE/dials" > "$STATE/dials.new" 2>/dev/null
mv "$STATE/dials.new" "$STATE/dials" 2>/dev/null
fi
vrrp-wan: a flap holdoff must not tear down a live WAN session ppp_dial() checked the flap holdoff and returned BEFORE renewing /run/vrrp-wan/may-dial. That lease is what vrrp-wan-guard expires after LEASE_TTL, so tripping the damper stopped the renew and the guard hung up pppoe0 on the MASTER ~80s later. A damper meant to suppress repeated DIALS was tearing down a working WAN instead. Observed in labsim, end to end: DIAL FLAP: >=6 attempts in 600s -- holding off 900s GUARD: lease stale (81s > 75s) -- hanging up pppoe0 An established session now outranks every check below it: ppp_active renews the lease and returns first. Everything after it only decides whether to start a NEW session. Two supporting fixes for how that storm started. The dial attempts were all no-ops because /etc/ppp/peers/pppoe0 was missing, and nothing said so -- systemd logs "skipped because of an unmet condition check" exactly once and the gate looks identical to a healthy backup. ppp_dial() now reports it, and distinguishes "configured but not rendered" (re-commit the subtree) from "no pppoe0 in config at all", which is what a reboot leaves behind when a commit was never saved. That is precisely how the sim secondary lost its WAN. Also `cat | wc -l` rather than `wc -l < file`: redirections are applied left to right, so the missing-file error escapes the 2>/dev/null on every first-ever dial. Harness: T11 copied-then-removed instead of mv, and verifies the restore -- losing that file strands a router permanently, which cost a debugging session. preflight now refuses to run if either router lacks the peers file or the pppoe0 config, since every failover result would otherwise be a false negative blamed on the ISP. New T12 forges a 900s holdoff against a live session and asserts it survives.
2026-09-06 00:04:41 +01:00
# `cat | wc`, not `wc -l < file`: the shell applies redirections left to
# right, so a missing file fails the `<` BEFORE `2>/dev/null` is in effect
# and dash prints "No such file or directory" on every first-ever dial.
if [ "$(cat "$STATE/dials" 2>/dev/null | wc -l)" -ge "$FLAP_MAX" ]; then
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
echo $((now + FLAP_HOLDOFF)) > "$STATE/holdoff"
logger -t vrrp-wan "DIAL FLAP: >=${FLAP_MAX} attempts in ${FLAP_WINDOW}s -- holding off ${FLAP_HOLDOFF}s"
return 1
fi
echo "$now" >> "$STATE/dials"
logger -t vrrp-wan "MASTER: dialling pppoe0"
systemctl reset-failed ppp@pppoe0 2>/dev/null
# `systemctl start` exits 0 even when a Condition blocks the start, so its
# return code proves nothing. is-active is the only honest answer.
systemctl start ppp@pppoe0 2>/dev/null
}
ppp_release() {
# Order matters: revoke the lease FIRST, then stop. The file's absence blocks
# any NEW start (including one a concurrent VyOS commit would trigger); the
# stop kills the process that already exists. Stopping first leaves a window
# in which a commit re-dials a box that is being demoted.
rm -f "$STATE/may-dial"
ppp_active || return 0
logger -t vrrp-wan "not MASTER: hanging up pppoe0"
systemctl stop ppp@pppoe0 2>/dev/null
}
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
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
# --- IPv6: the kernel plane -------------------------------------------------
# The HE 6in4 tunnel and the VLAN 9 router advertisements have to follow
# mastership too, or a failover keeps IPv4 and silently drops IPv6 -- the
# partial outage that presents as "some sites are broken".
#
# A THIRD plane, and deliberately not either of the other two. Not config,
# because nothing here needs a commit (unlike the cloned MAC) and a commit per
# transition is the cost the pppoe0 half exists to avoid. Not the systemd gate,
# because there is no equivalent of a peers file to destroy.
#
# What makes this cheap: the tunnel is anchored to 87.192.101.48, the 10 gig
# lease bound to the cloned MAC, so it follows the VIP to the other router
# UNCHANGED. A router-level failover therefore needs no HE API call at all --
# only the link brought up on the box that now owns the address.
#
# Note what is deliberately NOT done here: this does not invoke
# he-tunnel-follow. That script has its own 1-minute task-scheduler cadence and
# a 2-tick hysteresis, and on 2026-09-06 that hysteresis was the only thing that
# stopped a routine `vif53-pin-boot-disable` run from pointing HE at a PPPoE
# address -- by 22 seconds. Calling it from a 30s reconciler as well would halve
# the window it needs. Its job is the WITHIN-box fall back to PPPoE; ours is
# link state.
v6_take() {
# Absent on a router that has no tunnel in its config -- which is every
# router until the model change lands. No-op there rather than complain.
[ -e "/sys/class/net/$V6_TUNNEL" ] || return 0
# Needs SOME WAN address to source from. Either line will do: if bond0.53 is
# down but pppoe0 is up, he-tunnel-follow re-points the tunnel on its own
# schedule, and holding the link down until then would turn a degraded path
# into no path.
wan_up || ppp_up || return 0
ip link show "$V6_TUNNEL" 2>/dev/null | grep -q 'state DOWN' && {
logger -t vrrp-wan "MASTER: bringing $V6_TUNNEL up"
ip link set "$V6_TUNNEL" up 2>/dev/null
}
# radvd's config is rendered into /run by the VyOS commit, so on a box with
# no router-advert node there is nothing to start.
[ -f "$RADVD_CONF" ] || return 0
systemctl is-active --quiet radvd 2>/dev/null && return 0
logger -t vrrp-wan "MASTER: starting radvd"
systemctl start radvd 2>/dev/null
}
v6_release() {
[ -e "/sys/class/net/$V6_TUNNEL" ] || return 0
# radvd FIRST, and this ordering is the point: on a graceful stop it emits a
# final advertisement with router-lifetime 0, which is what tells VLAN 9
# hosts to stop using this box as their default router. Kill the daemon
# after tearing things down and they keep a dead gateway until the RA
# lifetime expires on its own.
if systemctl is-active --quiet radvd 2>/dev/null; then
logger -t vrrp-wan "not MASTER: stopping radvd (deprecates the v6 gateway)"
systemctl stop radvd 2>/dev/null
fi
ip link show "$V6_TUNNEL" 2>/dev/null | grep -q 'state DOWN' && return 0
logger -t vrrp-wan "not MASTER: bringing $V6_TUNNEL down"
ip link set "$V6_TUNNEL" down 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
# --- decide ----------------------------------------------------------------
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
if holds_vip; then
echo master > "$STATE/role"
[ -f "$STATE/since" ] || date +%s > "$STATE/since"
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
ppp_dial
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
# The WAN block is now an `if` rather than an early exit, so the IPv6 plane
# below is reached on EVERY tick and not only on the one that enables the
# WAN. On the promotion tick bond0.53 has no address yet, so v6_take no-ops
# and the next tick takes it.
if wan_disabled; then
logger -t vrrp-wan "MASTER with bond0.${WAN_VIF} disabled -> enabling"
t0=$(date +%s)
"$APPLY" enable 9>&-
logger -t vrrp-wan "bond0.${WAN_VIF} enable commit took $(( $(date +%s) - t0 ))s"
fi
v6_take
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
else
echo backup > "$STATE/role"
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
rm -f "$STATE/since" "$STATE/holdoff"
ppp_release
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
# Before the WAN goes, not after: once bond0.53 is disabled the source
# address is gone and radvd's farewell advertisement has no path out.
v6_release
if ! wan_disabled; then
logger -t vrrp-wan "not MASTER but bond0.${WAN_VIF} enabled -> releasing"
t0=$(date +%s)
"$APPLY" disable 9>&-
logger -t vrrp-wan "bond0.${WAN_VIF} disable commit took $(( $(date +%s) - t0 ))s"
fi
vyos: WAN follows VRRP mastership, rehearsed in labsim The ISP is consumer with one static IP, so "both routers hold WAN" is not available: the 10 gig lease is anchored to a cloned MAC and the PPPoE line to a single credential. The WAN therefore has to move with mastership. Proven end to end in the sim: the secondary was promoted, took the WAN, got a lease, installed a default route, and a LAN VM reached the internet through it (3/3, 9ms). Demoting released it -- link down, no address. Rebooting the master converged correctly too: it came back BACKUP with the WAN disabled while the peer kept it. The rehearsal earned its keep four times over, and none of these were visible from reading the docs: - `transition-script` alone is NOT safe to hang internet on. VyOS delivers it through keepalived-fifo.py, and on one promotion that helper logged NOTHING while Keepalived_vrrp logged all six instances entering MASTER and the built-in notify_master for conntrack-sync ran normally. The result was a router holding every VIP with no WAN -- the 2026-09-02 outage, recreated by the mechanism meant to prevent it. Hence vrrp-wan-reconcile on a 30s timer: the scripts give speed, the timer gives correctness. - The health check must ask REALITY, not a marker. Keying "am I master" on a /run file written by the transition script meant that when the script did not run, the router believed it was backup, passed the check, and kept the VIPs it could not serve. It now asks whether the VIP is actually on the box. - The old address-based check DEADLOCKED this design: may-I-be-master required already having WAN, and only the master gets WAN. That is why vyos002 sat in FAULT for ever -- the safety check had silently removed the redundancy it existed to protect. - script-template must be the FIRST thing a script does. Sourced after an if, an exec and a mkdir it terminated the script inside the source, rc=0, no output: the reconciler reported success having done nothing. Hence the split into vrrp-wan-apply, matching the shape /config/vyos-known-good already uses. Two hazards found and handled rather than discovered in production: - A `configure` session whose process dies leaks a unionfs mount under /opt/vyatta/config/tmp, and one of those holds the commit lock -- after which every commit fails, including the manual one you try to fix it with. A 30s job that can leak one per failure wedges the box on its own, so the reconciler reaps dead sessions before it starts. It cleared 8 on the sim. - Any `save` while a box is master persists the enabled WAN into config.boot, so a reboot would claim the shared MAC regardless of VRRP state. Observed: an ordinary console-apply did exactly this. config.boot must keep `disable` on BOTH routers; the model asserts it and vyos:verify reports it as drift. NOT yet applied to production, and it should not be until the remaining item is settled: a clean, deliberately-triggered failover has been seen via reboot, but `restart vrrp` twice failed to move mastership at all, so the trigger for a planned failover is still unproven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-09-02 18:02:58 +01:00
fi
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
# No `save`, deliberately. config.boot keeps `vif 53 disable` on BOTH routers, so
# a reboot in any order comes up unable to claim the cloned MAC. PPPoE needs no
# such convention any more: with the gate, config.boot is safe by construction
# and a stray `save` cannot make both boxes dial.