diff --git a/.gitignore b/.gitignore
index 09ca86b..11d2adc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ result-*
.nixos-test-history
/image
/installer
+/dist
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..0ea3a94
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.2.0
diff --git a/flake.nix b/flake.nix
index 72da5aa..ee5871f 100644
--- a/flake.nix
+++ b/flake.nix
@@ -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 {
diff --git a/keys/update.pub b/keys/update.pub
new file mode 100644
index 0000000..5680576
--- /dev/null
+++ b/keys/update.pub
@@ -0,0 +1 @@
+nixos-router-update-1:pdzUo8vb5GtdQFd1SGVin45qAW25AqERczbcsKGW0M4=
\ No newline at end of file
diff --git a/migrations/README.md b/migrations/README.md
new file mode 100644
index 0000000..7167241
--- /dev/null
+++ b/migrations/README.md
@@ -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))
diff --git a/modules/router/rollback.nix b/modules/router/rollback.nix
index ed93d78..d3188da 100644
--- a/modules/router/rollback.nix
+++ b/modules/router/rollback.nix
@@ -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";
diff --git a/modules/settings/update.nix b/modules/settings/update.nix
new file mode 100644
index 0000000..632162a
--- /dev/null
+++ b/modules/settings/update.nix
@@ -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";
+ };
+ };
+}
diff --git a/pkgs/router-migrate.nix b/pkgs/router-migrate.nix
new file mode 100644
index 0000000..d398b47
--- /dev/null
+++ b/pkgs/router-migrate.nix
@@ -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
+ '';
+}
diff --git a/pkgs/router-webui/system.go b/pkgs/router-webui/system.go
index de185b5..be9d1d9 100644
--- a/pkgs/router-webui/system.go
+++ b/pkgs/router-webui/system.go
@@ -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") }
diff --git a/pkgs/router-webui/templates/layout.html b/pkgs/router-webui/templates/layout.html
index 2d5752a..4f5c682 100644
--- a/pkgs/router-webui/templates/layout.html
+++ b/pkgs/router-webui/templates/layout.html
@@ -14,6 +14,7 @@
Overview
Network
+ Update
Advanced
diff --git a/pkgs/router-webui/templates/update.html b/pkgs/router-webui/templates/update.html
new file mode 100644
index 0000000..6438d22
--- /dev/null
+++ b/pkgs/router-webui/templates/update.html
@@ -0,0 +1,63 @@
+{{define "content"}}
+{{$d := .Data}}
+
+ Installed
+
+ Version {{or $d.Installed.Version "unknown"}}{{if $d.Installed.Rev}} {{$d.Installed.Rev}} {{end}}
+ Settings schema {{$d.Installed.Schema}}
+
+
+
+{{if $d.Status.Running}}
+Updating… Verifying, importing and building the new version. This page refreshes on its own;
+ the web UI restarts when the new version is switched on.
+{{else if $d.Pending}}
+Waiting for confirmation
+ A new configuration is active and not confirmed yet. Confirm it on the Overview , or the router
+ goes back to the previous version.
+{{end}}
+
+
+ Install a bundle
+ A .bundle file made with nix run .#release. 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.
+
+
+
+
+ Update server
+
+ {{if $d.URL}}
+
+ {{end}}
+ {{if $d.OfferErr}}Could not check: {{$d.OfferErr}}
{{end}}
+ {{with $d.Offer}}
+ {{if .Newer}}Available: {{.Version}} {{if .Rev}} {{.Rev}} {{end}}.{{else}}Nothing newer: the server has {{.Version}}.{{end}}
+
+ {{end}}
+ The server only has to serve static files: the latest file and cache/ that
+ nix run .#release writes. The data is signed, so plain HTTP works too.
+
+
+{{if $d.Status.Log}}
+
+ Last update{{if $d.Status.Result}} — {{if eq $d.Status.Result "success"}}succeeded{{else}}failed ({{$d.Status.Result}}){{end}}{{end}}
+ {{$d.Status.Log}}
+
+{{end}}
+{{end}}
diff --git a/pkgs/router-webui/update.go b/pkgs/router-webui/update.go
new file mode 100644
index 0000000..42aa25d
--- /dev/null
+++ b/pkgs/router-webui/update.go
@@ -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/.narinfo
+// cache/nar/.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.")
+}
diff --git a/pkgs/router-webui/update_test.go b/pkgs/router-webui/update_test.go
new file mode 100644
index 0000000..d14f26b
--- /dev/null
+++ b/pkgs/router-webui/update_test.go
@@ -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)
+ }
+}
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100644
index 0000000..0fcc09f
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,30 @@
+# Signs $release (a store path) as a binary cache and writes, into --out:
+# router-update-.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"
diff --git a/tests/common.nix b/tests/common.nix
index 13eda8e..d40ceaa 100644
--- a/tests/common.nix
+++ b/tests/common.nix
@@ -11,6 +11,7 @@
curl
tcpdump
wireguard-tools
+ jq
];
};
diff --git a/tests/default.nix b/tests/default.nix
index 90a96b0..d388276 100644
--- a/tests/default.nix
+++ b/tests/default.nix
@@ -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; };
diff --git a/tests/update-keys/evil.pub b/tests/update-keys/evil.pub
new file mode 100644
index 0000000..6bc27a8
--- /dev/null
+++ b/tests/update-keys/evil.pub
@@ -0,0 +1 @@
+test-evil-1:mv/vVvLI2msL34okqKVH0sIyDDsgKsQ6n10z/5Z2iSg=
\ No newline at end of file
diff --git a/tests/update-keys/evil.sec b/tests/update-keys/evil.sec
new file mode 100644
index 0000000..bc6847e
--- /dev/null
+++ b/tests/update-keys/evil.sec
@@ -0,0 +1 @@
+test-evil-1:MbE61i9has6cBVOUnguwUENnDF5NpvQGdLWm2iUDf52a/+9W8sjaawvfiiSopUfSwjIMOyAqxDqfXTP/lnaJKA==
\ No newline at end of file
diff --git a/tests/update-keys/good.pub b/tests/update-keys/good.pub
new file mode 100644
index 0000000..0d01301
--- /dev/null
+++ b/tests/update-keys/good.pub
@@ -0,0 +1 @@
+test-good-1:oTLD3FrF1sJjiVnmlS4EFQBDSYDNsXVexeH02gKjEw0=
\ No newline at end of file
diff --git a/tests/update-keys/good.sec b/tests/update-keys/good.sec
new file mode 100644
index 0000000..8f40f67
--- /dev/null
+++ b/tests/update-keys/good.sec
@@ -0,0 +1 @@
+test-good-1:A7gL1oSYUJU552emNAEpJYPKWcDSxreYZ9iQPNyo9DuhMsPcWsXWwmOJWeaVLgQVAENJgM2xdV7F4fTaAqMTDQ==
\ No newline at end of file
diff --git a/tests/update.nix b/tests/update.nix
new file mode 100644
index 0000000..5d060ed
--- /dev/null
+++ b/tests/update.nix
@@ -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: 2.0.0 " 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"
+ '';
+}