@push.rocks/smartdaemon 2.21.0 → 3.0.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 (43) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/classes.systemdtransientscope.js +1 -1
  3. package/dist_ts/classes.systemdunit.js +1 -1
  4. package/dist_ts/classes.systemdunitfile.js +1 -1
  5. package/dist_ts/index.d.ts +1 -0
  6. package/dist_ts/index.js +2 -1
  7. package/dist_ts/smartdaemon.classes.commanderror.d.ts +25 -0
  8. package/dist_ts/smartdaemon.classes.commanderror.js +27 -0
  9. package/dist_ts/smartdaemon.classes.systemdmanager.d.ts +12 -0
  10. package/dist_ts/smartdaemon.classes.systemdmanager.js +63 -30
  11. package/dist_ts/smartdaemon.paths.js +1 -1
  12. package/dist_ts/smartdaemon.plugins.d.ts +1 -3
  13. package/dist_ts/smartdaemon.plugins.js +2 -4
  14. package/dist_ts/systemd.definition.d.ts +3 -3
  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 +19 -8
  29. package/readme.md +119 -2
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/index.ts +1 -0
  32. package/ts/smartdaemon.classes.commanderror.ts +23 -0
  33. package/ts/smartdaemon.classes.systemdmanager.ts +78 -34
  34. package/ts/smartdaemon.plugins.ts +0 -4
  35. package/ts_systemdruntime/classes.systemdnotifier.ts +287 -0
  36. package/ts_systemdruntime/classes.systemdruntimeerror.ts +53 -0
  37. package/ts_systemdruntime/index.ts +22 -0
  38. package/ts_systemdruntime/systemdruntime.libc.ts +121 -0
  39. package/ts_systemdruntime/systemdruntime.listen.ts +153 -0
  40. package/ts_systemdruntime/systemdruntime.validation.ts +41 -0
  41. package/dist_ts/smartdamon.logging.d.ts +0 -2
  42. package/dist_ts/smartdamon.logging.js +0 -14
  43. package/ts/smartdamon.logging.ts +0 -15
package/readme.md CHANGED
@@ -21,7 +21,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
21
21
  - 🔄 **Systemd Integration**: Full Linux systemd support for production-grade service management
22
22
  - 🛡️ **Auto-Recovery**: Built-in service restart on failure
23
23
  - 📝 **Declarative Service Definitions**: Simple, clear service configuration
24
- - 🔌 **Interactive & Non-Interactive Modes**: Support for both passwordless sudo and interactive authentication
24
+ - 🔌 **Passwordless or Password-Based Sudo**: Works with passwordless sudo, or hands a configured sudo password to sudo on standard input (no terminal or PTY needed)
25
25
  - 🎛️ **Complete Lifecycle Management**: Start, stop, enable, disable, and reload services with ease
26
26
 
27
27
  ## 📦 Installation
