router: optional Wi-Fi access point, enforce allowWan
wifi.* settings turn the gateway's own radios into the site AP (hostapd): SSIDs are defined once in wifi.networks and act as untagged access ports of their VLAN (each BSS joins br0 with the VLAN's PVID), radios pick what they broadcast, passphrases are vars prompts. Extra SSIDs on a radio get BSSIDs derived from its hardware address. A udev rule puts configured radios in AP mode on appearance, since networkd cannot bridge a station-mode interface and gives up before hostapd switches it. wifi.enable gates all of it. The VM test grows a hwsim radio with two SSIDs and a WPA3 station in its own netns that must get a Kea lease on the SSID's VLAN. Making the client deterministic (route metrics, loose rp-filter, a guard against a vacuous negative check) exposed that allowWan was never enforced: networking.nat opens forward-to-WAN for all of its internalInterfaces, which listed every VLAN. It now lists only the allowWan VLANs; the duplicate custom rule is gone. No behavioural change on gw-cnx-1 (all its VLANs allow WAN).
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