Checks for system flake updates
  • Rust 66.2%
  • Shell 17.1%
  • C++ 10%
  • Nix 6%
  • Python 0.4%
  • Other 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-14 09:59:13 -04:00
nix [verified] Preserve tray history and use responsive themed status 2026-09-06 23:47:55 -04:00
scripts [verified] Clear update availability after successful apply 2026-09-13 23:10:24 -04:00
tests [verified] Clear update availability after successful apply 2026-09-13 23:10:24 -04:00
tray-cpp Redact store-path markers from details logs 2026-08-15 01:35:03 -04:00
tray-rust [verified] Persist update diagnostics and strip terminal controls 2026-09-14 09:43:03 -04:00
flake.nix [verified] Fix reboot action label and cold-open build progress 2026-09-13 22:35:07 -04:00
LICENSE Create LICENSE 2026-07-22 18:21:18 -04:00
README.md [verified] Fix reboot action label and cold-open build progress 2026-09-13 22:35:07 -04:00

nixos-updater

nixos-updater is an initial NixOS module for checking and updating the inputs of the system flake at /etc/nixos/flake.nix.

The privileged parts are root-owned oneshot systemd services. A check service periodically writes a candidate lock file in its state directory and compares it with the system lock file without mutating /etc/nixos. Automatic mode starts the apply service from a timer. Manual mode gives a configured local group permission to invoke only the immutable check, low, and high operations of a dedicated Polkit request broker. The broker chooses fixed systemd units and a fixed safe job mode; the group receives no generic systemd unit-management authority. The Plasma tray app stays unprivileged: it reads availability status, sends desktop notifications, and invokes that broker only when manual mode is enabled and its process is actually a member of the configured control group. Other graphical users keep read-only status and notification access without unusable manual controls.

NixOS use

Import the module and enable the paths you want:

{
  inputs.nixos-updater = {
    url = "path:/path/to/nixos-updater";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { nixpkgs, nixos-updater, ... }: {
    nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
      modules = [
        nixos-updater.nixosModules.default
        {
          nixos-updater = {
            enable = true;

            checks.calendar = "hourly";

            automatic = {
              enable = true;
              calendar = "daily";
            };

            manual.enable = true;
            tray = {
              enable = true;
              label = "System updates";
            };
          };

          users.users.alice.extraGroups = [ "nixos-updater" ];
        }
      ];
    };
  };
}

The NixOS module builds its default updater and tray packages from the consuming system's pkgs argument. This repository's nixpkgs input is used only for its own checks. Making that input follow the consuming system's nixpkgs, as above, avoids an independently updated check-only node in the system lock. Consumers that want package names in their own package set can also import the overlay:

nixpkgs.overlays = [ nixos-updater.overlays.default ];

The updater always uses nh os build for candidate construction. Its default nh helper follows the consuming module's final pkgs.nh, including package overlays. To select a compatible helper package explicitly, set nixos-updater.nhPackage:

nixos-updater.nhPackage = pkgs.myNh;

The selected package is embedded in the module-built updater closure, so nh does not need to be added to environment.systemPackages. It must provide bin/nh and implement the updater's fixed nh os build CLI. The updater runs this helper as root; selecting it is therefore part of the trusted NixOS configuration boundary. An explicit nixos-updater.package override remains authoritative and makes nhPackage irrelevant. NixOS's independent system.tools.nixos-rebuild option may be disabled; this module neither enables nor manages that system tool.

checks.enable defaults to true, and checks.calendar defaults to hourly (one check per hour). Recurring checks must be at least 10 minutes apart. Check timers do not run missed checks after boot by default, so they do not add catch-up work to startup; set checks.persistent = true if you want missed checks to run after boot. The tray can also be enabled with only automatic mode when a read-only notification icon is desired. The C++ tray is the stable default implementation. Setting nixos-updater.experimental = true selects the experimental Rust tray when nixos-updater.tray.package is unset. The Rust tray adds a read-only Details window to the same status and fixed-request controls. An explicit nixos-updater.tray.package override wins for either value of experimental. Both implementations consume the same status data, so switching requires no data migration.

The Rust Details window shows the next check schedule, the latest check and apply results, active updater units, and a bounded tail of sanitized journal messages. Closing the window stops and reaps its journal follower without quitting the tray; opening Details again creates one replacement surface. Log collection remains unprivileged. A journal permission failure makes only the live-output card unavailable and does not grant group membership, Polkit authority, or another privileged helper. The update button uses candidate availability independently of the status label, so a failed apply or a reboot reminder does not prevent retrying an available update. The top-left button says Update available when a candidate exists; Reboot required replaces that label only when no update is available. Active operations, pending requests, permissions, and stale observations still gate requests.

