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 @@ 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}}

+
+ + {{if not .Newer}}

{{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" + ''; +}