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
2026-08-15 01:33:00 +01:00
|
|
|
#!/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" <<EOF
|
|
|
|
|
instance-id: $vm
|
|
|
|
|
local-hostname: $vm
|
|
|
|
|
EOF
|
|
|
|
|
cat > "$tmp/user-data" <<EOF
|
|
|
|
|
#cloud-config
|
|
|
|
|
hostname: $vm
|
|
|
|
|
users:
|
|
|
|
|
- name: alpine
|
|
|
|
|
shell: /bin/ash
|
|
|
|
|
lock_passwd: false
|
|
|
|
|
plain_text_passwd: labsim
|
|
|
|
|
ssh_authorized_keys:
|
|
|
|
|
- $pubkey
|
|
|
|
|
ssh_authorized_keys:
|
|
|
|
|
- $pubkey
|
|
|
|
|
disable_root: false
|
|
|
|
|
chpasswd:
|
|
|
|
|
list: |
|
|
|
|
|
root:labsim
|
|
|
|
|
expire: false
|
|
|
|
|
write_files:
|
|
|
|
|
- path: /etc/network/interfaces
|
|
|
|
|
content: |
|
|
|
|
|
auto lo
|
|
|
|
|
iface lo inet loopback
|
|
|
|
|
auto eth0
|
|
|
|
|
iface eth0 inet dhcp
|
|
|
|
|
runcmd:
|
|
|
|
|
- [ sh, -c, "ifdown eth0 2>/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
|
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-15 23:07:07 +01:00
|
|
|
|
|
|
|
|
# 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"
|
|
|
|
|
|
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
2026-08-15 01:33:00 +01:00
|
|
|
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"
|
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-15 23:07:07 +01:00
|
|
|
# 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:-<none>}"
|
|
|
|
|
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
|
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
2026-08-15 01:33:00 +01:00
|
|
|
if [ "$got" = "<none>" ]; 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
|