diff --git a/migration/CUTOVER.md b/migration/CUTOVER.md new file mode 100644 index 0000000..a13a634 --- /dev/null +++ b/migration/CUTOVER.md @@ -0,0 +1,112 @@ +# Cutover runbook — USG to VyOS + +**Print this.** During the cutover there is no internet, so there is no +assistant and no web search. Everything you need is on this page and on the +boxes themselves. + +| | | +|---|---| +| vyos001 (MASTER) | `192.168.8.143` — also `10.0.1.252` on LoT | +| vyos002 (BACKUP) | `192.168.8.144` — also `10.0.1.253` on LoT | +| JetKVMs | `192.168.1.28`, `192.168.1.29`, `192.168.3.6` | +| Switch script | `/config/vyos-unifi-switch` on each box | +| Login | user `vyos` | + +Both boxes have **two** reachable addresses on different interfaces. If `eth2` +(`192.168.8.x`) is unreachable, try the LoT address (`10.0.1.x`), and only then +the JetKVM. + +--- + +## If something is wrong, do this + +``` +sudo /config/vyos-unifi-switch unifi +``` + +Then reconnect the USG. That command runs no health checks, asks nothing and +cannot refuse. It restores a byte-exact copy of the configuration the box had +before the cutover — verified by diff, not by assumption. + +**You do not have to be quick.** If you do nothing at all after +`vyos-unifi-switch vyos`, the box reverts by itself within 10 minutes. Verified: +config returns to the previous state and the box does **not** reboot +(`uptime` and boot-id unchanged across an auto-revert). + +--- + +## Before you unplug anything + +1. Tether your workstation to your phone if you want the assistant available. + Cutting the USG cuts your internet, not your LAN. +2. On **both** boxes, confirm the machinery is present: + ``` + sudo /config/vyos-unifi-switch status + ls -la /config/modes/ # unifi.boot + to-vyos.commands + ls -la /config/wan-secrets # must be 0600 + ``` + `status` must report `mode: unifi`. If `unifi.boot` is missing, **stop** — + there is no way back without it. +3. Confirm the revert action is `reload`, not `reboot`: + ``` + show configuration commands | match commit-confirm + ``` + Must show `action 'reload'`. Without it a failed switch **reboots** the + firewall instead of reverting it. The switch script refuses to run if this + is missing, but check anyway. + +## The cutover + +1. **Physically disconnect the USG.** Not just powered off — disconnected. The + switch script refuses to run while anything still answers on a gateway + address, because two devices on `.1` is the worst available outcome. +2. On **vyos002 (BACKUP) first**: + ``` + sudo /config/vyos-unifi-switch vyos + ``` +3. Watch the health checks. They cover PPPoE, the default route, kea, the DNS + forwarder and reachability. On failure the script reverts immediately and + tells you so. +4. If vyos002 came up clean, repeat on **vyos001 (MASTER)**. +5. Check a real client: does it get an address, and is it the *same* address as + before? Every active client has a reservation, so it should be. + +## What will probably go wrong first + +**PPPoE.** It is the one thing that could not be tested in advance — the line +permits a single session and the USG held it until you unplugged it. If the +WAN check fails: + +``` +show interfaces pppoe pppoe0 +sudo journalctl -u ppp@pppoe0 -n 50 --no-pager +``` + +Check the credential in `/config/wan-secrets` and that VLAN 51 actually reaches +the box. If it will not come up, run `vyos-unifi-switch unifi`, reconnect the +USG, and debug with the internet back on. + +## Things that are true and easy to forget + +- **WiFi keeps working, but through VyOS.** The SSIDs stay in UniFi and the APs + are untouched, but 37 of 83 active clients are wireless and every one is on + LoT — they get their addresses from VyOS now. +- **DHCP leases last 24h (86400s).** A device that does not renew promptly keeps + its old address for a while. That is fine, not a symptom. +- **The firewalls resolve via `10.0.0.194`**, not the USG. That was changed + ahead of time precisely so they keep DNS when the USG goes away. +- **The USG was a DNS resolver** for every VLAN except LoT. VyOS now runs + `dns forwarding` in its place. If names stop resolving but IPs still work, + that is where to look. +- **`eth2` and `bond0.2` are both in `192.168.8.0/23`.** It works, but if you + see odd source-address behaviour on the management NIC, that is why. + +## Afterwards + +Once it has been stable for a day: + +- Re-run `migration/unifi-export.py` — the UniFi controller is no longer the + source of truth for DHCP, and the export will drift. +- The VPN rules (ESP, UDP 500/4500) are carried over but the VPN itself still + terminated on the USG. Decide whether it moves. +- `labsim` still holds a deliberate `kvm→k8s` drop rule from earlier testing. diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py new file mode 100755 index 0000000..e81434f --- /dev/null +++ b/migration/vyos-mode-delta.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Generate the delta that turns a passive VyOS pair into the gateway. + +The switch works as: load the known-good `unifi.boot` snapshot, apply this +delta, commit-confirm. Deriving the gateway mode from base+delta every time +means there is no inverse to maintain and no drift between two hand-kept +configs -- the revert is just loading the snapshot again. + + ./vyos-mode-delta.py --priority 200 -o to-vyos.commands # vyos001 (master) + ./vyos-mode-delta.py --priority 100 -o to-vyos.commands # vyos002 (backup) + ./vyos-mode-delta.py --emit-secrets /path/wan-secrets # credentials, 0600 + +The PPPoE password is NOT written into the delta. The delta carries the +placeholder @@WAN_PASSWORD@@ and the switch script substitutes it at apply time +from /config/wan-secrets, so the generated artifact can be read, diffed and +copied around without carrying a credential. +""" +from __future__ import annotations + +import argparse +import importlib.util +import ipaddress +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# unifi-to-vyos.py has hyphens, so it cannot be imported by name. Reuse it +# rather than duplicating the DHCP/DNS generation -- the whole point is that +# what labsim proved and what production gets come from one code path. +_spec = importlib.util.spec_from_file_location( + "unifi_to_vyos", os.path.join(HERE, "unifi-to-vyos.py")) +unifi_to_vyos = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(unifi_to_vyos) + +WAN_VIF = "bond0.51" # WAN arrives trunked as vlan-only network 51 +WAN_IF = "pppoe0" +PLACEHOLDER = "@@WAN_PASSWORD@@" + + +def vrrp_group(vlan: int) -> str: + """VRRP group names as configured on the boxes: 'native' for the untagged + VLAN, 'vlan' otherwise.""" + return "native" if vlan == 1 else f"vlan{vlan}" + + +def build_delta(inv: dict, priority: int, wan_user: str) -> list[str]: + out: list[str] = [] + nets = [n for n in inv["networks"] if n["dhcp_enabled"] and n["subnet"]] + nets.sort(key=unifi_to_vyos.vlan_of) + + out += [ + "# ==========================================================", + "# Delta: passive VyOS pair -> gateway. Applied on top of a", + "# freshly loaded unifi.boot, never on top of itself.", + "# ==========================================================", + "", + "# An unconfirmed commit must reload the previous config, NOT reboot.", + "# 'reboot' is the VyOS default and would turn a failed switch into a", + "# real outage on the box that is meant to be carrying the network.", + "set system config-management commit-confirm action reload", + "", + "# --- gateway addresses ------------------------------------", + "# The VIP takes over the address the USG holds today, so no client", + "# changes anything: no renewal needed, hardcoded gateways keep working.", + ] + for n in nets: + vlan = unifi_to_vyos.vlan_of(n) + grp = vrrp_group(vlan) + iface = ipaddress.ip_interface(n["subnet"]) + out.append(f"# {n['name']} (VLAN {vlan}) -> {iface.with_prefixlen}") + # Delete the whole address node rather than a computed old value. + # `address` is multi-value, and the current VIPs are NOT at + # network+254 on the /23 networks -- they are 192.168.9.254, + # 10.0.9.254 and 10.0.1.254, in the upper half. A delete naming the + # wrong address fails quietly and leaves the group holding two VIPs. + out.append(f"delete high-availability vrrp group {grp} address") + out.append(f"set high-availability vrrp group {grp} address {iface.with_prefixlen}") + out.append(f"set high-availability vrrp group {grp} priority {priority}") + + out += [ + "", + "# --- WAN ---------------------------------------------------", + "# Vodafone line, PPPoE over the trunked WAN VLAN. This is the one", + "# part that cannot be tested before cutover: the line permits a", + "# single session and the USG holds it until it is unplugged.", + f"set interfaces pppoe {WAN_IF} source-interface {WAN_VIF}", + f"set interfaces pppoe {WAN_IF} authentication username '{wan_user}'", + f"set interfaces pppoe {WAN_IF} authentication password '{PLACEHOLDER}'", + f"set interfaces pppoe {WAN_IF} mtu 1492", + # The peer's resolvers would otherwise overwrite /etc/resolv.conf and + # cost the box its ability to resolve internal ad.itaz.eu names. + f"set interfaces pppoe {WAN_IF} no-peer-dns", + "", + "# The static default route exists only for unifi mode, where the USG", + "# is the next hop. PPPoE supplies the default here; leaving both at", + "# the same distance would be ambiguous.", + "delete protocols static route 0.0.0.0/0", + "", + "# --- NAT ---------------------------------------------------", + f"set nat source rule 100 outbound-interface name {WAN_IF}", + "set nat source rule 100 translation address masquerade", + "set nat source rule 100 description 'LAN out to the internet'", + ] + + # Port forwards, straight from UniFi. + for i, p in enumerate(inv["port_forwards"]): + if not p.get("enabled"): + continue + rule = 100 + i * 10 + proto = p["proto"] # tcp | udp | tcp_udp -- all valid VyOS values + out += [ + "", + f"set nat destination rule {rule} description '{p['name']}'", + f"set nat destination rule {rule} inbound-interface name {WAN_IF}", + f"set nat destination rule {rule} protocol {proto}", + f"set nat destination rule {rule} destination port '{p['dst_port']}'", + f"set nat destination rule {rule} translation address {p['fwd']}", + f"set nat destination rule {rule} translation port '{p['fwd_port']}'", + ] + + out += [ + "", + "# --- firewall ----------------------------------------------", + "# VyOS defaults to accepting everything. The USG has an implicit", + "# WAN drop, so migrating the port forwards alone would leave the", + "# router's own services and the whole LAN reachable from the WAN.", + "#", + "# Scoped to the WAN interface rather than a global default-action", + "# drop: that way a mistake here cannot lock anyone out over the LAN,", + "# which is the only path back in during a cutover.", + "", + "# Traffic TO the router.", + "set firewall ipv4 input filter default-action accept", + "set firewall ipv4 input filter rule 100 action accept", + "set firewall ipv4 input filter rule 100 state established", + "set firewall ipv4 input filter rule 100 state related", + "set firewall ipv4 input filter rule 100 description 'established/related'", + ] + # The two WAN_LOCAL accepts carried over from UniFi. + out += [ + "", + "set firewall ipv4 input filter rule 110 action accept", + "set firewall ipv4 input filter rule 110 protocol esp", + f"set firewall ipv4 input filter rule 110 inbound-interface name {WAN_IF}", + "set firewall ipv4 input filter rule 110 description 'VPN accept ESP (from UniFi WAN_LOCAL)'", + "", + "set firewall ipv4 input filter rule 120 action accept", + "set firewall ipv4 input filter rule 120 protocol udp", + "set firewall ipv4 input filter rule 120 destination port '500,4500'", + f"set firewall ipv4 input filter rule 120 inbound-interface name {WAN_IF}", + "set firewall ipv4 input filter rule 120 description 'VPN accept UDP500/4500 (from UniFi WAN_LOCAL)'", + "", + "set firewall ipv4 input filter rule 130 action accept", + "set firewall ipv4 input filter rule 130 protocol icmp", + f"set firewall ipv4 input filter rule 130 inbound-interface name {WAN_IF}", + "set firewall ipv4 input filter rule 130 description 'ICMP to the router (path MTU discovery)'", + "", + "# Everything else arriving from the WAN is dropped. LAN is untouched.", + "set firewall ipv4 input filter rule 900 action drop", + f"set firewall ipv4 input filter rule 900 inbound-interface name {WAN_IF}", + "set firewall ipv4 input filter rule 900 description 'drop all other WAN-to-router'", + "", + "# Traffic THROUGH the router.", + "set firewall ipv4 forward filter default-action accept", + "set firewall ipv4 forward filter rule 100 action accept", + "set firewall ipv4 forward filter rule 100 state established", + "set firewall ipv4 forward filter rule 100 state related", + "set firewall ipv4 forward filter rule 100 description 'established/related'", + ] + + # Destination NAT happens before the forward filter, so these rules must + # match the translated destination, not the WAN address. + for i, p in enumerate(inv["port_forwards"]): + if not p.get("enabled"): + continue + rule = 200 + i * 10 + out += [ + "", + f"set firewall ipv4 forward filter rule {rule} action accept", + f"set firewall ipv4 forward filter rule {rule} inbound-interface name {WAN_IF}", + f"set firewall ipv4 forward filter rule {rule} protocol {p['proto']}", + f"set firewall ipv4 forward filter rule {rule} destination address {p['fwd']}", + f"set firewall ipv4 forward filter rule {rule} destination port '{p['fwd_port']}'", + f"set firewall ipv4 forward filter rule {rule} description 'port forward: {p['name']}'", + ] + + out += [ + "", + "# New inbound connections from the WAN that are not a port forward.", + "set firewall ipv4 forward filter rule 900 action drop", + f"set firewall ipv4 forward filter rule 900 inbound-interface name {WAN_IF}", + "set firewall ipv4 forward filter rule 900 description 'drop unsolicited WAN-to-LAN'", + "", + ] + + # DHCP + DNS, from the same generator labsim proved. + dhcp_lines, stats = unifi_to_vyos.build(inv, "prod") + expected = len(inv["reservations"]) + if stats["mappings"] != expected: + raise SystemExit( + f"refusing to generate: {expected - stats['mappings']} reservation(s) " + f"missing -- every one must survive the cutover") + out += dhcp_lines + return out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--priority", type=int, required=True, + help="VRRP priority: 200 for the master, 100 for the backup") + ap.add_argument("--inventory", default=os.path.join(HERE, "export", "inventory.json")) + ap.add_argument("--raw-networkconf", default=os.path.join(HERE, "export", "rest_networkconf.json")) + ap.add_argument("-o", "--out") + ap.add_argument("--emit-secrets", metavar="PATH", + help="write the PPPoE credential to PATH with mode 0600 and exit") + args = ap.parse_args() + + with open(args.inventory) as fh: + inv = json.load(fh) + with open(args.raw_networkconf) as fh: + raw_nets = json.load(fh) + + wan = next((n for n in raw_nets + if n.get("purpose") == "wan" and n.get("wan_username")), None) + if wan is None: + print("no WAN network with credentials found in the export", file=sys.stderr) + return 1 + + if args.emit_secrets: + fd = os.open(args.emit_secrets, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(f"WAN_PASSWORD='{wan.get('x_wan_password', '')}'\n") + # Re-assert the mode in case the file already existed with a wider one. + os.chmod(args.emit_secrets, 0o600) + mode = oct(os.stat(args.emit_secrets).st_mode & 0o777) + print(f"wrote {args.emit_secrets} (mode {mode}) for user {wan['wan_username']}", + file=sys.stderr) + return 0 + + lines = build_delta(inv, args.priority, wan["wan_username"]) + text = "\n".join(lines) + "\n" + + if PLACEHOLDER not in text: + print("BUG: password placeholder missing from the delta", file=sys.stderr) + return 1 + if wan.get("x_wan_password") and wan["x_wan_password"] in text: + print("BUG: the WAN password leaked into the delta", file=sys.stderr) + return 1 + + n_set = sum(1 for l in lines if l.startswith("set ")) + n_del = sum(1 for l in lines if l.startswith("delete ")) + print(f"delta: {n_set} set, {n_del} delete, priority {args.priority}, " + f"{len(inv['reservations'])} reservations", file=sys.stderr) + + if args.out: + with open(args.out, "w") as fh: + fh.write(text) + print(f"wrote {args.out}", file=sys.stderr) + else: + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/migration/vyos-unifi-switch b/migration/vyos-unifi-switch new file mode 100755 index 0000000..b4848e5 --- /dev/null +++ b/migration/vyos-unifi-switch @@ -0,0 +1,246 @@ +#!/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 < "$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