From fcd8e55024eee2e0bb5ecfbca2b151364627824b Mon Sep 17 00:00:00 2001 From: Berwn Date: Fri, 31 Jul 2026 12:57:34 +0700 Subject: [PATCH] Add untagged access ports and a staging uplink for gw-cnx-1 New cnx.router.accessPorts option pins a port untagged to one VLAN via bridge PVID/EgressUntagged; convention is the last copper port as an always-available mgmt recovery port. gw-cnx-1 port roles: enp1s0 WAN, enp2s0 trunk, enp3s0 temporary DHCP uplink into the old OPNsense LAN (back to trunk at cutover), enp4s0 untagged mgmt. --- docs/src/gateways.md | 2 +- machines/gw-cnx-1/configuration.nix | 17 ++++++++++++-- modules/router/default.nix | 36 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/src/gateways.md b/docs/src/gateways.md index 8a2c591..b435fc1 100644 --- a/docs/src/gateways.md +++ b/docs/src/gateways.md @@ -13,7 +13,7 @@ Naming: `gw--`, e.g. `gw-cnx-1`. | Function | Implementation | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | WAN | PPPoE (`pppd`), per-site ISP credentials via clan vars prompts; `wan.vlanId` when the ISP tags the session (AIS: 10); `wan.macAddress` to clone the old router's MAC if the ISP has it pinned | -| LAN | VLAN-filtering bridge `br0` over the trunk ports (networkd) | +| LAN | VLAN-filtering bridge `br0` over the trunk ports (networkd); `accessPorts` pin a port untagged to one VLAN — convention: the last copper port is an untagged `mgmt` recovery port | | Firewall/NAT | nftables: default-deny WAN, no inter-VLAN, MSS clamp, v4 NAT | | DHCP | Kea, one subnet per VLAN | | DNS | Blocky (blocklist resolver), metrics on :4000 scraped by control | diff --git a/machines/gw-cnx-1/configuration.nix b/machines/gw-cnx-1/configuration.nix index 849aead..4320c31 100644 --- a/machines/gw-cnx-1/configuration.nix +++ b/machines/gw-cnx-1/configuration.nix @@ -28,9 +28,11 @@ wan.vlanId = null; # this ISP runs PPPoE untagged on the port trunkPorts = [ "enp2s0" - "enp3s0" - "enp4s0" + # "enp3s0" # STAGING: serves as the uplink below until cutover ]; + # Dedicated on-site recovery port: untagged mgmt, always available even + # if the switch config is broken. + accessPorts.enp4s0 = "mgmt"; # Replaces the newedge.house OPNsense box; renumbered to the fleet # convention (10.1..0/24, router .1, pool .100-.199). The old # untagged LAN becomes tagged mgmt — infra switch ports get PVID 10. @@ -73,6 +75,17 @@ }; }; + # STAGING (remove at cutover, and restore enp3s0 to trunkPorts): DHCP-client + # uplink into the existing OPNsense LAN so the box has internet + mesh while + # it runs alongside the old router. Default-deny firewall on this interface + # (it's in no VLAN zone); PPPoE simply retries until the WAN port is cabled. + # Do NOT connect the trunk ports to the production switch while staging — + # Kea on tag 10 would fight the OPNsense LAN DHCP in one broadcast domain. + systemd.network.networks."05-staging" = { + matchConfig.Name = "enp3s0"; + networkConfig.DHCP = "ipv4"; + }; + time.timeZone = "Etc/GMT-7"; # UTC+7 (Thailand, fixed offset, no DST) services.chrony.enable = true; } diff --git a/modules/router/default.nix b/modules/router/default.nix index c6e722a..89a3bc0 100644 --- a/modules/router/default.nix +++ b/modules/router/default.nix @@ -160,6 +160,19 @@ in description = "LAN ports carrying all VLANs tagged (incl. any 10G SFP+ ports)."; }; + accessPorts = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + enp4s0 = "mgmt"; + }; + description = '' + Ports acting as untagged access ports on a single VLAN (port name -> + VLAN name). Frames are untagged on the wire; the bridge tags them with + the VLAN's PVID. Use for an always-available on-site mgmt port. + ''; + }; + vlans = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule vlanModule); description = "VLANs served at this site; `mgmt` and `lan` are mandatory."; @@ -172,6 +185,14 @@ in assertion = cfg.vlans ? mgmt && cfg.vlans ? lan; message = "cnx.router: every site must define the `mgmt` and `lan` VLANs."; } + { + assertion = lib.all (v: cfg.vlans ? ${v}) (lib.attrValues cfg.accessPorts); + message = "cnx.router: every accessPorts value must name a defined VLAN."; + } + { + assertion = lib.all (p: !(cfg.accessPorts ? ${p})) cfg.trunkPorts; + message = "cnx.router: a port cannot be both a trunk and an access port."; + } ]; # Router diagnostics toolkit: packets (tcpdump), path (mtr), link @@ -270,6 +291,21 @@ in }; }) cfg.trunkPorts ) + // lib.mapAttrs' ( + port: vlanName: + lib.nameValuePair "25-access-${port}" { + matchConfig.Name = port; + networkConfig.Bridge = "br0"; + bridgeVLANs = [ + { + VLAN = cfg.vlans.${vlanName}.id; + PVID = cfg.vlans.${vlanName}.id; + EgressUntagged = cfg.vlans.${vlanName}.id; + } + ]; + linkConfig.RequiredForOnline = "no"; + } + ) cfg.accessPorts // lib.mapAttrs' ( name: vlan: lib.nameValuePair "40-${vlanIf name}" {