Files
lab/migration/vyos-mode-delta.py

276 lines
13 KiB
Python
Raw Normal View History

feat(migration): reversible USG->VyOS switch, proven on the sim The cutover is a switch, not a migration: unplug the USG, run one command, and if anything is wrong run the other one and plug it back in. The operator will have no internet during this and therefore no assistant, so the machinery has to live on the boxes and the failure paths have to be proven in advance. vyos-mode-delta.py generates the delta that turns the passive pair into the gateway. Only one artifact is authored: gateway mode is always derived from `load unifi.boot` + delta, so there is no inverse to maintain and no drift between two hand-kept configs. It reuses unifi-to-vyos.py rather than duplicating it, so what labsim proved and what production gets are one code path. The PPPoE password is never written into the delta -- it carries a placeholder the switch substitutes at apply time from /config/wan-secrets -- and generation fails if the real password appears in the output. Two things the delta covers that the plan had underweighted: - VyOS defaults to ACCEPT while the USG has an implicit WAN drop. Migrating the port forwards alone would have left the router's own services and the whole LAN reachable from the WAN. Added a stateful baseline scoped to the WAN interface rather than a global default-action drop, so a mistake there cannot lock anyone out over the LAN -- the only way back during a cutover. - The old 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 a computed address fails quietly and leaves the group holding two VIPs. The delta deletes the whole address node instead of guessing. vyos-unifi-switch runs on the box from /config, which survives image upgrades, so it works from a local terminal or the JetKVM with no workstation. Proven on labsim, not assumed: - unifi mode restores the previous config BYTE-EXACT (138 lines, diff clean). - Auto-revert fires when the commit is not confirmed: 85 static-mappings -> 0, kea stopped, hostname restored, and uptime plus boot-id UNCHANGED, so it reloaded rather than rebooted. That distinction is the whole reason `commit-confirm action reload` is a prerequisite. - Health-check failure triggers an immediate revert_soft rather than waiting out the timer. Four bugs found while doing it, each of which produced a wrong answer rather than an error: - commit-confirm is TWO steps. `config-mgmt commit_confirm` only arms the revert timer; a normal `commit` still has to follow. Arming alone committed nothing while reporting success. - `sudo sg vyattacfg "config-mgmt ..."` loses the config-session environment, so it reported "No configuration changes to commit" against a candidate that plainly had 446 added lines. - `... | grep -q` under `set -o pipefail` reports FAILURE on a match: grep exits early, the producer takes SIGPIPE. Whether it triggers depends on output size, so `status` misreported the mode intermittently. - `show configuration commands` quotes values, so a fixed-string match for `action reload` never matched `action 'reload'`. CUTOVER.md is the printable runbook: both reachable addresses per box, the escape hatch first, and the note that PPPoE is the one thing that could not be tested beforehand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-15 23:30:28 +01:00
#!/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<id>' 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.",
"#",
"# The WAN vif must be created first. Neither firewall has vif 51 today",
"# (only 2, 3, 9, 10 and 200 exist), and pppoe source-interface refers to",
"# an interface that has to already be configured -- without this the",
"# commit fails and takes the whole switch with it. No address on it:",
"# PPPoE rides the VLAN, it does not need L3 of its own.",
f"set interfaces bonding bond0 vif {WAN_VIF.split('.')[1]} description 'WAN (Vodafone, PPPoE)'",
"",
feat(migration): reversible USG->VyOS switch, proven on the sim The cutover is a switch, not a migration: unplug the USG, run one command, and if anything is wrong run the other one and plug it back in. The operator will have no internet during this and therefore no assistant, so the machinery has to live on the boxes and the failure paths have to be proven in advance. vyos-mode-delta.py generates the delta that turns the passive pair into the gateway. Only one artifact is authored: gateway mode is always derived from `load unifi.boot` + delta, so there is no inverse to maintain and no drift between two hand-kept configs. It reuses unifi-to-vyos.py rather than duplicating it, so what labsim proved and what production gets are one code path. The PPPoE password is never written into the delta -- it carries a placeholder the switch substitutes at apply time from /config/wan-secrets -- and generation fails if the real password appears in the output. Two things the delta covers that the plan had underweighted: - VyOS defaults to ACCEPT while the USG has an implicit WAN drop. Migrating the port forwards alone would have left the router's own services and the whole LAN reachable from the WAN. Added a stateful baseline scoped to the WAN interface rather than a global default-action drop, so a mistake there cannot lock anyone out over the LAN -- the only way back during a cutover. - The old 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 a computed address fails quietly and leaves the group holding two VIPs. The delta deletes the whole address node instead of guessing. vyos-unifi-switch runs on the box from /config, which survives image upgrades, so it works from a local terminal or the JetKVM with no workstation. Proven on labsim, not assumed: - unifi mode restores the previous config BYTE-EXACT (138 lines, diff clean). - Auto-revert fires when the commit is not confirmed: 85 static-mappings -> 0, kea stopped, hostname restored, and uptime plus boot-id UNCHANGED, so it reloaded rather than rebooted. That distinction is the whole reason `commit-confirm action reload` is a prerequisite. - Health-check failure triggers an immediate revert_soft rather than waiting out the timer. Four bugs found while doing it, each of which produced a wrong answer rather than an error: - commit-confirm is TWO steps. `config-mgmt commit_confirm` only arms the revert timer; a normal `commit` still has to follow. Arming alone committed nothing while reporting success. - `sudo sg vyattacfg "config-mgmt ..."` loses the config-session environment, so it reported "No configuration changes to commit" against a candidate that plainly had 446 added lines. - `... | grep -q` under `set -o pipefail` reports FAILURE on a match: grep exits early, the producer takes SIGPIPE. Whether it triggers depends on output size, so `status` misreported the mode intermittently. - `show configuration commands` quotes values, so a fixed-string match for `action reload` never matched `action 'reload'`. CUTOVER.md is the printable runbook: both reachable addresses per box, the escape hatch first, and the note that PPPoE is the one thing that could not be tested beforehand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-15 23:30:28 +01:00
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())