From bb654d83f8c22f0c8ed4dfb9bf4253a541461ac9 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 16 Aug 2026 23:29:12 +0100 Subject: [PATCH] test(labsim): second VyOS router proves DHCP active-passive HA Answers the question a single router could not, and that would otherwise only have been discovered at cutover: with kea high-availability active-passive, does exactly ONE box answer a DHCP request? Yes. Probing sim VLAN 10 with broadcast-dhcp-discover returns offers from a single distinct Server Identifier -- 172.31.10.252, the primary. The secondary runs kea but stays silent. Without this the delta would have put 6 subnets and 84 static-mappings on both boxes with nothing to arbitrate them, and two kea instances would have raced on every broadcast domain. Worth noting the raw response count is misleading: nmap reports "Response 1 of 2" because it sends several discovers, and both replies carry the same server identifier. Counting responses says "2 servers"; counting distinct server identifiers says "1". The second number is the true one. labsim now runs a real pair, mirroring production: router1 172.31..252 priority 200 DHCP HA primary router2 172.31..253 priority 100 DHCP HA secondary VIP 172.31..1 floating, held by the master That required converting router1, which held .1 directly, to .252 plus a floating VIP -- otherwise it is two routers, not a pair. All six VRRP groups show MASTER on router1 and BACKUP on router2. New tooling: - sim-ha-config.py generates each role's config, reusing unifi-to-vyos.py --mode sim for the DHCP half so what is proven here and what production gets share a code path. VLAN 10 correctly carries /23. - console-apply.py applies config over the serial console, which is necessary because a freshly installed VyOS holds the same addresses as its peer and cannot safely be reached over the network at all until reconfigured. Known sim-only quirk, deliberately not chased: router1 cannot ARP router2 on the untagged VLAN 1 while every tagged VLAN works, and VRRP forms correctly on all six groups regardless. Both OVS bonds carry identical vlan_mode/tag/trunks and the bond MACs differ, so this is OVS bond behaviour on the native VLAN with two bonds on one bridge -- not a VyOS config problem, and not present in production, which uses a real switch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH --- labsim/console-apply.py | 89 ++++++++++++++++++++++++++++++++ labsim/sim-ha-config.py | 109 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100755 labsim/console-apply.py create mode 100755 labsim/sim-ha-config.py diff --git a/labsim/console-apply.py b/labsim/console-apply.py new file mode 100755 index 0000000..82b1f42 --- /dev/null +++ b/labsim/console-apply.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Apply VyOS config to a labsim VM over its serial console. + +Needed because a freshly installed VyOS comes up holding the same addresses as +its peer, so there is a window where it cannot safely be reached over the +network at all. The console does not care. + + ./console-apply.py --vm labsim-vyos2 --config r2.conf +""" +from __future__ import annotations + +import argparse +import sys +import time + +import pexpect + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--vm", required=True) + ap.add_argument("--config", required=True) + ap.add_argument("--user", default="vyos") + ap.add_argument("--password", default="vyos") + args = ap.parse_args() + + cmds = [l.rstrip() for l in open(args.config) + if l.strip() and not l.lstrip().startswith("#")] + print(f"{len(cmds)} commands to apply to {args.vm}", file=sys.stderr) + + c = pexpect.spawn(f"virsh --connect qemu:///system console {args.vm}", + timeout=90, encoding="utf-8") + c.logfile_read = None + c.sendline("") + time.sleep(2) + c.sendline("") + + # Log in. A freshly booted box may still be starting services, so allow a + # generous window and re-prod the console rather than failing on the first + # miss. + for _ in range(40): + i = c.expect([r"login:", r"\$ ", r"# ", pexpect.TIMEOUT], timeout=15) + if i == 0: + c.sendline(args.user) + c.expect("Password:", timeout=30) + c.sendline(args.password) + c.expect(r"\$ ", timeout=60) + break + if i in (1, 2): + break + c.sendline("") + else: + print("never reached a prompt", file=sys.stderr) + return 1 + + c.sendline("configure") + c.expect(r"# ", timeout=60) + + for cmd in cmds: + c.sendline(cmd) + c.expect(r"# ", timeout=60) + out = c.before or "" + if "Set failed" in out or "not valid" in out or "Invalid" in out: + print(f"FAILED: {cmd}\n {out.strip()[:200]}", file=sys.stderr) + + print("committing...", file=sys.stderr) + c.sendline("commit") + c.expect(r"# ", timeout=300) + commit_out = c.before or "" + c.sendline("save") + c.expect(r"# ", timeout=120) + c.sendline("exit") + c.expect(r"\$ ", timeout=60) + c.sendline("exit") + c.close() + + bad = [l for l in commit_out.splitlines() + if "failed" in l.lower() or "error" in l.lower()] + if bad: + print("commit reported:", file=sys.stderr) + for l in bad[:10]: + print(f" {l.strip()}", file=sys.stderr) + return 1 + print("committed and saved", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/labsim/sim-ha-config.py b/labsim/sim-ha-config.py new file mode 100755 index 0000000..62389e4 --- /dev/null +++ b/labsim/sim-ha-config.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Generate the HA config for the labsim VyOS pair. + +Exists to answer one question that cannot be answered on a single router, and +that would otherwise only be discovered at cutover: with kea HA active-passive, +does exactly ONE box answer a DHCP request? + +Mirrors the production shape so the answer transfers: + + router1 172.31..252 priority 200 DHCP HA primary + router2 172.31..253 priority 100 DHCP HA secondary + VIP 172.31..1 (what clients use as their gateway) + +Note the sim's LoT VLAN is a /23 like production, so the VIP prefix differs +there -- getting that wrong produces a config that commits and then behaves +subtly wrongly, which is worse than a failure. + + ./sim-ha-config.py --role primary > r1.conf + ./sim-ha-config.py --role secondary > r2.conf +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +MIG = os.path.join(HERE, "..", "migration") + +# Reuse the DHCP/DNS generator rather than hand-writing subnets: the whole +# point is that what is proven here and what production gets share a code path. +_spec = importlib.util.spec_from_file_location( + "unifi_to_vyos", os.path.join(MIG, "unifi-to-vyos.py")) +unifi_to_vyos = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(unifi_to_vyos) + +# vlan -> (prefix, cidr). LoT is a /23 in the sim, matching production. +VLANS = { + 1: ("172.31.1", 24), + 2: ("172.31.2", 24), + 3: ("172.31.3", 24), + 9: ("172.31.9", 24), + 10: ("172.31.10", 23), + 200: ("172.31.200", 24), +} +DHCP_HA_NAME = "labsim-dhcp-pair" # must not equal either host-name + + +def group(vlan: int) -> str: + return "native" if vlan == 1 else f"vlan{vlan}" + + +def build(role: str) -> list[str]: + primary = role == "primary" + self_o, peer_o = (252, 253) if primary else (253, 252) + prio = 200 if primary else 100 + out = [f"# labsim VyOS HA -- {role}", ""] + + for vlan, (pfx, cidr) in VLANS.items(): + g = group(vlan) + iface = "bond0" if vlan == 1 else f"bond0 vif {vlan}" + out += [ + f"# VLAN {vlan}", + # The node's own address replaces the .1 it used to hold directly; + # .1 becomes the floating VIP, exactly as production will be. + f"delete interfaces bonding {iface} address", + f"set interfaces bonding {iface} address '{pfx}.{self_o}/{cidr}'", + f"set high-availability vrrp group {g} interface bond0{'' if vlan == 1 else f'.{vlan}'}", + f"set high-availability vrrp group {g} vrid {vlan}", + f"set high-availability vrrp group {g} address {pfx}.1/{cidr}", + f"set high-availability vrrp group {g} priority {prio}", + f"set high-availability vrrp group {g} hello-source-address {pfx}.{self_o}", + f"set high-availability vrrp group {g} peer-address {pfx}.{peer_o}", + f"set high-availability vrrp group {g} no-preempt", + f"set high-availability vrrp sync-group MAIN member {g}", + "", + ] + + out += [ + "# --- DHCP high-availability ---", + "# The thing under test: active-passive should mean exactly one OFFER.", + "set service dhcp-server high-availability mode active-passive", + f"set service dhcp-server high-availability status {role}", + f"set service dhcp-server high-availability name {DHCP_HA_NAME}", + f"set service dhcp-server high-availability source-address 172.31.10.{self_o}", + f"set service dhcp-server high-availability remote 172.31.10.{peer_o}", + "", + ] + + inv = json.load(open(os.path.join(MIG, "export", "inventory.json"))) + dhcp, stats = unifi_to_vyos.build(inv, "sim") + out += [l for l in dhcp if l.strip() and not l.startswith("#")] + print(f"{role}: {stats['subnets']} subnets, {stats['mappings']} mappings", + file=sys.stderr) + return out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--role", choices=("primary", "secondary"), required=True) + args = ap.parse_args() + sys.stdout.write("\n".join(build(args.role)) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())