From 1fa9f03eecdbe55248fd5971b66f71c0104115fe Mon Sep 17 00:00:00 2001 From: oddlama Date: Wed, 22 Mar 2023 00:49:08 +0100 Subject: [PATCH] nixos/hostapd: rewrite to support multi-AP, password from file, and more At this point this is basically a full rewrite of this module, which is a breaking change and was necessary to properly expose the useful parts of hostapd's config. The notable changes are: - `hostapd` is now started with additional systemd sandbox/hardening options - A single-daemon can now manage multiple distinct radios and BSSs, which is why all configuration had to be moved into `hostapd.radios` - By default WPA3-SAE will be used, but WPA2 and WPA3-SAE-TRANSITION are supported, too - Added passwordFile-like options for wpa and sae - Add new relevant options for MAC ACL, WiFi5, WiFi6 and WiFi7 configuration - Implements RFC42 as far as reasonable for hostapd - Removes `with lib;` --- .../manual/release-notes/rl-2311.section.md | 10 +- nixos/modules/services/networking/hostapd.nix | 1417 +++++++++++++++-- nixos/tests/wpa_supplicant.nix | 235 ++- pkgs/os-specific/linux/hostapd/default.nix | 5 - 4 files changed, 1422 insertions(+), 245 deletions(-) diff --git a/nixos/doc/manual/release-notes/rl-2311.section.md b/nixos/doc/manual/release-notes/rl-2311.section.md index 51c3b871f1e2..538fd3efcbd7 100644 --- a/nixos/doc/manual/release-notes/rl-2311.section.md +++ b/nixos/doc/manual/release-notes/rl-2311.section.md @@ -4,6 +4,8 @@ - FoundationDB now defaults to major version 7. +- Support for WiFi6 (IEEE 802.11ax) and WPA3-SAE-PK was enabled in the `hostapd` package, along with a significant rework of the hostapd module. + ## New Services {#sec-release-23.11-new-services} - Create the first release note entry in this section! @@ -22,8 +24,6 @@ - [Apache Guacamole](https://guacamole.apache.org/), a cross-platform, clientless remote desktop gateway. Available as [services.guacamole-server](#opt-services.guacamole-server.enable) and [services.guacamole-client](#opt-services.guacamole-client.enable) services. -- Support for WiFi6 (IEEE 802.11ax) and WPA3-SAE-PK was enabled in the `hostapd` package. - ## Backward Incompatibilities {#sec-release-23.11-incompatibilities} - `python3.pkgs.sequoia` was removed in favor of `python3.pkgs.pysequoia`. The latter package is based on upstream's dedicated repository for sequoia's Python bindings, where the Python bindings from [gitlab:sequoia-pgp/sequoia](https://gitlab.com/sequoia-pgp/sequoia) were removed long ago. @@ -32,6 +32,12 @@ - The latest version of `clonehero` now stores custom content in `~/.clonehero`. See the [migration instructions](https://clonehero.net/2022/11/29/v23-to-v1-migration-instructions.html). Typically, these content files would exist along side the binary, but the previous build used a wrapper script that would store them in `~/.config/unity3d/srylain Inc_/Clone Hero`. +- The `services.hostapd` module was rewritten to support `passwordFile` like options, WPA3-SAE, and management of multiple interfaces. This breaks compatibility with older configurations. + - `hostapd` is now started with additional systemd sandbox/hardening options for better security. + - `services.hostapd.interface` was replaced with a per-radio and per-bss configuration scheme using [services.hostapd.radios](#opt-services.hostapd.radios). + - `services.hostapd.wpa` has been replaced by [services.hostapd.radios.<name>.networks.<name>.authentication.wpaPassword](#opt-services.hostapd.radios._name_.networks._name_.authentication.wpaPassword) and [services.hostapd.radios.<name>.networks.<name>.authentication.saePasswords](#opt-services.hostapd.radios._name_.networks._name_.authentication.saePasswords) which configure WPA2-PSK and WP3-SAE respectively. + - The default authentication has been changed to WPA3-SAE. Options for other (legacy) schemes are still available. + - `python3.pkgs.fetchPypi` (and `python3Packages.fetchPypi`) has been deprecated in favor of top-level `fetchPypi`. - `mariadb` now defaults to `mariadb_1011` instead of `mariadb_106`, meaning the default version was upgraded from 10.6.x to 10.11.x. See the [upgrade notes](https://mariadb.com/kb/en/upgrading-from-mariadb-10-6-to-mariadb-10-11/) for potential issues. diff --git a/nixos/modules/services/networking/hostapd.nix b/nixos/modules/services/networking/hostapd.nix index ecc158f8151d..45e32dc3b31a 100644 --- a/nixos/modules/services/networking/hostapd.nix +++ b/nixos/modules/services/networking/hostapd.nix @@ -1,222 +1,1285 @@ { config, lib, pkgs, utils, ... }: - -# TODO: -# -# asserts -# ensure that the nl80211 module is loaded/compiled in the kernel -# wpa_supplicant and hostapd on the same wireless interface doesn't make any sense - -with lib; - +# All hope abandon ye who enter here. hostapd's configuration +# format is ... special, and you won't be able to infer any +# of their assumptions from just reading the "documentation" +# (i.e. the example config). Assume footguns at all points - +# to make informed decisions you will probably need to look +# at hostapd's code. You have been warned, proceed with care. let + inherit + (lib) + attrNames + attrValues + concatLists + concatMap + concatMapStrings + concatStringsSep + count + escapeShellArg + filter + flip + generators + getAttr + hasPrefix + imap0 + isInt + isString + length + literalExpression + maintainers + mapAttrsToList + mdDoc + mkDefault + mkEnableOption + mkIf + mkOption + mkPackageOption + mkRemovedOptionModule + optional + optionalAttrs + optionalString + optionals + singleton + stringLength + toLower + types + unique + ; cfg = config.services.hostapd; - escapedInterface = utils.escapeSystemdPath cfg.interface; + extraSettingsFormat = { + type = let + singleAtom = types.oneOf [ types.bool types.int types.str ]; + atom = types.either singleAtom (types.listOf singleAtom) // { + description = "atom (bool, int or string) or a list of them for duplicate keys"; + }; + in types.attrsOf atom; - configFile = pkgs.writeText "hostapd.conf" '' - interface=${cfg.interface} - driver=${cfg.driver} - ssid=${cfg.ssid} - hw_mode=${cfg.hwMode} - channel=${toString cfg.channel} - ieee80211n=1 - ieee80211ac=1 - ${optionalString (cfg.countryCode != null) "country_code=${cfg.countryCode}"} - ${optionalString (cfg.countryCode != null) "ieee80211d=1"} + generate = name: value: pkgs.writeText name (generators.toKeyValue { + listsAsDuplicateKeys = true; + mkKeyValue = generators.mkKeyValueDefault { + mkValueString = v: + if isInt v then toString v + else if isString v then v + else if true == v then "1" + else if false == v then "0" + else throw "unsupported type ${builtins.typeOf v}: ${(generators.toPretty {}) v}"; + } "="; + } value); + }; - # logging (debug level) - logger_syslog=-1 - logger_syslog_level=${toString cfg.logLevel} - logger_stdout=-1 - logger_stdout_level=${toString cfg.logLevel} + # Generates the header for a single BSS (i.e. WiFi network) + writeBssHeader = radio: bss: bssIdx: pkgs.writeText "hostapd-radio-${radio}-bss-${bss}.conf" '' + ''\n''\n# BSS ${toString bssIdx}: ${bss} + ################################ - ctrl_interface=/run/hostapd - ctrl_interface_group=${cfg.group} + ${if bssIdx == 0 then "interface" else "bss"}=${bss} + ''; - ${optionalString cfg.wpa '' - wpa=2 - wpa_pairwise=CCMP - wpa_passphrase=${cfg.wpaPassphrase} - ''} - ${optionalString cfg.noScan "noscan=1"} + makeRadioRuntimeFiles = radio: radioCfg: + pkgs.writeShellScript "make-hostapd-${radio}-files" ('' + set -euo pipefail - ${cfg.extraConfig} - '' ; + hostapd_config_file=/run/hostapd/${escapeShellArg radio}.hostapd.conf + rm -f "$hostapd_config_file" + cat > "$hostapd_config_file" <> "$hostapd_config_file" + ${concatMapStrings (script: "${script} \"$hostapd_config_file\"\n") (attrValues radioCfg.dynamicConfigScripts)} + '' + + concatMapStrings (x: "${x}\n") (imap0 (i: f: f i) + (mapAttrsToList (bss: bssCfg: bssIdx: '' + ''\n# BSS configuration: ${bss} + + mac_allow_file=/run/hostapd/${escapeShellArg bss}.mac.allow + rm -f "$mac_allow_file" + touch "$mac_allow_file" + + mac_deny_file=/run/hostapd/${escapeShellArg bss}.mac.deny + rm -f "$mac_deny_file" + touch "$mac_deny_file" + + cat ${writeBssHeader radio bss bssIdx} >> "$hostapd_config_file" + cat ${escapeShellArg (extraSettingsFormat.generate "hostapd-radio-${radio}-bss-${bss}-extra.conf" bssCfg.settings)} >> "$hostapd_config_file" + ${concatMapStrings (script: "${script} \"$hostapd_config_file\" \"$mac_allow_file\" \"$mac_deny_file\"\n") (attrValues bssCfg.dynamicConfigScripts)} + '') radioCfg.networks))); + + runtimeConfigFiles = mapAttrsToList (radio: _: "/run/hostapd/${radio}.hostapd.conf") cfg.radios; +in { + meta.maintainers = with maintainers; [ oddlama ]; options = { - services.hostapd = { + enable = mkEnableOption (mdDoc '' + Whether to enable hostapd. hostapd is a user space daemon for access point and + authentication servers. It implements IEEE 802.11 access point management, + IEEE 802.1X/WPA/WPA2/EAP Authenticators, RADIUS client, EAP server, and RADIUS + authentication server. + ''); - enable = mkOption { - type = types.bool; - default = false; - description = lib.mdDoc '' - Enable putting a wireless interface into infrastructure mode, - allowing other wireless devices to associate with the wireless - interface and do wireless networking. A simple access point will - {option}`enable hostapd.wpa`, - {option}`hostapd.wpaPassphrase`, and - {option}`hostapd.ssid`, as well as DHCP on the wireless - interface to provide IP addresses to the associated stations, and - NAT (from the wireless interface to an upstream interface). + package = mkPackageOption pkgs "hostapd" {}; + + radios = mkOption { + default = {}; + example = literalExpression '' + { + # Simple 2.4GHz AP + wlp2s0 = { + # countryCode = "US"; + networks.wlp2s0 = { + ssid = "AP 1"; + authentication.saePasswords = [{ password = "a flakey password"; }]; # Use saePasswordsFile if possible. + }; + }; + + # WiFi 5 (5GHz) with two advertised networks + wlp3s0 = { + band = "5g"; + channel = 0; # Enable automatic channel selection (ACS). Use only if your hardware supports it. + # countryCode = "US"; + networks.wlp3s0 = { + ssid = "My AP"; + authentication.saePasswords = [{ password = "a flakey password"; }]; # Use saePasswordsFile if possible. + }; + networks.wlp3s0-1 = { + ssid = "Open AP with WiFi5"; + authentication.mode = "none"; + }; + }; + + # Legacy WPA2 example + wlp4s0 = { + # countryCode = "US"; + networks.wlp4s0 = { + ssid = "AP 2"; + authentication = { + mode = "wpa2-sha256"; + wpaPassword = "a flakey password"; # Use wpaPasswordFile if possible. + }; + }; + }; + } ''; - }; + description = mdDoc '' + This option allows you to define APs for one or multiple physical radios. + At least one radio must be specified. - interface = mkOption { - example = "wlp2s0"; - type = types.str; - description = lib.mdDoc '' - The interfaces {command}`hostapd` will use. + For each radio, hostapd requires a separate logical interface (like wlp3s0, wlp3s1, ...). + A default interface is usually be created automatically by your system, but to use + multiple radios of a single device, it may be required to create additional logical interfaces + for example by using {option}`networking.wlanInterfaces`. + + Each physical radio can only support a single hardware-mode that is configured via + ({option}`services.hostapd.radios..band`). To create a dual-band + or tri-band AP, you will have to use a device that has multiple physical radios + and supports configuring multiple APs (Refer to valid interface combinations in + {command}`iw list`). ''; - }; + type = types.attrsOf (types.submodule (radioSubmod: { + options = { + driver = mkOption { + default = "nl80211"; + example = "none"; + type = types.str; + description = mdDoc '' + The driver {command}`hostapd` will use. + {var}`nl80211` is used with all Linux mac80211 drivers. + {var}`none` is used if building a standalone RADIUS server that does + not control any wireless/wired driver. + Most applications will probably use the default. + ''; + }; - noScan = mkOption { - type = types.bool; - default = false; - description = lib.mdDoc '' - Do not scan for overlapping BSSs in HT40+/- mode. - Caution: turning this on will violate regulatory requirements! - ''; - }; + noScan = mkOption { + type = types.bool; + default = false; + description = mdDoc '' + Disables scan for overlapping BSSs in HT40+/- mode. + Caution: turning this on will likely violate regulatory requirements! + ''; + }; - driver = mkOption { - default = "nl80211"; - example = "hostapd"; - type = types.str; - description = lib.mdDoc '' - Which driver {command}`hostapd` will use. - Most applications will probably use the default. - ''; - }; + countryCode = mkOption { + default = null; + example = "US"; + type = types.nullOr types.str; + description = mdDoc '' + Country code (ISO/IEC 3166-1). Used to set regulatory domain. + Set as needed to indicate country in which device is operating. + This can limit available channels and transmit power. + These two octets are used as the first two octets of the Country String + (dot11CountryString). - ssid = mkOption { - default = config.system.nixos.distroId; - defaultText = literalExpression "config.system.nixos.distroId"; - example = "mySpecialSSID"; - type = types.str; - description = lib.mdDoc "SSID to be used in IEEE 802.11 management frames."; - }; + Setting this will force you to also enable IEEE 802.11d and IEEE 802.11h. - hwMode = mkOption { - default = "g"; - type = types.enum [ "a" "b" "g" ]; - description = lib.mdDoc '' - Operation mode. - (a = IEEE 802.11a, b = IEEE 802.11b, g = IEEE 802.11g). - ''; - }; + IEEE 802.11d: This advertises the countryCode and the set of allowed channels + and transmit power levels based on the regulatory limits. - channel = mkOption { - default = 7; - example = 11; - type = types.int; - description = lib.mdDoc '' - Channel number (IEEE 802.11) - Please note that some drivers do not use this value from - {command}`hostapd` and the channel will need to be configured - separately with {command}`iwconfig`. - ''; - }; + IEEE802.11h: This enables radar detection and DFS (Dynamic Frequency Selection) + support if available. DFS support is required on outdoor 5 GHz channels in most + countries of the world. + ''; + }; - group = mkOption { - default = "wheel"; - example = "network"; - type = types.str; - description = lib.mdDoc '' - Members of this group can control {command}`hostapd`. - ''; - }; + band = mkOption { + default = "2g"; + type = types.enum ["2g" "5g" "6g" "60g"]; + description = mdDoc '' + Specifies the frequency band to use, possible values are 2g for 2.4 GHz, + 5g for 5 GHz, 6g for 6 GHz and 60g for 60 GHz. + ''; + }; - wpa = mkOption { - type = types.bool; - default = true; - description = lib.mdDoc '' - Enable WPA (IEEE 802.11i/D3.0) to authenticate with the access point. - ''; - }; + channel = mkOption { + default = 7; + example = 11; + type = types.int; + description = mdDoc '' + The channel to operate on. Use 0 to enable ACS (Automatic Channel Selection). + Beware that not every device supports ACS in which case {command}`hostapd` + will fail to start. + ''; + }; - wpaPassphrase = mkOption { - default = "my_sekret"; - example = "any_64_char_string"; - type = types.str; - description = lib.mdDoc '' - WPA-PSK (pre-shared-key) passphrase. Clients will need this - passphrase to associate with this access point. - Warning: This passphrase will get put into a world-readable file in - the Nix store! - ''; - }; + settings = mkOption { + default = {}; + example = { acs_exclude_dfs = true; }; + type = types.submodule { + freeformType = extraSettingsFormat.type; + }; + description = mdDoc '' + Extra configuration options to put at the end of global initialization, before defining BSSs. + To find out which options are global and which are per-bss you have to read hostapd's source code, + which is non-trivial and not documented otherwise. - logLevel = mkOption { - default = 2; - type = types.int; - description = lib.mdDoc '' - Levels (minimum value for logged events): - 0 = verbose debugging - 1 = debugging - 2 = informational messages - 3 = notification - 4 = warning - ''; - }; + Lists will be converted to multiple definitions of the same key, and booleans to 0/1. + Otherwise, the inputs are not modified or checked for correctness. + ''; + }; - countryCode = mkOption { - default = null; - example = "US"; - type = with types; nullOr str; - description = lib.mdDoc '' - Country code (ISO/IEC 3166-1). Used to set regulatory domain. - Set as needed to indicate country in which device is operating. - This can limit available channels and transmit power. - These two octets are used as the first two octets of the Country String - (dot11CountryString). - If set this enables IEEE 802.11d. This advertises the countryCode and - the set of allowed channels and transmit power levels based on the - regulatory limits. - ''; - }; + dynamicConfigScripts = mkOption { + default = {}; + type = types.attrsOf types.path; + example = literalExpression '' + { + exampleDynamicConfig = pkgs.writeShellScript "dynamic-config" ''' + HOSTAPD_CONFIG=$1 - extraConfig = mkOption { - default = ""; - example = '' - auth_algo=0 - ieee80211n=1 - ht_capab=[HT40-][SHORT-GI-40][DSSS_CCK-40] - ''; - type = types.lines; - description = lib.mdDoc "Extra configuration options to put in hostapd.conf."; + cat >> "$HOSTAPD_CONFIG" << EOF + # Add some dynamically generated statements here, + # for example based on the physical adapter in use + EOF + '''; + } + ''; + description = mdDoc '' + All of these scripts will be executed in lexicographical order before hostapd + is started, right after the global segment was generated and may dynamically + append global options the generated configuration file. + + The first argument will point to the configuration file that you may append to. + ''; + }; + + #### IEEE 802.11n (WiFi 4) related configuration + + wifi4 = { + enable = mkOption { + default = true; + type = types.bool; + description = mdDoc '' + Enables support for IEEE 802.11n (WiFi 4, HT). + This is enabled by default, since the vase majority of devices + are expected to support this. + ''; + }; + + capabilities = mkOption { + type = types.listOf types.str; + default = ["HT40" "HT40-" "SHORT-GI-20" "SHORT-GI-40"]; + example = ["LDPC" "HT40+" "HT40-" "GF" "SHORT-GI-20" "SHORT-GI-40" "TX-STBC" "RX-STBC1"]; + description = mdDoc '' + HT (High Throughput) capabilities given as a list of flags. + Please refer to the hostapd documentation for allowed values and + only set values supported by your physical adapter. + + The default contains common values supported by most adapters. + ''; + }; + + require = mkOption { + default = false; + type = types.bool; + description = mdDoc "Require stations (clients) to support WiFi 4 (HT) and disassociate them if they don't."; + }; + }; + + #### IEEE 802.11ac (WiFi 5) related configuration + + wifi5 = { + enable = mkOption { + default = true; + type = types.bool; + description = mdDoc "Enables support for IEEE 802.11ac (WiFi 5, VHT)"; + }; + + capabilities = mkOption { + type = types.listOf types.str; + default = []; + example = ["SHORT-GI-80" "TX-STBC-2BY1" "RX-STBC-1" "RX-ANTENNA-PATTERN" "TX-ANTENNA-PATTERN"]; + description = mdDoc '' + VHT (Very High Throughput) capabilities given as a list of flags. + Please refer to the hostapd documentation for allowed values and + only set values supported by your physical adapter. + ''; + }; + + require = mkOption { + default = false; + type = types.bool; + description = mdDoc "Require stations (clients) to support WiFi 5 (VHT) and disassociate them if they don't."; + }; + + operatingChannelWidth = mkOption { + default = "20or40"; + type = types.enum ["20or40" "80" "160" "80+80"]; + apply = x: + getAttr x { + "20or40" = 0; + "80" = 1; + "160" = 2; + "80+80" = 3; + }; + description = mdDoc '' + Determines the operating channel width for VHT. + + - {var}`"20or40"`: 20 or 40 MHz operating channel width + - {var}`"80"`: 80 MHz channel width + - {var}`"160"`: 160 MHz channel width + - {var}`"80+80"`: 80+80 MHz channel width + ''; + }; + }; + + #### IEEE 802.11ax (WiFi 6) related configuration + + wifi6 = { + enable = mkOption { + default = false; + type = types.bool; + description = mdDoc "Enables support for IEEE 802.11ax (WiFi 6, HE)"; + }; + + require = mkOption { + default = false; + type = types.bool; + description = mdDoc "Require stations (clients) to support WiFi 6 (HE) and disassociate them if they don't."; + }; + + singleUserBeamformer = mkOption { + default = false; + type = types.bool; + description = mdDoc "HE single user beamformer support"; + }; + + singleUserBeamformee = mkOption { + default = false; + type = types.bool; + description = mdDoc "HE single user beamformee support"; + }; + + multiUserBeamformer = mkOption { + default = false; + type = types.bool; + description = mdDoc "HE multi user beamformee support"; + }; + + operatingChannelWidth = mkOption { + default = "20or40"; + type = types.enum ["20or40" "80" "160" "80+80"]; + apply = x: + getAttr x { + "20or40" = 0; + "80" = 1; + "160" = 2; + "80+80" = 3; + }; + description = mdDoc '' + Determines the operating channel width for HE. + + - {var}`"20or40"`: 20 or 40 MHz operating channel width + - {var}`"80"`: 80 MHz channel width + - {var}`"160"`: 160 MHz channel width + - {var}`"80+80"`: 80+80 MHz channel width + ''; + }; + }; + + #### IEEE 802.11be (WiFi 7) related configuration + + wifi7 = { + enable = mkOption { + default = false; + type = types.bool; + description = mdDoc '' + Enables support for IEEE 802.11be (WiFi 7, EHT). This is currently experimental + and requires you to manually enable CONFIG_IEEE80211BE when building hostapd. + ''; + }; + + singleUserBeamformer = mkOption { + default = false; + type = types.bool; + description = mdDoc "EHT single user beamformer support"; + }; + + singleUserBeamformee = mkOption { + default = false; + type = types.bool; + description = mdDoc "EHT single user beamformee support"; + }; + + multiUserBeamformer = mkOption { + default = false; + type = types.bool; + description = mdDoc "EHT multi user beamformee support"; + }; + + operatingChannelWidth = mkOption { + default = "20or40"; + type = types.enum ["20or40" "80" "160" "80+80"]; + apply = x: + getAttr x { + "20or40" = 0; + "80" = 1; + "160" = 2; + "80+80" = 3; + }; + description = mdDoc '' + Determines the operating channel width for EHT. + + - {var}`"20or40"`: 20 or 40 MHz operating channel width + - {var}`"80"`: 80 MHz channel width + - {var}`"160"`: 160 MHz channel width + - {var}`"80+80"`: 80+80 MHz channel width + ''; + }; + }; + + #### BSS definitions + + networks = mkOption { + default = {}; + example = literalExpression '' + { + wlp2s0 = { + ssid = "Primary advertised network"; + authentication.saePasswords = [{ password = "a flakey password"; }]; # Use saePasswordsFile if possible. + }; + wlp2s0-1 = { + ssid = "Secondary advertised network (Open)"; + authentication.mode = "none"; + }; + } + ''; + description = mdDoc '' + This defines a BSS, colloquially known as a WiFi network. + You have to specify at least one. + ''; + type = types.attrsOf (types.submodule (bssSubmod: { + options = { + logLevel = mkOption { + default = 2; + type = types.int; + description = mdDoc '' + Levels (minimum value for logged events): + 0 = verbose debugging + 1 = debugging + 2 = informational messages + 3 = notification + 4 = warning + ''; + }; + + group = mkOption { + default = "wheel"; + example = "network"; + type = types.str; + description = mdDoc '' + Members of this group can access the control socket for this interface. + ''; + }; + + utf8Ssid = mkOption { + default = true; + type = types.bool; + description = mdDoc "Whether the SSID is to be interpreted using UTF-8 encoding."; + }; + + ssid = mkOption { + example = "❄️ cool ❄️"; + type = types.str; + description = mdDoc "SSID to be used in IEEE 802.11 management frames."; + }; + + bssid = mkOption { + type = types.nullOr types.str; + default = null; + example = "11:22:33:44:55:66"; + description = mdDoc '' + Specifies the BSSID for this BSS. Usually determined automatically, + but for now you have to manually specify them when using multiple BSS. + Try assigning related addresses from the locally administered MAC address ranges, + by reusing the hardware address but replacing the second nibble with 2, 6, A or E. + (e.g. if real address is `XX:XX:XX:XX:XX`, try `X2:XX:XX:XX:XX:XX`, `X6:XX:XX:XX:XX:XX`, ... + for the second, third, ... BSS) + ''; + }; + + macAcl = mkOption { + default = "deny"; + type = types.enum ["deny" "allow" "radius"]; + apply = x: + getAttr x { + "deny" = 0; + "allow" = 1; + "radius" = 2; + }; + description = mdDoc '' + Station MAC address -based authentication. The following modes are available: + + - {var}`"deny"`: Allow unless listed in {option}`macDeny` (default) + - {var}`"allow"`: Deny unless listed in {option}`macAllow` + - {var}`"radius"`: Use external radius server, but check both {option}`macAllow` and {option}`macDeny` first + + Please note that this kind of access control requires a driver that uses + hostapd to take care of management frame processing and as such, this can be + used with driver=hostap or driver=nl80211, but not with driver=atheros. + ''; + }; + + macAllow = mkOption { + type = types.listOf types.str; + default = []; + example = ["11:22:33:44:55:66"]; + description = mdDoc '' + Specifies the MAC addresses to allow if {option}`macAcl` is set to {var}`"allow"` or {var}`"radius"`. + These values will be world-readable in the Nix store. Values will automatically be merged with + {option}`macAllowFile` if necessary. + ''; + }; + + macAllowFile = mkOption { + type = types.nullOr types.path; + default = null; + description = mdDoc '' + Specifies a file containing the MAC addresses to allow if {option}`macAcl` is set to {var}`"allow"` or {var}`"radius"`. + The file should contain exactly one MAC address per line. Comments and empty lines are ignored, + only lines starting with a valid MAC address will be considered (e.g. `11:22:33:44:55:66`) and + any content after the MAC address is ignored. + ''; + }; + + macDeny = mkOption { + type = types.listOf types.str; + default = []; + example = ["11:22:33:44:55:66"]; + description = mdDoc '' + Specifies the MAC addresses to deny if {option}`macAcl` is set to {var}`"deny"` or {var}`"radius"`. + These values will be world-readable in the Nix store. Values will automatically be merged with + {option}`macDenyFile` if necessary. + ''; + }; + + macDenyFile = mkOption { + type = types.nullOr types.path; + default = null; + description = mdDoc '' + Specifies a file containing the MAC addresses to deny if {option}`macAcl` is set to {var}`"deny"` or {var}`"radius"`. + The file should contain exactly one MAC address per line. Comments and empty lines are ignored, + only lines starting with a valid MAC address will be considered (e.g. `11:22:33:44:55:66`) and + any content after the MAC address is ignored. + ''; + }; + + ignoreBroadcastSsid = mkOption { + default = "disabled"; + type = types.enum ["disabled" "empty" "clear"]; + apply = x: + getAttr x { + "disabled" = 0; + "empty" = 1; + "clear" = 2; + }; + description = mdDoc '' + Send empty SSID in beacons and ignore probe request frames that do not + specify full SSID, i.e., require stations to know SSID. Note that this does + not increase security, since your clients will then broadcast the SSID instead, + which can increase congestion. + + - {var}`"disabled"`: Advertise ssid normally. + - {var}`"empty"`: send empty (length=0) SSID in beacon and ignore probe request for broadcast SSID + - {var}`"clear"`: clear SSID (ASCII 0), but keep the original length (this may be required with some + legacy clients that do not support empty SSID) and ignore probe requests for broadcast SSID. Only + use this if empty does not work with your clients. + ''; + }; + + apIsolate = mkOption { + default = false; + type = types.bool; + description = mdDoc '' + Isolate traffic between stations (clients) and prevent them from + communicating with each other. + ''; + }; + + settings = mkOption { + default = {}; + example = { multi_ap = true; }; + type = types.submodule { + freeformType = extraSettingsFormat.type; + }; + description = mdDoc '' + Extra configuration options to put at the end of this BSS's defintion in the + hostapd.conf for the associated interface. To find out which options are global + and which are per-bss you have to read hostapd's source code, which is non-trivial + and not documented otherwise. + + Lists will be converted to multiple definitions of the same key, and booleans to 0/1. + Otherwise, the inputs are not modified or checked for correctness. + ''; + }; + + dynamicConfigScripts = mkOption { + default = {}; + type = types.attrsOf types.path; + example = literalExpression '' + { + exampleDynamicConfig = pkgs.writeShellScript "dynamic-config" ''' + HOSTAPD_CONFIG=$1 + # These always exist, but may or may not be used depending on the actual configuration + MAC_ALLOW_FILE=$2 + MAC_DENY_FILE=$3 + + cat >> "$HOSTAPD_CONFIG" << EOF + # Add some dynamically generated statements here + EOF + '''; + } + ''; + description = mdDoc '' + All of these scripts will be executed in lexicographical order before hostapd + is started, right after the bss segment was generated and may dynamically + append bss options to the generated configuration file. + + The first argument will point to the configuration file that you may append to. + The second and third argument will point to this BSS's MAC allow and MAC deny file respectively. + ''; + }; + + #### IEEE 802.11i (WPA) configuration + + authentication = { + mode = mkOption { + default = "wpa3-sae"; + type = types.enum ["none" "wpa2-sha256" "wpa3-sae-transition" "wpa3-sae"]; + description = mdDoc '' + Selects the authentication mode for this AP. + + - {var}`"none"`: Don't configure any authentication. This will disable wpa alltogether + and create an open AP. Use {option}`settings` together with this option if you + want to configure the authentication manually. Any password options will still be + effective, if set. + - {var}`"wpa2-sha256"`: WPA2-Personal using SHA256 (IEEE 802.11i/RSN). Passwords are set + using {option}`wpaPassword` or preferably by {option}`wpaPasswordFile` or {option}`wpaPskFile`. + - {var}`"wpa3-sae-transition"`: Use WPA3-Personal (SAE) if possible, otherwise fallback + to WPA2-SHA256. Only use if necessary and switch to the newer WPA3-SAE when possible. + You will have to specify both {option}`wpaPassword` and {option}`saePasswords` (or one of their alternatives). + - {var}`"wpa3-sae"`: Use WPA3-Personal (SAE). This is currently the recommended way to + setup a secured WiFi AP (as of March 2023) and therefore the default. Passwords are set + using either {option}`saePasswords` or preferably {option}`saePasswordsFile`. + ''; + }; + + pairwiseCiphers = mkOption { + default = ["CCMP"]; + example = ["CCMP-256" "GCMP-256"]; + type = types.listOf types.str; + description = mdDoc '' + Set of accepted cipher suites (encryption algorithms) for pairwise keys (unicast packets). + By default this allows just CCMP, which is the only commonly supported secure option. + Use {option}`enableRecommendedPairwiseCiphers` to also enable newer recommended ciphers. + + Please refer to the hostapd documentation for allowed values. Generally, only + CCMP or GCMP modes should be considered safe options. Most devices support CCMP while + GCMP is often only available with devices supporting WiFi 5 (IEEE 802.11ac) or higher. + ''; + }; + + enableRecommendedPairwiseCiphers = mkOption { + default = false; + example = true; + type = types.bool; + description = mdDoc '' + Additionally enable the recommended set of pairwise ciphers. + This enables newer secure ciphers, additionally to those defined in {option}`pairwiseCiphers`. + You will have to test whether your hardware supports these by trial-and-error, because + even if `iw list` indicates hardware support, your driver might not expose it. + + Beware {command}`hostapd` will most likely not return a useful error message in case + this is enabled despite the driver or hardware not supporting the newer ciphers. + Look out for messages like `Failed to set beacon parameters`. + ''; + }; + + wpaPassword = mkOption { + default = null; + example = "a flakey password"; + type = types.nullOr types.str; + description = mdDoc '' + Sets the password for WPA-PSK that will be converted to the pre-shared key. + The password length must be in the range [8, 63] characters. While some devices + may allow arbitrary characters (such as UTF-8) to be used, but the standard specifies + that each character in the passphrase must be an ASCII character in the range [0x20, 0x7e] + (IEEE Std. 802.11i-2004, Annex H.4.1). Use emojis at your own risk. + + Not used when {option}`mode` is {var}`"wpa3-sae"`. + + Warning: This password will get put into a world-readable file in the Nix store! + Using {option}`wpaPasswordFile` or {option}`wpaPskFile` instead is recommended. + ''; + }; + + wpaPasswordFile = mkOption { + default = null; + type = types.nullOr types.path; + description = mdDoc '' + Sets the password for WPA-PSK. Follows the same rules as {option}`wpaPassword`, + but reads the password from the given file to prevent the password from being + put into the Nix store. + + Not used when {option}`mode` is {var}`"wpa3-sae"`. + ''; + }; + + wpaPskFile = mkOption { + default = null; + type = types.nullOr types.path; + description = mdDoc '' + Sets the password(s) for WPA-PSK. Similar to {option}`wpaPasswordFile`, + but additionally allows specifying multiple passwords, and some other options. + + Each line, except for empty lines and lines starting with #, must contain a + MAC address and either a 64-hex-digit PSK or a password separated with a space. + The password must follow the same rules as outlined in {option}`wpaPassword`. + The special MAC address `00:00:00:00:00:00` can be used to configure PSKs + that any client can use. + + An optional key identifier can be added by prefixing the line with `keyid=` + An optional VLAN ID can be specified by prefixing the line with `vlanid=`. + An optional WPS tag can be added by prefixing the line with `wps=<0/1>` (default: 0). + Any matching entry with that tag will be used when generating a PSK for a WPS Enrollee + instead of generating a new random per-Enrollee PSK. + + Not used when {option}`mode` is {var}`"wpa3-sae"`. + ''; + }; + + saePasswords = mkOption { + default = []; + example = literalExpression '' + [ + # Any client may use these passwords + { password = "Wi-Figure it out"; } + { password = "second password for everyone"; mac = "ff:ff:ff:ff:ff:ff"; } + + # Only the client with MAC-address 11:22:33:44:55:66 can use this password + { password = "sekret pazzword"; mac = "11:22:33:44:55:66"; } + ] + ''; + description = mdDoc '' + Sets allowed passwords for WPA3-SAE. + + The last matching (based on peer MAC address and identifier) entry is used to + select which password to use. An empty string has the special meaning of + removing all previously added entries. + + Warning: These entries will get put into a world-readable file in + the Nix store! Using {option}`saePasswordFile` instead is recommended. + + Not used when {option}`mode` is {var}`"wpa2-sha256"`. + ''; + type = types.listOf (types.submodule { + options = { + password = mkOption { + example = "a flakey password"; + type = types.str; + description = mdDoc '' + The password for this entry. SAE technically imposes no restrictions on + password length or character set. But due to limitations of {command}`hostapd`'s + config file format, a true newline character cannot be parsed. + + Warning: This password will get put into a world-readable file in + the Nix store! Using {option}`wpaPasswordFile` or {option}`wpaPskFile` is recommended. + ''; + }; + + mac = mkOption { + default = null; + example = "11:22:33:44:55:66"; + type = types.nullOr types.str; + description = mdDoc '' + If this attribute is not included, or if is set to the wildcard address (`ff:ff:ff:ff:ff:ff`), + the entry is available for any station (client) to use. If a specific peer MAC address is included, + only a station with that MAC address is allowed to use the entry. + ''; + }; + + vlanid = mkOption { + default = null; + example = 1; + type = types.nullOr types.int; + description = mdDoc "If this attribute is given, all clients using this entry will get tagged with the given VLAN ID."; + }; + + pk = mkOption { + default = null; + example = ""; + type = types.nullOr types.str; + description = mdDoc '' + If this attribute is given, SAE-PK will be enabled for this connection. + This prevents evil-twin attacks, but a public key is required additionally to connect. + (Essentially adds pubkey authentication such that the client can verify identity of the AP) + ''; + }; + + id = mkOption { + default = null; + example = ""; + type = types.nullOr types.str; + description = mdDoc '' + If this attribute is given with non-zero length, it will set the password identifier + for this entry. It can then only be used with that identifier. + ''; + }; + }; + }); + }; + + saePasswordsFile = mkOption { + default = null; + type = types.nullOr types.path; + description = mdDoc '' + Sets the password for WPA3-SAE. Follows the same rules as {option}`saePasswords`, + but reads the entries from the given file to prevent them from being + put into the Nix store. + + One entry per line, empty lines and lines beginning with # will be ignored. + Each line must match the following format, although the order of optional + parameters doesn't matter: + `[|mac=][|vlanid=][|pk=][|id=]` + + Not used when {option}`mode` is {var}`"wpa2-sha256"`. + ''; + }; + + saeAddToMacAllow = mkOption { + type = types.bool; + default = false; + description = mdDoc '' + If set, all sae password entries that have a non-wildcard MAC associated to + them will additionally be used to populate the MAC allow list. This is + additional to any entries set via {option}`macAllow` or {option}`macAllowFile`. + ''; + }; + }; + + managementFrameProtection = mkOption { + default = "required"; + type = types.enum ["disabled" "optional" "required"]; + apply = x: + getAttr x { + "disabled" = 0; + "optional" = 1; + "required" = 2; + }; + description = mdDoc '' + Management frame protection (MFP) authenticates management frames + to prevent deauthentication (or related) attacks. + + - {var}`"disabled"`: No management frame protection + - {var}`"optional"`: Use MFP if a connection allows it + - {var}`"required"`: Force MFP for all clients + ''; + }; + }; + + config = let + bss = bssSubmod.name; + bssCfg = bssSubmod.config; + + pairwiseCiphers = + concatStringsSep " " (unique (bssCfg.authentication.pairwiseCiphers + ++ optionals bssCfg.authentication.enableRecommendedPairwiseCiphers ["CCMP" "CCMP-256" "GCMP" "GCMP-256"])); + in { + settings = { + ssid = bssCfg.ssid; + utf8_ssid = bssCfg.ssid; + + logger_syslog = mkDefault (-1); + logger_syslog_level = bssCfg.logLevel; + logger_stdout = mkDefault (-1); + logger_stdout_level = bssCfg.logLevel; + ctrl_interface = mkDefault "/run/hostapd"; + ctrl_interface_group = bssCfg.group; + + macaddr_acl = bssCfg.macAcl; + + ignore_broadcast_ssid = bssCfg.ignoreBroadcastSsid; + + # IEEE 802.11i (authentication) related configuration + # Encrypt management frames to protect against deauthentication and similar attacks + ieee80211w = bssCfg.managementFrameProtection; + + # Only allow WPA by default and disable insecure WEP + auth_algs = mkDefault 1; + # Always enable QoS, which is required for 802.11n and above + wmm_enabled = mkDefault true; + ap_isolate = bssCfg.apIsolate; + + sae_password = flip map bssCfg.authentication.saePasswords ( + entry: + entry.password + + optionalString (entry.mac != null) "|mac=${entry.mac}" + + optionalString (entry.vlanid != null) "|vlanid=${toString entry.vlanid}" + + optionalString (entry.pk != null) "|pk=${entry.pk}" + + optionalString (entry.id != null) "|id=${entry.id}" + ); + } // optionalAttrs (bssCfg.bssid != null) { + bssid = bssCfg.bssid; + } // optionalAttrs (bssCfg.macAllow != [] || bssCfg.macAllowFile != null || bssCfg.authentication.saeAddToMacAllow) { + accept_mac_file = "/run/hostapd/${bss}.mac.allow"; + } // optionalAttrs (bssCfg.macDeny != [] || bssCfg.macDenyFile != null) { + deny_mac_file = "/run/hostapd/${bss}.mac.deny"; + } // optionalAttrs (bssCfg.authentication.mode == "none") { + wpa = mkDefault 0; + } // optionalAttrs (bssCfg.authentication.mode == "wpa3-sae") { + wpa = 2; + wpa_key_mgmt = "SAE"; + # Derive PWE using both hunting-and-pecking loop and hash-to-element + sae_pwe = 2; + # Prevent downgrade attacks by indicating to clients that they should + # disable any transition modes from now on. + transition_disable = "0x01"; + } // optionalAttrs (bssCfg.authentication.mode == "wpa3-sae-transition") { + wpa = 2; + wpa_key_mgmt = "WPA-PSK-SHA256 SAE"; + } // optionalAttrs (bssCfg.authentication.mode == "wpa2-sha256") { + wpa = 2; + wpa_key_mgmt = "WPA-PSK-SHA256"; + } // optionalAttrs (bssCfg.authentication.mode != "none") { + wpa_pairwise = pairwiseCiphers; + rsn_pairwise = pairwiseCiphers; + } // optionalAttrs (bssCfg.authentication.wpaPassword != null) { + wpa_passphrase = bssCfg.authentication.wpaPassword; + } // optionalAttrs (bssCfg.authentication.wpaPskFile != null) { + wpa_psk_file = bssCfg.authentication.wpaPskFile; + }; + + dynamicConfigScripts = let + # All MAC addresses from SAE entries that aren't the wildcard address + saeMacs = filter (mac: mac != null && (toLower mac) != "ff:ff:ff:ff:ff:ff") (map (x: x.mac) bssCfg.authentication.saePasswords); + in { + "20-addMacAllow" = mkIf (bssCfg.macAllow != []) (pkgs.writeShellScript "add-mac-allow" '' + MAC_ALLOW_FILE=$2 + cat >> "$MAC_ALLOW_FILE" <> "$MAC_ALLOW_FILE" + ''); + "20-addMacAllowFromSae" = mkIf (bssCfg.authentication.saeAddToMacAllow && saeMacs != []) (pkgs.writeShellScript "add-mac-allow-from-sae" '' + MAC_ALLOW_FILE=$2 + cat >> "$MAC_ALLOW_FILE" <> "$MAC_ALLOW_FILE" + ''); + "20-addMacDeny" = mkIf (bssCfg.macDeny != []) (pkgs.writeShellScript "add-mac-deny" '' + MAC_DENY_FILE=$3 + cat >> "$MAC_DENY_FILE" <> "$MAC_DENY_FILE" + ''); + # Add wpa_passphrase from file + "20-wpaPasswordFile" = mkIf (bssCfg.authentication.wpaPasswordFile != null) (pkgs.writeShellScript "wpa-password-file" '' + HOSTAPD_CONFIG_FILE=$1 + cat >> "$HOSTAPD_CONFIG_FILE" <> "$HOSTAPD_CONFIG_FILE" + ''); + }; + }; + })); + }; + }; + + config.settings = let + radio = radioSubmod.name; + radioCfg = radioSubmod.config; + in { + driver = radioCfg.driver; + hw_mode = { + "2g" = "g"; + "5g" = "a"; + "6g" = "a"; + "60g" = "ad"; + }.${radioCfg.band}; + channel = radioCfg.channel; + noscan = radioCfg.noScan; + } // optionalAttrs (radioCfg.countryCode != null) { + country_code = radioCfg.countryCode; + # IEEE 802.11d: Limit to frequencies allowed in country + ieee80211d = true; + # IEEE 802.11h: Enable radar detection and DFS (Dynamic Frequency Selection) + ieee80211h = true; + } // optionalAttrs radioCfg.wifi4.enable { + # IEEE 802.11n (WiFi 4) related configuration + ieee80211n = true; + require_ht = radioCfg.wifi4.require; + ht_capab = concatMapStrings (x: "[${x}]") radioCfg.wifi4.capabilities; + } // optionalAttrs radioCfg.wifi5.enable { + # IEEE 802.11ac (WiFi 5) related configuration + ieee80211ac = true; + require_vht = radioCfg.wifi5.require; + vht_oper_chwidth = radioCfg.wifi5.operatingChannelWidth; + vht_capab = concatMapStrings (x: "[${x}]") radioCfg.wifi5.capabilities; + } // optionalAttrs radioCfg.wifi6.enable { + # IEEE 802.11ax (WiFi 6) related configuration + ieee80211ax = true; + require_he = mkIf radioCfg.wifi6.require true; + he_oper_chwidth = radioCfg.wifi6.operatingChannelWidth; + he_su_beamformer = radioCfg.wifi6.singleUserBeamformer; + he_su_beamformee = radioCfg.wifi6.singleUserBeamformee; + he_mu_beamformer = radioCfg.wifi6.multiUserBeamformer; + } // optionalAttrs radioCfg.wifi7.enable { + # IEEE 802.11be (WiFi 7) related configuration + ieee80211be = true; + eht_oper_chwidth = radioCfg.wifi7.operatingChannelWidth; + eht_su_beamformer = radioCfg.wifi7.singleUserBeamformer; + eht_su_beamformee = radioCfg.wifi7.singleUserBeamformee; + eht_mu_beamformer = radioCfg.wifi7.multiUserBeamformer; + }; + })); }; }; }; + imports = let + renamedOptionMessage = message: '' + ${message} + Refer to the documentation of `services.hostapd.radios` for an example and more information. + ''; + in [ + (mkRemovedOptionModule ["services" "hostapd" "interface"] + (renamedOptionMessage "All other options for this interface are now set via `services.hostapd.radios.«interface».*`.")) - ###### implementation + (mkRemovedOptionModule ["services" "hostapd" "driver"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».driver`.")) + (mkRemovedOptionModule ["services" "hostapd" "noScan"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».noScan`.")) + (mkRemovedOptionModule ["services" "hostapd" "countryCode"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».countryCode`.")) + (mkRemovedOptionModule ["services" "hostapd" "hwMode"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».band`.")) + (mkRemovedOptionModule ["services" "hostapd" "channel"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».channel`.")) + (mkRemovedOptionModule ["services" "hostapd" "extraConfig"] + (renamedOptionMessage '' + It has been replaced by `services.hostapd.radios.«interface».settings` and + `services.hostapd.radios.«interface».networks.«network».settings` respectively + for per-radio and per-network extra configuration. The module now supports a lot more + options inherently, so please re-check whether using settings is still necessary.'')) + + (mkRemovedOptionModule ["services" "hostapd" "logLevel"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».networks.«network».logLevel`.")) + (mkRemovedOptionModule ["services" "hostapd" "group"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».networks.«network».group`.")) + (mkRemovedOptionModule ["services" "hostapd" "ssid"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».networks.«network».ssid`.")) + + (mkRemovedOptionModule ["services" "hostapd" "wpa"] + (renamedOptionMessage "It has been replaced by `services.hostapd.radios.«interface».networks.«network».authentication.mode`.")) + (mkRemovedOptionModule ["services" "hostapd" "wpaPassphrase"] + (renamedOptionMessage '' + It has been replaced by `services.hostapd.radios.«interface».networks.«network».authentication.wpaPassword`. + While upgrading your config, please consider using the newer SAE authentication scheme + and one of the new `passwordFile`-like options to avoid putting the password into the world readable nix-store.'')) + ]; config = mkIf cfg.enable { + assertions = + [ + { + assertion = cfg.radios != {}; + message = "At least one radio must be configured with hostapd!"; + } + ] + # Radio warnings + ++ (concatLists (mapAttrsToList ( + radio: radioCfg: + [ + { + assertion = radioCfg.networks != {}; + message = "hostapd radio ${radio}: At least one network must be configured!"; + } + # XXX: There could be many more useful assertions about (band == xy) -> ensure other required settings. + # see https://github.com/openwrt/openwrt/blob/539cb5389d9514c99ec1f87bd4465f77c7ed9b93/package/kernel/mac80211/files/lib/netifd/wireless/mac80211.sh#L158 + { + assertion = length (filter (bss: bss == radio) (attrNames radioCfg.networks)) == 1; + message = ''hostapd radio ${radio}: Exactly one network must be named like the radio, for reasons internal to hostapd.''; + } + { + assertion = (radioCfg.wifi4.enable && builtins.elem "HT40-" radioCfg.wifi4.capabilities) -> radioCfg.channel != 0; + message = ''hostapd radio ${radio}: using ACS (channel = 0) together with HT40- (wifi4.capabilities) is unsupported by hostapd''; + } + ] + # BSS warnings + ++ (concatLists (mapAttrsToList (bss: bssCfg: let + auth = bssCfg.authentication; + countWpaPasswordDefinitions = count (x: x != null) [ + auth.wpaPassword + auth.wpaPasswordFile + auth.wpaPskFile + ]; + in [ + { + assertion = hasPrefix radio bss; + message = "hostapd radio ${radio} bss ${bss}: The bss (network) name ${bss} is invalid. It must be prefixed by the radio name for reasons internal to hostapd. A valid name would be e.g. ${radio}, ${radio}-1, ..."; + } + { + assertion = (length (attrNames radioCfg.networks) > 1) -> (bssCfg.bssid != null); + message = ''hostapd radio ${radio} bss ${bss}: bssid must be specified manually (for now) since this radio uses multiple BSS.''; + } + { + assertion = auth.mode == "wpa3-sae" -> bssCfg.managementFrameProtection == 2; + message = ''hostapd radio ${radio} bss ${bss}: uses WPA3-SAE which requires managementFrameProtection="required"''; + } + { + assertion = auth.mode == "wpa3-sae-transition" -> bssCfg.managementFrameProtection != 0; + message = ''hostapd radio ${radio} bss ${bss}: uses WPA3-SAE in transition mode with WPA2-SHA256, which requires managementFrameProtection="optional" or ="required"''; + } + { + assertion = countWpaPasswordDefinitions <= 1; + message = ''hostapd radio ${radio} bss ${bss}: must use at most one WPA password option (wpaPassword, wpaPasswordFile, wpaPskFile)''; + } + { + assertion = auth.wpaPassword != null -> (stringLength auth.wpaPassword >= 8 && stringLength auth.wpaPassword <= 63); + message = ''hostapd radio ${radio} bss ${bss}: uses a wpaPassword of invalid length (must be in [8,63]).''; + } + { + assertion = auth.saePasswords == [] || auth.saePasswordsFile == null; + message = ''hostapd radio ${radio} bss ${bss}: must use only one SAE password option (saePasswords or saePasswordsFile)''; + } + { + assertion = auth.mode == "wpa3-sae" -> (auth.saePasswords != [] || auth.saePasswordsFile != null); + message = ''hostapd radio ${radio} bss ${bss}: uses WPA3-SAE which requires defining a sae password option''; + } + { + assertion = auth.mode == "wpa3-sae-transition" -> (auth.saePasswords != [] || auth.saePasswordsFile != null) && countWpaPasswordDefinitions == 1; + message = ''hostapd radio ${radio} bss ${bss}: uses WPA3-SAE in transition mode requires defining both a wpa password option and a sae password option''; + } + { + assertion = auth.mode == "wpa2-sha256" -> countWpaPasswordDefinitions == 1; + message = ''hostapd radio ${radio} bss ${bss}: uses WPA2-SHA256 which requires defining a wpa password option''; + } + ]) + radioCfg.networks)) + ) + cfg.radios)); - environment.systemPackages = [ pkgs.hostapd ]; + environment.systemPackages = [cfg.package]; - services.udev.packages = optionals (cfg.countryCode != null) [ pkgs.crda ]; + services.udev.packages = with pkgs; [crda]; - systemd.services.hostapd = - { description = "hostapd wireless AP"; + systemd.services.hostapd = { + description = "IEEE 802.11 Host Access-Point Daemon"; - path = [ pkgs.hostapd ]; - after = [ "sys-subsystem-net-devices-${escapedInterface}.device" ]; - bindsTo = [ "sys-subsystem-net-devices-${escapedInterface}.device" ]; - requiredBy = [ "network-link-${cfg.interface}.service" ]; - wantedBy = [ "multi-user.target" ]; + path = [cfg.package]; + after = map (radio: "sys-subsystem-net-devices-${utils.escapeSystemdPath radio}.device") (attrNames cfg.radios); + bindsTo = map (radio: "sys-subsystem-net-devices-${utils.escapeSystemdPath radio}.device") (attrNames cfg.radios); + wantedBy = ["multi-user.target"]; - serviceConfig = - { ExecStart = "${pkgs.hostapd}/bin/hostapd ${configFile}"; - Restart = "always"; - }; + # Create merged configuration and acl files for each radio (and their bss's) prior to starting + preStart = concatStringsSep "\n" (mapAttrsToList makeRadioRuntimeFiles cfg.radios); + + serviceConfig = { + ExecStart = "${cfg.package}/bin/hostapd ${concatStringsSep " " runtimeConfigFiles}"; + Restart = "always"; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + RuntimeDirectory = "hostapd"; + + # Hardening + LockPersonality = true; + MemoryDenyWriteExecute = true; + DevicePolicy = "closed"; + DeviceAllow = "/dev/rfkill rw"; + NoNewPrivileges = true; + PrivateUsers = false; # hostapd requires true root access. + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + ProtectSystem = "strict"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "@chown" + ]; + UMask = "0077"; }; + }; }; } diff --git a/nixos/tests/wpa_supplicant.nix b/nixos/tests/wpa_supplicant.nix index a05a79e8367d..4f000df922c0 100644 --- a/nixos/tests/wpa_supplicant.nix +++ b/nixos/tests/wpa_supplicant.nix @@ -2,63 +2,159 @@ import ./make-test-python.nix ({ pkgs, lib, ...}: { name = "wpa_supplicant"; meta = with lib.maintainers; { - maintainers = [ rnhmjoj ]; + maintainers = [ oddlama rnhmjoj ]; }; - nodes.machine = { ... }: { - imports = [ ../modules/profiles/minimal.nix ]; + nodes = let + machineWithHostapd = extraConfigModule: { ... }: { + imports = [ + ../modules/profiles/minimal.nix + extraConfigModule + ]; - # add a virtual wlan interface - boot.kernelModules = [ "mac80211_hwsim" ]; + # add a virtual wlan interface + boot.kernelModules = [ "mac80211_hwsim" ]; - # wireless access point - services.hostapd = { - enable = true; - wpa = true; - interface = "wlan0"; - ssid = "nixos-test"; - wpaPassphrase = "reproducibility"; - }; - - # wireless client - networking.wireless = { - # the override is needed because the wifi is - # disabled with mkVMOverride in qemu-vm.nix. - enable = lib.mkOverride 0 true; - userControlled.enable = true; - interfaces = [ "wlan1" ]; - fallbackToWPA2 = true; - - networks = { - # test WPA2 fallback - mixed-wpa = { - psk = "password"; - authProtocols = [ "WPA-PSK" "SAE" ]; + # wireless access point + services.hostapd = { + enable = true; + radios.wlan0 = { + band = "2g"; + countryCode = "US"; + networks = { + wlan0 = { + ssid = "nixos-test-sae"; + authentication = { + mode = "wpa3-sae"; + saePasswords = [ { password = "reproducibility"; } ]; + }; + bssid = "02:00:00:00:00:00"; + }; + wlan0-1 = { + ssid = "nixos-test-mixed"; + authentication = { + mode = "wpa3-sae-transition"; + saePasswordsFile = pkgs.writeText "password" "reproducibility"; + wpaPasswordFile = pkgs.writeText "password" "reproducibility"; + }; + bssid = "02:00:00:00:00:01"; + }; + wlan0-2 = { + ssid = "nixos-test-wpa2"; + authentication = { + mode = "wpa2-sha256"; + wpaPassword = "reproducibility"; + }; + bssid = "02:00:00:00:00:02"; + }; + }; }; - sae-only = { - psk = "password"; - authProtocols = [ "SAE" ]; - }; - - # test network - nixos-test.psk = "@PSK_NIXOS_TEST@"; - - # secrets substitution test cases - test1.psk = "@PSK_VALID@"; # should be replaced - test2.psk = "@PSK_SPECIAL@"; # should be replaced - test3.psk = "@PSK_MISSING@"; # should not be replaced - test4.psk = "P@ssowrdWithSome@tSymbol"; # should not be replaced }; - # secrets - environmentFile = pkgs.writeText "wpa-secrets" '' - PSK_NIXOS_TEST="reproducibility" - PSK_VALID="S0m3BadP4ssw0rd"; - # taken from https://github.com/minimaxir/big-list-of-naughty-strings - PSK_SPECIAL=",./;'[]\-= <>?:\"{}|_+ !@#$%^\&*()`~"; - ''; + # wireless client + networking.wireless = { + # the override is needed because the wifi is + # disabled with mkVMOverride in qemu-vm.nix. + enable = lib.mkOverride 0 true; + userControlled.enable = true; + interfaces = [ "wlan1" ]; + fallbackToWPA2 = lib.mkDefault true; + + # networks will be added on-demand below for the specific + # network that should be tested + + # secrets + environmentFile = pkgs.writeText "wpa-secrets" '' + PSK_NIXOS_TEST="reproducibility" + ''; + }; + }; + in { + basic = { ... }: { + imports = [ ../modules/profiles/minimal.nix ]; + + # add a virtual wlan interface + boot.kernelModules = [ "mac80211_hwsim" ]; + + # wireless client + networking.wireless = { + # the override is needed because the wifi is + # disabled with mkVMOverride in qemu-vm.nix. + enable = lib.mkOverride 0 true; + userControlled.enable = true; + interfaces = [ "wlan1" ]; + fallbackToWPA2 = true; + + networks = { + # test WPA2 fallback + mixed-wpa = { + psk = "password"; + authProtocols = [ "WPA-PSK" "SAE" ]; + }; + sae-only = { + psk = "password"; + authProtocols = [ "SAE" ]; + }; + + # secrets substitution test cases + test1.psk = "@PSK_VALID@"; # should be replaced + test2.psk = "@PSK_SPECIAL@"; # should be replaced + test3.psk = "@PSK_MISSING@"; # should not be replaced + test4.psk = "P@ssowrdWithSome@tSymbol"; # should not be replaced + }; + + # secrets + environmentFile = pkgs.writeText "wpa-secrets" '' + PSK_VALID="S0m3BadP4ssw0rd"; + # taken from https://github.com/minimaxir/big-list-of-naughty-strings + PSK_SPECIAL=",./;'[]\-= <>?:\"{}|_+ !@#$%^\&*()`~"; + ''; + }; }; + # Test connecting to the SAE-only hotspot using SAE + machineSae = machineWithHostapd { + networking.wireless = { + fallbackToWPA2 = false; + networks.nixos-test-sae = { + psk = "@PSK_NIXOS_TEST@"; + authProtocols = [ "SAE" ]; + }; + }; + }; + + # Test connecting to the SAE and WPA2 mixed hotspot using SAE + machineMixedUsingSae = machineWithHostapd { + networking.wireless = { + fallbackToWPA2 = false; + networks.nixos-test-mixed = { + psk = "@PSK_NIXOS_TEST@"; + authProtocols = [ "SAE" ]; + }; + }; + }; + + # Test connecting to the SAE and WPA2 mixed hotspot using WPA2 + machineMixedUsingWpa2 = machineWithHostapd { + networking.wireless = { + fallbackToWPA2 = true; + networks.nixos-test-mixed = { + psk = "@PSK_NIXOS_TEST@"; + authProtocols = [ "WPA-PSK-SHA256" ]; + }; + }; + }; + + # Test connecting to the WPA2 legacy hotspot using WPA2 + machineWpa2 = machineWithHostapd { + networking.wireless = { + fallbackToWPA2 = true; + networks.nixos-test-wpa2 = { + psk = "@PSK_NIXOS_TEST@"; + authProtocols = [ "WPA-PSK-SHA256" ]; + }; + }; + }; }; testScript = @@ -66,30 +162,47 @@ import ./make-test-python.nix ({ pkgs, lib, ...}: config_file = "/run/wpa_supplicant/wpa_supplicant.conf" with subtest("Configuration file is inaccessible to other users"): - machine.wait_for_file(config_file) - machine.fail(f"sudo -u nobody ls {config_file}") + basic.wait_for_file(config_file) + basic.fail(f"sudo -u nobody ls {config_file}") with subtest("Secrets variables have been substituted"): - machine.fail(f"grep -q @PSK_VALID@ {config_file}") - machine.fail(f"grep -q @PSK_SPECIAL@ {config_file}") - machine.succeed(f"grep -q @PSK_MISSING@ {config_file}") - machine.succeed(f"grep -q P@ssowrdWithSome@tSymbol {config_file}") + basic.fail(f"grep -q @PSK_VALID@ {config_file}") + basic.fail(f"grep -q @PSK_SPECIAL@ {config_file}") + basic.succeed(f"grep -q @PSK_MISSING@ {config_file}") + basic.succeed(f"grep -q P@ssowrdWithSome@tSymbol {config_file}") with subtest("WPA2 fallbacks have been generated"): - assert int(machine.succeed(f"grep -c sae-only {config_file}")) == 1 - assert int(machine.succeed(f"grep -c mixed-wpa {config_file}")) == 2 + assert int(basic.succeed(f"grep -c sae-only {config_file}")) == 1 + assert int(basic.succeed(f"grep -c mixed-wpa {config_file}")) == 2 # save file for manual inspection - machine.copy_from_vm(config_file) + basic.copy_from_vm(config_file) with subtest("Daemon is running and accepting connections"): - machine.wait_for_unit("wpa_supplicant-wlan1.service") - status = machine.succeed("wpa_cli -i wlan1 status") + basic.wait_for_unit("wpa_supplicant-wlan1.service") + status = basic.succeed("wpa_cli -i wlan1 status") assert "Failed to connect" not in status, \ "Failed to connect to the daemon" - with subtest("Daemon can connect to the access point"): - machine.wait_until_succeeds( + machineSae.wait_for_unit("hostapd.service") + machineSae.copy_from_vm("/run/hostapd/wlan0.hostapd.conf") + with subtest("Daemon can connect to the SAE access point using SAE"): + machineSae.wait_until_succeeds( + "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED" + ) + + with subtest("Daemon can connect to the SAE and WPA2 mixed access point using SAE"): + machineMixedUsingSae.wait_until_succeeds( + "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED" + ) + + with subtest("Daemon can connect to the SAE and WPA2 mixed access point using WPA2"): + machineMixedUsingWpa2.wait_until_succeeds( + "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED" + ) + + with subtest("Daemon can connect to the WPA2 access point using WPA2"): + machineWpa2.wait_until_succeeds( "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED" ) ''; diff --git a/pkgs/os-specific/linux/hostapd/default.nix b/pkgs/os-specific/linux/hostapd/default.nix index 85d48683cf04..a35bd38add27 100644 --- a/pkgs/os-specific/linux/hostapd/default.nix +++ b/pkgs/os-specific/linux/hostapd/default.nix @@ -60,11 +60,6 @@ stdenv.mkDerivation rec { # TKIP is considered insecure and upstream support will be removed in the future CONFIG_NO_TKIP=y - # Enable Wi-Fi Protected Setup - CONFIG_WPS=y - CONFIG_WPS_UPNP=y - CONFIG_WPS_NFC=y - # Misc CONFIG_RADIUS_SERVER=y CONFIG_FULL_DYNAMIC_VLAN=y