Author SHA1 Message Date
kurogeek 4c589b8d0c router: allow SSH on the staging port
The staging uplink is in no VLAN zone and was fully default-deny; admit
TCP 22 on it so the box is reachable from the old LAN before the mgmt
VLAN or the mesh are up. Everything else on the port stays closed.
2026-09-16 08:30:14 +00:00
kurogeek c9b04711c9 router: stagingPort option, move gw-cnx-1 staging uplink into it
The pre-cutover DHCP-client uplink into the old LAN was a hand-written
systemd.network block in the gw-cnx-1 machine config. Make it a router
setting next to trunkPorts/accessPorts so every replacement gateway can
stage the same way, with an assertion that the port is not also the WAN,
a trunk or an access port. gw-cnx-1 sets stagingPort = "enp3s0" in the
inventory; the machine-local block is gone.
2026-09-16 08:30:14 +00:00
kurogeek c0c2193429 inventory.nix: router instance for gw-cnx-1
Move the `router` service instance out of clan.nix into its own clan
module, inventory.nix, imported by clan.nix. Fleet-wide role settings
(mesh subnet, proxy domain/ACME) and the gw-cnx-1 site layout live there;
clan.nix keeps the machine list and the clan-core services. The gw-cnx-1
toplevel is byte-identical before and after the move.
2026-09-10 03:35:37 +00:00
kurogeek 63e8b6252c 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).
2026-09-09 09:24:34 +00:00
kurogeek a3705e7a93 mob next [ci-skip] [ci skip] [skip ci]
lastFile:docs/src/gateways.md
2026-09-09 13:43:46 +07:00
10 changed files with 75 additions and 187 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ mgmt-only trust model, SSH exposure and the Wi-Fi bridge ports. Run it with
| DHCP | Kea, one subnet per VLAN | | DHCP | Kea, one subnet per VLAN |
| DNS | Blocky (blocklist resolver), metrics on :4000 scraped by control | | DNS | Blocky (blocklist resolver), metrics on :4000 scraped by control |
| IPv6 | DHCPv6-PD on ppp0, /64 per VLAN via SLAAC | | IPv6 | DHCPv6-PD on ppp0, /64 per VLAN via SLAAC |
| Bans | Optional per site (`crowdsec.enable`): CrowdSec + nftables bouncer (sshd log parsing) | | Bans | CrowdSec + nftables bouncer (sshd log parsing) |
| Omada | Optional per site: TP-Link Omada controller as a podman container | | 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` | | 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) | | Proxy | Optional: Caddy reverse proxy for internal services under `*.<site><n>.cnx.network` with a real Let's Encrypt wildcard (DNS-01 against ns1) |
@@ -71,7 +71,7 @@ Trust model: mgmt → everything; other VLANs → router DNS/DHCP + internet onl
installer: `ls -l /dev/disk/by-id/`). installer: `ls -l /dev/disk/by-id/`).
2. Add the machine to `inventory.machines` in `clan.nix`, to the `router` 2. Add the machine to `inventory.machines` in `clan.nix`, to the `router`
instance in `inventory.nix` (`roles.default.machines.gw-<city>-<n>.settings`: `site`, instance in `inventory.nix` (`roles.default.machines.gw-<city>-<n>.settings`: `site`,
`siteId` (next free number), port names, VLANs, `omada.enable`, `crowdsec.enable`; keep the `siteId` (next free number), port names, VLANs, `omada.enable`; keep the
`mgmt`/`lan` VLANs), and to the machine list in `modules/mesh-hosts.nix`. `mgmt`/`lan` VLANs), and to the machine list in `modules/mesh-hosts.nix`.
Do **not** add it to `modules/hosts.nix` (dynamic PPPoE IP; clan connects Do **not** add it to `modules/hosts.nix` (dynamic PPPoE IP; clan connects
over the mesh). over the mesh).
-2
View File
@@ -77,8 +77,6 @@ in
}; };
# This site runs the Omada controller for its APs/switches. # This site runs the Omada controller for its APs/switches.
omada.enable = true; omada.enable = true;
# sshd ban engine (was unconditional before the option existed).
crowdsec.enable = true;
# Internal reverse proxy: real wildcard cert *.cnx1.cnx.network; Blocky # Internal reverse proxy: real wildcard cert *.cnx1.cnx.network; Blocky
# resolves the names to the router's LAN address for mgmt+lan clients. # resolves the names to the router's LAN address for mgmt+lan clients.
+4 -5
View File
@@ -3,11 +3,10 @@
Turns a machine with several NICs into a site gateway: PPPoE WAN (ISP 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 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 one L3 interface per VLAN, Kea DHCP and Blocky DNS per VLAN, nftables
firewall/NAT, DHCPv6-PD, an iperf3 server and a WAN speed-test timer. firewall/NAT, DHCPv6-PD, CrowdSec, an iperf3 server and a WAN speed-test
Optional: a Wi-Fi access point on the router's own radios (hostapd), CrowdSec timer. Optional: a Wi-Fi access point on the router's own radios (hostapd),
with the nftables bouncer (sshd log parsing), the TP-Link Omada controller the TP-Link Omada controller (podman) and an internal Caddy reverse proxy
(podman) and an internal Caddy reverse proxy with a real wildcard certificate with a real wildcard certificate (ACME DNS-01).
(ACME DNS-01).
Addressing convention: a site owns `10.<siteId>.0.0/16`; VLAN `<id>` defaults 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` to `10.<siteId>.<id>.0/24`, router at `.1`, DHCP pool `.100-.199`. The `mgmt`
+2 -5
View File
@@ -1,14 +1,12 @@
# CrowdSec security engine + nftables bouncer: parses sshd auth attempts from # CrowdSec security engine + nftables bouncer: parses sshd auth attempts from
# the journal and bans offending source IPs at the firewall. Log-based (no # the journal and bans offending source IPs at the firewall. Log-based (no
# inline DPI) so it costs the N300 next to nothing. Opt-in per site # inline DPI) so it costs the N300 next to nothing.
# (`crowdsec.enable`): the hub sync needs internet at activation time.
{ settings }: { settings }:
{ lib, ... }: { ... }:
let let
cfg = settings; cfg = settings;
in in
{ {
config = lib.mkIf cfg.crowdsec.enable {
services.crowdsec = { services.crowdsec = {
enable = true; enable = true;
autoUpdateService = true; autoUpdateService = true;
@@ -45,5 +43,4 @@ in
registerBouncer.enable = true; registerBouncer.enable = true;
settings.mode = "nftables"; settings.mode = "nftables";
}; };
};
} }
+1 -17
View File
@@ -12,19 +12,7 @@ in
services.kea.dhcp4 = { services.kea.dhcp4 = {
enable = true; enable = true;
settings = { settings = {
interfaces-config = { interfaces-config.interfaces = lib.mapAttrsToList (name: _: "vlan-${name}") dhcpVlans;
interfaces = lib.mapAttrsToList (name: _: "vlan-${name}") dhcpVlans;
# The unit orders after network-online.target, which under networkd
# only waits for the WAN carrier (the vlan-* links are
# RequiredForOnline=no), so Kea can start before vlan-* have their
# addresses. By default it then logs the failed bind and runs with no
# socket at all: clients' DISCOVERs reach vlan-lan and nobody answers.
# Insist on every socket and keep retrying while networkd catches up;
# if it still cannot bind, exit and let systemd restart the unit.
service-sockets-require-all = true;
service-sockets-max-retries = 60;
service-sockets-retry-wait-time = 1000;
};
lease-database = { lease-database = {
type = "memfile"; type = "memfile";
persist = true; persist = true;
@@ -56,10 +44,6 @@ in
}; };
}; };
# The nixpkgs unit already has Restart=on-failure; space the restarts out so
# a persistent bind failure does not trip the start-rate limit.
systemd.services.kea-dhcp4-server.serviceConfig.RestartSec = 5;
services.blocky = { services.blocky = {
enable = true; enable = true;
settings = { settings = {
+1 -27
View File
@@ -3,8 +3,7 @@
# other VLANs -> DNS/DHCP on the router + WAN (if allowWan); no inter-VLAN # other VLANs -> DNS/DHCP on the router + WAN (if allowWan); no inter-VLAN
# WAN (ppp0) -> nothing inbound beyond established/related # WAN (ppp0) -> nothing inbound beyond established/related
# mesh -> admin SSH + metrics scrapes (same trust boundary as the fleet) # mesh -> admin SSH + metrics scrapes (same trust boundary as the fleet)
# staging -> admin SSH only inbound (pre-cutover uplink into the old LAN); # staging -> admin SSH only (pre-cutover uplink into the old LAN)
# allowWan VLANs are NATed out through it while ppp0 is down
{ settings }: { settings }:
{ lib, ... }: { lib, ... }:
let let
@@ -15,13 +14,6 @@ let
lib.filterAttrs (_: vlan: vlan.allowWan) cfg.vlans lib.filterAttrs (_: vlan: vlan.allowWan) cfg.vlans
); );
nonMgmtIfs = lib.filter (i: i != "vlan-mgmt") vlanIfs; nonMgmtIfs = lib.filter (i: i != "vlan-mgmt") vlanIfs;
# allowWan VLANs may also leave through the staging uplink. Same set as
# networking.nat.internalInterfaces below, so `allowWan` holds on both
# exits. The kernel picks the exit: ppp0 (metric 0, see pppoe.nix) while
# the session is up, the staging DHCP route (metric 1024) otherwise.
stagingExit = cfg.stagingPort != null && wanVlanIfs != [ ];
wanVlanSet = "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') wanVlanIfs} }";
in in
{ {
networking.nftables.enable = true; networking.nftables.enable = true;
@@ -58,9 +50,6 @@ in
extraForwardRules = '' extraForwardRules = ''
tcp flags syn tcp option maxseg size set rt mtu comment "MSS clamp for PPPoE mtu 1492" 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 "vlan-mgmt" accept comment "mgmt reaches all VLANs and the WAN"
''
+ lib.optionalString stagingExit ''
iifname ${wanVlanSet} oifname "${cfg.stagingPort}" accept comment "allowWan VLANs out via the staging uplink"
''; '';
}; };
@@ -73,19 +62,4 @@ in
externalInterface = "ppp0"; externalInterface = "ppp0";
internalInterfaces = wanVlanIfs; internalInterfaces = wanVlanIfs;
}; };
# networking.nat only masquerades on its single externalInterface; the
# staging uplink needs its own postrouting chain (nixos-nat's is
# oifname-scoped to ppp0, so the two never both apply).
networking.nftables.tables = lib.optionalAttrs stagingExit {
router-staging-nat = {
family = "ip";
content = ''
chain post {
type nat hook postrouting priority srcnat;
iifname ${wanVlanSet} oifname "${cfg.stagingPort}" masquerade comment "allowWan VLANs out via the staging uplink"
}
'';
};
};
} }
+7 -11
View File
@@ -256,15 +256,13 @@ in
example = "enp3s0"; example = "enp3s0";
description = '' description = ''
Temporary DHCPv4-client uplink into the existing LAN while the box Temporary DHCPv4-client uplink into the existing LAN while the box
runs alongside the router it replaces: gives it (and, NATed, the runs alongside the router it replaces: gives it internet + mesh
allowWan VLANs) internet + mesh before the WAN port is cabled; once before the WAN port is cabled (PPPoE simply retries until then). The
the PPPoE session is up its default route wins, and the staging port is in no VLAN zone; the firewall admits only SSH on it. Do NOT
route only carries traffic again if the session drops (PPPoE simply connect the trunk ports to the production switch while staging
retries until then). The port is in no VLAN zone; inbound, the Kea on the mgmt tag would fight the old router's DHCP in one
firewall admits only SSH on it. Do NOT connect the trunk ports to broadcast domain. Set to null at cutover (and usually hand the port
the production switch while staging Kea on the mgmt tag would back to `trunkPorts`).
fight the old router's DHCP in one broadcast domain. Set to null at
cutover (and usually hand the port back to `trunkPorts`).
''; '';
}; };
@@ -285,8 +283,6 @@ in
omada.enable = lib.mkEnableOption "TP-Link Omada SDN controller (podman container)"; omada.enable = lib.mkEnableOption "TP-Link Omada SDN controller (podman container)";
crowdsec.enable = lib.mkEnableOption "CrowdSec (sshd log parsing) with the nftables bouncer";
proxy = { proxy = {
enable = lib.mkEnableOption "internal reverse proxy (Caddy, wildcard cert via DNS-01)"; enable = lib.mkEnableOption "internal reverse proxy (Caddy, wildcard cert via DNS-01)";
-6
View File
@@ -30,11 +30,6 @@ in
''; '';
}; };
# defaultroute-metric 0: pppd refuses `defaultroute` while any other
# default route exists (e.g. the staging uplink's DHCP route, metric 1024,
# network.nix) unless given a metric; with 0 it only checks for a metric-0
# route, installs its own as the preferred exit, and removes it again on
# hangup so the staging route takes over.
services.pppd = { services.pppd = {
enable = true; enable = true;
peers.wan = { peers.wan = {
@@ -45,7 +40,6 @@ in
file ${creds.files."user-opts".path} file ${creds.files."user-opts".path}
noipdefault noipdefault
defaultroute defaultroute
defaultroute-metric 0
noauth noauth
hide-password hide-password
persist persist
+7 -60
View File
@@ -1,17 +1,14 @@
# End-to-end VM test of the router service: a PPPoE access concentrator plays # End-to-end VM test of the router service: a PPPoE access concentrator plays
# the ISP on the WAN port, a trunk carries tagged lan/iot VLANs to `client`, # the ISP on the WAN port, a trunk carries tagged lan/iot VLANs to `client`,
# an untagged access port carries mgmt to `admin`, and `oldlan` is the DHCP # and an untagged access port carries mgmt to `admin`.
# network the box is staged in before cutover.
# #
# isp ---(vlan 1: PPPoE)--- wan [gw] trunk ---(vlan 2: tagged 20/40)--- client # isp ---(vlan 1: PPPoE)--- wan [gw] trunk ---(vlan 2: tagged 20/40)--- client
# access --(vlan 3: untagged mgmt)--- admin # access --(vlan 3: untagged mgmt)--- admin
# staging -(vlan 4: DHCP client)--- oldlan
# #
# What is proven: PPPoE dial-in with the vars-provided credentials, bridge # What is proven: PPPoE dial-in with the vars-provided credentials, bridge
# VLAN tagging/untagging, Kea leases and reservations per VLAN, Blocky # VLAN tagging/untagging, Kea leases and reservations per VLAN, Blocky
# answering on the VLAN with the blocklist active, NAT to the WAN, the # answering on the VLAN with the blocklist active, NAT to the WAN, and the
# firewall trust model (allowWan, mgmt-only SSH, no inter-VLAN forwarding), # firewall trust model (allowWan, mgmt-only SSH, no inter-VLAN forwarding).
# and the staging uplink as NATed fallback exit behind ppp0.
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
# The vars mock answers every prompt with "mock-prompt-value-<name>"; the # The vars mock answers every prompt with "mock-prompt-value-<name>"; the
@@ -23,10 +20,6 @@ let
clientAddress = "10.9.20.50"; clientAddress = "10.9.20.50";
adminMac = "02:00:00:00:00:10"; adminMac = "02:00:00:00:00:10";
adminAddress = "10.9.10.50"; adminAddress = "10.9.10.50";
oldlanAddress = "192.168.88.1";
# Only reachable through oldlan's router role, i.e. via gw's staging
# default route (metric 1024); ppp0's metric-0 default must win while up.
beyondStaging = "203.0.113.1";
in in
{ {
name = "router"; name = "router";
@@ -43,7 +36,6 @@ in
isp = { }; isp = { };
client = { }; client = { };
admin = { }; admin = { };
oldlan = { };
}; };
instances.router = { instances.router = {
@@ -56,7 +48,6 @@ in
wan.interface = "wan"; wan.interface = "wan";
trunkPorts = [ "trunk" ]; trunkPorts = [ "trunk" ];
accessPorts.access = "mgmt"; accessPorts.access = "mgmt";
stagingPort = "staging";
vlans = { vlans = {
mgmt = { mgmt = {
id = 10; id = 10;
@@ -120,10 +111,6 @@ in
vlan = 3; vlan = 3;
assignIP = false; assignIP = false;
}; };
staging = {
vlan = 4;
assignIP = false;
};
}; };
# Something must listen on 22 for the mgmt-only SSH rule to be observable # Something must listen on 22 for the mgmt-only SSH rule to be observable
@@ -131,11 +118,13 @@ in
services.openssh.enable = true; services.openssh.enable = true;
# The sandbox has no internet: serve the blocklist from a local file # The sandbox has no internet: serve the blocklist from a local file
# instead of GitHub. (CrowdSec, whose hub sync needs the network too, # instead of GitHub, and skip CrowdSec, whose hub sync needs the network
# is opt-in and stays off.) # (it is not what this test exercises).
services.blocky.settings.blocking.denylists.ads = lib.mkForce [ services.blocky.settings.blocking.denylists.ads = lib.mkForce [
(toString (pkgs.writeText "ads.hosts" "0.0.0.0 ads.example.com\n")) (toString (pkgs.writeText "ads.hosts" "0.0.0.0 ads.example.com\n"))
]; ];
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 # Two simulated radios: wlan0 is the AP (settings above), wlan1 plays a
# wireless client. It lives in its own network namespace, like the # wireless client. It lives in its own network namespace, like the
@@ -262,32 +251,6 @@ in
}; };
environment.systemPackages = [ pkgs.netcat ]; environment.systemPackages = [ pkgs.netcat ];
}; };
# The LAN the box is staged in: a DHCP server handing gw its uplink
# lease, plus an address that is only reachable via that uplink's
# default route. No route back to 10.9.0.0/16: replies only reach the
# clients if gw masquerades them.
oldlan = {
virtualisation.interfaces.staging = {
vlan = 4;
assignIP = false;
};
networking.useDHCP = false;
networking.useNetworkd = true;
systemd.network.networks."10-staging" = {
matchConfig.Name = "staging";
address = [
"${oldlanAddress}/24"
"${beyondStaging}/32"
];
networkConfig.DHCPServer = true;
dhcpServerConfig = {
PoolOffset = 100;
PoolSize = 50;
};
};
networking.firewall.allowedUDPPorts = [ 67 ];
};
}; };
testScript = '' testScript = ''
@@ -339,21 +302,5 @@ in
gw.wait_until_succeeds("ip netns exec sta wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED") 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("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'") gw.succeed("ip netns exec sta ip -4 addr show wlan1 | grep -q 'inet 10.9.20.1[0-9][0-9]/24'")
with subtest("Staging uplink: NATed exit for allowWan VLANs, behind ppp0 while it is up"):
gw.wait_until_succeeds("ip -4 route show default dev staging | grep -q 'via ${oldlanAddress}'")
# pppd installs its default route despite the DHCP one (defaultroute-metric 0).
gw.succeed("ip route get ${beyondStaging} | grep -q 'dev ppp0'")
# On-link old-LAN hosts are reached through the staging port regardless.
client.succeed("ping -c1 -W2 -I lan0 ${oldlanAddress}")
client.fail("ping -c1 -W2 -I iot0 ${oldlanAddress}")
# ppp0 down: the staging route carries the WAN traffic, allowWan still holds.
gw.systemctl("stop pppd-wan.service")
gw.wait_until_succeeds("ip route get ${beyondStaging} | grep -q 'dev staging'")
client.succeed("ping -c1 -W2 -I lan0 ${beyondStaging}")
client.fail("ping -c1 -W2 -I iot0 ${beyondStaging}")
# ppp0 back: preferred again.
gw.systemctl("start pppd-wan.service")
gw.wait_until_succeeds("ip route get ${beyondStaging} | grep -q 'dev ppp0'")
''; '';
} }
+18 -19
View File
@@ -12,6 +12,7 @@
{ {
config, config,
lib, lib,
pkgs,
... ...
}: }:
let let
@@ -120,18 +121,26 @@ in
); );
message = "router: wifi.radios.<radio>.macAddress is required for radios broadcasting more than one network."; message = "router: wifi.radios.<radio>.macAddress is required for radios broadcasting more than one network.";
} }
{
assertion =
!config.networking.wireless.enable
&& !config.networking.wireless.iwd.enable
&& !config.networking.networkmanager.enable;
message = "router: wifi.enable needs the radios for hostapd; disable networking.wireless (wpa_supplicant), iwd and NetworkManager.";
}
]; ];
# Regulatory database for the kernel, so countryCode actually applies. # Regulatory database for the kernel, so countryCode actually applies.
hardware.wirelessRegulatoryDatabase = true; 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) ( clan.core.vars.generators = lib.genAttrs (map (name: "wifi-${name}-passphrase") secured) (
gen: gen:
let let
@@ -175,22 +184,12 @@ in
}; };
# Each BSS is an untagged access port of its VLAN on br0 (cf. accessPorts # Each BSS is an untagged access port of its VLAN on br0 (cf. accessPorts
# in network.nix). The kernel refuses to bridge a wireless interface in # in network.nix); networkd enslaves the interface once hostapd creates it.
# station mode (IFF_DONT_BRIDGE), and a failed enslave parks the link in
# networkd's `failed` state for good: networkd only re-evaluates a link
# when its matching .network file changes. So match on the AP interface
# type as well as the name: the radio's initial station-mode netdev
# matches nothing (unmanaged), and once hostapd switches it to AP and
# brings the carrier up, networkd matches this file for the first time
# and enslaves it. Extra BSSes are created by hostapd in AP mode already.
systemd.network.networks = lib.listToAttrs ( systemd.network.networks = lib.listToAttrs (
map ( map (
b: b:
lib.nameValuePair "27-wifi-${b.iface}" { lib.nameValuePair "27-wifi-${b.iface}" {
matchConfig = { matchConfig.Name = b.iface;
Name = b.iface;
WLANInterfaceType = "ap";
};
networkConfig.Bridge = "br0"; networkConfig.Bridge = "br0";
bridgeVLANs = [ bridgeVLANs = [
{ {