From 44dbd5188c4696fac40d0660be5817b0385792ba Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 01:33:00 +0100 Subject: [PATCH 01/26] feat(migration): export UniFi config and generate VyOS DHCP+DNS from it Groundwork for replacing the USG with the VyOS pair without anything on the network noticing. Three pieces: migration/unifi-export.py pulls 13 endpoints off the classic controller into timestamped JSON plus a normalised inventory: 11 networks, 31 DHCP reservations, 4 port forwards, 2 firewall rules, 0 static routes. Two things this turned up that a naive export would have lost: - 23 of the 31 reservations carry no network_id at all -- UniFi simply does not store the binding -- so they are resolved by subnet containment instead. Without that, three quarters of the reservations have no subnet to be placed in. - 30 of the 31 sit INSIDE the DHCP pool, which UniFi's dhcpd tolerates and which is flagged as a warning rather than discovered at cutover. migration/unifi-to-vyos.py turns that inventory into VyOS `set` commands for DHCP and DNS only -- the services the USG owns that VyOS must reproduce. Not a general converter. --prod and --sim come from one code path so the config proven in the sim and the config applied to the firewalls cannot drift. Prod mode hard-fails if any reservation is missing, since a silent drop is the failure mode that matters. DNS is included because the USG resolves for 5 of 6 VLANs today: UniFi hands out the gateway's own address whenever dhcpd_dns is empty, verified by labmaster resolving against 192.168.8.1. Replacing the USG without a forwarder would take DNS away from those VLANs entirely. labsim/labsim-dhcp-test.sh proves it by booting throwaway VMs with real production MACs -- the one piece of production config that transplants verbatim. Safe because ovs-labsim has no physical NIC, so those MACs cannot reach the real LAN. Result on VyOS 2026.08 (kea), 4/4: printer1 got 172.31.10.46 from inside the pool, sonoff-matter got 172.31.11.67 across the /23 boundary, Hubitat got its out-of-pool .2, and an unreserved MAC got an unreserved address. kea honours in-pool host reservations -- the open question blocking the cutover. Supporting changes to labsim: - VLAN 10 widened to /23. Every reservation is in LoT and LoT spans 10.0.0.x and 10.0.1.x, which a /24 cannot represent. - LoT's host leg moved to .3, because 10.0.0.2 is a real reservation (Hubitat) that maps onto the host's own address. - vlans.conf gained optional masklen and host_octet fields, defaulting to 24 and 2 so the other five VLANs are untouched. - Fixed /etc/network/interfaces hardcoding 255.255.255.0. That file is what actually takes effect on these Alpine guests -- cloud-init's network-config is ignored -- so any non-/24 VLAN was silently wrong. Two generator bugs found by VyOS rejecting the output: static-mapping names are validated as hostnames, so underscores fail; and two devices named "espressif" plus two named "thebeast" collided into single names, which would have overwritten one reservation with another's address. The raw export holds WiFi passphrases and the WAN PPPoE credentials and is gitignored. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- labsim/README.md | 47 +++++- labsim/labsim-dhcp-test.sh | 189 +++++++++++++++++++++ labsim/labsim-matrix.py | 10 +- labsim/labsim-up.sh | 5 +- labsim/lib.sh | 29 +++- labsim/ovs.sh | 11 +- labsim/vlans.conf | 15 +- migration/.gitignore | 4 + migration/_unifi.py | 51 ++++++ migration/unifi-export.py | 335 +++++++++++++++++++++++++++++++++++++ migration/unifi-to-vyos.py | 253 ++++++++++++++++++++++++++++ 11 files changed, 934 insertions(+), 15 deletions(-) create mode 100755 labsim/labsim-dhcp-test.sh create mode 100644 migration/.gitignore create mode 100755 migration/_unifi.py create mode 100755 migration/unifi-export.py create mode 100755 migration/unifi-to-vyos.py diff --git a/labsim/README.md b/labsim/README.md index 615c93d..05af260 100644 --- a/labsim/README.md +++ b/labsim/README.md @@ -15,10 +15,27 @@ Each VLAN is its own isolated libvirt network with one tiny Alpine VM on it. | 2 | k8s | 172.31.2.0/24 | 172.31.2.10 | 192.168.8.0/23 | | 3 | kvm | 172.31.3.0/24 | 172.31.3.10 | 192.168.3.0/24 | | 9 | private | 172.31.9.0/24 | 172.31.9.10 | 10.0.9.0/23 | -| 10 | lot | 172.31.10.0/24 | 172.31.10.10 | 10.0.0.0/23 | +| 10 | lot | **172.31.10.0/23** | 172.31.10.10 | 10.0.0.0/23 | | 200 | roomates | 172.31.200.0/24 | 172.31.200.10 | 192.168.2.0/24 | -The sim subnet always encodes the VLAN id: `172.31..0/24`. +The sim subnet encodes the VLAN id: `172.31..0/24`, with one exception. +**VLAN 10 is a `/23`** because every UniFi DHCP reservation lives in LoT and LoT +spans `10.0.0.x` *and* `10.0.1.x`, which a `/24` cannot hold. The mapping stays +readable — `10.0.0.46 → 172.31.10.46`, `10.0.1.67 → 172.31.11.67`. + +LoT's host leg is `.3`, not `.2`, because `10.0.0.2` is a real reservation +(Hubitat) that maps onto `172.31.10.2`. `.3` is unreserved and sits below the +DHCP pool, so it can never be handed out. + +`vlans.conf` therefore takes two optional trailing fields: + +``` +vlan_id:name:sim_prefix:real_subnet[:masklen][:host_octet] +``` + +defaulting to `24` and `2`. k8s and Private are also `/23` in production but +hold no reservations, so they keep their `/24` and their DHCP range is clamped +— reported at generation time, never silently. Address plan, identical on every VLAN: @@ -69,6 +86,32 @@ sudo virsh console labsim-2-k8s # root / labsim ./monitoring-up.sh # topology page + Prometheus + Grafana ``` +## Testing the DHCP migration + +`./labsim-dhcp-test.sh` boots throwaway VMs whose MACs are **real production +MACs** and checks each gets the address UniFi reserved for it. MACs are the one +piece of production config that transplants verbatim, which is what makes this a +test rather than a rehearsal. It is safe because `ovs-labsim` has no physical +NIC — verified with `ovs-vsctl show` — so a production MAC cannot reach the real +LAN. + +Apply the config first, from `../migration`: + +```bash +python3 unifi-to-vyos.py --mode sim -o /tmp/sim.conf # 6 subnets, 31 mappings +# load onto labsim-vyos, then: +./labsim-dhcp-test.sh +``` + +**Result on VyOS 2026.08 (kea): all four cases pass.** The one that mattered: +30 of the 31 UniFi reservations sit *inside* the DHCP pool, and **kea honours +in-pool host reservations** — `printer1` received `172.31.10.46` from within the +`.10.11–.11.254` pool. That was the open question blocking the cutover. + +Still open: whether kea will hand a *reserved* address to a *different* client +while the reserved device is offline. The negative case here only proves an +unreserved MAC gets an unreserved address. + - **http://localhost:9101/** — live mesh: a node per VLAN, the router in the middle, one line per pair coloured green/red with the ICMP RTT on it. Hover a line for per-direction detail. Refreshes every 5s. This is the one to watch diff --git a/labsim/labsim-dhcp-test.sh b/labsim/labsim-dhcp-test.sh new file mode 100755 index 0000000..3d79626 --- /dev/null +++ b/labsim/labsim-dhcp-test.sh @@ -0,0 +1,189 @@ +#!/bin/bash +# Prove that VyOS hands each device the address UniFi reserved for it. +# +# The question this answers is narrow and important: 30 of the 31 UniFi +# reservations sit INSIDE the DHCP pool (LoT's pool is 10.0.0.11-10.0.1.254 and +# only 10.0.0.2 falls outside it). UniFi's dhcpd tolerates that. VyOS uses kea, +# and whether kea honours in-pool host reservations decides whether the cutover +# silently renumbers 30 devices. That is not something to predict. +# +# Method: boot throwaway VMs whose MAC is a REAL production MAC, on the sim +# VLAN, and check the address they are given. MACs are the one piece of +# production config that transplants verbatim -- the subnet is rewritten, the +# MAC is not -- which is what makes this a real test rather than a rehearsal. +# +# Safe: the ovs-labsim bridge contains only internal ports and VM taps, with no +# physical NIC, so a production MAC here cannot reach or confuse the real LAN. +# Verified with `ovs-vsctl show` before this script was written. +# +# ./labsim-dhcp-test.sh run the standard cases +# ./labsim-dhcp-test.sh --keep leave the VMs up for inspection +# ./labsim-dhcp-test.sh --clean just remove any leftover test VMs +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/lib.sh" + +ROUTER_IP="${ROUTER_IP:-172.31.1.1}" +ROUTER_PW="${ROUTER_PW:-vyos}" +TEST_VLAN="${TEST_VLAN:-10}" +BOOT_WAIT="${BOOT_WAIT:-150}" +TAG="labsim-dhcptest" + +# mac|expected|why. "POOL" means: must get an address from the pool and must +# NOT get any reserved address -- the negative case that stops a pass from +# meaning merely "DHCP works". +CASES=( + "f8:0d:ac:90:65:c6|172.31.10.46|printer1 - reservation inside the pool" + "1c:69:20:7f:bc:77|172.31.11.67|sonoff-matter - in-pool AND across the /23 boundary" + "34:e1:d1:80:29:ce|172.31.10.2|Hubitat - the one reservation OUTSIDE the pool" + "52:54:00:ab:cd:ef|POOL|unreserved MAC - must get a pool address, not a reserved one" +) + +vm_of() { echo "${TAG}-$(echo "$1" | tr -d ':')"; } + +cleanup_vms() { + local n=0 + while read -r vm; do + [ -z "$vm" ] && continue + virsh_q destroy "$vm" >/dev/null 2>&1 + virsh_q undefine "$vm" --remove-all-storage >/dev/null 2>&1 + n=$((n + 1)) + done < <(virsh_q list --all --name 2>/dev/null | grep "^${TAG}-" || true) + [ "$n" -gt 0 ] && log "removed $n test VM(s)" + sudo rm -f "$IMG_DIR/${TAG}-"*.qcow2 "$IMG_DIR/${TAG}-"*-seed.iso 2>/dev/null + return 0 +} + +# A seed that asks for DHCP instead of taking a static address. Alpine's +# cloud-init ignores network-config here (verified previously and documented in +# README), so /etc/network/interfaces is what actually takes effect. +build_dhcp_seed() { + local iso="$1" vm="$2" pubkey="$3" + local tmp; tmp="$(mktemp -d)" + cat > "$tmp/meta-data" < "$tmp/user-data" </dev/null; ifup eth0 || udhcpc -i eth0 -q || true" ] +EOF + python3 - "$tmp/user-data" <<'PY' || die "generated user-data is not valid YAML" +import sys, yaml +yaml.safe_load(open(sys.argv[1]).read().split("#cloud-config",1)[1]) +PY + sudo genisoimage -quiet -output "$iso" -volid cidata -joliet -rock \ + "$tmp/user-data" "$tmp/meta-data" >/dev/null 2>&1 || die "seed build failed" + rm -rf "$tmp" +} + +router() { + timeout 30 sshpass -p "$ROUTER_PW" ssh -o StrictHostKeyChecking=no \ + -o BatchMode=no -o ConnectTimeout=8 "vyos@$ROUTER_IP" "$@" 2>/dev/null +} + +# --- argument handling ---------------------------------------------------- +KEEP=0 +case "${1:-}" in + --clean) cleanup_vms; exit 0 ;; + --keep) KEEP=1 ;; + "") ;; + *) die "usage: $0 [--keep|--clean]" ;; +esac + +command -v sshpass >/dev/null || die "sshpass required" +require_tools +[ -f "$BASE_IMAGE" ] || die "base image missing: $BASE_IMAGE (run labsim-up.sh first)" + +log "checking the router is serving DHCP..." +subnets=$(router '/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands | grep -c subnet-id') +maps=$(router '/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands | grep -c "static-mapping .* mac"') +log " router has ${subnets:-0} subnets and ${maps:-0} static-mappings" +[ "${maps:-0}" -gt 0 ] || die "router has no static-mappings -- apply the generated config first" + +cleanup_vms +SSH_PUB="$(find_ssh_pubkey)" +sudo mkdir -p "$IMG_DIR" + +# --- boot one VM per case ------------------------------------------------- +for c in "${CASES[@]}"; do + IFS='|' read -r mac expected why <<<"$c" + vm="$(vm_of "$mac")" + disk="$IMG_DIR/${vm}.qcow2"; seed="$IMG_DIR/${vm}-seed.iso" + log "booting $vm mac=$mac ($why)" + sudo qemu-img create -q -f qcow2 -F qcow2 -b "$BASE_IMAGE" "$disk" "$VM_DISK" >/dev/null + build_dhcp_seed "$seed" "$vm" "$SSH_PUB" + sudo virt-install --connect "$LIBVIRT_URI" --name "$vm" \ + --memory "$VM_MEM" --vcpus "$VM_CPUS" \ + --disk "path=$disk,format=qcow2,bus=virtio" \ + --disk "path=$seed,device=cdrom,readonly=on" \ + --network "network=labsim-ovs,portgroup=vlan${TEST_VLAN},model=virtio,mac=$mac" \ + --os-variant alpinelinux3.18 --graphics none --noautoconsole --import >/dev/null \ + || die "virt-install failed for $vm" +done + +log "waiting ${BOOT_WAIT}s for boot + DHCP..." +sleep "$BOOT_WAIT" + +# --- verdict -------------------------------------------------------------- +# The lease table on the router is the authority: it says what the server +# decided, independent of whether the guest brought the interface up cleanly. +leases="$(router '/opt/vyatta/bin/vyatta-op-cmd-wrapper show dhcp server leases')" +echo +echo "=== router lease table ===" +echo "$leases" +echo + +reserved_ips="$(cd "$SCRIPT_DIR/../migration" && python3 unifi-to-vyos.py --mode sim 2>/dev/null \ + | awk '/static-mapping .* ip-address/ {print $NF}')" + +pass=0; fail=0 +printf '%-19s %-16s %-16s %s\n' "MAC" "EXPECTED" "GOT" "RESULT" +for c in "${CASES[@]}"; do + IFS='|' read -r mac expected why <<<"$c" + got="$(echo "$leases" | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $1; exit}')" + got="${got:-}" + if [ "$expected" = "POOL" ]; then + if [ "$got" = "" ]; then + result="FAIL (no lease at all)" + elif echo "$reserved_ips" | grep -qx "$got"; then + result="FAIL (got a RESERVED address)" + else + result="pass" + fi + else + [ "$got" = "$expected" ] && result="pass" || result="FAIL" + fi + [ "$result" = "pass" ] && pass=$((pass + 1)) || fail=$((fail + 1)) + printf '%-19s %-16s %-16s %s\n' "$mac" "$expected" "$got" "$result" + printf ' %s\n' "$why" +done + +echo +log "$pass passed, $fail failed" +[ "$KEEP" -eq 1 ] && log "VMs left running (--keep). Remove with: $0 --clean" || cleanup_vms +[ "$fail" -eq 0 ] || exit 1 diff --git a/labsim/labsim-matrix.py b/labsim/labsim-matrix.py index 425624f..8250510 100755 --- a/labsim/labsim-matrix.py +++ b/labsim/labsim-matrix.py @@ -78,9 +78,15 @@ def load_vlans() -> list[dict]: line = line.strip() if not line or line.startswith("#"): continue - vid, name, prefix, real = line.split(":", 3) + # masklen and host_octet are optional trailing fields; VLAN 10 sets + # both because it must be a /23 (see vlans.conf). + parts = line.split(":") + vid, name, prefix, real = parts[0], parts[1], parts[2], parts[3] + masklen = int(parts[4]) if len(parts) > 4 and parts[4] else 24 + host = parts[5] if len(parts) > 5 and parts[5] else "2" vlans.append({"vid": vid, "name": name, "ip": f"{prefix}.10", - "label": f"{vid}:{name}", "real": real}) + "label": f"{vid}:{name}", "real": real, + "masklen": masklen, "host_ip": f"{prefix}.{host}"}) return vlans diff --git a/labsim/labsim-up.sh b/labsim/labsim-up.sh index eb3133d..84b3972 100755 --- a/labsim/labsim-up.sh +++ b/labsim/labsim-up.sh @@ -25,7 +25,8 @@ ovs_up # --- VMs ------------------------------------------------------------------ for entry in "${SELECTED[@]}"; do - IFS=: read -r vid name prefix real <<<"$entry" + parse_vlan_entry "$entry" + vid="$V_VID"; name="$V_NAME"; prefix="$V_PREFIX"; real="$V_REAL" vm="$(vm_name "$vid" "$name")" ip="${prefix}.10" @@ -48,7 +49,7 @@ for entry in "${SELECTED[@]}"; do # Copy-on-write overlay: each VM costs a few MB, not 176. sudo qemu-img create -q -f qcow2 -F qcow2 -b "$BASE_IMAGE" "$disk" "$VM_DISK" >/dev/null - build_seed "$seed" "$vm" "$vid" "$name" "$prefix" "$ip" "$real" "$SSH_PUB" + build_seed "$seed" "$vm" "$vid" "$name" "$prefix" "$ip" "$real" "$SSH_PUB" "$V_MASK" sudo virt-install \ --connect "$LIBVIRT_URI" \ diff --git a/labsim/lib.sh b/labsim/lib.sh index 9f54c94..ed0d767 100644 --- a/labsim/lib.sh +++ b/labsim/lib.sh @@ -58,9 +58,32 @@ selected_vlans() { [ ${#SELECTED[@]} -gt 0 ] || die "no VLANs selected (checked $CONF)" } +# Split one vlans.conf line, applying defaults for the two optional trailing +# fields. Sets V_VID V_NAME V_PREFIX V_REAL V_MASK V_HOST. +parse_vlan_entry() { + IFS=: read -r V_VID V_NAME V_PREFIX V_REAL V_MASK V_HOST <<<"$1" + V_MASK="${V_MASK:-24}" + V_HOST="${V_HOST:-2}" +} + +# Dotted netmask for a prefix length — cloud-init's network-config v1 wants the +# dotted form, not a /len. /24 -> 255.255.255.0, /23 -> 255.255.254.0. +netmask_for() { + local len="$1" i bits out=() + for i in 0 1 2 3; do + bits=$(( len - i * 8 )) + (( bits > 8 )) && bits=8 + (( bits < 0 )) && bits=0 + out+=( $(( 256 - 2 ** (8 - bits) )) ) + done + local IFS=.; echo "${out[*]}" +} + # cloud-init NoCloud seed: static addressing + SSH key + hello-world HTTP. build_seed() { local iso="$1" vm="$2" vid="$3" name="$4" prefix="$5" ip="$6" real="$7" pubkey="$8" + local masklen="${9:-24}" + local netmask; netmask="$(netmask_for "$masklen")" local tmp; tmp="$(mktemp -d)" cat > "$tmp/meta-data" <

labsim vlan $vid — $name

host: $vm

-

address: $ip/24

+

address: $ip/$masklen

gateway under test: ${prefix}.1

mirrors production: $real

