Files
nixos-router/tests/update.nix
T
nilsandClaude Sonnet 5.5 a58600823f Add a generic NAT table: port forward, source NAT, masquerade
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
2026-10-30 15:54:29 +01:00

314 lines
12 KiB
Nix

# Updating an installed router to a newer nixos-router.
#
# "Versions" are copies of this repository evaluated as flakes of their own
# (a different source path, VERSION, commit time, and for version 2 a settings
# migration), so the whole path runs for real: a builder signs releases as
# binary caches (router-release), and the router takes them, with these rules:
#
# - signed with another key, or not signed at all: refused,
# - not newer than the installed version: refused,
# - upload in the web UI: imported, the configuration flake points at the new
# source, settings.json is migrated, the router is rebuilt offline and the
# update waits for confirmation; unconfirmed, everything goes back,
# - the same update pulled from a server and confirmed in the web UI: the
# router runs the new version and rebuilds to exactly itself.
{ self, pkgs }:
let
inherit (pkgs) lib;
common = import ./common.nix;
keys = ./update-keys;
# A version of the repository: its source (with the test key as update key)
# evaluated as a flake that has been told its commit time.
mkVersion =
{
name,
version,
serial,
extra ? "",
}:
let
src = pkgs.runCommand "nixos-router-${name}" { } ''
cp -r ${self.outPath} $out
chmod -R u+w $out
cp ${keys}/good.pub $out/keys/update.pub
printf '%s\n' ${version} > $out/VERSION
${extra}
'';
flake = (import "${src}/flake.nix").outputs {
self = flake // {
outPath = src;
lastModified = serial;
rev = name;
};
inherit (self.inputs) nixpkgs;
};
in
flake // { inherit src; };
v1 = mkVersion {
name = "v1";
version = "1.0.0";
serial = 1000;
# Settings version 0: the real migrations are not part of this test.
extra = "rm $out/migrations/*.jq";
};
v2 = mkVersion {
name = "v2";
version = "2.0.0";
serial = 2000;
# Settings version 1: the time zone becomes explicit.
extra = ''
echo '.system.timeZone = "Europe/Berlin"' > $out/migrations/1.jq
'';
};
release = v: v.lib.mkRelease {
inherit pkgs;
# The router has all it needs to rebuild; keep the bundles small.
includeReference = false;
};
release1 = release v1;
release2 = release v2;
tool = v: r: "${v.lib.mkReleaseTool { inherit pkgs; release = r; }}/bin/router-release";
settings = pkgs.writeText "settings.json" (
builtins.toJSON {
system = {
hostName = "router";
stateVersion = "26.11";
};
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; and a short
# rollback timeout, so that an unconfirmed update comes back soon.
local = pkgs.writeText "local.nix" ''
{ modulesPath, pkgs, ... }:
{
imports = [ (modulesPath + "/testing/test-instrumentation.nix") ];
router.rollback.timeout = "90s";
environment.systemPackages = [ pkgs.jq ];
}
'';
configDir = v1.lib.mkConfigDir { inherit pkgs settings local; };
toplevel =
(v1.lib.mkRouterSystem {
settings = "${configDir}/settings.json";
modules = [ "${configDir}/local.nix" ];
}).config.system.build.toplevel;
sharedDisk = {
virtualisation.diskImage = "./target.qcow2";
virtualisation.diskSize = 8192;
};
builderIp = "192.168.10.60";
in
pkgs.testers.runNixOSTest {
name = "router-update";
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 [ ];
};
target = {
imports = [ sharedDisk ];
virtualisation.memorySize = 4096;
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";
};
};
# Where releases are made and signed, served, and uploaded from.
builder =
{ lib, pkgs, ... }:
{
imports = [ common.tools ];
virtualisation.vlans = [ 2 ];
virtualisation.memorySize = 2048;
virtualisation.diskSize = 6144;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
{
address = builderIp;
prefixLength = 24;
}
];
networking.firewall.enable = false;
environment.systemPackages = [ pkgs.python3 ];
virtualisation.additionalPaths = [
release1
release2
];
environment.etc."keys/good.sec".source = "${keys}/good.sec";
environment.etc."keys/evil.sec".source = "${keys}/evil.sec";
};
};
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()
builder.start()
target.wait_for_unit("multi-user.target")
builder.wait_for_unit("multi-user.target")
target.succeed("echo hunter2hunter2 | router-webui passwd /var/lib/router/webui-password")
target.wait_for_unit("router-webui.service")
ui = "https://192.168.10.1"
def release_json(): return target.succeed("cat /etc/router-release")
def version(): return target.succeed("jq -r .version /etc/router-release").strip()
def current(): return target.succeed("readlink -f /run/current-system").strip()
def flake_source(): return target.succeed("grep -o 'path:[^\"]*' /var/lib/router/config/flake.nix").strip()
def serve(directory, port):
builder.succeed(f"systemd-run --unit=serve-{port} -p WorkingDirectory={directory} ${pkgs.python3}/bin/python3 -m http.server {port} --bind ${builderIp}")
builder.wait_for_open_port(port, "${builderIp}")
def sign(tool, key, out):
builder.succeed(f"{tool} --key /etc/keys/{key}.sec --out {out} --compression none >&2")
def update_from(port, flags=""):
return target.execute(f"router-update {flags} --url http://${builderIp}:{port} 2>&1")
def login():
builder.succeed(f"curl -sk -c /tmp/jar -o /dev/null -d password=hunter2hunter2 {ui}/login")
def csrf(path):
return builder.succeed(f"curl -sk -b /tmp/jar {ui}{path} | grep -o 'name=\"csrf\" value=\"[^\"]*' | head -1 | cut -d'\"' -f4").strip()
def csrf_from_query(path):
return builder.succeed(f"curl -sk -b /tmp/jar {ui}{path} | grep -o 'upload?csrf=[^\"]*' | head -1 | cut -d= -f2").strip()
def update_finished():
target.wait_until_succeeds(
"test $(systemctl show -p ActiveState --value router-update.service) = inactive"
" && test $(systemctl show -p Result --value router-update.service) = success", timeout=900)
v1_flake = flake_source()
with subtest("the installed router is version 1 and trusts the update key"):
assert version() == "1.0.0", release_json()
assert v1_flake == "path:${v1.src}", v1_flake
target.succeed("test -s /etc/router-update-keys")
sign("${tool v2 release2}", "evil", "/root/evil")
serve("/root/evil", 8001)
sign("${tool v2 release2}", "good", "/root/good")
serve("/root/good", 8004)
# The good release without its signatures.
builder.succeed("cp -r /root/good /root/unsigned && sed -i '/^Sig:/d' /root/unsigned/cache/*.narinfo")
serve("/root/unsigned", 8002)
sign("${tool v1 release1}", "good", "/root/old")
serve("/root/old", 8003)
def unchanged():
assert version() == "1.0.0"
assert flake_source() == v1_flake
target.succeed("test ! -e /var/lib/router/config.pre-update")
target.fail("grep -q '\"version\"' /var/lib/router/config/settings.json")
with subtest("an update signed with another key is refused"):
rc, out = update_from(8001)
assert rc != 0 and "not signed with this router's update key" in out, out
unchanged()
with subtest("an update without signatures is refused"):
rc, out = update_from(8002)
assert rc != 0 and "not signed with this router's update key" in out, out
unchanged()
with subtest("a release that is not newer is refused"):
rc, out = update_from(8003)
assert rc != 0 and "not newer" in out, out
unchanged()
with subtest("a bundle uploaded in the web UI is verified, installed and migrated"):
login()
token = csrf_from_query("/update")
assert token
assert "Install a bundle" in builder.succeed(f"curl -sk -b /tmp/jar {ui}/update")
# (-F puts the file after the other fields, as the page's form does.)
assert builder.succeed(
f"curl -sk -b /tmp/jar -o /dev/null -w '%{{http_code}}' -F bundle=@/root/good/router-update-2.0.0.bundle '{ui}/update/upload?csrf=wrong'").strip() == "403"
builder.succeed(f"curl -sk -b /tmp/jar -o /dev/null -F bundle=@/root/good/router-update-2.0.0.bundle '{ui}/update/upload?csrf={token}'")
update_finished()
assert version() == "2.0.0", release_json()
assert flake_source() == "path:${v2.src}", flake_source()
settings = target.succeed("cat /var/lib/router/config/settings.json")
assert '"version": 1' in settings and "Europe/Berlin" in settings, settings
assert target.succeed("readlink /etc/localtime").strip().endswith("Europe/Berlin")
target.succeed("systemctl is-active router-rollback.timer")
target.succeed("test -d /var/lib/router/config.pre-update")
with subtest("an update that is not confirmed is rolled back, configuration included"):
target.wait_until_succeeds("test $(jq -r .version /etc/router-release) = 1.0.0", timeout=300)
unchanged()
assert "Berlin" not in target.succeed("cat /var/lib/router/config/settings.json")
assert target.succeed("readlink /etc/localtime").strip().endswith("/UTC")
target.wait_for_unit("router-webui.service")
with subtest("the same update, pulled from a server in the web UI, can be confirmed"):
login()
token = csrf("/update")
form = lambda path, data: builder.succeed(
f"curl -sk -b /tmp/jar -o /tmp/out -w '%{{http_code}}' --data-urlencode csrf={token} {data} {ui}{path}").strip()
assert form("/update/url", "--data-urlencode url=http://${builderIp}:8004/") == "303"
assert "Available: <strong>2.0.0</strong>" in (form("/update/check", "") and builder.succeed("cat /tmp/out"))
assert form("/update/install", "") == "303"
update_finished()
assert version() == "2.0.0"
target.succeed("systemctl is-active router-rollback.timer")
login()
token = csrf("/")
builder.succeed(f"curl -sk -b /tmp/jar -o /dev/null --data-urlencode csrf={token} {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")
target.succeed("test ! -e /var/lib/router/config.pre-update")
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
assert target.succeed("readlink /etc/localtime").strip().endswith("Europe/Berlin")
with subtest("the updated router rebuilds to exactly itself, offline"):
out = target.succeed("router-apply 2>&1")
assert "no changes" in out, out
with subtest("the old release is not accepted any more"):
rc, out = update_from(8003)
assert rc != 0 and "not newer" in out, out
assert version() == "2.0.0"
'';
}