|
|
|
@@ -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"
|
|
|
|
|
'';
|
|
|
|
|
}
|