336 lines
14 KiB
Python
336 lines
14 KiB
Python
|
|
#!/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())
|