services/router: init

This commit is contained in:
2026-09-17 08:57:32 +07:00
parent b85d6637f1
commit 60aac6efb2
39 changed files with 1831 additions and 929 deletions
+306
View File
@@ -0,0 +1,306 @@
# 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'")
'';
}