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

508 lines
27 KiB
Python
Raw Permalink 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)
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
# Two WANs, established by reading the live USG rather than the UniFi fields
# (which report wan_type=dhcp for both and are simply wrong):
#
# WAN1 Vodafone, PPPoE on the USG's eth0, ~900/700 Mbit. Verified working:
# pppoe0 came up with 90.241.226.213 peer 84.65.128.1, MTU 1492.
# WAN2 10 gig ISP, plain DHCP on the USG's eth2, public 87.192.101.48/21
# gw 87.192.96.1. This is what carries traffic today.
#
# Both reach the USG as untagged access ports but are carried across the switch
# fabric as vlan-only networks 51 and 53, so VyOS picks them up as bond vifs.
WAN_PPPOE_VIF = "bond0.51" # Vodafone
WAN_PPPOE_IF = "pppoe0"
WAN_DHCP_VIF = "bond0.53" # 10 gig ISP
# The DHCP lease is bound to the MAC, so cloning the USG's WAN2 MAC is how VyOS
# keeps 87.192.101.48 instead of negotiating a fresh lease -- or getting none,
# if the ISP hands out one per line. Only ONE box may carry this at a time.
WAN_DHCP_MAC = "f0:9f:c2:12:9b:4f"
# Route distances: the 10 gig line wins, Vodafone is failover.
#
# The live 10 gig default route is owned by `protocols failover`, so that losing
# the ISP *without* losing carrier withdraws it instead of black-holing every
# packet -- a DHCP-installed route never withdraws on a dead upstream.
#
# The vif still needs default-route-distance rather than no-default-route:
# vyos-failover resolves a dhcp-interface gateway by reading new_routers out of
# /run/dhclient/dhclient_<if>.lease, and no-default-route leaves that field
# EMPTY, so the daemon finds no next hop and installs nothing. Verified on
# vyos001: with no-default-route the default route fell through to Vodafone.
#
# So DHCP keeps a route, deliberately demoted BELOW Vodafone. Order of
# preference: failover's kernel route (distance 0) > pppoe (10) > DHCP (210).
# The demoted route is never selected while pppoe is up, so it cannot re-create
# the black-hole it exists to avoid.
DIST_PPPOE = 10
DIST_DHCP_FALLBACK = 210
# Health-checked primary. Two targets, any-available, so one resolver having a
# bad day is not read as "the line is down". Verified on the sim: failover and
# failback both inside 5s with the router's own interface still UP.
FAILOVER_METRIC = 1
FAILOVER_TARGETS = ["8.8.8.8", "1.1.1.1"]
FAILOVER_TIMEOUT = 5
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
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
PLACEHOLDER = "@@WAN_PASSWORD@@"
feat(migration): complete the VyOS HA stack per the official docs Prompted by "I thought we tested HA on libvirt" -- checking rather than recalling showed the sim has ONE VyOS router with zero high-availability config. VRRP was configured and running on the real pair, but it is only one of four parts of what VyOS considers an HA pair. Against docs.vyos.io (highavailability, conntrack-sync, dhcp-server, and the HA walkthrough), three gaps are now closed in the delta: - VRRP was multicast-only with default preemption. Added unicast hello-source-address/peer-address per group, as the walkthrough does, plus no-preempt. Without no-preempt a recovered box reclaims the VIP before conntrack state has synced and drops every established connection; the docs are explicit that preempt-delay must otherwise be >= purge-timeout. The per-VLAN node addresses are a table, not derived: VLAN 3 is .4/.5 while every other VLAN is .252/.253. - DHCP high-availability, which fixes a real defect rather than adding a feature. Both boxes carried the full 6 subnets and 84 static-mappings, so after cutover two kea instances would have raced on the same broadcast domains. Now active-passive with primary/secondary and swapped source/remote, syncing over TCP 647 on the LoT addresses. Each subnet already carries the unique subnet-id kea HA requires, and the peer name deliberately differs from both host-names. - conntrack-sync over a dedicated eth3 <-> eth3 link, gated behind --conntrack-link because it needs a cable that is not plugged in yet. This is what the peer cable is actually for -- VRRP does not want one, since its hellos must travel on the segment they protect. VRRP failover exercised on the production pair, which is free to break today because nothing uses the .254 VIPs: keepalived stopped on vyos001, all six VIPs moved to vyos002 within 12s, and returned on restart (preemption still default on the live boxes). Both boxes clean afterwards, no config drift. Master delta validated against vyos001's real running config on the sim router before installing. Installed on both: 6 no-preempt, 6 unicast pairs, DHCP HA primary/secondary respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 22:48:39 +01:00
# Per-VLAN interface addresses of each node, read from the live boxes. VRRP
# unicast (hello-source-address/peer-address) needs both ends explicitly, and
# these are NOT derivable from the subnet -- VLAN 3 is .4/.5 while everything
# else is .252/.253.
# vlan: (vyos001, vyos002)
NODE_ADDRS = {
1: ("192.168.1.252", "192.168.1.253"),
2: ("192.168.9.252", "192.168.9.253"),
3: ("192.168.3.4", "192.168.3.5"),
9: ("10.8.0.252", "10.8.0.253"),
10: ("10.0.1.252", "10.0.1.253"),
200: ("192.168.2.252", "192.168.2.253"),
}
# Dedicated point-to-point link for conntrack state sync (eth3 <-> eth3).
CONNTRACK_ADDRS = ("10.255.255.1/30", "10.255.255.2/30")
CONNTRACK_IF = "eth3"
# kea HA talks over TCP 647. The LoT addresses are used because they are stable
# and reachable today without the conntrack cable being plugged in.
DHCP_HA_NAME = "vyos-dhcp-pair" # must NOT equal either system host-name
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
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}"
feat(migration): complete the VyOS HA stack per the official docs Prompted by "I thought we tested HA on libvirt" -- checking rather than recalling showed the sim has ONE VyOS router with zero high-availability config. VRRP was configured and running on the real pair, but it is only one of four parts of what VyOS considers an HA pair. Against docs.vyos.io (highavailability, conntrack-sync, dhcp-server, and the HA walkthrough), three gaps are now closed in the delta: - VRRP was multicast-only with default preemption. Added unicast hello-source-address/peer-address per group, as the walkthrough does, plus no-preempt. Without no-preempt a recovered box reclaims the VIP before conntrack state has synced and drops every established connection; the docs are explicit that preempt-delay must otherwise be >= purge-timeout. The per-VLAN node addresses are a table, not derived: VLAN 3 is .4/.5 while every other VLAN is .252/.253. - DHCP high-availability, which fixes a real defect rather than adding a feature. Both boxes carried the full 6 subnets and 84 static-mappings, so after cutover two kea instances would have raced on the same broadcast domains. Now active-passive with primary/secondary and swapped source/remote, syncing over TCP 647 on the LoT addresses. Each subnet already carries the unique subnet-id kea HA requires, and the peer name deliberately differs from both host-names. - conntrack-sync over a dedicated eth3 <-> eth3 link, gated behind --conntrack-link because it needs a cable that is not plugged in yet. This is what the peer cable is actually for -- VRRP does not want one, since its hellos must travel on the segment they protect. VRRP failover exercised on the production pair, which is free to break today because nothing uses the .254 VIPs: keepalived stopped on vyos001, all six VIPs moved to vyos002 within 12s, and returned on restart (preemption still default on the live boxes). Both boxes clean afterwards, no config drift. Master delta validated against vyos001's real running config on the sim router before installing. Installed on both: 6 no-preempt, 6 unicast pairs, DHCP HA primary/secondary respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 22:48:39 +01:00
def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool,
conntrack_link: bool) -> list[str]:
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
out: list[str] = []
feat(migration): complete the VyOS HA stack per the official docs Prompted by "I thought we tested HA on libvirt" -- checking rather than recalling showed the sim has ONE VyOS router with zero high-availability config. VRRP was configured and running on the real pair, but it is only one of four parts of what VyOS considers an HA pair. Against docs.vyos.io (highavailability, conntrack-sync, dhcp-server, and the HA walkthrough), three gaps are now closed in the delta: - VRRP was multicast-only with default preemption. Added unicast hello-source-address/peer-address per group, as the walkthrough does, plus no-preempt. Without no-preempt a recovered box reclaims the VIP before conntrack state has synced and drops every established connection; the docs are explicit that preempt-delay must otherwise be >= purge-timeout. The per-VLAN node addresses are a table, not derived: VLAN 3 is .4/.5 while every other VLAN is .252/.253. - DHCP high-availability, which fixes a real defect rather than adding a feature. Both boxes carried the full 6 subnets and 84 static-mappings, so after cutover two kea instances would have raced on the same broadcast domains. Now active-passive with primary/secondary and swapped source/remote, syncing over TCP 647 on the LoT addresses. Each subnet already carries the unique subnet-id kea HA requires, and the peer name deliberately differs from both host-names. - conntrack-sync over a dedicated eth3 <-> eth3 link, gated behind --conntrack-link because it needs a cable that is not plugged in yet. This is what the peer cable is actually for -- VRRP does not want one, since its hellos must travel on the segment they protect. VRRP failover exercised on the production pair, which is free to break today because nothing uses the .254 VIPs: keepalived stopped on vyos001, all six VIPs moved to vyos002 within 12s, and returned on restart (preemption still default on the live boxes). Both boxes clean afterwards, no config drift. Master delta validated against vyos001's real running config on the sim router before installing. Installed on both: 6 no-preempt, 6 unicast pairs, DHCP HA primary/secondary respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 22:48:39 +01:00
primary = priority >= 200 # vyos001 is the master/primary
self_i, peer_i = (0, 1) if primary else (1, 0)
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
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}")
feat(migration): complete the VyOS HA stack per the official docs Prompted by "I thought we tested HA on libvirt" -- checking rather than recalling showed the sim has ONE VyOS router with zero high-availability config. VRRP was configured and running on the real pair, but it is only one of four parts of what VyOS considers an HA pair. Against docs.vyos.io (highavailability, conntrack-sync, dhcp-server, and the HA walkthrough), three gaps are now closed in the delta: - VRRP was multicast-only with default preemption. Added unicast hello-source-address/peer-address per group, as the walkthrough does, plus no-preempt. Without no-preempt a recovered box reclaims the VIP before conntrack state has synced and drops every established connection; the docs are explicit that preempt-delay must otherwise be >= purge-timeout. The per-VLAN node addresses are a table, not derived: VLAN 3 is .4/.5 while every other VLAN is .252/.253. - DHCP high-availability, which fixes a real defect rather than adding a feature. Both boxes carried the full 6 subnets and 84 static-mappings, so after cutover two kea instances would have raced on the same broadcast domains. Now active-passive with primary/secondary and swapped source/remote, syncing over TCP 647 on the LoT addresses. Each subnet already carries the unique subnet-id kea HA requires, and the peer name deliberately differs from both host-names. - conntrack-sync over a dedicated eth3 <-> eth3 link, gated behind --conntrack-link because it needs a cable that is not plugged in yet. This is what the peer cable is actually for -- VRRP does not want one, since its hellos must travel on the segment they protect. VRRP failover exercised on the production pair, which is free to break today because nothing uses the .254 VIPs: keepalived stopped on vyos001, all six VIPs moved to vyos002 within 12s, and returned on restart (preemption still default on the live boxes). Both boxes clean afterwards, no config drift. Master delta validated against vyos001's real running config on the sim router before installing. Installed on both: 6 no-preempt, 6 unicast pairs, DHCP HA primary/secondary respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 22:48:39 +01:00
own, peer = NODE_ADDRS[vlan] if primary else NODE_ADDRS[vlan][::-1]
# Unicast VRRP: the walkthrough sets both ends explicitly rather than
# relying on multicast, which is more predictable across a switch fabric.
out.append(f"set high-availability vrrp group {grp} hello-source-address {own}")
out.append(f"set high-availability vrrp group {grp} peer-address {peer}")
# Without no-preempt a recovered box reclaims the VIP immediately --
# before conntrack state has synced -- and drops every established
# connection. If preemption is ever wanted, preempt-delay must be >=
# the conntrack-sync purge-timeout.
out.append(f"set high-availability vrrp group {grp} no-preempt")
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
out += [
"",
]
test(labsim): conntrack-sync verified, and it exposed a delta defect conntrack-sync proven working on the sim pair -- bidirectional replication with zero errors: MASTER internal 34 external(from peer) 52 62 pkts sent / 109 recv 0 err BACKUP internal 76 external(from peer) 36 142 pkts sent / 73 recv 0 err Getting there required learning something that changes the production config: **VyOS only engages conntrack when a firewall or NAT is configured.** With neither present, both routers reported zero conntrack entries and conntrack-sync had nothing to replicate. Adding a single state-matching forward rule turned tracking on and replication began immediately. That is a defect in the delta, not just a test artifact. NAT and the firewall were both gated behind --with-wan, so the BACKUP would have had neither -- it would not have tracked connections at all, and replicated entries are useless to a box whose conntrack is not engaged. Exactly the failure that only shows up during a failover, when it is too late to notice. Fixed: a stateful forward rule (accept established/related, default-action accept) is now emitted on BOTH boxes, outside the WAN gate. Only NAT and the WAN-scoped rules remain master-only. Verified: vyos002 now carries stateful tracking and conntrack-sync but zero NAT lines. Master delta re-validated against a real VyOS config -- no errors. Also incidentally confirmed no-preempt: router1 rebooted and came back as BACKUP rather than seizing the VIP, which is the opposite of what the production pair did this afternoon (still on default preempt until cutover). Two traps recorded while doing this: - The detached `setsid nohup` config-apply pattern can strand a VyOS config session. An orphaned session (dirs under /opt/vyatta/config/tmp/, PID long dead) blocked every subsequent `set` on that box with a bare "Set failed", and the dirs are overlay mounts so they cannot simply be deleted. Rebooting cleared it. This pattern is used to survive losing SSH mid-change, so it is worth knowing it has a failure mode of its own. - Only VLAN 10 passes traffic between the two sim routers; every other VLAN fails ARP despite identical vlan_mode/tag/trunks on both OVS bonds and distinct MACs. VRRP forms on all six groups regardless. The sync link had to be bond0.10 as a result. OVS-specific, absent in production, but it means the sim proves mechanism rather than topology. Production deltas regenerated with --conntrack-link: eth3 at 10.255.255.1/30 and .2/30 awaiting the cable, which is not yet plugged (carrier=0 on both). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:17:21 +01:00
out += [
"",
"# --- stateful tracking (BOTH boxes) ------------------------",
"# VyOS only engages conntrack when a firewall or NAT exists. The",
"# backup has no WAN and therefore no NAT, so without this rule it",
"# tracks nothing -- and conntrack-sync entries replicated to a box",
"# whose conntrack is not engaged cannot be used when it takes over.",
"# Verified in labsim: zero conntrack entries until a state-matching",
"# rule was present, then replication began immediately.",
"set firewall ipv4 forward filter default-action accept",
"set firewall ipv4 forward filter rule 10 action accept",
"set firewall ipv4 forward filter rule 10 state established",
"set firewall ipv4 forward filter rule 10 state related",
"set firewall ipv4 forward filter rule 10 description 'stateful tracking'",
]
fix(migration): both boxes carry WAN and NAT; the backup just holds it down "No NAT? How are we supposed to get internet?" -- a fair question that exposed a worse design than I had admitted. Internet did work, but only via vyos001: NAT and the entire WAN were gated behind --with-wan, so vyos002 would have held the LAN VIPs and routed between VLANs with no path to the outside at all. Failover would have preserved addressing and lost the internet. The fix rests on a checked fact rather than an assumption: VyOS WARNS but still commits when a NAT rule names an interface that does not exist ("Interface bond0.53 for source NAT rule 900 does not exist!"). Verified on a real VyOS before relying on it. So both boxes now get the identical WAN, NAT, port-forward and firewall config, and the backup's two WAN interfaces are simply set `disable`. The cloned WAN MAC is therefore never live on two boxes at once, while everything needed to route and masquerade is already in place. The two deltas are now byte-identical apart from VRRP priority, own/peer addresses, DHCP HA role, the conntrack /30 -- and the two disable lines. Taking over the internet path becomes deleting two lines rather than reconstructing NAT under pressure: delete interfaces bonding bond0 vif 53 disable delete interfaces pppoe pppoe0 disable Both boxes now: 21 NAT rules, 58 firewall rules, full PPPoE. Backup delta validated against a real VyOS config with the disable lines present -- commits clean. Runbook updated with the takeover procedure and the warning that it must only be done when vyos001 is genuinely down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:30:23 +01:00
if True: # WAN config on BOTH boxes; see the disable block below
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
out += [
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
"# --- WAN -----------------------------------------------",
"# Both vifs must be created before anything references them.",
"# Neither firewall has vif 51 or 53 today (only 2, 3, 9, 10, 200),",
"# and pppoe source-interface points at an interface that must",
"# already exist -- without this the commit fails and, since the",
"# delta commits as one unit, takes the whole switch with it.",
f"set interfaces bonding bond0 vif {WAN_PPPOE_VIF.split('.')[1]} description 'WAN1 Vodafone (PPPoE)'",
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} description 'WAN2 10gig ISP (DHCP)'",
"",
"# WAN2, the 10 gig line -- primary. The cloned MAC is what keeps",
"# the existing public lease (87.192.101.48) instead of asking for",
"# a new one. Only the box carrying the WAN may set this.",
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} mac '{WAN_DHCP_MAC}'",
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} address dhcp",
# Demoted below Vodafone; `protocols failover` owns the live route.
# NOT no-default-route -- that blanks new_routers in the lease and
# leaves the failover daemon with no gateway to install.
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} dhcp-options default-route-distance {DIST_DHCP_FALLBACK}",
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
"",
"# WAN1, Vodafone -- failover at a higher distance. Verified working",
"# on the USG: pppoe0 came up with a public address, MTU 1492.",
f"set interfaces pppoe {WAN_PPPOE_IF} source-interface {WAN_PPPOE_VIF}",
f"set interfaces pppoe {WAN_PPPOE_IF} authentication username '{wan_user}'",
f"set interfaces pppoe {WAN_PPPOE_IF} authentication password '{PLACEHOLDER}'",
f"set interfaces pppoe {WAN_PPPOE_IF} mtu 1492",
f"set interfaces pppoe {WAN_PPPOE_IF} default-route-distance {DIST_PPPOE}",
# The peer's resolvers would otherwise overwrite resolv.conf.
f"set interfaces pppoe {WAN_PPPOE_IF} no-peer-dns",
"",
"# The static default route exists only for unifi mode, where the",
"# USG is the next hop. Both WANs supply one here.",
"delete protocols static route 0.0.0.0/0",
"",
"# --- Health-checked primary ----------------------------",
"# Without this, failover only fires when bond0.53 loses carrier",
"# or its lease. An ISP that keeps the link up while dropping",
"# traffic -- the common failure -- would black-hole everything,",
"# because a DHCP-installed route has nothing to withdraw it.",
"#",
"# vyos-failover pings each target bound to the interface",
"# (`ping -I bond0.53`), so the backup can never be validated",
"# through the primary's path and vice versa. On withdrawal the",
"# kernel falls through to Vodafone's distance-10 route.",
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} check type icmp",
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} check policy any-available",
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} check timeout {FAILOVER_TIMEOUT}",
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} metric {FAILOVER_METRIC}",
*[
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} check target {t}"
for t in FAILOVER_TARGETS
],
"",
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
"# --- NAT -----------------------------------------------",
f"set nat source rule 100 outbound-interface name {WAN_DHCP_VIF}",
"set nat source rule 100 translation address masquerade",
"set nat source rule 100 description 'LAN out via the 10gig line'",
f"set nat source rule 110 outbound-interface name {WAN_PPPOE_IF}",
"set nat source rule 110 translation address masquerade",
"set nat source rule 110 description 'LAN out via Vodafone (failover)'",
]
fix(migration): both boxes carry WAN and NAT; the backup just holds it down "No NAT? How are we supposed to get internet?" -- a fair question that exposed a worse design than I had admitted. Internet did work, but only via vyos001: NAT and the entire WAN were gated behind --with-wan, so vyos002 would have held the LAN VIPs and routed between VLANs with no path to the outside at all. Failover would have preserved addressing and lost the internet. The fix rests on a checked fact rather than an assumption: VyOS WARNS but still commits when a NAT rule names an interface that does not exist ("Interface bond0.53 for source NAT rule 900 does not exist!"). Verified on a real VyOS before relying on it. So both boxes now get the identical WAN, NAT, port-forward and firewall config, and the backup's two WAN interfaces are simply set `disable`. The cloned WAN MAC is therefore never live on two boxes at once, while everything needed to route and masquerade is already in place. The two deltas are now byte-identical apart from VRRP priority, own/peer addresses, DHCP HA role, the conntrack /30 -- and the two disable lines. Taking over the internet path becomes deleting two lines rather than reconstructing NAT under pressure: delete interfaces bonding bond0 vif 53 disable delete interfaces pppoe pppoe0 disable Both boxes now: 21 NAT rules, 58 firewall rules, full PPPoE. Backup delta validated against a real VyOS config with the disable lines present -- commits clean. Runbook updated with the takeover procedure and the warning that it must only be done when vyos001 is genuinely down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:30:23 +01:00
if not with_wan:
vrrp-wan: a flap holdoff must not tear down a live WAN session ppp_dial() checked the flap holdoff and returned BEFORE renewing /run/vrrp-wan/may-dial. That lease is what vrrp-wan-guard expires after LEASE_TTL, so tripping the damper stopped the renew and the guard hung up pppoe0 on the MASTER ~80s later. A damper meant to suppress repeated DIALS was tearing down a working WAN instead. Observed in labsim, end to end: DIAL FLAP: >=6 attempts in 600s -- holding off 900s GUARD: lease stale (81s > 75s) -- hanging up pppoe0 An established session now outranks every check below it: ppp_active renews the lease and returns first. Everything after it only decides whether to start a NEW session. Two supporting fixes for how that storm started. The dial attempts were all no-ops because /etc/ppp/peers/pppoe0 was missing, and nothing said so -- systemd logs "skipped because of an unmet condition check" exactly once and the gate looks identical to a healthy backup. ppp_dial() now reports it, and distinguishes "configured but not rendered" (re-commit the subtree) from "no pppoe0 in config at all", which is what a reboot leaves behind when a commit was never saved. That is precisely how the sim secondary lost its WAN. Also `cat | wc -l` rather than `wc -l < file`: redirections are applied left to right, so the missing-file error escapes the 2>/dev/null on every first-ever dial. Harness: T11 copied-then-removed instead of mv, and verifies the restore -- losing that file strands a router permanently, which cost a debugging session. preflight now refuses to run if either router lacks the peers file or the pppoe0 config, since every failover result would otherwise be a false negative blamed on the ISP. New T12 forges a 900s holdoff against a live session and asserts it survives.
2026-09-06 00:04:41 +01:00
# Both boxes carry the identical WAN and NAT config; only the RESTING
# STATE differs, and only for the DHCP line. Takeover is no longer a
# human deleting two lines under pressure -- vrrp-wan-reconcile does it,
# driven by who holds the management VIP. See migration/PPPOE-HA.md.
#
# bond0.53 stays here, on the CONFIG plane, because its lease is bound
# to a cloned MAC and only VyOS config can move a MAC between boxes.
# This is the "nothing to follow" default: a freshly built or PXE'd box
# has no live master to imitate, so it must come up unable to claim that
# MAC. On a running pair the model follows reality instead -- see the
# export-before-apply rule in migration/PPPOE-HA.md.
#
# pppoe0 is deliberately NOT disabled here any more. `disable` unlinks
# /etc/ppp/peers/pppoe0, which is pppd's own options file, so it
# destroys what the promotion path needs and leaves ppp@pppoe0
# restart-looping. Dialling is gated at the systemd unit instead.
#
# ORDERING TRAP for a rebuilt box: because pppoe0 is left ENABLED,
# interfaces_pppoe.py will try to dial on the first commit that touches
# the pppoe subtree. Install the gate FIRST --
# `migration/vrrp-wan-install --vip <mgmt VIP> --host vyos@<box>` --
# or the new box will take the single ISP session off the live master.
fix(migration): both boxes carry WAN and NAT; the backup just holds it down "No NAT? How are we supposed to get internet?" -- a fair question that exposed a worse design than I had admitted. Internet did work, but only via vyos001: NAT and the entire WAN were gated behind --with-wan, so vyos002 would have held the LAN VIPs and routed between VLANs with no path to the outside at all. Failover would have preserved addressing and lost the internet. The fix rests on a checked fact rather than an assumption: VyOS WARNS but still commits when a NAT rule names an interface that does not exist ("Interface bond0.53 for source NAT rule 900 does not exist!"). Verified on a real VyOS before relying on it. So both boxes now get the identical WAN, NAT, port-forward and firewall config, and the backup's two WAN interfaces are simply set `disable`. The cloned WAN MAC is therefore never live on two boxes at once, while everything needed to route and masquerade is already in place. The two deltas are now byte-identical apart from VRRP priority, own/peer addresses, DHCP HA role, the conntrack /30 -- and the two disable lines. Taking over the internet path becomes deleting two lines rather than reconstructing NAT under pressure: delete interfaces bonding bond0 vif 53 disable delete interfaces pppoe pppoe0 disable Both boxes now: 21 NAT rules, 58 firewall rules, full PPPoE. Backup delta validated against a real VyOS config with the disable lines present -- commits clean. Runbook updated with the takeover procedure and the warning that it must only be done when vyos001 is genuinely down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:30:23 +01:00
#
# NAT rules naming a down interface are harmless: VyOS warns at commit
# ("Interface ... does not exist!") and commits anyway, verified.
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
out += [
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
"",
vrrp-wan: a flap holdoff must not tear down a live WAN session ppp_dial() checked the flap holdoff and returned BEFORE renewing /run/vrrp-wan/may-dial. That lease is what vrrp-wan-guard expires after LEASE_TTL, so tripping the damper stopped the renew and the guard hung up pppoe0 on the MASTER ~80s later. A damper meant to suppress repeated DIALS was tearing down a working WAN instead. Observed in labsim, end to end: DIAL FLAP: >=6 attempts in 600s -- holding off 900s GUARD: lease stale (81s > 75s) -- hanging up pppoe0 An established session now outranks every check below it: ppp_active renews the lease and returns first. Everything after it only decides whether to start a NEW session. Two supporting fixes for how that storm started. The dial attempts were all no-ops because /etc/ppp/peers/pppoe0 was missing, and nothing said so -- systemd logs "skipped because of an unmet condition check" exactly once and the gate looks identical to a healthy backup. ppp_dial() now reports it, and distinguishes "configured but not rendered" (re-commit the subtree) from "no pppoe0 in config at all", which is what a reboot leaves behind when a commit was never saved. That is precisely how the sim secondary lost its WAN. Also `cat | wc -l` rather than `wc -l < file`: redirections are applied left to right, so the missing-file error escapes the 2>/dev/null on every first-ever dial. Harness: T11 copied-then-removed instead of mv, and verifies the restore -- losing that file strands a router permanently, which cost a debugging session. preflight now refuses to run if either router lacks the peers file or the pppoe0 config, since every failover result would otherwise be a false negative blamed on the ISP. New T12 forges a 900s holdoff against a live session and asserts it survives.
2026-09-06 00:04:41 +01:00
"# --- 10 gig held DOWN on this box --------------------------",
"# Do NOT enable by hand: vrrp-wan-reconcile owns this, keyed on",
"# whoever holds the management VIP. pppoe0 is gated at the unit",
"# (ppp@pppoe0.service.d/10-vrrp-wan-gate.conf), not in config.",
fix(migration): both boxes carry WAN and NAT; the backup just holds it down "No NAT? How are we supposed to get internet?" -- a fair question that exposed a worse design than I had admitted. Internet did work, but only via vyos001: NAT and the entire WAN were gated behind --with-wan, so vyos002 would have held the LAN VIPs and routed between VLANs with no path to the outside at all. Failover would have preserved addressing and lost the internet. The fix rests on a checked fact rather than an assumption: VyOS WARNS but still commits when a NAT rule names an interface that does not exist ("Interface bond0.53 for source NAT rule 900 does not exist!"). Verified on a real VyOS before relying on it. So both boxes now get the identical WAN, NAT, port-forward and firewall config, and the backup's two WAN interfaces are simply set `disable`. The cloned WAN MAC is therefore never live on two boxes at once, while everything needed to route and masquerade is already in place. The two deltas are now byte-identical apart from VRRP priority, own/peer addresses, DHCP HA role, the conntrack /30 -- and the two disable lines. Taking over the internet path becomes deleting two lines rather than reconstructing NAT under pressure: delete interfaces bonding bond0 vif 53 disable delete interfaces pppoe pppoe0 disable Both boxes now: 21 NAT rules, 58 firewall rules, full PPPoE. Backup delta validated against a real VyOS config with the disable lines present -- commits clean. Runbook updated with the takeover procedure and the warning that it must only be done when vyos001 is genuinely down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:30:23 +01:00
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} disable",
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
]
fix(migration): both boxes carry WAN and NAT; the backup just holds it down "No NAT? How are we supposed to get internet?" -- a fair question that exposed a worse design than I had admitted. Internet did work, but only via vyos001: NAT and the entire WAN were gated behind --with-wan, so vyos002 would have held the LAN VIPs and routed between VLANs with no path to the outside at all. Failover would have preserved addressing and lost the internet. The fix rests on a checked fact rather than an assumption: VyOS WARNS but still commits when a NAT rule names an interface that does not exist ("Interface bond0.53 for source NAT rule 900 does not exist!"). Verified on a real VyOS before relying on it. So both boxes now get the identical WAN, NAT, port-forward and firewall config, and the backup's two WAN interfaces are simply set `disable`. The cloned WAN MAC is therefore never live on two boxes at once, while everything needed to route and masquerade is already in place. The two deltas are now byte-identical apart from VRRP priority, own/peer addresses, DHCP HA role, the conntrack /30 -- and the two disable lines. Taking over the internet path becomes deleting two lines rather than reconstructing NAT under pressure: delete interfaces bonding bond0 vif 53 disable delete interfaces pppoe pppoe0 disable Both boxes now: 21 NAT rules, 58 firewall rules, full PPPoE. Backup delta validated against a real VyOS config with the disable lines present -- commits clean. Runbook updated with the takeover procedure and the warning that it must only be done when vyos001 is genuinely down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-17 00:30:23 +01:00
if True:
# Port forwards and the WAN firewall go on BOTH boxes. They name
# interfaces that are present-but-disabled on the backup, which VyOS
# accepts (it warns and commits). Putting them here means a failover is
# enabling an interface, not reconstructing NAT under pressure.
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
# 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_DHCP_VIF}",
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']}",
]
# `destination port` accepts a comma list but `translation port` does
# NOT -- "16881,6881 is not a valid service name" -- because mapping a
# list onto a list is ambiguous. Every forward here maps a port to
# itself, and omitting translation port makes VyOS preserve the
# original, which is exactly right. Only emit it when it genuinely
# differs, and refuse rather than guess when a differing list appears.
if p["fwd_port"] != p["dst_port"]:
if "," in str(p["fwd_port"]) or "," in str(p["dst_port"]):
raise SystemExit(
f"port forward '{p['name']}' remaps a LIST of ports "
f"({p['dst_port']} -> {p['fwd_port']}). VyOS cannot express "
f"that in one rule; split it into one rule per port by hand.")
out.append(f"set nat destination rule {rule} translation port '{p['fwd_port']}'")
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
out += [
"",
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
"# --- 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_DHCP_VIF}",
"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_DHCP_VIF}",
"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_DHCP_VIF}",
"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_DHCP_VIF}",
f"set firewall ipv4 input filter rule 910 action drop",
f"set firewall ipv4 input filter rule 910 inbound-interface name {WAN_PPPOE_IF}",
"set firewall ipv4 input filter rule 910 description 'drop all other WAN-to-router (Vodafone)'",
"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'",
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
]
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
# 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_DHCP_VIF}",
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_DHCP_VIF}",
f"set firewall ipv4 forward filter rule 910 action drop",
f"set firewall ipv4 forward filter rule 910 inbound-interface name {WAN_PPPOE_IF}",
"set firewall ipv4 forward filter rule 910 description 'drop unsolicited WAN-to-LAN (Vodafone)'",
"set firewall ipv4 forward filter rule 900 description 'drop unsolicited WAN-to-LAN'",
"",
]
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
feat(migration): complete the VyOS HA stack per the official docs Prompted by "I thought we tested HA on libvirt" -- checking rather than recalling showed the sim has ONE VyOS router with zero high-availability config. VRRP was configured and running on the real pair, but it is only one of four parts of what VyOS considers an HA pair. Against docs.vyos.io (highavailability, conntrack-sync, dhcp-server, and the HA walkthrough), three gaps are now closed in the delta: - VRRP was multicast-only with default preemption. Added unicast hello-source-address/peer-address per group, as the walkthrough does, plus no-preempt. Without no-preempt a recovered box reclaims the VIP before conntrack state has synced and drops every established connection; the docs are explicit that preempt-delay must otherwise be >= purge-timeout. The per-VLAN node addresses are a table, not derived: VLAN 3 is .4/.5 while every other VLAN is .252/.253. - DHCP high-availability, which fixes a real defect rather than adding a feature. Both boxes carried the full 6 subnets and 84 static-mappings, so after cutover two kea instances would have raced on the same broadcast domains. Now active-passive with primary/secondary and swapped source/remote, syncing over TCP 647 on the LoT addresses. Each subnet already carries the unique subnet-id kea HA requires, and the peer name deliberately differs from both host-names. - conntrack-sync over a dedicated eth3 <-> eth3 link, gated behind --conntrack-link because it needs a cable that is not plugged in yet. This is what the peer cable is actually for -- VRRP does not want one, since its hellos must travel on the segment they protect. VRRP failover exercised on the production pair, which is free to break today because nothing uses the .254 VIPs: keepalived stopped on vyos001, all six VIPs moved to vyos002 within 12s, and returned on restart (preemption still default on the live boxes). Both boxes clean afterwards, no config drift. Master delta validated against vyos001's real running config on the sim router before installing. Installed on both: 6 no-preempt, 6 unicast pairs, DHCP HA primary/secondary respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 22:48:39 +01:00
# --- DHCP high-availability -------------------------------------------
# Without this BOTH boxes run kea on the same VLANs and race to answer the
# same broadcasts, handing different pool addresses to the same client.
# active-passive so only the primary serves, matching the VRRP shape.
dhcp_self, dhcp_peer = NODE_ADDRS[10][self_i], NODE_ADDRS[10][peer_i]
out += [
"",
"# --- DHCP high-availability --------------------------------",
"# Peers sync leases over TCP 647. Each subnet already carries a",
"# unique subnet-id (keyed on VLAN id), which kea HA requires.",
"set service dhcp-server high-availability mode active-passive",
f"set service dhcp-server high-availability status {'primary' if primary else 'secondary'}",
# The peer name must not collide with either system host-name.
f"set service dhcp-server high-availability name {DHCP_HA_NAME}",
f"set service dhcp-server high-availability source-address {dhcp_self}",
f"set service dhcp-server high-availability remote {dhcp_peer}",
]
if conntrack_link:
# Stateful failover. Without it VRRP moves the address but every
# established connection dies, because the backup has no conntrack
# table. Needs the eth3 <-> eth3 cable physically present.
out += [
"",
"# --- conntrack-sync ----------------------------------------",
"# Dedicated point-to-point link: sync traffic must not compete",
"# with production, and must not die when the LAN does.",
f"set interfaces ethernet {CONNTRACK_IF} address {CONNTRACK_ADDRS[self_i]}",
f"set interfaces ethernet {CONNTRACK_IF} description 'conntrack-sync peer link'",
f"set service conntrack-sync interface {CONNTRACK_IF}",
"set service conntrack-sync failover-mechanism vrrp sync-group MAIN",
"set service conntrack-sync accept-protocol tcp",
"set service conntrack-sync accept-protocol udp",
"set service conntrack-sync accept-protocol icmp",
"set service conntrack-sync mcast-group 225.0.0.50",
]
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
# 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"))
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
ap.add_argument("--with-wan", action="store_true",
help="configure the WAN on this box. Only ONE of the pair may have\n it, because the cloned WAN MAC must be unique.")
feat(migration): complete the VyOS HA stack per the official docs Prompted by "I thought we tested HA on libvirt" -- checking rather than recalling showed the sim has ONE VyOS router with zero high-availability config. VRRP was configured and running on the real pair, but it is only one of four parts of what VyOS considers an HA pair. Against docs.vyos.io (highavailability, conntrack-sync, dhcp-server, and the HA walkthrough), three gaps are now closed in the delta: - VRRP was multicast-only with default preemption. Added unicast hello-source-address/peer-address per group, as the walkthrough does, plus no-preempt. Without no-preempt a recovered box reclaims the VIP before conntrack state has synced and drops every established connection; the docs are explicit that preempt-delay must otherwise be >= purge-timeout. The per-VLAN node addresses are a table, not derived: VLAN 3 is .4/.5 while every other VLAN is .252/.253. - DHCP high-availability, which fixes a real defect rather than adding a feature. Both boxes carried the full 6 subnets and 84 static-mappings, so after cutover two kea instances would have raced on the same broadcast domains. Now active-passive with primary/secondary and swapped source/remote, syncing over TCP 647 on the LoT addresses. Each subnet already carries the unique subnet-id kea HA requires, and the peer name deliberately differs from both host-names. - conntrack-sync over a dedicated eth3 <-> eth3 link, gated behind --conntrack-link because it needs a cable that is not plugged in yet. This is what the peer cable is actually for -- VRRP does not want one, since its hellos must travel on the segment they protect. VRRP failover exercised on the production pair, which is free to break today because nothing uses the .254 VIPs: keepalived stopped on vyos001, all six VIPs moved to vyos002 within 12s, and returned on restart (preemption still default on the live boxes). Both boxes clean afterwards, no config drift. Master delta validated against vyos001's real running config on the sim router before installing. Installed on both: 6 no-preempt, 6 unicast pairs, DHCP HA primary/secondary respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 22:48:39 +01:00
ap.add_argument("--conntrack-link", action="store_true",
help="emit conntrack-sync over the eth3 peer link. Requires the\n cable to be physically present on both boxes.")
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
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
feat(migration): complete the VyOS HA stack per the official docs Prompted by "I thought we tested HA on libvirt" -- checking rather than recalling showed the sim has ONE VyOS router with zero high-availability config. VRRP was configured and running on the real pair, but it is only one of four parts of what VyOS considers an HA pair. Against docs.vyos.io (highavailability, conntrack-sync, dhcp-server, and the HA walkthrough), three gaps are now closed in the delta: - VRRP was multicast-only with default preemption. Added unicast hello-source-address/peer-address per group, as the walkthrough does, plus no-preempt. Without no-preempt a recovered box reclaims the VIP before conntrack state has synced and drops every established connection; the docs are explicit that preempt-delay must otherwise be >= purge-timeout. The per-VLAN node addresses are a table, not derived: VLAN 3 is .4/.5 while every other VLAN is .252/.253. - DHCP high-availability, which fixes a real defect rather than adding a feature. Both boxes carried the full 6 subnets and 84 static-mappings, so after cutover two kea instances would have raced on the same broadcast domains. Now active-passive with primary/secondary and swapped source/remote, syncing over TCP 647 on the LoT addresses. Each subnet already carries the unique subnet-id kea HA requires, and the peer name deliberately differs from both host-names. - conntrack-sync over a dedicated eth3 <-> eth3 link, gated behind --conntrack-link because it needs a cable that is not plugged in yet. This is what the peer cable is actually for -- VRRP does not want one, since its hellos must travel on the segment they protect. VRRP failover exercised on the production pair, which is free to break today because nothing uses the .254 VIPs: keepalived stopped on vyos001, all six VIPs moved to vyos002 within 12s, and returned on restart (preemption still default on the live boxes). Both boxes clean afterwards, no config drift. Master delta validated against vyos001's real running config on the sim router before installing. Installed on both: 6 no-preempt, 6 unicast pairs, DHCP HA primary/secondary respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 22:48:39 +01:00
lines = build_delta(inv, args.priority, wan["wan_username"], args.with_wan,
args.conntrack_link)
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
text = "\n".join(lines) + "\n"
feat(migration): dual WAN, cloned MAC, and the new 10.8.0.0/23 Private VLAN Two corrections from reading the live USG instead of trusting UniFi's fields, which report wan_type=dhcp for both WANs and are simply wrong: - There are TWO WANs, not one. WAN2 is the 10 gig ISP on VLAN 53, plain DHCP with a PUBLIC address (87.192.101.48/21, gw 87.192.96.1) on the USG's eth2 -- and it is what actually carries traffic. WAN1 is Vodafone PPPoE on VLAN 51, the failover. The delta had PPPoE as the only WAN, which would have left the primary line unconfigured. - The DHCP lease is bound to MAC, so bond0.53 now clones the USG's WAN2 MAC (f0:9f:c2:12:9b:4f). That is how VyOS keeps the existing public lease rather than negotiating a new one -- or getting none, if the ISP allows one per line. Distances: 10 gig at 1, Vodafone at 10. Only ONE box may hold the cloned MAC, so --with-wan gates the entire WAN, NAT and firewall section. vyos001 gets it (320 set lines); vyos002 gets none (234, zero WAN/NAT/firewall) and routes the LAN only. Pretending both could hold it would have meant a duplicate MAC on VLAN 53 and a flapping switch table. Private was rebuilt at 10.8.0.0/23 (VLAN 9) after the old 10.0.8.0/23 was deleted. bond0.9 and the VRRP group were moved to 10.8.0.252/.253 with VIP 10.8.0.254 on both boxes, and the delta now targets 10.8.0.1. Creating that network first required breaking a deadlock in UniFi: every LAN write was rejected with api.err.WanIpOverlapped / 0.0.0.0/0, because WAN1 was set to DHCP on a line that only speaks PPPoE, so it sat at 0.0.0.0 forever and the validator treated that as a subnet overlapping everything. Verified server-side, not a UI bug -- the API rejected it identically. Setting wan_type=pppoe let it dial (90.241.226.213, MTU 1492), which cleared the phantom overlap and incidentally PROVED the Vodafone credentials and line work, which had been listed as untestable before cutover. dhcp-options no-default-route-dns does not exist; the valid set is client-id, default-route-distance, host-name, mtu, no-default-route, reject, user-class, vendor-class-id. Caught by validating the delta against vyos001's real config on the labsim router before installing. After adding the network, the gateway's dhcpd.conf was checked with `dhcpd3 -t -cf` (valid) and confirmed to contain the new subnet only after a force-provision -- controller state is not device state. Both boxes: mode unifi, VRRP unchanged, unifi.boot re-captured (232 lines, carrying the new VLAN 9), no config drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-16 18:16:25 +01:00
# Only a WAN-carrying delta has a credential to placeholder-substitute.
if args.with_wan and PLACEHOLDER not in text:
print("BUG: password placeholder missing from a WAN delta", file=sys.stderr)
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
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())