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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
This commit is contained in:
@@ -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.<vlan>.0/24`.
|
||||
The sim subnet encodes the VLAN id: `172.31.<vlan>.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
|
||||
|
||||
Reference in New Issue
Block a user