Files
nilsandClaude Sonnet 5.5 b9c63273e4 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
2026-10-29 13:34:21 +01:00

230 lines
8.3 KiB
Nix

# Updating an installed router to a newer version of nixos-router.
#
# A release is a store path (see `lib.mkRelease`): the new source, its
# nixpkgs, the configuration flake that points at them and, for offline
# routers, the reference system with everything the rebuild needs. It travels
# as a binary cache signed with the update key: a bundle file (uploaded in the
# web UI or copied over) or a URL the router pulls from. `router-update`
# accepts it only when every path in its closure carries a signature of
# `routerUpdate.publicKeys`, then
#
# 1. imports it into the store,
# 2. points the configuration flake at it,
# 3. migrates settings.json to the release's schema (migrations/N.jq),
# 4. rebuilds with `router-apply` (commit-confirm: unless the update is
# confirmed in time, the previous system comes back).
#
# The configuration directory as it was is kept in config.pre-update until the
# update is confirmed; a rollback (or a failed update) puts it back.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.routerSettings;
keys = config.routerUpdate.publicKeys;
settings = lib.importJSON cfg.file;
snapshot = "${cfg.stateDirectory}/config.pre-update";
updateDir = "${cfg.stateDirectory}/update";
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
migrate = pkgs.callPackage ../../pkgs/router-migrate.nix { };
restore = pkgs.writeShellApplication {
name = "router-update-restore";
runtimeInputs = [
pkgs.coreutils
pkgs.findutils
];
text = ''
[ -d ${snapshot} ] || exit 0
find ${cfg.directory} -mindepth 1 -delete
cp -a ${snapshot}/. ${cfg.directory}/
rm -rf ${snapshot}
echo "router: restored the configuration from before the update"
'';
};
update = pkgs.writeShellApplication {
name = "router-update";
runtimeInputs = [
config.nix.package
config.systemd.package
webui
migrate
pkgs.coreutils
pkgs.curl
pkgs.jq
pkgs.util-linux
];
text = ''
usage() {
echo "usage: router-update [--force] (--request | --bundle FILE | --url URL) [timeout]" >&2
echo " --bundle FILE a bundle made by router-release" >&2
echo " --url URL a server with the release's latest and cache/ (HTTP or HTTPS)" >&2
echo " --request what the web UI asked for (${updateDir}/request.json)" >&2
echo " --force also install a release that is not newer" >&2
exit 2
}
die() { echo "router-update: $*" >&2; exit 1; }
force=0 mode="" arg="" timeout=""
while [ $# -gt 0 ]; do
case $1 in
--force) force=1 ;;
--bundle) shift; [ $# -gt 0 ] || usage; mode=bundle arg=$1 ;;
--url) shift; [ $# -gt 0 ] || usage; mode=url arg=$1 ;;
--request)
req=${updateDir}/request.json
[ -r "$req" ] || die "no request"
mode=$(jq -er .mode "$req") arg=$(jq -r '.url // ""' "$req")
force=$(jq -r 'if .force then 1 else 0 end' "$req")
rm -f "$req" ;;
-*) usage ;;
*) timeout=$1 ;;
esac
shift
done
[ -n "$mode" ] || usage
[ "$(id -u)" -eq 0 ] || die "must run as root"
exec 9>/run/router-update.lock
flock -n 9 || die "another update is running"
trusted=$(tr '\n' ' ' < /etc/router-update-keys)
[ -n "''${trusted// /}" ] || die "this router has no update key"
staged=${updateDir}/staged
case $mode in
bundle)
rm -rf "$staged"
router-webui unbundle "$arg" "$staged"
mode=staged ;;
esac
case $mode in
staged)
store="file://$staged/cache"
latest=$(cat "$staged/latest") ;;
url)
base=''${arg%/}
store="$base/cache"
latest=$(curl -fsS --max-time 30 "$base/latest") ;;
*) usage ;;
esac
release=$(jq -er .release <<<"$latest") || die "the update has no release"
[[ $release =~ ^/nix/store/[0-9a-z]{32}-[A-Za-z0-9+._-]+$ ]] || die "not a store path: $release"
# Nothing is trusted until every path of the closure is signed by our key.
echo "router-update: verifying $release"
nix --option trusted-public-keys "$trusted" store verify \
--store "$store" --no-contents --sigs-needed 1 -r "$release" \
|| die "the update is not signed with this router's update key"
echo "router-update: importing it"
nix --option trusted-public-keys "$trusted" copy --from "$store" "$release"
rm -rf "$staged"
new_version=$(cat "$release/version")
new_serial=$(cat "$release/serial")
cur_version=$(jq -r '.version // "unknown"' /etc/router-release)
cur_serial=$(jq -r '.serial // 0' /etc/router-release)
echo "router-update: installed $cur_version, update $new_version"
if [ "$new_serial" -le "$cur_serial" ] && [ "$force" -ne 1 ]; then
die "this release is not newer than the installed one (--force installs it anyway)"
fi
config=${cfg.directory}
[ ! -d ${snapshot} ] || die "an earlier update is not confirmed yet"
cp -a "$config" ${snapshot}
fail() {
echo "router-update: $*; keeping $cur_version" >&2
${restore}/bin/router-update-restore
exit 1
}
install -m 0644 "$release/flake.nix" "$config/flake.nix"
rm -f "$config/flake.lock"
router-migrate-settings "$release" "$config/settings.json" || fail "migrating the settings failed"
echo "router-update: building and activating $new_version"
if ! router-apply ''${timeout:+"$timeout"}; then
if systemctl is-active --quiet router-rollback.timer; then
# Activation failed half way: the rollback restores the configuration.
systemctl start router-rollback.service
else
${restore}/bin/router-update-restore
fi
die "applying $new_version failed; $cur_version is back"
fi
if systemctl is-active --quiet router-rollback.timer; then
echo "router-update: $new_version is active; confirm it (router-confirm, or in the web UI) or $cur_version comes back"
else
rm -rf ${snapshot} # nothing changed: nothing to confirm
fi
'';
};
in
{
options = {
routerSettings.release = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
default = null;
description = "Version, serial, revision and settings schema of this nixos-router (`/etc/router-release`).";
};
routerUpdate.publicKeys = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "my-update-key-1:AAAA…=" ];
description = ''
Public keys (`nix key convert-secret-to-public`) of the keys whose
signatures make an update acceptable.
'';
};
};
config = {
environment.etc."router-release".text = builtins.toJSON (
if config.routerSettings.release == null then { } else config.routerSettings.release
);
environment.etc."router-update-keys".text = lib.concatLines keys;
environment.systemPackages = [
update
restore
];
assertions = lib.optional (config.routerSettings.release != null) {
assertion = (settings.version or 0) == config.routerSettings.release.schema;
message = ''
settings.json is at version ${toString (settings.version or 0)}, but this release of nixos-router expects
version ${toString config.routerSettings.release.schema}. `router-update` migrates the settings; if you edited
them by hand, set "version" accordingly.
'';
};
# A rolled back or failed update puts the configuration directory back;
# a confirmed one keeps the new one.
router.rollback.onRollback = "${restore}/bin/router-update-restore";
router.rollback.onConfirm = "rm -rf ${snapshot}";
systemd.services.router-update = {
description = "Install a router update";
restartIfChanged = false;
stopIfChanged = false;
path = [ "/run/current-system/sw" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${lib.getExe update} --request";
TimeoutStartSec = "2h";
};
};
# Old generations (and the updates' leftovers) would fill the disk.
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 30d";
};
};
}