268 lines
12 KiB
Python
268 lines
12 KiB
Python
|
|
#!/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)
|
||
|
|
|
||
|
|
WAN_VIF = "bond0.51" # WAN arrives trunked as vlan-only network 51
|
||
|
|
WAN_IF = "pppoe0"
|
||
|
|
PLACEHOLDER = "@@WAN_PASSWORD@@"
|
||
|
|
|
||
|
|
|
||
|
|
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) -> list[str]:
|
||
|
|
out: list[str] = []
|
||
|
|
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}")
|
||
|
|
|
||
|
|
out += [
|
||
|
|
"",
|
||
|
|
"# --- WAN ---------------------------------------------------",
|
||
|
|
"# Vodafone line, PPPoE over the trunked WAN VLAN. This is the one",
|
||
|
|
"# part that cannot be tested before cutover: the line permits a",
|
||
|
|
"# single session and the USG holds it until it is unplugged.",
|
||
|
|
f"set interfaces pppoe {WAN_IF} source-interface {WAN_VIF}",
|
||
|
|
f"set interfaces pppoe {WAN_IF} authentication username '{wan_user}'",
|
||
|
|
f"set interfaces pppoe {WAN_IF} authentication password '{PLACEHOLDER}'",
|
||
|
|
f"set interfaces pppoe {WAN_IF} mtu 1492",
|
||
|
|
# The peer's resolvers would otherwise overwrite /etc/resolv.conf and
|
||
|
|
# cost the box its ability to resolve internal ad.itaz.eu names.
|
||
|
|
f"set interfaces pppoe {WAN_IF} no-peer-dns",
|
||
|
|
"",
|
||
|
|
"# The static default route exists only for unifi mode, where the USG",
|
||
|
|
"# is the next hop. PPPoE supplies the default here; leaving both at",
|
||
|
|
"# the same distance would be ambiguous.",
|
||
|
|
"delete protocols static route 0.0.0.0/0",
|
||
|
|
"",
|
||
|
|
"# --- NAT ---------------------------------------------------",
|
||
|
|
f"set nat source rule 100 outbound-interface name {WAN_IF}",
|
||
|
|
"set nat source rule 100 translation address masquerade",
|
||
|
|
"set nat source rule 100 description 'LAN out to the internet'",
|
||
|
|
]
|
||
|
|
|
||
|
|
# 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_IF}",
|
||
|
|
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']}",
|
||
|
|
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_IF}",
|
||
|
|
"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_IF}",
|
||
|
|
"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_IF}",
|
||
|
|
"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_IF}",
|
||
|
|
"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_IF}",
|
||
|
|
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_IF}",
|
||
|
|
"set firewall ipv4 forward filter rule 900 description 'drop unsolicited WAN-to-LAN'",
|
||
|
|
"",
|
||
|
|
]
|
||
|
|
|
||
|
|
# 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("-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"])
|
||
|
|
text = "\n".join(lines) + "\n"
|
||
|
|
|
||
|
|
if PLACEHOLDER not in text:
|
||
|
|
print("BUG: password placeholder missing from the 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())
|