Add site gateway role (modules/router) and gw-cnx-1
Reusable cnx.router.* module for the Topton 1U boxes replacing OPNsense: PPPoE WAN (optionally VLAN-tagged, AIS: 10, secret credentials incl. username), VLAN-filtering bridge, nftables NAT/firewall with MSS clamp, Kea DHCP with per-VLAN lease time, Blocky DNS, DHCPv6-PD, CrowdSec with the ZeroTier mesh whitelisted, optional Omada controller, ZFS disk. Fleet baseline rides along: admins sops group is now derived for every machine in clan.nix (secrets encrypt to it from the first vars generate) and time sync is chrony everywhere instead of systemd-timesyncd.
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# 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,
|
||||
...
|
||||
}:
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
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
|
||||
];
|
||||
|
||||
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).";
|
||||
};
|
||||
|
||||
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.";
|
||||
}
|
||||
];
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user