- Rust 66.2%
- Shell 17.1%
- C++ 10%
- Nix 6%
- Python 0.4%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| nix | ||
| scripts | ||
| tests | ||
| tray-cpp | ||
| tray-rust | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
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