networkd enslaves the radio to br0 once, at link init. On real hardware (GL-MT6000, mt798x-wmac) that happens while the netdev is still in station mode, so the kernel rejects the bridge join (IFF_DONT_BRIDGE), the link is parked in networkd's failed state and never retried: link_reconfigure_impl() is a no-op while the matching .network file is unchanged, and the udev 'iw set type __ap' hook meant to pre-empt this loses the race. Wireless clients associate but their DHCP never reaches vlan-lan. Match on WLANInterfaceType=ap as well as the name. The station-mode netdev then matches nothing (unmanaged) and the file first matches when hostapd has switched the radio to AP and raised carrier, so the enslave succeeds on the first try. Drop the udev hook. Verified with checks.x86_64-linux.router (hwsim STA lease over the bridge).
router
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: 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
and lan VLANs are mandatory. Trust model: mgmt reaches everything; other
VLANs get router DNS/DHCP and (with allowWan) the internet, no inter-VLAN;
WAN nothing inbound; the admin mesh (mesh.subnet) gets SSH, metrics, iperf3
and the Omada UI.
Usage from another clan
# flake.nix
inputs.cnx-network.url = "git+https://<host>/B4L/cnx-network-clan";
# clan.nix
inventory.instances.router = {
module = { name = "router"; input = "cnx-network"; };
roles.default.settings.mesh.subnet = "fd..::/88"; # your admin overlay
roles.default.machines.gw-1.settings = {
site = "ams";
siteId = 1;
wan.interface = "enp1s0";
wan.vlanId = 10; # or null for untagged PPPoE
trunkPorts = [ "enp2s0" ];
accessPorts.enp4s0 = "mgmt"; # untagged on-site recovery port
# stagingPort = "enp3s0"; # DHCP uplink into the old LAN until cutover
vlans = {
mgmt.id = 10;
lan.id = 20;
iot = { id = 40; allowWan = false; };
};
};
};
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:
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
certificate obtained via RFC 2136 DNS-01 against proxy.acme.nameserver. The
gateway signs updates with TSIG key acme_<hostname with _>, whose secret is
the shared dns-acme-<hostname>-secret generator declared by this service.
The nameserver machine must declare the same generator so both sides hold one
secret — import acme-secret.nix from this directory with the gateway's name:
imports = [ (import "${inputs.cnx-network}/modules/clan/router/acme-secret.nix" "gw-1") ];
and load the key with an acl scoped to _acme-challenge.<site><siteId>.
The service does not open the WAN to anything; reach gateways over your mesh. One instance per machine.