Add signed updates from a bundle or a URL

router-release signs a release as a binary cache; router-update verifies
it against the router's update keys, migrates settings.json, and applies
it with commit-confirm. Includes the web UI page and a VM test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-29 13:34:21 +01:00
co-authored by Claude Sonnet 5.5
parent 2a111d012b
commit b9c63273e4
21 changed files with 1500 additions and 21 deletions
+311
View File
@@ -0,0 +1,311 @@
# 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;
};
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"
'';
}