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:
@@ -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.
|
- 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).
|
- 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.
|
- `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)
|
## 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.
|
- 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).
|
- 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)
|
## 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>`.
|
- **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>`.
|
||||||
|
|||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@
|
|||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
outputs = { self, nixpkgs }:
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
let
|
let
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
@@ -14,12 +18,46 @@
|
|||||||
default = self.nixosModules.router;
|
default = self.nixosModules.router;
|
||||||
};
|
};
|
||||||
|
|
||||||
# VM tests need KVM; only wired up for x86_64 until aarch64 CI exists.
|
# A deployable starting point; copy examples/desktop and adapt it.
|
||||||
checks.x86_64-linux = import ./tests {
|
nixosConfigurations.example = nixpkgs.lib.nixosSystem {
|
||||||
inherit self;
|
system = "x86_64-linux";
|
||||||
pkgs = nixpkgs.legacyPackages.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
|
# `nix run .#lab`: boot the basic test topology in tmux, one console pane
|
||||||
# per VM plus the interactive test-driver REPL.
|
# per VM plus the interactive test-driver REPL.
|
||||||
apps.x86_64-linux.lab =
|
apps.x86_64-linux.lab =
|
||||||
@@ -28,7 +66,12 @@
|
|||||||
test = self.checks.x86_64-linux.basic;
|
test = self.checks.x86_64-linux.basic;
|
||||||
lab = pkgs.writeShellApplication {
|
lab = pkgs.writeShellApplication {
|
||||||
name = "router-lab";
|
name = "router-lab";
|
||||||
runtimeInputs = with pkgs; [ tmux util-linux gnugrep coreutils ];
|
runtimeInputs = with pkgs; [
|
||||||
|
tmux
|
||||||
|
util-linux
|
||||||
|
gnugrep
|
||||||
|
coreutils
|
||||||
|
];
|
||||||
text = import ./tests/lab.nix {
|
text = import ./tests/lab.nix {
|
||||||
inherit (pkgs) lib;
|
inherit (pkgs) lib;
|
||||||
driver = test.driverInteractive;
|
driver = test.driverInteractive;
|
||||||
|
|||||||
@@ -310,8 +310,16 @@ in
|
|||||||
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
|
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));
|
assertion =
|
||||||
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN.";
|
!(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 != "";
|
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != "";
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
./firewall.nix
|
./firewall.nix
|
||||||
./dhcp.nix
|
./dhcp.nix
|
||||||
./dns.nix
|
./dns.nix
|
||||||
|
./rollback.nix
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,10 +179,9 @@ let
|
|||||||
uplink4 = cfg.wan.ipv4.interface;
|
uplink4 = cfg.wan.ipv4.interface;
|
||||||
pppoe = cfg.wan.ipv4.type == "pppoe";
|
pppoe = cfg.wan.ipv4.type == "pppoe";
|
||||||
# IPv4 leaves via the PPP device, IPv6 via the physical uplink.
|
# IPv4 leaves via the PPP device, IPv6 via the physical uplink.
|
||||||
uplinks = lib.unique [
|
# When the physical link also carries LANs, only the PPP device is an uplink.
|
||||||
cfg.wan.interface
|
sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans);
|
||||||
uplink4
|
uplinks = lib.unique ([ uplink4 ] ++ lib.optional (!sharedLink) cfg.wan.interface);
|
||||||
];
|
|
||||||
mss = cfg.wan.ipv4.pppoe.mtu - 40;
|
mss = cfg.wan.ipv4.pppoe.mtu - 40;
|
||||||
dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans);
|
dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans);
|
||||||
in
|
in
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ let
|
|||||||
pppoe = cfg.wan.ipv4.type == "pppoe";
|
pppoe = cfg.wan.ipv4.type == "pppoe";
|
||||||
vlan = cfg.wan.ipv4.pppoe.vlan;
|
vlan = cfg.wan.ipv4.pppoe.vlan;
|
||||||
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
|
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
|
||||||
|
wanVlanOn = parent: lib.optional (pppoe && vlan != null && parent == cfg.wan.interface) vlanDevice;
|
||||||
|
|
||||||
lans = lib.attrValues cfg.lans;
|
lans = lib.attrValues cfg.lans;
|
||||||
vlanLans = lib.filter (l: l.vlan != null) lans;
|
vlanLans = lib.filter (l: l.vlan != null) lans;
|
||||||
# VLAN devices hang off their parent; the parent's own unit must list them.
|
# 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);
|
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.
|
# Parents that carry only tagged LANs still need a unit to be brought up.
|
||||||
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
|
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
|
||||||
@@ -22,6 +26,12 @@ in
|
|||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
networking.useNetworkd = true;
|
networking.useNetworkd = true;
|
||||||
networking.useDHCP = false;
|
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 = {
|
boot.kernel.sysctl = {
|
||||||
"net.ipv4.conf.all.forwarding" = 1;
|
"net.ipv4.conf.all.forwarding" = 1;
|
||||||
@@ -49,87 +59,88 @@ in
|
|||||||
}
|
}
|
||||||
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
|
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
|
||||||
|
|
||||||
systemd.network.networks = {
|
systemd.network.networks =
|
||||||
"10-wan" = {
|
lib.optionalAttrs (!sharedLink) {
|
||||||
matchConfig.Name = cfg.wan.interface;
|
"10-wan" = {
|
||||||
networkConfig = {
|
matchConfig.Name = cfg.wan.interface;
|
||||||
DHCP = dhcp;
|
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 {
|
// lib.optionalAttrs v6 {
|
||||||
IPv6AcceptRA = true;
|
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
|
||||||
KeepConfiguration = "yes";
|
dhcpV6Config = {
|
||||||
}
|
WithoutRA = "solicit";
|
||||||
// lib.optionalAttrs (pppoe && vlan != null) {
|
UseDelegatedPrefix = true;
|
||||||
VLAN = [ vlanDevice ];
|
UseDNS = false;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (!pppoe) {
|
// lib.optionalAttrs (pppoe && vlan != null) {
|
||||||
# The router runs its own resolver; never adopt the ISP's DNS for the host.
|
# pppd configures the session; networkd only has to bring the device up.
|
||||||
dhcpV4Config.UseDNS = false;
|
"11-wan-pppoe" = {
|
||||||
}
|
matchConfig.Name = vlanDevice;
|
||||||
// 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;
|
|
||||||
networkConfig = {
|
networkConfig = {
|
||||||
LinkLocalAddressing = "no";
|
LinkLocalAddressing = "no";
|
||||||
IPv6AcceptRA = false;
|
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
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ in
|
|||||||
description = ''
|
description = ''
|
||||||
Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged;
|
Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged;
|
||||||
with PPPoE it also carries the (optionally tagged) PPPoE session.
|
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.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -722,6 +722,67 @@ let
|
|||||||
};
|
};
|
||||||
expect = [ "not a valid IPv6" ];
|
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 = {
|
nonOverlappingLans = {
|
||||||
router.lans = {
|
router.lans = {
|
||||||
a = lan { };
|
a = lan { };
|
||||||
@@ -769,6 +830,7 @@ let
|
|||||||
"ruleValid"
|
"ruleValid"
|
||||||
"wgValid"
|
"wgValid"
|
||||||
"wanValid"
|
"wanValid"
|
||||||
|
"sharedLinkValid"
|
||||||
];
|
];
|
||||||
|
|
||||||
subnet =
|
subnet =
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
wan4 = "pppoe";
|
wan4 = "pppoe";
|
||||||
};
|
};
|
||||||
vlans = import ./vlans.nix { inherit self pkgs; };
|
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; };
|
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
||||||
lib = import ./lib.nix { inherit pkgs; };
|
lib = import ./lib.nix { inherit pkgs; };
|
||||||
assertions = import ./assertions.nix { inherit self 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