The sim's routing config existed only as running state on the VMs. It was applied by hand over SSH, so rebuilding a VM lost the rehearsal and nothing recorded why any of it was shaped the way it was. The two ISP VMs were not referenced anywhere in the repo at all. sim-net-config.py generates all four roles; sim-net-apply.sh applies them over the serial console, or diffs them against the running VMs. Verified reproducing live state exactly before committing: primary 40/40 commands, secondary 16/16, isp-dhcp 19/19, isp-pppoe 21/21. Carries the reasoning that was previously nowhere: RFC 8212 needing policy in both directions or the session carries zero prefixes; probe targets that must not double as system name-servers; default-route-distance 210 rather than no-default-route, which blanks new_routers and hands the default route to the backup line; and the WI-8 bootstrap bug that pinned /32s fix. Dropped a stale `pppoe-server interface eth0` on isp-pppoe (a NIC that does not exist there) so a green drift check stays meaningful. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
213 lines
9.4 KiB
Markdown
213 lines
9.4 KiB
Markdown
# labsim — libvirt replica of the lab network
|
||
|
||
A throwaway copy of the production VLAN topology for testing routing, firewall
|
||
rules and failover **without touching the real network**. Same VLAN IDs and
|
||
roles as UniFi, deliberately different IP ranges so nothing can be confused for
|
||
production.
|
||
|
||
## Topology
|
||
|
||
Each VLAN is its own isolated libvirt network with one tiny Alpine VM on it.
|
||
|
||
| VLAN | Name | Sim subnet | VM address | Mirrors production |
|
||
|-----:|------|------------|-----------|--------------------|
|
||
| 1 | management | 172.31.1.0/24 | 172.31.1.10 | 192.168.1.0/24 |
|
||
| 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/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 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:
|
||
|
||
| Address | Role |
|
||
|---------|------|
|
||
| `.1` | gateway under test — a router VM you add (not created by default) |
|
||
| `.2` | host bridge — how you reach the VMs from this workstation |
|
||
| `.10` | the VLAN's micro VM |
|
||
| `.254` | reserved for a VRRP VIP, mirroring production |
|
||
|
||
The host sits at `.2` purely so you can SSH in. It is deliberately **not** the
|
||
VMs' default route — that is `.1` — so inter-VLAN tests fail loudly when no
|
||
router is present instead of being silently served by the host's own routing
|
||
table. libvirt also installs reject rules that stop these networks forwarding
|
||
to each other, so traffic between VLANs only works once a router VM bridges
|
||
them.
|
||
|
||
## Usage
|
||
|
||
```bash
|
||
./labsim-up.sh # bring up every VLAN (idempotent)
|
||
./labsim-up.sh 2 3 # only VLANs 2 and 3
|
||
./labsim-down.sh # destroy VMs + networks, keep the base image
|
||
./labsim-down.sh --purge # also delete the downloaded Alpine image
|
||
```
|
||
|
||
Each VM: 256 MB, 1 vCPU, a copy-on-write overlay on one shared 176 MB Alpine
|
||
image (so six VMs cost a few MB of disk, not 1 GB).
|
||
|
||
## Access
|
||
|
||
```bash
|
||
ssh alpine@172.31.2.10 # normal user (password: labsim)
|
||
ssh root@172.31.2.10 # privileged — this image has no sudo
|
||
curl http://172.31.2.10/ # hello-world page naming the VLAN
|
||
```
|
||
|
||
Console, when the network is the thing that is broken:
|
||
|
||
```bash
|
||
sudo virsh console labsim-2-k8s # root / labsim
|
||
```
|
||
|
||
## Watching it
|
||
|
||
```bash
|
||
./labsim-matrix.py --watch 2 # terminal grid, changed cells highlighted
|
||
./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:
|
||
most 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.
|
||
|
||
### The lease database will lie to you
|
||
|
||
The script wipes `/config/dhcp/dhcp4-leases.csv*` before every run, and both
|
||
halves of that matter:
|
||
|
||
- **Stale leases defeat reservations.** Re-running against yesterday's leases,
|
||
kea handed dynamic addresses to three devices that have reservations. The
|
||
reservation was present and correct in `/run/kea/kea-dhcp4.conf` the whole
|
||
time. Kea saw the reserved address as already leased to "another client" —
|
||
same MAC, but a different client-id from the earlier boot — and allocated
|
||
elsewhere. The cutover itself starts with an empty lease database, so this is
|
||
a *testing* artifact, but it is worth knowing that a reservation is not an
|
||
unconditional guarantee once leases exist.
|
||
- **The `*` is load-bearing.** Kea's memfile backend keeps lease-file-cleanup
|
||
rotations (`dhcp4-leases.csv.2`) and restores from them on start, so
|
||
truncating only the primary file changes nothing.
|
||
|
||
Both of those first appeared as a *passing* test. The verdict logic now refuses
|
||
to score a MAC with more than one lease, because taking the first match had
|
||
reported an hours-old lease as the current answer and turned three failures
|
||
into apparent passes.
|
||
|
||
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
|
||
while changing firewall rules.
|
||
- **http://localhost:3000/d/labsim-matrix** — Grafana (anonymous, no login) for
|
||
*history*: when did a path flip, and how has latency moved.
|
||
- **http://localhost:9101/metrics** — `labsim_reachable{src,dst,proto}` and
|
||
`labsim_rtt_ms{src,dst}`.
|
||
|
||
## Routing: BGP, dual WAN, and the ISP VMs
|
||
|
||
`sim-ha-config.py` covers the LAN side of the routers. `sim-net-config.py`
|
||
covers everything that makes this a rehearsal for production *routing*:
|
||
|
||
| role | VM | what it generates |
|
||
|---|---|---|
|
||
| `primary` | `labsim-vyos` | BGP + dual WAN + health-checked failover |
|
||
| `secondary` | `labsim-vyos2` | BGP only |
|
||
| `isp-dhcp` | `labsim-isp-dhcp` | 10gig-equivalent ISP on VLAN 53 |
|
||
| `isp-pppoe` | `labsim-isp-pppoe` | Vodafone-equivalent PPPoE ISP on VLAN 51 |
|
||
|
||
Both ISP VMs are VyOS with two NICs: one on the OVS trunk facing the sim
|
||
router, one on libvirt's `default` network, NATing customers to the real
|
||
internet. They use RFC 5737 documentation ranges (`203.0.113.0/24`,
|
||
`198.51.100.0/24`) so a leaked sim route cannot blackhole anything real.
|
||
|
||
```sh
|
||
./sim-net-apply.sh check # VM state vs what the code says — run this first
|
||
./sim-net-apply.sh apply # push generated config over the serial console
|
||
```
|
||
|
||
`check` is the important one. All of this previously existed only as running
|
||
state, applied by hand over SSH; rebuilding a VM lost it, and nothing recorded
|
||
why any of it was shaped the way it was.
|
||
|
||
### Known gaps vs production
|
||
|
||
- **WAN is on the primary router only.** Production has WAN on both. Two PPPoE
|
||
clients sharing one credential against a single access concentrator is a
|
||
failure mode production does not have, so the sim does not model it. VRRP and
|
||
conntrack failover are still exercised.
|
||
- **ISP VM interface names are not stable across a rebuild** — `isp-dhcp` came
|
||
up as `eth0`/`eth1` and `isp-pppoe` as `eth2`/`eth3` from identical XML.
|
||
Check `show interfaces` and pass `--wan-if` / `--uplink-if` rather than
|
||
trusting the defaults.
|
||
- **`eth2` on the primary router** is a libvirt-NAT uplink predating the ISP
|
||
VMs: a third default route with no production equivalent that masks real WAN
|
||
failures during a failover test. `--drop-scaffold` removes it.
|
||
- **Committing on `isp-pppoe` drops the router's PPPoE session**, and the
|
||
client does not redial promptly. After any change there, check `pppoe0` on
|
||
the router and `sudo systemctl restart ppp@pppoe0` if it is missing.
|
||
|
||
## Notes for whoever extends this
|
||
|
||
Things that cost time the first time round, all verified on this image:
|
||
|
||
- **No `sudo`.** Alpine ships `doas`; cloud-init's `sudo:` directive is inert
|
||
here. Use `root@` for privileged work.
|
||
- **cloud-init leaves users locked** (`!*` in `/etc/shadow`) unless
|
||
`lock_passwd: false`, and sshd then refuses key auth for that user.
|
||
- **One failing `runcmd` aborts every command after it.** Each entry is
|
||
`|| true` for that reason.
|
||
- **busybox here has no `httpd` applet**, and the VMs have no internet to
|
||
`apk add` one — so the hello-world server is `python3 -m http.server`
|
||
(python3 is already present because cloud-init depends on it).
|
||
- **`start-stop-daemon --exec /usr/bin/python3` matches cloud-init's own
|
||
python3** at boot and refuses to start anything.
|
||
- **busybox `pgrep -f PATTERN` matches its own argv**, so a "skip if already
|
||
running" guard always fires. Verified: `guard_exit=0` with nothing listening.
|
||
|
||
## Not modelled (yet)
|
||
|
||
VLANs are separate L2 segments rather than one 802.1Q trunk, so this exercises
|
||
inter-VLAN routing but not a `bond0.<vif>` trunk config specifically. A router
|
||
VM would attach one NIC per VLAN. Adding a tagged-trunk variant is the obvious
|
||
next step if the bond/vif config itself needs testing.
|