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:
2026-10-29 13:34:21 +01:00
co-authored by Claude Sonnet 5.5
parent 2a111d012b
commit b9c63273e4
21 changed files with 1500 additions and 21 deletions
+1
View File
@@ -3,3 +3,4 @@ result-*
.nixos-test-history
/image
/installer
/dist
+1
View File
@@ -0,0 +1 @@
0.2.0
+162 -21
View File
@@ -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 {
+1
View File
@@ -0,0 +1 @@
nixos-router-update-1:pdzUo8vb5GtdQFd1SGVin45qAW25AqERczbcsKGW0M4=
+16
View File
@@ -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))
+12
View File
@@ -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";
+229
View File
@@ -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";
};
};
}
+32
View File
@@ -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
'';
}
+2
View File
@@ -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") }
+1
View File
@@ -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>
+63
View File
@@ -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}}
+387
View File
@@ -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.")
}
+246
View File
@@ -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)
}
}
+30
View File
@@ -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"
+1
View File
@@ -11,6 +11,7 @@
curl
tcpdump
wireguard-tools
jq
];
};
+1
View File
@@ -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; };
+1
View File
@@ -0,0 +1 @@
test-evil-1:mv/vVvLI2msL34okqKVH0sIyDDsgKsQ6n10z/5Z2iSg=
+1
View File
@@ -0,0 +1 @@
test-evil-1:MbE61i9has6cBVOUnguwUENnDF5NpvQGdLWm2iUDf52a/+9W8sjaawvfiiSopUfSwjIMOyAqxDqfXTP/lnaJKA==
+1
View File
@@ -0,0 +1 @@
test-good-1:oTLD3FrF1sJjiVnmlS4EFQBDSYDNsXVexeH02gKjEw0=
+1
View File
@@ -0,0 +1 @@
test-good-1:A7gL1oSYUJU552emNAEpJYPKWcDSxreYZ9iQPNyo9DuhMsPcWsXWwmOJWeaVLgQVAENJgM2xdV7F4fTaAqMTDQ==
+311
View File
@@ -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"
'';
}