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
|
.nixos-test-history
|
||||||
/image
|
/image
|
||||||
/installer
|
/installer
|
||||||
|
/dist
|
||||||
|
|||||||
@@ -11,6 +11,42 @@
|
|||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
];
|
];
|
||||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
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
|
in
|
||||||
{
|
{
|
||||||
nixosModules = {
|
nixosModules = {
|
||||||
@@ -35,6 +71,9 @@
|
|||||||
{
|
{
|
||||||
settings,
|
settings,
|
||||||
modules ? [ ],
|
modules ? [ ],
|
||||||
|
updateKeys ? defaultUpdateKeys,
|
||||||
|
# The configuration flake passes it on: a `path:` flake has no commit.
|
||||||
|
release ? thisRelease,
|
||||||
}:
|
}:
|
||||||
nixpkgs.lib.nixosSystem {
|
nixpkgs.lib.nixosSystem {
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
@@ -44,6 +83,8 @@
|
|||||||
self.nixosModules.installed-layout
|
self.nixosModules.installed-layout
|
||||||
{
|
{
|
||||||
routerSettings.file = settings;
|
routerSettings.file = settings;
|
||||||
|
routerSettings.release = release;
|
||||||
|
routerUpdate.publicKeys = updateKeys;
|
||||||
# The device rebuilds itself, offline if need be: it needs the
|
# The device rebuilds itself, offline if need be: it needs the
|
||||||
# sources its configuration flake points to.
|
# sources its configuration flake points to.
|
||||||
system.extraDependencies = [
|
system.extraDependencies = [
|
||||||
@@ -82,6 +123,12 @@
|
|||||||
{ nixos-router, ... }:
|
{ nixos-router, ... }:
|
||||||
{
|
{
|
||||||
nixosConfigurations.router = nixos-router.lib.mkRouterSystem {
|
nixosConfigurations.router = nixos-router.lib.mkRouterSystem {
|
||||||
|
release = {
|
||||||
|
version = "${thisRelease.version}";
|
||||||
|
serial = ${toString thisRelease.serial};
|
||||||
|
rev = "${thisRelease.rev}";
|
||||||
|
schema = ${toString thisRelease.schema};
|
||||||
|
};
|
||||||
settings = ./settings.json;
|
settings = ./settings.json;
|
||||||
modules = if builtins.pathExists ./local.nix then [ ./local.nix ] else [ ];
|
modules = if builtins.pathExists ./local.nix then [ ./local.nix ] else [ ];
|
||||||
};
|
};
|
||||||
@@ -104,30 +151,99 @@
|
|||||||
imports = [ self.nixosModules.setup ];
|
imports = [ self.nixosModules.setup ];
|
||||||
router.setup = {
|
router.setup = {
|
||||||
flake = self.lib.configFlake pkgs;
|
flake = self.lib.configFlake pkgs;
|
||||||
reference =
|
reference = self.lib.referenceSystem { inherit pkgs local; };
|
||||||
(self.lib.mkRouterSystem {
|
schemaVersion = thisRelease.schema;
|
||||||
settings = builtins.toFile "settings.json" (
|
release = self.lib.mkRelease {
|
||||||
builtins.toJSON {
|
inherit pkgs;
|
||||||
router = {
|
includeReference = false;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# 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:
|
# The generic installer ISO:
|
||||||
# nix build .#installer
|
# nix build .#installer
|
||||||
nixosConfigurations.installer = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.installer = nixpkgs.lib.nixosSystem {
|
||||||
@@ -215,6 +331,31 @@
|
|||||||
program = "${deploy}/bin/router-deploy";
|
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.
|
# VM tests need KVM; only wired up for x86_64 until aarch64 CI exists.
|
||||||
checks.x86_64-linux =
|
checks.x86_64-linux =
|
||||||
import ./tests {
|
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 = ''
|
text = ''
|
||||||
${confirmed}
|
${confirmed}
|
||||||
echo "router: rolling back to $(readlink -f "$target")"
|
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
|
exec "$target/bin/switch-to-configuration" test
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -71,6 +77,12 @@ in
|
|||||||
internal = true;
|
internal = true;
|
||||||
description = "Shell commands `router-confirm` runs once the current system is confirmed.";
|
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 {
|
timeout = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "5min";
|
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 {
|
type System struct {
|
||||||
ConfigDir string // the configuration flake, settings.json in it
|
ConfigDir string // the configuration flake, settings.json in it
|
||||||
StateDir string // /var/lib/router: secrets, UI state
|
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") }
|
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">Overview</a>
|
<a href="/">Overview</a>
|
||||||
<a href="/network">Network</a>
|
<a href="/network">Network</a>
|
||||||
|
<a href="/update">Update</a>
|
||||||
<a href="/advanced">Advanced</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>
|
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
||||||
</nav>
|
</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
|
curl
|
||||||
tcpdump
|
tcpdump
|
||||||
wireguard-tools
|
wireguard-tools
|
||||||
|
jq
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
installer = import ./installer.nix { inherit self pkgs; };
|
installer = import ./installer.nix { inherit self pkgs; };
|
||||||
apply = import ./apply.nix { inherit self pkgs; };
|
apply = import ./apply.nix { inherit self pkgs; };
|
||||||
setup = import ./setup.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; };
|
image = import ./image.nix { inherit self pkgs; };
|
||||||
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
||||||
lib = import ./lib.nix { inherit 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