Files
lab/migration/wan-drill
Michal 395577850c
Some checks failed
CI/CD / lint (push) Failing after 9s
CI/CD / test (push) Failing after 9s
CI/CD / typecheck (push) Failing after 26s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
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

160 lines
7.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Controlled WAN failover drill. Runs from the workstation over the LAN.
#
# It is written as ONE unattended script on purpose. The drill takes the
# household's internet down, so anything driving it step-by-step from off-site
# -- a person on a laptop, or an agent that needs the internet to think --
# stops being able to act at exactly the moment it matters. This script only
# needs the LAN, and it always runs its cleanup.
#
# ./wan-drill run it
# ./wan-drill --dry show what it would do, touch nothing
#
# Recovery if this script itself dies: see migration/RECOVERY-CARD-wan-panic.md
# or /config/RECOVERY-CARD.md on either router. Short version, on vyos002:
# sudo /config/wan-panic
set -uo pipefail
P1=10.0.1.252 # vyos001, normally MASTER
P2=10.0.1.253 # vyos002, normally BACKUP
PW=vyos
SSH=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
-o LogLevel=ERROR -o ConnectTimeout=5)
TAKEOVER_BUDGET=240 # give vyos002 this long to raise a WAN
FAILBACK_BUDGET=180 # and vyos001 this long to take it back
WATCHDOG_HOLD=150 # vyos002 stands down by itself after this with no WAN
LOG="${LOG:-/tmp/wan-drill-$(date +%H%M%S).log}"
DRY=0; [ "${1:-}" = "--dry" ] && DRY=1
say() { printf '%s %s\n' "$(date +%T)" "$*" | tee -a "$LOG"; }
r() { timeout 25 sshpass -p "$PW" ssh "${SSH[@]}" "vyos@$1" "${@:2}" 2>/dev/null; }
holder() { for h in "$P1" "$P2"; do
[ "$(r "$h" 'ip -4 -o addr show | grep -c " 192.168.1.1/"' | tr -d ' \n')" != 0 ] \
&& { echo "$h"; return; }; done; echo none; }
wan_of() { r "$1" 'for i in bond0.53 pppoe0; do a=$(ip -4 addr show dev $i 2>/dev/null | sed -n "s/.*inet \([0-9.]*\).*/\1/p"); [ -n "$a" ] && printf "%s=%s " $i $a; done'; }
online() { [ "$(r "$1" 'ping -c1 -W2 9.9.9.9 >/dev/null 2>&1 && echo y' | tr -d ' \n')" = y ]; }
# --- IPv6 -------------------------------------------------------------------
# Until 2026-09-06 this drill measured IPv4 only, and PPPOE-HA.md recorded that
# "IPv6 stayed up" through a failover on the strength of a reading taken outside
# the window. It cannot have: the reconciler disables bond0.53 on the demoted
# box, so 87.192.101.48 moves to the survivor, and inbound protocol 41 from HE
# then lands on whichever router owns the tunnel. Measure it rather than assume.
#
# Quad9 again, so the v6 result is comparable with the v4 one on the line above.
online6() { [ "$(r "$1" 'ping -6 -c1 -W2 2620:fe::fe >/dev/null 2>&1 && echo y' | tr -d ' \n')" = y ]; }
# Tunnel link state and source address. The source is the interesting half: it
# should be IDENTICAL before and after a router-level failover, because the 10
# gig lease follows the cloned MAC. A changed source means something called the
# HE API during the drill, which a router failover must never need to do.
tun_of() { r "$1" 'ip tunnel show tun0 2>/dev/null | sed -nE "s/.* local ([0-9.]+).*/\1/p"' | tr -d ' \n'; }
he_calls(){ r "$1" 'sudo journalctl -t he-tunnel-follow --since "'"$2"'" --no-pager 2>/dev/null | grep -c "HE endpoint set to"' | tr -d ' \n'; }
cleanup() {
say "--- cleanup (always runs) ---"
r "$P2" 'sudo /config/wan-drill-watchdog disarm' >/dev/null
r "$P1" 'sudo rm -f /run/vrrp-wan/force-fault' >/dev/null
r "$P2" 'sudo rm -f /run/vrrp-wan/force-fault' >/dev/null
sleep 20
say "final holder : $(holder)"
say "final WAN : $P1 [$(wan_of $P1)] $P2 [$(wan_of $P2)]"
online "$P1" && say "final internet: UP via $P1" || {
online "$P2" && say "final internet: UP via $P2" \
|| say "final internet: *** DOWN -- run: sudo /config/wan-panic on $P2 ***"; }
say "log: $LOG"
}
trap cleanup EXIT
say "=== pre-flight ==="
DRILL_START="$(date '+%Y-%m-%d %H:%M:%S')"
start_holder="$(holder)"
say "holder now : $start_holder"
say "$P1 WAN : $(wan_of $P1)"
say "$P2 WAN : $(wan_of $P2)"
online "$P1" && say "internet : UP via $P1" || { say "internet ALREADY DOWN -- refusing to drill"; exit 1; }
tun_before="$(tun_of $P1)"; tun2_before="$(tun_of $P2)"
say "$P1 tun0 src : ${tun_before:-<no tunnel>}"
say "$P2 tun0 src : ${tun2_before:-<no tunnel -- IPv6 cannot survive a failover>}"
if online6 "$P1"; then say "IPv6 : UP via $P1"
else say "IPv6 : DOWN on $P1 before we start -- v6 figures below are not meaningful"; fi
[ "$start_holder" = "$P1" ] || { say "expected $P1 to hold the VIP, got $start_holder -- refusing"; exit 1; }
if [ "$DRY" = 1 ]; then
say "--dry: would arm the watchdog on $P2 (${WATCHDOG_HOLD}s) and force-fault $P1"
trap - EXIT; exit 0
fi
say "=== arming auto-abort on $P2 ==="
r "$P2" "sudo /config/wan-drill-watchdog arm $WATCHDOG_HOLD" | tee -a "$LOG"
say "=== DRILL: force-faulting $P1 ==="
t0=$(date +%s)
r "$P1" 'sudo touch /run/vrrp-wan/force-fault'
took=""; took6=""
while [ $(( $(date +%s) - t0 )) -lt "$TAKEOVER_BUDGET" ]; do
sleep 5
h="$(holder)"; w="$(wan_of $P2)"
# v6 keeps being probed after v4 comes back, because the two recover
# independently and the gap between them IS the number this drill exists to
# produce. Stop only when both are up, or the budget runs out.
[ -z "$took6" ] && online6 "$P2" && took6=$(( $(date +%s) - t0 ))
say " t+$(( $(date +%s) - t0 ))s holder=$h vyos002_wan=[$w] v6=$([ -n "$took6" ] && echo up || echo down)"
if [ -z "$took" ] && [ "$h" = "$P2" ] && [ -n "$w" ] && online "$P2"; then
took=$(( $(date +%s) - t0 ))
fi
[ -n "$took" ] && [ -n "$took6" ] && break
done
if [ -n "$took" ]; then
say "*** TAKEOVER OK: $P2 held the VIP and reached the internet in ${took}s ***"
else
say "*** TAKEOVER FAILED within ${TAKEOVER_BUDGET}s -- failing back ***"
fi
if [ -n "$took6" ] && [ -n "$took" ]; then
say "*** IPv6 followed in ${took6}s (v4 ${took}s, gap $(( took6 - took ))s) ***"
elif [ -n "$took6" ]; then
say "*** IPv6 followed in ${took6}s, but IPv4 never did ***"
else
say "*** IPv6 did NOT return within ${TAKEOVER_BUDGET}s on $P2 -- the v6 estate is down for the whole takeover ***"
fi
say "=== failing back to $P1 ==="
t1=$(date +%s)
r "$P2" 'sudo /config/wan-drill-watchdog disarm' >/dev/null
r "$P1" 'sudo rm -f /run/vrrp-wan/force-fault'
r "$P2" 'sudo touch /run/vrrp-wan/force-fault'
back=""; back6=""
while [ $(( $(date +%s) - t1 )) -lt "$FAILBACK_BUDGET" ]; do
sleep 5
h="$(holder)"; w="$(wan_of $P1)"
[ -z "$back6" ] && online6 "$P1" && back6=$(( $(date +%s) - t1 ))
say " t+$(( $(date +%s) - t1 ))s holder=$h vyos001_wan=[$w] v6=$([ -n "$back6" ] && echo up || echo down)"
if [ -z "$back" ] && [ "$h" = "$P1" ] && [ -n "$w" ] && online "$P1"; then
back=$(( $(date +%s) - t1 ))
fi
[ -n "$back" ] && [ -n "$back6" ] && break
done
[ -n "$back" ] && say "*** FAILBACK OK in ${back}s ***" \
|| say "*** FAILBACK FAILED -- cleanup will clear both levers ***"
[ -n "$back6" ] && say "*** IPv6 back in ${back6}s ***" \
|| say "*** IPv6 did NOT return within ${FAILBACK_BUDGET}s on $P1 ***"
# The invariant a router-level failover must satisfy: the HE endpoint is never
# touched. 87.192.101.48 follows the cloned MAC to the other box, so the tunnel
# source is the same address on either router and there is nothing to tell HE.
# A non-zero count here means something re-pointed the tunnel at a PPPoE address
# -- which Vodafone re-issues on every dial, so it would be wrong within minutes.
say "=== IPv6 invariants ==="
tun_after="$(tun_of $P1)"
say "tun0 src : ${tun_before:-none} -> ${tun_after:-none}"
[ "$tun_before" = "$tun_after" ] && say " OK: tunnel source unchanged across the drill" \
|| say " *** CHANGED -- a router failover should never move the HE endpoint ***"
for h in "$P1" "$P2"; do
n="$(he_calls "$h" "$DRILL_START")"
say "HE updates from $h since ${DRILL_START}: ${n:-?}"
[ "${n:-0}" = 0 ] || say " *** $h called the HE API during a router failover -- it should not need to ***"
done