379 lines
13 KiB
Nix
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.";
|
|
};
|
|
};
|
|
}
|