@push.rocks/smartdaemon 2.20.1 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.systemdservicedefinition.d.ts +16 -4
  3. package/dist_ts/classes.systemdservicedefinition.js +33 -7
  4. package/dist_ts/classes.systemdtransientscope.js +1 -1
  5. package/dist_ts/classes.systemdunit.d.ts +7 -0
  6. package/dist_ts/classes.systemdunit.js +11 -1
  7. package/dist_ts/classes.systemdunitfile.d.ts +26 -0
  8. package/dist_ts/classes.systemdunitfile.js +125 -9
  9. package/dist_ts/smartdaemon.classes.systemdmanager.js +1 -1
  10. package/dist_ts/smartdaemon.paths.js +1 -1
  11. package/dist_ts/systemd.definition.d.ts +16 -3
  12. package/dist_ts/systemd.definition.js +1 -1
  13. package/dist_ts/systemd.managerversion.d.ts +2 -0
  14. package/dist_ts/systemd.managerversion.js +3 -1
  15. package/dist_ts/systemd.userenvironment.d.ts +1 -1
  16. package/dist_ts_systemdruntime/classes.systemdnotifier.d.ts +82 -0
  17. package/dist_ts_systemdruntime/classes.systemdnotifier.js +217 -0
  18. package/dist_ts_systemdruntime/classes.systemdruntimeerror.d.ts +31 -0
  19. package/dist_ts_systemdruntime/classes.systemdruntimeerror.js +9 -0
  20. package/dist_ts_systemdruntime/index.d.ts +5 -0
  21. package/dist_ts_systemdruntime/index.js +14 -0
  22. package/dist_ts_systemdruntime/systemdruntime.libc.d.ts +87 -0
  23. package/dist_ts_systemdruntime/systemdruntime.libc.js +92 -0
  24. package/dist_ts_systemdruntime/systemdruntime.listen.d.ts +46 -0
  25. package/dist_ts_systemdruntime/systemdruntime.listen.js +136 -0
  26. package/dist_ts_systemdruntime/systemdruntime.validation.d.ts +13 -0
  27. package/dist_ts_systemdruntime/systemdruntime.validation.js +29 -0
  28. package/package.json +18 -6
  29. package/readme.md +186 -3
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/classes.systemdservicedefinition.ts +47 -10
  32. package/ts/classes.systemdunit.ts +11 -0
  33. package/ts/classes.systemdunitfile.ts +124 -7
  34. package/ts/systemd.definition.ts +12 -0
  35. package/ts/systemd.managerversion.ts +3 -0
  36. package/ts_systemdruntime/classes.systemdnotifier.ts +287 -0
  37. package/ts_systemdruntime/classes.systemdruntimeerror.ts +53 -0
  38. package/ts_systemdruntime/index.ts +22 -0
  39. package/ts_systemdruntime/systemdruntime.libc.ts +121 -0
  40. package/ts_systemdruntime/systemdruntime.listen.ts +153 -0
  41. package/ts_systemdruntime/systemdruntime.validation.ts +41 -0
