247 lines
10 KiB
Plaintext
247 lines
10 KiB
Plaintext
|
|
#!/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
|
||
|
|
warn "this delta claims no VIPs, so there is nothing to probe."
|
||
|
|
warn "That is expected in the lab and WRONG for the real cutover."
|
||
|
|
return 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 -q "^set interfaces pppoe" "$DELTA"; then
|
||
|
|
# Written as [ -n "$(...)" ] rather than `... | grep -q` for the pipefail
|
||
|
|
# reason above: a pipeline ending in grep -q cannot be trusted here.
|
||
|
|
_chk "pppoe0 has an address" '[ -n "$(ip -4 -br addr show pppoe0 2>/dev/null | awk "{print \$3}")" ]'
|
||
|
|
_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"
|
||
|
|
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"
|
||
|
|
|
||
|
|
say "committed. Waiting 25s for PPPoE and services to settle..."
|
||
|
|
sleep 25
|
||
|
|
|
||
|
|
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
|