207 lines
6.6 KiB
Nix
207 lines
6.6 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,
|
|
pkgs,
|
|
...
|
|
}:
|
|
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;
|
|
|
|
# The kernel refuses to bridge a wireless interface in station mode, and
|
|
# networkd stops retrying before hostapd switches the radio to AP mode;
|
|
# so put it in AP mode the moment it appears (kernel name or the renamed
|
|
# one, whichever the user configured). hostapd finds it already there.
|
|
services.udev.extraRules = lib.concatMapStrings (
|
|
radio:
|
|
let
|
|
run = ''RUN+="${pkgs.iw}/bin/iw dev ${radio} set type __ap"'';
|
|
in
|
|
''
|
|
ACTION=="add", SUBSYSTEM=="net", KERNEL=="${radio}", ${run}
|
|
ACTION=="add", SUBSYSTEM=="net", NAME=="${radio}", ${run}
|
|
''
|
|
) (lib.attrNames wifi.radios);
|
|
|
|
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); networkd enslaves the interface once hostapd creates it.
|
|
systemd.network.networks = lib.listToAttrs (
|
|
map (
|
|
b:
|
|
lib.nameValuePair "27-wifi-${b.iface}" {
|
|
matchConfig.Name = b.iface;
|
|
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
|
|
);
|
|
};
|
|
}
|