@@ -0,0 +1,136 @@
1
+ import { SystemdRuntimeError } from './classes.systemdruntimeerror.js';
2
+ import { libcConstants, systemdLibc } from './systemdruntime.libc.js';
3
+ import { assertSystemdDescriptor, isSystemdDescriptorName } from './systemdruntime.validation.js';
4
+ /** sd_listen_fds(3): `SD_LISTEN_FDS_START`, the first passed descriptor. */
5
+ export const systemdListenDescriptorsStart = 3;
6
+ /**
7
+ * Marks one open descriptor close-on-exec (`fcntl(F_SETFD, FD_CLOEXEC)`) and verifies the flag,
8
+ * so no child process inherits it. Throws `close_on_exec_failed` with `errno` when the descriptor
9
+ * is not open or the flag does not hold.
10
+ */
11
+ export const setCloseOnExec = (descriptorArg) => {
12
+ assertSystemdDescriptor(descriptorArg);
13
+ const libc = systemdLibc();
14
+ const { fcntl } = libc.functions;
15
+ const flags = fcntl(descriptorArg, libcConstants.F_GETFD, 0);
16
+ if (flags < 0) {
17
+ throw new SystemdRuntimeError('close_on_exec_failed', `Descriptor ${descriptorArg} cannot be read.`, {
18
+ errno: libc.errno(),
19
+ });
20
+ }
21
+ if (fcntl(descriptorArg, libcConstants.F_SETFD, flags | libcConstants.FD_CLOEXEC) < 0) {
22
+ throw new SystemdRuntimeError('close_on_exec_failed', `Descriptor ${descriptorArg} cannot be marked close-on-exec.`, {
23
+ errno: libc.errno(),
24
+ });
25
+ }
26
+ if ((fcntl(descriptorArg, libcConstants.F_GETFD, 0) & libcConstants.FD_CLOEXEC) === 0) {
27
+ throw new SystemdRuntimeError('close_on_exec_failed', `Descriptor ${descriptorArg} did not keep close-on-exec.`);
28
+ }
29
+ };
30
+ const positiveIntegerPattern = /^[1-9][0-9]*$/;
31
+ /** sd_listen_fds(3) refuses a count above `INT_MAX - SD_LISTEN_FDS_START`. */
32
+ const listenDescriptorsMaximum = 2_147_483_647 - systemdListenDescriptorsStart;
33
+ const invalidListen = (messageArg, errnoArg) => new SystemdRuntimeError('listen_environment_invalid', messageArg, errnoArg === undefined ? {} : { errno: errnoArg });
34
+ /**
35
+ * The process's hard `RLIMIT_NOFILE` (`getrlimit(2)`), or null for `RLIM_INFINITY`. No descriptor
36
+ * the manager passed can lie at or above it: systemd moves passed descriptors into place with
37
+ * `fcntl(F_DUPFD)` after applying the unit's limits (`shift_fds()` after `setrlimit_closest_all()` in
38
+ * v255 `src/core/exec-invoke.c`), `F_DUPFD` refuses a number at or above the soft limit, the soft
39
+ * limit never exceeds the hard one, and an unprivileged process cannot raise the hard one.
40
+ */
41
+ const descriptorHardLimit = () => {
42
+ const libc = systemdLibc();
43
+ const rlimit = Buffer.alloc(16);
44
+ if (libc.functions.getrlimit(libcConstants.RLIMIT_NOFILE, rlimit) !== 0) {
45
+ throw invalidListen('getrlimit(RLIMIT_NOFILE) failed, so LISTEN_FDS cannot be bounded.', libc.errno());
46
+ }
47
+ const hard = rlimit.readBigUInt64LE(8);
48
+ return hard === 0xffffffffffffffffn ? null : hard;
49
+ };
50
+ /**
51
+ * Takes the descriptors the service manager passed this process (sd_listen_fds_with_names(3)):
52
+ * socket-activation sockets and descriptor-store entries, numbered from 3.
53
+ *
54
+ * `LISTEN_PID`, `LISTEN_FDS` and `LISTEN_FDNAMES` always leave the environment, so no child sees
55
+ * them. Without them, or with `LISTEN_PID` naming another process (variables inherited from a
56
+ * parent), it returns an empty list and touches no descriptor. For this process every passed
57
+ * descriptor is marked close-on-exec before the list returns. Without `LISTEN_FDNAMES` each name is
58
+ * `unknown`, as in systemd.
59
+ *
60
+ * Throws `listen_environment_invalid` before touching any descriptor for a partial or malformed set
61
+ * (a count that is not a positive integer, a count whose last descriptor, `2 + count`, is at or
62
+ * above the process's hard `RLIMIT_NOFILE`, a names list whose length differs from the count, an
63
+ * invalid name), and with `errno` when `getrlimit(2)` fails. Descriptors are then marked one by one
64
+ * from 3, and memory grows only with descriptors proven open: the first passed descriptor that is
65
+ * not open throws `close_on_exec_failed`, as `sd_listen_fds(3)` fails on it; descriptors before it
66
+ * keep the flag.
67
+ */
68
+ export const takeSystemdListenDescriptors = (optionsArg = {}) => {
69
+ const env = optionsArg.env ?? process.env;
70
+ const pid = optionsArg.pid ?? process.pid;
71
+ const listenPid = env.LISTEN_PID;
72
+ const listenFds = env.LISTEN_FDS;
73
+ const listenFdNames = env.LISTEN_FDNAMES;
74
+ delete env.LISTEN_PID;
75
+ delete env.LISTEN_FDS;
76
+ delete env.LISTEN_FDNAMES;
77
+ if (listenPid === undefined && listenFds === undefined && listenFdNames === undefined)
78
+ return Object.freeze([]);
79
+ if (listenPid === undefined || !positiveIntegerPattern.test(listenPid)) {
80
+ throw invalidListen('LISTEN_PID must name a process.');
81
+ }
82
+ if (listenPid !== String(pid))
83
+ return Object.freeze([]);
84
+ if (listenFds === undefined || !positiveIntegerPattern.test(listenFds) || Number(listenFds) > listenDescriptorsMaximum) {
85
+ throw invalidListen('LISTEN_FDS must be a positive descriptor count.');
86
+ }
87
+ const count = Number(listenFds);
88
+ // The cheap early bound: the last passed descriptor is 2 + count.
89
+ const hardLimit = descriptorHardLimit();
90
+ if (hardLimit !== null && BigInt(systemdListenDescriptorsStart + count) > hardLimit) {
91
+ throw invalidListen(`LISTEN_FDS passes ${count} descriptors; descriptor ${systemdListenDescriptorsStart + count - 1} is at or above this process's hard RLIMIT_NOFILE (${hardLimit}).`);
92
+ }
93
+ // Names, when given, are checked before any descriptor; the list is bounded by the variable's length.
94
+ const names = listenFdNames === undefined ? null : listenFdNames.split(':');
95
+ if (names !== null) {
96
+ if (names.length !== count)
97
+ throw invalidListen(`LISTEN_FDNAMES names ${names.length} descriptors; LISTEN_FDS passes ${count}.`);
98
+ if (!names.every((name) => isSystemdDescriptorName(name, false)))
99
+ throw invalidListen('LISTEN_FDNAMES holds an invalid name.');
100
+ }
101
+ // Nothing scales with the count before a descriptor is proven open: as in sd_listen_fds(3), the
102
+ // first descriptor that is not open ends the walk with close_on_exec_failed.
103
+ const descriptors = [];
104
+ for (let index = 0; index < count; index++) {
105
+ const descriptor = systemdListenDescriptorsStart + index;
106
+ setCloseOnExec(descriptor);
107
+ descriptors.push(Object.freeze({ descriptor, name: names === null ? 'unknown' : names[index] }));
108
+ }
109
+ return Object.freeze(descriptors);
110
+ };
111
+ /**
112
+ * The watchdog interval the manager expects `WATCHDOG=1` within (`WATCHDOG_USEC`, sd_watchdog_enabled(3)),
113
+ * in microseconds; null when the unit has no watchdog or `WATCHDOG_PID` names another process.
114
+ * Reads the environment without changing it. Throws `watchdog_environment_invalid` for a malformed
115
+ * value.
116
+ */
117
+ export const watchdogIntervalUsec = (optionsArg = {}) => {
118
+ const env = optionsArg.env ?? process.env;
119
+ const pid = optionsArg.pid ?? process.pid;
120
+ const usec = env.WATCHDOG_USEC;
121
+ const watchdogPid = env.WATCHDOG_PID;
122
+ if (usec === undefined)
123
+ return null;
124
+ if (!positiveIntegerPattern.test(usec) || !Number.isSafeInteger(Number(usec))) {
125
+ throw new SystemdRuntimeError('watchdog_environment_invalid', 'WATCHDOG_USEC must be a positive integer.');
126
+ }
127
+ if (watchdogPid !== undefined) {
128
+ if (!positiveIntegerPattern.test(watchdogPid)) {
129
+ throw new SystemdRuntimeError('watchdog_environment_invalid', 'WATCHDOG_PID must name a process.');
130
+ }
131
+ if (watchdogPid !== String(pid))
132
+ return null;
133
+ }
134
+ return Number(usec);
135
+ };
136
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3lzdGVtZHJ1bnRpbWUubGlzdGVuLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHNfc3lzdGVtZHJ1bnRpbWUvc3lzdGVtZHJ1bnRpbWUubGlzdGVuLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLGtDQUFrQyxDQUFDO0FBQ3ZFLE9BQU8sRUFBRSxhQUFhLEVBQUUsV0FBVyxFQUFFLE1BQU0sMEJBQTBCLENBQUM7QUFDdEUsT0FBTyxFQUFFLHVCQUF1QixFQUFFLHVCQUF1QixFQUFFLE1BQU0sZ0NBQWdDLENBQUM7QUFFbEcsNEVBQTRFO0FBQzVFLE1BQU0sQ0FBQyxNQUFNLDZCQUE2QixHQUFHLENBQUMsQ0FBQztBQWdCL0M7Ozs7R0FJRztBQUNILE1BQU0sQ0FBQyxNQUFNLGNBQWMsR0FBRyxDQUFDLGFBQXFCLEVBQVEsRUFBRTtJQUM1RCx1QkFBdUIsQ0FBQyxhQUFhLENBQUMsQ0FBQztJQUN2QyxNQUFNLElBQUksR0FBRyxXQUFXLEVBQUUsQ0FBQztJQUMzQixNQUFNLEVBQUUsS0FBSyxFQUFFLEdBQUcsSUFBSSxDQUFDLFNBQVMsQ0FBQztJQUNqQyxNQUFNLEtBQUssR0FBRyxLQUFLLENBQUMsYUFBYSxFQUFFLGFBQWEsQ0FBQyxPQUFPLEVBQUUsQ0FBQyxDQUFDLENBQUM7SUFDN0QsSUFBSSxLQUFLLEdBQUcsQ0FBQyxFQUFFLENBQUM7UUFDZCxNQUFNLElBQUksbUJBQW1CLENBQUMsc0JBQXNCLEVBQUUsY0FBYyxhQUFhLGtCQUFrQixFQUFFO1lBQ25HLEtBQUssRUFBRSxJQUFJLENBQUMsS0FBSyxFQUFFO1NBQ3BCLENBQUMsQ0FBQztJQUNMLENBQUM7SUFDRCxJQUFJLEtBQUssQ0FBQyxhQUFhLEVBQUUsYUFBYSxDQUFDLE9BQU8sRUFBRSxLQUFLLEdBQUcsYUFBYSxDQUFDLFVBQVUsQ0FBQyxHQUFHLENBQUMsRUFBRSxDQUFDO1FBQ3RGLE1BQU0sSUFBSSxtQkFBbUIsQ0FBQyxzQkFBc0IsRUFBRSxjQUFjLGFBQWEsa0NBQWtDLEVBQUU7WUFDbkgsS0FBSyxFQUFFLElBQUksQ0FBQyxLQUFLLEVBQUU7U0FDcEIsQ0FBQyxDQUFDO0lBQ0wsQ0FBQztJQUNELElBQUksQ0FBQyxLQUFLLENBQUMsYUFBYSxFQUFFLGFBQWEsQ0FBQyxPQUFPLEVBQUUsQ0FBQyxDQUFDLEdBQUcsYUFBYSxDQUFDLFVBQVUsQ0FBQyxLQUFLLENBQUMsRUFBRSxDQUFDO1FBQ3RGLE1BQU0sSUFBSSxtQkFBbUIsQ0FBQyxzQkFBc0IsRUFBRSxjQUFjLGFBQWEsOEJBQThCLENBQUMsQ0FBQztJQUNuSCxDQUFDO0FBQ0gsQ0FBQyxDQUFDO0FBRUYsTUFBTSxzQkFBc0IsR0FBRyxlQUFlLENBQUM7QUFDL0MsOEVBQThFO0FBQzlFLE1BQU0sd0JBQXdCLEdBQUcsYUFBYSxHQUFHLDZCQUE2QixDQUFDO0FBRS9FLE1BQU0sYUFBYSxHQUFHLENBQUMsVUFBa0IsRUFBRSxRQUFpQixFQUFFLEVBQUUsQ0FDOUQsSUFBSSxtQkFBbUIsQ0FBQyw0QkFBNEIsRUFBRSxVQUFVLEVBQUUsUUFBUSxLQUFLLFNBQVMsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLEtBQUssRUFBRSxRQUFRLEVBQUUsQ0FBQyxDQUFDO0FBRXZIOzs7Ozs7R0FNRztBQUNILE1BQU0sbUJBQW1CLEdBQUcsR0FBa0IsRUFBRTtJQUM5QyxNQUFNLElBQUksR0FBRyxXQUFXLEVBQUUsQ0FBQztJQUMzQixNQUFNLE1BQU0sR0FBRyxNQUFNLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FBQyxDQUFDO0lBQ2hDLElBQUksSUFBSSxDQUFDLFNBQVMsQ0FBQyxTQUFTLENBQUMsYUFBYSxDQUFDLGFBQWEsRUFBRSxNQUFNLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FBQztRQUN4RSxNQUFNLGFBQWEsQ0FBQyxtRUFBbUUsRUFBRSxJQUFJLENBQUMsS0FBSyxFQUFFLENBQUMsQ0FBQztJQUN6RyxDQUFDO0lBQ0QsTUFBTSxJQUFJLEdBQUcsTUFBTSxDQUFDLGVBQWUsQ0FBQyxDQUFDLENBQUMsQ0FBQztJQUN2QyxPQUFPLElBQUksS0FBSyxtQkFBc0IsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUM7QUFDdkQsQ0FBQyxDQUFDO0FBRUY7Ozs7Ozs7Ozs7Ozs7Ozs7O0dBaUJHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sNEJBQTRCLEdBQUcsQ0FDMUMsVUFBVSxHQUFzQyxFQUFFLEVBQ2IsRUFBRTtJQUN2QyxNQUFNLEdBQUcsR0FBRyxVQUFVLENBQUMsR0FBRyxJQUFJLE9BQU8sQ0FBQyxHQUFHLENBQUM7SUFDMUMsTUFBTSxHQUFHLEdBQUcsVUFBVSxDQUFDLEdBQUcsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDO0lBQzFDLE1BQU0sU0FBUyxHQUFHLEdBQUcsQ0FBQyxVQUFVLENBQUM7SUFDakMsTUFBTSxTQUFTLEdBQUcsR0FBRyxDQUFDLFVBQVUsQ0FBQztJQUNqQyxNQUFNLGFBQWEsR0FBRyxHQUFHLENBQUMsY0FBYyxDQUFDO0lBQ3pDLE9BQU8sR0FBRyxDQUFDLFVBQVUsQ0FBQztJQUN0QixPQUFPLEdBQUcsQ0FBQyxVQUFVLENBQUM7SUFDdEIsT0FBTyxHQUFHLENBQUMsY0FBYyxDQUFDO0lBQzFCLElBQUksU0FBUyxLQUFLLFNBQVMsSUFBSSxTQUFTLEtBQUssU0FBUyxJQUFJLGFBQWEsS0FBSyxTQUFTO1FBQUUsT0FBTyxNQUFNLENBQUMsTUFBTSxDQUFDLEVBQUUsQ0FBQyxDQUFDO0lBQ2hILElBQUksU0FBUyxLQUFLLFNBQVMsSUFBSSxDQUFDLHNCQUFzQixDQUFDLElBQUksQ0FBQyxTQUFTLENBQUMsRUFBRSxDQUFDO1FBQ3ZFLE1BQU0sYUFBYSxDQUFDLGlDQUFpQyxDQUFDLENBQUM7SUFDekQsQ0FBQztJQUNELElBQUksU0FBUyxLQUFLLE1BQU0sQ0FBQyxHQUFHLENBQUM7UUFBRSxPQUFPLE1BQU0sQ0FBQyxNQUFNLENBQUMsRUFBRSxDQUFDLENBQUM7SUFDeEQsSUFBSSxTQUFTLEtBQUssU0FBUyxJQUFJLENBQUMsc0JBQXNCLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxJQUFJLE1BQU0sQ0FBQyxTQUFTLENBQUMsR0FBRyx3QkFBd0IsRUFBRSxDQUFDO1FBQ3ZILE1BQU0sYUFBYSxDQUFDLGlEQUFpRCxDQUFDLENBQUM7SUFDekUsQ0FBQztJQUNELE1BQU0sS0FBSyxHQUFHLE1BQU0sQ0FBQyxTQUFTLENBQUMsQ0FBQztJQUNoQyxrRUFBa0U7SUFDbEUsTUFBTSxTQUFTLEdBQUcsbUJBQW1CLEVBQUUsQ0FBQztJQUN4QyxJQUFJLFNBQVMsS0FBSyxJQUFJLElBQUksTUFBTSxDQUFDLDZCQUE2QixHQUFHLEtBQUssQ0FBQyxHQUFHLFNBQVMsRUFBRSxDQUFDO1FBQ3BGLE1BQU0sYUFBYSxDQUFDLHFCQUFxQixLQUFLLDRCQUE0Qiw2QkFBNkIsR0FBRyxLQUFLLEdBQUcsQ0FBQyxzREFBc0QsU0FBUyxJQUFJLENBQUMsQ0FBQztJQUMxTCxDQUFDO0lBQ0Qsc0dBQXNHO0lBQ3RHLE1BQU0sS0FBSyxHQUFHLGFBQWEsS0FBSyxTQUFTLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsYUFBYSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUM1RSxJQUFJLEtBQUssS0FBSyxJQUFJLEVBQUUsQ0FBQztRQUNuQixJQUFJLEtBQUssQ0FBQyxNQUFNLEtBQUssS0FBSztZQUFFLE1BQU0sYUFBYSxDQUFDLHdCQUF3QixLQUFLLENBQUMsTUFBTSxtQ0FBbUMsS0FBSyxHQUFHLENBQUMsQ0FBQztRQUNqSSxJQUFJLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLElBQUksRUFBRSxFQUFFLENBQUMsdUJBQXVCLENBQUMsSUFBSSxFQUFFLEtBQUssQ0FBQyxDQUFDO1lBQUUsTUFBTSxhQUFhLENBQUMsdUNBQXVDLENBQUMsQ0FBQztJQUNqSSxDQUFDO0lBQ0QsZ0dBQWdHO0lBQ2hHLDZFQUE2RTtJQUM3RSxNQUFNLFdBQVcsR0FBK0IsRUFBRSxDQUFDO0lBQ25ELEtBQUssSUFBSSxLQUFLLEdBQUcsQ0FBQyxFQUFFLEtBQUssR0FBRyxLQUFLLEVBQUUsS0FBSyxFQUFFLEVBQUUsQ0FBQztRQUMzQyxNQUFNLFVBQVUsR0FBRyw2QkFBNkIsR0FBRyxLQUFLLENBQUM7UUFDekQsY0FBYyxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBQzNCLFdBQVcsQ0FBQyxJQUFJLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxFQUFFLFVBQVUsRUFBRSxJQUFJLEVBQUUsS0FBSyxLQUFLLElBQUksQ0FBQyxDQUFDLENBQUMsU0FBUyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUM7SUFDbkcsQ0FBQztJQUNELE9BQU8sTUFBTSxDQUFDLE1BQU0sQ0FBQyxXQUFXLENBQUMsQ0FBQztBQUNwQyxDQUFDLENBQUM7QUFFRjs7Ozs7R0FLRztBQUNILE1BQU0sQ0FBQyxNQUFNLG9CQUFvQixHQUFHLENBQUMsVUFBVSxHQUFzQyxFQUFFLEVBQWlCLEVBQUU7SUFDeEcsTUFBTSxHQUFHLEdBQUcsVUFBVSxDQUFDLEdBQUcsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDO0lBQzFDLE1BQU0sR0FBRyxHQUFHLFVBQVUsQ0FBQyxHQUFHLElBQUksT0FBTyxDQUFDLEdBQUcsQ0FBQztJQUMxQyxNQUFNLElBQUksR0FBRyxHQUFHLENBQUMsYUFBYSxDQUFDO0lBQy9CLE1BQU0sV0FBVyxHQUFHLEdBQUcsQ0FBQyxZQUFZLENBQUM7SUFDckMsSUFBSSxJQUFJLEtBQUssU0FBUztRQUFFLE9BQU8sSUFBSSxDQUFDO0lBQ3BDLElBQUksQ0FBQyxzQkFBc0IsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsYUFBYSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQyxFQUFFLENBQUM7UUFDOUUsTUFBTSxJQUFJLG1CQUFtQixDQUFDLDhCQUE4QixFQUFFLDJDQUEyQyxDQUFDLENBQUM7SUFDN0csQ0FBQztJQUNELElBQUksV0FBVyxLQUFLLFNBQVMsRUFBRSxDQUFDO1FBQzlCLElBQUksQ0FBQyxzQkFBc0IsQ0FBQyxJQUFJLENBQUMsV0FBVyxDQUFDLEVBQUUsQ0FBQztZQUM5QyxNQUFNLElBQUksbUJBQW1CLENBQUMsOEJBQThCLEVBQUUsbUNBQW1DLENBQUMsQ0FBQztRQUNyRyxDQUFDO1FBQ0QsSUFBSSxXQUFXLEtBQUssTUFBTSxDQUFDLEdBQUcsQ0FBQztZQUFFLE9BQU8sSUFBSSxDQUFDO0lBQy9DLENBQUM7SUFDRCxPQUFPLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQztBQUN0QixDQUFDLENBQUMifQ==
@@ -0,0 +1,13 @@
1
+ /** `FDNAME_MAX`: the longest descriptor name systemd accepts. */
2
+ export declare const descriptorNameMaximumLength = 255;
3
+ /**
4
+ * systemd's `fdname_is_valid()` (src/basic/fd-util.c, v255): printable ASCII except `:`, at most
5
+ * 255 characters. systemd also admits the empty name; `nonEmptyArg` refuses it where a name has to
6
+ * identify a descriptor.
7
+ */
8
+ export declare const isSystemdDescriptorName: (nameArg: unknown, nonEmptyArg: boolean) => nameArg is string;
9
+ /** An `AF_UNIX` notification address: an absolute path or an abstract name written `@name`. */
10
+ export declare const isSystemdNotifySocketAddress: (addressArg: unknown) => addressArg is string;
11
+ export declare const assertSystemdNotifySocketAddress: (addressArg: string) => void;
12
+ export declare const isSystemdDescriptor: (descriptorArg: unknown) => descriptorArg is number;
13
+ export declare const assertSystemdDescriptor: (descriptorArg: number) => void;
@@ -0,0 +1,29 @@
1
+ import { SystemdRuntimeError } from './classes.systemdruntimeerror.js';
2
+ import { socketAddressMaximumBytes } from './systemdruntime.libc.js';
3
+ /** `FDNAME_MAX`: the longest descriptor name systemd accepts. */
4
+ export const descriptorNameMaximumLength = 255;
5
+ /**
6
+ * systemd's `fdname_is_valid()` (src/basic/fd-util.c, v255): printable ASCII except `:`, at most
7
+ * 255 characters. systemd also admits the empty name; `nonEmptyArg` refuses it where a name has to
8
+ * identify a descriptor.
9
+ */
10
+ export const isSystemdDescriptorName = (nameArg, nonEmptyArg) => typeof nameArg === 'string' && nameArg.length <= descriptorNameMaximumLength &&
11
+ (!nonEmptyArg || nameArg.length > 0) && /^[\x20-\x39\x3b-\x7e]*$/.test(nameArg);
12
+ /** An `AF_UNIX` notification address: an absolute path or an abstract name written `@name`. */
13
+ export const isSystemdNotifySocketAddress = (addressArg) => typeof addressArg === 'string' && /^[/@]./s.test(addressArg) && !addressArg.includes('\0') &&
14
+ Buffer.byteLength(addressArg, 'utf8') <= socketAddressMaximumBytes;
15
+ export const assertSystemdNotifySocketAddress = (addressArg) => {
16
+ if (!isSystemdNotifySocketAddress(addressArg)) {
17
+ throw new SystemdRuntimeError('notify_socket_invalid', 'NOTIFY_SOCKET must be an absolute AF_UNIX path or an abstract name (@name) of at most 107 bytes without NUL.');
18
+ }
19
+ };
20
+ /** The largest descriptor number the kernel can hand out (`INT_MAX`). */
21
+ const descriptorMaximum = 2_147_483_647;
22
+ export const isSystemdDescriptor = (descriptorArg) => typeof descriptorArg === 'number' && Number.isSafeInteger(descriptorArg) && descriptorArg >= 0 &&
23
+ descriptorArg <= descriptorMaximum;
24
+ export const assertSystemdDescriptor = (descriptorArg) => {
25
+ if (!isSystemdDescriptor(descriptorArg)) {
26
+ throw new SystemdRuntimeError('descriptor_invalid', `${String(descriptorArg)} is not a descriptor number.`);
27
+ }
28
+ };
29
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3lzdGVtZHJ1bnRpbWUudmFsaWRhdGlvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzX3N5c3RlbWRydW50aW1lL3N5c3RlbWRydW50aW1lLnZhbGlkYXRpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0sa0NBQWtDLENBQUM7QUFDdkUsT0FBTyxFQUFFLHlCQUF5QixFQUFFLE1BQU0sMEJBQTBCLENBQUM7QUFFckUsaUVBQWlFO0FBQ2pFLE1BQU0sQ0FBQyxNQUFNLDJCQUEyQixHQUFHLEdBQUcsQ0FBQztBQUUvQzs7OztHQUlHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sdUJBQXVCLEdBQUcsQ0FBQyxPQUFnQixFQUFFLFdBQW9CLEVBQXFCLEVBQUUsQ0FDbkcsT0FBTyxPQUFPLEtBQUssUUFBUSxJQUFJLE9BQU8sQ0FBQyxNQUFNLElBQUksMkJBQTJCO0lBQzVFLENBQUMsQ0FBQyxXQUFXLElBQUksT0FBTyxDQUFDLE1BQU0sR0FBRyxDQUFDLENBQUMsSUFBSSx5QkFBeUIsQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUM7QUFFbEYsK0ZBQStGO0FBQy9GLE1BQU0sQ0FBQyxNQUFNLDRCQUE0QixHQUFHLENBQUMsVUFBbUIsRUFBd0IsRUFBRSxDQUN4RixPQUFPLFVBQVUsS0FBSyxRQUFRLElBQUksU0FBUyxDQUFDLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxRQUFRLENBQUMsSUFBSSxDQUFDO0lBQzFGLE1BQU0sQ0FBQyxVQUFVLENBQUMsVUFBVSxFQUFFLE1BQU0sQ0FBQyxJQUFJLHlCQUF5QixDQUFDO0FBRXJFLE1BQU0sQ0FBQyxNQUFNLGdDQUFnQyxHQUFHLENBQUMsVUFBa0IsRUFBUSxFQUFFO0lBQzNFLElBQUksQ0FBQyw0QkFBNEIsQ0FBQyxVQUFVLENBQUMsRUFBRSxDQUFDO1FBQzlDLE1BQU0sSUFBSSxtQkFBbUIsQ0FDM0IsdUJBQXVCLEVBQ3ZCLDhHQUE4RyxDQUMvRyxDQUFDO0lBQ0osQ0FBQztBQUNILENBQUMsQ0FBQztBQUVGLHlFQUF5RTtBQUN6RSxNQUFNLGlCQUFpQixHQUFHLGFBQWEsQ0FBQztBQUV4QyxNQUFNLENBQUMsTUFBTSxtQkFBbUIsR0FBRyxDQUFDLGFBQXNCLEVBQTJCLEVBQUUsQ0FDckYsT0FBTyxhQUFhLEtBQUssUUFBUSxJQUFJLE1BQU0sQ0FBQyxhQUFhLENBQUMsYUFBYSxDQUFDLElBQUksYUFBYSxJQUFJLENBQUM7SUFDOUYsYUFBYSxJQUFJLGlCQUFpQixDQUFDO0FBRXJDLE1BQU0sQ0FBQyxNQUFNLHVCQUF1QixHQUFHLENBQUMsYUFBcUIsRUFBUSxFQUFFO0lBQ3JFLElBQUksQ0FBQyxtQkFBbUIsQ0FBQyxhQUFhLENBQUMsRUFBRSxDQUFDO1FBQ3hDLE1BQU0sSUFBSSxtQkFBbUIsQ0FBQyxvQkFBb0IsRUFBRSxHQUFHLE1BQU0sQ0FBQyxhQUFhLENBQUMsOEJBQThCLENBQUMsQ0FBQztJQUM5RyxDQUFDO0FBQ0gsQ0FBQyxDQUFDIn0=
package/package.json CHANGED
@@ -1,10 +1,21 @@
1
1
  {
2
2
  "name": "@push.rocks/smartdaemon",
3
- "version": "2.20.1",
3
+ "version": "2.22.0",
4
4
  "private": false,
5
5
  "description": "Start scripts as long running daemons and manage them.",
6
6
  "main": "dist_ts/index.js",
7
7
  "typings": "dist_ts/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist_ts/index.d.ts",
11
+ "default": "./dist_ts/index.js"
12
+ },
13
+ "./systemd-runtime": {
14
+ "types": "./dist_ts_systemdruntime/index.d.ts",
15
+ "default": "./dist_ts_systemdruntime/index.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
8
19
  "type": "module",
9
20
  "author": "Task Venture Capital GmbH <hello@task.vc>",
10
21
  "license": "MIT",
@@ -26,10 +37,10 @@
26
37
  },
