# 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 , -1, -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..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..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..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 ); }; }