# ABOUTME: systemd system and user services for the desktop session. # ABOUTME: Mostly rebuilt from moonarch/defaults/etc/systemd/, whose units use FHS paths. { config, 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] ' _" ''; }; # Shared by the two root oneshots below. Both read one state file and write a # single sysfs attribute, so everything else is taken away. Scored with # `systemd-analyze security`: 7.5 EXPOSED before, 0.9 SAFE after. # # Deliberately absent: # ProtectKernelTunables - remounts /sys read-only, which is exactly the # write both units exist for. ReadWritePaths is no fix, /sys/class/… is a # symlink into /sys/devices/…. # PrivateUsers - capabilities inside a user namespace are namespace-local, # while the kernel's sysfs store handlers test against the initial one. # # ProtectSystem=strict is safe here: it leaves /dev, /proc and /sys writable, # and the state file is only ever read. sysfsWriterHardening = { CapabilityBoundingSet = [ "" ]; AmbientCapabilities = [ "" ]; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; PrivateNetwork = true; IPAddressDeny = "any"; RestrictAddressFamilies = [ "AF_UNIX" ]; ProtectClock = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectControlGroups = true; ProtectProc = "invisible"; ProcSubset = "pid"; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; LockPersonality = true; MemoryDenyWriteExecute = true; SystemCallArchitectures = "native"; SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; KeyringMode = "private"; UMask = "0077"; }; # Shared by the Wayland session clients below: socket to the compositor, # nothing else. 9.4 UNSAFE before, 0.9 SAFE after, each one started under the # full set with `systemd-run --user` first. # # Deliberately absent: # ProtectHome - hides /home, /root *and /run/user*, where the Wayland # socket lives. Read-only fails too: connect() needs write on the inode. # PrivateUsers - maps the session uid to nobody, the socket is owned by the # real one. waylandClientHardening = { CapabilityBoundingSet = [ "" ]; AmbientCapabilities = [ "" ]; NoNewPrivileges = true; ProtectSystem = "strict"; ReadWritePaths = [ "%t" ]; PrivateTmp = true; PrivateDevices = true; PrivateNetwork = true; IPAddressDeny = "any"; RestrictAddressFamilies = [ "AF_UNIX" ]; RemoveIPC = true; ProtectClock = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectControlGroups = true; ProtectProc = "invisible"; ProcSubset = "pid"; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; LockPersonality = true; MemoryDenyWriteExecute = true; SystemCallArchitectures = "native"; SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; SystemCallErrorNumber = "EPERM"; KeyringMode = "private"; }; in { # --- Hardware --- services.upower.enable = true; services.fwupd.enable = true; # power-profiles-daemon over auto-cpufreq: on amd-pstate in active mode the # platform decides the frequency from the min/max and EPP registers alone, so # the governor is the wrong knob. ppd writes EPP and the ACPI platform_profile; # where neither exists it falls back to a driver that touches no hardware. services.power-profiles-daemon.enable = true; # --- System services from defaults/etc/systemd/system/ --- # # Rebuilt rather than copied, same reason as the user units: the originals # reference /usr/bin. systemd.services.moonarch-batsaver = { description = "Restore battery conservation mode threshold"; after = [ "sysinit.target" ]; wantedBy = [ "multi-user.target" ]; unitConfig.ConditionPathExists = [ "/sys/class/power_supply/BAT0/charge_control_end_threshold" "/var/lib/moonarch/batsaver-threshold" ]; serviceConfig = { Type = "oneshot"; ExecStart = "${moonarchPkgs.moonarch-scripts}/bin/moonarch-batsaver-restore"; } // sysfsWriterHardening; }; systemd.services.moonarch-camera = { description = "Switch off all cameras on boot"; after = [ "sysinit.target" ]; wantedBy = [ "multi-user.target" ]; unitConfig.ConditionPathExistsGlob = "/sys/class/video4linux/video*"; serviceConfig = { Type = "oneshot"; ExecStart = "${moonarchPkgs.moonarch-scripts}/bin/moonarch-camera-boot"; } // sysfsWriterHardening; }; # --- Session services --- # # Rebuilt rather than copied: the upstream units reference /usr/bin/, # which does not exist under Nix. # The bar, launcher and every popout. A unit rather than a spawn-at-startup so # a crash does not leave the session without a bar until the next login. systemd.user.services.quickshell = { description = "Quickshell bar and shell"; partOf = [ "graphical-session.target" ]; after = [ "graphical-session.target" ]; wantedBy = [ "graphical-session.target" ]; # The widgets shell out to niri, nmcli, powerprofilesctl, the moonarch # scripts and a dozen more, and which ones is a property of the QML, not of # this file. As a spawn-at-startup it inherited the session PATH; the unit # gets the same set rather than a list that goes stale on the next widget. path = [ config.system.path ]; # Restart=on-failure alone spun 32 times through a dead Wayland socket after # a rebuild had sent niri SIGTERM: graphical-session-pre.target went down, # graphical-session.target stayed up, so the unit was still wanted and every # attempt died on "no Qt platform plugin could be initialized". Quickshell # refuses a relaunch itself ("crashed within 10 seconds"), but systemd never # reached the default limit of 5 starts per 10s at a 3s delay. Three tries # per 30s ends it after ~9s. The window slides, so a single crash after a # normal run still gets its restart — the point of running this as a unit. unitConfig = { StartLimitIntervalSec = 30; StartLimitBurst = 3; }; serviceConfig = { Type = "simple"; ExecStart = "${lib.getExe pkgs.quickshell} -c moonarch"; Restart = "on-failure"; RestartSec = 3; }; }; # nixpkgs ships the package but no module, and the package carries no unit of # its own, so it is declared here. Mirrors /usr/lib/systemd/user/stasis.service. systemd.user.services.stasis = { description = "Stasis Wayland Idle Manager"; partOf = [ "graphical-session.target" ]; after = [ "graphical-session.target" ]; wantedBy = [ "graphical-session.target" ]; # Every binary a configured action names has to be listed here. stasis does # not run the actions through a login shell, so /etc/profile never applies # and nothing resolves via /run/current-system/sw/bin. A missing binary is # silent: the spawn fails, stasis logs the action as started, sees the # immediate exit, treats the step as finished and stops there — which is how # swaylock was absent for the whole idle chain while `niri msg` worked. # pactl is why pulseaudio appears at all — it stays off systemPackages, see # modules/desktop.nix. brightnessctl is for the laptop's brightness step. path = with pkgs; [ bashNonInteractive niri pulseaudio libnotify # moonarch-lock, not gtklock: the lock actions call the wrapper, which # carries gtklock, niri and jq in its own PATH. moonarchPkgs.moonarch-scripts brightnessctl systemd ]; # No waylandClientHardening: the locker is a child of this unit and # authenticates through the setuid-root unix_chkpwd. `systemd --user` builds # mount namespaces through an unprivileged user namespace, whose mounts are # nosuid — the wrapper loses setuid and the session is no longer unlockable. # Measured per directive with `echo wrong | unix_chkpwd $USER nonull`: exit # 7 means shadow was read, 9 means no access. Only these five return 7, so # only they are set. 9.4 → 9.0; there is no more to get here. serviceConfig = { Type = "simple"; ExecStart = "${lib.getExe pkgs.stasis}"; ExecReload = "${lib.getExe pkgs.stasis} reload"; Restart = "on-failure"; RestartSec = 2; # Stopped by hand it exits at once; stopped as part of the session it # does not answer SIGTERM, and the default 90 seconds left the whole # user manager waiting on it — niri received its own SIGTERM only once # stasis had been killed. Measured at 88s in the journal. What it holds # is an idle timer, so nothing is lost when it goes down hard. TimeoutStopSec = 5; IPAddressDeny = "any"; ProtectProc = "invisible"; ProcSubset = "pid"; RemoveIPC = true; KeyringMode = "private"; }; }; systemd.user.services.cliphist-text = { description = "Clipboard history manager (text)"; partOf = [ "graphical-session.target" ]; wantedBy = [ "graphical-session.target" ]; serviceConfig = { Type = "simple"; ExecStartPre = "${pkgs.runtimeShell} -c 'mkdir -p $XDG_RUNTIME_DIR/cliphist && ${pkgs.cliphist}/bin/cliphist wipe'"; ExecStart = "${pkgs.wl-clipboard}/bin/wl-paste --watch ${pkgs.cliphist}/bin/cliphist -db-path %t/cliphist/db store"; Restart = "on-failure"; RestartSec = 3; } // waylandClientHardening; }; systemd.user.services.cliphist-image = { description = "Clipboard history manager (image)"; partOf = [ "graphical-session.target" ]; after = [ "cliphist-text.service" ]; wantedBy = [ "graphical-session.target" ]; serviceConfig = { Type = "simple"; ExecStart = "${pkgs.wl-clipboard}/bin/wl-paste --type image --watch ${pkgs.cliphist}/bin/cliphist -db-path %t/cliphist/db store"; Restart = "on-failure"; RestartSec = 3; } // waylandClientHardening; }; # Night light stays disabled by default: it is a user toggle. Enabling it # here would create a system-scope symlink that overrides any # `systemctl --user disable`. systemd.user.services.wlsunset = { description = "Wlsunset night light (blue light filter)"; partOf = [ "graphical-session.target" ]; after = [ "graphical-session.target" ]; wantedBy = [ ]; serviceConfig = { Type = "simple"; ExecStartPre = "${pkgs.coreutils}/bin/sleep 2"; ExecStart = "${lib.getExe pkgs.wlsunset} -T 6500 -t 5000 -S 00:00 -s 00:01"; Restart = "on-failure"; RestartSec = 3; } // waylandClientHardening; }; # --- 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. }