"No NAT? How are we supposed to get internet?" -- a fair question that exposed a
worse design than I had admitted. Internet did work, but only via vyos001: NAT
and the entire WAN were gated behind --with-wan, so vyos002 would have held the
LAN VIPs and routed between VLANs with no path to the outside at all. Failover
would have preserved addressing and lost the internet.
The fix rests on a checked fact rather than an assumption: VyOS WARNS but still
commits when a NAT rule names an interface that does not exist
("Interface bond0.53 for source NAT rule 900 does not exist!"). Verified on a
real VyOS before relying on it.
So both boxes now get the identical WAN, NAT, port-forward and firewall config,
and the backup's two WAN interfaces are simply set `disable`. The cloned WAN MAC
is therefore never live on two boxes at once, while everything needed to route
and masquerade is already in place. The two deltas are now byte-identical apart
from VRRP priority, own/peer addresses, DHCP HA role, the conntrack /30 -- and
the two disable lines.
Taking over the internet path becomes deleting two lines rather than
reconstructing NAT under pressure:
delete interfaces bonding bond0 vif 53 disable
delete interfaces pppoe pppoe0 disable
Both boxes now: 21 NAT rules, 58 firewall rules, full PPPoE. Backup delta
validated against a real VyOS config with the disable lines present -- commits
clean. Runbook updated with the takeover procedure and the warning that it must
only be done when vyos001 is genuinely down.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
447 lines
24 KiB
Python
Executable File
447 lines
24 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.
|
|
DIST_DHCP, DIST_PPPOE = 1, 10
|
|
|
|
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",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} dhcp-options default-route-distance {DIST_DHCP}",
|
|
"",
|
|
"# 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",
|
|
"",
|
|
"# --- 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:
|
|
# The backup carries the identical WAN and NAT config but with the
|
|
# interfaces administratively DOWN. The cloned MAC is therefore never
|
|
# live on two boxes at once, while everything needed to route and
|
|
# masquerade is already present -- taking over is enabling two
|
|
# interfaces, not rebuilding a config under pressure.
|
|
#
|
|
# NAT rules naming a down interface are harmless: VyOS warns at commit
|
|
# ("Interface ... does not exist!") and commits anyway, verified.
|
|
out += [
|
|
"",
|
|
"# --- WAN held DOWN on this box -----------------------------",
|
|
"# Enable these two to take over the internet path:",
|
|
f"# set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} disable <- delete this",
|
|
f"# set interfaces pppoe {WAN_PPPOE_IF} disable <- and this",
|
|
f"set interfaces bonding bond0 vif {WAN_DHCP_VIF.split('.')[1]} disable",
|
|
f"set interfaces pppoe {WAN_PPPOE_IF} 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())
|