diff --git a/labsim/ovs.sh b/labsim/ovs.sh index 9d1c718..3ea6964 100644 --- a/labsim/ovs.sh +++ b/labsim/ovs.sh @@ -41,12 +41,15 @@ ovs_up() { # default route (.1 is), so inter-VLAN tests exercise the router, not the # host's routing table. for entry in "${SELECTED[@]}"; do - IFS=: read -r vid _name prefix _real <<<"$entry" - local port="hostv${vid}" - ovs --may-exist add-port "$OVS_BR" "$port" tag="$vid" \ + parse_vlan_entry "$entry" + local port="hostv${V_VID}" + ovs --may-exist add-port "$OVS_BR" "$port" tag="$V_VID" \ -- set interface "$port" type=internal sudo ip link set "$port" up 2>/dev/null || true - sudo ip addr replace "${prefix}.2/24" dev "$port" + # Drop any address from a previous mask/octet so a changed vlans.conf does + # not leave a stale second address on the port. + sudo ip -4 addr flush dev "$port" 2>/dev/null || true + sudo ip addr replace "${V_PREFIX}.${V_HOST}/${V_MASK}" dev "$port" done ovs_define_libvirt_net diff --git a/labsim/vlans.conf b/labsim/vlans.conf index 2b6712b..866f197 100644 --- a/labsim/vlans.conf +++ b/labsim/vlans.conf @@ -12,10 +12,21 @@ # .10 the micro VM for this VLAN # .254 VRRP VIP (reserved, mirrors production) # -# Format: vlan_id:name:sim_subnet_prefix:real_subnet(for reference) +# Format: vlan_id:name:sim_subnet_prefix:real_subnet:[masklen]:[host_octet] +# +# masklen defaults to 24 and host_octet to 2. Both exist for VLAN 10, which is +# the one VLAN that has to be a /23 here: every UniFi DHCP reservation lives in +# LoT, and LoT spans 10.0.0.x AND 10.0.1.x, which a /24 cannot represent. With +# /23 the mapping stays readable — 10.0.0.46 -> 172.31.10.46 and +# 10.0.1.67 -> 172.31.11.67. +# +# LoT's host leg is .3 rather than .2 because 10.0.0.2 is a real reservation +# (Hubitat) and would map straight onto the host's own address. .3 is free in +# production and sits below the DHCP pool (which starts at .11), so it can +# never be handed out. 1:management:172.31.1:192.168.1.0/24 2:k8s:172.31.2:192.168.8.0/23 3:kvm:172.31.3:192.168.3.0/24 9:private:172.31.9:10.0.9.0/23 -10:lot:172.31.10:10.0.0.0/23 +10:lot:172.31.10:10.0.0.0/23:23:3 200:roomates:172.31.200:192.168.2.0/24 diff --git a/migration/.gitignore b/migration/.gitignore new file mode 100644 index 0000000..1d4739b --- /dev/null +++ b/migration/.gitignore @@ -0,0 +1,4 @@ +# Raw UniFi export: contains WiFi passphrases (wlanconf) and controller auth +# material (setting). The inventory is regenerable — never commit it. +export/ +__pycache__/ diff --git a/migration/_unifi.py b/migration/_unifi.py new file mode 100755 index 0000000..b531588 --- /dev/null +++ b/migration/_unifi.py @@ -0,0 +1,51 @@ +"""Shared UniFi API client for the migration tooling. + +The controller is a CLASSIC self-hosted UniFi Network app (server_version +10.4.x), not UniFi OS: login is /api/login and data lives under +/api/s//... . UniFi OS would use /api/auth/login + /proxy/network/api. +Credentials come from the mcpctl server definition so they are not duplicated +here. +""" +from __future__ import annotations + +import json, re, ssl, subprocess, urllib.request, http.cookiejar + + +def client(): + raw = subprocess.run(["mcpctl", "describe", "server", "unifi-network"], + capture_output=True, text=True).stdout + m = re.search(r"UNIFI_TARGETS\s+(\[.*)", raw) + if not m: + raise SystemExit("could not read UNIFI_TARGETS from mcpctl") + blob = m.group(1).strip() + try: + targets = json.loads(blob) + except json.JSONDecodeError: + targets = json.loads(blob + "}" * (blob.count("{") - blob.count("}"))) + t = targets[0] + base = t["base_url"].rstrip("/") + auth = t.get("auth", {}) + site = t.get("default_site", "default") + + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()), + urllib.request.HTTPSHandler(context=ctx)) + req = urllib.request.Request( + f"{base}/api/login", + data=json.dumps({"username": auth.get("username"), + "password": auth.get("password")}).encode(), + headers={"Content-Type": "application/json"}) + opener.open(req, timeout=20).read() + return opener, base, site + + +def get(opener, base, site, path): + """GET /api/s//, returning the `data` list (never raising).""" + try: + body = opener.open(f"{base}/api/s/{site}/{path}", timeout=30).read() + return json.loads(body).get("data", []) + except Exception as exc: + return {"__error__": f"{type(exc).__name__}: {exc}"} diff --git a/migration/unifi-export.py b/migration/unifi-export.py new file mode 100755 index 0000000..9d4583c --- /dev/null +++ b/migration/unifi-export.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Export everything from the UniFi controller that the VyOS cutover must preserve. + +The point is that nothing quietly stops working after the switch. That means +capturing not just the networks but every DHCP reservation, every port forward +and every firewall rule — the things nobody remembers configuring until they +break. + +Writes one JSON file per endpoint into ./export/ (raw, unmodified — the source +of truth) plus inventory.json, a normalised view used by the VyOS generator. + + ./unifi-export.py # export to ./export/ + ./unifi-export.py --out /tmp/x # elsewhere + ./unifi-export.py --summary # print a human summary of what was found + +WARNING: the raw export contains secrets (wlanconf holds WiFi passphrases, +setting holds RADIUS/auth material). ./export/ is gitignored — keep it that way. +""" +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import sys + +import _unifi + +# endpoint -> why it matters for the cutover +ENDPOINTS = { + "rest/networkconf": "networks: VLANs, subnets, DHCP ranges, DNS, lease time", + "rest/user": "known clients — this is where fixed DHCP reservations live", + "stat/sta": "currently active clients and their live IPs", + "rest/firewallrule": "firewall rules", + "rest/firewallgroup": "address/port groups referenced by rules", + "rest/portforward": "port forwards (inbound NAT)", + "rest/routing": "static routes", + "rest/dhcpoption": "custom DHCP options", + "rest/wlanconf": "wireless networks (VLAN bindings)", + "stat/device": "switches/APs incl. per-port VLAN config", + "rest/setting": "controller settings (incl. USG/gateway config)", + "rest/usergroup": "bandwidth groups referenced by clients", + "rest/dynamicdns": "dynamic DNS", +} + + +def build_inventory(raw: dict) -> dict: + """Normalise the parts a migration actually has to reproduce.""" + nets_by_id = {n["_id"]: n for n in raw.get("rest/networkconf", []) if isinstance(n, dict)} + + networks = [] + for n in raw.get("rest/networkconf", []): + if not isinstance(n, dict): + continue + networks.append({ + "id": n.get("_id"), + "name": n.get("name"), + "purpose": n.get("purpose"), + "vlan": n.get("vlan"), + "vlan_enabled": n.get("vlan_enabled"), + "subnet": n.get("ip_subnet"), + "domain_name": n.get("domain_name"), + "dhcp_enabled": n.get("dhcpd_enabled"), + "dhcp_start": n.get("dhcpd_start"), + "dhcp_stop": n.get("dhcpd_stop"), + "dhcp_lease": n.get("dhcpd_leasetime"), + "dhcp_dns": [n.get(f"dhcpd_dns_{i}") for i in (1, 2, 3, 4) if n.get(f"dhcpd_dns_{i}")], + "dhcp_gateway": n.get("dhcpd_gateway") or n.get("dhcpd_gateway_enabled"), + "dhcp_ntp": [n.get(f"dhcpd_ntp_{i}") for i in (1, 2) if n.get(f"dhcpd_ntp_{i}")], + "igmp_snooping": n.get("igmp_snooping"), + "enabled": n.get("enabled", True), + }) + + # ip_subnet is the GATEWAY address with a prefix ("10.0.0.1/23"), not the + # network address — so derive the real subnet before matching against it. + subnets = [] + for n in networks: + if not n["subnet"]: + continue + try: + iface = ipaddress.ip_interface(n["subnet"]) + except ValueError: + continue + subnets.append((iface.network, n)) + + def resolve_net(ip: str | None, network_id: str | None) -> dict: + """Which network does this reservation belong to? + + Most reservations here (23 of 31 as of the first export) carry no + network_id at all — UniFi simply does not bind them. VyOS needs the + subnet to place a static-mapping, so fall back to containment. + """ + if network_id and network_id in nets_by_id: + nid = nets_by_id[network_id] + return {"name": nid.get("name"), "vlan": nid.get("vlan"), "by": "network_id"} + if ip: + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return {"name": None, "vlan": None, "by": "unresolved"} + for net, meta in subnets: + if addr in net: + return {"name": meta["name"], "vlan": meta["vlan"], "by": "subnet"} + return {"name": None, "vlan": None, "by": "unresolved"} + + # Fixed reservations: the single most important thing to carry over, and + # the easiest to lose — nobody has these written down anywhere else. + reservations = [] + for u in raw.get("rest/user", []): + if not isinstance(u, dict) or not u.get("use_fixedip"): + continue + net = resolve_net(u.get("fixed_ip"), u.get("network_id")) + reservations.append({ + "mac": (u.get("mac") or "").lower(), + "ip": u.get("fixed_ip"), + "name": u.get("name") or u.get("hostname") or "", + "hostname": u.get("hostname") or "", + "network_id": u.get("network_id"), + "network_name": net["name"], + "network_vlan": net["vlan"], + "resolved_by": net["by"], + "note": (u.get("note") or "").strip(), + }) + reservations.sort(key=lambda r: tuple(int(p) for p in r["ip"].split(".")) if r["ip"] else (0,)) + + # Active leases without a reservation: these devices work today by luck of + # the lease database. After a DHCP server swap they get a NEW address. + reserved_macs = {r["mac"] for r in reservations} + dynamic = [] + for c in raw.get("stat/sta", []): + if not isinstance(c, dict): + continue + mac = (c.get("mac") or "").lower() + if mac in reserved_macs or not c.get("ip"): + continue + dynamic.append({ + "mac": mac, + "ip": c.get("ip"), + "name": c.get("name") or c.get("hostname") or "", + "network": c.get("network"), + }) + dynamic.sort(key=lambda r: tuple(int(p) for p in r["ip"].split(".")) if r["ip"] else (0,)) + + port_forwards = [{ + "name": p.get("name"), "enabled": p.get("enabled"), + "proto": p.get("proto"), "src": p.get("src"), + "dst_port": p.get("dst_port"), "fwd": p.get("fwd"), + "fwd_port": p.get("fwd_port"), "log": p.get("log"), + } for p in raw.get("rest/portforward", []) if isinstance(p, dict)] + + firewall_rules = [{ + "name": r.get("name"), "enabled": r.get("enabled"), "action": r.get("action"), + "ruleset": r.get("ruleset"), "rule_index": r.get("rule_index"), + "protocol": r.get("protocol"), + "src_address": r.get("src_address"), "dst_address": r.get("dst_address"), + "src_firewallgroup_ids": r.get("src_firewallgroup_ids"), + "dst_firewallgroup_ids": r.get("dst_firewallgroup_ids"), + "src_networkconf_id": r.get("src_networkconf_id"), + "dst_networkconf_id": r.get("dst_networkconf_id"), + } for r in raw.get("rest/firewallrule", []) if isinstance(r, dict)] + + firewall_groups = [{ + "id": g.get("_id"), "name": g.get("name"), + "type": g.get("group_type"), "members": g.get("group_members"), + } for g in raw.get("rest/firewallgroup", []) if isinstance(g, dict)] + + static_routes = [{ + "name": r.get("name"), "enabled": r.get("enabled"), + "network": r.get("static-route_network"), + "nexthop": r.get("static-route_nexthop"), + "distance": r.get("static-route_distance"), + "type": r.get("static-route_type"), + } for r in raw.get("rest/routing", []) if isinstance(r, dict)] + + return { + "networks": networks, + "reservations": reservations, + "dynamic_clients": dynamic, + "port_forwards": port_forwards, + "firewall_rules": firewall_rules, + "firewall_groups": firewall_groups, + "static_routes": static_routes, + "warnings": find_warnings(networks, reservations), + } + + +def find_warnings(networks: list, reservations: list) -> list: + """Things that are fine under UniFi but bite when rebuilt on VyOS.""" + warns = [] + + for n in networks: + if not n["subnet"]: + continue + try: + iface = ipaddress.ip_interface(n["subnet"]) + except ValueError: + warns.append({"kind": "bad_subnet", "network": n["name"], "detail": n["subnet"]}) + continue + # UniFi stores the gateway in ip_subnet. A gateway equal to the network + # address is legal in a /23 but plenty of tooling rejects it, so it must + # not be discovered during the cutover window. + if iface.ip == iface.network.network_address: + warns.append({ + "kind": "gateway_is_network_address", "network": n["name"], + "detail": f"gateway {iface.ip} is the network address of {iface.network}", + }) + + # Reservations that sit inside the dynamic pool. UniFi's dhcpd tolerates + # this; whether VyOS does depends on its DHCP backend, so every one of these + # is a config that must be proven on the sim before cutover. + ranges = [] + for n in networks: + if n["dhcp_enabled"] and n["dhcp_start"] and n["dhcp_stop"]: + try: + ranges.append((n["name"], ipaddress.ip_address(n["dhcp_start"]), + ipaddress.ip_address(n["dhcp_stop"]))) + except ValueError: + pass + inside = [] + for r in reservations: + if not r["ip"]: + continue + try: + addr = ipaddress.ip_address(r["ip"]) + except ValueError: + continue + for name, lo, hi in ranges: + if lo <= addr <= hi: + inside.append(f"{r['ip']} ({r['name'] or r['mac']}) in {name} pool") + break + if inside: + warns.append({"kind": "reservation_inside_dhcp_pool", + "count": len(inside), "detail": inside}) + + unresolved = [f"{r['ip']} {r['mac']} {r['name']}" + for r in reservations if r["resolved_by"] == "unresolved"] + if unresolved: + warns.append({"kind": "reservation_matches_no_subnet", + "count": len(unresolved), "detail": unresolved}) + return warns + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "export")) + ap.add_argument("--summary", action="store_true") + args = ap.parse_args() + + os.makedirs(args.out, exist_ok=True) + opener, base, site = _unifi.client() + print(f"controller {base} site {site}") + + raw: dict = {} + errors = [] + for path, why in ENDPOINTS.items(): + data = _unifi.get(opener, base, site, path) + if isinstance(data, dict) and "__error__" in data: + errors.append((path, data["__error__"])) + print(f" {path:22} FAILED {data['__error__'][:50]}") + continue + raw[path] = data + fname = path.replace("/", "_") + ".json" + with open(os.path.join(args.out, fname), "w") as fh: + json.dump(data, fh, indent=2, sort_keys=True) + print(f" {path:22} {len(data):4d} -> {fname}") + + inventory = build_inventory(raw) + with open(os.path.join(args.out, "inventory.json"), "w") as fh: + json.dump(inventory, fh, indent=2, sort_keys=True) + + print(f"\nwrote {args.out}/inventory.json") + print(f" networks {len(inventory['networks'])}") + print(f" DHCP reservations {len(inventory['reservations'])}") + print(f" dynamic clients {len(inventory['dynamic_clients'])} (no reservation — see summary)") + print(f" port forwards {len(inventory['port_forwards'])}") + print(f" firewall rules {len(inventory['firewall_rules'])}") + print(f" static routes {len(inventory['static_routes'])}") + if errors: + print(f" endpoints failed {len(errors)}: {[e[0] for e in errors]}") + + if args.summary: + print_summary(inventory) + return 0 + + +def print_summary(inv: dict) -> None: + print("\n=== networks ===") + print(f"{'name':22} {'vlan':>5} {'subnet':20} {'dhcp range':32} lease") + for n in sorted(inv["networks"], key=lambda x: (x["vlan"] or 0)): + rng = f"{n['dhcp_start']} - {n['dhcp_stop']}" if n["dhcp_enabled"] else "(dhcp off)" + print(f"{(n['name'] or '')[:22]:22} {str(n['vlan'] or '-'):>5} " + f"{(n['subnet'] or '-'):20} {rng:32} {n['dhcp_lease'] or '-'}") + + print(f"\n=== DHCP reservations ({len(inv['reservations'])}) ===") + for r in inv["reservations"]: + print(f" {r['ip']:16} {r['mac']:18} {(r['network_name'] or '?')[:14]:14} {r['name'][:30]}") + + if inv["port_forwards"]: + print(f"\n=== port forwards ({len(inv['port_forwards'])}) ===") + for p in inv["port_forwards"]: + state = "" if p["enabled"] else " [DISABLED]" + print(f" {p['proto']:6} {str(p['src']):16}:{str(p['dst_port']):11} -> " + f"{p['fwd']}:{p['fwd_port']} {p['name']}{state}") + + if inv["firewall_rules"]: + print(f"\n=== firewall rules ({len(inv['firewall_rules'])}) ===") + for r in inv["firewall_rules"]: + state = "" if r["enabled"] else " [DISABLED]" + print(f" {str(r['ruleset']):22} {str(r['action']):8} {r['name']}{state}") + + if inv["warnings"]: + print(f"\n=== {len(inv['warnings'])} things to settle before cutover ===") + for w in inv["warnings"]: + n = w.get("count") + print(f" [{w['kind']}]" + (f" x{n}" if n else "")) + det = w["detail"] + for line in (det if isinstance(det, list) else [det])[:6]: + print(f" {line}") + if isinstance(det, list) and len(det) > 6: + print(f" ... and {len(det) - 6} more (see inventory.json)") + + n_dyn = len(inv["dynamic_clients"]) + if n_dyn: + print(f"\n=== {n_dyn} active clients WITHOUT a reservation ===") + print(" These hold their address only via the current lease database. A DHCP") + print(" server swap hands them a different one — fine for phones, not fine for") + print(" anything another host reaches by IP. Review before cutover:") + for c in inv["dynamic_clients"][:40]: + print(f" {c['ip']:16} {c['mac']:18} {(c['network'] or '')[:14]:14} {c['name'][:30]}") + if n_dyn > 40: + print(f" ... and {n_dyn - 40} more (see inventory.json)") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/migration/unifi-to-vyos.py b/migration/unifi-to-vyos.py new file mode 100755 index 0000000..d2480c6 --- /dev/null +++ b/migration/unifi-to-vyos.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Turn the UniFi export into the VyOS config that replaces it. + +Scope is deliberately narrow: DHCP and DNS. Those are the services the USG owns +that VyOS must reproduce byte-for-byte in behaviour, because getting them wrong +means clients lose their addresses or their name resolution. Everything else +either stays on UniFi (wireless), has no VyOS equivalent (user groups), or is +hand-written because it is not in the export (WAN, NAT, VRRP). + +This is not a general UniFi-to-VyOS converter and should not grow into one. + + ./unifi-to-vyos.py --mode prod # the cutover artifact + ./unifi-to-vyos.py --mode sim # same MACs, labsim addresses + ./unifi-to-vyos.py --mode prod --check # counts only, no output + +Both modes come from one code path on purpose: the config proven in labsim and +the config applied to the firewalls must not be able to drift apart. + +DNS note: UniFi hands out the gateway's own IP as resolver whenever a network +has no explicit dhcpd_dns -- true for 5 of the 6 VLANs, verified by labmaster +resolving against 192.168.8.1. So VyOS must run `service dns forwarding` or +those VLANs lose DNS entirely at cutover. LoT's explicit 10.0.0.194 is preserved +as-is. +""" +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import re +import sys + +# Where the production upstream resolver lives. Verified authoritative for +# ad.itaz.eu (SOA nas001.ad.itaz.eu) and also recursive, so it can serve as the +# single forwarder target. +UPSTREAM_DNS = "10.0.0.194" + +# labsim equivalents, keyed by VLAN id. Only VLAN 10 needs a /23: every one of +# the 31 reservations is in LoT, which spans 10.0.0.x and 10.0.1.x, and a /24 +# cannot represent that. k8s and Private are also /23 in production but hold no +# reservations, so they keep their existing /24 and their DHCP range is clamped +# (reported at generation time -- never silently). +SIM_SUBNETS = { + 1: "172.31.1.0/24", + 2: "172.31.2.0/24", + 3: "172.31.3.0/24", + 9: "172.31.9.0/24", + 10: "172.31.10.0/23", + 200: "172.31.200.0/24", +} + + +def vlan_of(net: dict) -> int: + """VLAN id, treating the untagged Management network as 1. + + UniFi stores vlan=None for the native network; the VyOS side already uses + vrid 1 for it (high-availability group 'native'), so 1 is the consistent id. + """ + return int(net["vlan"]) if net.get("vlan") else 1 + + +def sanitize(name: str, fallback: str) -> str: + """Reduce a UniFi client name to something VyOS will accept as a node name. + + VyOS validates static-mapping names as *hostnames*, so underscores are + rejected outright -- verified: `Dongle-M_C0D4` fails with "Invalid static + mapping hostname". Letters, digits and hyphens only, no leading digit or + hyphen, no trailing hyphen. + """ + cleaned = re.sub(r"[^A-Za-z0-9-]", "-", (name or "").strip()) + cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-") + if cleaned and cleaned[0].isdigit(): + cleaned = "h" + cleaned + return cleaned or fallback + + +class Mapper: + """Translates production addresses into the target mode's address space. + + In prod mode this is the identity. In sim mode an address is mapped by its + offset from the network address, so the host part is preserved: 10.0.0.46 + -> 172.31.10.46 and 10.0.1.67 -> 172.31.11.67. That is what makes the sim + test meaningful -- the MAC is identical and the host octet is recognisable. + """ + + def __init__(self, mode: str, networks: list) -> None: + self.mode = mode + self.clamped: list[str] = [] + self.map: dict[int, tuple] = {} + for n in networks: + prod = ipaddress.ip_network( + ipaddress.ip_interface(n["subnet"]).network) + if mode == "sim": + sim = ipaddress.ip_network(SIM_SUBNETS[vlan_of(n)]) + else: + sim = prod + self.map[vlan_of(n)] = (prod, sim) + + def net(self, vlan: int) -> ipaddress.IPv4Network: + return self.map[vlan][1] + + def addr(self, vlan: int, ip: str, what: str) -> str | None: + """Map one address, or None if it does not fit the target subnet.""" + prod, sim = self.map[vlan] + offset = int(ipaddress.ip_address(ip)) - int(prod.network_address) + if offset < 0 or offset >= sim.num_addresses: + self.clamped.append(f"{what}: {ip} does not fit {sim}") + return None + return str(ipaddress.ip_address(int(sim.network_address) + offset)) + + def gateway(self, vlan: int, net: dict) -> str: + """The address clients are told to use as their default route. + + Production: the USG's current address (VIPs move .254 -> .1 at cutover), + so no client has to change anything. Sim: the sim router at .1. + """ + if self.mode == "sim": + return str(self.net(vlan).network_address + 1) + return str(ipaddress.ip_interface(net["subnet"]).ip) + + +def build(inv: dict, mode: str) -> tuple[list[str], dict]: + nets = [n for n in inv["networks"] if n["dhcp_enabled"] and n["subnet"]] + nets.sort(key=vlan_of) + m = Mapper(mode, nets) + + out: list[str] = [] + used_tags: set[str] = set() + stats = {"subnets": 0, "mappings": 0, "dropped": []} + by_vlan: dict[int, list] = {} + for r in inv["reservations"]: + if r["network_vlan"] is None and r["network_name"] != "Management": + # resolved_by == "unresolved"; cannot place it without a subnet + stats["dropped"].append(f"{r['ip']} {r['mac']} (no network)") + continue + by_vlan.setdefault(r["network_vlan"] or 1, []).append(r) + + out.append("# --- DHCP ---------------------------------------------------") + for n in nets: + vlan = vlan_of(n) + sub = m.net(vlan) + base = f"set service dhcp-server shared-network-name {sanitize(n['name'], f'vlan{vlan}')} subnet {sub}" + gw = m.gateway(vlan, n) + + out.append("") + out.append(f"# {n['name']} (VLAN {vlan}) <- {n['subnet']}") + # subnet-id is required by kea and must be stable across regenerations; + # the VLAN id is already the unique per-network number in this lab. + out.append(f"{base} subnet-id {vlan}") + out.append(f"{base} option default-router {gw}") + + # Preserve UniFi's explicit resolver where it set one (LoT -> the NAS); + # otherwise hand out the gateway, which is what the USG does today and + # what `service dns forwarding` below will answer on. + for ns in (n["dhcp_dns"] or [gw]): + mapped = ns if ns not in (UPSTREAM_DNS,) or mode == "prod" else gw + if mode == "sim" and ns == UPSTREAM_DNS: + mapped = gw # no NAS in the sim; the router resolves + out.append(f"{base} option name-server {mapped}") + + if n["domain_name"]: + out.append(f"{base} option domain-name '{n['domain_name']}'") + if n["dhcp_lease"]: + out.append(f"{base} lease {n['dhcp_lease']}") + + start = m.addr(vlan, n["dhcp_start"], f"{n['name']} range start") + stop = m.addr(vlan, n["dhcp_stop"], f"{n['name']} range stop") + if start is None: + start = str(sub.network_address + 11) + if stop is None: + # Clamp to the last usable address rather than dropping the pool. + stop = str(sub.broadcast_address - 1) + out.append(f"{base} range LAN start {start}") + out.append(f"{base} range LAN stop {stop}") + stats["subnets"] += 1 + + for r in sorted(by_vlan.get(vlan, []), key=lambda x: ipaddress.ip_address(x["ip"])): + ip = m.addr(vlan, r["ip"], f"reservation {r['name']}") + if ip is None: + stats["dropped"].append(f"{r['ip']} {r['mac']} ({r['name']})") + continue + tag = sanitize(r["name"] or r["hostname"], "host-" + r["mac"].replace(":", "")) + # Distinct clients can sanitize to the same name; a collision would + # silently overwrite one reservation with another's address. + if tag in used_tags: + tag = f"{tag}-{r['mac'].replace(':', '')[-4:]}" + used_tags.add(tag) + out.append(f"{base} static-mapping {tag} mac {r['mac']}") + out.append(f"{base} static-mapping {tag} ip-address {ip}") + stats["mappings"] += 1 + + out.append("") + out.append("# --- DNS ----------------------------------------------------") + out.append("# The USG resolves for 5 of 6 VLANs today (it hands out its own") + out.append("# address when dhcpd_dns is empty). Without this, they lose DNS.") + for n in nets: + vlan = vlan_of(n) + out.append(f"set service dns forwarding listen-address {m.gateway(vlan, n)}") + out.append(f"set service dns forwarding allow-from {m.net(vlan)}") + if mode == "prod": + out.append(f"set service dns forwarding name-server {UPSTREAM_DNS}") + else: + # The sim has no NAS; forward to whatever the sim host can reach. + out.append("set service dns forwarding name-server 1.1.1.1") + out.append("set service dns forwarding cache-size 10000") + + stats["clamped"] = m.clamped + return out, stats + + +def main() -> int: + ap = argparse.ArgumentParser() + here = os.path.dirname(os.path.abspath(__file__)) + ap.add_argument("--mode", choices=("prod", "sim"), required=True) + ap.add_argument("--inventory", default=os.path.join(here, "export", "inventory.json")) + ap.add_argument("-o", "--out") + ap.add_argument("--check", action="store_true", help="counts only, no config") + args = ap.parse_args() + + with open(args.inventory) as fh: + inv = json.load(fh) + + lines, stats = build(inv, args.mode) + + expected = len(inv["reservations"]) + print(f"mode={args.mode} subnets={stats['subnets']} " + f"static-mappings={stats['mappings']}/{expected}", file=sys.stderr) + for c in stats["clamped"]: + print(f" clamped: {c}", file=sys.stderr) + for d in stats["dropped"]: + print(f" DROPPED: {d}", file=sys.stderr) + + if stats["mappings"] != expected and args.mode == "prod": + print(f"ERROR: {expected - stats['mappings']} reservation(s) missing from " + f"prod output -- every one must survive the cutover", file=sys.stderr) + return 1 + + if args.check: + return 0 + + text = "\n".join(lines) + "\n" + 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()) From f36ff4c6e319fd7f7a9a53346ce8839a89a16a9b Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 22:12:42 +0100 Subject: [PATCH 02/26] feat(migration): pin firewall management NICs and reserve them in UniFi Prerequisite for the cutover. eth2 on both firewalls was DHCP-served by the USG, and both addresses (192.168.8.143/.144) sit inside the pool VyOS will serve -- with no reservation for either MAC. After the switch they would renew from kea, get no mapping, and the management addresses could move. That is the worst possible moment for the address you SSH to to change, because losing the USG also means losing internet and any outside help. On both boxes, driven over the LoT path (bond0.10) so the interface being changed was never the one carrying the session: - eth2 pinned static at its current address, so it no longer depends on DHCP - `system name-server eth2` replaced with 10.0.0.194. That setting inherited resolvers from the DHCP lease, i.e. the boxes were resolving via the USG and would have lost DNS with it. 10.0.0.194 is reachable directly over bond0.10 and is authoritative for ad.itaz.eu, so internal names now resolve on the firewalls -- they did not before. - static default route via 192.168.8.1, replacing the one the lease provided. Superseded by PPPoE in vyos mode; this keeps unifi mode as it was. Verified after each: SSH on the pinned address, external and internal DNS, NTP still synced, VRRP unchanged (vyos001 MASTER, vyos002 BACKUP). migration/unifi-reserve.py adds the matching UniFi reservations so the controller cannot lease those addresses to anything else, keeping the management address identical in both modes. It reads the record back after writing, because a controller accepting a PUT is not proof it stored what was asked for, and it is idempotent. Also noted while doing this: VyOS `commit-confirm` REBOOTS the box if not confirmed -- "Minutes until reboot, unless 'confirm'" -- it does not roll the config back in place. For a gateway that means a real outage window, which changes how the switch script must use it. `config-mgmt commit_confirm -y` executes without the interactive prompt. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/_unifi.py | 19 +++++++ migration/unifi-reserve.py | 106 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100755 migration/unifi-reserve.py diff --git a/migration/_unifi.py b/migration/_unifi.py index b531588..df31d6e 100755 --- a/migration/_unifi.py +++ b/migration/_unifi.py @@ -49,3 +49,22 @@ def get(opener, base, site, path): return json.loads(body).get("data", []) except Exception as exc: return {"__error__": f"{type(exc).__name__}: {exc}"} + + +def put(opener, base, site, path, payload): + """PUT to /api/s//. Returns the `data` list or an __error__ dict. + + Classic controllers accept the session cookie alone -- no CSRF token, which + UniFi OS would require. Errors are returned rather than raised so a caller + changing production config can report and stop rather than traceback. + """ + req = urllib.request.Request( + f"{base}/api/s/{site}/{path}", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="PUT") + try: + return json.loads(opener.open(req, timeout=30).read()).get("data", []) + except urllib.error.HTTPError as exc: + return {"__error__": f"HTTP {exc.code}: {exc.read()[:300].decode(errors='replace')}"} + except Exception as exc: + return {"__error__": f"{type(exc).__name__}: {exc}"} diff --git a/migration/unifi-reserve.py b/migration/unifi-reserve.py new file mode 100755 index 0000000..3eb453e --- /dev/null +++ b/migration/unifi-reserve.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Set a fixed-IP reservation in UniFi, so it cannot lease that address away. + +Written for the firewalls' management NICs: their addresses are pinned static +on the VyOS side, but UniFi still owns the pool they sit in and would happily +hand the same address to something else. A reservation closes that gap while +the USG is still the DHCP server, and it keeps the management address identical +in both cutover modes. + + ./unifi-reserve.py 64:62:66:25:96:47 192.168.8.143 + ./unifi-reserve.py --dry-run + +Idempotent: an existing, matching reservation is reported and left alone. This +writes to the live controller, so it verifies by reading the record back rather +than trusting the response. +""" +from __future__ import annotations + +import argparse +import ipaddress +import sys + +import _unifi + + +def find_network(nets: list, ip: str) -> dict | None: + """Which configured network contains this address? + + ip_subnet holds the gateway address with a prefix ("192.168.8.1/23"), so + the network has to be derived from it rather than compared directly. + """ + addr = ipaddress.ip_address(ip) + for n in nets: + raw = n.get("ip_subnet") + if not raw: + continue + try: + if addr in ipaddress.ip_interface(raw).network: + return n + except ValueError: + continue + return None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("mac") + ap.add_argument("ip") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + mac = args.mac.lower().replace("-", ":") + ipaddress.ip_address(args.ip) # fail early on a typo + + opener, base, site = _unifi.client() + users = _unifi.get(opener, base, site, "rest/user") + nets = _unifi.get(opener, base, site, "rest/networkconf") + for blob in (users, nets): + if isinstance(blob, dict): + print(f"error reading controller: {blob['__error__']}", file=sys.stderr) + return 1 + + user = next((u for u in users if (u.get("mac") or "").lower() == mac), None) + if user is None: + print(f"{mac} is not a known client -- connect it once, or create it " + f"in the UI first", file=sys.stderr) + return 1 + + net = find_network(nets, args.ip) + if net is None: + print(f"no configured network contains {args.ip}", file=sys.stderr) + return 1 + + label = user.get("name") or user.get("hostname") or mac + if user.get("use_fixedip") and user.get("fixed_ip") == args.ip: + print(f"{label} ({mac}) already reserved at {args.ip} -- nothing to do") + return 0 + if user.get("use_fixedip"): + print(f"WARNING: {label} currently reserved at {user.get('fixed_ip')}, " + f"changing to {args.ip}", file=sys.stderr) + + print(f"{label} ({mac}) -> {args.ip} on '{net.get('name')}' (VLAN {net.get('vlan') or 'native'})") + if args.dry_run: + print(" --dry-run: not writing") + return 0 + + payload = {"use_fixedip": True, "fixed_ip": args.ip, "network_id": net["_id"]} + res = _unifi.put(opener, base, site, f"rest/user/{user['_id']}", payload) + if isinstance(res, dict): + print(f" write failed: {res['__error__']}", file=sys.stderr) + return 1 + + # Read it back: the controller accepting a PUT is not proof it stored what + # we asked for. + after = _unifi.get(opener, base, site, "rest/user") + check = next((u for u in after if (u.get("mac") or "").lower() == mac), {}) + if check.get("use_fixedip") and check.get("fixed_ip") == args.ip: + print(f" verified: reservation is live") + return 0 + print(f" VERIFY FAILED: controller reports use_fixedip=" + f"{check.get('use_fixedip')} fixed_ip={check.get('fixed_ip')}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 6c4318d3ae4ad42b0034521a9483747698332d4c Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 23:07:07 +0100 Subject: [PATCH 03/26] feat(migration): reserve every active client at its current address kea does not inherit UniFi's lease database. At cutover it starts with an empty view of who holds what, so it can hand an address that is currently in use to a different device. Reservations are what carry "this device has this address" across the switch, because they live in config rather than in lease state. unifi-reserve-all.py creates one per active client, dry run by default. 51 written, 51/51 verified live by reading the records back; the controller now holds 85 reservations and the generator emits all 85 with unique, valid hostnames and no duplicate addresses. Active clients with no reservation went from 48 to 4. Three guards, each of which caught something real in the dry run: - VRRP virtual addresses are excluded. UniFi reports them as ordinary client addresses because the firewalls' bond MACs answer for them, and their apparent IP flips between the real interface address and the VIP. Without this, 192.168.1.254 -- the gateway VIP itself -- would have been given a DHCP reservation. - The firewalls' own interface MACs are excluded; those are statically configured routers, not DHCP clients. - Any address claimed by more than one MAC is dropped rather than guessed at. This is how the VIPs surfaced in the first place. Also skipped: addresses already reserved to another MAC, network gateways, and anything on a network that does not serve DHCP (which excludes the WAN transit VLANs automatically). labsim-dhcp-test.sh gained a lease-database flush, and it is not tidiness. Two findings, both of which first appeared as a PASSING test: - Re-running against stale leases, kea gave dynamic addresses to three devices that have reservations. The reservations were present and correct in kea's own config throughout. Kea saw the reserved address as leased to "another client" -- same MAC, different client-id from the earlier boot -- and allocated elsewhere. Cutover starts with an empty lease database so this is a testing artifact, but a reservation is evidently not unconditional once leases exist. - Removing only dhcp4-leases.csv does nothing: kea's memfile backend keeps lease-file-cleanup rotations (.csv.2) and restores from them on start. The verdict logic no longer takes the first matching lease row. Doing so reported an hours-old lease as the current answer and scored three failures as passes, including one where the device had plainly been given a dynamic address. A MAC with more than one lease is now an explicit failure rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- labsim/README.md | 26 ++++- labsim/labsim-dhcp-test.sh | 36 ++++++- migration/unifi-reserve-all.py | 186 +++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 5 deletions(-) create mode 100755 migration/unifi-reserve-all.py diff --git a/labsim/README.md b/labsim/README.md index 05af260..497d2ee 100644 --- a/labsim/README.md +++ b/labsim/README.md @@ -104,10 +104,32 @@ python3 unifi-to-vyos.py --mode sim -o /tmp/sim.conf # 6 subnets, 31 mappings ``` **Result on VyOS 2026.08 (kea): all four cases pass.** The one that mattered: -30 of the 31 UniFi reservations sit *inside* the DHCP pool, and **kea honours -in-pool host reservations** — `printer1` received `172.31.10.46` from within the +most UniFi reservations sit *inside* the DHCP pool, and **kea honours in-pool +host reservations** — `printer1` received `172.31.10.46` from within the `.10.11–.11.254` pool. That was the open question blocking the cutover. +### The lease database will lie to you + +The script wipes `/config/dhcp/dhcp4-leases.csv*` before every run, and both +halves of that matter: + +- **Stale leases defeat reservations.** Re-running against yesterday's leases, + kea handed dynamic addresses to three devices that have reservations. The + reservation was present and correct in `/run/kea/kea-dhcp4.conf` the whole + time. Kea saw the reserved address as already leased to "another client" — + same MAC, but a different client-id from the earlier boot — and allocated + elsewhere. The cutover itself starts with an empty lease database, so this is + a *testing* artifact, but it is worth knowing that a reservation is not an + unconditional guarantee once leases exist. +- **The `*` is load-bearing.** Kea's memfile backend keeps lease-file-cleanup + rotations (`dhcp4-leases.csv.2`) and restores from them on start, so + truncating only the primary file changes nothing. + +Both of those first appeared as a *passing* test. The verdict logic now refuses +to score a MAC with more than one lease, because taking the first match had +reported an hours-old lease as the current answer and turned three failures +into apparent passes. + Still open: whether kea will hand a *reserved* address to a *different* client while the reserved device is offline. The negative case here only proves an unreserved MAC gets an unreserved address. diff --git a/labsim/labsim-dhcp-test.sh b/labsim/labsim-dhcp-test.sh index 3d79626..075bcc4 100755 --- a/labsim/labsim-dhcp-test.sh +++ b/labsim/labsim-dhcp-test.sh @@ -126,6 +126,24 @@ log " router has ${subnets:-0} subnets and ${maps:-0} static-mappings" [ "${maps:-0}" -gt 0 ] || die "router has no static-mappings -- apply the generated config first" cleanup_vms + +# Flush the lease database first. This is not tidiness -- it is the condition +# the cutover actually runs under, because kea does not inherit UniFi's leases +# and starts empty. It also makes the test deterministic: with stale leases +# present, kea saw the reserved address as held by "another client" (the same +# MAC but a different client-id from a previous boot) and allocated a dynamic +# address instead, which produced three misleading results before this existed. +log "flushing the router's lease database (cutover starts with an empty one)" +# Every dhcp4-leases.csv* must go, not just the main file: kea's memfile +# backend keeps lease-file-cleanup rotations (.1/.2) and restores from them on +# start, so truncating only the primary leaves the old leases intact. +router 'sudo systemctl stop isc-kea-dhcp4-server; + sudo sh -c "rm -f /config/dhcp/dhcp4-leases.csv*"; + sudo systemctl start isc-kea-dhcp4-server' >/dev/null +sleep 5 +remaining="$(router '/opt/vyatta/bin/vyatta-op-cmd-wrapper show dhcp server leases' | sed -n '3,$p' | grep -c .)" +[ "${remaining:-0}" -eq 0 ] || warn "lease table still has ${remaining} row(s) after flush" + SSH_PUB="$(find_ssh_pubkey)" sudo mkdir -p "$IMG_DIR" @@ -165,9 +183,21 @@ pass=0; fail=0 printf '%-19s %-16s %-16s %s\n' "MAC" "EXPECTED" "GOT" "RESULT" for c in "${CASES[@]}"; do IFS='|' read -r mac expected why <<<"$c" - got="$(echo "$leases" | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $1; exit}')" - got="${got:-}" - if [ "$expected" = "POOL" ]; then + # Never guess which lease is "the" lease. Taking the first match is how an + # hours-old lease was once reported as the current answer, turning three + # failures into apparent passes. + matches="$(echo "$leases" | awk -v m="$mac" 'tolower($2) == tolower(m) {print $1}')" + n_match="$(echo "$matches" | grep -c . )" + if [ "$n_match" -gt 1 ]; then + got="AMBIGUOUS($(echo "$matches" | tr '\n' ',' | sed 's/,$//'))" + else + got="${matches:-}" + fi + if [ "${got#AMBIGUOUS}" != "$got" ]; then + # More than one lease for this MAC means the flush did not take. Any + # verdict from here is a guess, so refuse to give one. + result="FAIL (multiple leases -- flush did not take)" + elif [ "$expected" = "POOL" ]; then if [ "$got" = "" ]; then result="FAIL (no lease at all)" elif echo "$reserved_ips" | grep -qx "$got"; then diff --git a/migration/unifi-reserve-all.py b/migration/unifi-reserve-all.py new file mode 100755 index 0000000..28c3963 --- /dev/null +++ b/migration/unifi-reserve-all.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Reserve every active client at the address it already has. + +Why this exists: kea does not inherit UniFi's lease database. At cutover it +starts with an empty view of who holds what, so it can hand an address that is +currently in use to a different device. Reservations are what carry "this +device has this address" across the switch, because they live in config rather +than in lease state. + +Dry run by default -- this writes to the live controller, and 40-odd writes is +not something to trigger by accident. + + ./unifi-reserve-all.py # show the plan, change nothing + ./unifi-reserve-all.py --apply # write them + ./unifi-reserve-all.py --skip-random # omit randomised/private MACs + +Only clients on networks that actually run DHCP are considered, which +automatically excludes WAN transit VLANs where a reservation is meaningless. +Anything already reserved is left alone, and an address already reserved to a +different MAC is reported and skipped rather than stolen. +""" +from __future__ import annotations + +import argparse +import ipaddress +import sys + +import _unifi + +# The VRRP virtual addresses, read from `show configuration commands` on +# vyos001. UniFi sees these as ordinary client addresses because the firewalls' +# bond MACs answer for them, and their reported IP flips between the real +# interface address and the VIP. Reserving one would put a DHCP reservation on +# the gateway address itself. +VIPS = { + "192.168.1.254", "192.168.9.254", "192.168.3.254", + "10.0.9.254", "10.0.1.254", "192.168.2.254", +} + +# Every MAC the two firewalls own (bond0/eth0/eth1 share one, eth2 and eth3 +# have their own). These interfaces are statically configured routers, not DHCP +# clients -- except eth2, which is deliberately reserved and already handled. +ROUTER_MACS = { + "64:62:66:25:96:45", "64:62:66:25:96:46", "64:62:66:25:96:48", # vyos001 + "64:62:66:25:96:51", "64:62:66:25:96:52", "64:62:66:25:96:54", # vyos002 +} + + +def is_random_mac(mac: str) -> bool: + """Locally-administered bit set => a privacy/randomised MAC. + + Worth calling out: such a device re-randomises periodically, so the + reservation stops matching it and becomes dead config. Harmless, but it + will never do what it looks like it does. + """ + try: + return bool(int(mac.split(":")[0], 16) & 0x02) + except (ValueError, IndexError): + return False + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--apply", action="store_true", help="actually write (default: dry run)") + ap.add_argument("--skip-random", action="store_true", help="omit randomised MACs") + args = ap.parse_args() + + opener, base, site = _unifi.client() + users = _unifi.get(opener, base, site, "rest/user") + nets = _unifi.get(opener, base, site, "rest/networkconf") + sta = _unifi.get(opener, base, site, "stat/sta") + for blob in (users, nets, sta): + if isinstance(blob, dict): + print(f"error reading controller: {blob['__error__']}", file=sys.stderr) + return 1 + + # Only networks that serve DHCP: a reservation on a WAN transit VLAN means + # nothing, and those are exactly the ones without dhcpd_enabled. + serving = [] + for n in nets: + if not (n.get("dhcpd_enabled") and n.get("ip_subnet")): + continue + try: + serving.append((ipaddress.ip_interface(n["ip_subnet"]).network, n)) + except ValueError: + continue + + by_mac = {(u.get("mac") or "").lower(): u for u in users} + taken = {u.get("fixed_ip"): (u.get("mac") or "").lower() + for u in users if u.get("use_fixedip")} + + plan, skipped = [], [] + for c in sta: + mac, ip = (c.get("mac") or "").lower(), c.get("ip") + if not mac or not ip: + continue + label = c.get("name") or c.get("hostname") or "?" + user = by_mac.get(mac) + + if ip in VIPS: + skipped.append((ip, mac, label, "VRRP virtual address - not a client")) + continue + if mac in ROUTER_MACS: + skipped.append((ip, mac, label, "firewall's own interface - statically configured")) + continue + + net = next((n for netw, n in serving if ipaddress.ip_address(ip) in netw), None) + if net is None: + skipped.append((ip, mac, label, "not on a DHCP-serving network")) + continue + # The gateway is the router, not a lease. + if ip == str(ipaddress.ip_interface(net["ip_subnet"]).ip): + skipped.append((ip, mac, label, "network gateway address")) + continue + if user is None: + skipped.append((ip, mac, label, "not a known client on the controller")) + continue + if user.get("use_fixedip"): + if user.get("fixed_ip") != ip: + skipped.append((ip, mac, label, + f"already reserved at {user.get('fixed_ip')} - left alone")) + continue + if ip in taken and taken[ip] != mac: + skipped.append((ip, mac, label, + f"address already reserved to {taken[ip]}")) + continue + if args.skip_random and is_random_mac(mac): + skipped.append((ip, mac, label, "randomised MAC (--skip-random)")) + continue + plan.append((ip, mac, label, user, net)) + + # Generic safety net: if two MACs report the same current address, at most + # one of them can legitimately keep it and we cannot tell which. Drop both + # and say so -- this is exactly how the VRRP VIPs first showed up. + counts: dict[str, int] = {} + for ip, *_ in plan: + counts[ip] = counts.get(ip, 0) + 1 + contested = {ip for ip, n in counts.items() if n > 1} + if contested: + for ip, mac, label, _u, _n in [p for p in plan if p[0] in contested]: + skipped.append((ip, mac, label, "address claimed by more than one MAC")) + plan = [p for p in plan if p[0] not in contested] + + plan.sort(key=lambda r: ipaddress.ip_address(r[0])) + + print(f"=== plan: {len(plan)} new reservation(s) ===") + for ip, mac, label, _u, net in plan: + flag = " [randomised MAC]" if is_random_mac(mac) else "" + print(f" {ip:16} {mac:18} {label[:28]:28} {net.get('name')}{flag}") + if skipped: + print(f"\n=== skipped ({len(skipped)}) ===") + for ip, mac, label, why in sorted(skipped): + print(f" {ip:16} {mac:18} {label[:24]:24} {why}") + + n_rand = sum(1 for p in plan if is_random_mac(p[1])) + if n_rand: + print(f"\nnote: {n_rand} of these use randomised MACs. The reservation " + f"stops matching once the device re-randomises.") + + if not args.apply: + print(f"\ndry run -- nothing written. Re-run with --apply to create {len(plan)}.") + return 0 + + print() + ok = fail = 0 + for ip, mac, label, user, net in plan: + res = _unifi.put(opener, base, site, f"rest/user/{user['_id']}", + {"use_fixedip": True, "fixed_ip": ip, "network_id": net["_id"]}) + if isinstance(res, dict): + print(f" FAILED {ip:16} {mac} {res['__error__'][:70]}") + fail += 1 + else: + ok += 1 + + # Read back rather than trusting the write responses. + after = _unifi.get(opener, base, site, "rest/user") + live = {(u.get("mac") or "").lower() for u in after if u.get("use_fixedip")} + verified = sum(1 for _ip, mac, _l, _u, _n in plan if mac in live) + print(f"\nwrote {ok}, failed {fail}, verified live {verified}/{len(plan)}") + print(f"total reservations on the controller now: " + f"{sum(1 for u in after if u.get('use_fixedip'))}") + return 0 if fail == 0 and verified == len(plan) else 1 + + +if __name__ == "__main__": + sys.exit(main()) From d56bbf6db0f23e1a1525d41cd866ec3d4790be84 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 23:30:28 +0100 Subject: [PATCH 04/26] 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) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/CUTOVER.md | 112 +++++++++++++++ migration/vyos-mode-delta.py | 267 +++++++++++++++++++++++++++++++++++ migration/vyos-unifi-switch | 246 ++++++++++++++++++++++++++++++++ 3 files changed, 625 insertions(+) create mode 100644 migration/CUTOVER.md create mode 100755 migration/vyos-mode-delta.py create mode 100755 migration/vyos-unifi-switch diff --git a/migration/CUTOVER.md b/migration/CUTOVER.md new file mode 100644 index 0000000..a13a634 --- /dev/null +++ b/migration/CUTOVER.md @@ -0,0 +1,112 @@ +# Cutover runbook — USG to VyOS + +**Print this.** During the cutover there is no internet, so there is no +assistant and no web search. Everything you need is on this page and on the +boxes themselves. + +| | | +|---|---| +| vyos001 (MASTER) | `192.168.8.143` — also `10.0.1.252` on LoT | +| vyos002 (BACKUP) | `192.168.8.144` — also `10.0.1.253` on LoT | +| JetKVMs | `192.168.1.28`, `192.168.1.29`, `192.168.3.6` | +| Switch script | `/config/vyos-unifi-switch` on each box | +| Login | user `vyos` | + +Both boxes have **two** reachable addresses on different interfaces. If `eth2` +(`192.168.8.x`) is unreachable, try the LoT address (`10.0.1.x`), and only then +the JetKVM. + +--- + +## If something is wrong, do this + +``` +sudo /config/vyos-unifi-switch unifi +``` + +Then reconnect the USG. That command runs no health checks, asks nothing and +cannot refuse. It restores a byte-exact copy of the configuration the box had +before the cutover — verified by diff, not by assumption. + +**You do not have to be quick.** If you do nothing at all after +`vyos-unifi-switch vyos`, the box reverts by itself within 10 minutes. Verified: +config returns to the previous state and the box does **not** reboot +(`uptime` and boot-id unchanged across an auto-revert). + +--- + +## Before you unplug anything + +1. Tether your workstation to your phone if you want the assistant available. + Cutting the USG cuts your internet, not your LAN. +2. On **both** boxes, confirm the machinery is present: + ``` + sudo /config/vyos-unifi-switch status + ls -la /config/modes/ # unifi.boot + to-vyos.commands + ls -la /config/wan-secrets # must be 0600 + ``` + `status` must report `mode: unifi`. If `unifi.boot` is missing, **stop** — + there is no way back without it. +3. Confirm the revert action is `reload`, not `reboot`: + ``` + show configuration commands | match commit-confirm + ``` + Must show `action 'reload'`. Without it a failed switch **reboots** the + firewall instead of reverting it. The switch script refuses to run if this + is missing, but check anyway. + +## The cutover + +1. **Physically disconnect the USG.** Not just powered off — disconnected. The + switch script refuses to run while anything still answers on a gateway + address, because two devices on `.1` is the worst available outcome. +2. On **vyos002 (BACKUP) first**: + ``` + sudo /config/vyos-unifi-switch vyos + ``` +3. Watch the health checks. They cover PPPoE, the default route, kea, the DNS + forwarder and reachability. On failure the script reverts immediately and + tells you so. +4. If vyos002 came up clean, repeat on **vyos001 (MASTER)**. +5. Check a real client: does it get an address, and is it the *same* address as + before? Every active client has a reservation, so it should be. + +## What will probably go wrong first + +**PPPoE.** It is the one thing that could not be tested in advance — the line +permits a single session and the USG held it until you unplugged it. If the +WAN check fails: + +``` +show interfaces pppoe pppoe0 +sudo journalctl -u ppp@pppoe0 -n 50 --no-pager +``` + +Check the credential in `/config/wan-secrets` and that VLAN 51 actually reaches +the box. If it will not come up, run `vyos-unifi-switch unifi`, reconnect the +USG, and debug with the internet back on. + +## Things that are true and easy to forget + +- **WiFi keeps working, but through VyOS.** The SSIDs stay in UniFi and the APs + are untouched, but 37 of 83 active clients are wireless and every one is on + LoT — they get their addresses from VyOS now. +- **DHCP leases last 24h (86400s).** A device that does not renew promptly keeps + its old address for a while. That is fine, not a symptom. +- **The firewalls resolve via `10.0.0.194`**, not the USG. That was changed + ahead of time precisely so they keep DNS when the USG goes away. +- **The USG was a DNS resolver** for every VLAN except LoT. VyOS now runs + `dns forwarding` in its place. If names stop resolving but IPs still work, + that is where to look. +- **`eth2` and `bond0.2` are both in `192.168.8.0/23`.** It works, but if you + see odd source-address behaviour on the management NIC, that is why. + +## Afterwards + +Once it has been stable for a day: + +- Re-run `migration/unifi-export.py` — the UniFi controller is no longer the + source of truth for DHCP, and the export will drift. +- The VPN rules (ESP, UDP 500/4500) are carried over but the VPN itself still + terminated on the USG. Decide whether it moves. +- `labsim` still holds a deliberate `kvm→k8s` drop rule from earlier testing. diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py new file mode 100755 index 0000000..e81434f --- /dev/null +++ b/migration/vyos-mode-delta.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Generate the delta that turns a passive VyOS pair into the gateway. + +The switch works as: load the known-good `unifi.boot` snapshot, apply this +delta, commit-confirm. Deriving the gateway mode from base+delta every time +means there is no inverse to maintain and no drift between two hand-kept +configs -- the revert is just loading the snapshot again. + + ./vyos-mode-delta.py --priority 200 -o to-vyos.commands # vyos001 (master) + ./vyos-mode-delta.py --priority 100 -o to-vyos.commands # vyos002 (backup) + ./vyos-mode-delta.py --emit-secrets /path/wan-secrets # credentials, 0600 + +The PPPoE password is NOT written into the delta. The delta carries the +placeholder @@WAN_PASSWORD@@ and the switch script substitutes it at apply time +from /config/wan-secrets, so the generated artifact can be read, diffed and +copied around without carrying a credential. +""" +from __future__ import annotations + +import argparse +import importlib.util +import ipaddress +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# unifi-to-vyos.py has hyphens, so it cannot be imported by name. Reuse it +# rather than duplicating the DHCP/DNS generation -- the whole point is that +# what labsim proved and what production gets come from one code path. +_spec = importlib.util.spec_from_file_location( + "unifi_to_vyos", os.path.join(HERE, "unifi-to-vyos.py")) +unifi_to_vyos = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(unifi_to_vyos) + +WAN_VIF = "bond0.51" # WAN arrives trunked as vlan-only network 51 +WAN_IF = "pppoe0" +PLACEHOLDER = "@@WAN_PASSWORD@@" + + +def vrrp_group(vlan: int) -> str: + """VRRP group names as configured on the boxes: 'native' for the untagged + VLAN, 'vlan' otherwise.""" + return "native" if vlan == 1 else f"vlan{vlan}" + + +def build_delta(inv: dict, priority: int, wan_user: str) -> list[str]: + out: list[str] = [] + nets = [n for n in inv["networks"] if n["dhcp_enabled"] and n["subnet"]] + nets.sort(key=unifi_to_vyos.vlan_of) + + out += [ + "# ==========================================================", + "# Delta: passive VyOS pair -> gateway. Applied on top of a", + "# freshly loaded unifi.boot, never on top of itself.", + "# ==========================================================", + "", + "# An unconfirmed commit must reload the previous config, NOT reboot.", + "# 'reboot' is the VyOS default and would turn a failed switch into a", + "# real outage on the box that is meant to be carrying the network.", + "set system config-management commit-confirm action reload", + "", + "# --- gateway addresses ------------------------------------", + "# The VIP takes over the address the USG holds today, so no client", + "# changes anything: no renewal needed, hardcoded gateways keep working.", + ] + for n in nets: + vlan = unifi_to_vyos.vlan_of(n) + grp = vrrp_group(vlan) + iface = ipaddress.ip_interface(n["subnet"]) + out.append(f"# {n['name']} (VLAN {vlan}) -> {iface.with_prefixlen}") + # Delete the whole address node rather than a computed old value. + # `address` is multi-value, and the current VIPs are NOT at + # network+254 on the /23 networks -- they are 192.168.9.254, + # 10.0.9.254 and 10.0.1.254, in the upper half. A delete naming the + # wrong address fails quietly and leaves the group holding two VIPs. + out.append(f"delete high-availability vrrp group {grp} address") + out.append(f"set high-availability vrrp group {grp} address {iface.with_prefixlen}") + out.append(f"set high-availability vrrp group {grp} priority {priority}") + + out += [ + "", + "# --- WAN ---------------------------------------------------", + "# Vodafone line, PPPoE over the trunked WAN VLAN. This is the one", + "# part that cannot be tested before cutover: the line permits a", + "# single session and the USG holds it until it is unplugged.", + f"set interfaces pppoe {WAN_IF} source-interface {WAN_VIF}", + f"set interfaces pppoe {WAN_IF} authentication username '{wan_user}'", + f"set interfaces pppoe {WAN_IF} authentication password '{PLACEHOLDER}'", + f"set interfaces pppoe {WAN_IF} mtu 1492", + # The peer's resolvers would otherwise overwrite /etc/resolv.conf and + # cost the box its ability to resolve internal ad.itaz.eu names. + f"set interfaces pppoe {WAN_IF} no-peer-dns", + "", + "# The static default route exists only for unifi mode, where the USG", + "# is the next hop. PPPoE supplies the default here; leaving both at", + "# the same distance would be ambiguous.", + "delete protocols static route 0.0.0.0/0", + "", + "# --- NAT ---------------------------------------------------", + f"set nat source rule 100 outbound-interface name {WAN_IF}", + "set nat source rule 100 translation address masquerade", + "set nat source rule 100 description 'LAN out to the internet'", + ] + + # Port forwards, straight from UniFi. + for i, p in enumerate(inv["port_forwards"]): + if not p.get("enabled"): + continue + rule = 100 + i * 10 + proto = p["proto"] # tcp | udp | tcp_udp -- all valid VyOS values + out += [ + "", + f"set nat destination rule {rule} description '{p['name']}'", + f"set nat destination rule {rule} inbound-interface name {WAN_IF}", + f"set nat destination rule {rule} protocol {proto}", + f"set nat destination rule {rule} destination port '{p['dst_port']}'", + f"set nat destination rule {rule} translation address {p['fwd']}", + f"set nat destination rule {rule} translation port '{p['fwd_port']}'", + ] + + out += [ + "", + "# --- firewall ----------------------------------------------", + "# VyOS defaults to accepting everything. The USG has an implicit", + "# WAN drop, so migrating the port forwards alone would leave the", + "# router's own services and the whole LAN reachable from the WAN.", + "#", + "# Scoped to the WAN interface rather than a global default-action", + "# drop: that way a mistake here cannot lock anyone out over the LAN,", + "# which is the only path back in during a cutover.", + "", + "# Traffic TO the router.", + "set firewall ipv4 input filter default-action accept", + "set firewall ipv4 input filter rule 100 action accept", + "set firewall ipv4 input filter rule 100 state established", + "set firewall ipv4 input filter rule 100 state related", + "set firewall ipv4 input filter rule 100 description 'established/related'", + ] + # The two WAN_LOCAL accepts carried over from UniFi. + out += [ + "", + "set firewall ipv4 input filter rule 110 action accept", + "set firewall ipv4 input filter rule 110 protocol esp", + f"set firewall ipv4 input filter rule 110 inbound-interface name {WAN_IF}", + "set firewall ipv4 input filter rule 110 description 'VPN accept ESP (from UniFi WAN_LOCAL)'", + "", + "set firewall ipv4 input filter rule 120 action accept", + "set firewall ipv4 input filter rule 120 protocol udp", + "set firewall ipv4 input filter rule 120 destination port '500,4500'", + f"set firewall ipv4 input filter rule 120 inbound-interface name {WAN_IF}", + "set firewall ipv4 input filter rule 120 description 'VPN accept UDP500/4500 (from UniFi WAN_LOCAL)'", + "", + "set firewall ipv4 input filter rule 130 action accept", + "set firewall ipv4 input filter rule 130 protocol icmp", + f"set firewall ipv4 input filter rule 130 inbound-interface name {WAN_IF}", + "set firewall ipv4 input filter rule 130 description 'ICMP to the router (path MTU discovery)'", + "", + "# Everything else arriving from the WAN is dropped. LAN is untouched.", + "set firewall ipv4 input filter rule 900 action drop", + f"set firewall ipv4 input filter rule 900 inbound-interface name {WAN_IF}", + "set firewall ipv4 input filter rule 900 description 'drop all other WAN-to-router'", + "", + "# Traffic THROUGH the router.", + "set firewall ipv4 forward filter default-action accept", + "set firewall ipv4 forward filter rule 100 action accept", + "set firewall ipv4 forward filter rule 100 state established", + "set firewall ipv4 forward filter rule 100 state related", + "set firewall ipv4 forward filter rule 100 description 'established/related'", + ] + + # Destination NAT happens before the forward filter, so these rules must + # match the translated destination, not the WAN address. + for i, p in enumerate(inv["port_forwards"]): + if not p.get("enabled"): + continue + rule = 200 + i * 10 + out += [ + "", + f"set firewall ipv4 forward filter rule {rule} action accept", + f"set firewall ipv4 forward filter rule {rule} inbound-interface name {WAN_IF}", + f"set firewall ipv4 forward filter rule {rule} protocol {p['proto']}", + f"set firewall ipv4 forward filter rule {rule} destination address {p['fwd']}", + f"set firewall ipv4 forward filter rule {rule} destination port '{p['fwd_port']}'", + f"set firewall ipv4 forward filter rule {rule} description 'port forward: {p['name']}'", + ] + + out += [ + "", + "# New inbound connections from the WAN that are not a port forward.", + "set firewall ipv4 forward filter rule 900 action drop", + f"set firewall ipv4 forward filter rule 900 inbound-interface name {WAN_IF}", + "set firewall ipv4 forward filter rule 900 description 'drop unsolicited WAN-to-LAN'", + "", + ] + + # DHCP + DNS, from the same generator labsim proved. + dhcp_lines, stats = unifi_to_vyos.build(inv, "prod") + expected = len(inv["reservations"]) + if stats["mappings"] != expected: + raise SystemExit( + f"refusing to generate: {expected - stats['mappings']} reservation(s) " + f"missing -- every one must survive the cutover") + out += dhcp_lines + return out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--priority", type=int, required=True, + help="VRRP priority: 200 for the master, 100 for the backup") + ap.add_argument("--inventory", default=os.path.join(HERE, "export", "inventory.json")) + ap.add_argument("--raw-networkconf", default=os.path.join(HERE, "export", "rest_networkconf.json")) + ap.add_argument("-o", "--out") + ap.add_argument("--emit-secrets", metavar="PATH", + help="write the PPPoE credential to PATH with mode 0600 and exit") + args = ap.parse_args() + + with open(args.inventory) as fh: + inv = json.load(fh) + with open(args.raw_networkconf) as fh: + raw_nets = json.load(fh) + + wan = next((n for n in raw_nets + if n.get("purpose") == "wan" and n.get("wan_username")), None) + if wan is None: + print("no WAN network with credentials found in the export", file=sys.stderr) + return 1 + + if args.emit_secrets: + fd = os.open(args.emit_secrets, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(f"WAN_PASSWORD='{wan.get('x_wan_password', '')}'\n") + # Re-assert the mode in case the file already existed with a wider one. + os.chmod(args.emit_secrets, 0o600) + mode = oct(os.stat(args.emit_secrets).st_mode & 0o777) + print(f"wrote {args.emit_secrets} (mode {mode}) for user {wan['wan_username']}", + file=sys.stderr) + return 0 + + lines = build_delta(inv, args.priority, wan["wan_username"]) + text = "\n".join(lines) + "\n" + + if PLACEHOLDER not in text: + print("BUG: password placeholder missing from the delta", file=sys.stderr) + return 1 + if wan.get("x_wan_password") and wan["x_wan_password"] in text: + print("BUG: the WAN password leaked into the delta", file=sys.stderr) + return 1 + + n_set = sum(1 for l in lines if l.startswith("set ")) + n_del = sum(1 for l in lines if l.startswith("delete ")) + print(f"delta: {n_set} set, {n_del} delete, priority {args.priority}, " + f"{len(inv['reservations'])} reservations", file=sys.stderr) + + if args.out: + with open(args.out, "w") as fh: + fh.write(text) + print(f"wrote {args.out}", file=sys.stderr) + else: + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/migration/vyos-unifi-switch b/migration/vyos-unifi-switch new file mode 100755 index 0000000..b4848e5 --- /dev/null +++ b/migration/vyos-unifi-switch @@ -0,0 +1,246 @@ +#!/bin/bash +# Switch this VyOS box between passive (USG is the gateway) and active +# (VyOS is the gateway). Installed at /config/vyos-unifi-switch, which +# survives image upgrades, so it can be run from a local terminal or the +# JetKVM console with no workstation and no internet. +# +# vyos-unifi-switch report the current mode +# vyos-unifi-switch vyos take over: VIPs to .1, DHCP, DNS, PPPoE, NAT +# vyos-unifi-switch unifi revert; the USG can then be reconnected +# +# READ THIS BEFORE THE CUTOVER +# +# `unifi` is the escape hatch. It runs no health checks, asks no questions and +# has nothing that can refuse. If anything at all looks wrong, run it, then +# plug the USG back in. +# +# `vyos` commits with commit-confirm. If it is not confirmed -- because the +# health checks failed, or because you lost access, or because you walked away +# -- the box returns to the saved configuration on its own. That requires +# `system config-management commit-confirm action reload`; without it VyOS +# REBOOTS instead, which on a gateway is an outage rather than an undo. The +# script refuses to run if that setting is missing. +set -uo pipefail + +MODES=/config/modes +UNIFI_BOOT="$MODES/unifi.boot" +DELTA="$MODES/to-vyos.commands" +SECRETS=/config/wan-secrets +MARKER="$MODES/current-mode" +CONFIRM_MINUTES="${CONFIRM_MINUTES:-10}" +OPRUN=/opt/vyatta/bin/vyatta-op-cmd-wrapper + +say() { printf '[switch] %s\n' "$*"; } +warn() { printf '[switch] WARNING: %s\n' "$*" >&2; } +die() { printf '[switch] ERROR: %s\n' "$*" >&2; exit 1; } + +# Run configuration commands in a real config session. Everything the caller +# feeds in runs between `configure` and `exit`. +run_cfg() { + local script rc + script="$(mktemp)" + { + echo 'source /opt/vyatta/etc/functions/script-template' + echo 'configure' + cat + echo 'exit' + } > "$script" + vbash "$script"; rc=$? + rm -f "$script" + return $rc +} + +# Two traps live in this one function, both of which produced wrong answers +# rather than errors: +# 1. `show configuration commands` quotes values ("action 'reload'"), so the +# quotes have to go before matching or every value-bearing check fails. +# 2. `... | grep -q` under `set -o pipefail` reports FAILURE even on a match: +# grep exits at the first hit, the producer takes SIGPIPE, and pipefail +# surfaces that. Whether it triggers depends on output size, so it fails +# intermittently. Match against a captured string instead of a pipeline. +cfg_has() { + local out + out="$($OPRUN show configuration commands 2>/dev/null | tr -d "'")" + case "$out" in *"$1"*) return 0 ;; *) return 1 ;; esac +} + +# ---------------------------------------------------------------- status ---- +current_mode() { + # The marker records intent; the running config is the truth. Report the + # config, and complain if the two disagree. + local live="unknown" + if cfg_has "set service dhcp-server"; then live="vyos"; else live="unifi"; fi + echo "$live" +} + +show_status() { + local live marked + live="$(current_mode)" + marked="$(cat "$MARKER" 2>/dev/null || echo "never set")" + echo "host: $(hostname)" + echo "mode: $live (marker: $marked)" + [ "$live" != "$marked" ] && [ "$marked" != "never set" ] && \ + warn "marker disagrees with the running config -- trust the config" + echo "VRRP:" + $OPRUN show vrrp 2>/dev/null | sed -n '3,$p' | awk '{printf " %-9s %-12s %s\n", $1, $2, $4}' + echo "DHCP server: $(systemctl is-active isc-kea-dhcp4-server 2>/dev/null)" + echo "DNS forwarder: $(systemctl is-active pdns-recursor 2>/dev/null)" + echo "WAN (pppoe0): $(ip -4 -br addr show pppoe0 2>/dev/null | awk '{print $2, $3}' || echo 'not present')" + echo "default route: $(ip -4 route show default 2>/dev/null | head -1 || echo none)" + echo "unsaved changes: $(cfg_unsaved)" +} + +cfg_unsaved() { + if /usr/bin/config-mgmt compare >/dev/null 2>&1; then echo "no"; else echo "possibly - check 'compare saved'"; fi +} + +# ------------------------------------------------------------- preflight ---- +require_files() { + [ -r "$UNIFI_BOOT" ] || die "missing $UNIFI_BOOT -- capture it while the USG is still the gateway" + [ -s "$UNIFI_BOOT" ] || die "$UNIFI_BOOT is empty" +} + +require_reload_action() { + cfg_has "set system config-management commit-confirm action reload" && return 0 + die "commit-confirm action is not 'reload'. Without it an unconfirmed switch + REBOOTS this box instead of reverting. Fix first: + configure + set system config-management commit-confirm action reload + commit; save" +} + +# The single worst outcome available is two devices answering on the gateway +# address. It costs one ARP probe to make that impossible. +# Returns 0 (success) when something IS answering on a gateway address we are +# about to claim -- i.e. "yes, still alive, do not proceed". +usg_still_alive() { + local found=0 ip dev targets + targets="$(grep -oE "vrrp group [a-z0-9]+ address [0-9.]+" "$DELTA" 2>/dev/null | awk '{print $NF}')" + if [ -z "$targets" ]; then + warn "this delta claims no VIPs, so there is nothing to probe." + warn "That is expected in the lab and WRONG for the real cutover." + return 1 + fi + for ip in $targets; do + dev="$(ip -4 route get "$ip" 2>/dev/null | sed -n 's/.* dev \([^ ]*\).*/\1/p' | head -1)" + if [ -n "$dev" ] && command -v arping >/dev/null 2>&1; then + # ARP is the right probe: it answers even when the host filters ICMP. + if arping -c 2 -w 3 -f -I "$dev" "$ip" >/dev/null 2>&1; then + warn "something already answers ARP on $ip (via $dev)"; found=1 + fi + elif ping -c 2 -W 2 "$ip" >/dev/null 2>&1; then + warn "something already answers ICMP on $ip"; found=1 + fi + done + [ "$found" -eq 1 ] +} + +# ------------------------------------------------------------- to unifi ----- +to_unifi() { + require_files + say "reverting to unifi mode (USG is the gateway)" + run_cfg < "$MARKER" + say "done. The USG can be reconnected." + say "If it was already connected during this, nothing was disturbed." +} + +# -------------------------------------------------------------- to vyos ----- +health_checks() { + local fails=0 + _chk() { # name, command + if eval "$2" >/dev/null 2>&1; then say " ok $1"; else say " FAIL $1"; fails=$((fails+1)); fi + } + _chk "kea (DHCP) is running" "systemctl is-active --quiet isc-kea-dhcp4-server" + _chk "DNS forwarder is running" "systemctl is-active --quiet pdns-recursor" + + # Only assert on the WAN if this delta actually brings one up. A delta with + # no PPPoE stanza is a lab/partial delta, and failing it on a missing + # pppoe0 would make the script untestable anywhere but the live cutover. + # Announced loudly, because a quietly skipped check is worse than no check. + if grep -q "^set interfaces pppoe" "$DELTA"; then + # Written as [ -n "$(...)" ] rather than `... | grep -q` for the pipefail + # reason above: a pipeline ending in grep -q cannot be trusted here. + _chk "pppoe0 has an address" '[ -n "$(ip -4 -br addr show pppoe0 2>/dev/null | awk "{print \$3}")" ]' + _chk "a default route exists" '[ -n "$(ip -4 route show default)" ]' + _chk "internet reachable" "ping -c2 -W3 8.8.8.8" + _chk "DNS resolves through us" "getent hosts vyos.net" + else + warn "this delta configures no WAN -- skipping all WAN health checks." + warn "That is expected in the lab and WRONG for the real cutover." + fi + return $fails +} + +to_vyos() { + require_files + require_reload_action + [ -r "$DELTA" ] || die "missing $DELTA" + + if usg_still_alive; then + die "refusing: something is still answering on a gateway address. + Disconnect the USG first. Two devices on the same gateway IP is the + one failure this script exists to prevent." + fi + + local pw="" tmp + if [ -r "$SECRETS" ]; then + # shellcheck disable=SC1090 + . "$SECRETS"; pw="${WAN_PASSWORD:-}" + fi + [ -n "$pw" ] || warn "no WAN_PASSWORD in $SECRETS -- PPPoE will not authenticate" + + tmp="$(mktemp)"; chmod 600 "$tmp" + sed "s|@@WAN_PASSWORD@@|${pw}|g" "$DELTA" | grep -vE '^\s*(#|$)' > "$tmp" + + say "switching to vyos mode (this box becomes the gateway)" + say "commit-confirm: ${CONFIRM_MINUTES} min to confirm, else it reverts itself" + + # commit-confirm is TWO steps, and doing only the first commits nothing: + # `config-mgmt commit_confirm` arms the revert timer, then a normal `commit` + # applies the candidate config. The interactive prompt lives in the first + # step, which is why it is invoked directly with -y instead of via the + # `commit-confirm` alias. IN_COMMIT_CONFIRM is what the real CLI sets, and + # the commit hooks look at it. + if ! run_cfg < <(printf 'load %s\n' "$UNIFI_BOOT"; cat "$tmp"; + printf 'sudo sg vyattacfg "/usr/bin/config-mgmt commit_confirm -y -t=%s"\n' "$CONFIRM_MINUTES"; + printf 'export IN_COMMIT_CONFIRM=t\ncommit\nunset IN_COMMIT_CONFIRM\n'); then + rm -f "$tmp" + die "commit-confirm failed. Nothing was applied; the box is still in its + previous mode. Run '$0 unifi' if you are unsure." + fi + rm -f "$tmp" + + say "committed. Waiting 25s for PPPoE and services to settle..." + sleep 25 + + say "health checks:" + if health_checks; then + say "all checks passed -- confirming" + /usr/bin/config-mgmt confirm >/dev/null 2>&1 || die "confirm failed; it will revert on its own shortly" + run_cfg <<'EOF' || warn "save failed -- config is live but will not survive a reboot" +save +EOF + echo "vyos" > "$MARKER" + say "vyos mode is live and saved." + else + warn "health checks FAILED -- reverting now rather than waiting out the timer" + /usr/bin/config-mgmt revert_soft >/dev/null 2>&1 \ + || warn "revert_soft failed; the commit-confirm timer will still fire within ${CONFIRM_MINUTES} min" + echo "unifi" > "$MARKER" + die "reverted to the previous configuration. Reconnect the USG. + Check: ip addr show pppoe0; journalctl -u pppd; $0 status" + fi +} + +# ----------------------------------------------------------------- main ----- +case "${1:-status}" in + vyos) to_vyos ;; + unifi) to_unifi ;; + status) show_status ;; + *) echo "usage: $(basename "$0") [vyos|unifi|status]" >&2; exit 2 ;; +esac From 7f5d3517a363ae90e13045da433578a38425ab8a Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 15 Aug 2026 23:51:13 +0100 Subject: [PATCH 05/26] fix(migration): create the WAN vif before PPPoE references it `set interfaces pppoe pppoe0 source-interface bond0.51` refers to an interface that must already exist, and neither firewall has vif 51 -- only 2, 3, 9, 10 and 200 are configured. The commit would have failed, and since the whole delta commits as one unit, that failure would have taken the entire switch with it at the worst possible moment. No address on the vif: PPPoE rides the VLAN and needs no L3 of its own. Found by checking the running config against the generated delta rather than by running it. The prod delta has still never been applied to any VyOS, which is the remaining gap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-mode-delta.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py index e81434f..1b646d7 100755 --- a/migration/vyos-mode-delta.py +++ b/migration/vyos-mode-delta.py @@ -85,6 +85,14 @@ def build_delta(inv: dict, priority: int, wan_user: str) -> list[str]: "# Vodafone line, PPPoE over the trunked WAN VLAN. This is the one", "# part that cannot be tested before cutover: the line permits a", "# single session and the USG holds it until it is unplugged.", + "#", + "# The WAN vif must be created first. Neither firewall has vif 51 today", + "# (only 2, 3, 9, 10 and 200 exist), and pppoe source-interface refers to", + "# an interface that has to already be configured -- without this the", + "# commit fails and takes the whole switch with it. No address on it:", + "# PPPoE rides the VLAN, it does not need L3 of its own.", + f"set interfaces bonding bond0 vif {WAN_VIF.split('.')[1]} description 'WAN (Vodafone, PPPoE)'", + "", f"set interfaces pppoe {WAN_IF} source-interface {WAN_VIF}", f"set interfaces pppoe {WAN_IF} authentication username '{wan_user}'", f"set interfaces pppoe {WAN_IF} authentication password '{PLACEHOLDER}'", From 01a923352fce27a6f0059a994bc31b8a0ece846a Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 00:02:21 +0100 Subject: [PATCH 06/26] fix(migration): do not emit a NAT translation port for a port list `set nat destination rule N translation port '16881,6881'` is rejected -- "16881,6881 is not a valid service name" -- because mapping a list of ports onto a list is ambiguous. `destination port` accepts the same list happily, which is why this only shows up on the translation side. All four UniFi port forwards map a port to itself, so translation port was redundant anyway: omitting it makes VyOS preserve the original port, which is exactly the intent. It is now emitted only when the forwarded port genuinely differs, and generation fails loudly rather than producing a config that will not commit if a differing port LIST ever appears. Found by loading vyos001's real running config onto the labsim router and applying the full delta to the candidate config without committing. Worth noting the delta had already passed a read-through: this one only surfaced by running it against a real VyOS of the same version. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-mode-delta.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py index 1b646d7..befccdb 100755 --- a/migration/vyos-mode-delta.py +++ b/migration/vyos-mode-delta.py @@ -125,8 +125,20 @@ def build_delta(inv: dict, priority: int, wan_user: str) -> list[str]: f"set nat destination rule {rule} protocol {proto}", f"set nat destination rule {rule} destination port '{p['dst_port']}'", f"set nat destination rule {rule} translation address {p['fwd']}", - f"set nat destination rule {rule} translation port '{p['fwd_port']}'", ] + # `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']}'") out += [ "", From 7e464a2828c39957130ad06adee7dd6c5cd6f3cf Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 00:08:13 +0100 Subject: [PATCH 07/26] docs(migration): record what the rehearsal proved and what it could not Ran vyos001's real running config plus the real production delta on the labsim router -- same VyOS version, isolated OVS bridge with no physical NIC, so the sim briefly holding vyos001's actual addresses could not reach the real LAN. Result: all 317 commands accepted and the whole delta commits (COMMIT OK), the revert is byte-exact, and auto-revert fires without rebooting (uptime and boot-id unchanged across it). Also written down are the two things this did NOT establish, because a runbook that overstates its own coverage is worse than one that admits the gap: PPPoE cannot be tried while the USG holds the single available session, and the rehearsal ran with vyos001's eth2/eth3 stanzas stripped because the sim VM has two NICs rather than four. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/CUTOVER.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/migration/CUTOVER.md b/migration/CUTOVER.md index a13a634..fee7182 100644 --- a/migration/CUTOVER.md +++ b/migration/CUTOVER.md @@ -35,6 +35,33 @@ config returns to the previous state and the box does **not** reboot --- +## What has actually been tested + +Proven on the labsim router (same VyOS version, isolated OVS bridge with no +physical NIC), by loading **vyos001's real running config** and applying the +**real production delta**: + +- All 317 commands accepted, and the whole delta **commits** (`COMMIT OK`). +- `unifi` mode restores the previous config **byte-exact** (diff clean). +- Auto-revert fires when the commit is not confirmed: config returns to the + saved state and the box does **not** reboot — `uptime` and boot-id unchanged + across the revert. +- Failed health checks trigger an immediate revert rather than waiting out the + timer. + +Two bugs were found this way and would each have failed the entire switch, +since the delta commits as one unit: `bond0.51` did not exist for PPPoE to +reference, and `translation port` rejects a port list. + +**Not tested, and untestable in advance:** + +- **PPPoE.** The line permits one session and the USG holds it. The first real + attempt is during the cutover. +- **The commit on the real boxes.** The rehearsal ran with vyos001's `eth2` and + `eth3` stanzas stripped, because the sim VM has only two NICs. Those are + plain interface configs that already work on the real hardware, but they were + not part of what committed. + ## Before you unplug anything 1. Tether your workstation to your phone if you want the assistant available. From b37cd794324b1b3c99a0f312b2d251e038d5fecf Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 00:13:19 +0100 Subject: [PATCH 08/26] fix(migration): refuse an unprobeable delta instead of warning past it The ARP guard against two devices holding the same gateway address is the one check that prevents this script's worst outcome. It reads the addresses to probe out of the delta -- so a delta with no VIP lines made the guard inert, and it previously warned and carried on. That was a testing convenience (the lab delta has no VIPs) weakening a production safety check, which is backwards. It now refuses by default. ALLOW_NO_VIP_DELTA=1 is the explicit lab override. The guard's probing path had never actually executed before this: every sim run took the no-VIPs branch. Verified against the live USG from vyos001: arping is present on VyOS, the regex extracts all six gateway addresses from the real delta (192.168.1.1, 192.168.8.1, 192.168.3.1, 10.0.9.0, 10.0.0.1, 192.168.2.1), and every one of them answers ARP right now -- so on the real boxes, with the USG connected, the guard fires. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-unifi-switch | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/migration/vyos-unifi-switch b/migration/vyos-unifi-switch index b4848e5..c4e1d67 100755 --- a/migration/vyos-unifi-switch +++ b/migration/vyos-unifi-switch @@ -117,9 +117,18 @@ usg_still_alive() { local found=0 ip dev targets targets="$(grep -oE "vrrp group [a-z0-9]+ address [0-9.]+" "$DELTA" 2>/dev/null | awk '{print $NF}')" if [ -z "$targets" ]; then - warn "this delta claims no VIPs, so there is nothing to probe." - warn "That is expected in the lab and WRONG for the real cutover." - return 1 + # A delta with no VIPs cannot be probed, which means the single guard + # against two devices sharing a gateway address is inert. Refuse by + # default: an unprobeable delta on the real boxes is a broken delta, and + # "warn and continue" would let the one failure this script exists to + # prevent through unnoticed. The override is for the lab only. + if [ "${ALLOW_NO_VIP_DELTA:-0}" = "1" ]; then + warn "delta claims no VIPs; proceeding because ALLOW_NO_VIP_DELTA=1 (lab only)" + return 1 + fi + die "this delta claims no VIPs, so the gateway-address guard cannot run. + On the real boxes that means a broken delta. If this really is a lab + run, re-invoke with ALLOW_NO_VIP_DELTA=1." fi for ip in $targets; do dev="$(ip -4 route get "$ip" 2>/dev/null | sed -n 's/.* dev \([^ ]*\).*/\1/p' | head -1)" From fc31013ceb83403d4b416091af24e130fae9cdfd Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 14:31:30 +0100 Subject: [PATCH 09/26] fix(migration): apply the reviewed reservation plan, not a recomputed one This caused a real outage. unifi-reserve-all.py recomputed its plan at --apply time by re-reading stat/sta, so a client that renewed between the dry run and the apply was pinned to whatever transient address it happened to hold at that instant. worker1-k8s0 was reviewed at 192.168.8.13 and written as 192.168.8.242. On its next reboot it could not get an address at all, taking a k8s node down. A plan that gets reviewed and a plan that gets applied must be the same object. The dry run now WRITES the plan to a file and --apply READS it and applies exactly that, reporting any client whose current address has since drifted rather than silently preferring the new value. 1 of 51 diverged; the rest were verified against the reviewed list and were correct. worker1 has been restored to .13 and confirmed: DHCPOFFER for its own MAC returns 192.168.8.13, and the node is up with a full lease and working internet. The second half of the outage was drift between controller and device: the USG was still running config from ~16h before these changes, so the controller looked perfectly correct while the gateway handed out something else. Writing the controller is only half the job, so the script now says so explicitly and gives the force-provision and DHCP-probe commands to verify with. `nmap --script broadcast-dhcp-discover --script-args broadcast-dhcp-discover.mac=...` is the way to prove a specific reservation is live without disturbing the client -- it elicits an OFFER without ever sending a REQUEST. _unifi.py gains post() for device commands. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/_unifi.py | 14 +++++++++ migration/unifi-reserve-all.py | 52 +++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/migration/_unifi.py b/migration/_unifi.py index df31d6e..6217df3 100755 --- a/migration/_unifi.py +++ b/migration/_unifi.py @@ -51,6 +51,20 @@ def get(opener, base, site, path): return {"__error__": f"{type(exc).__name__}: {exc}"} +def post(opener, base, site, path, payload): + """POST to /api/s// -- used for device commands (cmd/devmgr).""" + req = urllib.request.Request( + f"{base}/api/s/{site}/{path}", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + try: + return json.loads(opener.open(req, timeout=30).read()).get("data", []) + except urllib.error.HTTPError as exc: + return {"__error__": f"HTTP {exc.code}: {exc.read()[:300].decode(errors='replace')}"} + except Exception as exc: + return {"__error__": f"{type(exc).__name__}: {exc}"} + + def put(opener, base, site, path, payload): """PUT to /api/s//. Returns the `data` list or an __error__ dict. diff --git a/migration/unifi-reserve-all.py b/migration/unifi-reserve-all.py index 28c3963..4ab17af 100755 --- a/migration/unifi-reserve-all.py +++ b/migration/unifi-reserve-all.py @@ -63,6 +63,9 @@ def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--apply", action="store_true", help="actually write (default: dry run)") ap.add_argument("--skip-random", action="store_true", help="omit randomised MACs") + ap.add_argument("--plan", default="reservation-plan.tsv", + help="dry run WRITES this file; --apply READS it and applies " + "exactly what it contains") args = ap.parse_args() opener, base, site = _unifi.client() @@ -158,9 +161,42 @@ def main() -> int: f"stops matching once the device re-randomises.") if not args.apply: - print(f"\ndry run -- nothing written. Re-run with --apply to create {len(plan)}.") + with open(args.plan, "w") as fh: + for ip, mac, label, _u, _n in plan: + fh.write(f"{mac}\t{ip}\t{label}\n") + print(f"\ndry run -- nothing written to the controller.") + print(f"plan saved to {args.plan}; re-run with --apply to apply exactly that.") return 0 + # Apply the plan that was REVIEWED, not one recomputed now. + # + # This cost a k8s node an outage. The apply used to re-read stat/sta, and a + # client that renewed between the dry run and the apply got pinned to + # whatever transient address it happened to hold at that instant -- worker1 + # was reviewed at .13 and written as .242. A plan you looked at and a plan + # that gets applied must be the same object. + try: + with open(args.plan) as fh: + reviewed = {} + for line in fh: + parts = line.rstrip("\n").split("\t") + if len(parts) >= 2: + reviewed[parts[0].lower()] = parts[1] + except OSError: + print(f"no plan at {args.plan}. Run without --apply first and review it.", + file=sys.stderr) + return 1 + + drifted = [(ip, mac) for ip, mac, _l, _u, _n in plan + if mac in reviewed and reviewed[mac] != ip] + for ip, mac in drifted: + print(f" note: {mac} now reports {ip}, plan says {reviewed[mac]} -- " + f"applying the plan", file=sys.stderr) + + plan = [(reviewed[mac], mac, label, user, net) + for ip, mac, label, user, net in plan if mac in reviewed] + print(f"applying {len(plan)} reservation(s) from {args.plan}") + print() ok = fail = 0 for ip, mac, label, user, net in plan: @@ -179,6 +215,20 @@ def main() -> int: print(f"\nwrote {ok}, failed {fail}, verified live {verified}/{len(plan)}") print(f"total reservations on the controller now: " f"{sum(1 for u in after if u.get('use_fixedip'))}") + + # Writing the controller is only half the job. The gateway applies config + # on its own schedule, and a device running config from before these + # changes will hand out addresses that disagree with what the controller + # shows -- which is how a k8s node ended up unable to get any lease at all + # while the controller looked perfectly correct. + print("\nThe controller now disagrees with what the gateway is running.") + print("Push it to the device and wait for state to return to 'connected':") + print(" python3 -c \"import _unifi; o,b,s=_unifi.client(); " + "print(_unifi.post(o,b,s,'cmd/devmgr'," + "{'cmd':'force-provision','mac':''}))\"") + print("Then verify a real DISCOVER is answered before trusting it:") + print(" ssh vyos@ 'sudo nmap --script broadcast-dhcp-discover -e eth2 " + "--script-args broadcast-dhcp-discover.mac='") return 0 if fail == 0 and verified == len(plan) else 1 From 2a8fcb3bd3d843714353de19dbd9019d402d637b Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 16:25:04 +0100 Subject: [PATCH 10/26] fix(migration): the runbook pointed at addresses that die with the USG The access table led with 192.168.8.143/.144 and offered the LoT addresses as a fallback ("if unreachable, try"). That is backwards and would have stranded the operator at the worst moment: the workstation sits on LoT, and reaching 192.168.8.x routes *through the USG*, so those addresses are guaranteed dead the instant it is unplugged. Measured: ip route get 192.168.8.143 -> via 10.0.0.1 (the USG) ip route get 10.0.1.252 -> dev lanbr0 (same L2, no gateway) 10.0.1.252 and .253 are on the LoT VLAN, same broadcast domain as the workstation, and both answer SSH. They are now the only addresses the runbook gives, with the k8s ones struck through. Also recorded: the switch cannot be run before unplugging the USG (two devices on every gateway address; the guard refuses), so the order is forced. And during the gap between unplugging and completing the switch there is no inter-VLAN routing at all -- which means the JetKVMs (Management and kvm) and Tailscale are NOT fallbacks in that window. LoT SSH is the only remote path; below it is physical console. Added a step 0: open both SSH sessions and leave them open before touching anything. Both boxes are now installed and pass the pre-flight gate: mode unifi, unifi.boot 231 lines including the reload action, delta at the right priority (200/100), wan-secrets 0600, script executable, no config drift, VRRP still MASTER/BACKUP. `vyos-unifi-switch vyos` refuses on both -- all six gateway addresses detected answering ARP -- and neither box has gained dhcp-server, dns or nat, so nothing about their behaviour has changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/CUTOVER.md | 52 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/migration/CUTOVER.md b/migration/CUTOVER.md index fee7182..784f2fd 100644 --- a/migration/CUTOVER.md +++ b/migration/CUTOVER.md @@ -4,18 +4,45 @@ assistant and no web search. Everything you need is on this page and on the boxes themselves. +## Use these addresses. Not the other ones. + +| | use this | do NOT use | +|---|---|---| +| vyos001 (MASTER) | **`10.0.1.252`** | ~~192.168.8.143~~ | +| vyos002 (BACKUP) | **`10.0.1.253`** | ~~192.168.8.144~~ | + +`ssh vyos@10.0.1.252` — by IP, not by name. + +**The `192.168.8.x` addresses stop working the instant the USG is unplugged.** +That is not a maybe. Your workstation is on LoT (`10.0.0.210/23`) and reaching +`192.168.8.x` requires routing *through the USG*: + +``` +ip route get 192.168.8.143 -> via 10.0.0.1 <- the USG. Gone. +ip route get 10.0.1.252 -> dev lanbr0 <- same L2. Survives. +``` + +`10.0.1.252` and `.253` are on the LoT VLAN, the same broadcast domain as your +workstation, so they need no gateway at all. They are the only remote path that +survives the cutover. + +**Between unplugging the USG and finishing the switch there is no inter-VLAN +routing.** In that window: + +- the **JetKVMs are unreachable** from your workstation (they are on Management + and kvm) — they are *not* a fallback during the gap +- **Tailscale is down** with the internet +- your workstation keeps `10.0.0.210` (86400s lease) and can still resolve via + `10.0.0.194`, which is also link-scope + +If LoT SSH fails, the next step is physical console, not the network. + | | | |---|---| -| vyos001 (MASTER) | `192.168.8.143` — also `10.0.1.252` on LoT | -| vyos002 (BACKUP) | `192.168.8.144` — also `10.0.1.253` on LoT | -| JetKVMs | `192.168.1.28`, `192.168.1.29`, `192.168.3.6` | +| JetKVMs (after routing is restored) | `192.168.1.28`, `192.168.1.29`, `192.168.3.6` | | Switch script | `/config/vyos-unifi-switch` on each box | | Login | user `vyos` | -Both boxes have **two** reachable addresses on different interfaces. If `eth2` -(`192.168.8.x`) is unreachable, try the LoT address (`10.0.1.x`), and only then -the JetKVM. - --- ## If something is wrong, do this @@ -84,10 +111,19 @@ reference, and `translation port` rejects a port list. ## The cutover +0. **Open both SSH sessions BEFORE you unplug anything**, and leave them open: + ``` + ssh vyos@10.0.1.253 # vyos002, BACKUP + ssh vyos@10.0.1.252 # vyos001, MASTER + ``` + If either will not connect, stop. Do not unplug the USG. + 1. **Physically disconnect the USG.** Not just powered off — disconnected. The switch script refuses to run while anything still answers on a gateway address, because two devices on `.1` is the worst available outcome. -2. On **vyos002 (BACKUP) first**: + You cannot switch first and unplug after, for exactly that reason. + +2. In the **vyos002 (BACKUP)** session, first: ``` sudo /config/vyos-unifi-switch vyos ``` From 3768657b915c0d184d58943742d9651b9fd02018 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 16:49:27 +0100 Subject: [PATCH 11/26] chore(migration): firewalls resolve via 8.8.8.8/8.8.4.4 Matches the DNS the USG used on its WAN (wan_dns1/wan_dns2), replacing the 10.0.0.194 I had set earlier. Applied to both boxes and saved; VRRP unchanged (MASTER/BACKUP), NTP still synced, no config drift. /config/modes/unifi.boot was RE-CAPTURED on both afterwards. It had been taken before this change, so the escape hatch would have quietly reverted the resolver on any rollback -- a snapshot is only an escape hatch for the state it was taken from. Two things recorded in the runbook: - The boxes' name resolution now depends on the internet, so between unplugging the USG and PPPoE establishing they have no DNS. Harmless: nothing in the switch resolves a name, and the health checks use DNS precisely to prove the WAN came up. - Internal ad.itaz.eu names still resolve via Google, because that zone is published publicly with private addresses in it (nas001 -> 10.0.0.194, kvm-macstudio1 -> 192.168.3.8). So no conditional forward was needed -- though it is worth knowing the internal topology is public. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/CUTOVER.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/migration/CUTOVER.md b/migration/CUTOVER.md index 784f2fd..d666063 100644 --- a/migration/CUTOVER.md +++ b/migration/CUTOVER.md @@ -156,8 +156,15 @@ USG, and debug with the internet back on. LoT — they get their addresses from VyOS now. - **DHCP leases last 24h (86400s).** A device that does not renew promptly keeps its old address for a while. That is fine, not a symptom. -- **The firewalls resolve via `10.0.0.194`**, not the USG. That was changed - ahead of time precisely so they keep DNS when the USG goes away. +- **The firewalls resolve via `8.8.8.8` / `8.8.4.4`** — matching the DNS the USG + used on its WAN. This means their own name resolution now depends on the + *internet* being up, so between unplugging the USG and PPPoE establishing, + the boxes have no DNS at all. That is expected and harmless: they only need + DNS for NTP hostnames, and the switch's own health checks use it precisely to + prove the WAN came up. Nothing in the switch itself resolves a name. +- Internal `ad.itaz.eu` names still resolve through Google, because that zone is + published publicly with private addresses in it (`nas001` → `10.0.0.194`, + `kvm-macstudio1` → `192.168.3.8`). Convenient here; worth knowing it is public. - **The USG was a DNS resolver** for every VLAN except LoT. VyOS now runs `dns forwarding` in its place. If names stop resolving but IPs still work, that is where to look. From febe4b72bc9181f960ce03e49c72b3e33c1faf02 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 17:16:17 +0100 Subject: [PATCH 12/26] chore(migration): all DNS through VyOS to Google, NAS out of the path The NAS is legacy for ad.itaz.eu and those records now live in Cloudflare, so the zone resolves publicly -- verified: nas001.ad.itaz.eu and kvm-macstudio1.ad.itaz.eu both answer from 8.8.8.8. That removes the reason for a conditional forward and lets the NAS leave the DNS path entirely. Two changes: - `service dns forwarding name-server` is now 8.8.8.8 and 8.8.4.4, the same pair the USG used on its WAN, instead of 10.0.0.194. - Every VLAN is handed the gateway as its resolver. UniFi set an explicit resolver on LoT only (the NAS); carrying that over would have kept the NAS in the path for one VLAN and not the other five, which is the sort of asymmetry nobody remembers a year later. The NAS is still referenced 9 times, all legitimate and checked: 4 NAT destination rules, the 4 matching firewall accepts for those port forwards, and its own DHCP reservation. No DNS references remain. Validated by loading vyos001's real running config on the labsim router and applying the full delta -- all 318 commands accepted, no errors. Installed on both boxes and verified in place: priority 200/100, upstream 8.8.8.8 + 8.8.4.4, six client resolvers, 377 lines each. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/unifi-to-vyos.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/migration/unifi-to-vyos.py b/migration/unifi-to-vyos.py index d2480c6..0c94ed0 100755 --- a/migration/unifi-to-vyos.py +++ b/migration/unifi-to-vyos.py @@ -31,10 +31,13 @@ import os import re import sys -# Where the production upstream resolver lives. Verified authoritative for -# ad.itaz.eu (SOA nas001.ad.itaz.eu) and also recursive, so it can serve as the -# single forwarder target. -UPSTREAM_DNS = "10.0.0.194" +# Upstream resolvers for VyOS's own forwarder -- the same pair the USG used on +# its WAN (wan_dns1/wan_dns2). The NAS at 10.0.0.194 is deliberately NOT here: +# it is legacy for ad.itaz.eu, and those records now live in Cloudflare, so the +# zone resolves publicly (verified: nas001.ad.itaz.eu and kvm-macstudio1 both +# answer from 8.8.8.8). That means no conditional forward is needed and the NAS +# is out of the DNS path entirely. +UPSTREAM_DNS = ["8.8.8.8", "8.8.4.4"] # labsim equivalents, keyed by VLAN id. Only VLAN 10 needs a /23: every one of # the 31 reservations is in LoT, which spans 10.0.0.x and 10.0.1.x, and a /24 @@ -150,14 +153,12 @@ def build(inv: dict, mode: str) -> tuple[list[str], dict]: out.append(f"{base} subnet-id {vlan}") out.append(f"{base} option default-router {gw}") - # Preserve UniFi's explicit resolver where it set one (LoT -> the NAS); - # otherwise hand out the gateway, which is what the USG does today and - # what `service dns forwarding` below will answer on. - for ns in (n["dhcp_dns"] or [gw]): - mapped = ns if ns not in (UPSTREAM_DNS,) or mode == "prod" else gw - if mode == "sim" and ns == UPSTREAM_DNS: - mapped = gw # no NAS in the sim; the router resolves - out.append(f"{base} option name-server {mapped}") + # Every VLAN is handed the gateway as its resolver, so all lookups go + # through VyOS and out to the upstreams above. UniFi set an explicit + # resolver on LoT only (the NAS); that is deliberately not carried over + # -- the NAS is legacy and pointing clients at it would keep it in the + # path for one VLAN and not the others. + out.append(f"{base} option name-server {gw}") if n["domain_name"]: out.append(f"{base} option domain-name '{n['domain_name']}'") @@ -198,11 +199,8 @@ def build(inv: dict, mode: str) -> tuple[list[str], dict]: vlan = vlan_of(n) out.append(f"set service dns forwarding listen-address {m.gateway(vlan, n)}") out.append(f"set service dns forwarding allow-from {m.net(vlan)}") - if mode == "prod": - out.append(f"set service dns forwarding name-server {UPSTREAM_DNS}") - else: - # The sim has no NAS; forward to whatever the sim host can reach. - out.append("set service dns forwarding name-server 1.1.1.1") + for ns in UPSTREAM_DNS: + out.append(f"set service dns forwarding name-server {ns}") out.append("set service dns forwarding cache-size 10000") stats["clamped"] = m.clamped From f81c94af432f994023a80198107e3724d08f63bc Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 18:16:25 +0100 Subject: [PATCH 13/26] 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) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/CUTOVER.md | 20 ++- migration/vyos-mode-delta.py | 314 ++++++++++++++++++++--------------- 2 files changed, 201 insertions(+), 133 deletions(-) diff --git a/migration/CUTOVER.md b/migration/CUTOVER.md index d666063..6dfd26f 100644 --- a/migration/CUTOVER.md +++ b/migration/CUTOVER.md @@ -136,9 +136,23 @@ reference, and `translation port` rejects a port list. ## What will probably go wrong first -**PPPoE.** It is the one thing that could not be tested in advance — the line -permits a single session and the USG held it until you unplugged it. If the -WAN check fails: +**The WAN.** There are two, and they behave differently: + +| | line | VLAN | transport | notes | +|---|---|---|---|---| +| WAN2 | 10 gig ISP | **53** | DHCP, public `87.192.101.48/21` | primary, distance 1 | +| WAN1 | Vodafone | **51** | PPPoE, ~900/700 Mbit | failover, distance 10 | + +VyOS clones the USG's WAN2 MAC (`f0:9f:c2:12:9b:4f`) on `bond0.53`, which is how +it keeps the existing public lease rather than asking for a new one. **Only +vyos001 carries the WAN** — the MAC must be unique, so vyos002 routes the LAN +and holds the VIPs but has no internet path until the WAN is moved deliberately. + +PPPoE is no longer an unknown: it was proven on the USG before cutover +(`pppoe0` came up with `90.241.226.213`, MTU 1492). What remains untested is +VyOS dialling it, and whether the ISP hands the same lease to the cloned MAC. + +If the WAN check fails: ``` show interfaces pppoe pppoe0 diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py index befccdb..983c9dc 100755 --- a/migration/vyos-mode-delta.py +++ b/migration/vyos-mode-delta.py @@ -34,8 +34,28 @@ _spec = importlib.util.spec_from_file_location( unifi_to_vyos = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(unifi_to_vyos) -WAN_VIF = "bond0.51" # WAN arrives trunked as vlan-only network 51 -WAN_IF = "pppoe0" +# 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. +DIST_DHCP, DIST_PPPOE = 1, 10 + PLACEHOLDER = "@@WAN_PASSWORD@@" @@ -45,7 +65,7 @@ def vrrp_group(vlan: int) -> str: return "native" if vlan == 1 else f"vlan{vlan}" -def build_delta(inv: dict, priority: int, wan_user: str) -> list[str]: +def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool) -> list[str]: out: list[str] = [] nets = [n for n in inv["networks"] if n["dhcp_enabled"] and n["subnet"]] nets.sort(key=unifi_to_vyos.vlan_of) @@ -81,139 +101,170 @@ def build_delta(inv: dict, priority: int, wan_user: str) -> list[str]: out += [ "", - "# --- WAN ---------------------------------------------------", - "# Vodafone line, PPPoE over the trunked WAN VLAN. This is the one", - "# part that cannot be tested before cutover: the line permits a", - "# single session and the USG holds it until it is unplugged.", - "#", - "# The WAN vif must be created first. Neither firewall has vif 51 today", - "# (only 2, 3, 9, 10 and 200 exist), and pppoe source-interface refers to", - "# an interface that has to already be configured -- without this the", - "# commit fails and takes the whole switch with it. No address on it:", - "# PPPoE rides the VLAN, it does not need L3 of its own.", - f"set interfaces bonding bond0 vif {WAN_VIF.split('.')[1]} description 'WAN (Vodafone, PPPoE)'", - "", - f"set interfaces pppoe {WAN_IF} source-interface {WAN_VIF}", - f"set interfaces pppoe {WAN_IF} authentication username '{wan_user}'", - f"set interfaces pppoe {WAN_IF} authentication password '{PLACEHOLDER}'", - f"set interfaces pppoe {WAN_IF} mtu 1492", - # The peer's resolvers would otherwise overwrite /etc/resolv.conf and - # cost the box its ability to resolve internal ad.itaz.eu names. - f"set interfaces pppoe {WAN_IF} no-peer-dns", - "", - "# The static default route exists only for unifi mode, where the USG", - "# is the next hop. PPPoE supplies the default here; leaving both at", - "# the same distance would be ambiguous.", - "delete protocols static route 0.0.0.0/0", - "", - "# --- NAT ---------------------------------------------------", - f"set nat source rule 100 outbound-interface name {WAN_IF}", - "set nat source rule 100 translation address masquerade", - "set nat source rule 100 description 'LAN out to the internet'", ] - # Port forwards, straight from UniFi. - for i, p in enumerate(inv["port_forwards"]): - if not p.get("enabled"): - continue - rule = 100 + i * 10 - proto = p["proto"] # tcp | udp | tcp_udp -- all valid VyOS values + if with_wan: out += [ + "# --- 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)'", "", - f"set nat destination rule {rule} description '{p['name']}'", - f"set nat destination rule {rule} inbound-interface name {WAN_IF}", - f"set nat destination rule {rule} protocol {proto}", - f"set nat destination rule {rule} destination port '{p['dst_port']}'", - f"set nat destination rule {rule} translation address {p['fwd']}", + "# 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", + f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} dhcp-options default-route-distance {DIST_DHCP}", + "", + "# 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", + "", + "# --- 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)'", ] - # `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']}'") - - out += [ - "", - "# --- firewall ----------------------------------------------", - "# VyOS defaults to accepting everything. The USG has an implicit", - "# WAN drop, so migrating the port forwards alone would leave the", - "# router's own services and the whole LAN reachable from the WAN.", - "#", - "# Scoped to the WAN interface rather than a global default-action", - "# drop: that way a mistake here cannot lock anyone out over the LAN,", - "# which is the only path back in during a cutover.", - "", - "# Traffic TO the router.", - "set firewall ipv4 input filter default-action accept", - "set firewall ipv4 input filter rule 100 action accept", - "set firewall ipv4 input filter rule 100 state established", - "set firewall ipv4 input filter rule 100 state related", - "set firewall ipv4 input filter rule 100 description 'established/related'", - ] - # The two WAN_LOCAL accepts carried over from UniFi. - out += [ - "", - "set firewall ipv4 input filter rule 110 action accept", - "set firewall ipv4 input filter rule 110 protocol esp", - f"set firewall ipv4 input filter rule 110 inbound-interface name {WAN_IF}", - "set firewall ipv4 input filter rule 110 description 'VPN accept ESP (from UniFi WAN_LOCAL)'", - "", - "set firewall ipv4 input filter rule 120 action accept", - "set firewall ipv4 input filter rule 120 protocol udp", - "set firewall ipv4 input filter rule 120 destination port '500,4500'", - f"set firewall ipv4 input filter rule 120 inbound-interface name {WAN_IF}", - "set firewall ipv4 input filter rule 120 description 'VPN accept UDP500/4500 (from UniFi WAN_LOCAL)'", - "", - "set firewall ipv4 input filter rule 130 action accept", - "set firewall ipv4 input filter rule 130 protocol icmp", - f"set firewall ipv4 input filter rule 130 inbound-interface name {WAN_IF}", - "set firewall ipv4 input filter rule 130 description 'ICMP to the router (path MTU discovery)'", - "", - "# Everything else arriving from the WAN is dropped. LAN is untouched.", - "set firewall ipv4 input filter rule 900 action drop", - f"set firewall ipv4 input filter rule 900 inbound-interface name {WAN_IF}", - "set firewall ipv4 input filter rule 900 description 'drop all other WAN-to-router'", - "", - "# Traffic THROUGH the router.", - "set firewall ipv4 forward filter default-action accept", - "set firewall ipv4 forward filter rule 100 action accept", - "set firewall ipv4 forward filter rule 100 state established", - "set firewall ipv4 forward filter rule 100 state related", - "set firewall ipv4 forward filter rule 100 description 'established/related'", - ] - - # Destination NAT happens before the forward filter, so these rules must - # match the translated destination, not the WAN address. - for i, p in enumerate(inv["port_forwards"]): - if not p.get("enabled"): - continue - rule = 200 + i * 10 + else: out += [ + "# --- WAN -----------------------------------------------", + "# This box carries NO WAN. Only one of the pair may hold the", + "# cloned WAN MAC, so the backup routes the LAN and holds the VIPs", + "# if the master dies, but has no internet path until the WAN is", + "# moved to it deliberately.", "", - f"set firewall ipv4 forward filter rule {rule} action accept", - f"set firewall ipv4 forward filter rule {rule} inbound-interface name {WAN_IF}", - f"set firewall ipv4 forward filter rule {rule} protocol {p['proto']}", - f"set firewall ipv4 forward filter rule {rule} destination address {p['fwd']}", - f"set firewall ipv4 forward filter rule {rule} destination port '{p['fwd_port']}'", - f"set firewall ipv4 forward filter rule {rule} description 'port forward: {p['name']}'", ] - out += [ - "", - "# New inbound connections from the WAN that are not a port forward.", - "set firewall ipv4 forward filter rule 900 action drop", - f"set firewall ipv4 forward filter rule 900 inbound-interface name {WAN_IF}", - "set firewall ipv4 forward filter rule 900 description 'drop unsolicited WAN-to-LAN'", - "", - ] + if with_wan: + # NAT and the WAN firewall only mean anything on the box that has a + # WAN. On the other one they would reference interfaces that do not + # exist and fail the commit. + # 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']}'") + + out += [ + "", + "# --- firewall ----------------------------------------------", + "# VyOS defaults to accepting everything. The USG has an implicit", + "# WAN drop, so migrating the port forwards alone would leave the", + "# router's own services and the whole LAN reachable from the WAN.", + "#", + "# Scoped to the WAN interface rather than a global default-action", + "# drop: that way a mistake here cannot lock anyone out over the LAN,", + "# which is the only path back in during a cutover.", + "", + "# Traffic TO the router.", + "set firewall ipv4 input filter default-action accept", + "set firewall ipv4 input filter rule 100 action accept", + "set firewall ipv4 input filter rule 100 state established", + "set firewall ipv4 input filter rule 100 state related", + "set firewall ipv4 input filter rule 100 description 'established/related'", + ] + # The two WAN_LOCAL accepts carried over from UniFi. + out += [ + "", + "set firewall ipv4 input filter rule 110 action accept", + "set firewall ipv4 input filter rule 110 protocol esp", + f"set firewall ipv4 input filter rule 110 inbound-interface name {WAN_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'", + ] + + # 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'", + "", + ] # DHCP + DNS, from the same generator labsim proved. dhcp_lines, stats = unifi_to_vyos.build(inv, "prod") @@ -232,6 +283,8 @@ def main() -> int: help="VRRP priority: 200 for the master, 100 for the backup") ap.add_argument("--inventory", default=os.path.join(HERE, "export", "inventory.json")) ap.add_argument("--raw-networkconf", default=os.path.join(HERE, "export", "rest_networkconf.json")) + ap.add_argument("--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.") ap.add_argument("-o", "--out") ap.add_argument("--emit-secrets", metavar="PATH", help="write the PPPoE credential to PATH with mode 0600 and exit") @@ -259,11 +312,12 @@ def main() -> int: file=sys.stderr) return 0 - lines = build_delta(inv, args.priority, wan["wan_username"]) + lines = build_delta(inv, args.priority, wan["wan_username"], args.with_wan) text = "\n".join(lines) + "\n" - if PLACEHOLDER not in text: - print("BUG: password placeholder missing from the delta", file=sys.stderr) + # 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) 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) From 952f5c66e36fde674d1bc129db5b7163119bcfdc Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 22:48:39 +0100 Subject: [PATCH 14/26] 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) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-mode-delta.py | 81 +++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py index 983c9dc..faa3b1e 100755 --- a/migration/vyos-mode-delta.py +++ b/migration/vyos-mode-delta.py @@ -58,6 +58,29 @@ DIST_DHCP, DIST_PPPOE = 1, 10 PLACEHOLDER = "@@WAN_PASSWORD@@" +# 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 + + def vrrp_group(vlan: int) -> str: """VRRP group names as configured on the boxes: 'native' for the untagged @@ -65,8 +88,11 @@ def vrrp_group(vlan: int) -> str: return "native" if vlan == 1 else f"vlan{vlan}" -def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool) -> list[str]: +def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool, + conntrack_link: bool) -> list[str]: out: list[str] = [] + primary = priority >= 200 # vyos001 is the master/primary + self_i, peer_i = (0, 1) if primary else (1, 0) nets = [n for n in inv["networks"] if n["dhcp_enabled"] and n["subnet"]] nets.sort(key=unifi_to_vyos.vlan_of) @@ -98,6 +124,16 @@ def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool) -> list 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}") + 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") out += [ "", @@ -266,6 +302,44 @@ def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool) -> list "", ] + + # --- 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", + ] + # DHCP + DNS, from the same generator labsim proved. dhcp_lines, stats = unifi_to_vyos.build(inv, "prod") expected = len(inv["reservations"]) @@ -285,6 +359,8 @@ def main() -> int: ap.add_argument("--raw-networkconf", default=os.path.join(HERE, "export", "rest_networkconf.json")) 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.") + 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.") ap.add_argument("-o", "--out") ap.add_argument("--emit-secrets", metavar="PATH", help="write the PPPoE credential to PATH with mode 0600 and exit") @@ -312,7 +388,8 @@ def main() -> int: file=sys.stderr) return 0 - lines = build_delta(inv, args.priority, wan["wan_username"], args.with_wan) + lines = build_delta(inv, args.priority, wan["wan_username"], args.with_wan, + args.conntrack_link) text = "\n".join(lines) + "\n" # Only a WAN-carrying delta has a credential to placeholder-substitute. From bb654d83f8c22f0c8ed4dfb9bf4253a541461ac9 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 23:29:12 +0100 Subject: [PATCH 15/26] test(labsim): second VyOS router proves DHCP active-passive HA Answers the question a single router could not, and that would otherwise only have been discovered at cutover: with kea high-availability active-passive, does exactly ONE box answer a DHCP request? Yes. Probing sim VLAN 10 with broadcast-dhcp-discover returns offers from a single distinct Server Identifier -- 172.31.10.252, the primary. The secondary runs kea but stays silent. Without this the delta would have put 6 subnets and 84 static-mappings on both boxes with nothing to arbitrate them, and two kea instances would have raced on every broadcast domain. Worth noting the raw response count is misleading: nmap reports "Response 1 of 2" because it sends several discovers, and both replies carry the same server identifier. Counting responses says "2 servers"; counting distinct server identifiers says "1". The second number is the true one. labsim now runs a real pair, mirroring production: router1 172.31..252 priority 200 DHCP HA primary router2 172.31..253 priority 100 DHCP HA secondary VIP 172.31..1 floating, held by the master That required converting router1, which held .1 directly, to .252 plus a floating VIP -- otherwise it is two routers, not a pair. All six VRRP groups show MASTER on router1 and BACKUP on router2. New tooling: - sim-ha-config.py generates each role's config, reusing unifi-to-vyos.py --mode sim for the DHCP half so what is proven here and what production gets share a code path. VLAN 10 correctly carries /23. - console-apply.py applies config over the serial console, which is necessary because a freshly installed VyOS holds the same addresses as its peer and cannot safely be reached over the network at all until reconfigured. Known sim-only quirk, deliberately not chased: router1 cannot ARP router2 on the untagged VLAN 1 while every tagged VLAN works, and VRRP forms correctly on all six groups regardless. Both OVS bonds carry identical vlan_mode/tag/trunks and the bond MACs differ, so this is OVS bond behaviour on the native VLAN with two bonds on one bridge -- not a VyOS config problem, and not present in production, which uses a real switch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- labsim/console-apply.py | 89 ++++++++++++++++++++++++++++++++ labsim/sim-ha-config.py | 109 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100755 labsim/console-apply.py create mode 100755 labsim/sim-ha-config.py diff --git a/labsim/console-apply.py b/labsim/console-apply.py new file mode 100755 index 0000000..82b1f42 --- /dev/null +++ b/labsim/console-apply.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Apply VyOS config to a labsim VM over its serial console. + +Needed because a freshly installed VyOS comes up holding the same addresses as +its peer, so there is a window where it cannot safely be reached over the +network at all. The console does not care. + + ./console-apply.py --vm labsim-vyos2 --config r2.conf +""" +from __future__ import annotations + +import argparse +import sys +import time + +import pexpect + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--vm", required=True) + ap.add_argument("--config", required=True) + ap.add_argument("--user", default="vyos") + ap.add_argument("--password", default="vyos") + args = ap.parse_args() + + cmds = [l.rstrip() for l in open(args.config) + if l.strip() and not l.lstrip().startswith("#")] + print(f"{len(cmds)} commands to apply to {args.vm}", file=sys.stderr) + + c = pexpect.spawn(f"virsh --connect qemu:///system console {args.vm}", + timeout=90, encoding="utf-8") + c.logfile_read = None + c.sendline("") + time.sleep(2) + c.sendline("") + + # Log in. A freshly booted box may still be starting services, so allow a + # generous window and re-prod the console rather than failing on the first + # miss. + for _ in range(40): + i = c.expect([r"login:", r"\$ ", r"# ", pexpect.TIMEOUT], timeout=15) + if i == 0: + c.sendline(args.user) + c.expect("Password:", timeout=30) + c.sendline(args.password) + c.expect(r"\$ ", timeout=60) + break + if i in (1, 2): + break + c.sendline("") + else: + print("never reached a prompt", file=sys.stderr) + return 1 + + c.sendline("configure") + c.expect(r"# ", timeout=60) + + for cmd in cmds: + c.sendline(cmd) + c.expect(r"# ", timeout=60) + out = c.before or "" + if "Set failed" in out or "not valid" in out or "Invalid" in out: + print(f"FAILED: {cmd}\n {out.strip()[:200]}", file=sys.stderr) + + print("committing...", file=sys.stderr) + c.sendline("commit") + c.expect(r"# ", timeout=300) + commit_out = c.before or "" + c.sendline("save") + c.expect(r"# ", timeout=120) + c.sendline("exit") + c.expect(r"\$ ", timeout=60) + c.sendline("exit") + c.close() + + bad = [l for l in commit_out.splitlines() + if "failed" in l.lower() or "error" in l.lower()] + if bad: + print("commit reported:", file=sys.stderr) + for l in bad[:10]: + print(f" {l.strip()}", file=sys.stderr) + return 1 + print("committed and saved", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/labsim/sim-ha-config.py b/labsim/sim-ha-config.py new file mode 100755 index 0000000..62389e4 --- /dev/null +++ b/labsim/sim-ha-config.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Generate the HA config for the labsim VyOS pair. + +Exists to answer one question that cannot be answered on a single router, and +that would otherwise only be discovered at cutover: with kea HA active-passive, +does exactly ONE box answer a DHCP request? + +Mirrors the production shape so the answer transfers: + + router1 172.31..252 priority 200 DHCP HA primary + router2 172.31..253 priority 100 DHCP HA secondary + VIP 172.31..1 (what clients use as their gateway) + +Note the sim's LoT VLAN is a /23 like production, so the VIP prefix differs +there -- getting that wrong produces a config that commits and then behaves +subtly wrongly, which is worse than a failure. + + ./sim-ha-config.py --role primary > r1.conf + ./sim-ha-config.py --role secondary > r2.conf +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +MIG = os.path.join(HERE, "..", "migration") + +# Reuse the DHCP/DNS generator rather than hand-writing subnets: the whole +# point is that what is proven here and what production gets share a code path. +_spec = importlib.util.spec_from_file_location( + "unifi_to_vyos", os.path.join(MIG, "unifi-to-vyos.py")) +unifi_to_vyos = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(unifi_to_vyos) + +# vlan -> (prefix, cidr). LoT is a /23 in the sim, matching production. +VLANS = { + 1: ("172.31.1", 24), + 2: ("172.31.2", 24), + 3: ("172.31.3", 24), + 9: ("172.31.9", 24), + 10: ("172.31.10", 23), + 200: ("172.31.200", 24), +} +DHCP_HA_NAME = "labsim-dhcp-pair" # must not equal either host-name + + +def group(vlan: int) -> str: + return "native" if vlan == 1 else f"vlan{vlan}" + + +def build(role: str) -> list[str]: + primary = role == "primary" + self_o, peer_o = (252, 253) if primary else (253, 252) + prio = 200 if primary else 100 + out = [f"# labsim VyOS HA -- {role}", ""] + + for vlan, (pfx, cidr) in VLANS.items(): + g = group(vlan) + iface = "bond0" if vlan == 1 else f"bond0 vif {vlan}" + out += [ + f"# VLAN {vlan}", + # The node's own address replaces the .1 it used to hold directly; + # .1 becomes the floating VIP, exactly as production will be. + f"delete interfaces bonding {iface} address", + f"set interfaces bonding {iface} address '{pfx}.{self_o}/{cidr}'", + f"set high-availability vrrp group {g} interface bond0{'' if vlan == 1 else f'.{vlan}'}", + f"set high-availability vrrp group {g} vrid {vlan}", + f"set high-availability vrrp group {g} address {pfx}.1/{cidr}", + f"set high-availability vrrp group {g} priority {prio}", + f"set high-availability vrrp group {g} hello-source-address {pfx}.{self_o}", + f"set high-availability vrrp group {g} peer-address {pfx}.{peer_o}", + f"set high-availability vrrp group {g} no-preempt", + f"set high-availability vrrp sync-group MAIN member {g}", + "", + ] + + out += [ + "# --- DHCP high-availability ---", + "# The thing under test: active-passive should mean exactly one OFFER.", + "set service dhcp-server high-availability mode active-passive", + f"set service dhcp-server high-availability status {role}", + f"set service dhcp-server high-availability name {DHCP_HA_NAME}", + f"set service dhcp-server high-availability source-address 172.31.10.{self_o}", + f"set service dhcp-server high-availability remote 172.31.10.{peer_o}", + "", + ] + + inv = json.load(open(os.path.join(MIG, "export", "inventory.json"))) + dhcp, stats = unifi_to_vyos.build(inv, "sim") + out += [l for l in dhcp if l.strip() and not l.startswith("#")] + print(f"{role}: {stats['subnets']} subnets, {stats['mappings']} mappings", + file=sys.stderr) + return out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--role", choices=("primary", "secondary"), required=True) + args = ap.parse_args() + sys.stdout.write("\n".join(build(args.role)) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 64e748ea9473c53f03eb01a0d0c57147d7dd8a38 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 17 Aug 2026 00:17:21 +0100 Subject: [PATCH 16/26] 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) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-mode-delta.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py index faa3b1e..66d06dc 100755 --- a/migration/vyos-mode-delta.py +++ b/migration/vyos-mode-delta.py @@ -139,6 +139,23 @@ def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool, "", ] + + 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'", + ] + if with_wan: out += [ "# --- WAN -----------------------------------------------", From ccdd1e7e49682e4173aa28ff504254f7b2dbbc92 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 17 Aug 2026 00:30:23 +0100 Subject: [PATCH 17/26] 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) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/CUTOVER.md | 18 +++++++++++++++--- migration/vyos-mode-delta.py | 33 ++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/migration/CUTOVER.md b/migration/CUTOVER.md index 6dfd26f..8c8aa8c 100644 --- a/migration/CUTOVER.md +++ b/migration/CUTOVER.md @@ -144,9 +144,21 @@ reference, and `translation port` rejects a port list. | WAN1 | Vodafone | **51** | PPPoE, ~900/700 Mbit | failover, distance 10 | VyOS clones the USG's WAN2 MAC (`f0:9f:c2:12:9b:4f`) on `bond0.53`, which is how -it keeps the existing public lease rather than asking for a new one. **Only -vyos001 carries the WAN** — the MAC must be unique, so vyos002 routes the LAN -and holds the VIPs but has no internet path until the WAN is moved deliberately. +it keeps the existing public lease rather than asking for a new one. + +**Both boxes carry the identical WAN and NAT config.** vyos002's WAN interfaces +are simply held administratively down, so the cloned MAC is never live on two +boxes at once. To move the internet path to vyos002: + +``` +configure +delete interfaces bonding bond0 vif 53 disable +delete interfaces pppoe pppoe0 disable +commit; save +``` + +Two lines. Do it only when vyos001 is genuinely down or disconnected — two boxes +holding that MAC at once is exactly what the disable prevents. PPPoE is no longer an unknown: it was proven on the USG before cutover (`pppoe0` came up with `90.241.226.213`, MTU 1492). What remains untested is diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py index 66d06dc..fc376bf 100755 --- a/migration/vyos-mode-delta.py +++ b/migration/vyos-mode-delta.py @@ -156,7 +156,7 @@ def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool, "set firewall ipv4 forward filter rule 10 description 'stateful tracking'", ] - if with_wan: + if True: # WAN config on BOTH boxes; see the disable block below out += [ "# --- WAN -----------------------------------------------", "# Both vifs must be created before anything references them.", @@ -196,20 +196,31 @@ def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool, "set nat source rule 110 translation address masquerade", "set nat source rule 110 description 'LAN out via Vodafone (failover)'", ] - else: + + if not with_wan: + # The backup carries the identical WAN and NAT config but with the + # interfaces administratively DOWN. The cloned MAC is therefore never + # live on two boxes at once, while everything needed to route and + # masquerade is already present -- taking over is enabling two + # interfaces, not rebuilding a config under pressure. + # + # NAT rules naming a down interface are harmless: VyOS warns at commit + # ("Interface ... does not exist!") and commits anyway, verified. out += [ - "# --- WAN -----------------------------------------------", - "# This box carries NO WAN. Only one of the pair may hold the", - "# cloned WAN MAC, so the backup routes the LAN and holds the VIPs", - "# if the master dies, but has no internet path until the WAN is", - "# moved to it deliberately.", "", + "# --- WAN held DOWN on this box -----------------------------", + "# Enable these two to take over the internet path:", + f"# set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} disable <- delete this", + f"# set interfaces pppoe {WAN_PPPOE_IF} disable <- and this", + f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} disable", + f"set interfaces pppoe {WAN_PPPOE_IF} disable", ] - if with_wan: - # NAT and the WAN firewall only mean anything on the box that has a - # WAN. On the other one they would reference interfaces that do not - # exist and fail the commit. + 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. # Port forwards, straight from UniFi. for i, p in enumerate(inv["port_forwards"]): if not p.get("enabled"): From 63061e6e7e1549f2bcf2ebd80eaefed0ab2d5360 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 17 Aug 2026 00:37:16 +0100 Subject: [PATCH 18/26] feat(migration): peer link cabled and verified; conntrack-sync enabled in deltas eth3 <-> eth3 direct cable is in. Both ends negotiated 2500Mb full duplex -- these are 2.5 GbE ports, not the 1G I had assumed. Carrier alone proves nothing, so the link was tested end to end with temporary kernel-level addresses (never committed to VyOS config, removed afterwards): 3/3 packets, 0% loss, 0.371ms average. A cable can show carrier and still not pass traffic; now it is known to. Deltas regenerated with --conntrack-link and installed on both boxes: vyos001 nat=21 fw=58 conntrack=9 eth3=10.255.255.1/30 disable=0 vyos002 nat=21 fw=58 conntrack=9 eth3=10.255.255.2/30 disable=2 Identical apart from the peer /30, the VRRP/DHCP-HA roles, and the two disable lines holding vyos002's WAN down. Both still report mode=unifi and nothing about their behaviour has changed -- eth3 carries no address in the running config, and conntrack-sync appears only in the delta, which is applied at cutover. Not verified: multicast on the peer link. `ping -I eth3 224.0.0.1` drew no responders, but that is the all-hosts group which VyOS need not answer, so it proves nothing either way. conntrack-sync's own multicast (225.0.0.50) was proven working in labsim over bond0.10, and this is a point-to-point link, so the risk is low -- but it is untested on this specific cable and worth watching at cutover. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/CUTOVER.md | 1 + 1 file changed, 1 insertion(+) diff --git a/migration/CUTOVER.md b/migration/CUTOVER.md index 8c8aa8c..411e400 100644 --- a/migration/CUTOVER.md +++ b/migration/CUTOVER.md @@ -41,6 +41,7 @@ If LoT SSH fails, the next step is physical console, not the network. |---|---| | JetKVMs (after routing is restored) | `192.168.1.28`, `192.168.1.29`, `192.168.3.6` | | Switch script | `/config/vyos-unifi-switch` on each box | +| Peer link | `eth3` ↔ `eth3` direct cable, 2.5 GbE, `10.255.255.0/30` — conntrack state sync | | Login | user `vyos` | --- From 41b5448f561c087cffe93f71f8bb1dc8d2e5a130 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 17 Aug 2026 01:09:05 +0100 Subject: [PATCH 19/26] feat(pulumi-vyos): prototype VyOS subtrees as Pulumi resources with commit-confirm Goal: change VyOS and Kubernetes in one codebase and one plan -- so a BGP change touches both sides in a single `pulumi preview`. First, the worry about per-command pushes turned out to be unfounded for the community providers. Read foltik/vyos and its client library: a `vyos_config_block_tree` flattens the whole subtree into a single payload array and sends ONE POST to /configure, so one resource is one commit. Good. What they do not do is send `confirm_time`. Their payload is only op/path/value, so every change is an unprotected commit -- on a router you reach through the router, that is the difference between a mistake and an outage. The VyOS API itself supports commit-confirm; the providers simply do not use it. So this is a ~180-line Pulumi dynamic provider that does. Verified end to end on labsim: create and update each land in ~6s as one commit-confirmed transaction, update reports [diff: ~commands], destroy removes the subtree, and an unconfirmed commit was observed reverting the router on its own. Three API details found the hard way, all now encoded and commented: - confirm_time is ONLY read when the body parses as ConfigureListModel, i.e. {"commands": [...], "confirm_time": N}. A bare array is accepted and committed with NO timer armed, and the response looks like success. This silently discards the entire safety net, so the resource now checks the response actually says "commit-confirm" and refuses to proceed otherwise. - There is no /confirm endpoint; confirm is an op on /configure. - Confirm requires a `path` field even though it ignores it -- the Union resolves to ConfigureModel, which mandates path. Without it: "missing 'path' field", and the timer keeps running. Apply is `delete ` followed by the sets, in one request, so the result is the declared state rather than a merge -- otherwise `pulumi up` accumulates instead of converging. Known gaps, in the README rather than hidden: no read/refresh so out-of-band drift is not detected, and the API runs with a self-signed certificate and verification disabled. Both need addressing before production. The cutover itself should still use vyos-unifi-switch, which the API cannot replace. Sim left as found: test resource destroyed, dns forwarding restored to 15 lines. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- pulumi-vyos/Pulumi.labsim.yaml | 1 + pulumi-vyos/Pulumi.yaml | 3 + pulumi-vyos/README.md | 72 ++ pulumi-vyos/index.ts | 27 + pulumi-vyos/package.json | 11 + pulumi-vyos/pnpm-lock.yaml | 1822 ++++++++++++++++++++++++++++++++ pulumi-vyos/tsconfig.json | 2 + pulumi-vyos/vyosConfigTree.ts | 205 ++++ 8 files changed, 2143 insertions(+) create mode 100644 pulumi-vyos/Pulumi.labsim.yaml create mode 100644 pulumi-vyos/Pulumi.yaml create mode 100644 pulumi-vyos/README.md create mode 100644 pulumi-vyos/index.ts create mode 100644 pulumi-vyos/package.json create mode 100644 pulumi-vyos/pnpm-lock.yaml create mode 100644 pulumi-vyos/tsconfig.json create mode 100644 pulumi-vyos/vyosConfigTree.ts diff --git a/pulumi-vyos/Pulumi.labsim.yaml b/pulumi-vyos/Pulumi.labsim.yaml new file mode 100644 index 0000000..7339f38 --- /dev/null +++ b/pulumi-vyos/Pulumi.labsim.yaml @@ -0,0 +1 @@ +encryptionsalt: v1:tMNG4q79HiI=:v1:K35iEOw3pgyukCr6:LmKO37/jghjyT2sLuoLmCQIDPOIsgA== diff --git a/pulumi-vyos/Pulumi.yaml b/pulumi-vyos/Pulumi.yaml new file mode 100644 index 0000000..00df838 --- /dev/null +++ b/pulumi-vyos/Pulumi.yaml @@ -0,0 +1,3 @@ +name: vyos-proto +runtime: nodejs +description: Prototype — VyOS config subtrees as Pulumi resources, with commit-confirm diff --git a/pulumi-vyos/README.md b/pulumi-vyos/README.md new file mode 100644 index 0000000..2b40045 --- /dev/null +++ b/pulumi-vyos/README.md @@ -0,0 +1,72 @@ +# VyOS as Pulumi resources — prototype + +Proves that VyOS config can be managed from the same Pulumi plan as the +Kubernetes side, **without** giving up the safety property that matters on a +gateway: a config push that breaks your access undoes itself. + +## Why not the community Terraform providers + +They are better than expected. `foltik/vyos`'s `vyos_config_block_tree` flattens +an entire subtree into one payload and sends **one POST to `/configure`** — +so one resource is one commit, not one commit per config line. That worry was +unfounded. + +What they do *not* do is send `confirm_time`. The payload is only +`op`/`path`/`value`, so every change is an unprotected commit. On a router you +reach *through* the router, that is the difference between a mistake and an +outage. + +## What the VyOS API actually supports + +Read from `rest/models.py` and `rest/routers.py` on the box, then verified by +hand against a live router: + +- **Batching**: a list of operations in one request, applied as one commit. +- **commit-confirm**: `confirm_time` on the request starts the revert timer. + Response says `Initialized commit-confirm; N minutes to confirm before reload`. + +Three details that cost time and are easy to get wrong: + +1. **`confirm_time` is only read when the body parses as `ConfigureListModel`** — + i.e. `{"commands": [...], "confirm_time": N}`. A **bare array** is accepted + and committed happily with **no timer armed**. It looks identical to success. + The resource therefore checks the response actually mentions commit-confirm + and refuses to continue if it does not. +2. **There is no `/confirm` endpoint.** Confirming is an op on `/configure`. +3. **Confirm still requires a `path` field**, even though it ignores it — the + Union resolves to `ConfigureModel`, which mandates `path`. Without it you get + `missing 'path' field` and the timer keeps running. + +## Shape + +One resource per **subtree**, not per line: + +```ts +new VyosConfigTree("dns-forwarding", { + host, apiKey, + path: ["service", "dns", "forwarding"], + commands: [["cache-size", "20000"], ["name-server", "8.8.8.8"]], + confirmMinutes: 2, +}); +``` + +Apply is `delete ` + all the `set`s in one request, so the result is the +declared state rather than a merge with whatever was there — which is what makes +`pulumi up` converge instead of accumulate. + +## Verified on labsim + +- `pulumi up` create and update both land in ~6s, each a single + commit-confirmed transaction; update shows `[diff: ~commands]`. +- Auto-revert observed: an unconfirmed commit returned the router to its saved + config on its own. +- `pulumi destroy` removes the subtree. + +## Not done + +- The API is HTTP with a self-signed certificate and `rejectUnauthorized: false`. + Bind it to the management VLAN or the peer link and install a real + certificate before this goes near production. +- No `refresh`/drift detection yet: `read` is not implemented, so out-of-band + changes are not noticed until the next `up` overwrites them. +- The cutover itself should still use `vyos-unifi-switch`. This is for day-2. diff --git a/pulumi-vyos/index.ts b/pulumi-vyos/index.ts new file mode 100644 index 0000000..1f4f152 --- /dev/null +++ b/pulumi-vyos/index.ts @@ -0,0 +1,27 @@ +import * as pulumi from "@pulumi/pulumi"; +import { VyosConfigTree } from "./vyosConfigTree"; + +const cfg = new pulumi.Config(); +const host = cfg.get("host") ?? "172.31.1.252"; +const apiKey = cfg.get("apiKey") ?? "labsim-proto-key"; + +// One subtree, one resource, one commit. This is the shape a BGP change would +// take: edit the commands array, `pulumi up`, and it lands as a single +// commit-confirmed transaction alongside whatever Kubernetes resources changed +// in the same plan. +const dnsForwarding = new VyosConfigTree("dns-forwarding", { + host, apiKey, + path: ["service", "dns", "forwarding"], + commands: [ + ["cache-size", "20000"], + ["listen-address", "172.31.10.1"], + ["allow-from", "172.31.10.0/23"], + ["name-server", "8.8.8.8"], + ["name-server", "8.8.4.4"], + ["name-server", "1.1.1.1"], + ], + confirmMinutes: 2, + save: true, +}); + +export const managed = dnsForwarding.id; diff --git a/pulumi-vyos/package.json b/pulumi-vyos/package.json new file mode 100644 index 0000000..4ecef9a --- /dev/null +++ b/pulumi-vyos/package.json @@ -0,0 +1,11 @@ +{ + "name": "vyos-proto", + "main": "index.ts", + "devDependencies": { + "@types/node": "^22", + "typescript": "^5.9.3" + }, + "dependencies": { + "@pulumi/pulumi": "^3.140.0" + } +} diff --git a/pulumi-vyos/pnpm-lock.yaml b/pulumi-vyos/pnpm-lock.yaml new file mode 100644 index 0000000..b666c0b --- /dev/null +++ b/pulumi-vyos/pnpm-lock.yaml @@ -0,0 +1,1822 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@pulumi/pulumi': + specifier: ^3.140.0 + version: 3.257.0(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^22 + version: 22.20.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@gar/promise-retry@1.0.3': + resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@isaacs/string-locale-compare@1.1.0': + resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@logdna/tail-file@2.2.0': + resolution: {integrity: sha512-XGSsWDweP80Fks16lwkAUIr54ICyBs6PsI4mpfTLQaWgEJRtY9xEV+PeyDpJ+sJEGZxqINlpmAwe/6tS1pP8Ng==} + engines: {node: '>=10.3.0'} + + '@npmcli/agent@4.0.2': + resolution: {integrity: sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/arborist@9.9.1': + resolution: {integrity: sha512-K0mr16xJ/yiTApeGIFbpgZSvJFOvxO2VJnCBhP543t9NTlHzgL+ewpG0kaWv9xkjeESAlRWHG9Q4lbT/LqHbWw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + '@npmcli/fs@5.0.0': + resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/git@7.0.2': + resolution: {integrity: sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/installed-package-contents@4.0.0': + resolution: {integrity: sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + '@npmcli/map-workspaces@5.0.3': + resolution: {integrity: sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/metavuln-calculator@9.0.3': + resolution: {integrity: sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/name-from-folder@4.0.0': + resolution: {integrity: sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/node-gyp@5.0.0': + resolution: {integrity: sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/package-json@7.0.5': + resolution: {integrity: sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/promise-spawn@9.0.1': + resolution: {integrity: sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/query@5.0.0': + resolution: {integrity: sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/redact@4.0.0': + resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/run-script@10.0.4': + resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.10.0': + resolution: {integrity: sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-grpc@0.220.0': + resolution: {integrity: sha512-bv1xmNhmNwIM6MdUBw4yYuJeVcEViVLk3uD69vOQMwueHBnfyl/u0HnBlB1FNY/Te0UOzJzvcbyR8wN6b+iGbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.10.0': + resolution: {integrity: sha512-7gsvgf0UDoJ4l9ObrwBmz5G/ZogiPk+lq+g5GpLp24YQF/vPM/BSsnOfcLnfinast5ASUgLo78uSC/ObjlnXgg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-grpc@0.220.0': + resolution: {integrity: sha512-U1EF8KKu52XwH2ybUkjVDmaVQZGf3mXirRSw1KJQrOV5aymgJgkPJV7+kRPqawZe0rpVc/BK+pPSyMWuQoyJJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.220.0': + resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.220.0': + resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.220.0': + resolution: {integrity: sha512-/eIkBPMBTIvM3x/0mDX4aJeSkYifYClnBPr68PL1h5LV4VQv4+SV6CGrpiZ4fIWDnobVmhTWCm1J/QRdAWUfvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.220.0': + resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.220.0': + resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.9.0': + resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.10.0': + resolution: {integrity: sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + + '@pulumi/pulumi@3.257.0': + resolution: {integrity: sha512-YjI+4ClA3AwGoMR1J58GZpt81P0vTJQHi22l4JGSl8YAnPjuJPcvpuOFt8BJrY659bznBUnIS/fDTiCHcEWxCA==} + engines: {node: '>=22'} + peerDependencies: + ts-node: '>= 7.0.1 < 12' + typescript: '>= 3.8.3 < 7' + peerDependenciesMeta: + ts-node: + optional: true + typescript: + optional: true + + '@sigstore/bundle@4.0.0': + resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/core@3.2.1': + resolution: {integrity: sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/protobuf-specs@0.5.1': + resolution: {integrity: sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@sigstore/sign@4.1.1': + resolution: {integrity: sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/tuf@4.0.2': + resolution: {integrity: sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/verify@3.1.1': + resolution: {integrity: sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@tufjs/canonical-json@2.0.0': + resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@tufjs/models@4.1.0': + resolution: {integrity: sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@types/google-protobuf@3.15.12': + resolution: {integrity: sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bin-links@6.0.2: + resolution: {integrity: sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==} + engines: {node: ^20.17.0 || >=22.9.0} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + cacache@20.0.4: + resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} + engines: {node: ^20.17.0 || >=22.9.0} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + cmd-shim@8.0.0: + resolution: {integrity: sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==} + engines: {node: ^20.17.0 || >=22.9.0} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + google-protobuf@3.21.4: + resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore-walk@8.0.0: + resolution: {integrity: sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==} + engines: {node: ^20.17.0 || >=22.9.0} + + import-in-the-middle@3.3.3: + resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==} + engines: {node: '>=18'} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + json-parse-even-better-errors@5.0.0: + resolution: {integrity: sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + json-stringify-nice@1.1.4: + resolution: {integrity: sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + just-diff-apply@5.5.0: + resolution: {integrity: sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==} + + just-diff@6.0.2: + resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + make-fetch-happen@15.0.6: + resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==} + engines: {node: ^20.17.0 || >=22.9.0} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@5.0.2: + resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@2.0.0: + resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-bundled@5.0.0: + resolution: {integrity: sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-install-checks@8.0.0: + resolution: {integrity: sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-normalize-package-bin@5.0.0: + resolution: {integrity: sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-package-arg@13.0.2: + resolution: {integrity: sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-packlist@10.0.4: + resolution: {integrity: sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-pick-manifest@11.0.3: + resolution: {integrity: sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-registry-fetch@19.1.1: + resolution: {integrity: sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + p-map@7.0.6: + resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} + engines: {node: '>=18'} + + pacote@21.5.1: + resolution: {integrity: sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + parse-conflict-json@5.0.1: + resolution: {integrity: sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss-selector-parser@7.1.5: + resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} + engines: {node: '>=4'} + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + proggy@4.0.0: + resolution: {integrity: sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + promise-all-reject-late@1.0.1: + resolution: {integrity: sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==} + + promise-call-limit@3.0.2: + resolution: {integrity: sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==} + + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + + read-cmd-shim@6.0.0: + resolution: {integrity: sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==} + engines: {node: ^20.17.0 || >=22.9.0} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sigstore@4.1.1: + resolution: {integrity: sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==} + engines: {node: ^20.17.0 || >=22.9.0} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + ssri@13.0.1: + resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + treeverse@3.0.0: + resolution: {integrity: sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + tuf-js@4.1.0: + resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + +snapshots: + + '@gar/promise-retry@1.0.3': {} + + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@isaacs/string-locale-compare@1.1.0': {} + + '@js-sdsl/ordered-map@4.4.2': {} + + '@logdna/tail-file@2.2.0': {} + + '@npmcli/agent@4.0.2': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 11.5.2 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/arborist@9.9.1': + dependencies: + '@gar/promise-retry': 1.0.3 + '@isaacs/string-locale-compare': 1.1.0 + '@npmcli/fs': 5.0.0 + '@npmcli/installed-package-contents': 4.0.0 + '@npmcli/map-workspaces': 5.0.3 + '@npmcli/metavuln-calculator': 9.0.3 + '@npmcli/name-from-folder': 4.0.0 + '@npmcli/node-gyp': 5.0.0 + '@npmcli/package-json': 7.0.5 + '@npmcli/query': 5.0.0 + '@npmcli/redact': 4.0.0 + '@npmcli/run-script': 10.0.4 + bin-links: 6.0.2 + cacache: 20.0.4 + common-ancestor-path: 2.0.0 + hosted-git-info: 9.0.3 + json-stringify-nice: 1.1.4 + lru-cache: 11.5.2 + minimatch: 10.2.6 + nopt: 9.0.0 + npm-install-checks: 8.0.0 + npm-package-arg: 13.0.2 + npm-pick-manifest: 11.0.3 + npm-registry-fetch: 19.1.1 + pacote: 21.5.1 + parse-conflict-json: 5.0.1 + proc-log: 6.1.0 + proggy: 4.0.0 + promise-all-reject-late: 1.0.1 + promise-call-limit: 3.0.2 + semver: 7.8.5 + ssri: 13.0.1 + treeverse: 3.0.0 + walk-up-path: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@5.0.0': + dependencies: + semver: 7.8.5 + + '@npmcli/git@7.0.2': + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/promise-spawn': 9.0.1 + ini: 6.0.0 + lru-cache: 11.5.2 + npm-pick-manifest: 11.0.3 + proc-log: 6.1.0 + semver: 7.8.5 + which: 6.0.1 + + '@npmcli/installed-package-contents@4.0.0': + dependencies: + npm-bundled: 5.0.0 + npm-normalize-package-bin: 5.0.0 + + '@npmcli/map-workspaces@5.0.3': + dependencies: + '@npmcli/name-from-folder': 4.0.0 + '@npmcli/package-json': 7.0.5 + glob: 13.0.6 + minimatch: 10.2.6 + + '@npmcli/metavuln-calculator@9.0.3': + dependencies: + cacache: 20.0.4 + json-parse-even-better-errors: 5.0.0 + pacote: 21.5.1 + proc-log: 6.1.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/name-from-folder@4.0.0': {} + + '@npmcli/node-gyp@5.0.0': {} + + '@npmcli/package-json@7.0.5': + dependencies: + '@npmcli/git': 7.0.2 + glob: 13.0.6 + hosted-git-info: 9.0.3 + json-parse-even-better-errors: 5.0.0 + proc-log: 6.1.0 + semver: 7.8.5 + spdx-expression-parse: 4.0.0 + + '@npmcli/promise-spawn@9.0.1': + dependencies: + which: 6.0.1 + + '@npmcli/query@5.0.0': + dependencies: + postcss-selector-parser: 7.1.5 + + '@npmcli/redact@4.0.0': {} + + '@npmcli/run-script@10.0.4': + dependencies: + '@npmcli/node-gyp': 5.0.0 + '@npmcli/package-json': 7.0.5 + '@npmcli/promise-spawn': 9.0.1 + node-gyp: 12.4.0 + proc-log: 6.1.0 + + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation-grpc@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + import-in-the-middle: 3.3.3 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-node@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@pulumi/pulumi@3.257.0(typescript@5.9.3)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@logdna/tail-file': 2.2.0 + '@npmcli/arborist': 9.9.1 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/google-protobuf': 3.15.12 + '@types/semver': 7.8.0 + execa: 5.1.1 + google-protobuf: 3.21.4 + ini: 2.0.0 + js-yaml: 4.3.1 + minimist: 1.2.8 + normalize-package-data: 6.0.2 + require-from-string: 2.0.2 + semver: 7.8.5 + source-map-support: 0.5.21 + upath: 1.2.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@sigstore/bundle@4.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.1 + + '@sigstore/core@3.2.1': {} + + '@sigstore/protobuf-specs@0.5.1': {} + + '@sigstore/sign@4.1.1': + dependencies: + '@gar/promise-retry': 1.0.3 + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.1 + make-fetch-happen: 15.0.6 + proc-log: 6.1.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/tuf@4.0.2': + dependencies: + '@sigstore/protobuf-specs': 0.5.1 + tuf-js: 4.1.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/verify@3.1.1': + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.1 + + '@tufjs/canonical-json@2.0.0': {} + + '@tufjs/models@4.1.0': + dependencies: + '@tufjs/canonical-json': 2.0.0 + minimatch: 10.2.6 + + '@types/google-protobuf@3.15.12': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.8.0': {} + + abbrev@4.0.0: {} + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@4.0.4: {} + + bin-links@6.0.2: + dependencies: + cmd-shim: 8.0.0 + npm-normalize-package-bin: 5.0.0 + proc-log: 6.1.0 + read-cmd-shim: 6.0.0 + write-file-atomic: 7.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + buffer-from@1.1.2: {} + + cacache@20.0.4: + dependencies: + '@npmcli/fs': 5.0.0 + fs-minipass: 3.0.3 + glob: 13.0.6 + lru-cache: 11.5.2 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + p-map: 7.0.6 + ssri: 13.0.1 + + chownr@3.0.0: {} + + cjs-module-lexer@2.2.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cmd-shim@8.0.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + common-ancestor-path@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + es-module-lexer@2.3.1: {} + + escalade@3.2.0: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exponential-backoff@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + + get-caller-file@2.0.5: {} + + get-stream@6.0.1: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + google-protobuf@3.21.4: {} + + graceful-fs@4.2.11: {} + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.2 + + http-cache-semantics@4.2.0: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + ignore-walk@8.0.0: + dependencies: + minimatch: 10.2.6 + + import-in-the-middle@3.3.3: + dependencies: + cjs-module-lexer: 2.2.1 + es-module-lexer: 2.3.1 + module-details-from-path: 1.0.4 + + ini@2.0.0: {} + + ini@6.0.0: {} + + ip-address@10.5.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-stream@2.0.1: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@5.0.0: {} + + json-stringify-nice@1.1.4: {} + + jsonparse@1.3.1: {} + + just-diff-apply@5.5.0: {} + + just-diff@6.0.2: {} + + lodash.camelcase@4.3.0: {} + + long@5.3.2: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + make-fetch-happen@15.0.6: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/agent': 4.0.2 + '@npmcli/redact': 4.0.0 + cacache: 20.0.4 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 6.1.0 + ssri: 13.0.1 + transitivePeerDependencies: + - supports-color + + merge-stream@2.0.0: {} + + mimic-fn@2.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimist@1.2.8: {} + + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.3 + + minipass-fetch@5.0.2: + dependencies: + minipass: 7.1.3 + minipass-sized: 2.0.0 + minizlib: 3.1.0 + optionalDependencies: + iconv-lite: 0.7.3 + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@2.0.0: + dependencies: + minipass: 7.1.3 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + module-details-from-path@1.0.4: {} + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.5 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.28.0 + which: 6.0.1 + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + + npm-bundled@5.0.0: + dependencies: + npm-normalize-package-bin: 5.0.0 + + npm-install-checks@8.0.0: + dependencies: + semver: 7.8.5 + + npm-normalize-package-bin@5.0.0: {} + + npm-package-arg@13.0.2: + dependencies: + hosted-git-info: 9.0.3 + proc-log: 6.1.0 + semver: 7.8.5 + validate-npm-package-name: 7.0.2 + + npm-packlist@10.0.4: + dependencies: + ignore-walk: 8.0.0 + proc-log: 6.1.0 + + npm-pick-manifest@11.0.3: + dependencies: + npm-install-checks: 8.0.0 + npm-normalize-package-bin: 5.0.0 + npm-package-arg: 13.0.2 + semver: 7.8.5 + + npm-registry-fetch@19.1.1: + dependencies: + '@npmcli/redact': 4.0.0 + jsonparse: 1.3.1 + make-fetch-happen: 15.0.6 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minizlib: 3.1.0 + npm-package-arg: 13.0.2 + proc-log: 6.1.0 + transitivePeerDependencies: + - supports-color + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + p-map@7.0.6: {} + + pacote@21.5.1: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/git': 7.0.2 + '@npmcli/installed-package-contents': 4.0.0 + '@npmcli/package-json': 7.0.5 + '@npmcli/promise-spawn': 9.0.1 + '@npmcli/run-script': 10.0.4 + cacache: 20.0.4 + fs-minipass: 3.0.3 + minipass: 7.1.3 + npm-package-arg: 13.0.2 + npm-packlist: 10.0.4 + npm-pick-manifest: 11.0.3 + npm-registry-fetch: 19.1.1 + proc-log: 6.1.0 + sigstore: 4.1.1 + ssri: 13.0.1 + tar: 7.5.22 + transitivePeerDependencies: + - supports-color + + parse-conflict-json@5.0.1: + dependencies: + json-parse-even-better-errors: 5.0.0 + just-diff: 6.0.2 + just-diff-apply: 5.5.0 + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + picomatch@4.0.5: {} + + postcss-selector-parser@7.1.5: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + proc-log@6.1.0: {} + + proggy@4.0.0: {} + + promise-all-reject-late@1.0.1: {} + + promise-call-limit@3.0.2: {} + + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 22.20.1 + long: 5.3.2 + + read-cmd-shim@6.0.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: + optional: true + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sigstore@4.1.1: + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.1 + '@sigstore/sign': 4.1.1 + '@sigstore/tuf': 4.0.2 + '@sigstore/verify': 3.1.1 + transitivePeerDependencies: + - supports-color + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.5.0 + smart-buffer: 4.2.0 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + ssri@13.0.1: + dependencies: + minipass: 7.1.3 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-final-newline@2.0.0: {} + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + treeverse@3.0.0: {} + + tuf-js@4.1.0: + dependencies: + '@tufjs/models': 4.1.0 + debug: 4.4.3 + make-fetch-happen: 15.0.6 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici@6.28.0: {} + + upath@1.2.0: {} + + util-deprecate@1.0.2: {} + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validate-npm-package-name@7.0.2: {} + + walk-up-path@4.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/pulumi-vyos/tsconfig.json b/pulumi-vyos/tsconfig.json new file mode 100644 index 0000000..5920f95 --- /dev/null +++ b/pulumi-vyos/tsconfig.json @@ -0,0 +1,2 @@ +{ "compilerOptions": { "strict": true, "target": "es2020", "module": "commonjs", + "moduleResolution": "node", "skipLibCheck": true, "esModuleInterop": true } } diff --git a/pulumi-vyos/vyosConfigTree.ts b/pulumi-vyos/vyosConfigTree.ts new file mode 100644 index 0000000..d867180 --- /dev/null +++ b/pulumi-vyos/vyosConfigTree.ts @@ -0,0 +1,205 @@ +import * as pulumi from "@pulumi/pulumi"; +import * as https from "https"; + +/** + * A VyOS config subtree, managed as ONE Pulumi resource. + * + * Why a dynamic provider rather than the community Terraform providers: they + * batch correctly (one `vyos_config_block_tree` becomes a single POST to + * /configure, so one commit) but they never send `confirm_time`. VyOS's own API + * supports it -- `ConfigureListModel.confirm_time`, and "A non-zero confirm_time + * will start commit-confirm timer on commit" in rest/routers.py -- so a config + * push that breaks your access to the router can undo itself. On a gateway that + * is the difference between a mistake and an outage, and it is worth ~150 lines + * to keep. + * + * The unit of change is a SUBTREE, not a line. `protocols bgp` is one resource; + * so is `service dhcp-server`. That keeps one Pulumi resource == one commit, + * rather than turning 300 config lines into 300 commits with no ordering + * guarantee and no way to revert them as a unit. + */ + +export interface VyosConfigTreeArgs { + /** Router address, e.g. "10.0.1.252". */ + host: pulumi.Input; + /** API key from `set service https api keys id key `. */ + apiKey: pulumi.Input; + /** Subtree root as a path array, e.g. ["protocols", "bgp"]. */ + path: pulumi.Input; + /** + * Desired state of the subtree: `set` command suffixes relative to `path`, + * each already split into path components with the value last. + */ + commands: pulumi.Input; + /** + * Minutes before an unconfirmed commit reverts itself. 0 disables it. + * Non-zero is strongly preferred for anything reachable only through the + * router being changed. + */ + confirmMinutes?: pulumi.Input; + /** Persist to config.boot after a successful confirm. */ + save?: pulumi.Input; +} + +interface Inputs { + host: string; + apiKey: string; + path: string[]; + commands: string[][]; + confirmMinutes: number; + save: boolean; +} + +// Kept inside the module so the dynamic provider can serialise it. +function apiCall( + host: string, apiKey: string, endpoint: string, + form: Record, +): Promise { + const body = Object.entries(form) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&"); + return new Promise((resolve, reject) => { + const req = https.request({ + host, port: 443, path: `/${endpoint}`, method: "POST", + // VyOS ships a self-signed certificate by default. Verification is + // disabled deliberately; if this ever leaves a trusted segment, + // install a real certificate and turn it back on. + rejectUnauthorized: false, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": Buffer.byteLength(body), + }, + timeout: 120_000, + }, (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + if (parsed.success === false) { + reject(new Error(`VyOS API: ${parsed.error ?? data}`)); + } else { + resolve(parsed); + } + } catch { + reject(new Error(`VyOS API returned non-JSON: ${data.slice(0, 300)}`)); + } + }); + }); + req.on("error", reject); + req.on("timeout", () => { req.destroy(); reject(new Error("VyOS API timed out")); }); + req.write(body); + req.end(); + }); +} + +/** + * Replace a subtree: delete it, then set the desired state, in ONE request so + * it is a single commit. Deleting first makes the result the declared state + * rather than a merge with whatever was there -- which is what makes `pulumi + * up` converge instead of accumulating. + */ +async function applyTree(i: Inputs, desired: string[][]): Promise { + const commands: any[] = [{ op: "delete", path: i.path }]; + for (const c of desired) { + commands.push({ op: "set", path: [...i.path, ...c] }); + } + + // The payload MUST be {commands: [...], confirm_time: N}, not a bare array. + // VyOS only reads confirm_time when the body parses as ConfigureListModel + // (`if isinstance(data, (ConfigureModel, ConfigureListModel, ...))` in + // rest/routers.py). A bare array is accepted and committed happily -- with + // NO timer armed, silently discarding the safety net. Verified both ways: + // bare array gives no "Initialized commit-confirm" in the response, the + // object form returns "Initialized commit-confirm; N minutes to confirm". + const payload: any = { commands }; + if (i.confirmMinutes > 0) payload.confirm_time = i.confirmMinutes; + + const res = await apiCall(i.host, i.apiKey, "configure", + { key: i.apiKey, data: JSON.stringify(payload) }); + + if (i.confirmMinutes > 0) { + // Refuse to continue if the timer did not actually arm -- otherwise a + // silent fallback to an unprotected commit is exactly the failure this + // resource exists to prevent. + const said = String((res && res.data) || ""); + if (!said.includes("commit-confirm")) { + throw new Error( + "commit-confirm was requested but VyOS did not arm a timer " + + `(response: ${said.trim() || ""}). Refusing to proceed.`); + } + } + + if (i.confirmMinutes > 0) { + // The commit landed but is on a timer. Reaching the API again proves + // the box is still answering *after* the change -- the only evidence + // worth confirming on. If this throws we deliberately do NOT confirm, + // and the router reverts itself. + await apiCall(i.host, i.apiKey, "retrieve", { + key: i.apiKey, + data: JSON.stringify({ op: "showConfig", path: [] }), + }); + // There is no /confirm endpoint -- confirm is an op on /configure, and + // it requires a `path` field even though it ignores it (the Union + // resolves to ConfigureModel, which mandates path). Without it the API + // answers "missing 'path' field" and the timer keeps running. + await apiCall(i.host, i.apiKey, "configure", { + key: i.apiKey, + data: JSON.stringify({ op: "confirm", path: [] }), + }); + } + + if (i.save) { + await apiCall(i.host, i.apiKey, "config-file", { + key: i.apiKey, data: JSON.stringify({ op: "save" }), + }); + } +} + +const provider: pulumi.dynamic.ResourceProvider = { + async create(inputs: Inputs) { + await applyTree(inputs, inputs.commands); + return { id: `${inputs.host}:${inputs.path.join("/")}`, outs: inputs }; + }, + + async update(_id, _old: Inputs, news: Inputs) { + await applyTree(news, news.commands); + return { outs: news }; + }, + + async delete(_id, props: Inputs) { + const form: Record = { + key: props.apiKey, + data: JSON.stringify([{ op: "delete", path: props.path }]), + }; + if (props.confirmMinutes > 0) form.confirm_time = String(props.confirmMinutes); + await apiCall(props.host, props.apiKey, "configure", form); + if (props.confirmMinutes > 0) { + await apiCall(props.host, props.apiKey, "configure", + { key: props.apiKey, data: JSON.stringify({ op: "confirm", path: [] }) }); + } + }, + + async diff(_id, olds: Inputs, news: Inputs) { + const changed = + JSON.stringify(olds.commands) !== JSON.stringify(news.commands) || + JSON.stringify(olds.path) !== JSON.stringify(news.path) || + olds.host !== news.host; + return { + changes: changed, + // Changing which subtree or which router is a different resource. + replaces: olds.host !== news.host || + JSON.stringify(olds.path) !== JSON.stringify(news.path) ? ["path"] : [], + }; + }, +}; + +export class VyosConfigTree extends pulumi.dynamic.Resource { + constructor(name: string, args: VyosConfigTreeArgs, opts?: pulumi.CustomResourceOptions) { + super(provider, name, { + confirmMinutes: 5, + save: true, + ...args, + }, opts); + } +} From ee070371a8b53a348f01506fd07c89d716e79fbf Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 18 Aug 2026 00:44:11 +0100 Subject: [PATCH 20/26] feat(labsim): add WAN transport VLANs so the sim can host fake ISPs A cutover attempt failed on the WAN and nothing had tested it. The reason the sim could not have caught it: labsim modelled every LAN VLAN faithfully and omitted the WAN entirely -- vlans.conf had 1, 2, 3, 9, 10 and 200, never 51 or 53. Worse, the switch script's WAN health checks are conditional on the delta configuring PPPoE, so in the sim they printed "this delta configures no WAN -- skipping all WAN health checks" and passed. The sim proved the delta commits; it never proved the WAN works, and could not have. Adds VLANs 51 (Vodafone/PPPoE) and 53 (10gig/DHCP) to the fabric so a fake ISP can live on each and those checks actually execute. VyOS has service pppoe-server (accel-ppp) natively -- authentication local-users, client-ip-pool, gateway-address -- so a VyOS VM can play the concentrator, and dhcp-server can play the other ISP. vlans.conf gains host_octet 0, meaning "no host leg". A host address on a WAN transport VLAN would misrepresent the segment: the point is that VyOS reaches an ISP, not the host. Also fixes a real gap in ovs_bond_router: it returned early when the bond already existed, so adding a VLAN to vlans.conf never reached an existing bond. Re-runs now reconcile the trunk and say so. That gap is the same SHAPE as the production failure -- interface present, VLAN missing from the trunk, frames silently dropped -- which is precisely the class of bug the sim needs to be able to reproduce rather than embody. Both bonds updated: [2,3,9,10,200] -> [2,3,9,10,51,53,200]. Note on the production diagnosis, which is NOT settled: a passive RX test showed zero frames on 51/53 at the firewall, and an active DHCP DISCOVER (verified to have transmitted, tx +2) drew no reply. That is consistent with the VLANs not being trunked, but equally with the ISP only answering its registered CPE MAC -- which is exactly why the delta clones f0:9f:c2:12:9b:4f, and why it cannot be settled from production while the USG holds that MAC. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- labsim/ovs.sh | 21 ++++++++++++++++++--- labsim/vlans.conf | 11 +++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/labsim/ovs.sh b/labsim/ovs.sh index 3ea6964..d4067a9 100644 --- a/labsim/ovs.sh +++ b/labsim/ovs.sh @@ -49,7 +49,12 @@ ovs_up() { # Drop any address from a previous mask/octet so a changed vlans.conf does # not leave a stale second address on the port. sudo ip -4 addr flush dev "$port" 2>/dev/null || true - sudo ip addr replace "${V_PREFIX}.${V_HOST}/${V_MASK}" dev "$port" + # host_octet 0 means "no host leg": the WAN transport VLANs belong to the + # fake ISPs, and giving the host an address there would misrepresent the + # segment -- the whole point is that VyOS reaches an ISP, not the host. + if [ "$V_HOST" != "0" ]; then + sudo ip addr replace "${V_PREFIX}.${V_HOST}/${V_MASK}" dev "$port" + fi done ovs_define_libvirt_net @@ -117,9 +122,19 @@ ovs_bond_router() { local count; count="$(echo "$taps" | grep -c .)" [ "$count" -eq 2 ] || { warn "router $vm has $count tap(s), expected 2 — skipping bond"; return 1; } - # Already bonded? (idempotent re-runs) + # Already bonded? Re-runs must still reconcile the VLAN list: adding a VLAN to + # vlans.conf and finding the bond unchanged is exactly how a VLAN silently + # fails to reach a router -- interface present, tag missing, frames dropped by + # the switch. Returning early here once cost real debugging time. if ovs list-ports "$OVS_BR" 2>/dev/null | grep -qx "$LAG_NAME"; then - log "LACP bond $LAG_NAME already present" + local want; want="$(vlan_id_list | tr ',' '\n' | grep -vx 1 | paste -sd, -)" + local have; have="$(ovs get port "$LAG_NAME" trunks 2>/dev/null | tr -d '[] ')" + if [ "$want" != "$have" ]; then + log "bond $LAG_NAME trunk drift: [$have] -> [$want]; updating" + ovs set port "$LAG_NAME" trunks="$want" + else + log "LACP bond $LAG_NAME already present, trunk correct" + fi return 0 fi diff --git a/labsim/vlans.conf b/labsim/vlans.conf index 866f197..1d87b2f 100644 --- a/labsim/vlans.conf +++ b/labsim/vlans.conf @@ -30,3 +30,14 @@ 9:private:172.31.9:10.0.9.0/23 10:lot:172.31.10:10.0.0.0/23:23:3 200:roomates:172.31.200:192.168.2.0/24 + +# WAN transport VLANs, mirroring production. These exist so the sim can run a +# fake ISP on each and the switch script's WAN health checks actually execute +# instead of printing "this delta configures no WAN -- skipping". A cutover +# attempt failed on the WAN with nothing having tested it, because the sim +# modelled every LAN VLAN faithfully and omitted the WAN entirely. +# +# No host leg is wanted here (host_octet 0 means "skip"): the ISP VMs own these +# segments, and a host address on a WAN transport VLAN would be a lie. +51:wan1:172.31.51:vodafone-pppoe(VLAN 51):24:0 +53:wan3:172.31.53:10gig-dhcp(VLAN 53):24:0 From ff86a421f49aa67d8abb75dccf2f30ff6316d358 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 18 Aug 2026 01:00:19 +0100 Subject: [PATCH 21/26] fix(labsim): console-apply must handle both VyOS prompts, not just $ Two failures from one strict expect, both hit while building the sim ISPs. A run that dies mid-config leaves the console parked in configuration mode. The next run then waits for the operational `$ ` prompt against a perfectly healthy VM and hangs until timeout, with nothing in the output to say why -- the box was sitting at `vyos@isp-dhcp#` the whole time. Login now accepts `# ` as well and discards the stale candidate rather than committing something nobody has seen. The same mistake at the exit step: insisting on `$ ` after `save` hung, AND left the console in config mode, which is what created the first failure for the following run. Now accepts either prompt. Known-bad, not fixed: the tool reports "committed and saved" when the set commands have not applied. Verified against the clone -- prompt showed the host-name change had landed while `grep -c dhcp-server` returned 0. The failure detection only inspects c.before for a few strings and evidently misses the real failure mode, so success is being reported without evidence. That needs fixing before this tool is trusted for anything; it is currently only safe to use with an independent check afterwards, which is how the gap was found. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- labsim/console-apply.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/labsim/console-apply.py b/labsim/console-apply.py index 82b1f42..a15bb90 100755 --- a/labsim/console-apply.py +++ b/labsim/console-apply.py @@ -38,23 +38,39 @@ def main() -> int: # Log in. A freshly booted box may still be starting services, so allow a # generous window and re-prod the console rather than failing on the first # miss. + # + # `# ` matters as much as `$ `: a previous run that died mid-config leaves + # the console sitting in configuration mode, and waiting only for the + # operational prompt then hangs forever against a perfectly healthy VM. + in_config = False for _ in range(40): i = c.expect([r"login:", r"\$ ", r"# ", pexpect.TIMEOUT], timeout=15) if i == 0: c.sendline(args.user) c.expect("Password:", timeout=30) c.sendline(args.password) - c.expect(r"\$ ", timeout=60) + c.expect([r"\$ ", r"# "], timeout=60) break - if i in (1, 2): + if i == 1: + break + if i == 2: + in_config = True break c.sendline("") else: print("never reached a prompt", file=sys.stderr) return 1 - c.sendline("configure") - c.expect(r"# ", timeout=60) + if in_config: + # Drop whatever the previous run left half-built rather than committing + # a candidate nobody has seen. + print("console was left in config mode; discarding stale candidate", + file=sys.stderr) + c.sendline("discard") + c.expect(r"# ", timeout=60) + else: + c.sendline("configure") + c.expect(r"# ", timeout=60) for cmd in cmds: c.sendline(cmd) @@ -69,10 +85,14 @@ def main() -> int: commit_out = c.before or "" c.sendline("save") c.expect(r"# ", timeout=120) + # Accept either prompt on the way out. Insisting on `$ ` here hangs against + # a healthy box -- and worse, leaves the console parked in config mode, so + # the NEXT run finds a `# ` it was not expecting either. One strict expect + # turned into two failures. c.sendline("exit") - c.expect(r"\$ ", timeout=60) + c.expect([r"\$ ", r"# ", pexpect.TIMEOUT], timeout=60) c.sendline("exit") - c.close() + c.close(force=True) bad = [l for l in commit_out.splitlines() if "failed" in l.lower() or "error" in l.lower()] From 54b21fa9ffd3f87a01868b66167f77fc4a30389e Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 18 Aug 2026 01:10:50 +0100 Subject: [PATCH 22/26] fix(migration): poll for WAN health instead of sampling once at 25s A real cutover attempt reported failure and reverted a configuration that may well have been fine. The health check waited a fixed 25 seconds and then judged: [switch] committed. Waiting 25s for PPPoE and services to settle... [switch] FAIL pppoe0 has an address 25s is far too short for a WAN. PPPoE alone is PADI/PADO/PADR/PADS followed by LCP, authentication and IPCP -- routinely 15-30s on its own. Both lines had also just been released by the USG seconds earlier, and ISPs commonly hold the previous session and MAC binding for minutes before leasing to the "same" CPE again, which is exactly what a cloned MAC looks like from their side. The one thing the design could not tolerate was being impatient, and it was. Now polls every 15s up to HEALTH_BUDGET (default 180s), reporting progress, and stops early the moment everything is healthy. The budget deliberately finishes long before commit-confirm fires -- 180s against a 10 minute timer leaves 420s of margin -- so the decision to confirm or revert stays ours rather than being made by the timer. Also recorded while chasing this: the earlier claim that VLANs 51/53 are not trunked to the firewalls was WRONG, and the UniFi port settings disprove it -- those LAG ports are Native VLAN Management (1) with Tagged VLAN Management set to Allow All. My evidence never supported the claim: a passive RX count cannot distinguish an absent VLAN from a quiet one, because switches do not flood unicast, and the active DHCP probe used a random MAC that an ISP binding to its registered CPE would ignore regardless. Both observations fit a perfectly healthy trunk. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-unifi-switch | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/migration/vyos-unifi-switch b/migration/vyos-unifi-switch index c4e1d67..945ceed 100755 --- a/migration/vyos-unifi-switch +++ b/migration/vyos-unifi-switch @@ -224,8 +224,32 @@ to_vyos() { fi rm -f "$tmp" - say "committed. Waiting 25s for PPPoE and services to settle..." - sleep 25 + # Poll, do not sample once. + # + # A cutover attempt failed here on a fixed 25s wait. That is far too short for + # a WAN: PPPoE is PADI/PADO/PADR/PADS then LCP, auth and IPCP, routinely 15-30s + # by itself, and both lines had just been released by the USG seconds earlier. + # ISPs commonly hold the previous session and MAC binding for minutes before + # leasing to the "same" CPE again -- which is precisely what a cloned MAC looks + # like to them. One sample at 25s reported a healthy setup as broken and + # reverted it. + # + # There is still a deadline, because commit-confirm is running: stop well + # before it so the decision is ours rather than the timer's. + local budget="${HEALTH_BUDGET:-180}" waited=0 step=15 + say "committed. Polling health for up to ${budget}s (commit-confirm has ${CONFIRM_MINUTES} min)..." + while :; do + sleep "$step"; waited=$(( waited + step )) + if health_checks >/dev/null 2>&1; then + say "healthy after ${waited}s" + break + fi + if [ "$waited" -ge "$budget" ]; then + say "still unhealthy after ${waited}s -- final check:" + break + fi + say " not healthy yet at ${waited}s, still waiting..." + done say "health checks:" if health_checks; then From ce6911c196d0843c552f5b4d029c01b78fe6e053 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 18 Aug 2026 01:37:14 +0100 Subject: [PATCH 23/26] fix(migration): require a working WAN, not every WAN This reverted a cutover that had actually succeeded. The evidence, from the revert tearing it down: dhclient: DHCPRELEASE of 87.192.101.48 on bond0.53 to 185.232.119.244 vtysh: "no ip route 0.0.0.0/0 87.192.96.1 bond0.53 tag 210 1" netlinkd: RTM_NEWLINK -> bond0.53, mac=f0:9f:c2:12:9b:4f bond0.53 came up with the cloned MAC and was handed 87.192.101.48 -- the exact public address the USG holds -- with a default route via the real ISP gateway. kea was serving live LAN clients at the same moment (10.0.0.12, 10.0.0.13, 192.168.8.28). The gateway was working. The only failure was pppoe0: ppp@pppoe0.service exited 5/NOTINSTALLED. That is the Vodafone FAILOVER line, and the health check listed "pppoe0 has an address" as mandatory, so a working gateway was torn down because its backup WAN was down. The check encoded "every WAN must work" when the requirement is "the box must reach the internet". Now: default route, reachability and DNS are mandatory; each WAN interface is reported individually but fatal on neither. A failover line being down is worth seeing, not worth reverting for. This also incidentally settles the last genuine unknown in the migration, which could not be tested any other way: the ISP does hand the same lease to the cloned MAC. That was the one thing I had said was unknowable until the USG let go of it. Note the earlier polling fix (54b21fa) addressed a real weakness but not this failure -- no amount of waiting would have satisfied a check that required a line which was never going to come up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-unifi-switch | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/migration/vyos-unifi-switch b/migration/vyos-unifi-switch index 945ceed..df18ee6 100755 --- a/migration/vyos-unifi-switch +++ b/migration/vyos-unifi-switch @@ -171,13 +171,30 @@ health_checks() { # no PPPoE stanza is a lab/partial delta, and failing it on a missing # pppoe0 would make the script untestable anywhere but the live cutover. # Announced loudly, because a quietly skipped check is worse than no check. - if grep -q "^set interfaces pppoe" "$DELTA"; then + if grep -qE "^set interfaces (pppoe|bonding bond0 vif 5)" "$DELTA"; then + # What matters is that SOME WAN works, not that every WAN works. + # + # This reverted a cutover that had genuinely succeeded. The 10 gig line came + # up on bond0.53 and the cloned MAC was handed the same public address the + # USG had (87.192.101.48); kea was serving real LAN clients at the same + # moment. The only failure was pppoe0 -- the Vodafone FAILOVER line -- and + # requiring it undid a working gateway. + # # Written as [ -n "$(...)" ] rather than `... | grep -q` for the pipefail # reason above: a pipeline ending in grep -q cannot be trusted here. - _chk "pppoe0 has an address" '[ -n "$(ip -4 -br addr show pppoe0 2>/dev/null | awk "{print \$3}")" ]' _chk "a default route exists" '[ -n "$(ip -4 route show default)" ]' _chk "internet reachable" "ping -c2 -W3 8.8.8.8" _chk "DNS resolves through us" "getent hosts vyos.net" + + # Informational only: report each WAN, fail on neither. A failover line + # being down is worth seeing, not worth reverting for. + for _w in pppoe0 bond0.53; do + if [ -n "$(ip -4 -br addr show "$_w" 2>/dev/null | awk '{print $3}')" ]; then + say " ok WAN $_w has an address (informational)" + else + say " note WAN $_w has no address (informational, not fatal)" + fi + done else warn "this delta configures no WAN -- skipping all WAN health checks." warn "That is expected in the lab and WRONG for the real cutover." From 7b5331ddcd3e0ad55844ee0ac464207c8552bef6 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 18 Aug 2026 01:48:48 +0100 Subject: [PATCH 24/26] fix(migration): the backup has no WAN by design; stop failing it for that The cutover succeeded on vyos001 -- gateway live, bond0.53 holding 87.192.101.48 with the cloned MAC, kea serving, clients routing out through NAT. vyos002 then ran the same script and was judged unhealthy, because the mandatory checks are "default route exists / internet reachable / DNS resolves" and the backup deliberately holds its WAN interfaces DOWN. Its config was correct; the check did not apply to it. Confirmed by hand before the timer could revert a good config. This is the third instance of one mistake: asserting a condition that is not true of the box being checked. First requiring every WAN when one suffices, now requiring a WAN on the box that is configured not to have one. A delta containing `interfaces ... disable` for the WAN now identifies the backup, and the WAN-dependent checks are skipped with a note. kea and the DNS forwarder remain mandatory on both -- those are what the backup must actually be able to do. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-unifi-switch | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/migration/vyos-unifi-switch b/migration/vyos-unifi-switch index df18ee6..a911844 100755 --- a/migration/vyos-unifi-switch +++ b/migration/vyos-unifi-switch @@ -171,7 +171,15 @@ health_checks() { # no PPPoE stanza is a lab/partial delta, and failing it on a missing # pppoe0 would make the script untestable anywhere but the live cutover. # Announced loudly, because a quietly skipped check is worse than no check. - if grep -qE "^set interfaces (pppoe|bonding bond0 vif 5)" "$DELTA"; then + # A box whose delta holds its WAN interfaces DOWN is the backup: it has no + # route out by design, and demanding one reverts a perfectly correct config. + # This tore down vyos002 on the first successful cutover -- vyos001 went live + # and its backup was judged unhealthy for lacking the WAN it is deliberately + # not carrying. Same mistake as requiring the failover line: checks that do + # not apply to the box being checked. + if grep -qE "^set interfaces (pppoe pppoe0|bonding bond0 vif 53) disable$" "$DELTA"; then + say " note this box holds its WAN down (backup); skipping WAN checks" + elif grep -qE "^set interfaces (pppoe|bonding bond0 vif 5)" "$DELTA"; then # What matters is that SOME WAN works, not that every WAN works. # # This reverted a cutover that had genuinely succeeded. The 10 gig line came From f4984e396240cd255befc9515449c8519d3a0ada Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 18 Aug 2026 12:18:41 +0100 Subject: [PATCH 25/26] fix(vyos): health-check the 10 gig primary so failover actually fires The 10 gig line was primary by route distance alone, which only fails over when bond0.53 loses carrier or its DHCP 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. `protocols failover` now owns the live default route and pings two targets bound to the interface, so the backup can never be validated through the primary's path. Rehearsed on the labsim router: failover and failback both inside 5s with the router's own interface still UP. The vif keeps default-route-distance rather than no-default-route, demoted below Vodafone. vyos-failover resolves a dhcp-interface gateway by reading new_routers out of /run/dhclient/dhclient_.lease, and no-default-route leaves that field EMPTY -- the daemon then finds no next hop and installs nothing. Observed on vyos001: the default route fell through to Vodafone. Preference is now failover's kernel route (distance 0) > pppoe (10) > DHCP (210), so the demoted route can never re-create the black hole it exists to avoid. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- migration/vyos-mode-delta.py | 49 ++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/migration/vyos-mode-delta.py b/migration/vyos-mode-delta.py index fc376bf..134cf0a 100755 --- a/migration/vyos-mode-delta.py +++ b/migration/vyos-mode-delta.py @@ -54,7 +54,30 @@ WAN_DHCP_VIF = "bond0.53" # 10 gig ISP WAN_DHCP_MAC = "f0:9f:c2:12:9b:4f" # Route distances: the 10 gig line wins, Vodafone is failover. -DIST_DHCP, DIST_PPPOE = 1, 10 +# +# 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_.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 PLACEHOLDER = "@@WAN_PASSWORD@@" @@ -172,7 +195,10 @@ def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool, "# 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", - f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} dhcp-options default-route-distance {DIST_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}", "", "# WAN1, Vodafone -- failover at a higher distance. Verified working", "# on the USG: pppoe0 came up with a public address, MTU 1492.", @@ -188,6 +214,25 @@ def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool, "# 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 + ], + "", "# --- NAT -----------------------------------------------", f"set nat source rule 100 outbound-interface name {WAN_DHCP_VIF}", "set nat source rule 100 translation address masquerade", From 672b89ce38fcd0b0e4d0d16eb81cd75ab4e0d428 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 18 Aug 2026 12:18:53 +0100 Subject: [PATCH 26/26] feat(labctl): install VyOS from a Pulumi-rendered bundle, and enable its API Two halves of the same problem: a router should come up running the config that is declared for it, and should be manageable the moment it does. --vyos-bundle applies a bundle rendered by kubernetes-deployment verbatim, replacing the derived --vyos-bond/--vlan/... path rather than merging with it. Deriving a second opinion alongside a bundle is exactly the drift the bundle exists to prevent: Pulumi and labctl would each believe they knew the router's config and the box would end up with whichever ran last. Passing both is rejected rather than silently resolved. Secret-valued nodes arrive as @secret: sentinels and are dropped, with a warning naming each one. Writing the sentinel text into config.boot would look configured while being wrong, which is worse than being absent -- the router comes up without its PPPoE credential and the first `pulumi up` supplies it. A bundle committed to git has to stay safe to read. The hostname is forced to the one the install was asked for. A bundle is exported from one router and reused for its peer, and taking the hostname from it would put two vyos001s on the network. --vyos-api-key enables the HTTP API at install, on both the bundle and the derived path, so every VyOS this bastion provisions is manageable from first boot. vyos001 and vyos002 predate this and had to be enabled by hand on a live firewall after their cutover -- which is the gap this closes. It is deliberately not part of the Pulumi model: a provider able to rewrite its own transport can revoke its own access. listen-address is always set, and the API is NOT enabled when no address is known -- under DHCP there is none at build time, and binding to every interface would publish a config-write endpoint on the WAN. It warns and leaves the router SSH-only instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- .../bastion/src/templates/vyos-config-spec.ts | 144 ++++++++++++++++ bastion/src/bastion/tests/vyos-bundle.test.ts | 155 ++++++++++++++++++ bastion/src/cli/src/commands/install.ts | 72 +++++++- bastion/src/shared/src/index.ts | 2 + bastion/src/shared/src/types/index.ts | 2 + bastion/src/shared/src/types/state.ts | 52 ++++++ 6 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 bastion/src/bastion/tests/vyos-bundle.test.ts diff --git a/bastion/src/bastion/src/templates/vyos-config-spec.ts b/bastion/src/bastion/src/templates/vyos-config-spec.ts index aaac55b..8992390 100644 --- a/bastion/src/bastion/src/templates/vyos-config-spec.ts +++ b/bastion/src/bastion/src/templates/vyos-config-spec.ts @@ -52,6 +52,134 @@ function normalizeDiskPath(value: string | undefined): string { return raw.startsWith("/dev/") ? raw : `/dev/${raw}`; } +/** Sentinel marking a value that lives in Pulumi config, not in the bundle. */ +const SECRET_PREFIX = "@secret:"; + +/** + * Enable the VyOS HTTP API so the router is manageable the moment it boots. + * + * This belongs at install time rather than in the Pulumi model: the model is + * applied THROUGH this API, so a router that lacks it cannot be brought under + * management without a hand-run change on a live firewall. It is also why the + * model excludes `service https` outright -- a provider able to rewrite its own + * transport can lock itself out permanently. + * + * `listen-address` is always set. Leaving it unbound would expose a + * config-write endpoint on every segment the router touches, the WAN included. + */ +function apiSets(apiKey: string, listenAddress: string): VyosSetOp[] { + const sets: VyosSetOp[] = [ + { path: ["service", "https", "api", "keys", "id", "pulumi", "key"], value: apiKey }, + { path: ["service", "https", "api", "rest"] }, + ]; + if (listenAddress !== "") { + sets.push({ path: ["service", "https", "listen-address"], value: listenAddress }); + } + return sets; +} + +/** Tag nodes introduced by the API config, needed by the installer's ConfigTree. */ +const API_TAGS: string[][] = [["service", "https", "api", "keys", "id"]]; + +/** + * The address to bind the API to: an explicit choice, else the management + * address with its prefix length stripped. Under DHCP there is no address to + * bind at build time, so the caller must pass one or the listener stays unbound + * and the API is not enabled at all. + */ +function apiListenAddress(spec: VyosInstallSpec, mgmtAddress: string): string { + if (spec.apiListenAddress !== undefined && spec.apiListenAddress !== "") { + return spec.apiListenAddress; + } + return mgmtAddress.includes("/") ? (mgmtAddress.split("/")[0] ?? "") : ""; +} + +/** + * Use a Pulumi-rendered bundle as the router's config verbatim. + * + * Secret-valued nodes are dropped rather than installed with their sentinel + * text: writing `@secret:pppoePassword` into config.boot would look configured + * while being wrong, which is worse than being absent. The router comes up + * without those values and the first `pulumi up` fills them in. + * + * `system host-name` is forced to the hostname the install was asked for. The + * bundle carries the name of whichever router it was exported from, and + * installing vyos001's hostname onto vyos002 would collide on the network. + */ +function buildFromBundle( + params: { hostname: string; defaultPassword: string; disk?: string | undefined }, + spec: VyosInstallSpec, + bundle: NonNullable, + mgmtAddress: string, +): VyosConfigSpec { + const sets: VyosSetOp[] = []; + const dropped: string[] = []; + for (const op of bundle.sets) { + if (op.value !== undefined && op.value.startsWith(SECRET_PREFIX)) { + dropped.push(op.path.join(" ")); + continue; + } + if (op.path.length === 2 && op.path[0] === "system" && op.path[1] === "host-name") { + continue; + } + sets.push({ + path: op.path, + ...(op.value === undefined ? {} : { value: op.value }), + ...(op.replace === undefined ? {} : { replace: op.replace }), + }); + } + sets.unshift({ path: ["system", "host-name"], value: params.hostname }); + + if (dropped.length > 0) { + console.warn( + `vyos ${params.hostname}: ${dropped.length} secret-valued node(s) left unset by the ` + + `bundle; run \`pulumi up\` to supply them: ${dropped.join(", ")}`, + ); + } + + const tags = [...bundle.tags]; + const api = enableApi(spec, params.hostname, mgmtAddress); + if (api.length > 0) { + sets.push(...api); + tags.push(...API_TAGS); + } + + return { + hostname: params.hostname, + imageName: "", + password: spec.password ?? params.defaultPassword, + console: "K", + disk: normalizeDiskPath(params.disk), + reportAddress: mgmtAddress.includes("/") ? (mgmtAddress.split("/")[0] ?? "") : "", + raid: false, + freshConfig: spec.freshConfig ?? false, + sets, + tags, + }; +} + +/** + * The API config for this install, or nothing when it cannot be enabled safely. + * + * Refusing to enable it unbound is deliberate. Under DHCP there is no address + * known at build time, and the alternative -- binding to every interface -- + * would publish a config-write endpoint on the WAN. Better to leave the router + * SSH-only and say so than to open it everywhere. + */ +function enableApi(spec: VyosInstallSpec, hostname: string, mgmtAddress: string): VyosSetOp[] { + if (spec.apiKey === undefined || spec.apiKey === "") return []; + const listen = apiListenAddress(spec, mgmtAddress); + if (listen === "") { + console.warn( + `vyos ${hostname}: --vyos-api-key given but no address to bind to ` + + `(management is "${mgmtAddress}"). Pass --vyos-api-listen ; the HTTP API ` + + `has NOT been enabled, so Pulumi cannot manage this router yet.`, + ); + return []; + } + return apiSets(spec.apiKey, listen); +} + export function buildVyosConfigSpec(params: { hostname: string; spec?: VyosInstallSpec | undefined; @@ -62,6 +190,14 @@ export function buildVyosConfigSpec(params: { const spec = params.spec ?? {}; const mgmt = spec.mgmtInterface ?? "eth0"; const mgmtAddress = spec.mgmtAddress ?? "dhcp"; + + // A rendered bundle replaces the derived config entirely. Deriving a second + // opinion alongside it is the drift the bundle exists to prevent: Pulumi and + // labctl would each believe they knew the router's config, and the box would + // end up with whichever ran last. + if (spec.bundle !== undefined) { + return buildFromBundle(params, spec, spec.bundle, mgmtAddress); + } const bondMembers = spec.bondMembers ?? []; const vlans = spec.vlans ?? []; @@ -192,6 +328,14 @@ export function buildVyosConfigSpec(params: { }); } + // Enabled here too, not just for bundle installs: every VyOS this bastion + // provisions should be manageable from first boot. + const api = enableApi(spec, params.hostname, mgmtAddress); + if (api.length > 0) { + sets.push(...api); + tags.push(...API_TAGS); + } + return { hostname: params.hostname, imageName: "", diff --git a/bastion/src/bastion/tests/vyos-bundle.test.ts b/bastion/src/bastion/tests/vyos-bundle.test.ts new file mode 100644 index 0000000..c8f2f98 --- /dev/null +++ b/bastion/src/bastion/tests/vyos-bundle.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi } from "vitest"; +import type { VyosBundle } from "@lab/shared"; +import { buildVyosConfigSpec } from "../src/templates/vyos-config-spec.js"; + +/** + * A bundle is what makes "one config, two apply paths" true rather than + * aspirational: `pulumi up` POSTs the subtree model to a running router, labctl + * writes the same model into config.boot during a PXE install. These tests pin + * the properties that keep the two honest. + */ +const bundle: VyosBundle = { + sets: [ + { path: ["system", "host-name"], value: "vyos001" }, + { path: ["interfaces", "bonding", "bond0", "address"], value: "192.168.1.252/24" }, + { path: ["interfaces", "bonding", "bond0", "member", "interface"], value: "eth1", replace: false }, + { path: ["interfaces", "bonding", "bond0", "vif", "53", "disable"] }, + { path: ["interfaces", "pppoe", "pppoe0", "authentication", "password"], value: "@secret:pppoePassword" }, + { path: ["interfaces", "pppoe", "pppoe0", "mtu"], value: "1492" }, + ], + tags: [["interfaces", "bonding", "bond0"], ["interfaces", "ethernet"]], +}; + +const build = (hostname: string, extra: Record = {}) => + buildVyosConfigSpec({ + hostname, + spec: { bundle, ...extra }, + defaultPassword: "changeme", + }); + +describe("vyos config spec from a Pulumi bundle", () => { + it("applies non-secret nodes verbatim, preserving valuelessness and replace:false", () => { + const spec = build("vyos001"); + + expect(spec.sets).toContainEqual({ + path: ["interfaces", "bonding", "bond0", "address"], + value: "192.168.1.252/24", + }); + // A multi-value node must keep replace:false or the second bond member + // overwrites the first. + expect(spec.sets).toContainEqual({ + path: ["interfaces", "bonding", "bond0", "member", "interface"], + value: "eth1", + replace: false, + }); + // A valueless node must not acquire a value on the way through. + expect(spec.sets).toContainEqual({ + path: ["interfaces", "bonding", "bond0", "vif", "53", "disable"], + }); + expect(spec.tags).toEqual(bundle.tags); + }); + + it("drops secret-valued nodes instead of installing the sentinel text", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const spec = build("vyos001"); + + const values = spec.sets.map((s) => s.value ?? ""); + expect(values.some((v) => v.startsWith("@secret:"))).toBe(false); + expect(spec.sets.some((s) => s.path.includes("authentication"))).toBe(false); + // Silently dropping the WAN credential would leave someone debugging a dead + // PPPoE link, so it has to be said out loud. + expect(warn).toHaveBeenCalledWith(expect.stringContaining("pulumi up")); + warn.mockRestore(); + }); + + it("forces the hostname the install was asked for, not the bundle's", () => { + // The bundle is exported from one router and reused for its peer; taking the + // hostname from it would put two vyos001s on the network. + const spec = build("vyos002"); + const hostnames = spec.sets.filter( + (s) => s.path.length === 2 && s.path[0] === "system" && s.path[1] === "host-name", + ); + expect(hostnames).toEqual([{ path: ["system", "host-name"], value: "vyos002" }]); + }); + + it("still honours installer inputs, which are not router config", () => { + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { bundle, password: "s3cret", freshConfig: true }, + defaultPassword: "changeme", + disk: "nvme0n1", + }); + expect(spec.password).toBe("s3cret"); + expect(spec.freshConfig).toBe(true); + expect(spec.disk).toBe("/dev/nvme0n1"); + }); + + it("enables the HTTP API at install so Pulumi can manage the router from first boot", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { bundle, apiKey: "k3y", apiListenAddress: "10.0.1.252" }, + defaultPassword: "changeme", + }); + + expect(spec.sets).toContainEqual({ + path: ["service", "https", "api", "keys", "id", "pulumi", "key"], + value: "k3y", + }); + expect(spec.sets).toContainEqual({ path: ["service", "https", "api", "rest"] }); + expect(spec.sets).toContainEqual({ + path: ["service", "https", "listen-address"], + value: "10.0.1.252", + }); + // The key id is a tag node; without this the installer's ConfigTree rejects it. + expect(spec.tags).toContainEqual(["service", "https", "api", "keys", "id"]); + warn.mockRestore(); + }); + + it("binds the API to the static management address when none is given", () => { + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { apiKey: "k3y", mgmtAddress: "192.168.1.252/24" }, + defaultPassword: "changeme", + }); + expect(spec.sets).toContainEqual({ + path: ["service", "https", "listen-address"], + value: "192.168.1.252", + }); + }); + + it("refuses to enable the API unbound rather than exposing it on the WAN", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // Management is DHCP, so there is no address to bind at build time. Binding + // to everything would put a config-write endpoint on the WAN. + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { apiKey: "k3y", mgmtAddress: "dhcp" }, + defaultPassword: "changeme", + }); + expect(spec.sets.some((s) => s.path[0] === "service" && s.path[1] === "https")).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("has NOT been enabled")); + warn.mockRestore(); + }); + + it("does not enable the API when no key is supplied", () => { + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { mgmtAddress: "192.168.1.252/24" }, + defaultPassword: "changeme", + }); + expect(spec.sets.some((s) => s.path[0] === "service" && s.path[1] === "https")).toBe(false); + }); + + it("ignores the derived path entirely when a bundle is present", () => { + // Belt and braces: even if topology flags reach this far (the CLI rejects + // them), the bundle must win rather than merge. + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { bundle, bondMembers: ["eth2", "eth3"], vlans: [{ id: 99, address: "10.9.9.1/24" }] }, + defaultPassword: "changeme", + }); + expect(spec.sets.some((s) => s.path.includes("99"))).toBe(false); + expect(spec.sets.filter((s) => s.value === "eth2" || s.value === "eth3")).toEqual([]); + }); +}); diff --git a/bastion/src/cli/src/commands/install.ts b/bastion/src/cli/src/commands/install.ts index 9ea6786..694031b 100644 --- a/bastion/src/cli/src/commands/install.ts +++ b/bastion/src/cli/src/commands/install.ts @@ -1,11 +1,42 @@ // CLI command: provision install // Queue a discovered machine for OS installation via labd. +import { readFileSync } from "node:fs"; import { Command, Option, InvalidArgumentError } from "commander"; import { isValidOsId, SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY } from "@lab/shared"; -import type { VyosInstallSpec, VyosVlanSpec } from "@lab/shared"; +import type { VyosBundle, VyosInstallSpec, VyosVlanSpec } from "@lab/shared"; import { getLabdClient } from "../api/config.js"; +/** + * Load one router's config out of a Pulumi-rendered bundle. + * + * The bundle is produced by `kubernetes-deployment` (npm run vyos:bundle) and + * holds every router it manages, keyed by name. Selecting by hostname here is + * what keeps bring-up and `pulumi up` describing the same box: labctl replays + * the declared config rather than deriving its own. + */ +export function loadVyosBundle(path: string, hostname: string): VyosBundle { + let parsed: { version?: number; routers?: Record }; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (e) { + throw new InvalidArgumentError(`Cannot read VyOS bundle ${path}: ${(e as Error).message}`); + } + if (parsed.version !== 1) { + throw new InvalidArgumentError( + `VyOS bundle ${path} has version ${parsed.version ?? ""}; this labctl understands 1`, + ); + } + const router = parsed.routers?.[hostname]; + if (router === undefined) { + const known = Object.keys(parsed.routers ?? {}).join(", ") || ""; + throw new InvalidArgumentError( + `VyOS bundle ${path} has no entry for "${hostname}" (has: ${known})`, + ); + } + return router; +} + /** Parse a repeated --vlan flag: ":[:]". */ export function parseVlan(value: string, previous: VyosVlanSpec[] = []): VyosVlanSpec[] { const parts = value.split(":"); @@ -88,6 +119,20 @@ export function registerInstallCommand(parent: Command): void { .option("--vyos-password ", "VyOS: password for the 'vyos' user") .option("--vyos-hwid ", "VyOS: pin an interface name to a MAC via hw-id (repeatable)", parseHwId) .option("--vyos-fresh-config", "VyOS: on reinstall, overwrite the preserved config with the generated one") + .option( + "--vyos-bundle ", + "VyOS: apply a Pulumi-rendered bundle verbatim (kubernetes-deployment/infra/vyos/vyos-bundle.json). " + + "Replaces the derived --vyos-bond/--vlan/... config; secret values are left unset for `pulumi up`.", + ) + .option( + "--vyos-api-key ", + "VyOS: enable the HTTP API with this key so Pulumi can manage the router from first boot", + ) + .option( + "--vyos-api-listen ", + "VyOS: address the HTTP API binds to (default: the static management address). " + + "Required when management is DHCP; the API is never bound to all interfaces.", + ) .action(async (mac: string, hostname: string, opts: { role: string; os: string; @@ -104,6 +149,9 @@ export function registerInstallCommand(parent: Command): void { vyosPassword?: string; vyosHwid?: Record; vyosFreshConfig?: boolean; + vyosBundle?: string; + vyosApiKey?: string; + vyosApiListen?: string; }) => { if (!isValidOsId(opts.os)) { console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`); @@ -159,9 +207,31 @@ export function registerInstallCommand(parent: Command): void { ...(opts.vyosMgmtVlan !== undefined && opts.vyosMgmtVlan !== "" ? { mgmtVlan: parseVlan(opts.vyosMgmtVlan)[0] as VyosVlanSpec } : {}), ...(opts.vyosFreshConfig === true ? { freshConfig: true } : {}), + ...(opts.vyosBundle !== undefined && opts.vyosBundle !== "" + ? { bundle: loadVyosBundle(opts.vyosBundle, hostname) } : {}), + ...(opts.vyosApiKey !== undefined && opts.vyosApiKey !== "" + ? { apiKey: opts.vyosApiKey } : {}), + ...(opts.vyosApiListen !== undefined && opts.vyosApiListen !== "" + ? { apiListenAddress: opts.vyosApiListen } : {}), }; const hasVyosOptions = Object.keys(vyos).length > 0; + // A bundle already describes the whole router. Accepting derived topology + // flags alongside it would silently discard them (the bundle wins in + // buildVyosConfigSpec), so say so rather than appear to honour both. + if (vyos.bundle !== undefined) { + const derived = ["mgmtInterface", "mgmtAddress", "bondMembers", "bondAddress", + "bondVrrp", "vrrpPriority", "vlans", "mgmtVlan"] as const; + const conflicting = derived.filter((k) => vyos[k] !== undefined); + if (conflicting.length > 0) { + console.error( + `--vyos-bundle describes the whole router; these would be ignored: ${conflicting.join(", ")}`, + ); + console.error("Remove them, or change the bundle in kubernetes-deployment and re-render."); + process.exit(1); + } + } + if (hasVyosOptions && !opts.os.startsWith("vyos")) { console.error(`VyOS options require --os vyos-rolling (got --os ${opts.os})`); process.exit(1); diff --git a/bastion/src/shared/src/index.ts b/bastion/src/shared/src/index.ts index 24accf0..1a3239b 100644 --- a/bastion/src/shared/src/index.ts +++ b/bastion/src/shared/src/index.ts @@ -10,6 +10,8 @@ export type { BastionConfig, VyosVlanSpec, VyosInstallSpec, + VyosBundle, + VyosBundleSetOp, } from "./types/index.js"; export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./types/index.js"; diff --git a/bastion/src/shared/src/types/index.ts b/bastion/src/shared/src/types/index.ts index fd2d53c..73e471c 100644 --- a/bastion/src/shared/src/types/index.ts +++ b/bastion/src/shared/src/types/index.ts @@ -9,6 +9,8 @@ export type { BastionState, VyosVlanSpec, VyosInstallSpec, + VyosBundle, + VyosBundleSetOp, } from "./state.js"; export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./state.js"; diff --git a/bastion/src/shared/src/types/state.ts b/bastion/src/shared/src/types/state.ts index 72f5ee0..93b30bb 100644 --- a/bastion/src/shared/src/types/state.ts +++ b/bastion/src/shared/src/types/state.ts @@ -88,6 +88,33 @@ export interface VyosVlanSpec { vrrp?: string; } +/** One config node in a rendered bundle. Mirrors VyosSetOp on the bastion side. */ +export interface VyosBundleSetOp { + path: string[]; + value?: string; + /** false appends to a multi-value node (e.g. bond members) instead of replacing. */ + replace?: boolean; +} + +/** + * A router's complete desired config, rendered from the Pulumi model. + * + * Produced by `kubernetes-deployment/scripts/vyos-render-bundle.ts` from the + * same subtree model `pulumi up` applies. The point is that labctl never + * authors VyOS config: bring-up replays what Pulumi already declares, so a + * freshly installed router and a `pulumi up` cannot disagree. + * + * Secret values arrive as `@secret:` sentinels and are DROPPED at install + * time -- the bundle is committed to git and must stay safe to read. The router + * comes up on the LAN without its PPPoE credential; the first `pulumi up` + * supplies it. That handoff is deliberate. + */ +export interface VyosBundle { + sets: VyosBundleSetOp[]; + /** Paths that are VyOS tag nodes — the installer's ConfigTree needs them marked. */ + tags: string[][]; +} + /** * VyOS-specific install parameters. Rendered into the config.boot that the * installer adopts, so the router comes up already configured. @@ -96,6 +123,31 @@ export interface VyosVlanSpec { * PXE cannot run over LACP, so the install-time NIC has to stay unbonded. */ export interface VyosInstallSpec { + /** + * A complete rendered config for this router. When present it REPLACES the + * derived interface/VLAN/VRRP config below -- the bundle already describes + * all of it, and deriving a second opinion is exactly the drift this exists + * to prevent. The remaining install parameters (password, disk, console) are + * still honoured because they are installer inputs, not router config. + */ + bundle?: VyosBundle; + /** + * Key for the VyOS HTTP API, enabled at install so the router is manageable + * from the moment it boots. + * + * Without this the box comes up reachable only over SSH, and enabling the API + * later is a hand-run config change on a live firewall -- which is exactly the + * gap that left vyos001/vyos002 unmanageable by Pulumi after their cutover. + * The API is deliberately NOT part of the Pulumi model: a provider that + * manages its own transport can revoke its own access. + */ + apiKey?: string; + /** + * Address the API listens on. Defaults to the management address when static. + * Never left unbound: an unrestricted listener puts a config-write endpoint on + * every segment the router touches, including the WAN. + */ + apiListenAddress?: string; /** Interfaces aggregated into bond0 with LACP (802.3ad). Omit for no bond. */ bondMembers?: string[]; /** CIDR address on bond0 itself — the switch trunk's native/untagged VLAN. */