services/router: init
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
# 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
|
||||
);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user