diff --git a/DECISIONS.md b/DECISIONS.md index 09c3e9c..b35a711 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,36 @@ # 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 - **Who**: Dominik, ClaudeCode diff --git a/README.md b/README.md index c8954d0..fd0bd27 100644 --- a/README.md +++ b/README.md @@ -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 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 `stasis` comes from nixpkgs and needs no pin of its own. It ships no unit, so diff --git a/modules/desktop.nix b/modules/desktop.nix index 39bcbbe..c4ebe70 100644 --- a/modules/desktop.nix +++ b/modules/desktop.nix @@ -258,6 +258,12 @@ in networking.networkmanager.plugins = [ pkgs.networkmanager-openvpn ]; 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 moonarchPkgs.sweet-cursors moonarchPkgs.moonarch-scripts diff --git a/modules/services.nix b/modules/services.nix index c1cf0e6..7e81ff4 100644 --- a/modules/services.nix +++ b/modules/services.nix @@ -1,8 +1,79 @@ # 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, ... }: +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 --- 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 # spawn-at-startup in defaults/xdg/niri/config.kdl. Only the FHS path it # references has to exist — see modules/desktop.nix.