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'")
|
|
'';
|
|
}
|