Some checks failed
ppp_dial() checked the flap holdoff and returned BEFORE renewing /run/vrrp-wan/may-dial. That lease is what vrrp-wan-guard expires after LEASE_TTL, so tripping the damper stopped the renew and the guard hung up pppoe0 on the MASTER ~80s later. A damper meant to suppress repeated DIALS was tearing down a working WAN instead. Observed in labsim, end to end: DIAL FLAP: >=6 attempts in 600s -- holding off 900s GUARD: lease stale (81s > 75s) -- hanging up pppoe0 An established session now outranks every check below it: ppp_active renews the lease and returns first. Everything after it only decides whether to start a NEW session. Two supporting fixes for how that storm started. The dial attempts were all no-ops because /etc/ppp/peers/pppoe0 was missing, and nothing said so -- systemd logs "skipped because of an unmet condition check" exactly once and the gate looks identical to a healthy backup. ppp_dial() now reports it, and distinguishes "configured but not rendered" (re-commit the subtree) from "no pppoe0 in config at all", which is what a reboot leaves behind when a commit was never saved. That is precisely how the sim secondary lost its WAN. Also `cat | wc -l` rather than `wc -l < file`: redirections are applied left to right, so the missing-file error escapes the 2>/dev/null on every first-ever dial. Harness: T11 copied-then-removed instead of mv, and verifies the restore -- losing that file strands a router permanently, which cost a debugging session. preflight now refuses to run if either router lacks the peers file or the pppoe0 config, since every failover result would otherwise be a false negative blamed on the ISP. New T12 forges a 900s holdoff against a live session and asserts it survives.
508 lines
27 KiB
Python
Executable File
508 lines
27 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate the delta that turns a passive VyOS pair into the gateway.
|
|
|
|
The switch works as: load the known-good `unifi.boot` snapshot, apply this
|
|
delta, commit-confirm. Deriving the gateway mode from base+delta every time
|
|
means there is no inverse to maintain and no drift between two hand-kept
|
|
configs -- the revert is just loading the snapshot again.
|
|
|
|
./vyos-mode-delta.py --priority 200 -o to-vyos.commands # vyos001 (master)
|
|
./vyos-mode-delta.py --priority 100 -o to-vyos.commands # vyos002 (backup)
|
|
./vyos-mode-delta.py --emit-secrets /path/wan-secrets # credentials, 0600
|
|
|
|
The PPPoE password is NOT written into the delta. The delta carries the
|
|
placeholder @@WAN_PASSWORD@@ and the switch script substitutes it at apply time
|
|
from /config/wan-secrets, so the generated artifact can be read, diffed and
|
|
copied around without carrying a credential.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# unifi-to-vyos.py has hyphens, so it cannot be imported by name. Reuse it
|
|
# rather than duplicating the DHCP/DNS generation -- the whole point is that
|
|
# what labsim proved and what production gets come from one code path.
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"unifi_to_vyos", os.path.join(HERE, "unifi-to-vyos.py"))
|
|
unifi_to_vyos = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(unifi_to_vyos)
|
|
|
|
# Two WANs, established by reading the live USG rather than the UniFi fields
|
|
# (which report wan_type=dhcp for both and are simply wrong):
|
|
#
|
|
# WAN1 Vodafone, PPPoE on the USG's eth0, ~900/700 Mbit. Verified working:
|
|
# pppoe0 came up with 90.241.226.213 peer 84.65.128.1, MTU 1492.
|
|
# WAN2 10 gig ISP, plain DHCP on the USG's eth2, public 87.192.101.48/21
|
|
# gw 87.192.96.1. This is what carries traffic today.
|
|
#
|
|
# Both reach the USG as untagged access ports but are carried across the switch
|
|
# fabric as vlan-only networks 51 and 53, so VyOS picks them up as bond vifs.
|
|
WAN_PPPOE_VIF = "bond0.51" # Vodafone
|
|
WAN_PPPOE_IF = "pppoe0"
|
|
WAN_DHCP_VIF = "bond0.53" # 10 gig ISP
|
|
|
|
# The DHCP lease is bound to the MAC, so cloning the USG's WAN2 MAC is how VyOS
|
|
# keeps 87.192.101.48 instead of negotiating a fresh lease -- or getting none,
|
|
# if the ISP hands out one per line. Only ONE box may carry this at a time.
|
|
WAN_DHCP_MAC = "f0:9f:c2:12:9b:4f"
|
|
|
|
# Route distances: the 10 gig line wins, Vodafone is failover.
|
|
#
|
|
# The live 10 gig default route is owned by `protocols failover`, so that losing
|
|
# the ISP *without* losing carrier withdraws it instead of black-holing every
|
|
# packet -- a DHCP-installed route never withdraws on a dead upstream.
|
|
#
|
|
# The vif still needs default-route-distance rather than no-default-route:
|
|
# vyos-failover resolves a dhcp-interface gateway by reading new_routers out of
|
|
# /run/dhclient/dhclient_<if>.lease, and no-default-route leaves that field
|
|
# EMPTY, so the daemon finds no next hop and installs nothing. Verified on
|
|
# vyos001: with no-default-route the default route fell through to Vodafone.
|
|
#
|
|
# So DHCP keeps a route, deliberately demoted BELOW Vodafone. Order of
|
|
# preference: failover's kernel route (distance 0) > pppoe (10) > DHCP (210).
|
|
# The demoted route is never selected while pppoe is up, so it cannot re-create
|
|
# the black-hole it exists to avoid.
|
|
DIST_PPPOE = 10
|
|
DIST_DHCP_FALLBACK = 210
|
|
|
|
# Health-checked primary. Two targets, any-available, so one resolver having a
|
|
# bad day is not read as "the line is down". Verified on the sim: failover and
|
|
# failback both inside 5s with the router's own interface still UP.
|
|
FAILOVER_METRIC = 1
|
|
FAILOVER_TARGETS = ["8.8.8.8", "1.1.1.1"]
|
|
FAILOVER_TIMEOUT = 5
|
|
|
|
PLACEHOLDER = "@@WAN_PASSWORD@@"
|
|
|
|
# Per-VLAN interface addresses of each node, read from the live boxes. VRRP
|
|
# unicast (hello-source-address/peer-address) needs both ends explicitly, and
|
|
# these are NOT derivable from the subnet -- VLAN 3 is .4/.5 while everything
|
|
# else is .252/.253.
|
|
# vlan: (vyos001, vyos002)
|
|
NODE_ADDRS = {
|
|
1: ("192.168.1.252", "192.168.1.253"),
|
|
2: ("192.168.9.252", "192.168.9.253"),
|
|
3: ("192.168.3.4", "192.168.3.5"),
|
|
9: ("10.8.0.252", "10.8.0.253"),
|
|
10: ("10.0.1.252", "10.0.1.253"),
|
|
200: ("192.168.2.252", "192.168.2.253"),
|
|
}
|
|
|
|
# Dedicated point-to-point link for conntrack state sync (eth3 <-> eth3).
|
|
CONNTRACK_ADDRS = ("10.255.255.1/30", "10.255.255.2/30")
|
|
CONNTRACK_IF = "eth3"
|
|
|
|
# kea HA talks over TCP 647. The LoT addresses are used because they are stable
|
|
# and reachable today without the conntrack cable being plugged in.
|
|
DHCP_HA_NAME = "vyos-dhcp-pair" # must NOT equal either system host-name
|
|
|
|
|
|
|
|
def vrrp_group(vlan: int) -> str:
|
|
"""VRRP group names as configured on the boxes: 'native' for the untagged
|
|
VLAN, 'vlan<id>' otherwise."""
|
|
return "native" if vlan == 1 else f"vlan{vlan}"
|
|
|
|
|
|
def build_delta(inv: dict, priority: int, wan_user: str, with_wan: bool,
|
|
conntrack_link: bool) -> list[str]:
|
|
out: list[str] = []
|
|
primary = priority >= 200 # vyos001 is the master/primary
|
|
self_i, peer_i = (0, 1) if primary else (1, 0)
|
|
nets = [n for n in inv["networks"] if n["dhcp_enabled"] and n["subnet"]]
|
|
nets.sort(key=unifi_to_vyos.vlan_of)
|
|
|
|
out += [
|
|
"# ==========================================================",
|
|
"# Delta: passive VyOS pair -> gateway. Applied on top of a",
|
|
"# freshly loaded unifi.boot, never on top of itself.",
|
|
"# ==========================================================",
|
|
"",
|
|
"# An unconfirmed commit must reload the previous config, NOT reboot.",
|
|
"# 'reboot' is the VyOS default and would turn a failed switch into a",
|
|
"# real outage on the box that is meant to be carrying the network.",
|
|
"set system config-management commit-confirm action reload",
|
|
"",
|
|
"# --- gateway addresses ------------------------------------",
|
|
"# The VIP takes over the address the USG holds today, so no client",
|
|
"# changes anything: no renewal needed, hardcoded gateways keep working.",
|
|
]
|
|
for n in nets:
|
|
vlan = unifi_to_vyos.vlan_of(n)
|
|
grp = vrrp_group(vlan)
|
|
iface = ipaddress.ip_interface(n["subnet"])
|
|
out.append(f"# {n['name']} (VLAN {vlan}) -> {iface.with_prefixlen}")
|
|
# Delete the whole address node rather than a computed old value.
|
|
# `address` is multi-value, and the current VIPs are NOT at
|
|
# network+254 on the /23 networks -- they are 192.168.9.254,
|
|
# 10.0.9.254 and 10.0.1.254, in the upper half. A delete naming the
|
|
# wrong address fails quietly and leaves the group holding two VIPs.
|
|
out.append(f"delete high-availability vrrp group {grp} address")
|
|
out.append(f"set high-availability vrrp group {grp} address {iface.with_prefixlen}")
|
|
out.append(f"set high-availability vrrp group {grp} priority {priority}")
|
|
own, peer = NODE_ADDRS[vlan] if primary else NODE_ADDRS[vlan][::-1]
|
|
# Unicast VRRP: the walkthrough sets both ends explicitly rather than
|
|
# relying on multicast, which is more predictable across a switch fabric.
|
|
out.append(f"set high-availability vrrp group {grp} hello-source-address {own}")
|
|
out.append(f"set high-availability vrrp group {grp} peer-address {peer}")
|
|
# Without no-preempt a recovered box reclaims the VIP immediately --
|
|
# before conntrack state has synced -- and drops every established
|
|
# connection. If preemption is ever wanted, preempt-delay must be >=
|
|
# the conntrack-sync purge-timeout.
|
|
out.append(f"set high-availability vrrp group {grp} no-preempt")
|
|
|
|
out += [
|
|
"",
|
|
]
|
|
|
|
|
|
out += [
|
|
"",
|
|
"# --- stateful tracking (BOTH boxes) ------------------------",
|
|
"# VyOS only engages conntrack when a firewall or NAT exists. The",
|
|
"# backup has no WAN and therefore no NAT, so without this rule it",
|
|
"# tracks nothing -- and conntrack-sync entries replicated to a box",
|
|
"# whose conntrack is not engaged cannot be used when it takes over.",
|
|
"# Verified in labsim: zero conntrack entries until a state-matching",
|
|
"# rule was present, then replication began immediately.",
|
|
"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 'stateful tracking'",
|
|
]
|
|
|
|
if True: # WAN config on BOTH boxes; see the disable block below
|
|
out += [
|
|
"# --- WAN -----------------------------------------------",
|
|
"# Both vifs must be created before anything references them.",
|
|
"# Neither firewall has vif 51 or 53 today (only 2, 3, 9, 10, 200),",
|
|
"# and pppoe source-interface points at an interface that must",
|
|
"# already exist -- without this the commit fails and, since the",
|
|
"# delta commits as one unit, takes the whole switch with it.",
|
|
f"set interfaces bonding bond0 vif {WAN_PPPOE_VIF.split('.')[1]} description 'WAN1 Vodafone (PPPoE)'",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} description 'WAN2 10gig ISP (DHCP)'",
|
|
"",
|
|
"# WAN2, the 10 gig line -- primary. The cloned MAC is what keeps",
|
|
"# the existing public lease (87.192.101.48) instead of asking for",
|
|
"# a new one. Only the box carrying the WAN may set this.",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} mac '{WAN_DHCP_MAC}'",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} address dhcp",
|
|
# Demoted below Vodafone; `protocols failover` owns the live route.
|
|
# NOT no-default-route -- that blanks new_routers in the lease and
|
|
# leaves the failover daemon with no gateway to install.
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} dhcp-options default-route-distance {DIST_DHCP_FALLBACK}",
|
|
"",
|
|
"# WAN1, Vodafone -- failover at a higher distance. Verified working",
|
|
"# on the USG: pppoe0 came up with a public address, MTU 1492.",
|
|
f"set interfaces pppoe {WAN_PPPOE_IF} source-interface {WAN_PPPOE_VIF}",
|
|
f"set interfaces pppoe {WAN_PPPOE_IF} authentication username '{wan_user}'",
|
|
f"set interfaces pppoe {WAN_PPPOE_IF} authentication password '{PLACEHOLDER}'",
|
|
f"set interfaces pppoe {WAN_PPPOE_IF} mtu 1492",
|
|
f"set interfaces pppoe {WAN_PPPOE_IF} default-route-distance {DIST_PPPOE}",
|
|
# The peer's resolvers would otherwise overwrite resolv.conf.
|
|
f"set interfaces pppoe {WAN_PPPOE_IF} no-peer-dns",
|
|
"",
|
|
"# The static default route exists only for unifi mode, where the",
|
|
"# USG is the next hop. Both WANs supply one here.",
|
|
"delete protocols static route 0.0.0.0/0",
|
|
"",
|
|
"# --- Health-checked primary ----------------------------",
|
|
"# Without this, failover only fires when bond0.53 loses carrier",
|
|
"# or its lease. An ISP that keeps the link up while dropping",
|
|
"# traffic -- the common failure -- would black-hole everything,",
|
|
"# because a DHCP-installed route has nothing to withdraw it.",
|
|
"#",
|
|
"# vyos-failover pings each target bound to the interface",
|
|
"# (`ping -I bond0.53`), so the backup can never be validated",
|
|
"# through the primary's path and vice versa. On withdrawal the",
|
|
"# kernel falls through to Vodafone's distance-10 route.",
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} check type icmp",
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} check policy any-available",
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} check timeout {FAILOVER_TIMEOUT}",
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} metric {FAILOVER_METRIC}",
|
|
*[
|
|
f"set protocols failover route 0.0.0.0/0 dhcp-interface {WAN_DHCP_VIF} check target {t}"
|
|
for t in FAILOVER_TARGETS
|
|
],
|
|
"",
|
|
"# --- NAT -----------------------------------------------",
|
|
f"set nat source rule 100 outbound-interface name {WAN_DHCP_VIF}",
|
|
"set nat source rule 100 translation address masquerade",
|
|
"set nat source rule 100 description 'LAN out via the 10gig line'",
|
|
f"set nat source rule 110 outbound-interface name {WAN_PPPOE_IF}",
|
|
"set nat source rule 110 translation address masquerade",
|
|
"set nat source rule 110 description 'LAN out via Vodafone (failover)'",
|
|
]
|
|
|
|
if not with_wan:
|
|
# Both boxes carry the identical WAN and NAT config; only the RESTING
|
|
# STATE differs, and only for the DHCP line. Takeover is no longer a
|
|
# human deleting two lines under pressure -- vrrp-wan-reconcile does it,
|
|
# driven by who holds the management VIP. See migration/PPPOE-HA.md.
|
|
#
|
|
# bond0.53 stays here, on the CONFIG plane, because its lease is bound
|
|
# to a cloned MAC and only VyOS config can move a MAC between boxes.
|
|
# This is the "nothing to follow" default: a freshly built or PXE'd box
|
|
# has no live master to imitate, so it must come up unable to claim that
|
|
# MAC. On a running pair the model follows reality instead -- see the
|
|
# export-before-apply rule in migration/PPPOE-HA.md.
|
|
#
|
|
# pppoe0 is deliberately NOT disabled here any more. `disable` unlinks
|
|
# /etc/ppp/peers/pppoe0, which is pppd's own options file, so it
|
|
# destroys what the promotion path needs and leaves ppp@pppoe0
|
|
# restart-looping. Dialling is gated at the systemd unit instead.
|
|
#
|
|
# ORDERING TRAP for a rebuilt box: because pppoe0 is left ENABLED,
|
|
# interfaces_pppoe.py will try to dial on the first commit that touches
|
|
# the pppoe subtree. Install the gate FIRST --
|
|
# `migration/vrrp-wan-install --vip <mgmt VIP> --host vyos@<box>` --
|
|
# or the new box will take the single ISP session off the live master.
|
|
#
|
|
# NAT rules naming a down interface are harmless: VyOS warns at commit
|
|
# ("Interface ... does not exist!") and commits anyway, verified.
|
|
out += [
|
|
"",
|
|
"# --- 10 gig held DOWN on this box --------------------------",
|
|
"# Do NOT enable by hand: vrrp-wan-reconcile owns this, keyed on",
|
|
"# whoever holds the management VIP. pppoe0 is gated at the unit",
|
|
"# (ppp@pppoe0.service.d/10-vrrp-wan-gate.conf), not in config.",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} disable",
|
|
]
|
|
|
|
if True:
|
|
# Port forwards and the WAN firewall go on BOTH boxes. They name
|
|
# interfaces that are present-but-disabled on the backup, which VyOS
|
|
# accepts (it warns and commits). Putting them here means a failover is
|
|
# enabling an interface, not reconstructing NAT under pressure.
|
|
# Port forwards, straight from UniFi.
|
|
for i, p in enumerate(inv["port_forwards"]):
|
|
if not p.get("enabled"):
|
|
continue
|
|
rule = 100 + i * 10
|
|
proto = p["proto"] # tcp | udp | tcp_udp -- all valid VyOS values
|
|
out += [
|
|
"",
|
|
f"set nat destination rule {rule} description '{p['name']}'",
|
|
f"set nat destination rule {rule} inbound-interface name {WAN_DHCP_VIF}",
|
|
f"set nat destination rule {rule} protocol {proto}",
|
|
f"set nat destination rule {rule} destination port '{p['dst_port']}'",
|
|
f"set nat destination rule {rule} translation address {p['fwd']}",
|
|
]
|
|
# `destination port` accepts a comma list but `translation port` does
|
|
# NOT -- "16881,6881 is not a valid service name" -- because mapping a
|
|
# list onto a list is ambiguous. Every forward here maps a port to
|
|
# itself, and omitting translation port makes VyOS preserve the
|
|
# original, which is exactly right. Only emit it when it genuinely
|
|
# differs, and refuse rather than guess when a differing list appears.
|
|
if p["fwd_port"] != p["dst_port"]:
|
|
if "," in str(p["fwd_port"]) or "," in str(p["dst_port"]):
|
|
raise SystemExit(
|
|
f"port forward '{p['name']}' remaps a LIST of ports "
|
|
f"({p['dst_port']} -> {p['fwd_port']}). VyOS cannot express "
|
|
f"that in one rule; split it into one rule per port by hand.")
|
|
out.append(f"set nat destination rule {rule} translation port '{p['fwd_port']}'")
|
|
|
|
out += [
|
|
"",
|
|
"# --- firewall ----------------------------------------------",
|
|
"# VyOS defaults to accepting everything. The USG has an implicit",
|
|
"# WAN drop, so migrating the port forwards alone would leave the",
|
|
"# router's own services and the whole LAN reachable from the WAN.",
|
|
"#",
|
|
"# Scoped to the WAN interface rather than a global default-action",
|
|
"# drop: that way a mistake here cannot lock anyone out over the LAN,",
|
|
"# which is the only path back in during a cutover.",
|
|
"",
|
|
"# Traffic TO the router.",
|
|
"set firewall ipv4 input filter default-action accept",
|
|
"set firewall ipv4 input filter rule 100 action accept",
|
|
"set firewall ipv4 input filter rule 100 state established",
|
|
"set firewall ipv4 input filter rule 100 state related",
|
|
"set firewall ipv4 input filter rule 100 description 'established/related'",
|
|
]
|
|
# The two WAN_LOCAL accepts carried over from UniFi.
|
|
out += [
|
|
"",
|
|
"set firewall ipv4 input filter rule 110 action accept",
|
|
"set firewall ipv4 input filter rule 110 protocol esp",
|
|
f"set firewall ipv4 input filter rule 110 inbound-interface name {WAN_DHCP_VIF}",
|
|
"set firewall ipv4 input filter rule 110 description 'VPN accept ESP (from UniFi WAN_LOCAL)'",
|
|
"",
|
|
"set firewall ipv4 input filter rule 120 action accept",
|
|
"set firewall ipv4 input filter rule 120 protocol udp",
|
|
"set firewall ipv4 input filter rule 120 destination port '500,4500'",
|
|
f"set firewall ipv4 input filter rule 120 inbound-interface name {WAN_DHCP_VIF}",
|
|
"set firewall ipv4 input filter rule 120 description 'VPN accept UDP500/4500 (from UniFi WAN_LOCAL)'",
|
|
"",
|
|
"set firewall ipv4 input filter rule 130 action accept",
|
|
"set firewall ipv4 input filter rule 130 protocol icmp",
|
|
f"set firewall ipv4 input filter rule 130 inbound-interface name {WAN_DHCP_VIF}",
|
|
"set firewall ipv4 input filter rule 130 description 'ICMP to the router (path MTU discovery)'",
|
|
"",
|
|
"# Everything else arriving from the WAN is dropped. LAN is untouched.",
|
|
"set firewall ipv4 input filter rule 900 action drop",
|
|
f"set firewall ipv4 input filter rule 900 inbound-interface name {WAN_DHCP_VIF}",
|
|
f"set firewall ipv4 input filter rule 910 action drop",
|
|
f"set firewall ipv4 input filter rule 910 inbound-interface name {WAN_PPPOE_IF}",
|
|
"set firewall ipv4 input filter rule 910 description 'drop all other WAN-to-router (Vodafone)'",
|
|
"set firewall ipv4 input filter rule 900 description 'drop all other WAN-to-router'",
|
|
"",
|
|
"# Traffic THROUGH the router.",
|
|
"set firewall ipv4 forward filter default-action accept",
|
|
"set firewall ipv4 forward filter rule 100 action accept",
|
|
"set firewall ipv4 forward filter rule 100 state established",
|
|
"set firewall ipv4 forward filter rule 100 state related",
|
|
"set firewall ipv4 forward filter rule 100 description 'established/related'",
|
|
]
|
|
|
|
# Destination NAT happens before the forward filter, so these rules must
|
|
# match the translated destination, not the WAN address.
|
|
for i, p in enumerate(inv["port_forwards"]):
|
|
if not p.get("enabled"):
|
|
continue
|
|
rule = 200 + i * 10
|
|
out += [
|
|
"",
|
|
f"set firewall ipv4 forward filter rule {rule} action accept",
|
|
f"set firewall ipv4 forward filter rule {rule} inbound-interface name {WAN_DHCP_VIF}",
|
|
f"set firewall ipv4 forward filter rule {rule} protocol {p['proto']}",
|
|
f"set firewall ipv4 forward filter rule {rule} destination address {p['fwd']}",
|
|
f"set firewall ipv4 forward filter rule {rule} destination port '{p['fwd_port']}'",
|
|
f"set firewall ipv4 forward filter rule {rule} description 'port forward: {p['name']}'",
|
|
]
|
|
|
|
out += [
|
|
"",
|
|
"# New inbound connections from the WAN that are not a port forward.",
|
|
"set firewall ipv4 forward filter rule 900 action drop",
|
|
f"set firewall ipv4 forward filter rule 900 inbound-interface name {WAN_DHCP_VIF}",
|
|
f"set firewall ipv4 forward filter rule 910 action drop",
|
|
f"set firewall ipv4 forward filter rule 910 inbound-interface name {WAN_PPPOE_IF}",
|
|
"set firewall ipv4 forward filter rule 910 description 'drop unsolicited WAN-to-LAN (Vodafone)'",
|
|
"set firewall ipv4 forward filter rule 900 description 'drop unsolicited WAN-to-LAN'",
|
|
"",
|
|
]
|
|
|
|
|
|
# --- DHCP high-availability -------------------------------------------
|
|
# Without this BOTH boxes run kea on the same VLANs and race to answer the
|
|
# same broadcasts, handing different pool addresses to the same client.
|
|
# active-passive so only the primary serves, matching the VRRP shape.
|
|
dhcp_self, dhcp_peer = NODE_ADDRS[10][self_i], NODE_ADDRS[10][peer_i]
|
|
out += [
|
|
"",
|
|
"# --- DHCP high-availability --------------------------------",
|
|
"# Peers sync leases over TCP 647. Each subnet already carries a",
|
|
"# unique subnet-id (keyed on VLAN id), which kea HA requires.",
|
|
"set service dhcp-server high-availability mode active-passive",
|
|
f"set service dhcp-server high-availability status {'primary' if primary else 'secondary'}",
|
|
# The peer name must not collide with either system host-name.
|
|
f"set service dhcp-server high-availability name {DHCP_HA_NAME}",
|
|
f"set service dhcp-server high-availability source-address {dhcp_self}",
|
|
f"set service dhcp-server high-availability remote {dhcp_peer}",
|
|
]
|
|
|
|
if conntrack_link:
|
|
# Stateful failover. Without it VRRP moves the address but every
|
|
# established connection dies, because the backup has no conntrack
|
|
# table. Needs the eth3 <-> eth3 cable physically present.
|
|
out += [
|
|
"",
|
|
"# --- conntrack-sync ----------------------------------------",
|
|
"# Dedicated point-to-point link: sync traffic must not compete",
|
|
"# with production, and must not die when the LAN does.",
|
|
f"set interfaces ethernet {CONNTRACK_IF} address {CONNTRACK_ADDRS[self_i]}",
|
|
f"set interfaces ethernet {CONNTRACK_IF} description 'conntrack-sync peer link'",
|
|
f"set service conntrack-sync interface {CONNTRACK_IF}",
|
|
"set service conntrack-sync failover-mechanism vrrp sync-group MAIN",
|
|
"set service conntrack-sync accept-protocol tcp",
|
|
"set service conntrack-sync accept-protocol udp",
|
|
"set service conntrack-sync accept-protocol icmp",
|
|
"set service conntrack-sync mcast-group 225.0.0.50",
|
|
]
|
|
|
|
# DHCP + DNS, from the same generator labsim proved.
|
|
dhcp_lines, stats = unifi_to_vyos.build(inv, "prod")
|
|
expected = len(inv["reservations"])
|
|
if stats["mappings"] != expected:
|
|
raise SystemExit(
|
|
f"refusing to generate: {expected - stats['mappings']} reservation(s) "
|
|
f"missing -- every one must survive the cutover")
|
|
out += dhcp_lines
|
|
return out
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--priority", type=int, required=True,
|
|
help="VRRP priority: 200 for the master, 100 for the backup")
|
|
ap.add_argument("--inventory", default=os.path.join(HERE, "export", "inventory.json"))
|
|
ap.add_argument("--raw-networkconf", default=os.path.join(HERE, "export", "rest_networkconf.json"))
|
|
ap.add_argument("--with-wan", action="store_true",
|
|
help="configure the WAN on this box. Only ONE of the pair may have\n it, because the cloned WAN MAC must be unique.")
|
|
ap.add_argument("--conntrack-link", action="store_true",
|
|
help="emit conntrack-sync over the eth3 peer link. Requires the\n cable to be physically present on both boxes.")
|
|
ap.add_argument("-o", "--out")
|
|
ap.add_argument("--emit-secrets", metavar="PATH",
|
|
help="write the PPPoE credential to PATH with mode 0600 and exit")
|
|
args = ap.parse_args()
|
|
|
|
with open(args.inventory) as fh:
|
|
inv = json.load(fh)
|
|
with open(args.raw_networkconf) as fh:
|
|
raw_nets = json.load(fh)
|
|
|
|
wan = next((n for n in raw_nets
|
|
if n.get("purpose") == "wan" and n.get("wan_username")), None)
|
|
if wan is None:
|
|
print("no WAN network with credentials found in the export", file=sys.stderr)
|
|
return 1
|
|
|
|
if args.emit_secrets:
|
|
fd = os.open(args.emit_secrets, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "w") as fh:
|
|
fh.write(f"WAN_PASSWORD='{wan.get('x_wan_password', '')}'\n")
|
|
# Re-assert the mode in case the file already existed with a wider one.
|
|
os.chmod(args.emit_secrets, 0o600)
|
|
mode = oct(os.stat(args.emit_secrets).st_mode & 0o777)
|
|
print(f"wrote {args.emit_secrets} (mode {mode}) for user {wan['wan_username']}",
|
|
file=sys.stderr)
|
|
return 0
|
|
|
|
lines = build_delta(inv, args.priority, wan["wan_username"], args.with_wan,
|
|
args.conntrack_link)
|
|
text = "\n".join(lines) + "\n"
|
|
|
|
# Only a WAN-carrying delta has a credential to placeholder-substitute.
|
|
if args.with_wan and PLACEHOLDER not in text:
|
|
print("BUG: password placeholder missing from a WAN delta", file=sys.stderr)
|
|
return 1
|
|
if wan.get("x_wan_password") and wan["x_wan_password"] in text:
|
|
print("BUG: the WAN password leaked into the delta", file=sys.stderr)
|
|
return 1
|
|
|
|
n_set = sum(1 for l in lines if l.startswith("set "))
|
|
n_del = sum(1 for l in lines if l.startswith("delete "))
|
|
print(f"delta: {n_set} set, {n_del} delete, priority {args.priority}, "
|
|
f"{len(inv['reservations'])} reservations", file=sys.stderr)
|
|
|
|
if args.out:
|
|
with open(args.out, "w") as fh:
|
|
fh.write(text)
|
|
print(f"wrote {args.out}", file=sys.stderr)
|
|
else:
|
|
sys.stdout.write(text)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|