Files
nixos-router/tests/onestick.nix
T
nilsandClaude Sonnet 5.5 0653b5cf2f Add router-on-a-stick, commit-confirm rollback and an example config
- Allow the WAN link to also carry LANs when the uplink is PPPoE on a
  VLAN with IPv6 off; only the PPP device counts as an uplink then, so
  LAN traffic can never match WAN rules.
- Add router.rollback: router-arm / router-confirm / router-rollback
  plus `nix run .#deploy`, a commit-confirm deployment flow.
- Wait for the LAN devices instead of the uplink in networkd
  wait-online, so an unplugged uplink cannot block boot or activation.
- Add examples/desktop (single NIC, PPPoE on VLAN 1001) as
  nixosConfigurations.example, built as a flake check.

Covered by the onestick and rollback VM tests and new assertion cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:09:09 +01:00

218 lines
7.4 KiB
Nix

# Router on a stick: one NIC carries the untagged LAN and, as VLAN 7, the
# PPPoE uplink (IPv4 only), like a single-port box behind a VLAN-tagged ISP.
#
# upstream (ISP, PPPoE on vlan 7) -- switch -- router (eth1) -- client, peer
#
# The ISP and the LAN share one L2 segment in the test, which is what a real
# trunk looks like to the router: tagged frames go to the ISP, untagged to the LAN.
{ self, pkgs }:
let
inherit (pkgs) lib;
common = import ./common.nix;
vlan = 7;
ispV4 = "198.51.100.1";
routerV4 = "198.51.100.100";
lanHost =
address:
{ lib, ... }:
{
virtualisation.vlans = [ 1 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-lan" = {
matchConfig.Name = "eth1";
networkConfig.DHCP = if address == null then "ipv4" else "no";
address = lib.optional (address != null) "${address}/24";
gateway = lib.optional (address != null) "192.168.10.1";
};
};
in
pkgs.testers.runNixOSTest {
name = "router-onestick";
defaults.imports = [ common.tools ];
interactive.defaults = common.interactiveDefaults;
nodes = {
upstream =
{ lib, pkgs, ... }:
{
virtualisation.vlans = [ 1 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
boot.kernelModules = [
"pppoe"
"pppox"
"ppp_generic"
];
systemd.network.netdevs."05-pppoe" = {
netdevConfig = {
Kind = "vlan";
Name = "eth1.${toString vlan}";
};
vlanConfig.Id = vlan;
};
systemd.network.networks."10-trunk" = {
matchConfig.Name = "eth1";
networkConfig = {
VLAN = [ "eth1.${toString vlan}" ];
LinkLocalAddressing = "no";
};
};
systemd.network.networks."11-pppoe" = {
matchConfig.Name = "eth1.${toString vlan}";
networkConfig.LinkLocalAddressing = "no";
};
environment.etc = {
"ppp/options".text = "";
"ppp/chap-secrets".text = ''
"testuser" * "p4ssw0rd" *
'';
"ppp/pppoe-server-options".text = ''
require-chap
noccp
mtu 1492
mru 1492
'';
};
systemd.services.pppoe-server = {
wantedBy = [ "multi-user.target" ];
wants = [ "sys-subsystem-net-devices-eth1.${toString vlan}.device" ];
after = [
"systemd-networkd.service"
"sys-subsystem-net-devices-eth1.${toString vlan}.device"
];
serviceConfig = {
ExecStart = lib.concatStringsSep " " [
"${pkgs.rp-pppoe}/bin/pppoe-server -F -I eth1.${toString vlan}"
"-L ${ispV4} -R ${routerV4} -N 1"
"-O /etc/ppp/pppoe-server-options -q ${pkgs.ppp}/bin/pppd"
];
Restart = "always";
};
};
services.nginx = {
enable = true;
virtualHosts."isp".locations."/".return = ''200 "$remote_addr"'';
};
};
router =
{ lib, ... }:
{
imports = [ self.nixosModules.router ];
virtualisation.vlans = [ 1 ];
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
environment.etc."pppoe-test-password" = {
text = "p4ssw0rd";
mode = "0600";
};
router = {
enable = true;
wan = {
interface = "eth1";
ipv4 = {
type = "pppoe";
pppoe = {
inherit vlan;
username = "testuser";
passwordFile = "/etc/pppoe-test-password";
};
};
};
lans.main = {
interface = "eth1";
internet = true;
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
# Only the PPP device is an uplink, so this must work from the ISP
# and must not fire for traffic arriving from the LAN.
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.51";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
}
];
dns.forwarders = [ ispV4 ];
};
};
client = lanHost null;
peer =
{ lib, ... }:
{
imports = [ (lanHost "192.168.10.51") ];
services.nginx = {
enable = true;
virtualHosts."peer" = {
listen = [
{
addr = "0.0.0.0";
port = 8080;
}
];
locations."/".return = ''200 "$remote_addr"'';
};
};
};
};
testScript = ''
start_all()
upstream.wait_for_unit("nginx.service")
peer.wait_for_unit("nginx.service")
with subtest("the PPPoE session comes up on the tagged VLAN of the shared link"):
upstream.wait_for_unit("pppoe-server.service")
router.wait_for_unit("router-pppoe.service")
router.wait_until_succeeds("ip -4 addr show dev pppoe-wan | grep -q ${routerV4}")
router.wait_until_succeeds("ip -4 route show default | grep -q 'dev pppoe-wan'")
assert "vlan protocol 802.1Q id ${toString vlan}" in router.succeed("ip -d link show eth1.${toString vlan}")
router.wait_for_unit("nftables.service")
router.wait_for_unit("kea-dhcp4-server.service")
with subtest("the untagged side of the same NIC is the LAN"):
router.succeed("ip -4 addr show dev eth1 | grep -q 192.168.10.1/24")
client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.100")
client.succeed("ip route show default | grep -q 'via 192.168.10.1'")
with subtest("the LAN reaches the ISP through PPPoE and is NATed"):
out = client.wait_until_succeeds("curl -sf --max-time 5 http://${ispV4}/")
assert out.strip() == "${routerV4}", out
with subtest("the ISP cannot reach the router or the LAN"):
upstream.succeed("nc -z -w 2 ${ispV4} 80")
upstream.fail("nc -z -w 2 ${routerV4} 22")
upstream.fail("nc -z -w 2 ${routerV4} 53")
upstream.fail("ping -c 1 -W 2 ${routerV4}")
with subtest("a port forward works from the ISP"):
upstream.succeed("ip route replace 192.168.10.0/24 via ${routerV4}")
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8080/")
assert out.strip() == "${ispV4}", out
with subtest("the shared NIC is not an uplink: LAN traffic never hits WAN rules"):
# Positive control: the target answers directly.
client.succeed("curl -sf --max-time 5 http://192.168.10.51:8080/")
# Same port on the router's LAN address: no forward applies (and the
# input chain would drop it anyway), so this must fail.
client.fail("curl -sf --max-time 3 http://192.168.10.1:8080/")
router.succeed("nft list ruleset | grep -q 'iifname \"pppoe-wan\"'")
assert 'iifname { "eth1"' not in router.succeed("nft list ruleset"), "eth1 treated as an uplink"
'';
}