Files
lab/migration/unifi-reserve.py

107 lines
3.8 KiB
Python
Raw Permalink Normal View History

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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-15 22:12:42 +01:00
#!/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 <mac> <ip>
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())