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
292 lines
15 KiB
Python
Executable File
292 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate the labsim routing + WAN config: BGP, dual WAN, and the two ISP VMs.
|
|
|
|
`sim-ha-config.py` covers the LAN side of the sim routers (addresses, VRRP,
|
|
conntrack-sync, DHCP). This covers everything that makes the sim a rehearsal for
|
|
production routing rather than just a LAN:
|
|
|
|
* eBGP between the sim routers and the k3s nodes, carrying the service range
|
|
* dual WAN -- DHCP on VLAN 53, PPPoE on VLAN 51 -- with health-checked failover
|
|
* the two ISP VMs that terminate those WANs and NAT to the real internet
|
|
|
|
All of it previously existed only as running state on the VMs, applied by hand
|
|
over SSH. Rebuilding a VM lost the rehearsal, and nothing recorded *why* any of
|
|
it was shaped the way it is. That is the entire reason this file exists.
|
|
|
|
./sim-net-config.py --role primary > r1-net.conf
|
|
./console-apply.py --vm labsim-vyos --config r1-net.conf
|
|
|
|
or apply all four at once with ./sim-net-apply.sh.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BGP. Numbers match production so what is proven here ports over unchanged.
|
|
# ---------------------------------------------------------------------------
|
|
ROUTER_AS = 65000
|
|
CLUSTER_AS = 65001
|
|
# The service range Cilium advertises. Chosen against a survey of third-party
|
|
# RFC1918 defaults (docker-desktop, tailscale, k3s, EKS...) so it cannot collide
|
|
# with something we adopt later. Production uses the same /22 -- keep them equal.
|
|
SERVICE_CIDR = "10.61.0.0/22"
|
|
K8S_VLAN = 2
|
|
K8S_NODES = ["172.31.2.11", "172.31.2.12", "172.31.2.13"]
|
|
PEER_GROUP = "K8S"
|
|
PFX_LIST = "K8S-SERVICE-IPS"
|
|
RM_IN, RM_OUT = "K8S-IN", "K8S-OUT"
|
|
# One route per node; ECMP across all three. 4 leaves headroom for a fourth node
|
|
# without a config change.
|
|
MAX_PATHS = 4
|
|
# A safety valve, not a capacity plan: a misconfigured Cilium that starts
|
|
# advertising pod CIDRs should tear the session down, not quietly fill the FIB.
|
|
MAX_PREFIX = 100
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dual WAN. The sim ISPs deliberately use TEST-NET-3 (203.0.113.0/24) and
|
|
# TEST-NET-2 (198.51.100.0/24) from RFC 5737: documentation ranges that are
|
|
# guaranteed never to be real destinations, so a leaked sim route cannot
|
|
# blackhole something that matters.
|
|
# ---------------------------------------------------------------------------
|
|
WAN_DHCP_VLAN = 53 # "10gig-equivalent" -- the primary in production
|
|
WAN_PPPOE_VLAN = 51 # "Vodafone-equivalent" -- the backup
|
|
ISP_DHCP_NET = "203.0.113.0/24"
|
|
ISP_DHCP_GW = "203.0.113.1"
|
|
ISP_DHCP_POOL = ("203.0.113.100", "203.0.113.150")
|
|
ISP_PPPOE_NET = "198.51.100.0/24"
|
|
ISP_PPPOE_GW = "198.51.100.1"
|
|
ISP_PPPOE_POOL = ("198.51.100.100", "198.51.100.150")
|
|
# Sim-only fake credentials. Both ends are in this file on purpose: they
|
|
# authenticate nothing real, and splitting them across a secret store would make
|
|
# the sim unreproducible for no security gain. The PRODUCTION PPPoE password
|
|
# lives in /config/wan-secrets on the router and is never in git.
|
|
PPPOE_USER, PPPOE_PASS = "simdsl", "simpass"
|
|
PPPOE_MTU = 1492 # 1500 - 8 bytes of PPPoE header
|
|
PPPOE_AC = "sim-isp"
|
|
|
|
# Failover probe targets. NOT 8.8.8.8/8.8.4.4: those are `system name-server`,
|
|
# so a probe failure and a DNS failure would be the same event and the router
|
|
# would flap the WAN every time DNS hiccuped.
|
|
PROBE_TARGETS = ["9.9.9.9", "208.67.222.222"]
|
|
# The bug this shape fixes (WI-8, found here, fixed in production): `ping -I
|
|
# bond0.53` binds the SOURCE address but does not make the kernel use that
|
|
# interface's gateway. On a cold boot where PPPoE won the default route, probes
|
|
# for the 10 gig egressed via PPPoE, succeeded, and the 10 gig was still never
|
|
# selected -- the house ran on the backup line silently. Pinning each target as
|
|
# a /32 via `dhcp-interface` forces the probe onto the line being tested.
|
|
WAN_DHCP_DISTANCE = 210 # NOT `no-default-route`, which blanks new_routers
|
|
PPPOE_DISTANCE = 10 # in the lease file, leaving failover no gateway
|
|
# to install and silently handing the default
|
|
# route to the backup line.
|
|
SIM_LAN = "172.31.0.0/16"
|
|
|
|
# The sim routers' own libvirt-NAT uplink, from before the ISP VMs existed. It
|
|
# is a third default route that does not exist in production and quietly masks
|
|
# WAN failures during a failover test. `--drop-scaffold` removes it.
|
|
SCAFFOLD_IF = "eth2"
|
|
SCAFFOLD_NAT_RULE = 100
|
|
|
|
|
|
def bgp(role: str) -> list[str]:
|
|
"""eBGP toward the k3s nodes. Identical on both routers except router-id."""
|
|
octet = 252 if role == "primary" else 253
|
|
out = [
|
|
f"# --- BGP: AS{ROUTER_AS} <-> AS{CLUSTER_AS} (k3s/Cilium) ---",
|
|
# FRR enforces RFC 8212: an eBGP session with no policy establishes but
|
|
# exchanges ZERO prefixes, silently. Both directions need a policy or
|
|
# the session looks perfectly healthy and carries nothing.
|
|
f"set policy prefix-list {PFX_LIST} rule 10 action permit",
|
|
f"set policy prefix-list {PFX_LIST} rule 10 prefix {SERVICE_CIDR}",
|
|
# `le 32` because Cilium advertises individual /32 service addresses out
|
|
# of the pool, not the aggregate.
|
|
f"set policy prefix-list {PFX_LIST} rule 10 le 32",
|
|
f"set policy route-map {RM_IN} rule 10 action permit",
|
|
f"set policy route-map {RM_IN} rule 10 match ip address prefix-list {PFX_LIST}",
|
|
# Deny everything outbound. The cluster must never learn a default route
|
|
# from us -- Cilium would install it and blackhole pod egress.
|
|
f"set policy route-map {RM_OUT} rule 10 action deny",
|
|
f"set protocols bgp system-as {ROUTER_AS}",
|
|
f"set protocols bgp parameters router-id 172.31.{K8S_VLAN}.{octet}",
|
|
f"set protocols bgp address-family ipv4-unicast maximum-paths ebgp {MAX_PATHS}",
|
|
f"set protocols bgp peer-group {PEER_GROUP} remote-as {CLUSTER_AS}",
|
|
f"set protocols bgp peer-group {PEER_GROUP} address-family ipv4-unicast route-map import {RM_IN}",
|
|
f"set protocols bgp peer-group {PEER_GROUP} address-family ipv4-unicast route-map export {RM_OUT}",
|
|
f"set protocols bgp peer-group {PEER_GROUP} address-family ipv4-unicast maximum-prefix {MAX_PREFIX}",
|
|
]
|
|
out += [f"set protocols bgp neighbor {n} peer-group {PEER_GROUP}" for n in K8S_NODES]
|
|
out.append("")
|
|
return out
|
|
|
|
|
|
def wan(drop_scaffold: bool) -> list[str]:
|
|
"""Dual WAN + health-checked failover. Primary router only -- see README."""
|
|
out = [
|
|
"# --- WAN: DHCP (primary) + PPPoE (backup), health-checked ---",
|
|
f"set interfaces bonding bond0 vif {WAN_PPPOE_VLAN} description "
|
|
f"'WAN1 Vodafone-equivalent (sim ISP PPPoE)'",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VLAN} address dhcp",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VLAN} description "
|
|
f"'WAN3 10gig-equivalent (sim ISP DHCP)'",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VLAN} dhcp-options "
|
|
f"default-route-distance {WAN_DHCP_DISTANCE}",
|
|
f"set interfaces pppoe pppoe0 source-interface bond0.{WAN_PPPOE_VLAN}",
|
|
f"set interfaces pppoe pppoe0 authentication username {PPPOE_USER}",
|
|
f"set interfaces pppoe pppoe0 authentication password {PPPOE_PASS}",
|
|
f"set interfaces pppoe pppoe0 default-route-distance {PPPOE_DISTANCE}",
|
|
f"set interfaces pppoe pppoe0 mtu {PPPOE_MTU}",
|
|
# The ISP's resolvers would otherwise overwrite ours in resolv.conf every
|
|
# time the session comes up.
|
|
"set interfaces pppoe pppoe0 no-peer-dns",
|
|
"",
|
|
"# Failover: prefer the DHCP WAN, fall back to PPPoE when probes fail.",
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface bond0.{WAN_DHCP_VLAN} metric 1",
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface bond0.{WAN_DHCP_VLAN} check type icmp",
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface bond0.{WAN_DHCP_VLAN} check timeout 5",
|
|
# any-available, not all: one unreachable public resolver is a normal
|
|
# internet event, not a reason to abandon a working 10 gig line.
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface bond0.{WAN_DHCP_VLAN} check policy any-available",
|
|
]
|
|
for t in PROBE_TARGETS:
|
|
out.append(f"set protocols failover route 0.0.0.0/0 dhcp-interface "
|
|
f"bond0.{WAN_DHCP_VLAN} check target {t}")
|
|
out.append("")
|
|
out.append("# Pin the probe targets to the line under test (WI-8 -- see above).")
|
|
for t in PROBE_TARGETS:
|
|
out.append(f"set protocols static route {t}/32 dhcp-interface bond0.{WAN_DHCP_VLAN}")
|
|
out += [
|
|
"",
|
|
"# Masquerade out of whichever WAN currently holds the default route.",
|
|
f"set nat source rule 110 outbound-interface name bond0.{WAN_DHCP_VLAN}",
|
|
f"set nat source rule 110 source address {SIM_LAN}",
|
|
"set nat source rule 110 translation address masquerade",
|
|
"set nat source rule 120 outbound-interface name pppoe0",
|
|
f"set nat source rule 120 source address {SIM_LAN}",
|
|
"set nat source rule 120 translation address masquerade",
|
|
"",
|
|
]
|
|
if drop_scaffold:
|
|
out += [
|
|
"# Remove the pre-ISP-VM libvirt-NAT uplink: a third default route",
|
|
"# that has no production equivalent and hides real WAN failures.",
|
|
f"delete interfaces ethernet {SCAFFOLD_IF} address",
|
|
f"delete nat source rule {SCAFFOLD_NAT_RULE}",
|
|
"",
|
|
]
|
|
return out
|
|
|
|
|
|
def isp_dhcp(wan_if: str, uplink_if: str) -> list[str]:
|
|
"""The 10gig-equivalent ISP: hands out a lease, NATs to the real internet."""
|
|
return [
|
|
f"# --- sim ISP: DHCP WAN on VLAN {WAN_DHCP_VLAN} ---",
|
|
"set system host-name isp-dhcp",
|
|
f"set interfaces ethernet {wan_if} address {ISP_DHCP_GW}/24",
|
|
f"set interfaces ethernet {wan_if} description "
|
|
f"'sim ISP - 10gig-equivalent WAN on VLAN{WAN_DHCP_VLAN}'",
|
|
f"set interfaces ethernet {uplink_if} address dhcp",
|
|
f"set interfaces ethernet {uplink_if} description 'uplink to the real internet'",
|
|
f"set service dhcp-server shared-network-name WAN{WAN_DHCP_VLAN} "
|
|
f"subnet {ISP_DHCP_NET} subnet-id 1",
|
|
f"set service dhcp-server shared-network-name WAN{WAN_DHCP_VLAN} "
|
|
f"subnet {ISP_DHCP_NET} option default-router {ISP_DHCP_GW}",
|
|
f"set service dhcp-server shared-network-name WAN{WAN_DHCP_VLAN} "
|
|
f"subnet {ISP_DHCP_NET} option name-server 8.8.8.8",
|
|
f"set service dhcp-server shared-network-name WAN{WAN_DHCP_VLAN} "
|
|
f"subnet {ISP_DHCP_NET} range CUST start {ISP_DHCP_POOL[0]}",
|
|
f"set service dhcp-server shared-network-name WAN{WAN_DHCP_VLAN} "
|
|
f"subnet {ISP_DHCP_NET} range CUST stop {ISP_DHCP_POOL[1]}",
|
|
"",
|
|
] + _isp_common(uplink_if, ISP_DHCP_NET, "sim ISP: NAT customers to the real internet")
|
|
|
|
|
|
def isp_pppoe(wan_if: str, uplink_if: str) -> list[str]:
|
|
"""The Vodafone-equivalent ISP: terminates PPPoE, NATs to the real internet."""
|
|
return [
|
|
f"# --- sim ISP: PPPoE WAN on VLAN {WAN_PPPOE_VLAN} ---",
|
|
"set system host-name isp-pppoe",
|
|
f"set interfaces ethernet {wan_if} description "
|
|
f"'sim ISP - Vodafone-equivalent WAN on VLAN{WAN_PPPOE_VLAN} (PPPoE)'",
|
|
f"set interfaces ethernet {uplink_if} address dhcp",
|
|
f"set interfaces ethernet {uplink_if} description 'uplink to the real internet'",
|
|
f"set service pppoe-server access-concentrator {PPPOE_AC}",
|
|
f"set service pppoe-server interface {wan_if}",
|
|
f"set service pppoe-server gateway-address {ISP_PPPOE_GW}",
|
|
"set service pppoe-server authentication mode local",
|
|
f"set service pppoe-server authentication local-users username {PPPOE_USER} "
|
|
f"password {PPPOE_PASS}",
|
|
f"set service pppoe-server client-ip-pool CUST range "
|
|
f"{ISP_PPPOE_POOL[0]}-{ISP_PPPOE_POOL[1]}",
|
|
"set service pppoe-server default-pool CUST",
|
|
"set service pppoe-server name-server 8.8.8.8",
|
|
"",
|
|
] + _isp_common(uplink_if, ISP_PPPOE_NET, "sim ISP: NAT PPPoE customers to the real internet")
|
|
|
|
|
|
def _isp_common(uplink_if: str, customer_net: str, desc: str) -> list[str]:
|
|
return [
|
|
"set nat source rule 100 description " + f"'{desc}'",
|
|
f"set nat source rule 100 outbound-interface name {uplink_if}",
|
|
f"set nat source rule 100 source address {customer_net}",
|
|
"set nat source rule 100 translation address masquerade",
|
|
"",
|
|
# An ISP that drops return traffic is not simulating an ISP. The forward
|
|
# chain defaults to accept here on purpose -- these VMs model the
|
|
# internet, and the thing under test is the router's firewall, not this.
|
|
"set firewall ipv4 forward filter default-action accept",
|
|
"set firewall ipv4 forward filter rule 10 action accept",
|
|
"set firewall ipv4 forward filter rule 10 state established",
|
|
"set firewall ipv4 forward filter rule 10 state related",
|
|
"set firewall ipv4 forward filter rule 10 description conntrack-engage",
|
|
"",
|
|
]
|
|
|
|
|
|
def build(role: str, drop_scaffold: bool, wan_if: str, uplink_if: str) -> list[str]:
|
|
if role == "primary":
|
|
# WAN lives on the primary only. Production has WAN on both routers;
|
|
# the sim does not, because two PPPoE clients sharing one credential
|
|
# against a single access concentrator is a different failure mode than
|
|
# anything production has. VRRP/conntrack failover is still exercised --
|
|
# see README, "known gaps".
|
|
return [f"# labsim routing -- {role}", ""] + bgp(role) + wan(drop_scaffold)
|
|
if role == "secondary":
|
|
return [f"# labsim routing -- {role}", ""] + bgp(role)
|
|
if role == "isp-dhcp":
|
|
return isp_dhcp(wan_if, uplink_if)
|
|
return isp_pppoe(wan_if, uplink_if)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--role", required=True,
|
|
choices=("primary", "secondary", "isp-dhcp", "isp-pppoe"))
|
|
ap.add_argument("--drop-scaffold", action="store_true",
|
|
help="also remove the pre-ISP-VM libvirt-NAT uplink (primary only)")
|
|
# The ISP VMs' interface names depend on PCI enumeration order, which is not
|
|
# stable across a rebuild: isp-dhcp came up as eth0/eth1 and isp-pppoe as
|
|
# eth2/eth3 from identical XML. Check with `show interfaces` before applying
|
|
# rather than trusting these defaults.
|
|
ap.add_argument("--wan-if", default=None, help="ISP VM: customer-facing NIC")
|
|
ap.add_argument("--uplink-if", default=None, help="ISP VM: internet-facing NIC")
|
|
args = ap.parse_args()
|
|
|
|
defaults = {"isp-dhcp": ("eth0", "eth1"), "isp-pppoe": ("eth2", "eth3")}
|
|
w, u = defaults.get(args.role, ("", ""))
|
|
w, u = args.wan_if or w, args.uplink_if or u
|
|
|
|
if args.drop_scaffold and args.role != "primary":
|
|
print("--drop-scaffold only applies to --role primary", file=sys.stderr)
|
|
return 2
|
|
|
|
lines = build(args.role, args.drop_scaffold, w, u)
|
|
sys.stdout.write("\n".join(lines) + "\n")
|
|
n = len([l for l in lines if l.startswith(("set ", "delete "))])
|
|
print(f"{args.role}: {n} commands", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|