27
38
  "homepage": "https://code.foss.global/push.rocks/smartdaemon",
28
39
  "devDependencies": {
29
- "@git.zone/tsbuild": "^5.1.2",
30
- "@git.zone/tsrun": "^3.0.2",
31
- "@git.zone/tstest": "^7.1.0",
32
- "@types/node": "^26.6.3"
40
+ "@git.zone/tsbuild": "^6.0.2",
41
+ "@git.zone/tsrun": "^3.0.3",
42
+ "@git.zone/tstest": "^7.2.1",
43
+ "@types/node": "^26.6.4"
33
44
  },
34
45
  "dependencies": {
35
46
  "@push.rocks/lik": "^6.4.4",
@@ -41,6 +52,7 @@
41
52
  },
42
53
  "files": [
43
54
  "ts/**/*",
55
+ "ts_systemdruntime/**/*",
44
56
  "ts_web/**/*",
45
57
  "dist/**/*",
46
58
  "dist_*/**/*",
@@ -58,7 +70,7 @@
58
70
  ],
59
71
  "scripts": {
60
72
  "test": "tstest test/ --verbose --timeout 60",
61
- "build": "tsbuild --web",
73
+ "build": "tsbuild tsfolders --web",
62
74
  "format": "gitzone format",
63
75
  "buildDocs": "tsdoc"
64
76
  }
