router.firewall.nat replaces the dnat/toPort/hairpin fields of the filter rules. Filter rules gain natOnly, and may use wan with log and rate limit in the web UI. The Firewall page edits the NAT table and can add the matching allow rule. Settings schema 1 (migrations/1.jq) converts old forwards. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
213 lines
8.6 KiB
Nix
213 lines
8.6 KiB
Nix
# A router configured by settings.json, changed and rebuilt on the device:
|
|
# install it (its configuration directory travels with the secrets), boot it
|
|
# without network, then
|
|
# - rebuilding the unchanged settings gives exactly the installed system,
|
|
# - a changed setting is built offline, activated and confirmed,
|
|
# - an unconfirmed change is rolled back,
|
|
# - and the same through the web UI from a LAN client: discard the
|
|
# unapplied change, edit the DHCP pool and add a reservation, apply,
|
|
# confirm.
|
|
# The rest of the web UI (dashboard, backup, forms) is tested in webui.nix,
|
|
# which needs no installed system.
|
|
{ self, pkgs }:
|
|
let
|
|
inherit (pkgs) lib;
|
|
common = import ./common.nix;
|
|
|
|
settings = pkgs.writeText "settings.json" (
|
|
builtins.toJSON {
|
|
# The schema of this source: the number of migrations.
|
|
version = lib.count (lib.hasSuffix ".jq") (builtins.attrNames (builtins.readDir ../migrations));
|
|
system = {
|
|
hostName = "router";
|
|
stateVersion = "26.11";
|
|
sshKeys = [
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyForTestsOnlyXXXXXXXXXXXXXXXXXXXXXXXXXX test"
|
|
];
|
|
};
|
|
router = {
|
|
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";
|
|
};
|
|
};
|
|
};
|
|
}
|
|
);
|
|
|
|
# Only so the test driver can talk to the installed system.
|
|
local = pkgs.writeText "local.nix" ''
|
|
{ modulesPath, ... }:
|
|
{
|
|
imports = [ (modulesPath + "/testing/test-instrumentation.nix") ];
|
|
}
|
|
'';
|
|
|
|
configDir = self.lib.mkConfigDir { inherit pkgs settings local; };
|
|
|
|
# What the device evaluates from configDir.
|
|
toplevel =
|
|
(self.lib.mkRouterSystem {
|
|
settings = "${configDir}/settings.json";
|
|
modules = [ "${configDir}/local.nix" ];
|
|
}).config.system.build.toplevel;
|
|
|
|
sharedDisk = {
|
|
virtualisation.diskImage = "./target.qcow2";
|
|
virtualisation.diskSize = 8192;
|
|
};
|
|
in
|
|
pkgs.testers.runNixOSTest {
|
|
name = "router-apply";
|
|
|
|
nodes = {
|
|
installer =
|
|
{ lib, ... }:
|
|
{
|
|
imports = [
|
|
sharedDisk
|
|
self.nixosModules.installer
|
|
common.tools
|
|
];
|
|
router.installer.system = toplevel;
|
|
virtualisation.memorySize = 2048;
|
|
virtualisation.fileSystems."/".autoFormat = true;
|
|
virtualisation.emptyDiskImages = [ 1024 ];
|
|
virtualisation.rootDevice = "/dev/vdb";
|
|
nix.settings.substituters = lib.mkForce [ ];
|
|
};
|
|
|
|
client =
|
|
{ lib, ... }:
|
|
{
|
|
imports = [ common.tools ];
|
|
virtualisation.vlans = [ 2 ];
|
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
|
{
|
|
address = "192.168.10.50";
|
|
prefixLength = 24;
|
|
}
|
|
];
|
|
};
|
|
|
|
target = {
|
|
imports = [ sharedDisk ];
|
|
# Evaluating a NixOS system takes memory.
|
|
virtualisation.memorySize = 3072;
|
|
virtualisation.vlans = [
|
|
1
|
|
2
|
|
];
|
|
virtualisation.useBootLoader = true;
|
|
virtualisation.useEFIBoot = true;
|
|
virtualisation.useDefaultFilesystems = false;
|
|
virtualisation.efi.keepVariables = false;
|
|
virtualisation.fileSystems."/" = {
|
|
device = "/dev/disk/by-label/this-is-not-real-and-will-never-be-used";
|
|
fsType = "ext4";
|
|
};
|
|
};
|
|
};
|
|
|
|
testScript = ''
|
|
installer.start()
|
|
installer.wait_for_unit("multi-user.target")
|
|
installer.succeed("mkdir -p /root/secrets && cp -r ${configDir} /root/secrets/config")
|
|
installer.succeed("router-install --yes --secrets /root/secrets /dev/vda >&2")
|
|
installer.shutdown()
|
|
|
|
target.state_dir = installer.state_dir
|
|
target.start()
|
|
target.wait_for_unit("multi-user.target")
|
|
|
|
def current():
|
|
return target.succeed("readlink -f /run/current-system").strip()
|
|
|
|
def pool_end():
|
|
return target.succeed("grep -o '192.168.10.100 - 192.168.10.[0-9]*' /etc/kea/dhcp4-server.conf").split()[-1]
|
|
|
|
with subtest("the installed system is the one built from the settings"):
|
|
assert current() == "${toplevel}"
|
|
target.succeed("test -f /var/lib/router/config/settings.json")
|
|
|
|
with subtest("rebuilding unchanged settings on the device changes nothing"):
|
|
out = target.succeed("router-apply 2>&1")
|
|
assert "no changes" in out, out
|
|
|
|
with subtest("a changed setting is built offline, activated and confirmed"):
|
|
target.succeed("sed -i 's/192.168.10.199/192.168.10.150/' /var/lib/router/config/settings.json")
|
|
target.succeed("router-apply 5min >&2")
|
|
assert current() != "${toplevel}"
|
|
assert pool_end() == "192.168.10.150"
|
|
target.succeed("systemctl is-active router-rollback.timer")
|
|
target.succeed("router-confirm >&2")
|
|
target.fail("systemctl is-active router-rollback.timer")
|
|
confirmed = current()
|
|
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == confirmed
|
|
|
|
with subtest("an unconfirmed change is rolled back"):
|
|
target.succeed("sed -i 's/192.168.10.150/192.168.10.120/' /var/lib/router/config/settings.json")
|
|
target.succeed("router-apply 20s >&2")
|
|
assert pool_end() == "192.168.10.120"
|
|
target.wait_until_succeeds(f"test $(readlink -f /run/current-system) = {confirmed}", timeout=120)
|
|
assert pool_end() == "192.168.10.150"
|
|
|
|
ui = "https://192.168.10.1"
|
|
|
|
def curl(args):
|
|
return client.succeed(f"curl -sk -b /tmp/jar -c /tmp/jar {args}")
|
|
|
|
def csrf(path):
|
|
return curl(f"{ui}{path} | grep -o 'name=\"csrf\" value=\"[^\"]*' | head -1 | cut -d'\"' -f4").strip()
|
|
|
|
with subtest("the web UI is reachable from the LAN and wants the password"):
|
|
target.succeed("echo hunter2hunter2 | router-webui passwd /var/lib/router/webui-password")
|
|
target.wait_for_unit("router-webui.service")
|
|
client.start()
|
|
client.wait_until_succeeds(f"curl -sk -o /dev/null {ui}/login", timeout=60)
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=wrong {ui}/login") == "401"
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter2hunter2 {ui}/login") == "303"
|
|
|
|
with subtest("the web UI shows the rolled back change as unapplied and discards it"):
|
|
page = curl(f"{ui}/")
|
|
assert "not part of the confirmed system" in page, page
|
|
token = csrf("/")
|
|
curl(f"-d csrf={token} {ui}/discard")
|
|
assert "192.168.10.150" in target.succeed("cat /var/lib/router/config/settings.json")
|
|
assert "match the confirmed system" in curl(f"{ui}/")
|
|
|
|
with subtest("a pool change and a reservation made in the web UI are applied and confirmed"):
|
|
token = csrf("/network")
|
|
form = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
|
|
"csrf": token, "hostName": "router", "wan.interface": "eth1", "wan.type": "dhcp",
|
|
"lans": "1", "lan.0.name": "main", "lan.0.interface": "eth2", "lan.0.address": "192.168.10.1",
|
|
"lan.0.internet": "on", "lan.0.dhcp": "on",
|
|
"lan.0.poolStart": "192.168.10.100", "lan.0.poolEnd": "192.168.10.130",
|
|
}.items())
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -X POST {ui}/network") == "403"
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' {form} {ui}/network") == "303"
|
|
# A reservation too: one apply for both changes.
|
|
reservation = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
|
|
"csrf": csrf("/dhcp"), "rows": "1", "res.0.lan": "main", "res.0.name": "printer",
|
|
"res.0.mac": "AA:BB:CC:00:11:22", "res.0.address": "192.168.10.60",
|
|
}.items())
|
|
assert curl(f"-o /dev/null -w '%{{http_code}}' {reservation} {ui}/dhcp") == "303"
|
|
curl(f"-d csrf={csrf('/')} {ui}/apply")
|
|
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-apply.service) = inactive"
|
|
" && test $(systemctl show -p Result --value router-apply.service) = success", timeout=600)
|
|
assert pool_end() == "192.168.10.130"
|
|
target.succeed("grep -q 'aa:bb:cc:00:11:22' /etc/kea/dhcp4-server.conf && grep -q '192.168.10.60' /etc/kea/dhcp4-server.conf")
|
|
assert "Waiting for confirmation" in curl(f"{ui}/")
|
|
curl(f"-d csrf={csrf('/')} {ui}/confirm")
|
|
target.wait_until_fails("systemctl is-active router-rollback.timer", timeout=60)
|
|
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive")
|
|
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
|
|
assert "match the confirmed system" in curl(f"{ui}/")
|
|
'';
|
|
}
|