Compare commits
2 Commits
f41ffdd039
...
ad6eb7a9a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad6eb7a9a6 | ||
|
|
7f551081ad |
@@ -143,6 +143,49 @@ unreserved MAC gets an unreserved address.
|
|||||||
- **http://localhost:9101/metrics** — `labsim_reachable{src,dst,proto}` and
|
- **http://localhost:9101/metrics** — `labsim_reachable{src,dst,proto}` and
|
||||||
`labsim_rtt_ms{src,dst}`.
|
`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
|
## Notes for whoever extends this
|
||||||
|
|
||||||
Things that cost time the first time round, all verified on this image:
|
Things that cost time the first time round, all verified on this image:
|
||||||
|
|||||||
59
labsim/sim-net-apply.sh
Executable file
59
labsim/sim-net-apply.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Apply -- or drift-check -- the labsim routing config on all four VMs.
|
||||||
|
#
|
||||||
|
# ./sim-net-apply.sh check what the VMs run vs what sim-net-config.py says
|
||||||
|
# ./sim-net-apply.sh apply push the generated config over the serial console
|
||||||
|
#
|
||||||
|
# `check` is the one you want most of the time. The whole failure mode this
|
||||||
|
# guards against is somebody (including me) fixing something on a VM over SSH
|
||||||
|
# and never writing it down, so the next rebuild silently loses it.
|
||||||
|
#
|
||||||
|
# Applied over the serial console rather than SSH because a freshly installed
|
||||||
|
# sim router holds the same addresses as its peer -- there is a window where it
|
||||||
|
# is not safely reachable over the network at all. See console-apply.py.
|
||||||
|
set -uo pipefail
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ACTION="${1:-check}"
|
||||||
|
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
# role : vm : address : regex selecting the subtrees this generator owns
|
||||||
|
TARGETS=(
|
||||||
|
"primary:labsim-vyos:172.31.1.252:^set (protocols (bgp|failover|static)|policy (prefix-list|route-map)|nat source rule 1[12]0|interfaces (pppoe|bonding bond0 vif 5[13])|firewall (group interface-group LAN|ipv4|ipv6))"
|
||||||
|
"secondary:labsim-vyos2:172.31.1.253:^set (protocols bgp|policy (prefix-list|route-map)|firewall (group interface-group LAN|ipv4|ipv6))"
|
||||||
|
"isp-dhcp:labsim-isp-dhcp:192.168.122.136:^set (interfaces ethernet|nat source|service dhcp-server|firewall ipv4 forward|system host-name)"
|
||||||
|
"isp-pppoe:labsim-isp-pppoe:192.168.122.63:^set (interfaces ethernet|nat source|service pppoe-server|firewall ipv4 forward|system host-name)"
|
||||||
|
)
|
||||||
|
# Sim-only credential; these VMs hold nothing real and are not reachable from
|
||||||
|
# outside the hypervisor.
|
||||||
|
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
|
||||||
|
-o LogLevel=ERROR -o PreferredAuthentications=password -o ConnectTimeout=5)
|
||||||
|
live() { timeout 30 sshpass -p vyos ssh "${SSH_OPTS[@]}" "vyos@$1" \
|
||||||
|
"/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands" 2>/dev/null; }
|
||||||
|
norm() { sed "s/'//g" | grep -v 'hw-id\|offload' | sort -u; }
|
||||||
|
|
||||||
|
rc=0
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
IFS=: read -r role vm addr rx <<<"$t"
|
||||||
|
"$HERE/sim-net-config.py" --role "$role" >"$WORK/$role.conf" 2>/dev/null || {
|
||||||
|
printf ' %-11s GENERATE FAILED\n' "$role"; rc=1; continue; }
|
||||||
|
|
||||||
|
if [ "$ACTION" = apply ]; then
|
||||||
|
printf ' %-11s applying to %s over console...\n' "$role" "$vm"
|
||||||
|
"$HERE/console-apply.py" --vm "$vm" --config "$WORK/$role.conf" || rc=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! live "$addr" >"$WORK/$role.live" || [ ! -s "$WORK/$role.live" ]; then
|
||||||
|
printf ' %-11s UNREACHABLE (%s)\n' "$role" "$addr"; rc=1; continue
|
||||||
|
fi
|
||||||
|
grep -E '^set ' "$WORK/$role.conf" | norm >"$WORK/$role.g"
|
||||||
|
grep -E "$rx" "$WORK/$role.live" | norm >"$WORK/$role.l"
|
||||||
|
if d="$(diff "$WORK/$role.g" "$WORK/$role.l")" && [ -z "$d" ]; then
|
||||||
|
printf ' %-11s in sync (%s commands)\n' "$role" "$(wc -l <"$WORK/$role.g")"
|
||||||
|
else
|
||||||
|
printf ' %-11s DRIFT — "<" only in code, ">" only on the VM:\n' "$role"
|
||||||
|
printf '%s\n' "$d" | sed 's/^/ /'
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $rc
|
||||||
409
labsim/sim-net-config.py
Executable file
409
labsim/sim-net-config.py
Executable file
@@ -0,0 +1,409 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Firewall. The policy is: internal VLANs talk to each other and to the
|
||||||
|
# internet; the internet initiates nothing inward.
|
||||||
|
#
|
||||||
|
# That was already the *effect* of the previous IPv4 ruleset, but it was built
|
||||||
|
# as a blacklist -- `default-action accept` plus explicit drops on each WAN
|
||||||
|
# interface. The result is identical right up until someone adds a WAN, at
|
||||||
|
# which point it is wide open and nothing looks wrong. This is the same policy
|
||||||
|
# expressed as a whitelist, so a new interface is closed until it is named.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LAN_IFACES = ["bond0", "bond0.2", "bond0.3", "bond0.9", "bond0.10", "bond0.200"]
|
||||||
|
LAN_GROUP = "LAN"
|
||||||
|
|
||||||
|
|
||||||
|
def firewall(wan_dhcp_if: str | None = f"bond0.{WAN_DHCP_VLAN}") -> list[str]:
|
||||||
|
"""wan_dhcp_if=None on a router with no DHCP WAN -- a firewall rule naming
|
||||||
|
an interface that does not exist is rejected at commit."""
|
||||||
|
out = [f"# --- firewall: LAN-to-anywhere, internet-to-nothing ---"]
|
||||||
|
# Delete each filter before rebuilding it. `set` on a rule number is
|
||||||
|
# ADDITIVE: if a rule 10 already exists carrying an inbound-interface
|
||||||
|
# constraint, `set ... rule 10 state established` silently ANDs onto it,
|
||||||
|
# and you get a stateful-accept rule that only applies to one interface
|
||||||
|
# pair. Observed in labsim: return traffic from the internet matched
|
||||||
|
# neither that rule nor the LAN rule and hit the default drop, so LAN
|
||||||
|
# hosts could reach nothing outbound. Everything here is one commit, so
|
||||||
|
# nftables is rebuilt atomically -- there is no window with no firewall.
|
||||||
|
out += [f"delete firewall {fam} {hook} filter"
|
||||||
|
for fam in ("ipv4", "ipv6") for hook in ("forward", "input")]
|
||||||
|
out += [f"set firewall group interface-group {LAN_GROUP} interface {i}"
|
||||||
|
for i in LAN_IFACES]
|
||||||
|
out += [
|
||||||
|
"",
|
||||||
|
# INPUT -- traffic terminating ON the router.
|
||||||
|
# Loopback first. Under a default-drop input policy, services talking to
|
||||||
|
# 127.0.0.1 are filtered like anything else, and the failures are
|
||||||
|
# bizarre and hard to attribute. Nothing off-box can forge iif lo.
|
||||||
|
"set firewall ipv4 input filter rule 5 action accept",
|
||||||
|
"set firewall ipv4 input filter rule 5 description 'loopback'",
|
||||||
|
"set firewall ipv4 input filter rule 5 inbound-interface name lo",
|
||||||
|
"set firewall ipv4 input filter rule 10 action accept",
|
||||||
|
"set firewall ipv4 input filter rule 10 description 'established/related'",
|
||||||
|
"set firewall ipv4 input filter rule 10 state established",
|
||||||
|
"set firewall ipv4 input filter rule 10 state related",
|
||||||
|
# One rule covers VRRP, conntrack-sync, kea HA, SSH, DNS and BGP,
|
||||||
|
# because every one of them arrives on a LAN interface. Enumerating the
|
||||||
|
# protocols instead would mean a new firewall rule every time the pair
|
||||||
|
# gains a feature -- and a lockout the day someone forgets.
|
||||||
|
f"set firewall ipv4 input filter rule 20 action accept",
|
||||||
|
f"set firewall ipv4 input filter rule 20 description 'trusted LAN to the router'",
|
||||||
|
f"set firewall ipv4 input filter rule 20 inbound-interface group {LAN_GROUP}",
|
||||||
|
# DHCP client. Lease RENEWAL is unicast UDP to port 68 and conntrack
|
||||||
|
# does not reliably cover it, so without this the WAN keeps working
|
||||||
|
# until the lease expires and then dies -- a delayed failure that looks
|
||||||
|
# nothing like a firewall change.
|
||||||
|
"set firewall ipv4 input filter default-action drop",
|
||||||
|
"",
|
||||||
|
# FORWARD -- traffic passing THROUGH the router.
|
||||||
|
"set firewall ipv4 forward filter rule 10 action accept",
|
||||||
|
"set firewall ipv4 forward filter rule 10 description 'established/related'",
|
||||||
|
"set firewall ipv4 forward filter rule 10 state established",
|
||||||
|
"set firewall ipv4 forward filter rule 10 state related",
|
||||||
|
# Inter-VLAN *and* LAN-to-internet in one rule: both are "came in on a
|
||||||
|
# LAN interface". Deliberately no restriction between internal VLANs --
|
||||||
|
# segmenting them is a separate decision, not a side effect of this one.
|
||||||
|
f"set firewall ipv4 forward filter rule 20 action accept",
|
||||||
|
f"set firewall ipv4 forward filter rule 20 description 'LAN to anywhere (inter-VLAN + internet)'",
|
||||||
|
f"set firewall ipv4 forward filter rule 20 inbound-interface group {LAN_GROUP}",
|
||||||
|
"set firewall ipv4 forward filter default-action drop",
|
||||||
|
"",
|
||||||
|
# IPv6 already runs default-deny. It only lacks the loopback rule.
|
||||||
|
"set firewall ipv6 input filter rule 5 action accept",
|
||||||
|
"set firewall ipv6 input filter rule 5 description 'loopback'",
|
||||||
|
"set firewall ipv6 input filter rule 5 inbound-interface name lo",
|
||||||
|
"set firewall ipv6 input filter rule 10 action accept",
|
||||||
|
"set firewall ipv6 input filter rule 10 description 'replies to our own traffic'",
|
||||||
|
"set firewall ipv6 input filter rule 10 state established",
|
||||||
|
"set firewall ipv6 input filter rule 10 state related",
|
||||||
|
# RFC 4890: filtering ICMPv6 wholesale breaks ND and PMTUD, which
|
||||||
|
# presents as "IPv6 works until something large", not as a block.
|
||||||
|
"set firewall ipv6 input filter rule 20 action accept",
|
||||||
|
"set firewall ipv6 input filter rule 20 description 'ICMPv6 - ND/RA/PMTUD'",
|
||||||
|
"set firewall ipv6 input filter rule 20 protocol icmpv6",
|
||||||
|
f"set firewall ipv6 input filter rule 30 action accept",
|
||||||
|
f"set firewall ipv6 input filter rule 30 description 'trusted LAN to the router'",
|
||||||
|
f"set firewall ipv6 input filter rule 30 inbound-interface group {LAN_GROUP}",
|
||||||
|
"set firewall ipv6 input filter default-action drop",
|
||||||
|
"set firewall ipv6 forward filter rule 10 action accept",
|
||||||
|
"set firewall ipv6 forward filter rule 10 description 'replies to our own traffic'",
|
||||||
|
"set firewall ipv6 forward filter rule 10 state established",
|
||||||
|
"set firewall ipv6 forward filter rule 10 state related",
|
||||||
|
"set firewall ipv6 forward filter rule 20 action accept",
|
||||||
|
"set firewall ipv6 forward filter rule 20 description 'ICMPv6 - ND/RA/PMTUD'",
|
||||||
|
"set firewall ipv6 forward filter rule 20 protocol icmpv6",
|
||||||
|
f"set firewall ipv6 forward filter rule 30 action accept",
|
||||||
|
f"set firewall ipv6 forward filter rule 30 description 'trusted LAN interfaces only'",
|
||||||
|
f"set firewall ipv6 forward filter rule 30 inbound-interface group {LAN_GROUP}",
|
||||||
|
"set firewall ipv6 forward filter default-action drop",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if wan_dhcp_if:
|
||||||
|
dhcp = [
|
||||||
|
"set firewall ipv4 input filter rule 140 action accept",
|
||||||
|
"set firewall ipv4 input filter rule 140 description 'DHCP client lease renewal'",
|
||||||
|
"set firewall ipv4 input filter rule 140 protocol udp",
|
||||||
|
"set firewall ipv4 input filter rule 140 destination port 68",
|
||||||
|
f"set firewall ipv4 input filter rule 140 inbound-interface name {wan_dhcp_if}",
|
||||||
|
]
|
||||||
|
i = out.index("set firewall ipv4 input filter default-action drop")
|
||||||
|
out[i:i] = dhcp
|
||||||
|
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) + firewall())
|
||||||
|
if role == "secondary":
|
||||||
|
# The backup has no WAN in the sim, so it has no DHCP client to
|
||||||
|
# exempt -- but it gets the same policy otherwise, because after a VRRP
|
||||||
|
# failover it IS the router and a divergent ruleset would only be
|
||||||
|
# discovered during the failover.
|
||||||
|
return ([f"# labsim routing -- {role}", ""]
|
||||||
|
+ bgp(role) + firewall(wan_dhcp_if=None))
|
||||||
|
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())
|
||||||
Reference in New Issue
Block a user