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:
2026-10-27 23:09:09 +01:00
co-authored by Claude Sonnet 5.5
parent 3f8f2f4199
commit 0653b5cf2f
14 changed files with 714 additions and 89 deletions
+9 -1
View File
@@ -79,7 +79,7 @@ Learned along the way:
- In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check.
- Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module).
- `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do.
- Checks: `basic`, `pppoe`, `vlans` and `wireguard` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: NPTv6.
- Checks: `basic`, `pppoe`, `onestick`, `rollback`, `vlans` and `wireguard` (VM tests), `example` (the example system builds), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: NPTv6.
## IPv6 design (confirmed)
@@ -113,6 +113,14 @@ Learned along the way:
- IPv6 has no NAT: it is opened only by explicit pinholes (`from = "wan"`, IPv6 `destination`). The prefix is dynamic, so v6 destinations are not checked against the zone.
- Tested in `basic`/`pppoe` (source preserved, toPort, ranges, sources, rate limit, v6 pinhole, hairpin, log lines) and in `assertions`; mutation-tested (source match, hairpin masquerade, rate limit).
## Deployment and rollback (confirmed)
- **Router on a stick**: `wan.interface` may also carry LANs, but only for PPPoE on a VLAN with IPv6 disabled (the untagged link is then free for a LAN; native IPv6 needs an untagged uplink). In that mode only the PPP device is an uplink in the firewall, so LAN traffic can never match WAN rules; the WAN VLAN is created from the LAN's/trunk's networkd unit. A LAN VLAN may not reuse the PPPoE VLAN id.
- **Commit-confirm** (`router.rollback`, on by default): `router-arm [time]` starts a transient timer; `router-rollback` re-activates the confirmed generation (`/nix/var/nix/profiles/system`, else `/run/booted-system`) with `switch-to-configuration test`; `router-confirm` stops the timer, sets the profile and the boot default. Deploy with `nix run .#deploy -- <flake-attr> <user@host> [--yes]`: build, arm, `nixos-rebuild test --target-host`, check SSH, confirm. A lost connection means the timer rolls back by itself; a reboot also returns to the confirmed generation.
- `systemd.network.wait-online` waits only for the LAN devices (not the uplink) by default: an unplugged uplink must not block boot or activation, while VLAN devices still exist before Kea starts (anyInterface broke this; caught by `vlans`).
- `examples/desktop` + `nixosConfigurations.example` is the deployable starting point (single NIC `eno1`, PPPoE VLAN 1001); it is built as a flake check. Secrets stay on the device (`/var/lib/router/pppoe-password`).
- Open for real hardware: Nix is still on the device (`router-confirm` uses `nix-env`); no image/read-only root yet.
## WireGuard (confirmed)
- **Use cases**: road warrior (listening, dual-stack), site-to-site (no transfer network), and a provider VPN the router dials into (dual-stack transfer net, masquerade). All via `router.wireguard.<name>`.
+68
View File
@@ -0,0 +1,68 @@
# A single-NIC desktop PC as the router ("router on a stick").
#
# ISP -- ONT/modem -- switch/port --+-- eno1 (untagged) LAN 192.168.10.0/24
# +-- eno1.1001 PPPoE uplink (IPv4)
#
# The switch port the PC is plugged into must carry VLAN 1001 tagged (towards
# the ISP) and the LAN untagged. IPv6 is off: without a second link there is no
# untagged uplink for DHCPv6 to run on.
#
# Before the first deployment:
# 1. Replace the hardware section (`nixos-generate-config --show-hardware-config`).
# 2. Put your SSH public key below (otherwise nobody can log in over the network).
# 3. Put the PPPoE username below and the password in /var/lib/router/pppoe-password
# (mode 0600, root); it is read at runtime and never enters the Nix store.
{ config, lib, ... }:
{
# --- Hardware: replace with the generated configuration --------------------
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
fileSystems."/boot" = {
device = "/dev/disk/by-label/boot";
fsType = "vfat";
};
# ---------------------------------------------------------------------------
networking.hostName = "router";
system.stateVersion = "25.11";
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA-REPLACE-WITH-YOUR-PUBLIC-KEY you@workstation"
];
warnings = lib.optional (lib.any (lib.hasInfix "REPLACE") config.users.users.root.openssh.authorizedKeys.keys) ''
examples/desktop: the SSH key is still the placeholder; remote deployment will not work.
'';
router = {
enable = true;
wan = {
interface = "eno1";
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "CHANGE-ME@isp.example";
passwordFile = "/var/lib/router/pppoe-password";
};
};
};
lans.main = {
interface = "eno1";
internet = true;
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
# A deployment that is not confirmed within this time is rolled back.
rollback.timeout = "5min";
};
}
+50 -7
View File
@@ -3,9 +3,13 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
outputs =
{ self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
@@ -14,12 +18,46 @@
default = self.nixosModules.router;
};
# VM tests need KVM; only wired up for x86_64 until aarch64 CI exists.
checks.x86_64-linux = import ./tests {
inherit self;
pkgs = nixpkgs.legacyPackages.x86_64-linux;
# A deployable starting point; copy examples/desktop and adapt it.
nixosConfigurations.example = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
self.nixosModules.router
./examples/desktop
];
};
# `nix run .#deploy -- <flake-attr> <user@host>`: activate a new
# configuration with commit-confirm; see scripts/deploy.sh.
apps.x86_64-linux.deploy =
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
deploy = pkgs.writeShellApplication {
name = "router-deploy";
runtimeInputs = with pkgs; [
nixos-rebuild
openssh
coreutils
];
text = builtins.readFile ./scripts/deploy.sh;
};
in
{
type = "app";
program = "${deploy}/bin/router-deploy";
};
# VM tests need KVM; only wired up for x86_64 until aarch64 CI exists.
checks.x86_64-linux =
import ./tests {
inherit self;
pkgs = nixpkgs.legacyPackages.x86_64-linux;
}
// {
# The example must keep building as a complete system.
example = self.nixosConfigurations.example.config.system.build.toplevel;
};
# `nix run .#lab`: boot the basic test topology in tmux, one console pane
# per VM plus the interactive test-driver REPL.
apps.x86_64-linux.lab =
@@ -28,7 +66,12 @@
test = self.checks.x86_64-linux.basic;
lab = pkgs.writeShellApplication {
name = "router-lab";
runtimeInputs = with pkgs; [ tmux util-linux gnugrep coreutils ];
runtimeInputs = with pkgs; [
tmux
util-linux
gnugrep
coreutils
];
text = import ./tests/lab.nix {
inherit (pkgs) lib;
driver = test.driverInteractive;
+10 -2
View File
@@ -310,8 +310,16 @@ in
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
}
{
assertion = !(lib.elem cfg.wan.interface (map (l: l.interface) lans));
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN.";
assertion =
!(lib.elem cfg.wan.interface (map (l: l.interface) lans))
|| (cfg.wan.ipv4.type == "pppoe" && cfg.wan.ipv4.pppoe.vlan != null && !cfg.wan.ipv6.enable);
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN (only allowed for PPPoE on a VLAN with IPv6 disabled).";
}
{
assertion =
cfg.wan.ipv4.pppoe.vlan == null
|| !(lib.any (l: l.interface == cfg.wan.interface && l.vlan == cfg.wan.ipv4.pppoe.vlan) lans);
message = "router: VLAN ${toString cfg.wan.ipv4.pppoe.vlan} on `${cfg.wan.interface}` is used by both the PPPoE uplink and a LAN.";
}
{
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != "";
+1
View File
@@ -8,5 +8,6 @@
./firewall.nix
./dhcp.nix
./dns.nix
./rollback.nix
];
}
+3 -4
View File
@@ -179,10 +179,9 @@ let
uplink4 = cfg.wan.ipv4.interface;
pppoe = cfg.wan.ipv4.type == "pppoe";
# IPv4 leaves via the PPP device, IPv6 via the physical uplink.
uplinks = lib.unique [
cfg.wan.interface
uplink4
];
# When the physical link also carries LANs, only the PPP device is an uplink.
sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
uplinks = lib.unique ([ uplink4 ] ++ lib.optional (!sharedLink) cfg.wan.interface);
mss = cfg.wan.ipv4.pppoe.mtu - 40;
dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans);
in
+86 -75
View File
@@ -5,11 +5,15 @@ let
pppoe = cfg.wan.ipv4.type == "pppoe";
vlan = cfg.wan.ipv4.pppoe.vlan;
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
wanVlanOn = parent: lib.optional (pppoe && vlan != null && parent == cfg.wan.interface) vlanDevice;
lans = lib.attrValues cfg.lans;
vlanLans = lib.filter (l: l.vlan != null) lans;
# VLAN devices hang off their parent; the parent's own unit must list them.
vlansOn = parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans);
vlansOn =
parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans) ++ wanVlanOn parent;
# The WAN link's own unit is only needed when no LAN shares the link.
sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
untaggedParents = map (l: l.interface) (lib.filter (l: l.vlan == null) lans);
# Parents that carry only tagged LANs still need a unit to be brought up.
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
@@ -22,6 +26,12 @@ in
config = lib.mkIf cfg.enable {
networking.useNetworkd = true;
networking.useDHCP = false;
# Wait for the LAN devices (VLANs included: Kea only binds interfaces that
# exist when it starts) but not for the uplink: an ISP outage or a pulled
# cable must not hold up boot or a deployment.
systemd.network.wait-online.extraArgs = map (l: "--interface=${l.device}") (
lib.attrValues cfg.lans
);
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = 1;
@@ -49,87 +59,88 @@ in
}
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
systemd.network.networks = {
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig = {
DHCP = dhcp;
systemd.network.networks =
lib.optionalAttrs (!sharedLink) {
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig = {
DHCP = dhcp;
}
// lib.optionalAttrs v6 {
IPv6AcceptRA = true;
KeepConfiguration = "yes";
}
// lib.optionalAttrs (pppoe && vlan != null) {
VLAN = [ vlanDevice ];
};
}
// lib.optionalAttrs (!pppoe) {
# The router runs its own resolver; never adopt the ISP's DNS for the host.
dhcpV4Config.UseDNS = false;
}
// lib.optionalAttrs v6 {
IPv6AcceptRA = true;
KeepConfiguration = "yes";
}
// lib.optionalAttrs (pppoe && vlan != null) {
VLAN = [ vlanDevice ];
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
dhcpV6Config = {
WithoutRA = "solicit";
UseDelegatedPrefix = true;
UseDNS = false;
};
};
}
// lib.optionalAttrs (!pppoe) {
# The router runs its own resolver; never adopt the ISP's DNS for the host.
dhcpV4Config.UseDNS = false;
}
// lib.optionalAttrs v6 {
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
dhcpV6Config = {
WithoutRA = "solicit";
UseDelegatedPrefix = true;
UseDNS = false;
};
};
}
// lib.optionalAttrs (pppoe && vlan != null) {
# pppd configures the session; networkd only has to bring the device up.
"11-wan-pppoe" = {
matchConfig.Name = vlanDevice;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
};
};
}
// lib.mapAttrs' (
name: lan:
lib.nameValuePair "20-lan-${name}" (
{
matchConfig.Name = lan.device;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig = {
ConfigureWithoutCarrier = true;
# Never accept RAs from the inside.
IPv6AcceptRA = false;
}
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
VLAN = vlansOn lan.interface;
}
// lib.optionalAttrs lan.ipv6.pd {
DHCPPrefixDelegation = true;
IPv6SendRA = true;
};
}
// lib.optionalAttrs lan.ipv6.pd {
dhcpPrefixDelegationConfig = {
UplinkInterface = cfg.wan.interface;
SubnetId = 0;
Announce = true;
Assign = true;
};
# No RDNSS yet: the global address is unknown at evaluation time.
ipv6SendRAConfig.EmitDNS = false;
}
)
) cfg.lans
// lib.listToAttrs (
map (
parent:
lib.nameValuePair "19-trunk-${parent}" {
matchConfig.Name = parent;
// lib.optionalAttrs (pppoe && vlan != null) {
# pppd configures the session; networkd only has to bring the device up.
"11-wan-pppoe" = {
matchConfig.Name = vlanDevice;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
ConfigureWithoutCarrier = true;
VLAN = vlansOn parent;
};
}
) trunkParents
);
};
}
// lib.mapAttrs' (
name: lan:
lib.nameValuePair "20-lan-${name}" (
{
matchConfig.Name = lan.device;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig = {
ConfigureWithoutCarrier = true;
# Never accept RAs from the inside.
IPv6AcceptRA = false;
}
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
VLAN = vlansOn lan.interface;
}
// lib.optionalAttrs lan.ipv6.pd {
DHCPPrefixDelegation = true;
IPv6SendRA = true;
};
}
// lib.optionalAttrs lan.ipv6.pd {
dhcpPrefixDelegationConfig = {
UplinkInterface = cfg.wan.interface;
SubnetId = 0;
Announce = true;
Assign = true;
};
# No RDNSS yet: the global address is unknown at evaluation time.
ipv6SendRAConfig.EmitDNS = false;
}
)
) cfg.lans
// lib.listToAttrs (
map (
parent:
lib.nameValuePair "19-trunk-${parent}" {
matchConfig.Name = parent;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
ConfigureWithoutCarrier = true;
VLAN = vlansOn parent;
};
}
) trunkParents
);
};
}
+4
View File
@@ -14,6 +14,10 @@ in
description = ''
Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged;
with PPPoE it also carries the (optionally tagged) PPPoE session.
The link may also carry LANs ("router on a stick") when the uplink
is PPPoE on a VLAN and IPv6 is disabled: the untagged link is then
free for a LAN and only the PPP device counts as an uplink.
'';
};
+82
View File
@@ -0,0 +1,82 @@
# Commit-confirm for deployments. `router-arm` starts a timer; unless
# `router-confirm` stops it in time, `router-rollback` re-activates the last
# confirmed generation. Pair it with `nixos-rebuild test` (activate without
# changing the boot default): a reboot also comes back to the confirmed system.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.router.rollback;
profile = "/nix/var/nix/profiles/system";
tools = [
pkgs.coreutils
config.systemd.package
];
# The confirmed system: the profile, or what booted if there is none.
confirmed = ''
target=${profile}
[ -e "$target/bin/switch-to-configuration" ] || target=/run/booted-system
'';
rollback = pkgs.writeShellApplication {
name = "router-rollback";
runtimeInputs = tools;
text = ''
${confirmed}
echo "router: rolling back to $(readlink -f "$target")"
exec "$target/bin/switch-to-configuration" test
'';
};
arm = pkgs.writeShellApplication {
name = "router-arm";
runtimeInputs = tools;
text = ''
timeout=''${1:-${cfg.timeout}}
systemctl stop router-rollback.timer router-rollback.service 2>/dev/null || true
systemd-run --quiet --unit=router-rollback --on-active="$timeout" \
--timer-property=AccuracySec=1s ${rollback}/bin/router-rollback
echo "router: rollback in $timeout unless confirmed (router-confirm)"
'';
};
confirm = pkgs.writeShellApplication {
name = "router-confirm";
runtimeInputs = tools;
text = ''
systemctl stop router-rollback.timer
current=$(readlink -f /run/current-system)
${config.nix.package}/bin/nix-env -p ${profile} --set "$current"
"$current/bin/switch-to-configuration" boot
echo "router: confirmed $current"
'';
};
in
{
options.router.rollback = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Install the commit-confirm helpers `router-arm`, `router-confirm` and `router-rollback`.";
};
timeout = lib.mkOption {
type = lib.types.str;
default = "5min";
example = "90s";
description = "How long a deployment may stay unconfirmed before it is rolled back (systemd time span).";
};
};
config = lib.mkIf (config.router.enable && cfg.enable) {
environment.systemPackages = [
arm
confirm
rollback
];
};
}
+60
View File
@@ -0,0 +1,60 @@
# Deploy a router configuration with commit-confirm.
#
# router-deploy <flake-attr> <user@host> [--yes]
#
# Builds locally, arms the rollback timer on the router, activates the new
# configuration without making it the boot default, and checks that the router
# is still reachable. Confirmed (manually, or with --yes) it becomes the boot
# default; otherwise the router rolls itself back when the timer expires.
usage() {
echo "usage: router-deploy <flake-attr> <user@host> [--yes]" >&2
exit 2
}
[ $# -ge 2 ] || usage
attr=$1
host=$2
auto=${3:-}
ssh_opts=(-o ConnectTimeout=10 -o ServerAliveInterval=5 -o ServerAliveCountMax=3)
remote() { ssh "${ssh_opts[@]}" "$host" "$@"; }
echo ">> building .#$attr"
nixos-rebuild build --flake ".#$attr"
echo ">> arming rollback on $host"
remote router-arm
echo ">> activating (not the boot default yet)"
if ! nixos-rebuild test --flake ".#$attr" --target-host "$host"; then
echo "!! activation failed; the router rolls back by itself when the timer expires" >&2
echo " (or run: ssh $host router-rollback)" >&2
exit 1
fi
echo ">> checking the router is still reachable"
reachable=0
for _ in 1 2 3 4 5 6; do
if remote true; then
reachable=1
break
fi
sleep 5
done
if [ "$reachable" -ne 1 ]; then
echo "!! lost contact with $host; it will roll back when the timer expires" >&2
exit 1
fi
if [ "$auto" != "--yes" ]; then
echo "Check that everything works (LAN, internet, DNS), then confirm."
read -r -p "Confirm and make it permanent? [y/N] " answer
if [ "$answer" != "y" ]; then
echo ">> not confirmed: rolling back"
remote router-rollback
remote systemctl stop router-rollback.timer
exit 1
fi
fi
remote router-confirm
echo ">> confirmed"
+62
View File
@@ -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 =
+2
View File
@@ -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; };
+217
View File
@@ -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"
'';
}
+60
View File
@@ -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")
'';
}