Files
cnx-network-clan/modules/clan/router/wifi.nix
T
kurogeek f98226bc6e router/wifi: assert no supplicant/NetworkManager owns the radios
With the AP-typed match, the station-mode netdev is unmanaged until
hostapd flips it, so any wpa_supplicant/iwd/NetworkManager in the
importing config would grab the radio first and keep resetting it to
managed mode under hostapd. Fail at eval instead of at runtime.
2026-09-18 09:36:07 +00:00

208 lines
6.8 KiB
Nix

# 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,
...
}:
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.";
}
{
assertion =
!config.networking.wireless.enable
&& !config.networking.wireless.iwd.enable
&& !config.networking.networkmanager.enable;
message = "router: wifi.enable needs the radios for hostapd; disable networking.wireless (wpa_supplicant), iwd and NetworkManager.";
}
];
# Regulatory database for the kernel, so countryCode actually applies.
hardware.wirelessRegulatoryDatabase = true;
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). The kernel refuses to bridge a wireless interface in
# station mode (IFF_DONT_BRIDGE), and a failed enslave parks the link in
# networkd's `failed` state for good: networkd only re-evaluates a link
# when its matching .network file changes. So match on the AP interface
# type as well as the name: the radio's initial station-mode netdev
# matches nothing (unmanaged), and once hostapd switches it to AP and
# brings the carrier up, networkd matches this file for the first time
# and enslaves it. Extra BSSes are created by hostapd in AP mode already.
systemd.network.networks = lib.listToAttrs (
map (
b:
lib.nameValuePair "27-wifi-${b.iface}" {
matchConfig = {
Name = b.iface;
WLANInterfaceType = "ap";
};
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
);
};
}