networkd enslaves the radio to br0 once, at link init. On real hardware (GL-MT6000, mt798x-wmac) that happens while the netdev is still in station mode, so the kernel rejects the bridge join (IFF_DONT_BRIDGE), the link is parked in networkd's failed state and never retried: link_reconfigure_impl() is a no-op while the matching .network file is unchanged, and the udev 'iw set type __ap' hook meant to pre-empt this loses the race. Wireless clients associate but their DHCP never reaches vlan-lan. Match on WLANInterfaceType=ap as well as the name. The station-mode netdev then matches nothing (unmanaged) and the file first matches when hostapd has switched the radio to AP and raised carrier, so the enslave succeeds on the first try. Drop the udev hook. Verified with checks.x86_64-linux.router (hwsim STA lease over the bridge).
201 lines
6.5 KiB
Nix
201 lines
6.5 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.";
|
|
}
|
|
];
|
|
|
|
# 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
|
|
);
|
|
};
|
|
}
|