Files
cnx-network-clan/modules/clan/router/interface.nix
2026-09-17 08:57:32 +07:00

379 lines
13 KiB
Nix

# Settings of the `router` service (inventory `roles.default.settings`).
# Pure schema: no machine config is reachable here; the implementation files
# get the evaluated result as `settings`.
#
# 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
site = toString config.siteId;
vlanModule =
{ 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";
defaultText = lib.literalExpression ''"10.<siteId>.<id>.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";
defaultText = lib.literalExpression ''"10.<siteId>.<id>.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";
defaultText = lib.literalExpression ''"10.<siteId>.<id>.100"'';
};
to = lib.mkOption {
type = lib.types.str;
default = "10.${site}.${octet}.199";
defaultText = lib.literalExpression ''"10.<siteId>.<id>.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.";
};
};
};
proxyServiceModule = {
options = {
backend = lib.mkOption {
type = lib.types.str;
example = "https://127.0.0.1:8043";
description = "URL Caddy forwards to (internal/mesh address).";
};
insecureSkipVerify = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Skip TLS verification towards the backend (self-signed upstreams like Omada).";
};
};
};
wifiNetworkModule =
{ name, ... }:
{
options = {
ssid = lib.mkOption {
type = lib.types.str;
default = name;
defaultText = lib.literalExpression "<attribute name>";
description = "SSID as advertised; defaults to the attribute name.";
};
vlan = lib.mkOption {
type = lib.types.str;
example = "lan";
description = "VLAN (by name) the clients of this SSID land in, like an untagged access port.";
};
security = lib.mkOption {
type = lib.types.enum [
"wpa3"
"wpa3-transition"
"wpa2"
"open"
];
default = "wpa3-transition";
description = ''
- `wpa3`: WPA3-Personal (SAE) only.
- `wpa3-transition`: WPA3 with WPA2 fallback for older clients.
- `wpa2`: WPA2-PSK only, for legacy IoT devices.
- `open`: no encryption (captive/guest use; pair with an isolated VLAN).
Except for `open`, the passphrase is a vars prompt
(`wifi-<name>-passphrase`, entered at `clan vars generate`).
'';
};
hidden = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Do not advertise the SSID in beacons (clients must know it).";
};
isolateClients = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Keep wireless clients of this SSID from talking to each other (guest networks).";
};
};
};
wifiRadioModule = {
options = {
band = lib.mkOption {
type = lib.types.enum [
"2g"
"5g"
"6g"
];
default = "2g";
description = "Frequency band of this radio; a dual-band card exposes one radio interface per band.";
};
channel = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "Channel; 0 lets hostapd pick one (ACS) — not every driver supports that.";
};
wifi6 = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable 802.11ax (WiFi 6) on this radio; WiFi 4/5 are always on.";
};
macAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "aa:bb:cc:dd:ee:ff";
description = ''
Hardware address of the radio (facter.json / `ip link`). Needed when
the radio serves more than one network: hostapd wants a fixed BSSID
per extra network, derived from this address (locally administered
variants of its first octet).
'';
};
networks = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = [
"home"
"iot"
];
description = "Networks (from `wifi.networks`) this radio broadcasts; at most four per radio.";
};
};
};
in
{
options = {
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.
'';
};
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.
'';
};
stagingPort = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "enp3s0";
description = ''
Temporary DHCPv4-client uplink into the existing LAN while the box
runs alongside the router it replaces: gives it internet + mesh
before the WAN port is cabled (PPPoE simply retries until then). The
port is in no VLAN zone; the firewall admits only SSH on it. Do NOT
connect the trunk ports to the production switch while staging —
Kea on the mgmt tag would fight the old router's DHCP in one
broadcast domain. Set to null at cutover (and usually hand the port
back to `trunkPorts`).
'';
};
vlans = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule vlanModule);
description = "VLANs served at this site; `mgmt` and `lan` are mandatory.";
};
mesh.subnet = lib.mkOption {
type = lib.types.str;
example = "fd12:3456:789a:bcde:f000::/88";
description = ''
IPv6 prefix of the admin mesh (the ZeroTier overlay; see
modules/mesh-hosts.nix). Admin SSH, metrics scrapes, iperf3 and the
Omada UI accept connections from it, and CrowdSec never bans it.
'';
};
omada.enable = lib.mkEnableOption "TP-Link Omada SDN controller (podman container)";
proxy = {
enable = lib.mkEnableOption "internal reverse proxy (Caddy, wildcard cert via DNS-01)";
domain = lib.mkOption {
type = lib.types.str;
example = "example.net";
description = ''
Parent zone of the proxy names: services are served as
<name>.<site><siteId>.<domain> under a wildcard certificate.
'';
};
acme = {
nameserver = lib.mkOption {
type = lib.types.str;
example = "203.0.113.53";
description = ''
Authoritative nameserver of `domain` that accepts RFC 2136
updates for _acme-challenge.<site><siteId> with this gateway's
TSIG key (acme_<hostname with _>, secret from the shared
dns-acme-<hostname>-secret generator, see acme-secret.nix).
'';
};
email = lib.mkOption {
type = lib.types.str;
example = "postmaster@example.net";
description = "ACME account contact.";
};
};
services = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule proxyServiceModule);
default = { };
description = "Proxied services; attr name becomes <name>.<site><siteId>.<domain>.";
};
allowVlans = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"mgmt"
"lan"
];
description = "VLANs whose clients may reach the proxy (443, plus 80 for the redirect).";
};
};
wifi = {
enable = lib.mkEnableOption "a Wi-Fi access point on the router's own radios (hostapd)";
countryCode = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "TH";
description = "ISO 3166-1 country code for the regulatory domain; required when enabled.";
};
networks = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule wifiNetworkModule);
default = { };
example = {
home.vlan = "lan";
things = {
vlan = "iot";
security = "wpa2";
};
};
description = ''
Wireless networks (SSIDs). Each one behaves like an untagged access
port on its VLAN; the radios below choose which to broadcast.
'';
};
radios = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule wifiRadioModule);
default = { };
example = {
wlp5s0 = {
band = "5g";
channel = 36;
networks = [ "home" ];
};
};
description = "Wireless radios of the router (interface name -> config); at least one when enabled.";
};
};
speedtest.interval = lib.mkOption {
type = lib.types.str;
default = "hourly";
description = "systemd OnCalendar spec for the WAN speed test.";
};
};
}