cnx.router.proxy serves <service>.<site><n>.cnx.network with a real Let's Encrypt wildcard obtained via a gateway-scoped TSIG key against ns1; Blocky resolves the names to the router's LAN address, so they exist only internally. First user: Omada UI on gw-cnx-1 (omada.cnx1.cnx.network).
255 lines
7.8 KiB
Nix
255 lines
7.8 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,
|
|
...
|
|
}:
|
|
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
|
|
./proxy.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;
|
|
};
|
|
}
|