# Site gateway (OPNsense replacement) for the Topton 1U boxes: PPPoE WAN, # VLAN-filtering bridge over the LAN ports, and per-VLAN L3 interfaces. # Imported by machines/gw--; everything is driven by cnx.router.*. # # Fleet addressing convention: each site owns 10..0.0/16. A VLAN's # subnet defaults to 10...0/24 with the router at .1 and the # DHCP pool at .100-.199. VLANs that need more space (e.g. public-wifi guest) # override `subnet`/`address`/`dhcp.pool` and take a wider block from the # upper half (10..128.0/17), e.g. guest -> 10..128.0/22. # VLAN ids: 10 = mgmt, 20 = lan (mandatory); 30 = guest, 40 = iot (reserved). { config, lib, pkgs, ... }: let cfg = config.cnx.router; vlanIf = name: "vlan-${name}"; site = toString cfg.siteId; vlanModule = { name, config, ... }: let octet = toString config.id; in { options = { id = lib.mkOption { type = lib.types.ints.between 1 4094; description = "802.1Q VLAN id (fleet convention: 10 mgmt, 20 lan, 30 guest, 40 iot)."; }; address = lib.mkOption { type = lib.types.str; default = "10.${site}.${octet}.1"; description = "Router address on this VLAN."; }; prefixLength = lib.mkOption { type = lib.types.ints.between 8 30; default = 24; }; subnet = lib.mkOption { type = lib.types.str; default = "10.${site}.${octet}.0/24"; description = "The VLAN's network in CIDR form (must contain `address`)."; }; dhcp = { enable = lib.mkOption { type = lib.types.bool; default = true; }; pool = { from = lib.mkOption { type = lib.types.str; default = "10.${site}.${octet}.100"; }; to = lib.mkOption { type = lib.types.str; default = "10.${site}.${octet}.199"; }; }; leaseTime = lib.mkOption { type = lib.types.ints.positive; default = 86400; description = '' Lease validity in seconds. Lower it for high-churn networks, e.g. public-WiFi guest VLANs (3600-7200), so the pool recycles. ''; }; reservations = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { hwAddress = lib.mkOption { type = lib.types.str; example = "aa:bb:cc:dd:ee:ff"; description = "Client MAC address."; }; ipAddress = lib.mkOption { type = lib.types.str; description = "Fixed address handed to this client (inside the VLAN's subnet, outside the pool)."; }; }; } ); default = { }; description = "Static DHCP leases; the attribute name becomes the client's hostname."; }; }; allowWan = lib.mkOption { type = lib.types.bool; default = true; description = "Whether clients on this VLAN may reach the internet."; }; }; }; in { imports = [ ./pppoe.nix ./ipv6.nix ./firewall.nix ./dns-dhcp.nix ./crowdsec.nix ./omada.nix ./proxy.nix ./iperf.nix ./speedtest.nix ]; options.cnx.router = { enable = lib.mkEnableOption "site gateway (router) role"; site = lib.mkOption { type = lib.types.str; description = "City code of the site, e.g. \"cnx\"."; }; siteId = lib.mkOption { type = lib.types.ints.between 1 254; description = "Site number; drives the 10...0/24 addressing."; }; wan.interface = lib.mkOption { type = lib.types.str; description = "Physical WAN port the PPPoE session runs on."; }; wan.vlanId = lib.mkOption { type = lib.types.nullOr (lib.types.ints.between 1 4094); default = null; description = '' 802.1Q tag the ISP requires for the PPPoE session (AIS Thailand: 10); null for untagged PPPoE directly on the port. Unrelated to the LAN VLANs — this tag exists only on the WAN port. ''; }; wan.macAddress = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "aa:bb:cc:dd:ee:ff"; description = '' Spoofed MAC for the WAN port, e.g. to keep the MAC the ISP has pinned (cloned from the old router). null keeps the hardware MAC. ''; }; wan.pppInterface = lib.mkOption { type = lib.types.str; internal = true; readOnly = true; default = if cfg.wan.vlanId == null then cfg.wan.interface else "wan-vlan"; description = "Interface pppd dials on (the WAN port or its ISP VLAN)."; }; trunkPorts = lib.mkOption { type = lib.types.listOf lib.types.str; description = "LAN ports carrying all VLANs tagged (incl. any 10G SFP+ ports)."; }; vlans = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule vlanModule); description = "VLANs served at this site; `mgmt` and `lan` are mandatory."; }; }; config = lib.mkIf cfg.enable { assertions = [ { assertion = cfg.vlans ? mgmt && cfg.vlans ? lan; message = "cnx.router: every site must define the `mgmt` and `lan` VLANs."; } ]; # Router diagnostics toolkit: packets (tcpdump), path (mtr), link # negotiation (ethtool), NAT state (conntrack), DNS (kdig), per-flow # bandwidth (iftop), WAN throughput (librespeed-cli; iperf3 covers LAN). environment.systemPackages = with pkgs; [ tcpdump mtr ethtool conntrack-tools knot-dns iftop librespeed-cli ]; networking.useNetworkd = true; networking.useDHCP = false; systemd.network.enable = true; systemd.network.netdevs = { "20-br0" = { netdevConfig = { Name = "br0"; Kind = "bridge"; }; bridgeConfig.VLANFiltering = true; }; } // lib.optionalAttrs (cfg.wan.vlanId != null) { "15-wan-vlan" = { netdevConfig = { Name = "wan-vlan"; Kind = "vlan"; }; vlanConfig.Id = cfg.wan.vlanId; }; } // lib.mapAttrs' ( name: vlan: lib.nameValuePair "30-${vlanIf name}" { netdevConfig = { Name = vlanIf name; Kind = "vlan"; }; vlanConfig.Id = vlan.id; } ) cfg.vlans; systemd.network.networks = let taggedAll = lib.mapAttrsToList (_: vlan: { VLAN = vlan.id; }) cfg.vlans; in { # WAN port carries only the PPPoE session; no IP config of its own. "10-wan" = { matchConfig.Name = cfg.wan.interface; networkConfig.LinkLocalAddressing = "no"; vlan = lib.optional (cfg.wan.vlanId != null) "wan-vlan"; linkConfig = { RequiredForOnline = "carrier"; } # The wan-vlan subinterface (and thus the PPPoE session) inherits # the parent port's MAC, so spoofing here covers both cases. // lib.optionalAttrs (cfg.wan.macAddress != null) { MACAddress = cfg.wan.macAddress; }; }; } // lib.optionalAttrs (cfg.wan.vlanId != null) { # The ISP-side VLAN subinterface pppd dials on (e.g. AIS tags PPPoE). "15-wan-vlan" = { matchConfig.Name = "wan-vlan"; networkConfig.LinkLocalAddressing = "no"; linkConfig.RequiredForOnline = "no"; }; } // { # The bridge itself is L2-only; L3 lives on the vlan-* interfaces, # which hang off the bridge (tagged on the bridge "self" port). "20-br0" = { matchConfig.Name = "br0"; networkConfig.LinkLocalAddressing = "no"; vlan = lib.mapAttrsToList (name: _: vlanIf name) cfg.vlans; bridgeVLANs = taggedAll; linkConfig.RequiredForOnline = "no"; }; } // lib.listToAttrs ( map (port: { name = "25-trunk-${port}"; value = { matchConfig.Name = port; networkConfig.Bridge = "br0"; bridgeVLANs = taggedAll; linkConfig.RequiredForOnline = "no"; }; }) cfg.trunkPorts ) // lib.mapAttrs' ( name: vlan: lib.nameValuePair "40-${vlanIf name}" { matchConfig.Name = vlanIf name; address = [ "${vlan.address}/${toString vlan.prefixLength}" ]; networkConfig = { IPv6AcceptRA = false; # Announce a /64 carved from the DHCPv6-PD prefix on ppp0 (SLAAC). IPv6SendRA = true; DHCPPrefixDelegation = true; }; dhcpPrefixDelegationConfig.SubnetId = "auto"; linkConfig.RequiredForOnline = "no"; } ) cfg.vlans; }; }