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
314 lines
12 KiB
Nix
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"
|
|
'';
|
|
}
|