#!/bin/bash # Does the WAN follow VRRP mastership, and does exactly ONE router ever hold the # ISP session? # # The question is not "did a client get internet". A client can be answered by # the wrong path entirely -- for months labsim-vyos's only default route was the # libvirt-NAT scaffold on eth2, so every "the LAN still has internet" verdict was # answered by eth2 rather than by the WAN under test. This script therefore # refuses to run while that is true, and asks its questions of the ROUTERS and # the ACCESS CONCENTRATOR, which cannot be answered by accident. # # The invariant, checked continuously and independently of any individual test: # # the AC never reports two `simdsl` sessions, and no two routers ever have a # pppoe0 interface at the same time # # A run that violates it FAILS regardless of its own verdict, because a single # consumer credential is the whole constraint the design exists to satisfy. # # ./labsim-pppoe-ha-test.sh --list # ./labsim-pppoe-ha-test.sh T3 # ./labsim-pppoe-ha-test.sh --all set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" R1="${R1:-172.31.1.252}"; R2="${R2:-172.31.1.253}" ISP="${ISP:-192.168.122.63}" # the fake access concentrator LANVM="${LANVM:-172.31.10.10}" VIP="${VIP:-172.31.1.1}" PW="${VYOS_PW:-vyos}"; LANPW="${LANPW:-labsim}" EVID="$SCRIPT_DIR/wan-failover-evidence" SSH=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=6 -o PreferredAuthentications=password) r() { timeout 45 sshpass -p "$PW" ssh "${SSH[@]}" "vyos@$1" "${@:2}" 2>/dev/null; } isp() { timeout 30 sshpass -p "$PW" ssh "${SSH[@]}" "vyos@$ISP" "$@" 2>/dev/null; } # The LAN VMs are Alpine and their sshd offers keyboard-interactive, not # `password`. Reusing the routers' option set here made ssh exit 255 BEFORE # running anything, and T5 read that as "the LAN lost the internet" while a # tcpdump on the router showed the pings flowing out pppoe0 and the replies # coming back. An exit code that can mean "the network is broken" or "I could # not log in" is not a connectivity test. LAN_SSH=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=6) lan() { timeout 45 sshpass -p "$LANPW" ssh "${LAN_SSH[@]}" "root@$LANVM" "$@" 2>/dev/null; } # Assert on what the guest actually reported, not on ssh's exit status. lan_online() { [ "$(lan 'ping -c2 -W3 9.9.9.9 >/dev/null 2>&1 && echo ONLINE')" = ONLINE ]; } log() { printf '\033[36m==>\033[0m %s\n' "$*"; } pass() { printf ' \033[32mPASS\033[0m %s\n' "$*"; } fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAILED=$((FAILED+1)); } FAILED=0 # --- observations ---------------------------------------------------------- ac_sessions() { isp '/opt/vyatta/bin/vyatta-op-cmd-wrapper show pppoe-server sessions' \ | grep -c ' simdsl ' || true; } ac_detail() { isp '/opt/vyatta/bin/vyatta-op-cmd-wrapper show pppoe-server sessions'; } ppp_on() { r "$1" 'ip -4 addr show pppoe0 2>/dev/null | grep -c inet' | tr -d ' \n'; } holder() { for h in "$R1" "$R2"; do [ "$(r "$h" "ip -4 -o addr show | grep -c ' ${VIP}/'" | tr -d ' \n')" != 0 ] \ && { echo "$h"; return; }; done; echo none; } status() { r "$1" 'sudo /config/vrrp-wan-reconcile --status'; } # How many routers currently hold a PPPoE interface. The invariant's other half. ppp_holders() { n=0; for h in "$R1" "$R2"; do [ "$(ppp_on "$h")" != 0 ] && n=$((n+1)); done; echo "$n"; } check_invariant() { local s p ok=0 s="$(ac_sessions)"; p="$(ppp_holders)" [ "${s:-0}" -le 1 ] || { fail "INVARIANT: AC reports $s simdsl sessions"; ok=1; } [ "${p:-0}" -le 1 ] || { fail "INVARIANT: $p routers hold pppoe0"; ok=1; } return $ok } # --- preconditions --------------------------------------------------------- # The scaffold check is a hard gate, not a warning. A default route via eth2 # means the box can reach the internet without the WAN working at all, and every # connectivity verdict below would be a lie. preflight() { log "preflight" local rc=0 for h in "$R1" "$R2"; do if r "$h" 'ip route show default' | grep -q 'dev eth2'; then fail "$h still routes via eth2 (libvirt-NAT scaffold) -- run sim-net-config.py --drop-scaffold" rc=1 fi if [ "$(r "$h" '[ -f /etc/systemd/system/ppp@pppoe0.service.d/10-vrrp-wan-gate.conf ] && echo y')" != y ]; then fail "$h is missing the ppp gate drop-in -- run migration/vrrp-wan-install" rc=1 fi [ "$(r "$h" 'systemctl is-active vrrp-wan-guard.timer')" = active ] \ || { fail "$h vrrp-wan-guard.timer not active"; rc=1; } done [ "$rc" -eq 0 ] && pass "scaffold dropped, gate present, guard running on both" return $rc } settle() { # wait until exactly one router holds pppoe0, or give up local i for i in $(seq 1 "${1:-24}"); do [ "$(ppp_holders)" = 1 ] && return 0 sleep 5 done return 1 } # Wait until a SPECIFIC router holds pppoe0 and the other does not. # # The obvious `settle` is wrong for a failover: "exactly one holder" is already # true before the handover starts, so it returns instantly and the test reports # that nothing moved while the handover is still in flight. Asking who holds it # is the only useful form of the question. settle_on() { local want="$1" other i other=$([ "$want" = "$R1" ] && echo "$R2" || echo "$R1") for i in $(seq 1 "${2:-30}"); do [ "$(ppp_on "$want")" != 0 ] && [ "$(ppp_on "$other")" = 0 ] && return 0 sleep 5 done return 1 } save_evidence() { local name="$1"; local d="$EVID/$name"; mkdir -p "$d" { echo "=== $(date -Is) ==="; echo "--- AC sessions ---"; ac_detail for h in "$R1" "$R2"; do echo "--- $h ---"; status "$h" r "$h" 'ip -4 -br addr show pppoe0 2>/dev/null; ip route show default; sudo journalctl -t vrrp-wan -n 8 --no-pager' done; } > "$d/state.txt" 2>&1 log "evidence -> wan-failover-evidence/$name/" } # --- tests ----------------------------------------------------------------- T0() { # baseline log "T0 baseline: exactly one session, held by the VIP holder" local h s; h="$(holder)"; s="$(ac_sessions)" [ "$s" = 1 ] && pass "AC reports 1 session" || fail "AC reports $s sessions" [ "$(ppp_on "$h")" != 0 ] && pass "the VIP holder ($h) is the one dialled" \ || fail "VIP holder $h has no pppoe0" local other; other=$([ "$h" = "$R1" ] && echo "$R2" || echo "$R1") [ "$(ppp_on "$other")" = 0 ] && pass "the backup ($other) is not dialled" \ || fail "backup $other also holds pppoe0" save_evidence T0-baseline } T3() { # clean, deliberate failover log "T3 clean failover via force-fault" local from to t0 t1; from="$(holder)" to=$([ "$from" = "$R1" ] && echo "$R2" || echo "$R1") log " master=$from -> expecting $to" t0=$(date +%s) r "$from" 'sudo mkdir -p /run/vrrp-wan && sudo touch /run/vrrp-wan/force-fault' if settle_on "$to" 30; then t1=$(date +%s) [ "$(ppp_on "$to")" != 0 ] && pass "pppoe0 moved to $to in $((t1-t0))s" \ || fail "pppoe0 did not move to $to" [ "$(ppp_on "$from")" = 0 ] && pass "$from released pppoe0" \ || fail "$from still holds pppoe0" else fail "never settled to exactly one pppoe0 holder" fi check_invariant save_evidence T3-clean-failover r "$from" 'sudo rm -f /run/vrrp-wan/force-fault' settle 30 >/dev/null } T5() { # 10gig down -> PPPoE carries traffic log "T5 10 gig down on the master: traffic must survive on pppoe0" local h; h="$(holder)" r "$h" 'sudo ip link set bond0.53 down' sleep 20 local via; via="$(r "$h" 'ip route show default' | head -1)" if echo "$via" | grep -q pppoe0; then pass "default route moved to pppoe0: $via" else fail "default route did not move to pppoe0: ${via:-}" fi # Poll, do not sample. Judging connectivity on one ping 20s after the link # dropped failed while the path was still reconverging, and reported "the LAN # lost the internet" for a path that came back moments later. A single # negative sample is the least trustworthy verdict this harness can produce. local ok=no i for i in $(seq 1 12); do lan_online && { ok=yes; break; } sleep 5 done [ "$ok" = yes ] && pass "LAN reaches the internet over pppoe0 (after $((i*5))s)" \ || fail "LAN never regained the internet with only pppoe0 up (60s)" save_evidence T5-tengig-down r "$h" 'sudo ip link set bond0.53 up' sleep 20 } T11() { # a blessed box with no peers file must not restart-loop log "T11 missing peers file must not restart-loop" local h; h="$(holder)" r "$h" 'sudo mv /etc/ppp/peers/pppoe0 /tmp/peers.bak; sudo systemctl restart ppp@pppoe0' sleep 12 local n; n="$(r "$h" 'systemctl show ppp@pppoe0 -p NRestarts --value')" [ "${n:-99}" -le 1 ] && pass "NRestarts=$n (gate refused the start)" \ || fail "NRestarts=$n -- restart loop is back" r "$h" 'sudo mv /tmp/peers.bak /etc/ppp/peers/pppoe0' save_evidence T11-no-peers-file settle 24 >/dev/null } T8() { # lease expiry: the guard must hang up a demoted-but-unreconciled box log "T8 lease expiry revokes the session" local h; h="$(holder)" r "$h" 'sudo systemctl stop vrrp-wan-reconcile.timer' r "$h" 'sudo touch -d "-200 seconds" /run/vrrp-wan/may-dial' sleep 12 [ "$(ppp_on "$h")" = 0 ] && pass "guard hung up on a stale lease" \ || fail "stale lease did not revoke the session" r "$h" 'sudo systemctl start vrrp-wan-reconcile.timer' save_evidence T8-lease-expiry settle 24 >/dev/null } case "${1:---all}" in --list) echo "T0 baseline | T3 clean failover | T5 10gig-down | T8 lease expiry | T11 no-peers-file"; exit 0 ;; --all) preflight || exit 1; T0; T3; T5; T8; T11 ;; *) preflight || exit 1; "$1" ;; esac echo [ "$FAILED" -eq 0 ] && { echo "ALL PASS"; exit 0; } echo "$FAILED check(s) FAILED"; exit 1