package/readme.md CHANGED
@@ -338,6 +338,12 @@ installation scope, after any required offline commissioning. A failed or timed
338
338
  out command can leave partial enablement; inspect and retry the exact operation
339
339
  without assuming cancellation or undoing the application's retained protection.
340
340
 
341
+ `disable()` is its counterpart: it removes the enablement links the unit's `[Install]`
342
+ section names (`systemctl disable`) and returns a fresh snapshot. It does not stop a running
343
+ unit, mask, unmask or remove the definition, so a disabled service keeps running until it is
344
+ stopped. A failed or timed out command can leave partial enablement as well; inspect and retry.
345
+ Disable a unit before `SystemdUnitFile.uninstall()` removes its definition.
346
+
341
347
  `inspectRelationships()` returns immutable sorted arrays for `before`, `after`,
342
348
  `requires`, `wants`, `conflicts`, `requiredBy` and `wantedBy` from the loaded manager
343
349
  graph. Literal systemd hex escapes are preserved. The bounded snapshot rejects
@@ -391,7 +397,7 @@ try {
391
397
  explicit restart policy, kill mode and stop timeout. System-scope definitions require
392
398
  `user` and `group`; user-scope definitions reject both directives because the user
393
399
  manager already runs under the calling identity. It defaults to `Type=exec`,
394
- journal output and null stdin. It introduces no shell; percent specifiers and
400
+ journal output (see `standardOutput` and `standardError` below) and null stdin. It introduces no shell; percent specifiers and
395
401
  argument environment expansion are escaped. Paths must be absolute and normalized.
396
402
  Descriptions, paths and arguments reject control characters, lone surrogates and Unicode
397
403
  noncharacters (U+FDD0–U+FDEF and every code point ending in FFFE or FFFF), which systemd
@@ -464,6 +470,7 @@ A user-scope `SystemdUnitFile` validates each value where it is read:
464
470
  calls need, plus a well-formed config root (`XDG_CONFIG_HOME`, or `~/.config`), and
465
471
  rejects anything missing or malformed with `invalid_options` before touching any
466
472
  directory or running `systemctl`.
473
+ - `uninstall()` needs the same and refuses the same way, before removing anything.
467
474
 
468
475
  An owner can therefore ask whether its definition is installed from a context without
469
476
  a login session:
@@ -631,6 +638,41 @@ and `%` doubled, so systemd applies no specifier; `Environment=` never expands `
631
638
  taken literally. An empty object renders nothing. Systemd merges these variables over its own
632
639
  (`HOME`, `USER`, `INVOCATION_ID`, …), so a name such as `NOTIFY_SOCKET` overrides the manager's value.
633
640
 
641
+ `standardOutput` and `standardError` say where the service's output streams go
642
+ (`StandardOutput=`, `StandardError=`): `'journal'`, the default, `'null'`, which discards the
643
+ stream, or `{ append: path }`, which appends it to a file (`append:`, systemd 240 and later).
644
+ Omitted, both render `journal` as before, so existing definitions keep their bytes.
645
+
646
+ ```typescript
647
+ const controller = new SystemdServiceDefinition({
648
+ scope: 'user',
649
+ unitName: 'app-controller.service',
650
+ description: 'App controller',
651
+ executable: '/home/owner/.local/share/pnpm/app',
652
+ args: ['serve'],
653
+ workingDirectory: '/home/owner',
654
+ restart: 'on-failure',
655
+ killMode: 'process',
656
+ timeoutStopSeconds: 90,
657
+ standardOutput: 'null',
658
+ standardError: { append: '/home/owner/.config/app/logs/controller.log' },
659
+ });
660
+ // StandardOutput=null
661
+ // StandardError=append:/home/owner/.config/app/logs/controller.log
662
+ ```
663
+
664
+ The path must be absolute and normalized, without a backslash, surrounding whitespace or a
665
+ trailing slash; `%` is doubled, so systemd applies no specifier. The service manager opens the file
666
+ before it invokes the executable and before it switches to the service's `User=`, so with its own
667
+ privileges: as root under the system manager, as the user under a user manager. It creates a missing
668
+ file owned by that user with mode 0666 masked by the unit's umask (0644 under the usual umask 0022).
669
+ Its directory must already exist, or the service fails to start. A caller that needs another owner
670
+ or a private file creates it with the owner and mode it wants first; an existing file keeps both.
671
+ Systemd reads `append:` from version 240 and an older manager would ignore the line, so a definition
672
+ with an `append` output has `requiredManagerMajor` 240 (254 with `fileDescriptorStorePreserve` as
673
+ well) and `SystemdUnitFile.install()` refuses it on an older manager with `manager_too_old`. The manager reports only the kind of output (`append`) as a property, not the path, so
674
+ `inspectServiceSettings()` does not report it: verify an installed definition by its digest.
675
+
634
676
  #### Notify access and the file descriptor store
635
677
 
636
678
  A service that keeps a descriptor across its own restarts, such as a network namespace a child
@@ -654,8 +696,9 @@ empties the store; a `systemctl soft-reboot` keeps the running kernel, so a pres
654
696
  descriptors in it pass on to the next userspace boot. Systemd reads `FileDescriptorStorePreserve=` from version 254
655
697
  and an older manager would ignore the line, so a definition with `fileDescriptorStorePreserve` has
656
698
  `requiredManagerMajor` 254 and `SystemdUnitFile.install()` refuses it on an older manager with
657
- `SystemdUnitManagerTooOldError` (`manager_too_old`) before touching anything; every other definition
658
- has `requiredManagerMajor` null. Sending `FDSTORE=1` needs an `AF_UNIX` socket, so a definition
699
+ `SystemdUnitManagerTooOldError` (`manager_too_old`) before touching anything. `requiredManagerMajor` is
700
+ the highest major any rendered line needs (240 for an `append` output, see above); a definition with
701
+ neither has it null. Sending `FDSTORE=1` needs an `AF_UNIX` socket, so a definition
659
702
  with a store or a notify access other than `'none'` that restricts its socket families must allow
660
703
  `AF_UNIX`. Omitted, each option renders nothing.
661
704
 
@@ -910,6 +953,42 @@ user scope requires `expectedUid` to match the caller's actual uid. The manager'
910
953
  selected fragment must match the installed file after reload, so an unrecognized
911
954
  custom user unit path fails closed.
912
955
 
956
+ #### Removing an installed definition
957
+
958
+ `uninstall(expectedSha256)` removes what `install()` wrote: the unit file, when it holds exactly
959
+ the bytes `expectedSha256` digests, and every previous definition retained beside it
960
+ (`<unit>.previous-<sha256>`), then reloads the manager and verifies its view.
961
+
962
+ ```typescript
963
+ const unit = new SystemdUnit({ scope: 'user', unitName: 'authswitch.service' });
964
+ const userFile = new SystemdUnitFile({ scope: 'user', unitName: 'authswitch.service' });
965
+ await unit.stop();
966
+ await unit.disable();
967
+ const installed = await userFile.inspect();
968
+ if (installed) {
969
+ const result = await userFile.uninstall(installed.sha256);
970
+ console.log(result.removed, result.masked, result.removedPaths);
971
+ }
972
+ ```
973
+
974
+ It admits the unit only at rest (`systemdUnitQuiescence()`) with no job queued, no pending reload
975
+ and no drop-ins, while the manager still selects this file, and refuses with the same
976
+ `SystemdUnitFileUnsafeUnitError` reasons as installation. Removal admits fewer unit-file states: a
977
+ loaded unit must be `disabled`, so no enablement link outlives its definition (an enabled one is
978
+ refused with `unexpected_file_state`; disable it first), a masked unit keeps its mask, and a unit the
979
+ manager does not find is admitted. A stopped service whose `killMode: 'process'` left processes
980
+ running still has a control group and is refused with `active` until they end.
981
+
982
+ Every file is checked as installation checks the unit file (regular, single link, mode 0644, owned
983
+ by the expected uid) and is removed only while its name still holds the file that was checked; a
984
+ retained definition whose bytes do not match the digest in its name, or a unit file with other bytes
985
+ than expected, refuses the removal with `changed` before anything is removed. After removal the
986
+ manager must report the unit not found (still masked, for a masked unit) and at rest, or the call
987
+ rejects with `reload_failed`, the files already removed and the cause attached. With nothing to
988
+ remove the call resolves `{ removed: false, … }` without reloading the manager. `removed` reports
989
+ whether the unit file itself was removed and `removedPaths` every file that was. Removal never
990
+ stops, disables or unmasks the unit.
991
+
913
992
  With `killMode: 'mixed'`, the main process owns graceful child shutdown and must
914
993
  remain alive until its children have joined. Systemd sends SIGTERM to the main
915
994
  process first and can send SIGKILL to the whole cgroup when the main process exits
@@ -919,6 +998,106 @@ other process in the unit survives, so the main process must own their shutdown
919
998
  deliberate retention (as with `Delegate=yes` workloads). A successful systemd start still requires application readiness
920
999
  verification, and a completed stop alone does not prove database drain.
921
1000
 
1001
+ ### The service side: notifications and passed descriptors (`/systemd-runtime`, Node.js 26)
1002
+
1003
+ The definitions above tell the manager what to expect; a Node.js main process answers it through
1004
+ the `@push.rocks/smartdaemon/systemd-runtime` subpath: `sd_notify(3)` messages (with descriptors
1005
+ for the store) and the descriptors the manager passes (`LISTEN_FDS`). Node.js has no `AF_UNIX`
1006
+ datagram socket and no `fcntl(2)`, so the subpath calls glibc through `node:ffi`:
1007
+
1008
+ - It requires **Node.js 26 or later**. Importing it on an older Node.js throws
1009
+ `SystemdRuntimeError` with code `runtime_unsupported`; the main entry is unaffected and keeps
1010
+ its Node.js range. The package is ES modules: CommonJS can `require()` either entry only on a
1011
+ Node.js that loads ES modules through `require()` by default (20.19+, 22.12+, 23+), where the
1012
+ subpath throws the same `runtime_unsupported` below 26; an older Node.js throws `ERR_REQUIRE_ESM`.
1013
+ - It runs on Linux x64 and arm64 with glibc (`libc.so.6`). Elsewhere, and with `node:ffi`
1014
+ unavailable (`--no-experimental-ffi`) or denied by the permission model, the first call that
1015
+ needs libc throws `runtime_unsupported`. Under the permission model pass `--allow-ffi`.
1016
+ - `node:ffi` is experimental: loading it prints one `ExperimentalWarning`. A single executable
1017
+ application suppresses it through its `execArgv` (`--disable-warning=ExperimentalWarning`).
1018
+ - libc is opened on first use. Outside systemd nothing is loaded: without `NOTIFY_SOCKET`
1019
+ `SystemdNotifier.fromEnvironment()` returns null, and without `LISTEN_*` variables
1020
+ `takeSystemdListenDescriptors()` returns an empty list.
1021
+
1022
+ ```typescript
1023
+ import {
1024
+ SystemdNotifier,
1025
+ takeSystemdListenDescriptors,
1026
+ watchdogIntervalUsec,
1027
+ } from '@push.rocks/smartdaemon/systemd-runtime';
1028
+
1029
+ // Descriptors passed for this process, numbered from 3, already close-on-exec.
1030
+ const passed = takeSystemdListenDescriptors(); // [{ descriptor: 3, name: 'pallet-router' }]
1031
+ const namespaceDescriptor = passed.find(({ name }) => name === 'pallet-router')?.descriptor ?? createNamespace();
1032
+
1033
+ // null when no service manager listens; the caller decides what that means.
1034
+ const notifier = SystemdNotifier.fromEnvironment({ take: true });
1035
+ if (notifier) {
1036
+ notifier.extendTimeout(60_000_000, 'replaying journal');
1037
+ notifier.replaceDescriptors('pallet-router', [namespaceDescriptor], { poll: false });
1038
+ notifier.ready('serving');
1039
+ const interval = watchdogIntervalUsec();
1040
+ if (interval !== null) setInterval(() => notifier.watchdog(), interval / 2000);
1041
+ }
1042
+ ```
1043
+
1044
+ | Call | Sends or does |
1045
+ | --- | --- |
1046
+ | `ready(status?)` | `READY=1` (and `STATUS=`) |
1047
+ | `status(text)` | `STATUS=` |
1048
+ | `stopping(status?)` | `STOPPING=1` |
1049
+ | `reloading(status?)` | `RELOADING=1` with `MONOTONIC_USEC=` (`CLOCK_MONOTONIC`); send `ready()` when done |
1050
+ | `watchdog()` | `WATCHDOG=1` |
1051
+ | `extendTimeout(usec, status?)` | `EXTEND_TIMEOUT_USEC=` |
1052
+ | `storeDescriptors(name, fds, { poll? })` | `FDSTORE=1`, `FDNAME=`, `FDPOLL=0` for `poll: false`; descriptors as `SCM_RIGHTS` |
1053
+ | `removeDescriptors(name)` | `FDSTOREREMOVE=1`, `FDNAME=` |
1054
+ | `replaceDescriptors(name, fds, { poll? })` | remove, then store: two messages, handled in order |
1055
+ | `send(fields, fds?)` | any `NAME=value` fields in insertion order |
1056
+ | `takeSystemdNotifySocket()` | removes `NOTIFY_SOCKET` from the environment and returns it validated, or null |
1057
+ | `takeSystemdListenDescriptors()` | removes `LISTEN_PID`/`LISTEN_FDS`/`LISTEN_FDNAMES`; for this process marks every passed descriptor close-on-exec and returns it with its name (`unknown` without names) |
1058
+ | `setCloseOnExec(fd)` | `fcntl(F_SETFD, FD_CLOEXEC)`, verified |
1059
+ | `watchdogIntervalUsec()` | `WATCHDOG_USEC` for this process (`WATCHDOG_PID`), or null |
1060
+
1061
+ Every call is synchronous and either completes or throws `SystemdRuntimeError` (`code`, and
1062
+ `errno` when a libc call failed); nothing is dropped silently. systemd ignores a message it cannot
1063
+ parse without telling the sender, so the notifier refuses it first: field names are upper-case,
1064
+ values carry no newline or NUL, a message is at most 4096 bytes (`notify_field_invalid`); a
1065
+ descriptor name is 1–255 printable ASCII characters without `:` (`descriptor_name_invalid`); one
1066
+ message carries 1–253 descriptors (`descriptor_invalid`). `NOTIFY_SOCKET` must be an absolute
1067
+ path or an abstract name (`@name`) of at most 107 bytes; anything else, `vsock:` addresses
1068
+ included, is `notify_socket_invalid`. A send the kernel refuses is `notify_failed` with its errno
1069
+ (`ENOENT` for a missing path, `ECONNREFUSED` for an abstract name nobody holds). `LISTEN_*`
1070
+ variables naming another process are removed and nothing else is touched; a partial or malformed
1071
+ set (`LISTEN_FDS` not a positive integer, a names list of another length) is
1072
+ `listen_environment_invalid` before any descriptor is touched, and a passed descriptor that is not
1073
+ open is `close_on_exec_failed`.
1074
+
1075
+ `LISTEN_FDS` is also bounded by the process's hard `RLIMIT_NOFILE` before any descriptor is touched:
1076
+ a count whose last descriptor (`2 + count`) is at or above that limit is
1077
+ `listen_environment_invalid`. No descriptor systemd passed can lie at or above the hard limit,
1078
+ because it moves them into place with `fcntl(F_DUPFD)` (which refuses a number at or above the soft
1079
+ limit) after applying the unit's limits; a wrapper that lowers its hard limit below a passed
1080
+ descriptor before starting Node.js is refused. The hard limit can be as large as `fs.nr_open`
1081
+ (`LimitNOFILE=infinity`), so nothing is allocated per counted descriptor up front: like
1082
+ `sd_listen_fds(3)`, the descriptors are marked one by one from 3, the first that is not open throws
1083
+ `close_on_exec_failed` (the ones before it keep the flag), and the list grows only with open
1084
+ descriptors.
1085
+
1086
+ Sends are blocking `sendmsg(2)` calls on a fresh `AF_UNIX` datagram socket, the same as
1087
+ `sd_notify(3)`: when the manager's receive queue is full, the call waits for room and the event loop
1088
+ stalls for that time.
1089
+
1090
+ `node:ffi` has no errno API: the subpath reads glibc's thread-local `errno` through
1091
+ `__errno_location()` immediately after the failed call. Node.js itself calls libuv's
1092
+ `uv_disable_stdio_inheritance()` at startup (`InitializeNodeWithArgsInternal()` in `src/node.cc`;
1093
+ `deps/uv/src/unix/core.c`, Node.js 25.9.0 and 26.11.1), which marks descriptors 0–15 close-on-exec
1094
+ and continues past 15 until the first descriptor that is not open. The subpath still sets and
1095
+ verifies the flag on every passed descriptor, as `sd_listen_fds(3)` does.
1096
+
1097
+ `test/test.systemdruntimeunit.node.ts` proves the subpath under a real `Type=notify` unit of the
1098
+ caller's user manager (`systemd-run --user`), and skips with the reason without Node.js 26 or a
1099
+ user manager.
1100
+
922
1101
  ### Starting a program in its own transient scope
923
1102
 
924
1103
  A service that launches a long-lived program keeps that program in its own
@@ -1172,7 +1351,11 @@ console.log(template); // View generated systemd unit file
1172
1351
  - **systemd 254 or later** for `SystemdUnit.inspectServiceSettings()`, `SystemdUnit.cleanFileDescriptorStore()`,
1173
1352
  `runInTransientScope()` and for installing a definition with `fileDescriptorStorePreserve` (Debian 13,
1174
1353
  Ubuntu 24.04, Fedora 39 and later ship it); an older manager is refused by name with `manager_too_old`
1354
+ - **systemd 240 or later** for installing a definition with an `append` output (`standardOutput` or
1355
+ `standardError`), refused the same way
1175
1356
  - **Node.js**: Version 14.x or higher
1357
+ - **Node.js 26 or later** for the `@push.rocks/smartdaemon/systemd-runtime` subpath (`node:ffi`), on Linux
1358
+ x64 or arm64 with glibc
1176
1359
  - **Permissions**: Either root access or properly configured sudo
1177
1360
 
1178
1361
  ## ⚙️ Generated Systemd Service Files
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartdaemon',
6
- version: '2.20.1',
6
+ version: '2.22.0',
7
7
  description: 'Start scripts as long running daemons and manage them.'
8
8
  }
@@ -1,11 +1,12 @@
1
1
  import * as plugins from './smartdaemon.plugins.js';
2
2
  import { environmentByteLimit, environmentVariableLimit, isCleanText, renderServiceRelations, systemdAddressFamilies,
3
3
  systemdCapabilities, type TSystemdServiceStartup, type ISystemdServiceDependencies, type ISystemdServiceInstallTargets,
4
- type ISystemdServiceCredential, type TSystemdCapability, type TSystemdAddressFamily } from './systemd.definition.js';
5
- import { fileDescriptorStorePreserveMajor } from './systemd.managerversion.js';
4
+ type ISystemdServiceCredential, type TSystemdCapability, type TSystemdAddressFamily,
5
+ type TSystemdServiceOutput } from './systemd.definition.js';
6
+ import { fileDescriptorStorePreserveMajor, outputAppendMajor } from './systemd.managerversion.js';
6
7
  export type { ISystemdServiceStartup, ISystemdServiceNotifyStartup, TSystemdServiceStartup,
7
8
  ISystemdServiceDependencies, ISystemdServiceInstallTargets, ISystemdServiceCredential, TSystemdCapability,
8
- TSystemdAddressFamily } from './systemd.definition.js';
9
+ TSystemdAddressFamily, TSystemdServiceOutput } from './systemd.definition.js';
9
10
  export { systemdCapabilities, systemdAddressFamilies } from './systemd.definition.js';
10
11
 
11
12
  interface ISystemdServiceDefinitionBaseOptions {
@@ -78,6 +79,17 @@ interface ISystemdServiceDefinitionBaseOptions {
78
79
  * 64 variables and 8192 UTF-8 bytes of `NAME=value` in total. They are taken literally: `%` is escaped, and `Environment=` never expands `$`.
79
80
  */
80
81
  environment?: Readonly<Record<string, string>>;
82
+ /**
83
+ * Where the service's standard output goes (`StandardOutput=`): `'journal'` (the default),
84
+ * `'null'`, or `{ append: path }`, an absolute normalized path without a backslash, surrounding
85
+ * whitespace or trailing slash, whose file systemd appends to (`append:`, systemd 240 and later).
86
+ * An `append` output sets `requiredManagerMajor` to at least 240, so `SystemdUnitFile.install()`
87
+ * refuses an older manager, which would ignore the line. Omitted, the unit renders
88
+ * `StandardOutput=journal` as before.
89
+ */
90
+ standardOutput?: TSystemdServiceOutput;
91
+ /** Where the service's standard error goes (`StandardError=`), as `standardOutput`; omitted, `journal`. */
92
+ standardError?: TSystemdServiceOutput;
81
93
  /**
82
94
  * System scope only. Runs the service as a transient user and group allocated at start
83
95
  * (`DynamicUser=`), named by `user` and `group`, which then must not be `root` and have at most
@@ -177,8 +189,9 @@ export class SystemdServiceDefinition {
177
189
  public readonly sha256: string;
178
190
  /**
179
191
  * The oldest systemd major whose manager reads every directive this definition renders, when it
180
- * renders one an older manager would ignore: 254 with `fileDescriptorStorePreserve`; otherwise
181
- * null. `SystemdUnitFile.install()` refuses a definition on an older manager.
192
+ * renders one an older manager would ignore: the highest of 254 with `fileDescriptorStorePreserve`
193
+ * and 240 with an `append` output; otherwise null. `SystemdUnitFile.install()` refuses a
194
+ * definition on an older manager.
182
195
  */
183
196
  public readonly requiredManagerMajor: number | null;
184
197
 
@@ -217,7 +230,8 @@ export class SystemdServiceDefinition {
217
230
  const optionalKeys: TSystemdServiceDefinitionOption[] = ['scope', 'startup', 'dependencies', 'install',
218
231
  'restartSeconds', 'restartPreventExitStatus', 'successExitStatus', 'startLimitIntervalSeconds',
219
232
  'startLimitBurst', 'delegate', 'limitNoFile', 'tasksMax', 'oomScoreAdjust', 'notifyAccess',
220
- 'fileDescriptorStoreMax', 'fileDescriptorStorePreserve', 'environment', ...systemOnlyOptions];
233
+ 'fileDescriptorStoreMax', 'fileDescriptorStorePreserve', 'environment', 'standardOutput', 'standardError',
234
+ ...systemOnlyOptions];
221
235
  // Every own key is a known option held by an enumerable data property, so no accessor or trap runs.
222
236
  for (const key of Reflect.ownKeys(options)) {
223
237
  requireValue(typeof key === 'string' && [...keys, ...optionalKeys, 'user', 'group'].includes(key as never), 'options');
@@ -334,6 +348,25 @@ export class SystemdServiceDefinition {
334
348
  service.push(...[...assignments.keys()].sort()
335
349
  .map((name) => `Environment=${quote(assignments.get(name)!)}`));
336
350
  }
351
+ /**
352
+ * An output stream's directive value, read once through the object's own data descriptor. An
353
+ * `append:` path is taken raw after specifier expansion, so only `%` is escaped.
354
+ */
355
+ const output = (value: unknown, option: 'standardOutput' | 'standardError'): string => {
356
+ if (value === undefined || value === 'journal') return 'journal';
357
+ if (value === 'null') return 'null';
358
+ plainObject(value, option);
359
+ const entryKeys = Reflect.ownKeys(value as object);
360
+ const descriptor = Object.getOwnPropertyDescriptor(value, 'append');
361
+ requireValue(entryKeys.length === 1 && entryKeys[0] === 'append' && descriptor?.enumerable &&
362
+ 'value' in descriptor, option);
363
+ const file: unknown = descriptor!.value;
364
+ requireValue(absolute(file) && !file.endsWith('/') && file.trim() === file &&
365
+ !file.includes('\\'), option);
366
+ return `append:${(file as string).replaceAll('%', '%%')}`;
367
+ };
368
+ const standardOutput = output(options.standardOutput, 'standardOutput');
369
+ const standardError = output(options.standardError, 'standardError');
337
370
  // Every system-only option is refused by name in a user manager's service.
338
371
  for (const option of systemOnlyOptions) {
339
372
  requireValue(scope === 'system' || options[option] === undefined, option);
@@ -508,8 +541,8 @@ export class SystemdServiceDefinition {
508
541
  `TimeoutStopSec=${options.timeoutStopSeconds}s`,
509
542
  ...service,
510
543
  'StandardInput=null',
511
- 'StandardOutput=journal',
512
- 'StandardError=journal',
544
+ `StandardOutput=${standardOutput}`,
545
+ `StandardError=${standardError}`,
513
546
  '',
514
547
  '[Install]',
515
548
  ...relations.install,
@@ -517,8 +550,12 @@ export class SystemdServiceDefinition {
517
550
  ].join('\n');
518
551
  requireValue(Buffer.byteLength(this.content) <= 16384, 'options');
519
552
  this.sha256 = plugins.crypto.createHash('sha256').update(this.content).digest('hex');
520
- this.requiredManagerMajor = options.fileDescriptorStorePreserve !== undefined
521
- ? fileDescriptorStorePreserveMajor : null;
553
+ // An older manager ignores a directive it does not read, so the newest one any line needs counts.
554
+ const requiredMajors = [
555
+ ...(options.fileDescriptorStorePreserve !== undefined ? [fileDescriptorStorePreserveMajor] : []),
556
+ ...([standardOutput, standardError].some((value) => value.startsWith('append:')) ? [outputAppendMajor] : []),
557
+ ];
558
+ this.requiredManagerMajor = requiredMajors.length > 0 ? Math.max(...requiredMajors) : null;
522
559
  Object.freeze(this);
523
560
  }
524
561
  }
@@ -539,6 +539,17 @@ export class SystemdUnit {
539
539
  return this.inspect();
540
540
  }
541
541
 
542
+ /**
543
+ * Persistently disables the unit: removes the enablement links its `[Install]` section names
544
+ * (`systemctl disable`, which reloads the manager), then inspects. Never stops, masks, unmasks or
545
+ * removes the definition; a running unit keeps running. A failed or timed out command can leave
546
+ * partial enablement: inspect and retry the exact operation.
547
+ */
548
+ public async disable(): Promise<ISystemdUnitState> {
549
+ await this.command(['disable', this.unitName]);
550
+ return this.inspect();
551
+ }
552
+
542
553
  /** Loaded manager graph, including reverse edges. Enablement alone does not prove every required link. */
543
554
  public async inspectRelationships(): Promise<ISystemdUnitRelationships> {
544
555
  const keys = ['Id', 'Before', 'After', 'Requires', 'Wants', 'Conflicts', 'RequiredBy', 'WantedBy'];