- C++ 45.4%
- Shell 38.3%
- Nix 14.9%
- CMake 1%
- C 0.4%
| nix | ||
| scripts | ||
| tests | ||
| tray-cpp | ||
| 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 ];
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 default
implementation.
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.
Set nixos-updater.experimental = true to use nh os build instead of
nix build for the candidate build stage. Input checks and candidate-lock
generation still use nix flake update. Both package variants activate the
exact store path produced by the candidate build.
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
# nix build --no-update-lock-file --option allow-dirty true \
--reference-lock-file /var/lib/nixos-updater/apply-lock \
--out-link /var/lib/nixos-updater/candidate-system \
'path:/etc/nixos#nixosConfigurations."<hostname>".config.system.build.toplevel'
# 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
With nixos-updater.experimental = true, the candidate build command is instead:
# 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 -- \
--no-update-lock-file --option allow-dirty true \
--reference-lock-file /var/lib/nixos-updater/apply-lock
Experimental mode deliberately builds and activates the base NixOS
configuration. It 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, update-low when updates are available, an upside-down update-none
while applying, update-medium when a reboot is required, and update-high
after a failed check or apply. Themes without those icons
fall back to the normal updater icon. 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.
- Update previews, history, and rollback controls are not built yet.
Testing
Run the package, module, broker-policy, security, migration, and transactional-apply checks with:
nix flake check --no-write-lock-file