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).
307 lines
11 KiB
Nix
307 lines
11 KiB
Nix
# 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`,
|
|
# and an untagged access port carries mgmt to `admin`.
|
|
#
|
|
# isp ---(vlan 1: PPPoE)--- wan [gw] trunk ---(vlan 2: tagged 20/40)--- client
|
|
# access --(vlan 3: untagged mgmt)--- admin
|
|
#
|
|
# What is proven: PPPoE dial-in with the vars-provided credentials, bridge
|
|
# VLAN tagging/untagging, Kea leases and reservations per VLAN, Blocky
|
|
# 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).
|
|
{ pkgs, lib, ... }:
|
|
let
|
|
# The vars mock answers every prompt with "mock-prompt-value-<name>"; the
|
|
# ISP side must accept exactly those.
|
|
chapSecrets = ''"mock-prompt-value-username" * "mock-prompt-value-password" *'';
|
|
ispAddress = "192.0.2.1";
|
|
|
|
clientMac = "02:00:00:00:00:20";
|
|
clientAddress = "10.9.20.50";
|
|
adminMac = "02:00:00:00:00:10";
|
|
adminAddress = "10.9.10.50";
|
|
in
|
|
{
|
|
name = "router";
|
|
|
|
clan = {
|
|
directory = ./.;
|
|
# Bridges, VLAN netdevs, PPPoE and nftables need a real kernel.
|
|
test.useContainers = false;
|
|
inventory = {
|
|
# Every node is a clan machine (the test framework's defaults require
|
|
# it); only gw gets the router role.
|
|
machines = {
|
|
gw = { };
|
|
isp = { };
|
|
client = { };
|
|
admin = { };
|
|
};
|
|
|
|
instances.router = {
|
|
module.name = "router";
|
|
module.input = "self";
|
|
roles.default.machines.gw.settings = {
|
|
site = "tst";
|
|
siteId = 9;
|
|
mesh.subnet = "fd00:7e57:c1a1:c0de::/64";
|
|
wan.interface = "wan";
|
|
trunkPorts = [ "trunk" ];
|
|
accessPorts.access = "mgmt";
|
|
vlans = {
|
|
mgmt = {
|
|
id = 10;
|
|
dhcp.reservations.admin = {
|
|
hwAddress = adminMac;
|
|
ipAddress = adminAddress;
|
|
};
|
|
};
|
|
lan = {
|
|
id = 20;
|
|
dhcp.reservations.client = {
|
|
hwAddress = clientMac;
|
|
ipAddress = clientAddress;
|
|
};
|
|
};
|
|
iot = {
|
|
id = 40;
|
|
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"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
nodes = {
|
|
gw = {
|
|
virtualisation.interfaces = {
|
|
wan = {
|
|
vlan = 1;
|
|
assignIP = false;
|
|
};
|
|
trunk = {
|
|
vlan = 2;
|
|
assignIP = false;
|
|
};
|
|
access = {
|
|
vlan = 3;
|
|
assignIP = false;
|
|
};
|
|
};
|
|
|
|
# Something must listen on 22 for the mgmt-only SSH rule to be observable
|
|
# (a refused and a dropped connection look the same to the client).
|
|
services.openssh.enable = true;
|
|
|
|
# The sandbox has no internet: serve the blocklist from a local file
|
|
# instead of GitHub, and skip CrowdSec, whose hub sync needs the network
|
|
# (it is not what this test exercises).
|
|
services.blocky.settings.blocking.denylists.ads = lib.mkForce [
|
|
(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
|
|
# 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 = {
|
|
virtualisation.interfaces.wan = {
|
|
vlan = 1;
|
|
assignIP = false;
|
|
};
|
|
# PPPoE access concentrator: one session, peer gets 192.0.2.10.
|
|
systemd.services.pppoe-server = {
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
serviceConfig.ExecStart =
|
|
"${pkgs.rp-pppoe}/sbin/pppoe-server -F -O /etc/ppp/pppoe-server-options"
|
|
+ " -q ${pkgs.ppp}/sbin/pppd -I wan -L ${ispAddress} -R 192.0.2.10";
|
|
};
|
|
environment.etc = {
|
|
"ppp/pppoe-server-options".text = ''
|
|
plugin pppoe.so
|
|
require-chap
|
|
lcp-echo-interval 10
|
|
lcp-echo-failure 2
|
|
nobsdcomp
|
|
noccp
|
|
novj
|
|
'';
|
|
"ppp/chap-secrets" = {
|
|
text = chapSecrets;
|
|
mode = "0640";
|
|
};
|
|
};
|
|
};
|
|
|
|
client = {
|
|
virtualisation.interfaces.trunk = {
|
|
vlan = 2;
|
|
assignIP = false;
|
|
};
|
|
networking.useDHCP = false;
|
|
networking.vlans = {
|
|
lan0 = {
|
|
id = 20;
|
|
interface = "trunk";
|
|
};
|
|
iot0 = {
|
|
id = 40;
|
|
interface = "trunk";
|
|
};
|
|
};
|
|
networking.interfaces.lan0 = {
|
|
useDHCP = true;
|
|
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
|
|
];
|
|
};
|
|
|
|
admin = {
|
|
virtualisation.interfaces.access = {
|
|
vlan = 3;
|
|
assignIP = false;
|
|
};
|
|
networking.useDHCP = false;
|
|
networking.interfaces.access = {
|
|
useDHCP = true;
|
|
macAddress = adminMac;
|
|
};
|
|
environment.systemPackages = [ pkgs.netcat ];
|
|
};
|
|
};
|
|
|
|
testScript = ''
|
|
start_all()
|
|
|
|
with subtest("PPPoE session comes up with the vars credentials"):
|
|
gw.wait_for_unit("pppd-wan.service")
|
|
gw.wait_until_succeeds("ping -c1 -W1 ${ispAddress}")
|
|
|
|
with subtest("DHCP hands out reserved leases per VLAN"):
|
|
gw.wait_for_unit("kea-dhcp4-server.service")
|
|
client.wait_until_succeeds("ip -4 addr show lan0 | grep -q 'inet ${clientAddress}/24'")
|
|
client.wait_until_succeeds("ip -4 addr show iot0 | grep -q 'inet 10.9.40.1[0-9][0-9]/24'")
|
|
admin.wait_until_succeeds("ip -4 addr show access | grep -q 'inet ${adminAddress}/24'")
|
|
|
|
with subtest("Blocky serves the VLAN and blocks the denylist"):
|
|
gw.wait_for_unit("blocky.service")
|
|
answer = client.wait_until_succeeds("dig +short +time=2 @10.9.20.1 ads.example.com")
|
|
assert answer.strip() == "0.0.0.0", f"expected blocked answer, got: {answer!r}"
|
|
|
|
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"):
|
|
admin.succeed("ping -c1 -W2 ${clientAddress}")
|
|
client.fail("ping -c1 -W2 -I lan0 ${adminAddress}")
|
|
|
|
with subtest("SSH on the router only from mgmt"):
|
|
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'")
|
|
'';
|
|
}
|