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