@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.
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/classes.systemdtransientscope.js +1 -1
- package/dist_ts/classes.systemdunit.js +1 -1
- package/dist_ts/classes.systemdunitfile.js +1 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/smartdaemon.classes.commanderror.d.ts +25 -0
- package/dist_ts/smartdaemon.classes.commanderror.js +27 -0
- package/dist_ts/smartdaemon.classes.systemdmanager.d.ts +12 -0
- package/dist_ts/smartdaemon.classes.systemdmanager.js +63 -30
- package/dist_ts/smartdaemon.paths.js +1 -1
- package/dist_ts/smartdaemon.plugins.d.ts +1 -3
- package/dist_ts/smartdaemon.plugins.js +2 -4
- package/dist_ts/systemd.definition.d.ts +3 -3
- 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 +19 -8
- package/readme.md +119 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/smartdaemon.classes.commanderror.ts +23 -0
- package/ts/smartdaemon.classes.systemdmanager.ts +78 -34
- package/ts/smartdaemon.plugins.ts +0 -4
- 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
- package/dist_ts/smartdamon.logging.d.ts +0 -2
- package/dist_ts/smartdamon.logging.js +0 -14
- 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
|
-
- 🔌 **
|
|
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
|
|
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
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/index.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
};
|