@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
@@ -157,15 +157,27 @@ const admittedFileStates: Readonly<Record<string, readonly string[]>> = {
157
157
  masked: ['masked', 'masked-runtime'],
158
158
  'not-found': [''],
159
159
  };
160
- /** Admits a quiet unit whose load and unit-file states installation may write behind. */
161
- const requireAdmittedState = (state: ISystemdUnitState): void => {
160
+ /**
161
+ * The unit-file states removal admits: a loaded unit only when disabled, so no enablement link is
162
+ * left naming a definition that no longer exists.
163
+ */
164
+ const removableFileStates: Readonly<Record<string, readonly string[]>> = {
165
+ loaded: ['disabled'],
166
+ masked: ['masked', 'masked-runtime'],
167
+ 'not-found': [''],
168
+ };
169
+ /** Admits a quiet unit whose load and unit-file states the operation may write behind. */
170
+ const requireAdmittedState = (
171
+ state: ISystemdUnitState,
172
+ fileStates: Readonly<Record<string, readonly string[]>>,
173
+ ): void => {
162
174
  const reason: TSystemdUnitFileUnsafeUnitReason | null = !idle(state)
163
175
  ? 'active'
164
176
  : state.job !== ''
165
177
  ? 'job_pending'
166
- : !Object.hasOwn(admittedFileStates, state.loadState)
178
+ : !Object.hasOwn(fileStates, state.loadState)
167
179
  ? 'unexpected_load_state'
168
- : !admittedFileStates[state.loadState].includes(state.unitFileState)
180
+ : !fileStates[state.loadState].includes(state.unitFileState)
169
181
  ? 'unexpected_file_state'
170
182
  : null;
171
183
  if (reason) throw new SystemdUnitFileUnsafeUnitError(reason, state, null);
@@ -310,7 +322,7 @@ export class SystemdUnitFile {
310
322
  let temporaryInfo: plugins.Stats | undefined;
311
323
  try {
312
324
  const before = await this.unit.inspect();
313
- const beforeConfiguration = await this.admit(before, maskDirectories);
325
+ const beforeConfiguration = await this.admit(before, maskDirectories, admittedFileStates);
314
326
  await this.directories(true);
315
327
  const parent = await plugins.fs.lstat(this.directory);
316
328
  const previous = await this.read(this.path);
@@ -345,7 +357,7 @@ export class SystemdUnitFile {
345
357
  );
346
358
  const rechecked = await this.unit.inspect();
347
359
  requireValue(JSON.stringify(rechecked) === JSON.stringify(before), 'changed');
348
- const recheckedConfiguration = await this.admit(rechecked, maskDirectories);
360
+ const recheckedConfiguration = await this.admit(rechecked, maskDirectories, admittedFileStates);
349
361
  requireValue(
350
362
  recheckedConfiguration.fragmentPath === beforeConfiguration.fragmentPath,
351
363
  'changed',
@@ -395,11 +407,116 @@ export class SystemdUnitFile {
395
407
  }
396
408
  }
397
409
 
410
+ /**
411
+ * Removes the definition `install()` wrote and the previous definitions it retained beside it
412
+ * (`<unit>.previous-<sha256>`), then reloads the manager. The expected SHA-256 of the installed
413
+ * definition is mandatory, so only the bytes the caller installed are removed; an absent unit
414
+ * file with no retained definition is idempotent (`removed: false`) and reloads nothing.
415
+ *
416
+ * Admits only a unit at rest with no job queued, no pending reload and no drop-ins, whose
417
+ * manager view still selects this file: a loaded unit must be disabled first
418
+ * (`SystemdUnit.disable()`), so no enablement link outlives its definition; a masked unit keeps
419
+ * its mask; a unit the manager does not find is admitted. Refusals are the `unsafe_unit` reasons
420
+ * of `install()`, `unexpected_file_state` for an enabled unit among them. Every file is checked
421
+ * like the installed one (regular, single link, mode 0644, owned by the expected uid) and each
422
+ * retained definition must hold the bytes its name digests, or nothing is removed (`changed`).
423
+ * After removal the manager must report the unit not found (or still masked) and at rest;
424
+ * otherwise `reload_failed`, with the files already removed. Never stops, disables or unmasks.
425
+ */
426
+ public async uninstall(
427
+ expectedSha256: string,
428
+ ): Promise<Readonly<{ removed: boolean; masked: boolean; removedPaths: readonly string[] }>> {
429
+ requireValue(typeof expectedSha256 === 'string' && /^[a-f0-9]{64}$/.test(expectedSha256),
430
+ 'invalid_options');
431
+ const maskDirectories = this.maskDirectories();
432
+ if (maskDirectories === null) throw new SystemdUnitFileError('invalid_options');
433
+ requireValue(!this.busy, 'busy');
434
+ this.busy = true;
435
+ try {
436
+ const before = await this.unit.inspect();
437
+ const beforeConfiguration = await this.admit(before, maskDirectories, removableFileStates);
438
+ const unchanged = Object.freeze({ removed: false, masked: masked(before), removedPaths: Object.freeze([]) });
439
+ // A loaded unit selects this file, so its absence is drift, not an earlier removal.
440
+ if (!await this.directories(false)) {
441
+ requireValue(before.loadState !== 'loaded', 'changed');
442
+ return unchanged;
443
+ }
444
+ const parent = await plugins.fs.lstat(this.directory);
445
+ const current = await this.read(this.path);
446
+ if (current) requireValue(digest(current.bytes) === expectedSha256, 'changed');
447
+ else requireValue(before.loadState !== 'loaded', 'changed');
448
+ const targets = [
449
+ ...(current ? [{ path: this.path, info: current.info }] : []),
450
+ ...await this.retainedDefinitions(),
451
+ ];
452
+ if (targets.length === 0) return unchanged;
453
+ const rechecked = await this.unit.inspect();
454
+ requireValue(JSON.stringify(rechecked) === JSON.stringify(before), 'changed');
455
+ const recheckedConfiguration = await this.admit(rechecked, maskDirectories, removableFileStates);
456
+ requireValue(recheckedConfiguration.fragmentPath === beforeConfiguration.fragmentPath, 'changed');
457
+ requireSafePath(sameFile(parent, await plugins.fs.lstat(this.directory)), this.directory, 'replaced');
458
+ // Every name must still hold the file that was checked before the first is removed, so a
459
+ // refusal never leaves a partial removal.
460
+ for (const target of targets) {
461
+ requireValue(sameFile(target.info, await plugins.fs.lstat(target.path)), 'changed');
462
+ }
463
+ for (const target of targets) await plugins.fs.unlink(target.path);
464
+ await this.flushDirectory(parent);
465
+ const removedPaths = Object.freeze(targets.map((target) => target.path));
466
+ try {
467
+ const reloadedConfiguration = await this.unit.reloadConfiguration();
468
+ const after = await this.unit.inspect();
469
+ requireValue(
470
+ quiet(after) &&
471
+ (masked(before)
472
+ ? masked(after) && after.unitFileState === before.unitFileState
473
+ : after.loadState === 'not-found' && after.unitFileState === ''),
474
+ 'changed',
475
+ );
476
+ const configuration = await this.unit.inspectConfiguration();
477
+ const expectedFragment = masked(after) ? beforeConfiguration.fragmentPath : '';
478
+ for (const snapshot of [reloadedConfiguration, configuration]) {
479
+ requireAdmittedConfiguration(after, snapshot, snapshot.fragmentPath === expectedFragment);
480
+ }
481
+ requireValue(await this.read(this.path) === null, 'changed');
482
+ return Object.freeze({ removed: current !== null, masked: masked(after), removedPaths });
483
+ } catch (error) {
484
+ // The files are already removed; the cause says why the reload was not accepted.
485
+ throw new SystemdUnitFileError('reload_failed', { cause: error });
486
+ }
487
+ } catch (error) {
488
+ if (error instanceof SystemdUnitFileError) throw error;
489
+ throw new SystemdUnitFileError('write_failed');
490
+ } finally {
491
+ this.busy = false;
492
+ }
493
+ }
494
+
495
+ /**
496
+ * The previous definitions `install()` retained beside the unit, each checked like the unit file
497
+ * and holding exactly the bytes its name digests.
498
+ */
499
+ private async retainedDefinitions(): Promise<Array<{ path: string; info: plugins.Stats }>> {
500
+ const prefix = `${this.name}.previous-`;
501
+ const retained: Array<{ path: string; info: plugins.Stats }> = [];
502
+ for (const entry of (await plugins.fs.readdir(this.directory)).sort()) {
503
+ if (!entry.startsWith(prefix)) continue;
504
+ const sha256 = entry.slice(prefix.length);
505
+ if (!/^[a-f0-9]{64}$/.test(sha256)) continue;
506
+ const path = plugins.path.join(this.directory, entry);
507
+ const file = await this.read(path);
508
+ requireValue(file !== null && digest(file.bytes) === sha256, 'changed');
509
+ retained.push({ path, info: file!.info });
510
+ }
511
+ return retained;
512
+ }
513
+
398
514
  private async admit(
399
515
  state: ISystemdUnitState,
400
516
  maskDirectories: IMaskDirectories,
517
+ fileStates: Readonly<Record<string, readonly string[]>>,
401
518
  ): Promise<ISystemdUnitConfigurationState> {
402
- requireAdmittedState(state);
519
+ requireAdmittedState(state, fileStates);
403
520
  const configuration = await this.unit.inspectConfiguration();
404
521
  const maskPaths = (state.unitFileState === 'masked-runtime'
405
522
  ? maskDirectories.runtime : maskDirectories.persistent)
@@ -28,6 +28,18 @@ export interface ISystemdServiceDependencies {
28
28
  conflicts: readonly string[];
29
29
  }
30
30
 
31
+ /**
32
+ * Where one of the service's output streams goes (`StandardOutput=`, `StandardError=`): the
33
+ * journal, nowhere (`null`), or appended to a file (`append:`, systemd 240 and later). The
34
+ * service manager opens an `append` file with `O_APPEND` before it invokes the executable and
35
+ * before it drops to the service's `User=`, so with the manager's own privileges: root for the
36
+ * system manager, the user for a user manager. It creates a missing file owned by that user
37
+ * with mode 0666 masked by the unit's umask (0644 under the usual umask 0022); its directory must
38
+ * exist. A caller that needs another owner or a private mode creates the file first: an existing
39
+ * file keeps its owner and mode.
40
+ */
41
+ export type TSystemdServiceOutput = 'journal' | 'null' | { readonly append: string };
42
+
31
43
  /** Install metadata only; rendering and file installation do not enable these links. */
32
44
  export interface ISystemdServiceInstallTargets {
33
45
  wantedBy: readonly string[];
@@ -13,6 +13,9 @@ export interface ISystemdManagerVersion {
13
13
  /** Internal: the systemd major that first reads `FileDescriptorStorePreserve=` and reports it. */
14
14
  export const fileDescriptorStorePreserveMajor = 254;
15
15
 
16
+ /** Internal: the systemd major that first reads `append:` in `StandardOutput=` and `StandardError=`. */
17
+ export const outputAppendMajor = 240;
18
+
16
19
  /** Internal: the systemd major whose `systemctl clean` first releases a file descriptor store (`--what=fdstore`). */
17
20
  export const fileDescriptorStoreCleanMajor = 254;
18
21
 
@@ -0,0 +1,287 @@
1
+ import { SystemdRuntimeError } from './classes.systemdruntimeerror.js';
2
+ import {
3
+ controlSpace,
4
+ descriptorsPerMessageMaximum,
5
+ libcConstants,
6
+ socketAddressLength,
7
+ systemdLibc,
8
+ writeSocketAddress,
9
+ } from './systemdruntime.libc.js';
10
+ import {
11
+ assertSystemdDescriptor,
12
+ assertSystemdNotifySocketAddress,
13
+ isSystemdDescriptorName,
14
+ } from './systemdruntime.validation.js';
15
+
16
+ /** `NOTIFY_BUFFER_MAX` (`PIPE_BUF`): systemd ignores a longer notification message. */
17
+ export const notifyMessageMaximumBytes = 4096;
18
+
19
+ export interface ISystemdEnvironmentOptions {
20
+ /** The environment to read; `process.env` by default. */
21
+ readonly env?: NodeJS.ProcessEnv;
22
+ }
23
+
24
+ export interface ISystemdNotifierEnvironmentOptions extends ISystemdEnvironmentOptions {
25
+ /** Remove `NOTIFY_SOCKET` from the environment, so no child inherits it. Default `false`. */
26
+ readonly take?: boolean;
27
+ }
28
+
29
+ export interface ISystemdStoreDescriptorsOptions {
30
+ /**
31
+ * Whether the manager watches the stored descriptors and drops one that reports `POLLHUP` or
32
+ * `POLLERR` (`FDPOLL=`). Default `true`, as in systemd; `false` sends `FDPOLL=0`, for descriptors
33
+ * that are not pollable or always report hang-up, such as namespace descriptors.
34
+ */
35
+ readonly poll?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Takes `NOTIFY_SOCKET` out of the environment: the validated address, or null when it is unset or
40
+ * empty. An address that is set but invalid is removed as well and then refused
41
+ * (`notify_socket_invalid`).
42
+ */
43
+ export const takeSystemdNotifySocket = (optionsArg: ISystemdEnvironmentOptions = {}): string | null => {
44
+ const env = optionsArg.env ?? process.env;
45
+ const address = env.NOTIFY_SOCKET;
46
+ delete env.NOTIFY_SOCKET;
47
+ if (address === undefined || address === '') return null;
48
+ assertSystemdNotifySocketAddress(address);
49
+ return address;
50
+ };
51
+
52
+ const fieldNamePattern = /^[A-Z][A-Z0-9_]*$/;
53
+
54
+ /**
55
+ * Sends `sd_notify(3)` messages to a service manager's notification socket: one `AF_UNIX`
56
+ * `SOCK_DGRAM` datagram per message, descriptors attached as `SCM_RIGHTS`. Every call is synchronous
57
+ * and either delivers the whole message to the socket or throws a `SystemdRuntimeError`; whether
58
+ * the manager acts on it depends on the unit's `NotifyAccess=`. Fields and descriptor names are
59
+ * validated before sending, because systemd ignores a message it cannot parse without telling the
60
+ * sender.
61
+ */
62
+ export class SystemdNotifier {
63
+ /**
64
+ * The notifier for the unit that started this process, or null when `NOTIFY_SOCKET` is unset or
65
+ * empty — the process was not started by a service manager that listens. The caller decides what
66
+ * that means; nothing is sent silently nowhere. An invalid `NOTIFY_SOCKET` throws
67
+ * `notify_socket_invalid`.
68
+ */
69
+ public static fromEnvironment(optionsArg: ISystemdNotifierEnvironmentOptions = {}): SystemdNotifier | null {
70
+ const env = optionsArg.env ?? process.env;
71
+ const address = optionsArg.take === true ? takeSystemdNotifySocket({ env }) : env.NOTIFY_SOCKET;
72
+ if (address === undefined || address === null || address === '') return null;
73
+ return new SystemdNotifier(address);
74
+ }
75
+
76
+ /** The notification socket: an absolute path, or an abstract name written `@name`. */
77
+ public readonly socketAddress: string;
78
+
79
+ constructor(socketAddressArg: string) {
80
+ assertSystemdNotifySocketAddress(socketAddressArg);
81
+ this.socketAddress = socketAddressArg;
82
+ }
83
+
84
+ /** `READY=1`, with an optional `STATUS=`. */
85
+ public ready(statusArg?: string): void {
86
+ this.send({ READY: '1', ...(statusArg === undefined ? {} : { STATUS: statusArg }) });
87
+ }
88
+
89
+ /** `STATUS=`: free-form text the manager shows as the unit's status. */
90
+ public status(textArg: string): void {
91
+ this.send({ STATUS: textArg });
92
+ }
93
+
94
+ /** `STOPPING=1`, with an optional `STATUS=`. */
95
+ public stopping(statusArg?: string): void {
96
+ this.send({ STOPPING: '1', ...(statusArg === undefined ? {} : { STATUS: statusArg }) });
97
+ }
98
+
99
+ /**
100
+ * `RELOADING=1` with `MONOTONIC_USEC=` (`CLOCK_MONOTONIC` now), with an optional `STATUS=`. The
101
+ * service sends `ready()` once the reload finished.
102
+ */
103
+ public reloading(statusArg?: string): void {
104
+ this.send({
105
+ RELOADING: '1',
106
+ MONOTONIC_USEC: String(monotonicMicroseconds()),
107
+ ...(statusArg === undefined ? {} : { STATUS: statusArg }),
108
+ });
109
+ }
110
+
111
+ /** `WATCHDOG=1`: keeps a `WatchdogSec=` unit alive. */
112
+ public watchdog(): void {
113
+ this.send({ WATCHDOG: '1' });
114
+ }
115
+
116
+ /**
117
+ * `EXTEND_TIMEOUT_USEC=`: asks the manager to wait `microsecondsArg` longer for the current start,
118
+ * runtime-start, stop or watchdog deadline, with an optional `STATUS=`.
119
+ */
120
+ public extendTimeout(microsecondsArg: number, statusArg?: string): void {
121
+ if (!Number.isSafeInteger(microsecondsArg) || microsecondsArg <= 0) {
122
+ throw new SystemdRuntimeError('notify_field_invalid', 'EXTEND_TIMEOUT_USEC must be a positive integer.');
123
+ }
124
+ this.send({
125
+ EXTEND_TIMEOUT_USEC: String(microsecondsArg),
126
+ ...(statusArg === undefined ? {} : { STATUS: statusArg }),
127
+ });
128
+ }
129
+
130
+ /**
131
+ * `FDSTORE=1` with `FDNAME=`: the manager keeps duplicates of `descriptorsArg` (1–253) under
132
+ * `nameArg` in the unit's descriptor store (`FileDescriptorStoreMax=`) and passes them to the
133
+ * next main process through `LISTEN_FDS`. Storing does not replace descriptors already stored
134
+ * under the name; `replaceDescriptors()` does.
135
+ */
136
+ public storeDescriptors(
137
+ nameArg: string,
138
+ descriptorsArg: readonly number[],
139
+ optionsArg: ISystemdStoreDescriptorsOptions = {},
140
+ ): void {
141
+ assertDescriptorName(nameArg);
142
+ assertStoredDescriptors(descriptorsArg);
143
+ this.send(
144
+ { FDSTORE: '1', FDNAME: nameArg, ...(optionsArg.poll === false ? { FDPOLL: '0' } : {}) },
145
+ descriptorsArg,
146
+ );
147
+ }
148
+
149
+ /** `FDSTOREREMOVE=1` with `FDNAME=`: the manager closes every descriptor stored under `nameArg`. */
150
+ public removeDescriptors(nameArg: string): void {
151
+ assertDescriptorName(nameArg);
152
+ this.send({ FDSTOREREMOVE: '1', FDNAME: nameArg });
153
+ }
154
+
155
+ /**
156
+ * Removes the descriptors stored under `nameArg`, then stores `descriptorsArg` under it: two
157
+ * messages, handled by the manager in order. Between them the store holds nothing under the name.
158
+ */
159
+ public replaceDescriptors(
160
+ nameArg: string,
161
+ descriptorsArg: readonly number[],
162
+ optionsArg: ISystemdStoreDescriptorsOptions = {},
163
+ ): void {
164
+ assertDescriptorName(nameArg);
165
+ assertStoredDescriptors(descriptorsArg);
166
+ this.removeDescriptors(nameArg);
167
+ this.storeDescriptors(nameArg, descriptorsArg, optionsArg);
168
+ }
169
+
170
+ /**
171
+ * Sends one message: `NAME=value` fields in insertion order, joined by newlines, and up to 253
172
+ * descriptors. Names are upper-case (`A–Z`, digits, `_`); values carry no newline or NUL; the
173
+ * message is at most 4096 bytes.
174
+ */
175
+ public send(fieldsArg: Readonly<Record<string, string>>, descriptorsArg: readonly number[] = []): void {
176
+ const entries = Object.entries(fieldsArg);
177
+ if (entries.length === 0) {
178
+ throw new SystemdRuntimeError('notify_field_invalid', 'A notification needs at least one field.');
179
+ }
180
+ for (const [name, value] of entries) {
181
+ if (!fieldNamePattern.test(name)) {
182
+ throw new SystemdRuntimeError('notify_field_invalid', `${JSON.stringify(name)} is not a notification field name.`);
183
+ }
184
+ if (typeof value !== 'string' || /[\n\0]/.test(value)) {
185
+ throw new SystemdRuntimeError('notify_field_invalid', `The ${name} value must be a string without newline or NUL.`);
186
+ }
187
+ }
188
+ assertDescriptorList(descriptorsArg);
189
+ const payload = Buffer.from(entries.map(([name, value]) => `${name}=${value}`).join('\n'), 'utf8');
190
+ if (payload.length > notifyMessageMaximumBytes) {
191
+ throw new SystemdRuntimeError(
192
+ 'notify_field_invalid',
193
+ `The message is ${payload.length} bytes; systemd reads at most ${notifyMessageMaximumBytes}.`,
194
+ );
195
+ }
196
+ sendDatagram(this.socketAddress, payload, descriptorsArg);
197
+ }
198
+ }
199
+
200
+ const assertDescriptorName = (nameArg: string): void => {
201
+ if (!isSystemdDescriptorName(nameArg, true)) {
202
+ throw new SystemdRuntimeError(
203
+ 'descriptor_name_invalid',
204
+ 'A descriptor name is 1–255 printable ASCII characters without ":".',
205
+ );
206
+ }
207
+ };
208
+
209
+ /** At most 253 valid descriptor numbers. */
210
+ const assertDescriptorList = (descriptorsArg: readonly number[]): void => {
211
+ if (descriptorsArg.length > descriptorsPerMessageMaximum) {
212
+ throw new SystemdRuntimeError(
213
+ 'descriptor_invalid',
214
+ `One message carries at most ${descriptorsPerMessageMaximum} descriptors.`,
215
+ );
216
+ }
217
+ descriptorsArg.forEach(assertSystemdDescriptor);
218
+ };
219
+
220
+ /** 1–253 valid descriptor numbers, as `FDSTORE=1` needs. */
221
+ const assertStoredDescriptors = (descriptorsArg: readonly number[]): void => {
222
+ if (descriptorsArg.length === 0) {
223
+ throw new SystemdRuntimeError('descriptor_invalid', 'FDSTORE=1 needs at least one descriptor.');
224
+ }
225
+ assertDescriptorList(descriptorsArg);
226
+ };
227
+
228
+ /** `CLOCK_MONOTONIC` in microseconds, as `MONOTONIC_USEC=` expects. */
229
+ const monotonicMicroseconds = (): bigint => {
230
+ const libc = systemdLibc();
231
+ const timespec = Buffer.alloc(16);
232
+ if (libc.functions.clock_gettime(libcConstants.CLOCK_MONOTONIC, timespec) !== 0) {
233
+ throw new SystemdRuntimeError('notify_failed', 'clock_gettime(CLOCK_MONOTONIC) failed.', { errno: libc.errno() });
234
+ }
235
+ return timespec.readBigInt64LE(0) * 1_000_000n + timespec.readBigInt64LE(8) / 1_000n;
236
+ };
237
+
238
+ // One block holds the whole message, so every pointer inside it stays valid while the block is
239
+ // borrowed by the call: msghdr, iovec, sockaddr_un, control data, payload.
240
+ const iovecOffset = 56;
241
+ const addressOffset = 72;
242
+ const controlOffset = 184;
243
+
244
+ const sendDatagram = (addressArg: string, payloadArg: Buffer, descriptorsArg: readonly number[]): void => {
245
+ const libc = systemdLibc();
246
+ const space = descriptorsArg.length === 0 ? 0 : controlSpace(descriptorsArg.length);
247
+ const payloadOffset = controlOffset + space;
248
+ const block = Buffer.alloc(payloadOffset + payloadArg.length);
249
+ const base = libc.ffi.getRawPointer(block);
250
+ writeSocketAddress(block, addressOffset, addressArg);
251
+ payloadArg.copy(block, payloadOffset);
252
+ block.writeBigUInt64LE(base + BigInt(addressOffset), 0);
253
+ block.writeUInt32LE(socketAddressLength(addressArg), 8);
254
+ block.writeBigUInt64LE(base + BigInt(iovecOffset), 16);
255
+ block.writeBigUInt64LE(1n, 24);
256
+ block.writeBigUInt64LE(base + BigInt(payloadOffset), iovecOffset);
257
+ block.writeBigUInt64LE(BigInt(payloadArg.length), iovecOffset + 8);
258
+ if (space > 0) {
259
+ block.writeBigUInt64LE(base + BigInt(controlOffset), 32);
260
+ block.writeBigUInt64LE(BigInt(space), 40);
261
+ block.writeBigUInt64LE(BigInt(16 + 4 * descriptorsArg.length), controlOffset);
262
+ block.writeInt32LE(libcConstants.SOL_SOCKET, controlOffset + 8);
263
+ block.writeInt32LE(libcConstants.SCM_RIGHTS, controlOffset + 12);
264
+ descriptorsArg.forEach((descriptor, index) => block.writeInt32LE(descriptor, controlOffset + 16 + 4 * index));
265
+ }
266
+ const socket = libc.functions.socket(libcConstants.AF_UNIX, libcConstants.SOCK_DGRAM | libcConstants.SOCK_CLOEXEC, 0);
267
+ if (socket < 0) {
268
+ throw new SystemdRuntimeError('notify_failed', 'socket(AF_UNIX, SOCK_DGRAM) failed.', { errno: libc.errno() });
269
+ }
270
+ try {
271
+ let sent = libc.functions.sendmsg(socket, block, libcConstants.MSG_NOSIGNAL);
272
+ let errno = sent < 0n ? libc.errno() : 0;
273
+ while (sent < 0n && errno === libcConstants.EINTR) {
274
+ sent = libc.functions.sendmsg(socket, block, libcConstants.MSG_NOSIGNAL);
275
+ errno = sent < 0n ? libc.errno() : 0;
276
+ }
277
+ if (sent < 0n) {
278
+ throw new SystemdRuntimeError('notify_failed', `sendmsg to ${addressArg} failed.`, { errno });
279
+ }
280
+ if (sent !== BigInt(payloadArg.length)) {
281
+ throw new SystemdRuntimeError('notify_failed', `sendmsg to ${addressArg} sent ${sent} of ${payloadArg.length} bytes.`);
282
+ }
283
+ } finally {
284
+ // Linux releases the descriptor whatever close(2) reports; the socket is this call's alone.
285
+ libc.functions.close(socket);
286
+ }
287
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Why a systemd runtime operation refused or failed:
3
+ *
4
+ * - `runtime_unsupported`: the runtime cannot make the call: Node.js older than 26, a platform
5
+ * other than Linux on x64 or arm64, a C library other than glibc (`libc.so.6`), `node:ffi`
6
+ * disabled (`--no-experimental-ffi`) or denied by the permission model (no `--allow-ffi`).
7
+ * - `notify_socket_invalid`: `NOTIFY_SOCKET` is not an `AF_UNIX` path or abstract name this
8
+ * module can address (`vsock:` addresses included).
9
+ * - `notify_field_invalid`: a notification field name or value systemd would not parse, or a
10
+ * message larger than systemd reads (4096 bytes).
11
+ * - `notify_failed`: the message could not be sent (`errno` names why).
12
+ * - `descriptor_name_invalid`: an `FDNAME=` systemd would refuse.
13
+ * - `descriptor_invalid`: a descriptor number that cannot exist, or a count outside 1–253.
14
+ * - `listen_environment_invalid`: `LISTEN_PID`, `LISTEN_FDS` or `LISTEN_FDNAMES` is malformed,
15
+ * `LISTEN_FDS` names a descriptor at or above the process's hard `RLIMIT_NOFILE`, or
16
+ * `getrlimit(RLIMIT_NOFILE)` failed, so the count cannot be bounded (`errno` names why).
17
+ * - `watchdog_environment_invalid`: `WATCHDOG_USEC` or `WATCHDOG_PID` is malformed.
18
+ * - `close_on_exec_failed`: a descriptor could not be marked close-on-exec (`errno` names why).
19
+ */
20
+ export type TSystemdRuntimeErrorCode =
21
+ | 'runtime_unsupported'
22
+ | 'notify_socket_invalid'
23
+ | 'notify_field_invalid'
24
+ | 'notify_failed'
25
+ | 'descriptor_name_invalid'
26
+ | 'descriptor_invalid'
27
+ | 'listen_environment_invalid'
28
+ | 'watchdog_environment_invalid'
29
+ | 'close_on_exec_failed';
30
+
31
+ export interface ISystemdRuntimeErrorOptions {
32
+ /** The C library's `errno` after the failed call, when a call failed. */
33
+ readonly errno?: number;
34
+ readonly cause?: unknown;
35
+ }
36
+
37
+ export class SystemdRuntimeError extends Error {
38
+ /** The C library's `errno` after the failed call, or null when no call failed. */
39
+ public readonly errno: number | null;
40
+
41
+ constructor(
42
+ public readonly code: TSystemdRuntimeErrorCode,
43
+ messageArg: string,
44
+ optionsArg: ISystemdRuntimeErrorOptions = {},
45
+ ) {
46
+ super(
47
+ `${messageArg}${optionsArg.errno === undefined ? '' : ` (errno ${optionsArg.errno})`}`,
48
+ optionsArg.cause === undefined ? undefined : { cause: optionsArg.cause },
49
+ );
50
+ this.name = 'SystemdRuntimeError';
51
+ this.errno = optionsArg.errno ?? null;
52
+ }
53
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@push.rocks/smartdaemon/systemd-runtime`: the service side of systemd for a Node.js main
3
+ * process — `sd_notify(3)` messages with descriptors, `LISTEN_FDS` adoption with close-on-exec, and
4
+ * the watchdog interval — through glibc calls made with `node:ffi`. Requires Node.js 26 or later:
5
+ * importing it on an older Node.js throws `SystemdRuntimeError` `runtime_unsupported`.
6
+ */
7
+ import { assertSystemdRuntimeNode } from './systemdruntime.libc.js';
8
+
9
+ export * from './classes.systemdruntimeerror.js';
10
+ export * from './classes.systemdnotifier.js';
11
+ export {
12
+ type ISystemdListenDescriptor,
13
+ type ISystemdProcessEnvironmentOptions,
14
+ setCloseOnExec,
15
+ systemdListenDescriptorsStart,
16
+ takeSystemdListenDescriptors,
17
+ watchdogIntervalUsec,
18
+ } from './systemdruntime.listen.js';
19
+ export { systemdRuntimeNodeMajor } from './systemdruntime.libc.js';
20
+ export { isSystemdDescriptorName, isSystemdNotifySocketAddress } from './systemdruntime.validation.js';
21
+
22
+ assertSystemdRuntimeNode();