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
+206
View File
@@ -0,0 +1,206 @@
# Wireless access point on the router itself (hostapd). Every SSID is a BSS
# interface that joins the VLAN bridge as an untagged access port of its VLAN,
# so wireless clients get exactly the same DHCP/DNS/firewall treatment as a
# wired port in that VLAN. Passphrases are vars prompts, never in the store.
#
# hostapd names BSS interfaces <radio>, <radio>-1, <radio>-2 ... and wants a
# fixed BSSID for every extra one; they are derived from the radio's hardware
# address by setting the locally-administered bit and flipping bits 2-3 of
# the first octet per index (02 -> 06, 0a, 0e), which never collides with the
# radio's own address.
{ settings }:
{
config,
lib,
pkgs,
...
}:
let
cfg = settings;
wifi = cfg.wifi;
bssIf = radio: i: if i == 0 then radio else "${radio}-${toString i}";
hexDigit =
c:
{
"0" = 0;
"1" = 1;
"2" = 2;
"3" = 3;
"4" = 4;
"5" = 5;
"6" = 6;
"7" = 7;
"8" = 8;
"9" = 9;
a = 10;
b = 11;
c = 12;
d = 13;
e = 14;
f = 15;
}
.${lib.toLower c};
octetToInt = s: 16 * hexDigit (builtins.substring 0 1 s) + hexDigit (builtins.substring 1 1 s);
intToOctet = n: lib.toLower (lib.fixedWidthString 2 "0" (lib.toHexString n));
deriveBssid =
mac: i:
let
octets = lib.splitString ":" mac;
first = builtins.bitXor (builtins.bitOr (octetToInt (builtins.head octets)) 2) (i * 4);
in
lib.concatStringsSep ":" ([ (intToOctet first) ] ++ builtins.tail octets);
# Every BSS of every radio, flattened.
bsses = lib.concatLists (
lib.mapAttrsToList (
radio: r:
lib.imap0 (i: name: {
inherit radio name;
index = i;
iface = bssIf radio i;
net = wifi.networks.${name} or null;
}) r.networks
) wifi.radios
);
referenced = lib.unique (map (b: b.name) bsses);
secured = lib.filter (name: wifi.networks.${name}.security != "open") (
lib.filter (name: wifi.networks ? ${name}) referenced
);
passphraseFile =
name: config.clan.core.vars.generators."wifi-${name}-passphrase".files.passphrase.path;
authentication =
name: net:
{
wpa3 = {
mode = "wpa3-sae";
saePasswordsFile = passphraseFile name;
};
wpa3-transition = {
mode = "wpa3-sae-transition";
saePasswordsFile = passphraseFile name;
wpaPasswordFile = passphraseFile name;
};
wpa2 = {
mode = "wpa2-sha1";
wpaPasswordFile = passphraseFile name;
};
open.mode = "none";
}
.${net.security};
in
{
config = lib.mkIf wifi.enable {
assertions = [
{
assertion = wifi.radios != { };
message = "router: wifi.enable needs at least one radio in wifi.radios.";
}
{
assertion = wifi.countryCode != null;
message = "router: wifi.countryCode is required when wifi.enable is set (regulatory domain).";
}
{
assertion = lib.all (b: b.net != null) bsses;
message = "router: every wifi.radios.<radio>.networks entry must name a network in wifi.networks.";
}
{
assertion = lib.all (b: b.net == null || cfg.vlans ? ${b.net.vlan}) bsses;
message = "router: every wifi.networks.<name>.vlan must name a VLAN in vlans.";
}
{
assertion = lib.all (r: lib.length r.networks <= 4) (lib.attrValues wifi.radios);
message = "router: a radio can broadcast at most four networks.";
}
{
assertion = lib.all (r: lib.length r.networks <= 1 || r.macAddress != null) (
lib.attrValues wifi.radios
);
message = "router: wifi.radios.<radio>.macAddress is required for radios broadcasting more than one network.";
}
];
# Regulatory database for the kernel, so countryCode actually applies.
hardware.wirelessRegulatoryDatabase = true;
# The kernel refuses to bridge a wireless interface in station mode, and
# networkd stops retrying before hostapd switches the radio to AP mode;
# so put it in AP mode the moment it appears (kernel name or the renamed
# one, whichever the user configured). hostapd finds it already there.
services.udev.extraRules = lib.concatMapStrings (
radio:
let
run = ''RUN+="${pkgs.iw}/bin/iw dev ${radio} set type __ap"'';
in
''
ACTION=="add", SUBSYSTEM=="net", KERNEL=="${radio}", ${run}
ACTION=="add", SUBSYSTEM=="net", NAME=="${radio}", ${run}
''
) (lib.attrNames wifi.radios);
clan.core.vars.generators = lib.genAttrs (map (name: "wifi-${name}-passphrase") secured) (
gen:
let
name = lib.removeSuffix "-passphrase" (lib.removePrefix "wifi-" gen);
in
{
prompts.passphrase = {
description = "Wi-Fi passphrase for SSID \"${wifi.networks.${name}.ssid}\" (8-63 ASCII characters)";
type = "hidden";
};
files.passphrase.secret = true;
# No trailing newline: hostapd turns every line of the file into a
# sae_password entry, and an empty one wipes the list.
script = ''printf '%s' "$(cat "$prompts"/passphrase)" > "$out"/passphrase'';
}
);
services.hostapd = {
enable = true;
radios = lib.mapAttrs (radio: r: {
inherit (r) band channel;
inherit (wifi) countryCode;
wifi6.enable = r.wifi6;
networks = lib.listToAttrs (
map (
b:
lib.nameValuePair b.iface (
{
inherit (b.net) ssid;
ignoreBroadcastSsid = if b.net.hidden then "empty" else "disabled";
apIsolate = b.net.isolateClients;
authentication = authentication b.name b.net;
}
// lib.optionalAttrs (lib.length r.networks > 1) {
bssid = if b.index == 0 then r.macAddress else deriveBssid r.macAddress b.index;
}
)
) (lib.filter (b: b.radio == radio) bsses)
);
}) wifi.radios;
};
# Each BSS is an untagged access port of its VLAN on br0 (cf. accessPorts
# in network.nix); networkd enslaves the interface once hostapd creates it.
systemd.network.networks = lib.listToAttrs (
map (
b:
lib.nameValuePair "27-wifi-${b.iface}" {
matchConfig.Name = b.iface;
networkConfig.Bridge = "br0";
bridgeVLANs = [
{
VLAN = cfg.vlans.${b.net.vlan}.id;
PVID = cfg.vlans.${b.net.vlan}.id;
EgressUntagged = cfg.vlans.${b.net.vlan}.id;
}
];
linkConfig.RequiredForOnline = "no";
}
) bsses
);
};
}