wifi.* settings turn the gateway's own radios into the site AP (hostapd): SSIDs are defined once in wifi.networks and act as untagged access ports of their VLAN (each BSS joins br0 with the VLAN's PVID), radios pick what they broadcast, passphrases are vars prompts. Extra SSIDs on a radio get BSSIDs derived from its hardware address. A udev rule puts configured radios in AP mode on appearance, since networkd cannot bridge a station-mode interface and gives up before hostapd switches it. wifi.enable gates all of it. The VM test grows a hwsim radio with two SSIDs and a WPA3 station in its own netns that must get a Kea lease on the SSID's VLAN. Making the client deterministic (route metrics, loose rp-filter, a guard against a vacuous negative check) exposed that allowWan was never enforced: networking.nat opens forward-to-WAN for all of its internalInterfaces, which listed every VLAN. It now lists only the allowWan VLANs; the duplicate custom rule is gone. No behavioural change on gw-cnx-1 (all its VLANs allow WAN).
363 lines
12 KiB
Nix
363 lines
12 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.
|
|
'';
|
|
};
|
|
|
|
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.";
|
|
};
|
|
};
|
|
}
|