# 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..0.0/16. A VLAN's # subnet defaults to 10...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..128.0/17), e.g. guest -> 10..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...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...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...100"''; }; to = lib.mkOption { type = lib.types.str; default = "10.${site}.${octet}.199"; defaultText = lib.literalExpression ''"10...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 ""; 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--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...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 .. 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. with this gateway's TSIG key (acme_, secret from the shared dns-acme--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 ..."; }; 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."; }; }; }