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.
326 lines
10 KiB
Nix
326 lines
10 KiB
Nix
# 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-<city>-<n>; everything is driven by cnx.router.*.
|
|
#
|
|
# Fleet addressing convention: each site owns 10.<siteId>.0.0/16. A VLAN's
|
|
# subnet defaults to 10.<siteId>.<vlanId>.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.<siteId>.128.0/17), e.g. guest -> 10.<siteId>.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.<siteId>.<vlan>.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).";
|
|
};
|
|
|
|
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.";
|
|
};
|
|
};
|
|
|
|
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.";
|
|
}
|
|
{
|
|
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
|
|
# 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' (
|
|
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}" {
|
|
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;
|
|
};
|
|
}
|