Builds request Nix's internal-json output without enabling nom. A streaming filter annotates each recognized build counter with its activity type before journald can rate-limit away the one-time aggregate start. Raw diagnostics are preserved, and pipeline failures still prevent activation. Details decodes and sanitizes those messages, and shows completed/expected build counts only for a recognized build aggregate in the active service invocation. Nix can revise its expected count, so this is labelled Nix estimate, never a percentage of the update. Substitution counts are not build totals. The last known phase, package name, and counters are retained per service invocation independently of the bounded output tail. Compiler output does not replace known state with Unknown, and counter-only updates do not erase the package name. A real phase transition clears the previous phase's item. On opening Details, a separate five-second-bounded query replays the latest 100 typed counters from the current boot, independently of the noisy output tail. The ordinary follower supplies live updates. Counter timestamps remain independent of later phase observations, so replay cannot replace newer counts or revert activation. Closing Details stops and reaps both journal followers and any pending progress replay. Without usable counters, the pane keeps Progress total unavailable; it never guesses a missing total. Older runs whose untyped aggregate start was lost cannot be reconstructed. Raw output and older producers remain supported. The module supplies fixed current-system paths for both systemctl and journalctl to the user service.

The experimental package requires a consuming nixpkgs Rust platform at least 1.88. It uses Iced's software tiny-skia renderer with explicit Wayland, libxkbcommon, X11, Xcursor, Xi, Xrandr, and xcb runtime linkage. It does not add a Mesa/GL/Vulkan renderer or another GUI toolkit. The package's no-default- features check also keeps the accepted status/control-only Rust build free of Iced, regex, the journal follower, and the Details GUI path.

secured defaults to true. For the default /etc/nixos path, activation copies a user-owned flake tree into a child of a private, root-owned adjacent staging directory. It recursively assigns that child to root while the outer directory keeps the new inodes inaccessible to the former owner, then atomically exchanges the child into place. This migrates existing user-owned checkouts while disconnecting retained writable file descriptors from the configured source. The first activation of the current v3 migration protocol forces this exchange even when an older migration already made every entry appear root-owned. The source-parent ancestry is validated before any marker is trusted. After the source-parent filesystem is synchronized, a root-owned, private, path-scoped v3 marker with fixed contents makes later activations idempotent. Entry access modes and ACLs are preserved, so safe existing read and traversal access may remain; the replacement tree is root-controlled rather than necessarily private to root. A configured source symlink is replaced with an independent directory copy. Migration fails closed if the source does not contain flake.nix or a source ancestor is not exclusively root-controlled. Custom flakePath values are validated but not migrated unless nixos-updater.migrateSource = true is explicitly set. Before either a check or apply run invokes Nix, the updater verifies that the configured path, its ancestors, resolved and symlink targets, and entries below it are root-owned and have no extended ACLs. Source entries must have no group/other write bits; writable ancestors are accepted only when protected by the sticky bit. It refuses the run if that boundary is not intact. Absolute and resolvable relative local path inputs, plus local file: URLs recorded in the existing or newly generated lock file, receive the same validation. Local Git sources may use an ordinary root-owned .git directory, but secured mode rejects Git control-path indirection such as gitfiles/linked worktrees, common directories, alternate object stores, configuration includes or external worktrees, per-worktree configuration, and nested submodule control files. This boundary prevents an unprivileged local user from changing configuration that the updater evaluates as root through either visible source bytes or hidden Git authority paths.

The configured flake inputs and their maintainers are trusted. nixos-updater is intended for highly trusted ecosystem inputs and does not try to sandbox a hostile dependency graph: trusted NixOS configuration can arrange privileged code execution during activation by design. Likewise, a local or remote attacker who has already obtained root is outside this boundary. The updater instead avoids creating a privilege-escalation path from an unprivileged local account, and manual users can request only the broker's fixed update operations.

Set nixos-updater.secured = false only when retaining a user-owned development checkout is intentional. This preserves the previous behavior, but anyone who can modify the evaluated flake can arrange for code to run as root during an automatic or authorized manual apply. Disable it before the first secured activation; disabling it later does not restore the checkout's previous owner.

The updater uses nh os build for every candidate build. Input checks and candidate-lock generation still use nix flake update. The nixos-updater.experimental option changes only the default tray selection; it does not change privileged updater, request-broker, service, or timer behavior.

With manual.enable = true, users in nixos-updater.manual.controlGroup may request a check or apply run with:

$ /run/wrappers/bin/pkexec /run/current-system/sw/bin/nixos-updater-request check
$ /run/wrappers/bin/pkexec /run/current-system/sw/bin/nixos-updater-request low  # low-resource apply
$ /run/wrappers/bin/pkexec /run/current-system/sw/bin/nixos-updater-request high # unrestricted apply

pkexec resolves the current-system symlink before Polkit binds each operation to the immutable broker executable and its exact first argument. The broker rejects extra or unknown arguments, invokes only systemctl start --no-block --job-mode=replace for its fixed units, and admits at most one manual apply at a time. A second manual apply request fails promptly while the first is queued or running instead of creating another privileged process waiting on the updater lock. Repeated check requests target one fixed unit and are coalesced by systemd.

