@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.systemdservicedefinition.d.ts +16 -4
- package/dist_ts/classes.systemdservicedefinition.js +33 -7
- package/dist_ts/classes.systemdtransientscope.js +1 -1
- package/dist_ts/classes.systemdunit.d.ts +7 -0
- package/dist_ts/classes.systemdunit.js +11 -1
- package/dist_ts/classes.systemdunitfile.d.ts +26 -0
- package/dist_ts/classes.systemdunitfile.js +125 -9
- package/dist_ts/smartdaemon.classes.systemdmanager.js +1 -1
- package/dist_ts/smartdaemon.paths.js +1 -1
- package/dist_ts/systemd.definition.d.ts +16 -3
- package/dist_ts/systemd.definition.js +1 -1
- package/dist_ts/systemd.managerversion.d.ts +2 -0
- package/dist_ts/systemd.managerversion.js +3 -1
- package/dist_ts/systemd.userenvironment.d.ts +1 -1
- package/dist_ts_systemdruntime/classes.systemdnotifier.d.ts +82 -0
- package/dist_ts_systemdruntime/classes.systemdnotifier.js +217 -0
- package/dist_ts_systemdruntime/classes.systemdruntimeerror.d.ts +31 -0
- package/dist_ts_systemdruntime/classes.systemdruntimeerror.js +9 -0
- package/dist_ts_systemdruntime/index.d.ts +5 -0
- package/dist_ts_systemdruntime/index.js +14 -0
- package/dist_ts_systemdruntime/systemdruntime.libc.d.ts +87 -0
- package/dist_ts_systemdruntime/systemdruntime.libc.js +92 -0
- package/dist_ts_systemdruntime/systemdruntime.listen.d.ts +46 -0
- package/dist_ts_systemdruntime/systemdruntime.listen.js +136 -0
- package/dist_ts_systemdruntime/systemdruntime.validation.d.ts +13 -0
- package/dist_ts_systemdruntime/systemdruntime.validation.js +29 -0
- package/package.json +18 -6
- package/readme.md +186 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.systemdservicedefinition.ts +47 -10
- package/ts/classes.systemdunit.ts +11 -0
- package/ts/classes.systemdunitfile.ts +124 -7
- package/ts/systemd.definition.ts +12 -0
- package/ts/systemd.managerversion.ts +3 -0
- package/ts_systemdruntime/classes.systemdnotifier.ts +287 -0
- package/ts_systemdruntime/classes.systemdruntimeerror.ts +53 -0
- package/ts_systemdruntime/index.ts +22 -0
- package/ts_systemdruntime/systemdruntime.libc.ts +121 -0
- package/ts_systemdruntime/systemdruntime.listen.ts +153 -0
- 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
|
-
/**
|
|
161
|
-
|
|
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(
|
|
178
|
+
: !Object.hasOwn(fileStates, state.loadState)
|
|
167
179
|
? 'unexpected_load_state'
|
|
168
|
-
: !
|
|
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)
|
package/ts/systemd.definition.ts
CHANGED
|
@@ -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();
|