router: optional Wi-Fi access point, enforce allowWan

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).
This commit is contained in:
2026-09-09 09:24:34 +00:00
parent a3705e7a93
commit 63e8b6252c
7 changed files with 474 additions and 9 deletions
+128
View File
@@ -106,6 +106,94 @@ let
};
};
};
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 = {
@@ -225,6 +313,46 @@ in
};
};
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";