Files
lab/migration/vyos-unifi-switch
Michal ce6911c196 fix(migration): require a working WAN, not every WAN
This reverted a cutover that had actually succeeded.

The evidence, from the revert tearing it down:

  dhclient: DHCPRELEASE of 87.192.101.48 on bond0.53 to 185.232.119.244
  vtysh:    "no ip route 0.0.0.0/0 87.192.96.1 bond0.53 tag 210 1"
  netlinkd: RTM_NEWLINK -> bond0.53, mac=f0:9f:c2:12:9b:4f

bond0.53 came up with the cloned MAC and was handed 87.192.101.48 -- the exact
public address the USG holds -- with a default route via the real ISP gateway.
kea was serving live LAN clients at the same moment (10.0.0.12, 10.0.0.13,
192.168.8.28). The gateway was working.

The only failure was pppoe0: ppp@pppoe0.service exited 5/NOTINSTALLED. That is
the Vodafone FAILOVER line, and the health check listed "pppoe0 has an address"
as mandatory, so a working gateway was torn down because its backup WAN was
down. The check encoded "every WAN must work" when the requirement is "the box
must reach the internet".

Now: default route, reachability and DNS are mandatory; each WAN interface is
reported individually but fatal on neither. A failover line being down is worth
seeing, not worth reverting for.

This also incidentally settles the last genuine unknown in the migration, which
could not be tested any other way: the ISP does hand the same lease to the
cloned MAC. That was the one thing I had said was unknowable until the USG let
go of it.

Note the earlier polling fix (54b21fa) addressed a real weakness but not this
failure -- no amount of waiting would have satisfied a check that required a
line which was never going to come up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-18 01:37:14 +01:00

297 lines
12 KiB
Bash
Executable File

