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>
This commit is contained in:
@@ -722,6 +722,67 @@ let
|
||||
};
|
||||
expect = [ "not a valid IPv6" ];
|
||||
};
|
||||
sharedLinkValid = {
|
||||
router = {
|
||||
wan = {
|
||||
ipv4 = {
|
||||
type = "pppoe";
|
||||
pppoe = {
|
||||
vlan = 1001;
|
||||
username = "u";
|
||||
passwordFile = "/var/lib/pw";
|
||||
};
|
||||
};
|
||||
};
|
||||
lans.main = lan { interface = "eth0"; };
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
sharedLinkIpv6 = {
|
||||
router = {
|
||||
wan = {
|
||||
ipv4 = {
|
||||
type = "pppoe";
|
||||
pppoe = {
|
||||
vlan = 1001;
|
||||
username = "u";
|
||||
passwordFile = "/var/lib/pw";
|
||||
};
|
||||
};
|
||||
ipv6.enable = true;
|
||||
};
|
||||
lans.main = lan { interface = "eth0"; };
|
||||
};
|
||||
expect = [ "also used by a LAN" ];
|
||||
};
|
||||
sharedLinkUntaggedUplink = {
|
||||
router = {
|
||||
wan = {
|
||||
ipv4.type = "dhcp";
|
||||
};
|
||||
lans.main = lan { interface = "eth0"; };
|
||||
};
|
||||
expect = [ "also used by a LAN" ];
|
||||
};
|
||||
sharedLinkVlanClash = {
|
||||
router = {
|
||||
wan = {
|
||||
ipv4 = {
|
||||
type = "pppoe";
|
||||
pppoe = {
|
||||
vlan = 1001;
|
||||
username = "u";
|
||||
passwordFile = "/var/lib/pw";
|
||||
};
|
||||
};
|
||||
};
|
||||
lans.main = lan {
|
||||
interface = "eth0";
|
||||
vlan = 1001;
|
||||
};
|
||||
};
|
||||
expect = [ "used by both the PPPoE uplink and a LAN" ];
|
||||
};
|
||||
nonOverlappingLans = {
|
||||
router.lans = {
|
||||
a = lan { };
|
||||
@@ -769,6 +830,7 @@ let
|
||||
"ruleValid"
|
||||
"wgValid"
|
||||
"wanValid"
|
||||
"sharedLinkValid"
|
||||
];
|
||||
|
||||
subnet =
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
wan4 = "pppoe";
|
||||
};
|
||||
vlans = import ./vlans.nix { inherit self pkgs; };
|
||||
onestick = import ./onestick.nix { inherit self pkgs; };
|
||||
rollback = import ./rollback.nix { inherit self pkgs; };
|
||||
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
||||
lib = import ./lib.nix { inherit pkgs; };
|
||||
assertions = import ./assertions.nix { inherit self pkgs; };
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# Commit-confirm: an unconfirmed activation is rolled back by the timer, a
|
||||
# confirmed one stays.
|
||||
{ self, pkgs }:
|
||||
let
|
||||
common = import ./common.nix;
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "router-rollback";
|
||||
|
||||
defaults.imports = [ common.tools ];
|
||||
|
||||
nodes.router =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [ self.nixosModules.router ];
|
||||
virtualisation.vlans = [
|
||||
1
|
||||
2
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
|
||||
router = {
|
||||
enable = true;
|
||||
wan.interface = "eth1";
|
||||
lans.main = {
|
||||
interface = "eth2";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.100";
|
||||
end = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
# Stands in for a freshly deployed configuration.
|
||||
specialisation.deployed.configuration.environment.etc."deployed".text = "yes";
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
router.wait_for_unit("multi-user.target")
|
||||
router.wait_for_unit("nftables.service")
|
||||
activate = "/run/current-system/specialisation/deployed/bin/switch-to-configuration test"
|
||||
|
||||
with subtest("an unconfirmed deployment is rolled back"):
|
||||
router.fail("test -e /etc/deployed")
|
||||
router.succeed("router-arm 60s")
|
||||
router.succeed(activate)
|
||||
router.succeed("test -e /etc/deployed")
|
||||
router.wait_until_fails("test -e /etc/deployed", timeout=180)
|
||||
router.wait_for_unit("nftables.service")
|
||||
|
||||
with subtest("a confirmed deployment stays"):
|
||||
router.succeed("router-arm 60s")
|
||||
router.succeed(activate)
|
||||
router.succeed("router-confirm")
|
||||
router.fail("systemctl is-active router-rollback.timer")
|
||||
router.sleep(75)
|
||||
router.succeed("test -e /etc/deployed")
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user