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.
208 lines
6.8 KiB
Nix
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
|
|
);
|
|
};
|
|
}
|