The check service uses an alternate output lock file:

# nix flake update --flake path:/etc/nixos --output-lock-file /var/lib/nixos-updater/candidate-lock

Both apply services generate a candidate lock, validate it in secured mode, build and activate against it while the canonical lock remains unchanged, and commit it only after successful activation. The principal commands are equivalent to:

# nix flake update --flake path:/etc/nixos --output-lock-file /var/lib/nixos-updater/apply-lock
# nh os build --bypass-root-check --no-nom --diff never --no-specialisation \
    --hostname '<hostname>' \
    --out-link /var/lib/nixos-updater/candidate-system path:/etc/nixos -- \
    --log-format internal-json --no-update-lock-file --option allow-dirty true \
    --reference-lock-file /var/lib/nixos-updater/apply-lock
# nix build --no-link --profile /nix/var/nix/profiles/system /nix/store/<candidate-system>
# /nix/store/<candidate-system>/bin/switch-to-configuration switch
# cp /var/lib/nixos-updater/apply-lock /etc/nixos/.flake.lock.nixos-updater.<temporary>
# mv /etc/nixos/.flake.lock.nixos-updater.<temporary> /etc/nixos/flake.lock

The candidate build deliberately uses the base NixOS configuration and ignores an active /etc/specialisation selection rather than letting nh resolve a different closure than the candidate out-link.

The automatic timer starts nixos-updater-low@automatic.service, which runs with reduced CPU and IO priority and a memory limit of 50% of physical RAM. The check service uses the same resource limits. The fixed manual nixos-updater-high@manual.service runs without those resource restrictions. The shared process lock is kept in a root-only subdirectory of the otherwise readable state directory. Checks skip when another run is active. One accepted manual apply may wait for an automatic run to release the lock; further manual apply requests are rejected until that fixed manual unit is no longer queued or active. Apply units carry NixOS switch metadata that prevents an in-flight activation from stopping its own service if the candidate removes the updater. The two pre-template service names are retained as unselected compatibility units so upgrading from that layout is protected by the same rule.

The apply service skips the build when flake.lock is unchanged. A candidate generation, build, profile update, or activation failure leaves the canonical lock untouched. The next apply simply generates the latest candidate and tries again; Nix reuses already fetched inputs and built store paths when applicable. An interruption after successful activation but before the final rename may repeat activation on the next run. The final copy is created beside flake.lock, so the rename commits either the old complete lock or the new complete lock. When the tray is running, it sends a desktop notification after an attempted check or apply fails, and after an apply succeeds. Failed attempts are shown as retryable; the next apply regenerates the latest candidate rather than retaining the failed candidate as pending work. When the successfully built system has different kernel, kernel module, or initrd artifacts than the booted system, the completion notification also says that a reboot is required. A successful boot action reports the same whenever the installed closure differs from the running system, including userspace-only changes. The tray keeps showing that state until the next boot. rebuildAction can be set to boot or test instead of switch. The default switch action persists and activates the candidate. boot persists it for the next boot without switching the running userspace, while test activates it temporarily without updating the system profile. A successful selected action commits the candidate lock, so use switch when the update must remain active across reboots.

The tray uses themed update-status icons when available: update-none while current or applying, update-low when updates are available, update-medium when a reboot is required, and update-high after a failed check or apply. The C++ client rotates the themed update-none while applying. The Rust client leaves theme resolution to the desktop host and ships no replacement status icons or custom applying pixmap. The tray requires a private, user-owned XDG_RUNTIME_DIR for its single-instance lock and fails closed rather than using a predictable shared temporary path.

The explicit path: flake reference avoids Git ownership checks. Raw local flake paths inside Git repositories are interpreted by Nix as git+file references instead. In the default secured mode, the updater's own ownership validation still applies before Nix evaluates that reference.

Current scope

  • The checker publishes whether flake input updates are available.
  • The apply service mutates the lock file in the configured flake directory.
  • The tray app can notify on availability and rebuild completion, request manual runs, and show the newest completed updater service result exposed by systemd.
  • The experimental Rust tray can additionally observe the current timer, active updater units, and a bounded sanitized live journal tail in a read-only Details window. Its separate last-check and last-apply records survive reboot and journal-tail eviction through the canonical status document; systemd and journal evidence supplement those records with duration when available. The GUI drains updates every 100 ms and reads status every 250 ms. Systemd polling runs every 500 ms with Details open and every ten seconds otherwise, with at most one query running and one pending refresh.
  • Full run-history browsing, update previews, and rollback controls are not built yet.

Testing

Run the C++ and Rust tray packages, default/no-default Rust matrices, package closure and DT_NEEDED audit, negative and virtual-X11 desktop smoke, module selector/deep-equality checks, broker-policy, security, migration, and transactional-apply checks with:

nix flake check --no-write-lock-file