@@ -115,6 +115,20 @@ const service = await daemon.addService({
115
115
  await service.enable(); // Automatically uses sudo with password
116
116
  ```
117
117
 
118
+ Each privileged command runs as `sudo -k -S -p '' -- <command>`: sudo reads the password from
119
+ standard input, which is closed right after the password line, so a rejected password fails at
120
+ once instead of waiting for a retry. The password is never placed in argv, the environment or the
121
+ output, and no PTY (`node-pty`) is involved. `-k` makes sudo ask for the password every time and
122
+ leaves no cached credentials behind. If a sudoers rule lets the command run without a password
123
+ (`NOPASSWD`), sudo does not read standard input and the password line reaches the command's
124
+ standard input instead (`systemctl`, `mv`, `chmod` and `rm` never read it); leave `sudoPassword`
125
+ unset when sudo is passwordless.
126
+
127
+ A command that does not exit 0 rejects with `SmartDaemonCommandError` (`command`, `exitCode`,
128
+ `signal`, `stderr`), with or without a sudo password: a rejected password, a missing `sudo`
129
+ (exit code 127) or a failing `systemctl` call all surface this way. Besides `systemctl`, saving
130
+ and deleting a unit file as a non-root user runs `mv`, `chmod` and `rm` through sudo.
131
+
118
132
  ### Passwordless Sudo (Recommended for Production)
119
133
 
120
134
  For production environments, configure passwordless sudo for specific systemctl commands:
@@ -998,6 +1012,106 @@ other process in the unit survives, so the main process must own their shutdown
998
1012
  deliberate retention (as with `Delegate=yes` workloads). A successful systemd start still requires application readiness
999
1013
  verification, and a completed stop alone does not prove database drain.
1000
1014
 
1015
+ ### The service side: notifications and passed descriptors (`/systemd-runtime`, Node.js 26)
1016
+
1017
+ The definitions above tell the manager what to expect; a Node.js main process answers it through
1018
+ the `@push.rocks/smartdaemon/systemd-runtime` subpath: `sd_notify(3)` messages (with descriptors
1019
+ for the store) and the descriptors the manager passes (`LISTEN_FDS`). Node.js has no `AF_UNIX`
1020
+ datagram socket and no `fcntl(2)`, so the subpath calls glibc through `node:ffi`:
1021
+
1022
+ - It requires **Node.js 26 or later**. Importing it on an older Node.js throws
1023
+ `SystemdRuntimeError` with code `runtime_unsupported`; the main entry is unaffected and keeps
1024
+ its Node.js range. The package is ES modules: CommonJS can `require()` either entry only on a
1025
+ Node.js that loads ES modules through `require()` by default (20.19+, 22.12+, 23+), where the
1026
+ subpath throws the same `runtime_unsupported` below 26; an older Node.js throws `ERR_REQUIRE_ESM`.
1027
+ - It runs on Linux x64 and arm64 with glibc (`libc.so.6`). Elsewhere, and with `node:ffi`
1028
+ unavailable (`--no-experimental-ffi`) or denied by the permission model, the first call that
1029
+ needs libc throws `runtime_unsupported`. Under the permission model pass `--allow-ffi`.
1030
+ - `node:ffi` is experimental: loading it prints one `ExperimentalWarning`. A single executable
1031
+ application suppresses it through its `execArgv` (`--disable-warning=ExperimentalWarning`).
1032
+ - libc is opened on first use. Outside systemd nothing is loaded: without `NOTIFY_SOCKET`
1033
+ `SystemdNotifier.fromEnvironment()` returns null, and without `LISTEN_*` variables
1034
+ `takeSystemdListenDescriptors()` returns an empty list.
1035
+
1036
+ ```typescript
1037
+ import {
1038
+ SystemdNotifier,
1039
+ takeSystemdListenDescriptors,
1040
+ watchdogIntervalUsec,
1041
+ } from '@push.rocks/smartdaemon/systemd-runtime';
1042
+
1043
+ // Descriptors passed for this process, numbered from 3, already close-on-exec.
1044
+ const passed = takeSystemdListenDescriptors(); // [{ descriptor: 3, name: 'pallet-router' }]
1045
+ const namespaceDescriptor = passed.find(({ name }) => name === 'pallet-router')?.descriptor ?? createNamespace();
1046
+
1047
+ // null when no service manager listens; the caller decides what that means.
1048
+ const notifier = SystemdNotifier.fromEnvironment({ take: true });
1049
+ if (notifier) {
1050
+ notifier.extendTimeout(60_000_000, 'replaying journal');
1051
+ notifier.replaceDescriptors('pallet-router', [namespaceDescriptor], { poll: false });
1052
+ notifier.ready('serving');
1053
+ const interval = watchdogIntervalUsec();
1054
+ if (interval !== null) setInterval(() => notifier.watchdog(), interval / 2000);
1055
+ }
1056
+ ```
1057
+
1058
+ | Call | Sends or does |
1059
+ | --- | --- |
1060
+ | `ready(status?)` | `READY=1` (and `STATUS=`) |
1061
+ | `status(text)` | `STATUS=` |
1062
+ | `stopping(status?)` | `STOPPING=1` |
1063
+ | `reloading(status?)` | `RELOADING=1` with `MONOTONIC_USEC=` (`CLOCK_MONOTONIC`); send `ready()` when done |
1064
+ | `watchdog()` | `WATCHDOG=1` |
1065
+ | `extendTimeout(usec, status?)` | `EXTEND_TIMEOUT_USEC=` |
1066
+ | `storeDescriptors(name, fds, { poll? })` | `FDSTORE=1`, `FDNAME=`, `FDPOLL=0` for `poll: false`; descriptors as `SCM_RIGHTS` |
1067
+ | `removeDescriptors(name)` | `FDSTOREREMOVE=1`, `FDNAME=` |
1068
+ | `replaceDescriptors(name, fds, { poll? })` | remove, then store: two messages, handled in order |
1069
+ | `send(fields, fds?)` | any `NAME=value` fields in insertion order |
1070
+ | `takeSystemdNotifySocket()` | removes `NOTIFY_SOCKET` from the environment and returns it validated, or null |
1071
+ | `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) |
1072
+ | `setCloseOnExec(fd)` | `fcntl(F_SETFD, FD_CLOEXEC)`, verified |
1073
+ | `watchdogIntervalUsec()` | `WATCHDOG_USEC` for this process (`WATCHDOG_PID`), or null |
1074
+
1075
+ Every call is synchronous and either completes or throws `SystemdRuntimeError` (`code`, and
1076
+ `errno` when a libc call failed); nothing is dropped silently. systemd ignores a message it cannot
1077
+ parse without telling the sender, so the notifier refuses it first: field names are upper-case,
1078
+ values carry no newline or NUL, a message is at most 4096 bytes (`notify_field_invalid`); a
1079
+ descriptor name is 1–255 printable ASCII characters without `:` (`descriptor_name_invalid`); one
1080
+ message carries 1–253 descriptors (`descriptor_invalid`). `NOTIFY_SOCKET` must be an absolute
1081
+ path or an abstract name (`@name`) of at most 107 bytes; anything else, `vsock:` addresses
1082
+ included, is `notify_socket_invalid`. A send the kernel refuses is `notify_failed` with its errno
1083
+ (`ENOENT` for a missing path, `ECONNREFUSED` for an abstract name nobody holds). `LISTEN_*`
1084
+ variables naming another process are removed and nothing else is touched; a partial or malformed
1085
+ set (`LISTEN_FDS` not a positive integer, a names list of another length) is
1086
+ `listen_environment_invalid` before any descriptor is touched, and a passed descriptor that is not
1087
+ open is `close_on_exec_failed`.
1088
+
1089
+ `LISTEN_FDS` is also bounded by the process's hard `RLIMIT_NOFILE` before any descriptor is touched:
1090
+ a count whose last descriptor (`2 + count`) is at or above that limit is
1091
+ `listen_environment_invalid`. No descriptor systemd passed can lie at or above the hard limit,
1092
+ because it moves them into place with `fcntl(F_DUPFD)` (which refuses a number at or above the soft
1093
+ limit) after applying the unit's limits; a wrapper that lowers its hard limit below a passed
1094
+ descriptor before starting Node.js is refused. The hard limit can be as large as `fs.nr_open`
1095
+ (`LimitNOFILE=infinity`), so nothing is allocated per counted descriptor up front: like
1096
+ `sd_listen_fds(3)`, the descriptors are marked one by one from 3, the first that is not open throws
1097
+ `close_on_exec_failed` (the ones before it keep the flag), and the list grows only with open
1098
+ descriptors.
1099
+
1100
+ Sends are blocking `sendmsg(2)` calls on a fresh `AF_UNIX` datagram socket, the same as
1101
+ `sd_notify(3)`: when the manager's receive queue is full, the call waits for room and the event loop
1102
+ stalls for that time.
1103
+
1104
+ `node:ffi` has no errno API: the subpath reads glibc's thread-local `errno` through
1105
+ `__errno_location()` immediately after the failed call. Node.js itself calls libuv's
1106
+ `uv_disable_stdio_inheritance()` at startup (`InitializeNodeWithArgsInternal()` in `src/node.cc`;
1107
+ `deps/uv/src/unix/core.c`, Node.js 25.9.0 and 26.11.1), which marks descriptors 0–15 close-on-exec
1108
+ and continues past 15 until the first descriptor that is not open. The subpath still sets and
1109
+ verifies the flag on every passed descriptor, as `sd_listen_fds(3)` does.
1110
+
1111
+ `test/test.systemdruntimeunit.node.ts` proves the subpath under a real `Type=notify` unit of the
1112
+ caller's user manager (`systemd-run --user`), and skips with the reason without Node.js 26 or a
1113
+ user manager.
1114
+
1001
1115
  ### Starting a program in its own transient scope
1002
1116
 
1003
1117
  A service that launches a long-lived program keeps that program in its own
@@ -1254,6 +1368,8 @@ console.log(template); // View generated systemd unit file
1254
1368
  - **systemd 240 or later** for installing a definition with an `append` output (`standardOutput` or
1255
1369
  `standardError`), refused the same way
1256
1370
  - **Node.js**: Version 14.x or higher
1371
+ - **Node.js 26 or later** for the `@push.rocks/smartdaemon/systemd-runtime` subpath (`node:ffi`), on Linux
1372
+ x64 or arm64 with glibc
1257
1373
  - **Permissions**: Either root access or properly configured sudo
1258
1374
 
1259
1375
  ## ⚙️ Generated Systemd Service Files
@@ -1296,7 +1412,8 @@ WantedBy=multi-user.target
1296
1412
 
1297
1413
  ### Permission Denied Errors
1298
1414
 
1299
- If you encounter "Interactive authentication required" errors:
1415
+ If an operation rejects with a `SmartDaemonCommandError` whose `stderr` reports that sudo needs a
1416
+ password or a terminal (for example "a terminal is required to read the password"):
1300
1417
 
1301
1418
  1. **Option 1**: Run your application with sudo
1302
1419
  ```bash
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartdaemon',
6
- version: '2.21.0',
6
+ version: '3.0.0',
7
7
  description: 'Start scripts as long running daemons and manage them.'
8
8
  }
package/ts/index.ts CHANGED
@@ -4,3 +4,4 @@ export * from './classes.systemdunit.js';
4
4
  export * from './classes.systemdservicedefinition.js';
5
5
  export * from './classes.systemdunitfile.js';
6
6
  export * from './classes.systemdtransientscope.js';
7
+ export * from './smartdaemon.classes.commanderror.js';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A privileged command run by `SmartDaemon` (through `systemdManager.execute()`) did not exit 0.
3
+ * The exit status alone decides failure; `stderr` carries what the command or sudo reported.
4
+ */
5
+ export class SmartDaemonCommandError extends Error {
6
+ constructor(
7
+ /** The command as run, including any `sudo` prefix. It never contains the sudo password. */
8
+ public readonly command: string,
9
+ /** The exit code; `1` when a signal ended the command. */
10
+ public readonly exitCode: number,
11
+ /** The signal that ended the command, if one did. */
12
+ public readonly signal: NodeJS.Signals | undefined,
13
+ /** The command's standard error, as captured. */
14
+ public readonly stderr: string,
15
+ /** `cause`: the stdin write error when sudo exited before reading the password. */
16
+ options?: ErrorOptions,
17
+ ) {
18
+ const reason = signal ? `was terminated by signal ${signal}` : `exited with code ${exitCode}`;
19
+ const detail = stderr.trim();
20
+ super(`Command "${command}" ${reason}${detail ? `: ${detail}` : '.'}`, options);
21
+ this.name = 'SmartDaemonCommandError';
22
+ }
23
+ }
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './smartdaemon.plugins.js';
2
2
  import * as paths from './smartdaemon.paths.js';
3
3
  import { SmartDaemon } from './smartdaemon.classes.smartdaemon.js';
4
+ import { SmartDaemonCommandError } from './smartdaemon.classes.commanderror.js';
4
5
  import {
5
6
  type ISmartDaemonServiceConstructorOptions,
6
7
  SmartDaemonService,
@@ -31,8 +32,10 @@ export class SmartDaemonSystemdManager {
31
32
  public smartFs: plugins.smartfs.SmartFs;
32
33
 
33
34
  public shouldExecute: boolean = false;
35
+ /** Whether this process runs as root; set by the first privileged operation's check. */
34
36
  public isRoot: boolean = false;
35
37
  public sudoPassword?: string;
38
+ private rootStatus?: Promise<void>;
36
39
 
37
40
  constructor(smartdaemonRefArg: SmartDaemon, sudoPasswordArg?: string) {
38
41
  this.smartdaemonRef = smartdaemonRefArg;
@@ -41,14 +44,6 @@ export class SmartDaemonSystemdManager {
41
44
  });
42
45
  this.smartFs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
43
46
  this.sudoPassword = sudoPasswordArg;
44
-
45
- // Check if we're running as root
46
- this.checkIsRoot().then(isRoot => {
47
- this.isRoot = isRoot;
48
- if (!isRoot) {
49
- console.log('Not running as root. Sudo will be used for privileged operations.');
50
- }
51
- });
52
47
  }
53
48
 
54
49
  private async checkIsRoot(): Promise<boolean> {
@@ -56,6 +51,23 @@ export class SmartDaemonSystemdManager {
56
51
  return result.stdout.trim() === '0';
57
52
  }
58
53
 
54
+ /** Checks once whether this process runs as root and sets `isRoot`; later calls reuse that check. */
55
+ private ensureRootStatus(): Promise<void> {
56
+ if (!this.rootStatus) {
57
+ this.rootStatus = this.checkIsRoot().then((isRoot) => {
58
+ this.isRoot = isRoot;
59
+ if (!isRoot) {
60
+ console.log('Not running as root. Sudo will be used for privileged operations.');
61
+ }
62
+ });
63
+ // A failed check is not memoized, so the next privileged operation asks again.
64
+ this.rootStatus.catch(() => {
65
+ this.rootStatus = undefined;
66
+ });
67
+ }
68
+ return this.rootStatus;
69
+ }
70
+
59
71
  public async checkElegibility() {
60
72
  if (plugins.os.platform() === 'linux') {
61
73
  this.shouldExecute = true;
@@ -66,31 +78,61 @@ export class SmartDaemonSystemdManager {
66
78
  return this.shouldExecute;
67
79
  }
68
80
 
69
- public async execute(commandArg: string) {
70
- if (await this.checkElegibility()) {
71
- // Only use sudo if we're not root and command doesn't already have sudo
72
- if (!this.isRoot && !commandArg.startsWith('sudo')) {
73
- commandArg = `sudo ${commandArg}`;
74
-
75
- if (this.sudoPassword) {
76
- // Use interactive PTY mode for password input
77
- const interactiveExec = await this.smartshellInstance.execInteractiveControlPty(commandArg);
78
- await interactiveExec.sendLine(this.sudoPassword);
79
- const result = await interactiveExec.finalPromise;
80
- return result;
81
- }
82
- }
83
-
84
- // Execute command (with or without sudo)
85
- try {
86
- await this.smartshellInstance.exec(commandArg);
87
- } catch (error) {
88
- if (!this.isRoot && error instanceof Error && error.message.includes('authentication')) {
89
- throw new Error('Sudo authentication failed. Please configure passwordless sudo or provide a sudo password.');
90
- }
91
- throw error;
92
- }
81
+ /**
82
+ * Runs a command, through sudo when this process is not root and the command does not start with
83
+ * `sudo` already. With a `sudoPassword`, sudo reads it from standard input (`sudo -k -S -p ''`):
84
+ * it never reaches argv, the environment or a terminal. Rejects with `SmartDaemonCommandError`
85
+ * when the command does not exit 0.
86
+ */
87
+ public async execute(commandArg: string): Promise<plugins.smartshell.IExecResult | undefined> {
88
+ if (!(await this.checkElegibility())) {
89
+ return undefined;
90
+ }
91
+ await this.ensureRootStatus();
92
+ if (this.isRoot || commandArg.startsWith('sudo')) {
93
+ return await this.runChecked(commandArg);
94
+ }
95
+ if (!this.sudoPassword) {
96
+ return await this.runChecked(`sudo ${commandArg}`);
97
+ }
98
+ // -k ignores cached credentials, so sudo reads the password line whenever its policy asks for
99
+ // one; ending stdin right after it makes a rejected password fail instead of waiting for a retry.
100
+ const sudoCommand = `sudo -k -S -p '' -- ${commandArg}`;
101
+ const interactiveExec = await this.smartshellInstance.execInteractiveControl(sudoCommand);
102
+ let writeError: unknown;
103
+ try {
104
+ await interactiveExec.sendLine(this.sudoPassword);
105
+ } catch (error) {
106
+ // sudo can exit before reading stdin (EPIPE); its exit status below decides the outcome.
107
+ writeError = error;
108
+ }
109
+ interactiveExec.endInput();
110
+ const result = await interactiveExec.finalPromise;
111
+ this.assertSucceeded(sudoCommand, result, writeError);
112
+ return result;
113
+ }
114
+
115
+ private async runChecked(commandArg: string): Promise<plugins.smartshell.IExecResult> {
116
+ const result = await this.smartshellInstance.exec(commandArg);
117
+ this.assertSucceeded(commandArg, result);
118
+ return result;
119
+ }
120
+
121
+ private assertSucceeded(
122
+ commandArg: string,
123
+ resultArg: plugins.smartshell.IExecResult,
124
+ causeArg?: unknown,
125
+ ): void {
126
+ if (resultArg.exitCode === 0 && !resultArg.signal) {
127
+ return;
93
128
  }
129
+ throw new SmartDaemonCommandError(
130
+ commandArg,
131
+ resultArg.exitCode,
132
+ resultArg.signal,
133
+ resultArg.stderr,
134
+ causeArg === undefined ? undefined : { cause: causeArg },
135
+ );
94
136
  }
95
137
 
96
138
  /**
@@ -136,7 +178,8 @@ export class SmartDaemonSystemdManager {
136
178
  if (await this.checkElegibility()) {
137
179
  const content = this.smartdaemonRef.templateManager.generateUnitFileForService(serviceArg);
138
180
  const targetPath = SmartDaemonSystemdManager.createFilePathFromServiceName(serviceArg.name);
139
-
181
+
182
+ await this.ensureRootStatus();
140
183
  if (this.isRoot) {
141
184
  // Direct write when running as root
142
185
  await this.smartFs.file(targetPath).encoding('utf8').mode(0o644).write(content);
@@ -153,7 +196,8 @@ export class SmartDaemonSystemdManager {
153
196
  public async deleteService(serviceArg: SmartDaemonService) {
154
197
  if (await this.checkElegibility()) {
155
198
  const filePath = SmartDaemonSystemdManager.createFilePathFromServiceName(serviceArg.name);
156
-
199
+
200
+ await this.ensureRootStatus();
157
201
  if (this.isRoot) {
158
202
  await this.smartFs.file(filePath).delete();
159
203
  } else {
@@ -13,8 +13,6 @@ export { path, childProcess, fs, fsConstants, crypto, util, os };
13
13
  // @pushrocks scope
14
14
  import * as lik from '@push.rocks/lik';
15
15
  import * as smartfs from '@push.rocks/smartfs';
16
- import * as smartlog from '@push.rocks/smartlog';
17
- import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
18
16
  import * as smartmarkdown from '@push.rocks/smartmarkdown/iso';
19
17
  import * as smartpath from '@push.rocks/smartpath';
20
18
  import * as smartshell from '@push.rocks/smartshell';
@@ -22,8 +20,6 @@ import * as smartshell from '@push.rocks/smartshell';
22
20
  export {
23
21
  lik,
24
22
  smartfs,
25
- smartlog,
26
- smartlogDestinationLocal,
27
23
  smartmarkdown,
28
24
  smartpath,
29
25
  smartshell,
@@ -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
+ };