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:
@@ -12,10 +12,11 @@ Naming: `gw-<city>-<n>`, e.g. `gw-cnx-1`.
|
||||
|
||||
The service has a NixOS VM test (`modules/clan/router/tests/vm/`): an ISP
|
||||
node runs a PPPoE access concentrator, a `client` sits on the tagged lan/iot
|
||||
VLANs behind the trunk, an `admin` on the untagged mgmt access port. It checks
|
||||
the PPPoE dial-in, leases/reservations, Blocky, NAT, `allowWan`, the mgmt-only
|
||||
trust model and SSH exposure. Run it with `nix build .#checks.x86_64-linux.router`
|
||||
(also part of `nix flake check`).
|
||||
VLANs behind the trunk, an `admin` on the untagged mgmt access port, and a
|
||||
simulated radio (`mac80211_hwsim`) carries two SSIDs with a WPA3 station. It
|
||||
checks the PPPoE dial-in, leases/reservations, Blocky, NAT, `allowWan`, the
|
||||
mgmt-only trust model, SSH exposure and the Wi-Fi bridge ports. Run it with
|
||||
`nix build .#checks.x86_64-linux.router` (also part of `nix flake check`).
|
||||
|
||||
## What each gateway runs
|
||||
|
||||
@@ -29,6 +30,7 @@ trust model and SSH exposure. Run it with `nix build .#checks.x86_64-linux.route
|
||||
| IPv6 | DHCPv6-PD on ppp0, /64 per VLAN via SLAAC |
|
||||
| Bans | CrowdSec + nftables bouncer (sshd log parsing) |
|
||||
| Omada | Optional per site: TP-Link Omada controller as a podman container |
|
||||
| Wi-Fi | Optional: hostapd on the router's radios; each SSID (`wifi.networks`) is an untagged access port of its VLAN, passphrases via vars prompts — see `modules/clan/router/README.md` |
|
||||
| Proxy | Optional: Caddy reverse proxy for internal services under `*.<site><n>.cnx.network` with a real Let's Encrypt wildcard (DNS-01 against ns1) |
|
||||
| Diagnostics | iperf3 server on 5201, reachable from every VLAN and the mesh (`iperf3 -c <router-vlan-addr>`); CLI toolkit: tcpdump, mtr, ethtool, conntrack, kdig, iftop, librespeed-cli |
|
||||
| Speed test | Hourly librespeed run (`speedtest.timer`) → `speedtest_*` metrics via node_exporter; vmalert flags download < 50% of the link's own 7-day median (`WANSpeedDegraded`) |
|
||||
|
||||
@@ -4,8 +4,9 @@ Turns a machine with several NICs into a site gateway: PPPoE WAN (ISP
|
||||
credentials via vars prompts), a VLAN-filtering bridge over the LAN ports with
|
||||
one L3 interface per VLAN, Kea DHCP and Blocky DNS per VLAN, nftables
|
||||
firewall/NAT, DHCPv6-PD, CrowdSec, an iperf3 server and a WAN speed-test
|
||||
timer. Optional: the TP-Link Omada controller (podman) and an internal Caddy
|
||||
reverse proxy with a real wildcard certificate (ACME DNS-01).
|
||||
timer. Optional: a Wi-Fi access point on the router's own radios (hostapd),
|
||||
the TP-Link Omada controller (podman) and an internal Caddy reverse proxy
|
||||
with a real wildcard certificate (ACME DNS-01).
|
||||
|
||||
Addressing convention: a site owns `10.<siteId>.0.0/16`; VLAN `<id>` defaults
|
||||
to `10.<siteId>.<id>.0/24`, router at `.1`, DHCP pool `.100-.199`. The `mgmt`
|
||||
@@ -42,6 +43,34 @@ inventory.instances.router = {
|
||||
|
||||
Then `clan vars generate gw-1` prompts for the PPPoE username/password.
|
||||
|
||||
### Wi-Fi access point
|
||||
|
||||
If the box has wireless cards, the router can be the site's AP. An SSID is
|
||||
defined once and behaves like an untagged access port of its VLAN; radios
|
||||
choose what to broadcast, so a dual-band card serves the same SSID twice:
|
||||
|
||||
```nix
|
||||
wifi = {
|
||||
enable = true;
|
||||
countryCode = "TH";
|
||||
networks = {
|
||||
home.vlan = "lan"; # WPA3 with WPA2 fallback
|
||||
things = { vlan = "iot"; security = "wpa2"; }; # legacy IoT
|
||||
guest = { vlan = "guest"; isolateClients = true; };
|
||||
};
|
||||
radios = {
|
||||
wlp5s0 = { band = "2g"; channel = 6; macAddress = "…"; networks = [ "home" "things" ]; };
|
||||
wlp6s0 = { band = "5g"; channel = 36; networks = [ "home" ]; };
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Passphrases are vars prompts (`wifi-<name>-passphrase`), asked once at `clan
|
||||
vars generate`. A radio broadcasting more than one SSID needs its hardware
|
||||
`macAddress`: hostapd wants a fixed BSSID per extra SSID, derived from it.
|
||||
`security = "wpa3-transition"` (the default) offers SAE and WPA2-PSK-SHA256;
|
||||
devices that only speak classic WPA2-PSK need `security = "wpa2"`.
|
||||
|
||||
### Internal proxy
|
||||
|
||||
`proxy.enable` serves `<name>.<site><siteId>.<proxy.domain>` under a wildcard
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
./proxy.nix
|
||||
./iperf.nix
|
||||
./speedtest.nix
|
||||
./wifi.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ let
|
||||
lib.filterAttrs (_: vlan: vlan.allowWan) cfg.vlans
|
||||
);
|
||||
nonMgmtIfs = lib.filter (i: i != "vlan-mgmt") vlanIfs;
|
||||
ifSet = ifs: "{ ${lib.concatStringsSep ", " (map (i: "\"${i}\"") ifs)} }";
|
||||
in
|
||||
{
|
||||
networking.nftables.enable = true;
|
||||
@@ -44,13 +43,16 @@ in
|
||||
extraForwardRules = ''
|
||||
tcp flags syn tcp option maxseg size set rt mtu comment "MSS clamp for PPPoE mtu 1492"
|
||||
iifname "vlan-mgmt" accept comment "mgmt reaches all VLANs and the WAN"
|
||||
iifname ${ifSet wanVlanIfs} oifname "ppp0" accept comment "LAN to internet"
|
||||
'';
|
||||
};
|
||||
|
||||
# networking.nat both masquerades and opens forward-to-WAN for exactly its
|
||||
# internalInterfaces — so this list, not a rule of our own, is where
|
||||
# `allowWan` is enforced. Listing every VLAN here would silently let
|
||||
# allowWan = false VLANs out.
|
||||
networking.nat = {
|
||||
enable = true;
|
||||
externalInterface = "ppp0";
|
||||
internalInterfaces = vlanIfs;
|
||||
internalInterfaces = wanVlanIfs;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,6 +106,94 @@ let
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
wifiNetworkModule =
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
ssid = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = name;
|
||||
defaultText = lib.literalExpression "<attribute name>";
|
||||
description = "SSID as advertised; defaults to the attribute name.";
|
||||
};
|
||||
vlan = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "lan";
|
||||
description = "VLAN (by name) the clients of this SSID land in, like an untagged access port.";
|
||||
};
|
||||
security = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"wpa3"
|
||||
"wpa3-transition"
|
||||
"wpa2"
|
||||
"open"
|
||||
];
|
||||
default = "wpa3-transition";
|
||||
description = ''
|
||||
- `wpa3`: WPA3-Personal (SAE) only.
|
||||
- `wpa3-transition`: WPA3 with WPA2 fallback for older clients.
|
||||
- `wpa2`: WPA2-PSK only, for legacy IoT devices.
|
||||
- `open`: no encryption (captive/guest use; pair with an isolated VLAN).
|
||||
Except for `open`, the passphrase is a vars prompt
|
||||
(`wifi-<name>-passphrase`, entered at `clan vars generate`).
|
||||
'';
|
||||
};
|
||||
hidden = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Do not advertise the SSID in beacons (clients must know it).";
|
||||
};
|
||||
isolateClients = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Keep wireless clients of this SSID from talking to each other (guest networks).";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
wifiRadioModule = {
|
||||
options = {
|
||||
band = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"2g"
|
||||
"5g"
|
||||
"6g"
|
||||
];
|
||||
default = "2g";
|
||||
description = "Frequency band of this radio; a dual-band card exposes one radio interface per band.";
|
||||
};
|
||||
channel = lib.mkOption {
|
||||
type = lib.types.ints.unsigned;
|
||||
default = 0;
|
||||
description = "Channel; 0 lets hostapd pick one (ACS) — not every driver supports that.";
|
||||
};
|
||||
wifi6 = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable 802.11ax (WiFi 6) on this radio; WiFi 4/5 are always on.";
|
||||
};
|
||||
macAddress = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "aa:bb:cc:dd:ee:ff";
|
||||
description = ''
|
||||
Hardware address of the radio (facter.json / `ip link`). Needed when
|
||||
the radio serves more than one network: hostapd wants a fixed BSSID
|
||||
per extra network, derived from this address (locally administered
|
||||
variants of its first octet).
|
||||
'';
|
||||
};
|
||||
networks = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
example = [
|
||||
"home"
|
||||
"iot"
|
||||
];
|
||||
description = "Networks (from `wifi.networks`) this radio broadcasts; at most four per radio.";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options = {
|
||||
@@ -225,6 +313,46 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
wifi = {
|
||||
enable = lib.mkEnableOption "a Wi-Fi access point on the router's own radios (hostapd)";
|
||||
|
||||
countryCode = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "TH";
|
||||
description = "ISO 3166-1 country code for the regulatory domain; required when enabled.";
|
||||
};
|
||||
|
||||
networks = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule wifiNetworkModule);
|
||||
default = { };
|
||||
example = {
|
||||
home.vlan = "lan";
|
||||
things = {
|
||||
vlan = "iot";
|
||||
security = "wpa2";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
Wireless networks (SSIDs). Each one behaves like an untagged access
|
||||
port on its VLAN; the radios below choose which to broadcast.
|
||||
'';
|
||||
};
|
||||
|
||||
radios = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule wifiRadioModule);
|
||||
default = { };
|
||||
example = {
|
||||
wlp5s0 = {
|
||||
band = "5g";
|
||||
channel = 36;
|
||||
networks = [ "home" ];
|
||||
};
|
||||
};
|
||||
description = "Wireless radios of the router (interface name -> config); at least one when enabled.";
|
||||
};
|
||||
};
|
||||
|
||||
speedtest.interval = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "hourly";
|
||||
|
||||
@@ -68,6 +68,29 @@ in
|
||||
allowWan = false;
|
||||
};
|
||||
};
|
||||
# Access point on the simulated radio wlan0 (mac80211_hwsim, address
|
||||
# 02:00:00:00:00:00): two SSIDs, one per VLAN.
|
||||
wifi = {
|
||||
enable = true;
|
||||
countryCode = "US";
|
||||
networks = {
|
||||
home.vlan = "lan";
|
||||
things = {
|
||||
vlan = "iot";
|
||||
security = "wpa2";
|
||||
hidden = true;
|
||||
isolateClients = true;
|
||||
};
|
||||
};
|
||||
radios.wlan0 = {
|
||||
channel = 6;
|
||||
macAddress = "02:00:00:00:00:00";
|
||||
networks = [
|
||||
"home"
|
||||
"things"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -102,6 +125,54 @@ in
|
||||
];
|
||||
services.crowdsec.enable = lib.mkForce false;
|
||||
services.crowdsec-firewall-bouncer.enable = lib.mkForce false;
|
||||
|
||||
# Two simulated radios: wlan0 is the AP (settings above), wlan1 plays a
|
||||
# wireless client. It lives in its own network namespace, like the
|
||||
# separate host it stands in for — otherwise its lease would add a
|
||||
# second 10.9.20.0/24 route to the router's own table. Its DHCP lease
|
||||
# must come from Kea on the SSID's VLAN: wlan1 -> air -> wlan0 -> br0 ->
|
||||
# vlan-lan. The mock passphrase is what the vars mock fed into the
|
||||
# wifi-home-passphrase generator.
|
||||
boot.kernelModules = [ "mac80211_hwsim" ];
|
||||
systemd.services.wifi-station = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# No BindsTo: the device unit vanishes once wlan1 moves into the netns.
|
||||
after = [ "sys-subsystem-net-devices-wlan1.device" ];
|
||||
path = [
|
||||
pkgs.iproute2
|
||||
pkgs.iw
|
||||
];
|
||||
preStart = ''
|
||||
ip netns add sta
|
||||
iw phy phy1 set netns name sta
|
||||
ip netns exec sta ip link set lo up
|
||||
mkdir -p /run/wpa_supplicant/client # nixpkgs' wpa_cli keeps its sockets here
|
||||
'';
|
||||
serviceConfig.ExecStart =
|
||||
"${pkgs.iproute2}/bin/ip netns exec sta ${pkgs.wpa_supplicant}/bin/wpa_supplicant -i wlan1 -c "
|
||||
+ pkgs.writeText "sta.conf" ''
|
||||
ctrl_interface=/run/wpa_supplicant/control
|
||||
network={
|
||||
ssid="home"
|
||||
key_mgmt=SAE
|
||||
sae_password="mock-prompt-value-passphrase"
|
||||
ieee80211w=2
|
||||
scan_freq=2437
|
||||
}
|
||||
'';
|
||||
};
|
||||
environment.systemPackages = [
|
||||
pkgs.wpa_supplicant
|
||||
(pkgs.writeShellScriptBin "sta-dhcp" ''
|
||||
# One DHCP round on the station, applying the offered address.
|
||||
exec ${pkgs.iproute2}/bin/ip netns exec sta ${pkgs.busybox}/bin/udhcpc -i wlan1 -n -q -f \
|
||||
-s ${pkgs.writeShellScript "udhcpc-apply" ''
|
||||
case "$1" in
|
||||
bound|renew) ${pkgs.iproute2}/bin/ip addr replace "$ip/$mask" dev "$interface" ;;
|
||||
esac
|
||||
''}
|
||||
'')
|
||||
];
|
||||
};
|
||||
|
||||
isp = {
|
||||
@@ -155,6 +226,13 @@ in
|
||||
macAddress = clientMac;
|
||||
};
|
||||
networking.interfaces.iot0.useDHCP = true;
|
||||
# Dual-homed on purpose (lan + iot). Both leases bring a default route:
|
||||
# prefer lan0 so replies to other VLANs and the WAN leave where the
|
||||
# router expects them (its rp-filter would drop them on vlan-iot), and
|
||||
# loosen this client's own rp-filter so a WAN reply on iot0 would be
|
||||
# seen — the negative allowWan check must fail at the router, not here.
|
||||
systemd.network.networks."40-iot0".dhcpV4Config.RouteMetric = 2048;
|
||||
networking.firewall.checkReversePath = "loose";
|
||||
environment.systemPackages = [
|
||||
pkgs.dnsutils
|
||||
pkgs.netcat
|
||||
@@ -195,6 +273,8 @@ in
|
||||
|
||||
with subtest("NAT to the WAN only for VLANs with allowWan"):
|
||||
client.wait_until_succeeds("ping -c1 -W1 -I lan0 ${ispAddress}")
|
||||
# iot has a route to the WAN; the router is what refuses to forward.
|
||||
client.succeed("ip route show dev iot0 | grep -q ^default")
|
||||
client.fail("ping -c1 -W2 -I iot0 ${ispAddress}")
|
||||
|
||||
with subtest("mgmt reaches other VLANs, other VLANs do not"):
|
||||
@@ -205,5 +285,22 @@ in
|
||||
gw.wait_for_open_port(22)
|
||||
admin.succeed("nc -z -w2 10.9.10.1 22")
|
||||
client.fail("nc -z -w2 10.9.20.1 22")
|
||||
|
||||
with subtest("Wireless SSIDs are bridge access ports of their VLAN"):
|
||||
gw.wait_for_unit("hostapd.service")
|
||||
# Second SSID: derived BSSID, hidden, bridged into the iot VLAN.
|
||||
gw.wait_until_succeeds("ip link show wlan0-1 | grep -q '06:00:00:00:00:00'")
|
||||
gw.wait_until_succeeds("bridge vlan show dev wlan0-1 | grep -q '40 PVID Egress Untagged'")
|
||||
gw.succeed("hostapd_cli -i wlan0-1 get_config | grep -q '^ssid=things'")
|
||||
# The second BSS section of the rendered config carries hidden + isolation.
|
||||
things = gw.succeed("sed -n '/^bss=wlan0-1/,$p' /run/hostapd/wlan0.hostapd.conf")
|
||||
assert "ignore_broadcast_ssid=1" in things and "ap_isolate=1" in things, things
|
||||
assert "sae_password=mock-prompt-value-passphrase" not in things, "wpa2 SSID must not carry SAE entries"
|
||||
# A WPA3 (SAE) station on the first SSID authenticates with the vars
|
||||
# passphrase and gets its lease from Kea on the lan VLAN, through the bridge.
|
||||
gw.wait_for_unit("wifi-station.service")
|
||||
gw.wait_until_succeeds("ip netns exec sta wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED")
|
||||
gw.succeed("timeout 60 sta-dhcp")
|
||||
gw.succeed("ip netns exec sta ip -4 addr show wlan1 | grep -q 'inet 10.9.20.1[0-9][0-9]/24'")
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -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