# 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: 2.0.0" 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" ''; }