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:
@@ -0,0 +1,229 @@
|
||||
# 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";
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user