diff --git a/PLAN.md b/PLAN.md index f5e1988..8fba134 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 -- [--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.`. diff --git a/examples/desktop/default.nix b/examples/desktop/default.nix new file mode 100644 index 0000000..7a67c49 --- /dev/null +++ b/examples/desktop/default.nix @@ -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"; + }; +} diff --git a/flake.nix b/flake.nix index 50a5361..2b405bc 100644 --- a/flake.nix +++ b/flake.nix @@ -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 -- `: 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; diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix index 1e75f0d..9ae4859 100644 --- a/modules/router/assertions.nix +++ b/modules/router/assertions.nix @@ -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 != ""; diff --git a/modules/router/default.nix b/modules/router/default.nix index e24e90d..96e2037 100644 --- a/modules/router/default.nix +++ b/modules/router/default.nix @@ -8,5 +8,6 @@ ./firewall.nix ./dhcp.nix ./dns.nix + ./rollback.nix ]; } diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix index e47c6f3..e5b28fd 100644 --- a/modules/router/firewall.nix +++ b/modules/router/firewall.nix @@ -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 diff --git a/modules/router/networking.nix b/modules/router/networking.nix index 465b769..42e4f77 100644 --- a/modules/router/networking.nix +++ b/modules/router/networking.nix @@ -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 + ); }; } diff --git a/modules/router/options.nix b/modules/router/options.nix index 5fc8725..3541f8e 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -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. ''; }; diff --git a/modules/router/rollback.nix b/modules/router/rollback.nix new file mode 100644 index 0000000..76a89a1 --- /dev/null +++ b/modules/router/rollback.nix @@ -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 + ]; + }; +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..4fc4bbb --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,60 @@ +# Deploy a router configuration with commit-confirm. +# +# router-deploy [--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 [--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" diff --git a/tests/assertions.nix b/tests/assertions.nix index 1d9c9d3..d790037 100644 --- a/tests/assertions.nix +++ b/tests/assertions.nix @@ -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 = diff --git a/tests/default.nix b/tests/default.nix index dab2c5a..a1dd738 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -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; }; diff --git a/tests/onestick.nix b/tests/onestick.nix new file mode 100644 index 0000000..038268f --- /dev/null +++ b/tests/onestick.nix @@ -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" + ''; +} diff --git a/tests/rollback.nix b/tests/rollback.nix new file mode 100644 index 0000000..3db2795 --- /dev/null +++ b/tests/rollback.nix @@ -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") + ''; +}