diff --git a/docs/src/gateways.md b/docs/src/gateways.md index 3cdc2b5..1568c83 100644 --- a/docs/src/gateways.md +++ b/docs/src/gateways.md @@ -12,10 +12,11 @@ Naming: `gw--`, 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 `*..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 `); 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`) | diff --git a/modules/clan/router/README.md b/modules/clan/router/README.md index f1a4965..b130c71 100644 --- a/modules/clan/router/README.md +++ b/modules/clan/router/README.md @@ -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..0.0/16`; VLAN `` defaults to `10...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--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 `..` under a wildcard diff --git a/modules/clan/router/default.nix b/modules/clan/router/default.nix index 467c2b6..b720315 100644 --- a/modules/clan/router/default.nix +++ b/modules/clan/router/default.nix @@ -47,6 +47,7 @@ ./proxy.nix ./iperf.nix ./speedtest.nix + ./wifi.nix ]; }; }; diff --git a/modules/clan/router/firewall.nix b/modules/clan/router/firewall.nix index d1623ea..4555fcb 100644 --- a/modules/clan/router/firewall.nix +++ b/modules/clan/router/firewall.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; }; } diff --git a/modules/clan/router/interface.nix b/modules/clan/router/interface.nix index 0aa5575..ba8a217 100644 --- a/modules/clan/router/interface.nix +++ b/modules/clan/router/interface.nix @@ -106,6 +106,94 @@ let }; }; }; + + wifiNetworkModule = + { name, ... }: + { + options = { + ssid = lib.mkOption { + type = lib.types.str; + default = name; + defaultText = lib.literalExpression ""; + 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--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"; diff --git a/modules/clan/router/tests/vm/default.nix b/modules/clan/router/tests/vm/default.nix index 74b061f..682921b 100644 --- a/modules/clan/router/tests/vm/default.nix +++ b/modules/clan/router/tests/vm/default.nix @@ -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'") ''; } diff --git a/modules/clan/router/wifi.nix b/modules/clan/router/wifi.nix new file mode 100644 index 0000000..8b21768 --- /dev/null +++ b/modules/clan/router/wifi.nix @@ -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 , -1, -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..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..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..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 + ); + }; +}