#!/bin/bash
# Switch this VyOS box between passive (USG is the gateway) and active
# (VyOS is the gateway). Installed at /config/vyos-unifi-switch, which
# survives image upgrades, so it can be run from a local terminal or the
# JetKVM console with no workstation and no internet.
#
# vyos-unifi-switch report the current mode
# vyos-unifi-switch vyos take over: VIPs to .1, DHCP, DNS, PPPoE, NAT
# vyos-unifi-switch unifi revert; the USG can then be reconnected
#
# READ THIS BEFORE THE CUTOVER
#
# `unifi` is the escape hatch. It runs no health checks, asks no questions and
# has nothing that can refuse. If anything at all looks wrong, run it, then
# plug the USG back in.
#
# `vyos` commits with commit-confirm. If it is not confirmed -- because the
# health checks failed, or because you lost access, or because you walked away
# -- the box returns to the saved configuration on its own. That requires
# `system config-management commit-confirm action reload`; without it VyOS
# REBOOTS instead, which on a gateway is an outage rather than an undo. The
# script refuses to run if that setting is missing.
set -uo pipefail
MODES=/config/modes
UNIFI_BOOT="$MODES/unifi.boot"
DELTA="$MODES/to-vyos.commands"
SECRETS=/config/wan-secrets
MARKER="$MODES/current-mode"
CONFIRM_MINUTES="${CONFIRM_MINUTES:-10}"
OPRUN=/opt/vyatta/bin/vyatta-op-cmd-wrapper
say() { printf '[switch] %s\n' "$*"; }
warn() { printf '[switch] WARNING: %s\n' "$*" >&2; }
die() { printf '[switch] ERROR: %s\n' "$*" >&2; exit 1; }
# Run configuration commands in a real config session. Everything the caller
# feeds in runs between `configure` and `exit`.
run_cfg() {
local script rc
script="$(mktemp)"
{
echo 'source /opt/vyatta/etc/functions/script-template'
echo 'configure'
cat
echo 'exit'
} > "$script"
vbash "$script"; rc=$?
rm -f "$script"
return $rc
}
# Two traps live in this one function, both of which produced wrong answers
# rather than errors:
# 1. `show configuration commands` quotes values ("action 'reload'"), so the
# quotes have to go before matching or every value-bearing check fails.
# 2. `... | grep -q` under `set -o pipefail` reports FAILURE even on a match:
# grep exits at the first hit, the producer takes SIGPIPE, and pipefail
# surfaces that. Whether it triggers depends on output size, so it fails
# intermittently. Match against a captured string instead of a pipeline.
cfg_has() {
local out
out="$($OPRUN show configuration commands 2>/dev/null | tr -d "'")"
case "$out" in *"$1"*) return 0 ;; *) return 1 ;; esac
}
# ---------------------------------------------------------------- status ----
current_mode() {
# The marker records intent; the running config is the truth. Report the
# config, and complain if the two disagree.
local live="unknown"
if cfg_has "set service dhcp-server"; then live="vyos"; else live="unifi"; fi
echo "$live"
}
show_status() {
local live marked
live="$(current_mode)"
marked="$(cat "$MARKER" 2>/dev/null || echo "never set")"
echo "host: $(hostname)"
echo "mode: $live (marker: $marked)"
[ "$live" != "$marked" ] && [ "$marked" != "never set" ] && \
warn "marker disagrees with the running config -- trust the config"
echo "VRRP:"
$OPRUN show vrrp 2>/dev/null | sed -n '3,$p' | awk '{printf " %-9s %-12s %s\n", $1, $2, $4}'
echo "DHCP server: $(systemctl is-active isc-kea-dhcp4-server 2>/dev/null)"
echo "DNS forwarder: $(systemctl is-active pdns-recursor 2>/dev/null)"
echo "WAN (pppoe0): $(ip -4 -br addr show pppoe0 2>/dev/null | awk '{print $2, $3}' || echo 'not present')"
echo "default route: $(ip -4 route show default 2>/dev/null | head -1 || echo none)"
echo "unsaved changes: $(cfg_unsaved)"
}
cfg_unsaved() {
if /usr/bin/config-mgmt compare >/dev/null 2>&1; then echo "no"; else echo "possibly - check 'compare saved'"; fi
}
# ------------------------------------------------------------- preflight ----
require_files() {
[ -r "$UNIFI_BOOT" ] || die "missing $UNIFI_BOOT -- capture it while the USG is still the gateway"
[ -s "$UNIFI_BOOT" ] || die "$UNIFI_BOOT is empty"
}
require_reload_action() {
cfg_has "set system config-management commit-confirm action reload" && return 0
die "commit-confirm action is not 'reload'. Without it an unconfirmed switch
REBOOTS this box instead of reverting. Fix first:
configure
set system config-management commit-confirm action reload
commit; save"
}
# The single worst outcome available is two devices answering on the gateway
# address. It costs one ARP probe to make that impossible.
# Returns 0 (success) when something IS answering on a gateway address we are
# about to claim -- i.e. "yes, still alive, do not proceed".
usg_still_alive() {
local found=0 ip dev targets
targets="$(grep -oE "vrrp group [a-z0-9]+ address [0-9.]+" "$DELTA" 2>/dev/null | awk '{print $NF}')"
if [ -z "$targets" ]; then
# A delta with no VIPs cannot be probed, which means the single guard
# against two devices sharing a gateway address is inert. Refuse by
# default: an unprobeable delta on the real boxes is a broken delta, and
# "warn and continue" would let the one failure this script exists to
# prevent through unnoticed. The override is for the lab only.
if [ "${ALLOW_NO_VIP_DELTA:-0}" = "1" ]; then
warn "delta claims no VIPs; proceeding because ALLOW_NO_VIP_DELTA=1 (lab only)"
return 1
fi
die "this delta claims no VIPs, so the gateway-address guard cannot run.
On the real boxes that means a broken delta. If this really is a lab
run, re-invoke with ALLOW_NO_VIP_DELTA=1."
fi
for ip in $targets; do
dev="$(ip -4 route get "$ip" 2>/dev/null | sed -n 's/.* dev \([^ ]*\).*/\1/p' | head -1)"
if [ -n "$dev" ] && command -v arping >/dev/null 2>&1; then
# ARP is the right probe: it answers even when the host filters ICMP.
if arping -c 2 -w 3 -f -I "$dev" "$ip" >/dev/null 2>&1; then
warn "something already answers ARP on $ip (via $dev)"; found=1
fi
elif ping -c 2 -W 2 "$ip" >/dev/null 2>&1; then
warn "something already answers ICMP on $ip"; found=1
fi
done
[ "$found" -eq 1 ]
}
# ------------------------------------------------------------- to unifi -----
to_unifi() {
require_files
say "reverting to unifi mode (USG is the gateway)"
run_cfg <<EOF || die "load/commit failed -- the box is unchanged, use the console"
load $UNIFI_BOOT
commit
save
EOF
echo "unifi" > "$MARKER"
say "done. The USG can be reconnected."
say "If it was already connected during this, nothing was disturbed."
}
# -------------------------------------------------------------- to vyos -----
health_checks() {
local fails=0
_chk() { # name, command
if eval "$2" >/dev/null 2>&1; then say " ok $1"; else say " FAIL $1"; fails=$((fails+1)); fi
}
_chk "kea (DHCP) is running" "systemctl is-active --quiet isc-kea-dhcp4-server"
_chk "DNS forwarder is running" "systemctl is-active --quiet pdns-recursor"
# Only assert on the WAN if this delta actually brings one up. A delta with
# no PPPoE stanza is a lab/partial delta, and failing it on a missing
# pppoe0 would make the script untestable anywhere but the live cutover.
# Announced loudly, because a quietly skipped check is worse than no check.
if grep -qE "^set interfaces (pppoe|bonding bond0 vif 5)" "$DELTA"; then
# What matters is that SOME WAN works, not that every WAN works.
#
# This reverted a cutover that had genuinely succeeded. The 10 gig line came
# up on bond0.53 and the cloned MAC was handed the same public address the
# USG had (87.192.101.48); kea was serving real LAN clients at the same
# moment. The only failure was pppoe0 -- the Vodafone FAILOVER line -- and
# requiring it undid a working gateway.
#
# Written as [ -n "$(...)" ] rather than `... | grep -q` for the pipefail
# reason above: a pipeline ending in grep -q cannot be trusted here.
_chk "a default route exists" '[ -n "$(ip -4 route show default)" ]'
_chk "internet reachable" "ping -c2 -W3 8.8.8.8"
_chk "DNS resolves through us" "getent hosts vyos.net"
# Informational only: report each WAN, fail on neither. A failover line
# being down is worth seeing, not worth reverting for.
for _w in pppoe0 bond0.53; do
if [ -n "$(ip -4 -br addr show "$_w" 2>/dev/null | awk '{print $3}')" ]; then
say " ok WAN $_w has an address (informational)"
else
say " note WAN $_w has no address (informational, not fatal)"
fi
done
else
warn "this delta configures no WAN -- skipping all WAN health checks."
warn "That is expected in the lab and WRONG for the real cutover."
fi
return $fails
}
to_vyos() {
require_files
require_reload_action
[ -r "$DELTA" ] || die "missing $DELTA"
if usg_still_alive; then
die "refusing: something is still answering on a gateway address.
Disconnect the USG first. Two devices on the same gateway IP is the
one failure this script exists to prevent."
fi
local pw="" tmp
if [ -r "$SECRETS" ]; then
# shellcheck disable=SC1090
. "$SECRETS"; pw="${WAN_PASSWORD:-}"
fi
[ -n "$pw" ] || warn "no WAN_PASSWORD in $SECRETS -- PPPoE will not authenticate"
tmp="$(mktemp)"; chmod 600 "$tmp"
sed "s|@@WAN_PASSWORD@@|${pw}|g" "$DELTA" | grep -vE '^\s*(#|$)' > "$tmp"
say "switching to vyos mode (this box becomes the gateway)"
say "commit-confirm: ${CONFIRM_MINUTES} min to confirm, else it reverts itself"
# commit-confirm is TWO steps, and doing only the first commits nothing:
# `config-mgmt commit_confirm` arms the revert timer, then a normal `commit`
# applies the candidate config. The interactive prompt lives in the first
# step, which is why it is invoked directly with -y instead of via the
# `commit-confirm` alias. IN_COMMIT_CONFIRM is what the real CLI sets, and
# the commit hooks look at it.
if ! run_cfg < <(printf 'load %s\n' "$UNIFI_BOOT"; cat "$tmp";
printf 'sudo sg vyattacfg "/usr/bin/config-mgmt commit_confirm -y -t=%s"\n' "$CONFIRM_MINUTES";
printf 'export IN_COMMIT_CONFIRM=t\ncommit\nunset IN_COMMIT_CONFIRM\n'); then
rm -f "$tmp"
die "commit-confirm failed. Nothing was applied; the box is still in its
previous mode. Run '$0 unifi' if you are unsure."
fi
rm -f "$tmp"
# Poll, do not sample once.
#
# A cutover attempt failed here on a fixed 25s wait. That is far too short for
# a WAN: PPPoE is PADI/PADO/PADR/PADS then LCP, auth and IPCP, routinely 15-30s
# by itself, and both lines had just been released by the USG seconds earlier.
# ISPs commonly hold the previous session and MAC binding for minutes before
# leasing to the "same" CPE again -- which is precisely what a cloned MAC looks
# like to them. One sample at 25s reported a healthy setup as broken and
# reverted it.
#
# There is still a deadline, because commit-confirm is running: stop well
# before it so the decision is ours rather than the timer's.
local budget="${HEALTH_BUDGET:-180}" waited=0 step=15
say "committed. Polling health for up to ${budget}s (commit-confirm has ${CONFIRM_MINUTES} min)..."
while :; do
sleep "$step"; waited=$(( waited + step ))
if health_checks >/dev/null 2>&1; then
say "healthy after ${waited}s"
break
fi
if [ "$waited" -ge "$budget" ]; then
say "still unhealthy after ${waited}s -- final check:"
break
fi
say " not healthy yet at ${waited}s, still waiting..."
done
say "health checks:"
if health_checks; then
say "all checks passed -- confirming"
/usr/bin/config-mgmt confirm >/dev/null 2>&1 || die "confirm failed; it will revert on its own shortly"
run_cfg <<'EOF' || warn "save failed -- config is live but will not survive a reboot"
save
EOF
echo "vyos" > "$MARKER"
say "vyos mode is live and saved."
else
warn "health checks FAILED -- reverting now rather than waiting out the timer"
/usr/bin/config-mgmt revert_soft >/dev/null 2>&1 \
|| warn "revert_soft failed; the commit-confirm timer will still fire within ${CONFIRM_MINUTES} min"
echo "unifi" > "$MARKER"
die "reverted to the previous configuration. Reconnect the USG.
Check: ip addr show pppoe0; journalctl -u pppd; $0 status"
fi
}
# ----------------------------------------------------------------- main -----
case "${1:-status}" in
vyos) to_vyos ;;
unifi) to_unifi ;;
status) show_status ;;
*) echo "usage: $(basename "$0") [vyos|unifi|status]" >&2; exit 2 ;;
esac