Files
moonix/modules/services.nix
T
nevaforget 709b80e65c fix(desktop): keep launcher apps out of the bar's cgroup
Quickshell has no scope support of its own, so apps started from its
launcher inherited the cgroup of quickshell.service and systemd killed
them along with the unit on every restart — a rebuild that rewrites the
unit was enough, and Librewolf counted each one as a crash until it
refused to restore the session. app2unit is the start prefix the QML now
uses; see moonarch's LauncherPopout.qml.

The unit also gained a start limit. After a rebuild sent niri SIGTERM,
graphical-session.target stayed up while the Wayland socket was gone, so
Restart=on-failure spun 32 times in two minutes against a dead display.
Three tries per 30s ends that; the window slides, so a single crash after
a normal run still gets its restart.
2026-09-09 15:54:03 +02:00

274 lines
11 KiB
Nix

# ABOUTME: systemd system and user services for the desktop session.
# ABOUTME: Rebuilt from moonarch/defaults/etc/systemd/, whose units use FHS paths.
{ config, pkgs, lib, moonarchPkgs, ... }:
let
# 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
{
# --- Audio ---
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
jack.enable = true;
};
# --- Hardware ---
hardware.bluetooth.enable = true;
services.blueman.enable = true;
services.udisks2.enable = true;
services.upower.enable = true;
services.gvfs.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;
# --- Firewall, replaces the ufw calls in post-install.sh ---
networking.firewall.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
swaylock
brightnessctl
systemd
];
# No waylandClientHardening: swaylock 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;
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;
};
# 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.
}