Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

users/ryan/modules/targets/guix/shepherd.nix

aeb9107982a669bb3d2f270a6d8e78eb021f6501 · 19.2 KB · 408 lines raw

1 # systemd user units (as home-manager models them) -> GNU Shepherd services.
2 #
3 # Fail-closed by design: every [Section] Key must be either translated with
4 # the same semantics, or on the explicit `ignoredKeys` list below (keys that
5 # don't change what runs or with what privileges). Anything else is an
6 # eval-time error, unless the user acknowledges it per unit via
7 # `targets.guix.shepherd.unenforced`, in which case it's dropped *with a
8 # build warning*. Sandboxing (Protect*, Private*, SystemCallFilter, ...) is
9 # deliberately never translated: a service that declares it is relying on it.
10 { lib, setpriv }:
11 let
12 inherit (lib)
13 concatStringsSep concatMapStringsSep concatMapStrings concatMap mapAttrs mapAttrsToList
14 filterAttrs attrNames hasSuffix removeSuffix optional optionals
15 optionalString elem toList last unique;
16
17 # ---------------------------------------------------------------- helpers
18
19 # JSON string escapes are a subset of Guile's (\" \\ \n \uXXXX).
20 q = builtins.toJSON;
21 sym = s: "(string->symbol ${q s})";
22
23 # HM's unit types leave null / [] placeholders for typed-but-unset keys
24 # (Description, Documentation, X-*-Triggers, Environment, ExecStart); HM's
25 # own INI renderer drops them, so do the same.
26 clean = unit:
27 filterAttrs (_: s: s != { })
28 (mapAttrs (_: filterAttrs (_: v: v != null && v != [ ])) unit);
29
30 str = v: if builtins.isBool v then (if v then "true" else "false") else toString v;
31 # Scalar keys: systemd's last assignment wins.
32 scalar = v: str (last (toList v));
33 isTrue = v: elem (scalar v) [ "true" "yes" "on" "1" ];
34
35 # ------------------------------------------------------- key allowlists
36
37 # Keys with no effect on what runs, how, or with what privileges.
38 ignoredKeys = {
39 Unit = [
40 "Description" # consumed as #:documentation
41 "Documentation"
42 "After" # pure ordering; dependencies below imply ordering in Shepherd
43 "Before"
44 "RefuseManualStart"
45 "RefuseManualStop"
46 "X-SwitchMethod"
47 ];
48 Service = [ "ExecReload" ]; # only reachable via `systemctl reload`
49 Socket = [ ];
50 Install = [ ];
51 };
52
53 limitMap = {
54 LimitCPU = "cpu"; LimitFSIZE = "fsize"; LimitDATA = "data";
55 LimitSTACK = "stack"; LimitCORE = "core"; LimitRSS = "rss";
56 LimitNOFILE = "nofile"; LimitAS = "as"; LimitNPROC = "nproc";
57 LimitMEMLOCK = "memlock";
58 };
59
60 translatedKeys = {
61 # Triggers are embedded in the generated file, so a trigger change is a
62 # content change and activation reloads the service (reload -> restart,
63 # the conservative direction).
64 Unit = [ "Wants" "Requires" "BindsTo" "PartOf" "X-Restart-Triggers" "X-Reload-Triggers" ];
65 Service = [
66 "Type" "ExecStart" "Environment" "WorkingDirectory" "Restart"
67 "RestartSec" "UMask" "NoNewPrivileges"
68 ] ++ attrNames limitMap;
69 Socket = [
70 "ListenStream" "FileDescriptorName" "Service" "Accept"
71 "SocketMode" "DirectoryMode"
72 ];
73 Install = [ "WantedBy" ];
74 };
75
76 sectionsFor = kind: [ "Unit" "Install" (if kind == "service" then "Service" else "Socket") ];
77
78 # ----------------------------------------------------- systemd specifiers
79
80 specExprs = {
81 t = ''(getenv "XDG_RUNTIME_DIR")'';
82 h = ''(getenv "HOME")'';
83 U = "(number->string (getuid))";
84 u = "(passwd:name (getpwuid (getuid)))";
85 "%" = q "%";
86 };
87 specParts = s: builtins.split "%(.)" s;
88 badSpecs = s:
89 concatMap
90 (p: optional (builtins.isList p && !(specExprs ? ${builtins.head p})) "%${builtins.head p}")
91 (specParts s);
92 # -> Scheme expression, evaluated at service start (not at load).
93 expand = s:
94 let
95 exprs = concatMap
96 (p: if builtins.isList p then [ specExprs.${builtins.head p} ] else optional (p != "") (q p))
97 (specParts s);
98 in
99 if exprs == [ ] then q ""
100 else if builtins.length exprs == 1 then builtins.head exprs
101 else "(string-append ${concatStringsSep " " exprs})";
102
103 # ------------------------------------------------- command-line splitting
104
105 # systemd quoting, minus the parts we refuse: backslash escapes (C-style in
106 # systemd, so not a literal-char escape) and $VAR expansion are rejected by
107 # cmdErrors rather than approximated.
108 tokenize = s:
109 let
110 ws = c: c == " " || c == "\t" || c == "\n";
111 step = st: c:
112 if st.q != null then
113 (if c == st.q then st // { q = null; } else st // { cur = st.cur + c; })
114 else if c == "\"" || c == "'" then
115 st // { q = c; cur = if st.cur == null then "" else st.cur; }
116 else if ws c then
117 (if st.cur == null then st else st // { out = st.out ++ [ st.cur ]; cur = null; })
118 else
119 st // { cur = (if st.cur == null then "" else st.cur) + c; };
120 end = builtins.foldl' step { out = [ ]; cur = null; q = null; }
121 (lib.stringToCharacters s);
122 in
123 {
124 tokens = end.out ++ optional (end.cur != null) end.cur;
125 unterminated = end.q != null;
126 };
127
128 cmdErrors = what: s:
129 let t = tokenize s; first = if t.tokens == [ ] then "" else builtins.head t.tokens;
130 in
131 optional (t.tokens == [ ]) "${what} is empty"
132 ++ optional t.unterminated "${what} has an unterminated quote"
133 ++ optional (lib.hasInfix "\\" s) "${what} uses backslash escapes (not translated)"
134 ++ optional (lib.hasInfix "$" s) "${what} uses $VAR expansion (not translated)"
135 ++ optional (builtins.match "[-@:+!|].*" first != null)
136 "${what} uses an executable prefix (${builtins.substring 0 1 first}) with no Shepherd equivalent"
137 ++ map (sp: "${what} uses unsupported specifier ${sp}") (badSpecs s);
138
139 # ----------------------------------------------------------- validation
140
141 octal = s: builtins.match "0?[0-7]{3,4}" s != null;
142 limitVal = s: builtins.match "(infinity|[0-9]+)(:(infinity|[0-9]+))?" s != null;
143 seconds = s: builtins.match "([0-9]+)s?" s;
144
145 keyErrors = { kind, name, unit, unenforced }:
146 let
147 full = "${name}.${kind}";
148 allowed = sectionsFor kind;
149 in
150 concatMap
151 (sec:
152 if !(elem sec allowed) then
153 optional (!(elem "${sec}.*" unenforced)) "${full}: section [${sec}] has no Shepherd equivalent"
154 else
155 concatMap
156 (key: optional
157 (!(elem key (translatedKeys.${sec} ++ ignoredKeys.${sec}))
158 && !(elem "${sec}.${key}" unenforced))
159 "${full}: ${sec}.${key} has no Shepherd equivalent")
160 (attrNames unit.${sec}))
161 (attrNames unit);
162
163 # Names Shepherd knows about, used to resolve dependency edges.
164 graphicalSym = "hm-graphical-session";
165
166 # Which service a socket unit activates (systemd default: same name).
167 socketTarget = sname: sock:
168 removeSuffix ".service" (scalar (sock.Socket.Service or "${sname}.service"));
169
170 resolveDep = { self, services, sockets }: dep:
171 if dep == "graphical-session.target" then { ok = graphicalSym; }
172 else if hasSuffix ".service" dep && services ? ${removeSuffix ".service" dep} then
173 { ok = removeSuffix ".service" dep; }
174 else if hasSuffix ".socket" dep && sockets ? ${removeSuffix ".socket" dep} then
175 let target = socketTarget (removeSuffix ".socket" dep) sockets.${removeSuffix ".socket" dep};
176 in if target == self then { skip = true; } else { ok = target; }
177 else { err = "dependency ${dep} is not a translated unit"; };
178
179 serviceErrors = { name, unit, services, sockets, unenforced }:
180 let
181 full = "${name}.service";
182 svc = unit.Service or { };
183 exec = toList (svc.ExecStart or [ ]);
184 deps = concatMap (k: toList (unit.Unit.${k} or [ ])) [ "Wants" "Requires" "BindsTo" "PartOf" ];
185 envTokens = concatMap (e: (tokenize (str e)).tokens) (toList (svc.Environment or [ ]));
186 limits = filterAttrs (k: _: limitMap ? ${k}) svc;
187 in
188 keyErrors { kind = "service"; inherit name unit unenforced; }
189 ++ optional (!(elem (scalar (svc.Type or "simple")) [ "simple" "exec" ]))
190 "${full}: Type=${scalar svc.Type} is not translated (only simple/exec)"
191 ++ optional (builtins.length exec != 1)
192 "${full}: needs exactly one ExecStart (has ${toString (builtins.length exec)})"
193 ++ concatMap (cmdErrors "${full}: ExecStart") (map str exec)
194 ++ concatMap (e: optional (!(lib.hasInfix "=" e)) "${full}: Environment entry '${e}' is not K=V") envTokens
195 ++ concatMap (e: map (sp: "${full}: Environment uses unsupported specifier ${sp}") (badSpecs e)) envTokens
196 ++ optional (lib.any (e: lib.hasInfix "$" e || lib.hasInfix "\\" e) (map str (toList (svc.Environment or [ ]))))
197 "${full}: Environment uses escapes or $VAR (not translated)"
198 ++ optionals (svc ? WorkingDirectory) (
199 let d = scalar svc.WorkingDirectory; in
200 optional (lib.hasPrefix "-" d) "${full}: WorkingDirectory=-... (ignore-missing) is not translated"
201 ++ map (sp: "${full}: WorkingDirectory uses unsupported specifier ${sp}") (badSpecs d))
202 ++ optional (svc ? Restart && !(elem (scalar svc.Restart) [ "no" "always" "on-failure" "on-abnormal" ]))
203 "${full}: Restart=${scalar svc.Restart} is not translated"
204 ++ optional (svc ? RestartSec && seconds (scalar svc.RestartSec) == null)
205 "${full}: RestartSec must be whole seconds"
206 ++ optional (svc ? UMask && !(octal (scalar svc.UMask))) "${full}: UMask must be octal"
207 ++ mapAttrsToList (k: v: "${full}: ${k}=${scalar v} is not a plain integer/infinity limit")
208 (filterAttrs (_: v: !(limitVal (scalar v))) limits)
209 ++ concatMap (d: let r = resolveDep { self = name; inherit services sockets; } d;
210 in optional (r ? err) "${full}: ${r.err}") deps
211 ++ concatMap (t: optional (!(elem t [ "default.target" "graphical-session.target" ]))
212 "${full}: WantedBy=${t} is not translated")
213 (toList (unit.Install.WantedBy or [ ]));
214
215 socketErrors = { name, unit, services, unenforced }:
216 let
217 full = "${name}.socket";
218 sock = unit.Socket or { };
219 target = socketTarget name unit;
220 paths = map str (toList (sock.ListenStream or [ ]));
221 dirMode = if sock ? DirectoryMode then scalar sock.DirectoryMode else null;
222 in
223 keyErrors { kind = "socket"; inherit name unit unenforced; }
224 ++ optional (!(services ? ${target})) "${full}: activates ${target}.service, which is not translated"
225 ++ optional (paths == [ ]) "${full}: no ListenStream"
226 ++ concatMap (p: optional (builtins.match "(/|%t|%h).*" p == null)
227 "${full}: ListenStream=${p} is not a unix socket path (TCP/UDP not translated)")
228 paths
229 ++ concatMap (p: map (sp: "${full}: ListenStream uses unsupported specifier ${sp}") (badSpecs p)) paths
230 ++ optional (sock ? Accept && isTrue sock.Accept) "${full}: Accept=yes (inetd-style) is not translated"
231 ++ optional (dirMode != null && !(octal dirMode)) "${full}: DirectoryMode must be octal"
232 # Shepherd can set the parent directory's mode but not the socket's. A
233 # 0700 parent makes the socket's own mode moot; anything looser doesn't.
234 ++ optional (sock ? SocketMode && !(elem dirMode [ "0700" "700" ]))
235 "${full}: SocketMode is only honoured with DirectoryMode=0700 (Shepherd can't chmod the socket)"
236 ++ concatMap (t: optional (t != "sockets.target") "${full}: WantedBy=${t} is not translated")
237 (toList (unit.Install.WantedBy or [ ]));
238
239 # ------------------------------------------------------------- emission
240
241 prelude = ''
242 (use-modules (shepherd service))
243
244 ;; Unit Environment= first, then Shepherd's *current* environment (which
245 ;; the compositor bridge updates via `herd eval root (setenv ...)`).
246 (define (hm-env overrides)
247 (let ((keys (map (lambda (kv) (substring kv 0 (string-index kv #\=)))
248 overrides)))
249 (append overrides
250 (filter (lambda (kv)
251 (let ((i (string-index kv #\=)))
252 (not (and i (member (substring kv 0 i) keys)))))
253 (environ)))))
254 '';
255
256 limitExpr = v:
257 let
258 parts = lib.splitString ":" (scalar v);
259 one = x: if x == "infinity" then "#f" else x;
260 in
261 if builtins.length parts == 1 then "${one (builtins.head parts)} ${one (builtins.head parts)}"
262 else "${one (builtins.elemAt parts 0)} ${one (builtins.elemAt parts 1)}";
263
264 octalExpr = s: "#o${lib.removePrefix "0" s}";
265
266 emitService = { name, unit, services, sockets }:
267 let
268 svc = unit.Service or { };
269 mySockets = filterAttrs (sn: s: socketTarget sn s == name) sockets;
270 socketActivated = mySockets != { };
271
272 argv0 = (tokenize (str (builtins.head (toList svc.ExecStart)))).tokens;
273 argv = optionals (svc ? NoNewPrivileges && isTrue svc.NoNewPrivileges)
274 [ "${setpriv}/bin/setpriv" "--no-new-privs" ]
275 ++ argv0;
276 cmd = "(list ${concatMapStringsSep " " expand argv})";
277
278 env = concatMap (e: (tokenize (str e)).tokens) (toList (svc.Environment or [ ]));
279 envExpr = "(hm-env (list ${concatMapStringsSep " " (kv:
280 let i = lib.stringLength (builtins.head (lib.splitString "=" kv)); in
281 "(string-append ${q (builtins.substring 0 (i + 1) kv)} ${expand (builtins.substring (i + 1) (-1) kv)})")
282 env}))";
283
284 deps = unique (concatMap
285 (d: let r = resolveDep { self = name; inherit services sockets; } d;
286 in optional (r ? ok) r.ok)
287 (concatMap (k: toList (unit.Unit.${k} or [ ])) [ "Wants" "Requires" "BindsTo" "PartOf" ]));
288 wantedBy = toList (unit.Install.WantedBy or [ ]);
289 graphical = elem "graphical-session.target" wantedBy || elem graphicalSym deps;
290 requirement = unique (deps ++ optional graphical graphicalSym);
291 autostart = elem "default.target" wantedBy
292 || lib.any (s: elem "sockets.target" (toList (s.Install.WantedBy or [ ]))) (lib.attrValues mySockets);
293
294 limits = filterAttrs (k: _: limitMap ? ${k}) svc;
295 opts = concatStringsSep "\n "
296 ([ "#:environment-variables ${envExpr}" ]
297 ++ optional (svc ? WorkingDirectory)
298 "#:directory ${let d = scalar svc.WorkingDirectory; in if d == "~" then specExprs.h else expand d}"
299 ++ optional (svc ? UMask) "#:file-creation-mask ${octalExpr (scalar svc.UMask)}"
300 ++ optional (limits != { }) "#:resource-limits (list ${concatStringsSep " "
301 (mapAttrsToList (k: v: "(list '${limitMap.${k}} ${limitExpr v})") limits)})");
302
303 endpoints = concatStringsSep "\n " (concatMap
304 (sn:
305 let
306 s = mySockets.${sn}.Socket;
307 fdName = scalar (s.FileDescriptorName or sn);
308 dirMode = if s ? DirectoryMode then octalExpr (scalar s.DirectoryMode) else "#o755";
309 in
310 map (p: "(endpoint (make-socket-address AF_UNIX ${expand (str p)}) #:name ${q fdName} #:socket-directory-permissions ${dirMode})")
311 (toList s.ListenStream))
312 (attrNames mySockets));
313
314 # Socket units keep listening after the daemon exits regardless of
315 # Restart= (that's socket-activation semantics), hence respawn? here.
316 respawn = socketActivated || elem (scalar (svc.Restart or "no")) [ "always" "on-failure" "on-abnormal" ];
317 restartSec = if svc ? RestartSec then builtins.head (seconds (scalar svc.RestartSec)) else null;
318
319 constructor =
320 if socketActivated then ''
321 (make-systemd-constructor ${cmd}
322 (list ${endpoints})
323 #:lazy-start? #t
324 ${opts})''
325 else ''
326 (make-forkexec-constructor ${cmd}
327 ${opts})'';
328 in
329 {
330 inherit graphical autostart;
331 text = ''
332 ;; Generated by home-manager (targets.guix) from ${name}.service${
333 optionalString socketActivated " + ${concatMapStringsSep ", " (s: "${s}.socket") (attrNames mySockets)}"
334 }.
335 ;; Do not edit; regenerated on every activation.${
336 concatMapStrings (t: "\n;; trigger: ${str t}")
337 (concatMap (k: toList (unit.Unit.${k} or [ ])) [ "X-Restart-Triggers" "X-Reload-Triggers" ])}
338 ${prelude}
339 (register-services
340 (list
341 (service (list ${sym name})
342 #:documentation ${q (scalar (unit.Unit.Description or "${name} (from home-manager)"))}
343 #:requirement (list ${concatMapStringsSep " " sym requirement})
344 #:respawn? ${if respawn then "#t" else "#f"}${
345 optionalString (restartSec != null) "\n #:respawn-delay ${restartSec}"}
346 ;; Constructed at start time so specifiers and the inherited
347 ;; environment reflect the session at that moment.
348 #:start (lambda args
349 (apply ${constructor}
350 args))
351 #:stop ${if socketActivated then "(make-systemd-destructor)" else "(make-kill-destructor)"})))
352 ${optionalString (autostart && !graphical) "\n(start-in-the-background (list ${sym name}))"}
353 '';
354 };
355
356 graphicalSessionFile = ''
357 ;; Generated by home-manager (targets.guix). Marker for graphical-session.target:
358 ;; started by the compositor bridge after it pushes its environment into
359 ;; Shepherd; stopping it stops every graphical service.
360 (use-modules (shepherd service))
361 (register-services
362 (list (service (list ${sym graphicalSym})
363 #:documentation "Graphical session (home-manager graphical-session.target)"
364 #:start (const #t)
365 #:stop (const #f))))
366 '';
367 in
368 {
369 inherit graphicalSym;
370
371 # -> { errors, warnings, files = { "<name>.scm" = text; }, graphical = [ names ] }
372 translate = { systemdUser, unenforced, ignoreUnits }:
373 let
374 keep = kind: units: filterAttrs (n: _: !(elem "${n}.${kind}" ignoreUnits)) (mapAttrs (_: clean) units);
375 services = keep "service" (systemdUser.services or { });
376 sockets = keep "socket" (systemdUser.sockets or { });
377 unen = full: unenforced.${full} or [ ];
378
379 # Units that make things happen on their own. Targets and slices are
380 # inert unless something references them, and any such reference
381 # (Wants=/WantedBy=/PartOf=/Slice=) is already an error above.
382 otherKinds = [ "timers" "paths" "mounts" "automounts" ];
383 otherErrors = concatMap
384 (k: map (n: "${n}.${removeSuffix "s" k}: ${removeSuffix "s" k} units are not translated (add to targets.guix.shepherd.ignoreUnits to skip)")
385 (attrNames (keep (removeSuffix "s" k) (systemdUser.${k} or { }))))
386 otherKinds;
387
388 errors =
389 concatMap (n: serviceErrors { name = n; unit = services.${n}; inherit services sockets; unenforced = unen "${n}.service"; }) (attrNames services)
390 ++ concatMap (n: socketErrors { name = n; unit = sockets.${n}; inherit services; unenforced = unen "${n}.socket"; }) (attrNames sockets)
391 ++ otherErrors
392 ++ concatMap (full: optional (!(services ? ${removeSuffix ".service" full}) && !(sockets ? ${removeSuffix ".socket" full}))
393 "targets.guix.shepherd.unenforced: ${full} is not a defined service/socket")
394 (attrNames unenforced);
395
396 warnings = concatMap
397 (full: map (k: "targets.guix: ${full}: ${k} dropped (not enforced under Shepherd)") unenforced.${full})
398 (attrNames unenforced);
399
400 emitted = mapAttrs (n: u: emitService { name = n; unit = u; inherit services sockets; }) services;
401 in
402 {
403 inherit errors warnings;
404 graphical = attrNames (filterAttrs (_: e: e.graphical) emitted);
405 files = { "${graphicalSym}.scm" = graphicalSessionFile; }
406 // lib.mapAttrs' (n: e: lib.nameValuePair "${n}.scm" e.text) emitted;
407 };
408 }