router.update.channel names the nixpkgs branch to follow (unstable or a release like 26.05). router-upgrade check compares its newest tested revision with the running one; preview locks a copy of the configuration flake to it and lists the packages that change version and the size of the download without fetching anything; download fetches it; apply takes the lock and activates with commit-confirm, the lock going back on a rollback or a failed build. router-update keeps an upgraded nixpkgs pinned. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
237 lines
8.6 KiB
Nix
237 lines
8.6 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"
|
|
# A nixpkgs that router-upgrade chose stays, rather than the release's.
|
|
if [ -r "$config/nixpkgs-pin.json" ]; then
|
|
nix flake lock --override-input nixos-router/nixpkgs "$(jq -r .ref "$config/nixpkgs-pin.json")" "path:$config" \
|
|
|| fail "pinning the upgraded nixpkgs failed"
|
|
fi
|
|
|
|
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";
|
|
};
|
|
};
|
|
}
|