Add MVP router flake with VM test and tmux lab
router.* schema (wan, lans, dns.forwarders) compiled to systemd-networkd, a generated nftables ruleset with NAT, Kea DHCPv4 and Unbound, plus build-time assertions against lock-out and interface conflicts. tests/basic.nix is a three-node VM test (upstream/router/client) covering WAN DHCP, LAN DHCP and DNS, NAT, WAN-deny and LAN SSH/ping. `nix run .#lab` boots the same topology in tmux with one console pane per VM and the driver REPL. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
result
|
||||
result-*
|
||||
.nixos-test-history
|
||||
@@ -0,0 +1,86 @@
|
||||
# nixos-router — Plan
|
||||
|
||||
Working name: `nixos-router`. A NixOS-based distribution for routers/firewalls, delivered as a flake exporting NixOS modules and bootable images (not a nixpkgs fork).
|
||||
|
||||
## Decisions
|
||||
|
||||
| Area | Decision |
|
||||
|---|---|
|
||||
| Audience | Own homelab first; keep structure clean enough to publish later. |
|
||||
| Config model | Opinionated `router.*` option schema compiled to systemd-networkd + nftables, with build-time assertions. |
|
||||
| Hardware | x86_64 UEFI first. aarch64 is a planned target; avoid x86-only assumptions. |
|
||||
| v1 features | IPv6 + prefix delegation, VLANs / multiple LANs, WireGuard. |
|
||||
| Deferred | Multi-WAN failover, QoS (CAKE/SQM), web UI. |
|
||||
| Updates (v1) | Push from workstation (`nixos-rebuild --target-host` / colmena) + commit-confirm rollback timer. |
|
||||
| Updates (later) | **A/B image updates** with automatic rollback. |
|
||||
| Footprint | Minimal closure, read-only root, no Nix daemon/compiler on device, small persistent `/var`. |
|
||||
|
||||
## Architecture
|
||||
|
||||
1. **`router.*` schema** — users describe intent (wan, lan, vlans, zones, port forwards, wireguard); modules compile it to:
|
||||
- systemd-networkd (DHCPv4/v6, DHCPv6-PD, VLANs, bridges, PPPoE later)
|
||||
- nftables zone-based ruleset (generated, not `networking.firewall`)
|
||||
- DHCP / DNS / RA services
|
||||
2. **Build-time validation** — Nix assertions reject lock-out or inconsistent configs (no management access rule, overlapping subnets, zone referencing missing interface); generated ruleset checked with `nft -c`.
|
||||
3. **Images** — x86_64 first (installer ISO + raw disk), aarch64 later. Read-only root (erofs/squashfs store), small persistent `/var`.
|
||||
4. **Deployment** — deploy a prebuilt closure (`nix copy` + `switch-to-configuration`); never evaluate or build on the router. This keeps the device light and leads naturally to A/B updates.
|
||||
|
||||
## Implications
|
||||
|
||||
- No on-device evaluation: all designs must work from a prebuilt closure.
|
||||
- Flake exposes `x86_64-linux` and `aarch64-linux` outputs from the start; hardware specifics (bootloader, kernel, NIC naming) live in separate `boards/` modules.
|
||||
- IPv6 PD fan-out to multiple VLANs needs stable per-LAN subnet-ID allocation; this is the hardest part of the schema.
|
||||
|
||||
## Default zone policy (confirmed)
|
||||
|
||||
- WAN: deny inbound (except established/related, required ICMPv6, explicit port forwards).
|
||||
- LAN: allow outbound to WAN.
|
||||
- Other zones (e.g. IoT): isolated from each other and from LAN unless a rule opens them.
|
||||
|
||||
## Management access (confirmed)
|
||||
|
||||
- Key-only SSH, reachable from LAN zones (trusted, non-WAN, non-isolated zones). No GUI in v1.
|
||||
- Build-time assertion fails if no management path exists.
|
||||
- Isolated zones (e.g. IoT) get no router access beyond DHCP/DNS/NDP.
|
||||
|
||||
## Services (confirmed)
|
||||
|
||||
- DHCP: Kea (DHCPv4 + DHCPv6 incl. PD).
|
||||
- DNS: Unbound.
|
||||
- RA: systemd-networkd (or radvd if networkd falls short).
|
||||
|
||||
## Repo layout (confirmed)
|
||||
|
||||
```
|
||||
flake.nix
|
||||
modules/ # router.* schema and service modules
|
||||
boards/ # hardware-specific modules (x86_64 first, aarch64 later)
|
||||
tests/ # nixosTest VM tests
|
||||
images/ # image/ISO builds
|
||||
examples/ # sample configs
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
**Phase 1 (MVP) skeleton done**: flake, `router.*` schema (wan, lans, dns.forwarders), networkd, nftables (zone-less for now: WAN/LAN), Kea DHCPv4, Unbound, assertions. `nix build .#checks.x86_64-linux.basic` runs a 3-node VM test (upstream/router/client) covering WAN DHCP, LAN DHCP+DNS, NAT, WAN-deny and LAN SSH.
|
||||
|
||||
Interactive lab: `nix run .#lab` (x86_64) opens tmux with one console pane per VM plus the test-driver REPL (`router.shell_interact()`, `client.succeed(...)`, `test_script()`). The REPL pane also shows all VM output interleaved.
|
||||
|
||||
Learned along the way:
|
||||
- The default policy now explicitly answers ICMP echo on LANs only; the WAN gets only ICMP error types and ND.
|
||||
- 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).
|
||||
- Subnets are given explicitly (`subnet` + `address`) for now; a small CIDR helper lib is needed before IPv6 PD / VLAN subnet-ID allocation.
|
||||
- Not yet covered: IPv6, VLANs, WireGuard, formal zones, a test for the assertions themselves (verified manually via `nix eval`).
|
||||
|
||||
## Open items
|
||||
|
||||
- **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system.
|
||||
|
||||
## Phases
|
||||
|
||||
1. **MVP** — flake with `router.*` schema: single WAN (DHCP), single LAN, NAT, basic firewall, DHCP + DNS. NixOS VM tests (`nixosTest`) in CI.
|
||||
2. **Real-world features** — IPv6 PD, VLANs, zone policy, port forwards, WireGuard.
|
||||
3. **Images and hardware** — installer ISO, read-only root, aarch64.
|
||||
4. **Operations** — commit-confirm rollback, monitoring (node exporter, nftables counters), A/B image updates; multi-WAN failover.
|
||||
5. **Optional** — web UI (stats, then configuration).
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792773339,
|
||||
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
description = "nixos-router: a NixOS-based distribution for routers/firewalls";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
in
|
||||
{
|
||||
nixosModules = {
|
||||
router = ./modules/router;
|
||||
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;
|
||||
};
|
||||
|
||||
# `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 =
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
test = self.checks.x86_64-linux.basic;
|
||||
lab = pkgs.writeShellApplication {
|
||||
name = "router-lab";
|
||||
runtimeInputs = with pkgs; [ tmux util-linux gnugrep coreutils ];
|
||||
text = import ./tests/lab.nix {
|
||||
inherit (pkgs) lib;
|
||||
driver = test.driverInteractive;
|
||||
nodes = builtins.attrNames test.nodes;
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
type = "app";
|
||||
program = "${lab}/bin/router-lab";
|
||||
};
|
||||
|
||||
formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
lans = lib.attrValues cfg.lans;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = lans != [ ];
|
||||
message = "router: at least one LAN must be defined.";
|
||||
}
|
||||
{
|
||||
assertion = lib.any (l: l.allowManagement) lans;
|
||||
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 =
|
||||
let
|
||||
ifaces = map (l: l.interface) lans;
|
||||
in
|
||||
lib.length ifaces == lib.length (lib.unique ifaces);
|
||||
message = "router: two LANs share the same interface.";
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
imports = [
|
||||
./options.nix
|
||||
./assertions.nix
|
||||
./networking.nix
|
||||
./firewall.nix
|
||||
./dhcp.nix
|
||||
./dns.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && dhcpLans != [ ]) {
|
||||
services.kea.dhcp4 = {
|
||||
enable = true;
|
||||
settings = {
|
||||
interfaces-config = {
|
||||
interfaces = map (l: l.interface) dhcpLans;
|
||||
# Interfaces are configured asynchronously by networkd.
|
||||
re-detect = true;
|
||||
};
|
||||
lease-database = {
|
||||
type = "memfile";
|
||||
persist = true;
|
||||
name = "/var/lib/kea/dhcp4.leases";
|
||||
};
|
||||
valid-lifetime = 3600;
|
||||
subnet4 = lib.imap1 (id: l: {
|
||||
inherit id;
|
||||
inherit (l.ipv4) subnet;
|
||||
interface = l.interface;
|
||||
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ];
|
||||
option-data = [
|
||||
{ name = "routers"; data = l.ipv4.address; }
|
||||
{ name = "domain-name-servers"; data = l.ipv4.address; }
|
||||
];
|
||||
}) dhcpLans;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
lans = lib.attrValues cfg.lans;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.unbound = {
|
||||
enable = true;
|
||||
settings = {
|
||||
server = {
|
||||
interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans;
|
||||
# LAN addresses are assigned by networkd, possibly after Unbound starts.
|
||||
ip-freebind = true;
|
||||
access-control = [
|
||||
"127.0.0.0/8 allow"
|
||||
] ++ map (l: "${l.ipv4.subnet} allow") lans;
|
||||
};
|
||||
} // lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
|
||||
forward-zone = [
|
||||
{
|
||||
name = ".";
|
||||
forward-addr = cfg.dns.forwarders;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
lans = lib.attrValues cfg.lans;
|
||||
|
||||
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
|
||||
lanIfaces = map (l: l.interface) lans;
|
||||
mgmtIfaces = map (l: l.interface) (lib.filter (l: l.allowManagement) lans);
|
||||
dhcpIfaces = map (l: l.interface) (lib.filter (l: l.dhcp.enable) lans);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
# The generated ruleset replaces networking.firewall entirely.
|
||||
networking.firewall.enable = false;
|
||||
networking.nftables.enable = true;
|
||||
|
||||
networking.nftables.tables.router = {
|
||||
family = "inet";
|
||||
content = ''
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
|
||||
iifname "lo" accept
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
# Error types and ND are required on every interface (WAN included);
|
||||
# echo is only answered on LANs.
|
||||
icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
|
||||
icmpv6 type {
|
||||
destination-unreachable, packet-too-big, time-exceeded, parameter-problem,
|
||||
nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert
|
||||
} accept
|
||||
iifname ${set lanIfaces} icmp type echo-request accept
|
||||
iifname ${set lanIfaces} icmpv6 type echo-request accept
|
||||
|
||||
iifname "${cfg.wan.interface}" udp dport 68 accept
|
||||
|
||||
iifname ${set lanIfaces} udp dport 53 accept
|
||||
iifname ${set lanIfaces} tcp dport 53 accept
|
||||
${lib.optionalString (dhcpIfaces != [ ]) ''iifname ${set dhcpIfaces} udp dport 67 accept''}
|
||||
${lib.optionalString (mgmtIfaces != [ ]) ''iifname ${set mgmtIfaces} tcp dport 22 accept''}
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
|
||||
|
||||
iifname ${set lanIfaces} oifname "${cfg.wan.interface}" accept
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
networking.nftables.tables.router-nat = {
|
||||
family = "ip";
|
||||
content = ''
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
oifname "${cfg.wan.interface}" masquerade
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
openFirewall = false;
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
KbdInteractiveAuthentication = false;
|
||||
PermitRootLogin = "prohibit-password";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.router;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
|
||||
boot.kernel.sysctl = {
|
||||
"net.ipv4.conf.all.forwarding" = 1;
|
||||
};
|
||||
|
||||
systemd.network.networks = {
|
||||
"10-wan" = {
|
||||
matchConfig.Name = cfg.wan.interface;
|
||||
networkConfig.DHCP = "ipv4";
|
||||
# The router runs its own resolver; never adopt the ISP's DNS for the host.
|
||||
dhcpV4Config.UseDNS = false;
|
||||
};
|
||||
} // lib.mapAttrs' (
|
||||
name: lan:
|
||||
lib.nameValuePair "20-lan-${name}" {
|
||||
matchConfig.Name = lan.interface;
|
||||
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
|
||||
networkConfig.ConfigureWithoutCarrier = true;
|
||||
}
|
||||
) cfg.lans;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{ lib, ... }:
|
||||
let
|
||||
inherit (lib) mkOption mkEnableOption types;
|
||||
in
|
||||
{
|
||||
options.router = {
|
||||
enable = mkEnableOption "the router role";
|
||||
|
||||
wan = {
|
||||
interface = mkOption {
|
||||
type = types.str;
|
||||
example = "eth0";
|
||||
description = "Name of the uplink interface.";
|
||||
};
|
||||
};
|
||||
|
||||
lans = mkOption {
|
||||
default = { };
|
||||
description = "LAN segments served by this router, keyed by name.";
|
||||
type = types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
interface = mkOption {
|
||||
type = types.str;
|
||||
example = "eth1";
|
||||
description = "Interface (or bridge/VLAN device) carrying this LAN.";
|
||||
};
|
||||
|
||||
ipv4 = {
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.1";
|
||||
description = "Router address on this LAN.";
|
||||
};
|
||||
prefixLength = mkOption {
|
||||
type = types.ints.between 8 30;
|
||||
default = 24;
|
||||
};
|
||||
subnet = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.0/24";
|
||||
description = "Network in CIDR notation; must contain `address`.";
|
||||
};
|
||||
};
|
||||
|
||||
dhcp = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
};
|
||||
pool = {
|
||||
start = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.100";
|
||||
};
|
||||
end = mkOption {
|
||||
type = types.str;
|
||||
example = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
allowManagement = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Whether clients on this LAN may reach SSH on the router.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
dns.forwarders = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Upstream resolvers. When empty, Unbound resolves recursively.";
|
||||
};
|
||||
};
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# Single WAN (DHCP) + single LAN: DHCP, DNS, NAT and the default firewall policy.
|
||||
#
|
||||
# upstream (ISP, 203.0.113.1) -- vlan 1 -- router -- vlan 2 -- client
|
||||
{ self, pkgs }:
|
||||
let
|
||||
# `fail` in the test script also passes on "command not found", so make sure
|
||||
# the tools the script relies on are really there.
|
||||
tools = { pkgs, ... }: {
|
||||
environment.systemPackages = with pkgs; [ dnsutils netcat-openbsd curl tcpdump ];
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "router-basic";
|
||||
|
||||
defaults.imports = [ tools ];
|
||||
|
||||
nodes = {
|
||||
# Plays the ISP: hands out one WAN lease, answers DNS, and serves a page
|
||||
# that echoes the client address it sees (to prove NAT happens).
|
||||
upstream = { lib, ... }: {
|
||||
virtualisation.vlans = [ 1 ];
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.enable = false;
|
||||
networking.interfaces.eth1.ipv4 = {
|
||||
addresses = lib.mkForce [ { address = "203.0.113.1"; prefixLength = 24; } ];
|
||||
# Route back to the LAN via the router so that "unreachable" below
|
||||
# is due to the firewall and not a missing route.
|
||||
routes = [ { address = "192.168.10.0"; prefixLength = 24; via = "203.0.113.100"; } ];
|
||||
};
|
||||
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
settings = {
|
||||
interface = "eth1";
|
||||
bind-interfaces = true;
|
||||
dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h";
|
||||
dhcp-option = [ "option:router,203.0.113.1" ];
|
||||
address = "/example.test/203.0.113.1";
|
||||
};
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."example.test".locations."/".return = ''200 "$remote_addr"'';
|
||||
};
|
||||
};
|
||||
|
||||
router = { lib, ... }: {
|
||||
imports = [ self.nixosModules.router ];
|
||||
virtualisation.vlans = [ 1 2 ];
|
||||
|
||||
# The test driver preconfigures static addresses on every vlan interface.
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
|
||||
|
||||
router = {
|
||||
enable = true;
|
||||
wan.interface = "eth1";
|
||||
lans.main = {
|
||||
interface = "eth2";
|
||||
ipv4 = { address = "192.168.10.1"; subnet = "192.168.10.0/24"; };
|
||||
dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; };
|
||||
};
|
||||
dns.forwarders = [ "203.0.113.1" ];
|
||||
};
|
||||
|
||||
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
|
||||
# against a fake ISP resolver that cannot provide it; neither applies to
|
||||
# a real forwarder, so relax both for the made-up test domain only.
|
||||
services.unbound.settings.server = {
|
||||
local-zone = [ "test. transparent" ];
|
||||
domain-insecure = [ "example.test" ];
|
||||
};
|
||||
};
|
||||
|
||||
client = { lib, ... }: {
|
||||
virtualisation.vlans = [ 2 ];
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
systemd.network.networks."10-lan" = {
|
||||
matchConfig.Name = "eth1";
|
||||
networkConfig.DHCP = "ipv4";
|
||||
};
|
||||
networking.firewall.enable = false;
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
start_all()
|
||||
|
||||
upstream.wait_for_unit("dnsmasq.service")
|
||||
upstream.wait_for_unit("nginx.service")
|
||||
|
||||
with subtest("router obtains its WAN lease and brings up services"):
|
||||
router.wait_for_unit("network-online.target")
|
||||
router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100")
|
||||
router.wait_for_unit("nftables.service")
|
||||
router.wait_for_unit("kea-dhcp4-server.service")
|
||||
router.wait_for_unit("unbound.service")
|
||||
|
||||
with subtest("client gets address, gateway and DNS from the router"):
|
||||
client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.")
|
||||
client.succeed("ip route show default | grep -q 'via 192.168.10.1'")
|
||||
client.succeed("resolvectl status eth1 | grep -q 192.168.10.1")
|
||||
|
||||
with subtest("client resolves names through the router"):
|
||||
client.wait_until_succeeds("dig +short +time=2 +tries=1 @192.168.10.1 example.test | grep -qx 203.0.113.1")
|
||||
|
||||
with subtest("client reaches the 'internet' and is NATed"):
|
||||
out = client.wait_until_succeeds("curl -sf --max-time 5 --resolve example.test:80:203.0.113.1 http://example.test/")
|
||||
assert out.strip() == "203.0.113.100", f"expected NATed source 203.0.113.100, got {out!r}"
|
||||
|
||||
with subtest("WAN cannot initiate to the router or the LAN"):
|
||||
# Positive control: the probe tool works against a port that is open.
|
||||
upstream.succeed("nc -z -w 2 203.0.113.1 80")
|
||||
upstream.fail("nc -z -w 2 203.0.113.100 22")
|
||||
upstream.fail("nc -z -w 2 203.0.113.100 53")
|
||||
upstream.fail("ping -c 1 -W 2 192.168.10.1")
|
||||
upstream.fail("ping -c 1 -W 2 203.0.113.100")
|
||||
client_ip = client.succeed("ip -4 -o addr show dev eth1 | awk '{print $4}' | cut -d/ -f1").strip()
|
||||
upstream.fail(f"ping -c 1 -W 2 {client_ip}")
|
||||
|
||||
with subtest("LAN can reach SSH and ping on the router"):
|
||||
client.succeed("nc -z -w 2 192.168.10.1 22")
|
||||
client.succeed("ping -c 1 -W 2 192.168.10.1")
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{ self, pkgs }:
|
||||
{
|
||||
basic = import ./basic.nix { inherit self pkgs; };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Shell script for `nix run .#lab` (see flake.nix).
|
||||
{ lib, driver, nodes }:
|
||||
''
|
||||
sock=nixos-router-lab
|
||||
log=$(mktemp -t nixos-router-lab.XXXXXX)
|
||||
trap 'tmux -L $sock kill-server 2>/dev/null || true; rm -f "$log"' EXIT
|
||||
tmux -L $sock kill-server 2>/dev/null || true
|
||||
|
||||
# Bottom pane: the test-driver REPL. `script` keeps it on a tty while
|
||||
# logging everything it prints, which the per-VM panes below filter.
|
||||
# When the REPL exits, take the whole session down.
|
||||
repl=$(tmux -L $sock new-session -d -x 220 -y 60 -s lab -P -F '#{pane_id}' \
|
||||
"script -qfc '${driver}/bin/nixos-test-driver --interactive' '$log'; tmux -L $sock kill-server")
|
||||
tmux -L $sock set -g pane-border-status top
|
||||
tmux -L $sock select-pane -t "$repl" -T repl
|
||||
|
||||
# Top row: one pane per VM, console lines only (prefixed "<name> #").
|
||||
prev="$repl"
|
||||
first=1
|
||||
remaining=${toString (builtins.length nodes - 1)}
|
||||
for node in ${lib.escapeShellArgs nodes}; do
|
||||
cmd="tail -F '$log' 2>/dev/null | grep -a --line-buffered -E \"(^|\\[[0-9;]*m)$node #\""
|
||||
if [ "$first" = 1 ]; then
|
||||
prev=$(tmux -L $sock split-window -vb -l 65% -t "$prev" -P -F '#{pane_id}' "$cmd")
|
||||
first=0
|
||||
else
|
||||
# Give the new pane its share of the pane being split, so the row ends up even.
|
||||
prev=$(tmux -L $sock split-window -h -l "$((100 * remaining / (remaining + 1)))%" -t "$prev" -P -F '#{pane_id}' "$cmd")
|
||||
remaining=$((remaining - 1))
|
||||
fi
|
||||
tmux -L $sock select-pane -t "$prev" -T "$node"
|
||||
done
|
||||
|
||||
# Boot the VMs once the REPL is ready, and leave a reminder at the prompt.
|
||||
for _ in $(seq 60); do
|
||||
tmux -L $sock capture-pane -p -t "$repl" | grep -q CPython && break
|
||||
sleep 0.5
|
||||
done
|
||||
tmux -L $sock send-keys -t "$repl" 'start_all()' Enter
|
||||
tmux -L $sock send-keys -t "$repl" -l 'print("router.shell_interact() | client.succeed(\"ip a\") | test_script() | Ctrl-D quits, Ctrl-b d detaches")'
|
||||
tmux -L $sock send-keys -t "$repl" Enter
|
||||
tmux -L $sock select-pane -t "$repl"
|
||||
tmux -L $sock attach -t lab
|
||||
''
|
||||
Reference in New Issue
Block a user