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