feat(services): report the age of the nixpkgs lock
A rebuild against an unchanged lock installs nothing new, so a machine built daily can still run months-old packages. A user timer reads the locked lastModified of the nixpkgs input and notifies once it passes seven days, offering a terminal that runs the update.
This commit is contained in:
1 parent
b1ed5d087b
commit
c8d9a00a8d
4 files changed
+157
-1
No files matched your search
@@ -1,5 +1,36 @@
|
|||||||
# Decisions
|
# Decisions
|
||||||
|
|
||||||
|
## 2026-09-07 – A timer reports the age of the nixpkgs lock
|
||||||
|
|
||||||
|
- **Who**: Dominik, ClaudeCode
|
||||||
|
- **Why**: nixpkgs carries no patch backports, it bumps versions, so the age of
|
||||||
|
`flake.lock` is the exposure to known holes. Rebuilding often hides that:
|
||||||
|
`nixos-rebuild switch` against an unchanged lock installs nothing new, so a
|
||||||
|
machine built daily can still run months-old packages. The date in the lock is
|
||||||
|
the signal, not the date of the last build.
|
||||||
|
- **Tradeoffs**: `system.autoUpgrade` would drop the manual step, but nixpkgs
|
||||||
|
tracks unstable and an update can fail to build — the rebuild belongs in front
|
||||||
|
of someone. A CVE scanner (`vulnix`, `vulnxscan`) was weighed and dropped: on a
|
||||||
|
single desktop it reports what the lock date already says, and the update, not
|
||||||
|
the report, is what closes a hole. The notification carries an "Update now"
|
||||||
|
action: `notify-send -A` implies `--wait`, so the process lives while the
|
||||||
|
toast stands, and nothing has to bound that — the notification is on screen
|
||||||
|
for exactly as long, and a second one a day later would repeat itself. Text
|
||||||
|
alone was tried first and discarded: a command in a toast can be neither
|
||||||
|
clicked nor copied, only retyped. The terminal comes from
|
||||||
|
`xdg-terminal-exec` rather than a named emulator, so switching terminals is
|
||||||
|
one entry in `xdg-terminals.list` instead of an edit here.
|
||||||
|
- **How**: `modules/services.nix` builds `moonix-flake-age` with
|
||||||
|
`writeShellApplication` and drives it from a user timer, `OnStartupSec=5min`
|
||||||
|
and `OnUnitActiveSec=1d` — relative to the user manager rather than the
|
||||||
|
calendar, so a machine that is off at midnight does not report into a session
|
||||||
|
nobody sees. The flake is found through the `/etc/nixos/flake.nix` symlink, so
|
||||||
|
a machine built straight from the remote reference has no lock and exits
|
||||||
|
silently. `.nodes[.nodes.root.inputs.nixpkgs]` resolves the input
|
||||||
|
indirection: the node is named `nixpkgs_2` as soon as a dependency locks a
|
||||||
|
nixpkgs of its own, and reading `.nodes.nixpkgs` would report the date of a
|
||||||
|
transitive input.
|
||||||
|
|
||||||
## 2026-08-24 – power-profiles-daemon replaces auto-cpufreq
|
## 2026-08-24 – power-profiles-daemon replaces auto-cpufreq
|
||||||
|
|
||||||
- **Who**: Dominik, ClaudeCode
|
- **Who**: Dominik, ClaudeCode
|
||||||
|
|||||||
@@ -233,6 +233,21 @@ Every `switch` leaves a generation behind. A bad one is left through the boot
|
|||||||
menu, where the previous generation is still listed, or with `sudo
|
menu, where the previous generation is still listed, or with `sudo
|
||||||
nixos-rebuild switch --rollback` from a system that still comes up.
|
nixos-rebuild switch --rollback` from a system that still comes up.
|
||||||
|
|
||||||
|
A `switch` alone installs nothing new though: with the lock unchanged it rebuilds
|
||||||
|
the same package versions, so a machine built daily can still carry months-old
|
||||||
|
software. The `moonix-flake-age` user timer in `modules/services.nix` therefore
|
||||||
|
reports once a day when the `nixpkgs` input has gone stale — seven days, set at
|
||||||
|
the top of that file. It reads the `lastModified` of the locked input rather than
|
||||||
|
the file's mtime, which a fresh clone rewrites without changing a version, and
|
||||||
|
finds the lock through the `/etc/nixos/flake.nix` symlink above. A machine built
|
||||||
|
from the remote reference holds no checkout and reports nothing.
|
||||||
|
|
||||||
|
The notification is critical, so it stays up until it is answered, and carries an
|
||||||
|
**Update now** action that opens a terminal on the checkout and runs `nix flake
|
||||||
|
update` followed by `sudo nixos-rebuild switch`. The terminal is whichever one
|
||||||
|
`xdg-terminals.list` names. Its body lists the same commands for running them by
|
||||||
|
hand.
|
||||||
|
|
||||||
## Version bumps
|
## Version bumps
|
||||||
|
|
||||||
`stasis` comes from nixpkgs and needs no pin of its own. It ships no unit, so
|
`stasis` comes from nixpkgs and needs no pin of its own. It ships no unit, so
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ in
|
|||||||
networking.networkmanager.plugins = [ pkgs.networkmanager-openvpn ];
|
networking.networkmanager.plugins = [ pkgs.networkmanager-openvpn ];
|
||||||
|
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
|
# XDG plumbing, before anything that calls into it. xdg-terminal-exec
|
||||||
|
# resolves a caller to whichever TerminalEmulator entry the XDG data
|
||||||
|
# hierarchy offers — footclient here, which reuses the server niri starts
|
||||||
|
# — so nothing downstream has to name an emulator.
|
||||||
|
xdg-terminal-exec
|
||||||
|
|
||||||
# Own programs
|
# Own programs
|
||||||
moonarchPkgs.sweet-cursors
|
moonarchPkgs.sweet-cursors
|
||||||
moonarchPkgs.moonarch-scripts
|
moonarchPkgs.moonarch-scripts
|
||||||
|
|||||||
+105
-1
@@ -1,8 +1,79 @@
|
|||||||
# ABOUTME: systemd system and user services for the desktop session.
|
# ABOUTME: systemd system and user services for the desktop session.
|
||||||
# ABOUTME: Rebuilt from moonarch/defaults/etc/systemd/, whose units use FHS paths.
|
# ABOUTME: Mostly rebuilt from moonarch/defaults/etc/systemd/, whose units use FHS paths.
|
||||||
|
|
||||||
{ pkgs, lib, moonarchPkgs, ... }:
|
{ pkgs, lib, moonarchPkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
# Days after which the nixpkgs input counts as stale. nixpkgs carries no
|
||||||
|
# patch backports, it bumps versions, so an old lock means known holes stay
|
||||||
|
# open until the next update — the age of the lock is the exposure.
|
||||||
|
maxLockAgeDays = 7;
|
||||||
|
|
||||||
|
# Reports the age of the nixpkgs input to the session's notification daemon.
|
||||||
|
#
|
||||||
|
# The age comes from the lock rather than the file mtime: a fresh clone or a
|
||||||
|
# branch switch rewrites the mtime without changing a single package version.
|
||||||
|
#
|
||||||
|
# The flake is found through /etc/nixos/flake.nix, which README documents as a
|
||||||
|
# symlink into the account's checkout. A machine built straight from the
|
||||||
|
# remote reference has no checkout and therefore no lock to read, which is why
|
||||||
|
# a missing link is not an error.
|
||||||
|
flakeAgeCheck = pkgs.writeShellApplication {
|
||||||
|
name = "moonix-flake-age";
|
||||||
|
runtimeInputs = with pkgs; [ bash coreutils jq libnotify xdg-terminal-exec ];
|
||||||
|
text = ''
|
||||||
|
link=/etc/nixos/flake.nix
|
||||||
|
[ -L "$link" ] || exit 0
|
||||||
|
|
||||||
|
flake=$(dirname "$(readlink -f "$link")")
|
||||||
|
lock="$flake/flake.lock"
|
||||||
|
[ -r "$lock" ] || exit 0
|
||||||
|
|
||||||
|
# root.inputs.nixpkgs names the node rather than being it: the entry is
|
||||||
|
# called nixpkgs_2 as soon as a dependency locks a nixpkgs of its own.
|
||||||
|
# Reading .nodes.nixpkgs directly would report a transitive input's date.
|
||||||
|
locked=$(jq -r '.nodes[.nodes.root.inputs.nixpkgs].locked.lastModified' "$lock")
|
||||||
|
if [ -z "$locked" ] || [ "$locked" = null ]; then
|
||||||
|
echo "no nixpkgs lastModified in $lock" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
days=$(( ( $(date +%s) - locked ) / 86400 ))
|
||||||
|
[ "$days" -ge ${toString maxLockAgeDays} ] || exit 0
|
||||||
|
|
||||||
|
# The path is resolved rather than written out: `nix flake update` acts on
|
||||||
|
# the working directory, so without the right one it updates nothing or
|
||||||
|
# the wrong flake. printf because the body carries newlines.
|
||||||
|
#
|
||||||
|
# Three lines exactly: NotifToast.qml caps the body at maximumLineCount 3
|
||||||
|
# and elides the rest, so a fourth line would drop a command.
|
||||||
|
body=$(printf 'cd %s\nnix flake update\nsudo nixos-rebuild switch' "$flake")
|
||||||
|
|
||||||
|
# Critical rather than normal: a stale lock does not pass, it stays true
|
||||||
|
# until a rebuild, and the toast is exempt from the auto-expire timer.
|
||||||
|
#
|
||||||
|
# -A implies --wait, so this blocks while the toast stands and prints the
|
||||||
|
# chosen action; a dismissed toast leaves it empty. Nothing has to bound
|
||||||
|
# that wait: while the process lives the notification is still on screen,
|
||||||
|
# and a second one a day later would say the same thing.
|
||||||
|
choice=$(notify-send -a moonix -u critical \
|
||||||
|
-A update="Update now" \
|
||||||
|
"nixpkgs is $days days old" "$body" || true)
|
||||||
|
|
||||||
|
[ "$choice" = update ] || exit 0
|
||||||
|
|
||||||
|
# A terminal rather than a silent rebuild: nixpkgs is unstable, so the
|
||||||
|
# build can fail, and sudo needs someone to answer it. xdg-terminal-exec
|
||||||
|
# instead of a named emulator so switching terminals is one entry in
|
||||||
|
# xdg-terminals.list. bash -lc for the PATH — neither the system nix nor
|
||||||
|
# the sudo wrapper is in this script's closure, and pinning nix here
|
||||||
|
# would shadow nix.package.
|
||||||
|
exec xdg-terminal-exec bash -lc \
|
||||||
|
"cd \"$flake\" && nix flake update && sudo nixos-rebuild switch; \
|
||||||
|
read -r -p '[enter to close] ' _"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
{
|
{
|
||||||
# --- Audio ---
|
# --- Audio ---
|
||||||
services.pipewire = {
|
services.pipewire = {
|
||||||
@@ -159,6 +230,39 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# --- Flake age ---
|
||||||
|
#
|
||||||
|
# A user unit rather than a system one: the notification goes to the session's
|
||||||
|
# daemon, and the checkout it reads belongs to the account.
|
||||||
|
|
||||||
|
systemd.user.services.moonix-flake-age = {
|
||||||
|
description = "Warn when the nixpkgs input has gone stale";
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = lib.getExe flakeAgeCheck;
|
||||||
|
# ProtectHome is left off deliberately: the lock sits in the account's
|
||||||
|
# checkout. The rest costs nothing for a script that only reads a file.
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
RestrictNamespaces = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
LockPersonality = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.user.timers.moonix-flake-age = {
|
||||||
|
description = "Daily nixpkgs staleness check";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
# Relative to the user manager, not the calendar: a desktop that is off at
|
||||||
|
# midnight would otherwise report on a session nobody is looking at. The
|
||||||
|
# delay after login gives the notification daemon in the bar time to claim
|
||||||
|
# the D-Bus name.
|
||||||
|
timerConfig = {
|
||||||
|
OnStartupSec = "5min";
|
||||||
|
OnUnitActiveSec = "1d";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# The polkit agent is not a service here: niri spawns it via
|
# The polkit agent is not a service here: niri spawns it via
|
||||||
# spawn-at-startup in defaults/xdg/niri/config.kdl. Only the FHS path it
|
# spawn-at-startup in defaults/xdg/niri/config.kdl. Only the FHS path it
|
||||||
# references has to exist — see modules/desktop.nix.
|
# references has to exist — see modules/desktop.nix.
|
||||||
|
|||||||
Reference in new issue
Block a user