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:
@@ -3,3 +3,4 @@ result-*
|
||||
.nixos-test-history
|
||||
/image
|
||||
/installer
|
||||
/dist
|
||||
|
||||
@@ -11,6 +11,42 @@
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
|
||||
# What a release is: shown by the web UI, compared by router-update
|
||||
# (the serial never goes down: the commit time) and checked against the
|
||||
# settings (the schema is the number of migrations/N.jq).
|
||||
migrations = builtins.filter (n: nixpkgs.lib.hasSuffix ".jq" n) (
|
||||
builtins.attrNames (builtins.readDir ./migrations)
|
||||
);
|
||||
thisRelease = {
|
||||
version = nixpkgs.lib.fileContents ./VERSION;
|
||||
serial = self.lastModified or 0;
|
||||
rev = self.rev or self.dirtyRev or "unknown";
|
||||
schema = builtins.length migrations;
|
||||
};
|
||||
|
||||
devPkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
# The development helpers (see DEVELOPING.md) as apps.
|
||||
devScript =
|
||||
name: inputs: file:
|
||||
let
|
||||
p = devPkgs.writeShellApplication {
|
||||
inherit name;
|
||||
runtimeInputs = inputs;
|
||||
text = builtins.readFile file;
|
||||
# Interactive helpers: the word splitting of $@ and the like is intended.
|
||||
excludeShellChecks = [ "SC2086" ];
|
||||
};
|
||||
in
|
||||
{
|
||||
type = "app";
|
||||
program = "${p}/bin/${name}";
|
||||
};
|
||||
|
||||
# Public keys of the update signing key (see `nix run .#release`).
|
||||
defaultUpdateKeys = nixpkgs.lib.optional (builtins.pathExists ./keys/update.pub) (
|
||||
nixpkgs.lib.fileContents ./keys/update.pub
|
||||
);
|
||||
in
|
||||
{
|
||||
nixosModules = {
|
||||
@@ -35,6 +71,9 @@
|
||||
{
|
||||
settings,
|
||||
modules ? [ ],
|
||||
updateKeys ? defaultUpdateKeys,
|
||||
# The configuration flake passes it on: a `path:` flake has no commit.
|
||||
release ? thisRelease,
|
||||
}:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
@@ -44,6 +83,8 @@
|
||||
self.nixosModules.installed-layout
|
||||
{
|
||||
routerSettings.file = settings;
|
||||
routerSettings.release = release;
|
||||
routerUpdate.publicKeys = updateKeys;
|
||||
# The device rebuilds itself, offline if need be: it needs the
|
||||
# sources its configuration flake points to.
|
||||
system.extraDependencies = [
|
||||
@@ -82,6 +123,12 @@
|
||||
{ nixos-router, ... }:
|
||||
{
|
||||
nixosConfigurations.router = nixos-router.lib.mkRouterSystem {
|
||||
release = {
|
||||
version = "${thisRelease.version}";
|
||||
serial = ${toString thisRelease.serial};
|
||||
rev = "${thisRelease.rev}";
|
||||
schema = ${toString thisRelease.schema};
|
||||
};
|
||||
settings = ./settings.json;
|
||||
modules = if builtins.pathExists ./local.nix then [ ./local.nix ] else [ ];
|
||||
};
|
||||
@@ -104,30 +151,99 @@
|
||||
imports = [ self.nixosModules.setup ];
|
||||
router.setup = {
|
||||
flake = self.lib.configFlake pkgs;
|
||||
reference =
|
||||
(self.lib.mkRouterSystem {
|
||||
settings = builtins.toFile "settings.json" (
|
||||
builtins.toJSON {
|
||||
router = {
|
||||
wan.interface = "eth0";
|
||||
lans.lan = {
|
||||
interface = "eth1";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.1.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.1.100";
|
||||
end = "192.168.1.200";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
# A path, not a derivation: the module system would read its attributes.
|
||||
modules = pkgs.lib.optional (local != null) "${local}";
|
||||
}).config.system.build.toplevel;
|
||||
reference = self.lib.referenceSystem { inherit pkgs local; };
|
||||
schemaVersion = thisRelease.schema;
|
||||
release = self.lib.mkRelease {
|
||||
inherit pkgs;
|
||||
includeReference = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# A router built the way installed ones are: its closure carries what
|
||||
# building any other settings needs, so it can be done offline.
|
||||
lib.referenceSystem =
|
||||
{
|
||||
pkgs,
|
||||
local ? null,
|
||||
}:
|
||||
(self.lib.mkRouterSystem {
|
||||
settings = builtins.toFile "settings.json" (
|
||||
builtins.toJSON {
|
||||
router = {
|
||||
wan.interface = "eth0";
|
||||
lans.lan = {
|
||||
interface = "eth1";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.1.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.1.100";
|
||||
end = "192.168.1.200";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
# A path, not a derivation: the module system would read its attributes.
|
||||
modules = pkgs.lib.optional (local != null) "${local}";
|
||||
}).config.system.build.toplevel;
|
||||
|
||||
# An update: this source, its nixpkgs, the configuration flake that
|
||||
# points at them and (unless the router has them already) the reference
|
||||
# system with everything its offline builds need. router-update takes
|
||||
# this store path from a signed binary cache (`nix run .#release`).
|
||||
lib.mkRelease =
|
||||
{
|
||||
pkgs,
|
||||
includeReference ? true,
|
||||
}:
|
||||
pkgs.runCommand "router-release-${thisRelease.version}" { } ''
|
||||
mkdir -p $out/migrations
|
||||
printf %s ${thisRelease.version} > $out/version
|
||||
printf %s ${toString thisRelease.serial} > $out/serial
|
||||
printf %s ${toString thisRelease.schema} > $out/schema
|
||||
printf %s ${thisRelease.rev} > $out/rev
|
||||
cp ${self.lib.configFlake pkgs} $out/flake.nix
|
||||
for f in ${nixpkgs.lib.concatMapStringsSep " " (n: "${./migrations}/${n}") migrations}; do
|
||||
cp "$f" $out/migrations/
|
||||
done
|
||||
# These references are what make the closure.
|
||||
echo ${self.outPath} ${nixpkgs.outPath} \
|
||||
${nixpkgs.lib.optionalString includeReference (self.lib.referenceSystem { inherit pkgs; })} \
|
||||
> $out/closure
|
||||
'';
|
||||
|
||||
# `nix run .#release -- --key KEY [--out DIR]`: signs a release as a
|
||||
# binary cache and writes the bundle and the pull-server layout.
|
||||
lib.mkReleaseTool =
|
||||
{ pkgs, release }:
|
||||
pkgs.writeShellApplication {
|
||||
name = "router-release";
|
||||
runtimeInputs = with pkgs; [
|
||||
nix
|
||||
gnutar
|
||||
coreutils
|
||||
jq
|
||||
];
|
||||
text = ''
|
||||
release=${release}
|
||||
${builtins.readFile ./scripts/release.sh}
|
||||
'';
|
||||
};
|
||||
|
||||
packages.x86_64-linux.release = self.lib.mkRelease { pkgs = nixpkgs.legacyPackages.x86_64-linux; };
|
||||
apps.x86_64-linux.release =
|
||||
let
|
||||
tool = self.lib.mkReleaseTool {
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
release = self.packages.x86_64-linux.release;
|
||||
};
|
||||
in
|
||||
{
|
||||
type = "app";
|
||||
program = "${tool}/bin/router-release";
|
||||
};
|
||||
|
||||
# The generic installer ISO:
|
||||
# nix build .#installer
|
||||
nixosConfigurations.installer = nixpkgs.lib.nixosSystem {
|
||||
@@ -215,6 +331,31 @@
|
||||
program = "${deploy}/bin/router-deploy";
|
||||
};
|
||||
|
||||
# Development loop (see DEVELOPING.md):
|
||||
# nix run .#fast quick checks, no VMs
|
||||
# nix run .#test -- update VM tests, one at a time, with a summary
|
||||
# nix run .#webui-dev the web UI locally, templates hot-reload
|
||||
apps.x86_64-linux.fast = devScript "router-fast" (with devPkgs; [
|
||||
git
|
||||
go_1_27
|
||||
nix
|
||||
coreutils
|
||||
gnugrep
|
||||
]) ./scripts/fast.sh;
|
||||
apps.x86_64-linux.test = devScript "router-test" (with devPkgs; [
|
||||
nix
|
||||
coreutils
|
||||
gnugrep
|
||||
gnused
|
||||
]) ./scripts/test.sh;
|
||||
apps.x86_64-linux.webui-dev = devScript "router-webui-dev" (with devPkgs; [
|
||||
git
|
||||
go_1_27
|
||||
iproute2
|
||||
jq
|
||||
coreutils
|
||||
]) ./scripts/webui-dev.sh;
|
||||
|
||||
# VM tests need KVM; only wired up for x86_64 until aarch64 CI exists.
|
||||
checks.x86_64-linux =
|
||||
import ./tests {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
nixos-router-update-1:pdzUo8vb5GtdQFd1SGVin45qAW25AqERczbcsKGW0M4=
|
||||
@@ -0,0 +1,16 @@
|
||||
# settings.json migrations
|
||||
|
||||
`settings.json` carries a top-level `"version"` (absent means 0). When a
|
||||
release changes what settings.json may contain (a renamed or removed option, a
|
||||
new default that must be written down), add `N.jq` here, where N is the next
|
||||
number after the highest one. The file is a jq program that turns a version
|
||||
N-1 document into a version N document; keep unknown keys as they are.
|
||||
|
||||
`router-update` runs the missing ones, in order, on the router's settings.json
|
||||
before it rebuilds, and sets `"version"` after each. A release's schema version
|
||||
is the number of `N.jq` files; the build refuses settings that are not at that
|
||||
version, so a router never builds settings its release does not understand.
|
||||
|
||||
Example `1.jq`, renaming `router.wan.interface` to `router.wan.device`:
|
||||
|
||||
.router.wan |= (.device = .interface | del(.interface))
|
||||
@@ -29,6 +29,12 @@ let
|
||||
text = ''
|
||||
${confirmed}
|
||||
echo "router: rolling back to $(readlink -f "$target")"
|
||||
# A failing hook must not stop the rollback.
|
||||
hook() {
|
||||
:
|
||||
${cfg.onRollback}
|
||||
}
|
||||
hook || echo "router: a rollback hook failed" >&2
|
||||
exec "$target/bin/switch-to-configuration" test
|
||||
'';
|
||||
};
|
||||
@@ -71,6 +77,12 @@ in
|
||||
internal = true;
|
||||
description = "Shell commands `router-confirm` runs once the current system is confirmed.";
|
||||
};
|
||||
onRollback = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = "Shell commands `router-rollback` runs before it re-activates the confirmed system.";
|
||||
};
|
||||
timeout = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "5min";
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# router-migrate-settings RELEASE FILE: bring a settings.json up to the
|
||||
# schema of a release by running its migrations/N.jq in order.
|
||||
{
|
||||
writeShellApplication,
|
||||
coreutils,
|
||||
jq,
|
||||
}:
|
||||
writeShellApplication {
|
||||
name = "router-migrate-settings";
|
||||
runtimeInputs = [
|
||||
coreutils
|
||||
jq
|
||||
];
|
||||
text = ''
|
||||
[ $# -eq 2 ] || { echo "usage: router-migrate-settings RELEASE SETTINGS.json" >&2; exit 2; }
|
||||
release=$1 file=$2
|
||||
schema=$(cat "$release/schema")
|
||||
version=$(jq -r '.version // 0' "$file")
|
||||
if [ "$version" -gt "$schema" ]; then
|
||||
echo "the settings are at version $version, newer than this release ($schema)" >&2
|
||||
exit 1
|
||||
fi
|
||||
while [ "$version" -lt "$schema" ]; do
|
||||
next=$((version + 1))
|
||||
echo "migrating the settings to version $next"
|
||||
jq -f "$release/migrations/$next.jq" "$file" | jq --argjson v "$next" '.version = $v' > "$file.new"
|
||||
chmod 0600 "$file.new"
|
||||
mv "$file.new" "$file"
|
||||
version=$next
|
||||
done
|
||||
'';
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
type System struct {
|
||||
ConfigDir string // the configuration flake, settings.json in it
|
||||
StateDir string // /var/lib/router: secrets, UI state
|
||||
// ReleaseFile describes the installed nixos-router; /etc/router-release if empty.
|
||||
ReleaseFile string
|
||||
}
|
||||
|
||||
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<nav>
|
||||
<a href="/">Overview</a>
|
||||
<a href="/network">Network</a>
|
||||
<a href="/update">Update</a>
|
||||
<a href="/advanced">Advanced</a>
|
||||
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "content"}}
|
||||
{{$d := .Data}}
|
||||
<section>
|
||||
<h2>Installed</h2>
|
||||
<dl>
|
||||
<dt>Version</dt><dd>{{or $d.Installed.Version "unknown"}}{{if $d.Installed.Rev}} <span class="muted">{{$d.Installed.Rev}}</span>{{end}}</dd>
|
||||
<dt>Settings schema</dt><dd>{{$d.Installed.Schema}}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{{if $d.Status.Running}}
|
||||
<section class="busy"><h2>Updating…</h2><p>Verifying, importing and building the new version. This page refreshes on its own;
|
||||
the web UI restarts when the new version is switched on.</p></section>
|
||||
{{else if $d.Pending}}
|
||||
<section class="pending"><h2>Waiting for confirmation</h2>
|
||||
<p>A new configuration is active and not confirmed yet. Confirm it on the <a href="/">Overview</a>, or the router
|
||||
goes back to the previous version.</p></section>
|
||||
{{end}}
|
||||
|
||||
<section>
|
||||
<h2>Install a bundle</h2>
|
||||
<p>A <code>.bundle</code> file made with <code>nix run .#release</code>. It is installed only if it is signed with
|
||||
this router's update key. The settings are kept (and migrated if the new version needs it); the new version
|
||||
must be confirmed afterwards, or the router goes back.</p>
|
||||
<form method="post" action="/update/upload?csrf={{.CSRF}}" enctype="multipart/form-data">
|
||||
<p><label><input type="checkbox" name="force"> also install a version that is not newer</label></p>
|
||||
<p><input type="file" name="bundle" required> <button>Upload and install</button></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Update server</h2>
|
||||
<form method="post" action="/update/url" class="actions">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Address <input name="url" value="{{$d.URL}}" size="40" placeholder="https://updates.example.com/router"></label>
|
||||
<button class="secondary">Save</button>
|
||||
</form>
|
||||
{{if $d.URL}}
|
||||
<form method="post" action="/update/check" class="actions">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="secondary">Check for updates</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if $d.OfferErr}}<p class="error">Could not check: {{$d.OfferErr}}</p>{{end}}
|
||||
{{with $d.Offer}}
|
||||
<p>{{if .Newer}}Available: <strong>{{.Version}}</strong>{{if .Rev}} <span class="muted">{{.Rev}}</span>{{end}}.{{else}}Nothing newer: the server has {{.Version}}.{{end}}</p>
|
||||
<form method="post" action="/update/install">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
{{if not .Newer}}<p><label><input type="checkbox" name="force"> install it anyway</label></p>{{end}}
|
||||
<button>{{if .Newer}}Install {{.Version}}{{else}}Install{{end}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<p class="hint">The server only has to serve static files: the <code>latest</code> file and <code>cache/</code> that
|
||||
<code>nix run .#release</code> writes. The data is signed, so plain HTTP works too.</p>
|
||||
</section>
|
||||
|
||||
{{if $d.Status.Log}}
|
||||
<section>
|
||||
<h2>Last update{{if $d.Status.Result}} — {{if eq $d.Status.Result "success"}}succeeded{{else}}failed ({{$d.Status.Result}}){{end}}{{end}}</h2>
|
||||
<pre class="log">{{$d.Status.Log}}</pre>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,387 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Updates arrive as a tar of a signed binary cache (see lib.mkRelease):
|
||||
//
|
||||
// latest
|
||||
// cache/nix-cache-info
|
||||
// cache/<hash>.narinfo
|
||||
// cache/nar/<hash>.nar[.zst|.xz]
|
||||
//
|
||||
// The UI only stages the files; router-update (root, a systemd unit) verifies
|
||||
// the signatures. Extraction is still strict: only these names, only regular
|
||||
// files, bounded in size, so a hostile bundle cannot write elsewhere.
|
||||
|
||||
const (
|
||||
maxBundle = 8 << 30 // bytes in all
|
||||
maxLatest = 64 << 10
|
||||
)
|
||||
|
||||
var bundleNames = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^latest$`),
|
||||
regexp.MustCompile(`^cache/nix-cache-info$`),
|
||||
regexp.MustCompile(`^cache/[0-9a-z]{32}\.narinfo$`),
|
||||
regexp.MustCompile(`^cache/nar/[0-9a-z]{20,64}\.nar(\.[a-z0-9]{1,5})?$`),
|
||||
}
|
||||
|
||||
// unbundle extracts a bundle into dir, which it replaces.
|
||||
func unbundle(r io.Reader, dir string) error {
|
||||
dir = filepath.Clean(dir)
|
||||
tmp := dir + ".tmp"
|
||||
if err := os.RemoveAll(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(tmp, "cache", "nar"), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := extract(tar.NewReader(r), tmp); err != nil {
|
||||
os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
for _, f := range []string{"latest", "cache/nix-cache-info"} {
|
||||
if _, err := os.Stat(filepath.Join(tmp, f)); err != nil {
|
||||
os.RemoveAll(tmp)
|
||||
return fmt.Errorf("not an update bundle: %s is missing", f)
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dir)
|
||||
}
|
||||
|
||||
func extract(tr *tar.Reader, root string) error {
|
||||
var total int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("not an update bundle: %w", err)
|
||||
}
|
||||
name := path.Clean(strings.TrimPrefix(hdr.Name, "./"))
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
// Directories are made here; the entries only have to be harmless.
|
||||
switch name {
|
||||
case ".", "cache", "cache/nar", "cache/log", "cache/realisations":
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("unexpected directory %q in the bundle", hdr.Name)
|
||||
case tar.TypeReg:
|
||||
default:
|
||||
return fmt.Errorf("unexpected entry %q in the bundle (only plain files are allowed)", hdr.Name)
|
||||
}
|
||||
ok := false
|
||||
for _, re := range bundleNames {
|
||||
ok = ok || re.MatchString(name)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected file %q in the bundle", hdr.Name)
|
||||
}
|
||||
limit := int64(maxBundle) - total
|
||||
if name == "latest" {
|
||||
limit = min(limit, maxLatest)
|
||||
}
|
||||
if hdr.Size > limit {
|
||||
return fmt.Errorf("the bundle is too large (%q)", hdr.Name)
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(name)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q appears twice in the bundle", hdr.Name)
|
||||
}
|
||||
n, err := io.Copy(f, tr)
|
||||
total += n
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release describes a version of nixos-router (/etc/router-release, and the
|
||||
// latest file of an update).
|
||||
type Release struct {
|
||||
Version string `json:"version"`
|
||||
Serial int64 `json:"serial"`
|
||||
Rev string `json:"rev"`
|
||||
Schema int `json:"schema"`
|
||||
}
|
||||
|
||||
func (s System) releasePath() string {
|
||||
if s.ReleaseFile != "" {
|
||||
return s.ReleaseFile
|
||||
}
|
||||
return "/etc/router-release"
|
||||
}
|
||||
|
||||
func (s System) installedRelease() Release {
|
||||
var r Release
|
||||
if data, err := os.ReadFile(s.releasePath()); err == nil {
|
||||
json.Unmarshal(data, &r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (s System) updateDir() string { return filepath.Join(s.StateDir, "update") }
|
||||
func (s System) updateURLPath() string { return filepath.Join(s.StateDir, "webui", "update.json") }
|
||||
|
||||
func (s System) updateURL() string {
|
||||
var v struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if data, err := os.ReadFile(s.updateURLPath()); err == nil {
|
||||
json.Unmarshal(data, &v)
|
||||
}
|
||||
return v.URL
|
||||
}
|
||||
|
||||
func (s System) saveUpdateURL(u string) error {
|
||||
data, _ := json.Marshal(struct {
|
||||
URL string `json:"url"`
|
||||
}{u})
|
||||
if err := os.MkdirAll(filepath.Dir(s.updateURLPath()), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(s.updateURLPath(), data, 0o600)
|
||||
}
|
||||
|
||||
// parseUpdateURL accepts the base of an update server: http or https, no
|
||||
// credentials, no query. The data is signed, so plain HTTP is no risk to the
|
||||
// router; it only lets an attacker withhold updates.
|
||||
func parseUpdateURL(v string) (string, error) {
|
||||
v = strings.TrimRight(strings.TrimSpace(v), "/")
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil ||
|
||||
u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", errors.New("an http:// or https:// address without credentials")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Available is what the update server offers.
|
||||
type Available struct {
|
||||
Release
|
||||
Newer bool
|
||||
}
|
||||
|
||||
func (s System) checkUpdate(base string) (Available, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(base + "/latest")
|
||||
if err != nil {
|
||||
return Available{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Available{}, fmt.Errorf("the server answered %s", resp.Status)
|
||||
}
|
||||
var a Available
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxLatest)).Decode(&a); err != nil || a.Version == "" {
|
||||
return Available{}, errors.New("the server's latest file is not an update description")
|
||||
}
|
||||
a.Newer = a.Serial > s.installedRelease().Serial
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// request asks the router-update unit to install; it reads this file.
|
||||
func (s System) requestUpdate(mode, url string, force bool) error {
|
||||
data, _ := json.Marshal(map[string]any{"mode": mode, "url": url, "force": force})
|
||||
if err := os.MkdirAll(s.updateDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(filepath.Join(s.updateDir(), "request.json"), data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return startUnit("router-update.service")
|
||||
}
|
||||
|
||||
// startUnit is replaced in tests.
|
||||
var startUnit = func(unit string) error {
|
||||
_, err := command("systemctl", "start", "--no-block", unit)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateStatus is the state of the router-update unit.
|
||||
type UpdateStatus struct {
|
||||
Running bool
|
||||
Result string // of the last run; "" if none
|
||||
Log string
|
||||
}
|
||||
|
||||
func (s System) UpdateStatus() UpdateStatus {
|
||||
st := UpdateStatus{}
|
||||
st.Running = unitProperty("router-update.service", "ActiveState") == "activating"
|
||||
if inv := unitProperty("router-update.service", "InvocationID"); inv != "" {
|
||||
if !st.Running {
|
||||
st.Result = unitProperty("router-update.service", "Result")
|
||||
}
|
||||
st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "200", "_SYSTEMD_INVOCATION_ID="+inv)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
type updateData struct {
|
||||
Installed Release
|
||||
Status UpdateStatus
|
||||
URL string
|
||||
Offer *Available
|
||||
OfferErr string
|
||||
Pending bool // an update waits for confirmation
|
||||
}
|
||||
|
||||
func (a *app) updateData() updateData {
|
||||
st := a.sys.Status()
|
||||
return updateData{
|
||||
Installed: a.sys.installedRelease(),
|
||||
Status: a.sys.UpdateStatus(),
|
||||
URL: a.sys.updateURL(),
|
||||
Pending: st.RollbackIn > 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) updatePage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
d := a.updateData()
|
||||
p := page{Title: "Update", CSRF: sess.csrf, LoggedIn: true, Data: d, Flash: r.URL.Query().Get("msg")}
|
||||
if d.Status.Running {
|
||||
p.Refresh = 3
|
||||
}
|
||||
a.render(w, "update", p)
|
||||
}
|
||||
|
||||
func (a *app) updateBack(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
http.Redirect(w, r, "/update?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) busy() bool {
|
||||
st := a.sys.Status()
|
||||
return st.Applying || st.ConfirmRunning || st.RollbackIn > 0 || a.sys.UpdateStatus().Running
|
||||
}
|
||||
|
||||
func (a *app) updateSaveURL(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
v := strings.TrimSpace(r.PostFormValue("url"))
|
||||
if v != "" {
|
||||
var err error
|
||||
if v, err = parseUpdateURL(v); err != nil {
|
||||
a.updateBack(w, r, "Not saved: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.sys.saveUpdateURL(v); err != nil {
|
||||
a.updateBack(w, r, "Not saved: "+err.Error())
|
||||
return
|
||||
}
|
||||
a.updateBack(w, r, "Saved.")
|
||||
}
|
||||
|
||||
func (a *app) updateCheck(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
d := a.updateData()
|
||||
p := page{Title: "Update", CSRF: sess.csrf, LoggedIn: true}
|
||||
if d.URL == "" {
|
||||
a.updateBack(w, r, "Set the update server first.")
|
||||
return
|
||||
}
|
||||
offer, err := a.sys.checkUpdate(d.URL)
|
||||
if err != nil {
|
||||
d.OfferErr = err.Error()
|
||||
} else {
|
||||
d.Offer = &offer
|
||||
}
|
||||
p.Data = d
|
||||
a.render(w, "update", p)
|
||||
}
|
||||
|
||||
func (a *app) updateInstall(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
if a.busy() {
|
||||
a.updateBack(w, r, "Another change is in progress or waiting for confirmation.")
|
||||
return
|
||||
}
|
||||
u := a.sys.updateURL()
|
||||
if u == "" {
|
||||
a.updateBack(w, r, "Set the update server first.")
|
||||
return
|
||||
}
|
||||
if err := a.sys.requestUpdate("url", u, r.PostFormValue("force") == "on"); err != nil {
|
||||
a.updateBack(w, r, "Could not start the update: "+err.Error())
|
||||
return
|
||||
}
|
||||
a.updateBack(w, r, "Updating.")
|
||||
}
|
||||
|
||||
// updateUpload takes a bundle as multipart/form-data and streams it into the
|
||||
// staging directory; a bundle is hundreds of megabytes. The CSRF token is in
|
||||
// the query string, since the body must not be read before it is checked.
|
||||
func (a *app) updateUpload(w http.ResponseWriter, r *http.Request) {
|
||||
sess, ok := a.session(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !sameOrigin(r) || r.URL.Query().Get("csrf") != sess.csrf {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if a.busy() {
|
||||
a.updateBack(w, r, "Another change is in progress or waiting for confirmation.")
|
||||
return
|
||||
}
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
a.updateBack(w, r, "Not an upload: "+err.Error())
|
||||
return
|
||||
}
|
||||
force := false
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
a.updateBack(w, r, "No file was uploaded.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.updateBack(w, r, "Upload failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
if part.FormName() == "force" {
|
||||
force = true
|
||||
continue
|
||||
}
|
||||
if part.FormName() == "bundle" && part.FileName() != "" {
|
||||
a.stageBundle(w, r, part, force)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) stageBundle(w http.ResponseWriter, r *http.Request, part *multipart.Part, force bool) {
|
||||
if err := os.MkdirAll(a.sys.updateDir(), 0o700); err != nil {
|
||||
a.updateBack(w, r, "Could not stage the bundle: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := unbundle(part, filepath.Join(a.sys.updateDir(), "staged")); err != nil {
|
||||
a.updateBack(w, r, "Rejected: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.sys.requestUpdate("staged", "", force); err != nil {
|
||||
a.updateBack(w, r, "Could not start the update: "+err.Error())
|
||||
return
|
||||
}
|
||||
a.updateBack(w, r, "Bundle received; verifying and installing.")
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
body string
|
||||
typ byte
|
||||
link string
|
||||
}
|
||||
|
||||
func makeBundle(t *testing.T, entries ...entry) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
for _, e := range entries {
|
||||
typ := e.typ
|
||||
if typ == 0 {
|
||||
typ = tar.TypeReg
|
||||
}
|
||||
hdr := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Size: int64(len(e.body)), Linkname: e.link}
|
||||
if typ != tar.TypeReg {
|
||||
hdr.Size = 0
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if typ == tar.TypeReg {
|
||||
tw.Write([]byte(e.body))
|
||||
}
|
||||
}
|
||||
tw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
const hash32 = "00fs4zz9c0jjcvs62wg39b91dydz7zqb"
|
||||
|
||||
func goodBundle(t *testing.T) []byte {
|
||||
return makeBundle(t,
|
||||
entry{name: "latest", body: `{"release":"/nix/store/x","version":"0.3.0","serial":2000,"schema":0}`},
|
||||
entry{name: "cache/", typ: tar.TypeDir},
|
||||
entry{name: "cache/log/", typ: tar.TypeDir},
|
||||
entry{name: "cache/nix-cache-info", body: "StoreDir: /nix/store\n"},
|
||||
entry{name: "cache/" + hash32 + ".narinfo", body: "StorePath: ..."},
|
||||
entry{name: "cache/nar/" + strings.Repeat("a1", 26) + ".nar.zst", body: "nar"},
|
||||
)
|
||||
}
|
||||
|
||||
func TestUnbundle(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "staged")
|
||||
os.MkdirAll(dir, 0o700)
|
||||
os.WriteFile(filepath.Join(dir, "old"), []byte("x"), 0o600)
|
||||
if err := unbundle(bytes.NewReader(goodBundle(t)), dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"latest", "cache/nix-cache-info", "cache/" + hash32 + ".narinfo"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||
t.Fatalf("%s: %v", f, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "old")); err == nil {
|
||||
t.Fatal("the previous staging was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbundleRefusesHostileBundles(t *testing.T) {
|
||||
good := []entry{
|
||||
{name: "latest", body: "{}"},
|
||||
{name: "cache/nix-cache-info", body: "x"},
|
||||
}
|
||||
for name, extra := range map[string]entry{
|
||||
"traversal": {name: "../../etc/passwd", body: "x"},
|
||||
"absolute": {name: "/etc/passwd", body: "x"},
|
||||
"nested": {name: "cache/nar/../../../x", body: "x"},
|
||||
"other file": {name: "cache/evil.sh", body: "x"},
|
||||
"symlink": {name: "cache/" + hash32 + ".narinfo", typ: tar.TypeSymlink, link: "/etc/passwd"},
|
||||
"hardlink": {name: "cache/" + hash32 + ".narinfo", typ: tar.TypeLink, link: "/etc/passwd"},
|
||||
"directory": {name: "etc/", typ: tar.TypeDir},
|
||||
"duplicate": {name: "latest", body: "{}"},
|
||||
"huge latest": {name: "latest", body: strings.Repeat("x", maxLatest+1)},
|
||||
} {
|
||||
dir := filepath.Join(t.TempDir(), "staged")
|
||||
entries := append([]entry{}, good...)
|
||||
if name == "huge latest" {
|
||||
entries = good[1:] // so the size, not a duplicate, is what is refused
|
||||
}
|
||||
err := unbundle(bytes.NewReader(makeBundle(t, append(entries, extra)...)), dir)
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
if _, statErr := os.Stat(dir); statErr == nil {
|
||||
t.Errorf("%s: left %s behind", name, dir)
|
||||
}
|
||||
if _, statErr := os.Stat(dir + ".tmp"); statErr == nil {
|
||||
t.Errorf("%s: left a temporary directory", name)
|
||||
}
|
||||
}
|
||||
// Incomplete or not a tar at all.
|
||||
if err := unbundle(bytes.NewReader(makeBundle(t, entry{name: "latest", body: "{}"})), filepath.Join(t.TempDir(), "s")); err == nil {
|
||||
t.Error("a bundle without a cache was accepted")
|
||||
}
|
||||
if err := unbundle(strings.NewReader("this is not a tar file"), filepath.Join(t.TempDir(), "s")); err == nil {
|
||||
t.Error("garbage was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpdateURL(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://updates.example.com/router/": "https://updates.example.com/router",
|
||||
" http://192.168.1.5:8000 ": "http://192.168.1.5:8000",
|
||||
} {
|
||||
if got, err := parseUpdateURL(in); err != nil || got != want {
|
||||
t.Errorf("%q: %q %v", in, got, err)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "ftp://x/y", "file:///etc", "https://user:pw@host/x", "https://host/x?y=1", "updates.example.com", "https://"} {
|
||||
if _, err := parseUpdateURL(in); err == nil {
|
||||
t.Errorf("%q accepted", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckUpdate(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
_ = a
|
||||
sys.ReleaseFile = filepath.Join(t.TempDir(), "router-release")
|
||||
os.WriteFile(sys.ReleaseFile, []byte(`{"version":"0.2.0","serial":1000,"schema":0}`), 0o600)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/new/latest":
|
||||
w.Write([]byte(`{"release":"/nix/store/x","version":"0.3.0","serial":2000,"schema":1}`))
|
||||
case "/same/latest":
|
||||
w.Write([]byte(`{"version":"0.2.0","serial":1000}`))
|
||||
case "/junk/latest":
|
||||
w.Write([]byte(`hello`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
if o, err := sys.checkUpdate(srv.URL + "/new"); err != nil || !o.Newer || o.Version != "0.3.0" {
|
||||
t.Fatalf("new: %+v %v", o, err)
|
||||
}
|
||||
if o, err := sys.checkUpdate(srv.URL + "/same"); err != nil || o.Newer {
|
||||
t.Fatalf("same: %+v %v", o, err)
|
||||
}
|
||||
for _, p := range []string{"/junk", "/missing"} {
|
||||
if _, err := sys.checkUpdate(srv.URL + p); err == nil {
|
||||
t.Fatalf("%s: no error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func upload(t *testing.T, h http.Handler, token string, cookie *http.Cookie, bundle []byte, force bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
if force {
|
||||
mw.WriteField("force", "on")
|
||||
}
|
||||
fw, _ := mw.CreateFormFile("bundle", "router-update.bundle")
|
||||
fw.Write(bundle)
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", "/update/upload?csrf="+url.QueryEscape(token), &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestUpdateUploadAndInstall(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
var started []string
|
||||
startUnit = func(unit string) error { started = append(started, unit); return nil }
|
||||
orig := startUnit
|
||||
defer func() { startUnit = orig }()
|
||||
h := a.routes()
|
||||
cookie := login(t, h)
|
||||
token := csrfFrom(t, do(h, "GET", "/update", nil, cookie).Body.String())
|
||||
|
||||
if rec := upload(t, h, "wrong", cookie, goodBundle(t), false); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("upload with a wrong token: %d", rec.Code)
|
||||
}
|
||||
if rec := upload(t, h, token, nil, goodBundle(t), false); rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/login") {
|
||||
t.Fatalf("upload without a session: %d %s", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if len(started) != 0 {
|
||||
t.Fatal("the update unit was started by a rejected upload")
|
||||
}
|
||||
|
||||
rec := upload(t, h, token, cookie, makeBundle(t, entry{name: "evil", body: "x"}), false)
|
||||
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Rejected") || len(started) != 0 {
|
||||
t.Fatalf("hostile bundle: %q, started %v", loc, started)
|
||||
}
|
||||
|
||||
rec = upload(t, h, token, cookie, goodBundle(t), true)
|
||||
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Bundle+received") {
|
||||
t.Fatalf("upload: %d %q", rec.Code, loc)
|
||||
}
|
||||
if len(started) != 1 || started[0] != "router-update.service" {
|
||||
t.Fatalf("started %v", started)
|
||||
}
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(sys.updateDir(), "request.json"))
|
||||
if err := json.Unmarshal(data, &req); err != nil || req.Mode != "staged" || !req.Force {
|
||||
t.Fatalf("request: %s", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sys.updateDir(), "staged", "latest")); err != nil {
|
||||
t.Fatalf("not staged: %v", err)
|
||||
}
|
||||
|
||||
// Pulling: the saved server, through install.
|
||||
if rec := do(h, "POST", "/update/url", url.Values{"csrf": {token}, "url": {"ftp://nope"}}, cookie); !strings.Contains(rec.Header().Get("Location"), "Not+saved") {
|
||||
t.Fatalf("bad url: %q", rec.Header().Get("Location"))
|
||||
}
|
||||
do(h, "POST", "/update/url", url.Values{"csrf": {token}, "url": {"http://192.168.10.60:8000/"}}, cookie)
|
||||
if sys.updateURL() != "http://192.168.10.60:8000" {
|
||||
t.Fatalf("url: %q", sys.updateURL())
|
||||
}
|
||||
do(h, "POST", "/update/install", url.Values{"csrf": {token}}, cookie)
|
||||
data, _ = os.ReadFile(filepath.Join(sys.updateDir(), "request.json"))
|
||||
if !strings.Contains(string(data), `"mode":"url"`) || !strings.Contains(string(data), "192.168.10.60:8000") {
|
||||
t.Fatalf("request: %s", data)
|
||||
}
|
||||
if page := do(h, "GET", "/update", nil, cookie).Body.String(); !strings.Contains(page, "192.168.10.60:8000") {
|
||||
t.Fatalf("the page lacks the saved server:\n%s", page)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Signs $release (a store path) as a binary cache and writes, into --out:
|
||||
# router-update-<version>.bundle one file to upload in the web UI
|
||||
# latest, cache/ the same, laid out for `router pulls from a URL`
|
||||
# usage: router-release --key KEYFILE [--out DIR] [--compression zstd|none]
|
||||
key=""
|
||||
out=dist
|
||||
compression=zstd
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--key) key=$2; shift ;;
|
||||
--out) out=$2; shift ;;
|
||||
--compression) compression=$2; shift ;;
|
||||
*) echo "usage: router-release --key KEYFILE [--out DIR] [--compression zstd|none]" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -n "$key" ] && [ -r "$key" ] || { echo "router-release: --key needs the secret key file (nix key generate-secret)" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$out"
|
||||
out=$(realpath "$out")
|
||||
version=$(cat "$release/version")
|
||||
rm -rf "$out/cache" "$out/latest"
|
||||
nix --extra-experimental-features nix-command copy --to "file://$out/cache?secret-key=$(realpath "$key")&compression=$compression" "$release"
|
||||
jq -n --arg release "$release" --arg version "$version" \
|
||||
--argjson serial "$(cat "$release/serial")" --argjson schema "$(cat "$release/schema")" \
|
||||
'{release: $release, version: $version, serial: $serial, schema: $schema}' > "$out/latest"
|
||||
tar -C "$out" -cf "$out/router-update-$version.bundle" latest cache
|
||||
echo "release $version ($(du -h "$out/router-update-$version.bundle" | cut -f1)):"
|
||||
echo " upload $out/router-update-$version.bundle in the router's web UI (Update)"
|
||||
echo " or serve $out over HTTP(S) and give the router that URL"
|
||||
@@ -11,6 +11,7 @@
|
||||
curl
|
||||
tcpdump
|
||||
wireguard-tools
|
||||
jq
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
installer = import ./installer.nix { inherit self pkgs; };
|
||||
apply = import ./apply.nix { inherit self pkgs; };
|
||||
setup = import ./setup.nix { inherit self pkgs; };
|
||||
update = import ./update.nix { inherit self pkgs; };
|
||||
image = import ./image.nix { inherit self pkgs; };
|
||||
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
||||
lib = import ./lib.nix { inherit pkgs; };
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
test-evil-1:mv/vVvLI2msL34okqKVH0sIyDDsgKsQ6n10z/5Z2iSg=
|
||||
@@ -0,0 +1 @@
|
||||
test-evil-1:MbE61i9has6cBVOUnguwUENnDF5NpvQGdLWm2iUDf52a/+9W8sjaawvfiiSopUfSwjIMOyAqxDqfXTP/lnaJKA==
|
||||
@@ -0,0 +1 @@
|
||||
test-good-1:oTLD3FrF1sJjiVnmlS4EFQBDSYDNsXVexeH02gKjEw0=
|
||||
@@ -0,0 +1 @@
|
||||
test-good-1:A7gL1oSYUJU552emNAEpJYPKWcDSxreYZ9iQPNyo9DuhMsPcWsXWwmOJWeaVLgQVAENJgM2xdV7F4fTaAqMTDQ==
|
||||
@@ -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"
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user