- 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>
218 lines
7.4 KiB
Nix
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"
|
|
'';
|
|
}
|