@push.rocks/smartdaemon 2.11.0 → 2.13.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 +57 -3
- package/dist_ts/classes.systemdservicedefinition.js +178 -41
- package/dist_ts/classes.systemdtransientscope.d.ts +78 -0
- package/dist_ts/classes.systemdtransientscope.js +204 -0
- package/dist_ts/classes.systemdunit.js +3 -15
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/systemd.definition.d.ts +24 -1
- package/dist_ts/systemd.definition.js +19 -2
- package/dist_ts/systemd.userenvironment.d.ts +6 -0
- package/dist_ts/systemd.userenvironment.js +21 -1
- package/package.json +1 -1
- package/readme.md +158 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.systemdservicedefinition.ts +249 -48
- package/ts/classes.systemdtransientscope.ts +272 -0
- package/ts/classes.systemdunit.ts +2 -11
- package/ts/index.ts +1 -0
- package/ts/systemd.definition.ts +42 -2
- package/ts/systemd.userenvironment.ts +20 -0
package/readme.md
CHANGED
|
@@ -464,6 +464,93 @@ and `%` doubled, so systemd applies no specifier; `Environment=` never expands `
|
|
|
464
464
|
taken literally. An empty object renders nothing. Systemd merges these variables over its own
|
|
465
465
|
(`HOME`, `USER`, `INVOCATION_ID`, …), so a name such as `NOTIFY_SOCKET` overrides the manager's value.
|
|
466
466
|
|
|
467
|
+
#### Credentials, a dynamic user and the sandbox
|
|
468
|
+
|
|
469
|
+
A system service can receive secrets as systemd credentials instead of variables, run as a
|
|
470
|
+
transient account and be confined. Each option below renders nothing when omitted, so existing
|
|
471
|
+
definitions keep their exact bytes; `false` renders the directive as `no`. A user service refuses
|
|
472
|
+
all of them, since a user manager cannot grant them. `DynamicUser=` follows `Group=`; the others
|
|
473
|
+
follow the `environment` lines in this order:
|
|
474
|
+
|
|
475
|
+
| Option | Directive | Accepted values |
|
|
476
|
+
| --- | --- | --- |
|
|
477
|
+
| `dynamicUser` | `DynamicUser=` | boolean; with `true`, `user` and `group` name the allocated account |
|
|
478
|
+
| `loadCredentials` | one `LoadCredential=` line per credential, ordered by name | array of `{ name, source? }` |
|
|
479
|
+
| `loadCredentialsEncrypted` | one `LoadCredentialEncrypted=` line per credential, ordered by name | array of `{ name, source? }` |
|
|
480
|
+
| `noNewPrivileges` | `NoNewPrivileges=` | boolean |
|
|
481
|
+
| `protectSystem` | `ProtectSystem=` | boolean, `'full'` or `'strict'` |
|
|
482
|
+
| `protectHome` | `ProtectHome=` | boolean, `'read-only'` or `'tmpfs'` |
|
|
483
|
+
| `privateTmp`, `privateDevices`, `protectKernelTunables`, `protectKernelModules`, `protectKernelLogs`, `protectControlGroups`, `protectClock`, `protectHostname`, `restrictNamespaces`, `restrictRealtime`, `restrictSUIDSGID`, `lockPersonality` | the directive of the same name, in this order | boolean |
|
|
484
|
+
| `capabilityBoundingSet` | `CapabilityBoundingSet=` | distinct capabilities from `systemdCapabilities`, sorted; empty drops every capability |
|
|
485
|
+
| `ambientCapabilities` | `AmbientCapabilities=` | distinct capabilities, sorted; within `capabilityBoundingSet` when both are given |
|
|
486
|
+
| `restrictAddressFamilies` | `RestrictAddressFamilies=` | at least one distinct family of `AF_INET`, `AF_INET6`, `AF_NETLINK`, `AF_PACKET`, `AF_UNIX`, `AF_VSOCK`, sorted; includes `AF_UNIX` for a notify service |
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
const relay = new SystemdServiceDefinition({
|
|
490
|
+
unitName: 'relay.service',
|
|
491
|
+
description: 'Cluster relay',
|
|
492
|
+
executable: '/opt/relay/current/relay',
|
|
493
|
+
args: [],
|
|
494
|
+
workingDirectory: '/opt/relay/current',
|
|
495
|
+
user: 'relay',
|
|
496
|
+
group: 'relay',
|
|
497
|
+
dynamicUser: true,
|
|
498
|
+
restart: 'always',
|
|
499
|
+
restartSeconds: 2,
|
|
500
|
+
killMode: 'mixed',
|
|
501
|
+
timeoutStopSeconds: 60,
|
|
502
|
+
dependencies: {
|
|
503
|
+
defaultDependencies: true,
|
|
504
|
+
after: ['network-online.target'],
|
|
505
|
+
before: ['node.service'],
|
|
506
|
+
requires: [],
|
|
507
|
+
wants: ['network-online.target'],
|
|
508
|
+
conflicts: [],
|
|
509
|
+
},
|
|
510
|
+
environment: { RELAY_BIND_PORT: '8443' },
|
|
511
|
+
loadCredentials: [{ name: 'RELAY_AUTHORIZATION' }],
|
|
512
|
+
noNewPrivileges: true,
|
|
513
|
+
protectSystem: 'strict',
|
|
514
|
+
protectHome: true,
|
|
515
|
+
privateTmp: true,
|
|
516
|
+
privateDevices: true,
|
|
517
|
+
capabilityBoundingSet: [],
|
|
518
|
+
restrictAddressFamilies: ['AF_INET', 'AF_INET6', 'AF_UNIX'],
|
|
519
|
+
});
|
|
520
|
+
// DynamicUser=yes
|
|
521
|
+
// LoadCredential=RELAY_AUTHORIZATION
|
|
522
|
+
// CapabilityBoundingSet=
|
|
523
|
+
// RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
A credential's value never appears in the unit, in the service's environment (so child processes
|
|
527
|
+
do not inherit it) or in `systemctl show`: the service reads it from
|
|
528
|
+
`$CREDENTIALS_DIRECTORY/<name>`, readable by the service's user only. `name` is 1–255 characters of
|
|
529
|
+
`[A-Za-z0-9_.@-]`, not starting with `.`. `source` is either an absolute normalized path of
|
|
530
|
+
`[A-Za-z0-9_.@/+-]` characters without a trailing `/` (a file, a directory of files or an `AF_UNIX`
|
|
531
|
+
socket), or a credential name that systemd looks up among its own credentials and then in
|
|
532
|
+
`/etc/credstore/`, `/run/credstore/` and `/usr/lib/credstore/` (for encrypted credentials also the
|
|
533
|
+
`credstore.encrypted` directories). Without `source`, systemd looks up `name` itself, so
|
|
534
|
+
`{ name: 'RELAY_AUTHORIZATION' }` reads `/etc/credstore/RELAY_AUTHORIZATION`; a `source` equal to
|
|
535
|
+
`name` is refused, as it only spells the same lookup twice. These characters need no quoting and
|
|
536
|
+
hold no `%` specifier, `:` separator or escape, so systemd reads each line exactly as written.
|
|
537
|
+
Encrypted credentials are sealed with `systemd-creds encrypt` (host key or TPM2). Systemd keeps both
|
|
538
|
+
kinds in one table by name, so a name appears in at most one list; at most 64 per list. The
|
|
539
|
+
directory search needs systemd 250 or later; an absolute `source` works from systemd 247.
|
|
540
|
+
|
|
541
|
+
`dynamicUser: true` allocates the account when the service starts. Systemd would use a static
|
|
542
|
+
account of the same name instead, so `user` and `group` must not be `root`, and they have at most
|
|
543
|
+
31 characters, the longest name systemd allocates. For such a service systemd enforces
|
|
544
|
+
`ProtectSystem=strict`, `PrivateTmp=yes`, `NoNewPrivileges=yes`, `RestrictSUIDSGID=yes` and at least
|
|
545
|
+
`ProtectHome=read-only`; a definition that states any of them weaker is refused, so the unit never
|
|
546
|
+
reads as less confined than it is.
|
|
547
|
+
|
|
548
|
+
Each refusal throws `SystemdServiceDefinitionError`, whose `option` names the offending option (for
|
|
549
|
+
every option of a definition, not only these) and whose message is
|
|
550
|
+
`Invalid direct systemd service definition: <option>.`. `option` is `'options'` for the definition
|
|
551
|
+
object itself: not a plain object, an unknown or accessor key, or a unit over 16384 bytes. No
|
|
552
|
+
refusal carries the offending value.
|
|
553
|
+
|
|
467
554
|
Optional `dependencies` describes the complete literal runtime relationships:
|
|
468
555
|
|
|
469
556
|
```typescript
|
|
@@ -605,6 +692,77 @@ other process in the unit survives, so the main process must own their shutdown
|
|
|
605
692
|
deliberate retention (as with `Delegate=yes` workloads). A successful systemd start still requires application readiness
|
|
606
693
|
verification, and a completed stop alone does not prove database drain.
|
|
607
694
|
|
|
695
|
+
### Starting a program in its own transient scope
|
|
696
|
+
|
|
697
|
+
A service that launches a long-lived program keeps that program in its own
|
|
698
|
+
control group, so stopping the service ends the program too, even when the
|
|
699
|
+
program detached itself with `setsid`. `runInTransientScope()` starts a program in
|
|
700
|
+
a new transient scope unit of the calling user's systemd manager instead
|
|
701
|
+
(`systemd-run --user --scope`): the program and every process it starts run in
|
|
702
|
+
that scope and outlive the caller's unit. The call waits for the program's direct
|
|
703
|
+
process and returns its result; a program that daemonizes and exits returns while
|
|
704
|
+
its daemon keeps the scope active, and the scope ends when its last process exits.
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
import { runInTransientScope, SystemdTransientScopeError } from '@push.rocks/smartdaemon';
|
|
708
|
+
|
|
709
|
+
try {
|
|
710
|
+
const result = await runInTransientScope({
|
|
711
|
+
scope: 'user',
|
|
712
|
+
description: 'Codex app-server daemon',
|
|
713
|
+
executable: '/home/owner/.local/bin/codex',
|
|
714
|
+
args: ['app-server', 'daemon', 'start'],
|
|
715
|
+
workingDirectory: '/home/owner',
|
|
716
|
+
environment: { HOME: '/home/owner', CODEX_HOME: '/home/owner/.codex', PATH: '/usr/bin:/bin' },
|
|
717
|
+
});
|
|
718
|
+
console.log(result.unitName, result.exitCode, result.stdout);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (error instanceof SystemdTransientScopeError && error.code === 'user_manager_unavailable') {
|
|
721
|
+
// No user manager: the caller decides how to run the program without one.
|
|
722
|
+
}
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
The result holds the scope's `unitName`, the direct process's `exitCode` (or the
|
|
728
|
+
`signal` that ended it), and its `stdout` and `stderr`, UTF-8 decoded. A failure
|
|
729
|
+
status is a result, not an error. The program is executed directly from an
|
|
730
|
+
absolute `executable` with literal `args`, with no shell, PATH search or `$`
|
|
731
|
+
expansion (systemd 258 expands `$` in a scope's command line unless told not to,
|
|
732
|
+
and the call always passes `--expand-environment=no`), in the absolute
|
|
733
|
+
`workingDirectory`, with stdin on `/dev/null`. Its
|
|
734
|
+
environment is exactly `environment` plus the manager's `XDG_RUNTIME_DIR` and
|
|
735
|
+
systemd's `INVOCATION_ID`: nothing of the calling process is inherited, so pass
|
|
736
|
+
every variable the program needs. `XDG_RUNTIME_DIR`, `INVOCATION_ID` and names
|
|
737
|
+
starting with `SYSTEMD_` or `DBUS_`, which would configure systemd-run itself, are
|
|
738
|
+
refused. `unitName` names the scope (a `.scope` name) and defaults to a unique
|
|
739
|
+
`smartdaemon-<32 hex digits>.scope`; a scope of that name that is still active
|
|
740
|
+
refuses a second start. The literal `description` is the unit's description.
|
|
741
|
+
|
|
742
|
+
Failures throw `SystemdTransientScopeError` with the scope's `unitName` (null for
|
|
743
|
+
refused options), the `stderr` read so far and one of these codes:
|
|
744
|
+
|
|
745
|
+
| Code | Meaning |
|
|
746
|
+
| --- | --- |
|
|
747
|
+
| `invalid_options` | Malformed options, or a runtime directory that is not a private directory of the caller |
|
|
748
|
+
| `unsupported_platform` | The process is not running on Linux |
|
|
749
|
+
| `user_manager_unavailable` | No `XDG_RUNTIME_DIR` (or `runtimeDirectory`), a runtime directory that does not exist, or a manager that does not answer `systemctl --user` |
|
|
750
|
+
| `scope_start_failed` | systemd-run exited without reporting the scope running; the program has not run |
|
|
751
|
+
| `timed_out` | The manager probe or the program did not finish and close its output within `commandTimeoutMs` |
|
|
752
|
+
| `output_limit` | The program's stdout or stderr exceeded 1 MiB |
|
|
753
|
+
|
|
754
|
+
The call completes once the direct process has exited and its stdout and stderr
|
|
755
|
+
have closed, so a daemon must not keep them open (Codex, for example, redirects
|
|
756
|
+
its daemon's output); otherwise the call runs into its timeout. On `timed_out` and
|
|
757
|
+
`output_limit` the direct process is killed and the processes it started stay in
|
|
758
|
+
the scope. `commandTimeoutMs` (default 120000, maximum 600000) bounds the probe
|
|
759
|
+
and the program each. Trusted owning code can set `runtimeDirectory` and the
|
|
760
|
+
absolute `systemdRunPath` and `systemctlPath`. systemd-run reports the started
|
|
761
|
+
scope as its first stderr line, which systemd 254 and later print; an older
|
|
762
|
+
systemd-run, which lacks `--expand-environment`, fails with `scope_start_failed`.
|
|
763
|
+
If the program cannot be executed after the scope started, systemd-run reports
|
|
764
|
+
that as the program's exit status 1.
|
|
765
|
+
|
|
608
766
|
### SmartDaemonService Class
|
|
609
767
|
|
|
610
768
|
```typescript
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import * as plugins from './smartdaemon.plugins.js';
|
|
2
|
-
import { environmentByteLimit, environmentVariableLimit, isCleanText, renderServiceRelations,
|
|
3
|
-
type ISystemdServiceDependencies, type ISystemdServiceInstallTargets
|
|
2
|
+
import { environmentByteLimit, environmentVariableLimit, isCleanText, renderServiceRelations, systemdAddressFamilies,
|
|
3
|
+
systemdCapabilities, type TSystemdServiceStartup, type ISystemdServiceDependencies, type ISystemdServiceInstallTargets,
|
|
4
|
+
type ISystemdServiceCredential, type TSystemdCapability, type TSystemdAddressFamily } from './systemd.definition.js';
|
|
4
5
|
export type { ISystemdServiceStartup, ISystemdServiceNotifyStartup, TSystemdServiceStartup,
|
|
5
|
-
ISystemdServiceDependencies, ISystemdServiceInstallTargets
|
|
6
|
+
ISystemdServiceDependencies, ISystemdServiceInstallTargets, ISystemdServiceCredential, TSystemdCapability,
|
|
7
|
+
TSystemdAddressFamily } from './systemd.definition.js';
|
|
8
|
+
export { systemdCapabilities, systemdAddressFamilies } from './systemd.definition.js';
|
|
6
9
|
|
|
7
10
|
interface ISystemdServiceDefinitionBaseOptions {
|
|
8
11
|
unitName: string;
|
|
@@ -33,6 +36,53 @@ interface ISystemdServiceDefinitionBaseOptions {
|
|
|
33
36
|
* 64 variables and 8192 UTF-8 bytes of `NAME=value` in total. They are taken literally: `%` is escaped, and `Environment=` never expands `$`.
|
|
34
37
|
*/
|
|
35
38
|
environment?: Readonly<Record<string, string>>;
|
|
39
|
+
/**
|
|
40
|
+
* System scope only. Runs the service as a transient user and group allocated at start
|
|
41
|
+
* (`DynamicUser=`), named by `user` and `group`, which then must not be `root` and have at most
|
|
42
|
+
* 31 characters. Systemd enforces `ProtectSystem=strict`, `PrivateTmp=`, `NoNewPrivileges=` and
|
|
43
|
+
* `RestrictSUIDSGID=` for such a service, so a definition that weakens any of them is refused.
|
|
44
|
+
*/
|
|
45
|
+
dynamicUser?: boolean;
|
|
46
|
+
/** System scope only. Credentials read in plaintext, one `LoadCredential=` line each, ordered by name. */
|
|
47
|
+
loadCredentials?: readonly ISystemdServiceCredential[];
|
|
48
|
+
/** System scope only. Credentials sealed with `systemd-creds encrypt`, one `LoadCredentialEncrypted=` line each, ordered by name. */
|
|
49
|
+
loadCredentialsEncrypted?: readonly ISystemdServiceCredential[];
|
|
50
|
+
/** System scope only (`NoNewPrivileges=`). */
|
|
51
|
+
noNewPrivileges?: boolean;
|
|
52
|
+
/** System scope only (`ProtectSystem=`): `true` mounts `/usr` and the boot loader read-only, `'full'` also `/etc`, `'strict'` the whole file system but the API file systems. */
|
|
53
|
+
protectSystem?: boolean | 'full' | 'strict';
|
|
54
|
+
/** System scope only (`ProtectHome=`). */
|
|
55
|
+
protectHome?: boolean | 'read-only' | 'tmpfs';
|
|
56
|
+
/** System scope only (`PrivateTmp=`). */
|
|
57
|
+
privateTmp?: boolean;
|
|
58
|
+
/** System scope only (`PrivateDevices=`). */
|
|
59
|
+
privateDevices?: boolean;
|
|
60
|
+
/** System scope only (`ProtectKernelTunables=`). */
|
|
61
|
+
protectKernelTunables?: boolean;
|
|
62
|
+
/** System scope only (`ProtectKernelModules=`). */
|
|
63
|
+
protectKernelModules?: boolean;
|
|
64
|
+
/** System scope only (`ProtectKernelLogs=`). */
|
|
65
|
+
protectKernelLogs?: boolean;
|
|
66
|
+
/** System scope only (`ProtectControlGroups=`). */
|
|
67
|
+
protectControlGroups?: boolean;
|
|
68
|
+
/** System scope only (`ProtectClock=`). */
|
|
69
|
+
protectClock?: boolean;
|
|
70
|
+
/** System scope only (`ProtectHostname=`). */
|
|
71
|
+
protectHostname?: boolean;
|
|
72
|
+
/** System scope only (`RestrictNamespaces=`): `true` forbids creating every kind of namespace. */
|
|
73
|
+
restrictNamespaces?: boolean;
|
|
74
|
+
/** System scope only (`RestrictRealtime=`). */
|
|
75
|
+
restrictRealtime?: boolean;
|
|
76
|
+
/** System scope only (`RestrictSUIDSGID=`). */
|
|
77
|
+
restrictSUIDSGID?: boolean;
|
|
78
|
+
/** System scope only (`LockPersonality=`). */
|
|
79
|
+
lockPersonality?: boolean;
|
|
80
|
+
/** System scope only. The only capabilities the service's processes can ever hold (`CapabilityBoundingSet=`); empty drops all. */
|
|
81
|
+
capabilityBoundingSet?: readonly TSystemdCapability[];
|
|
82
|
+
/** System scope only. Capabilities a non-root service's processes receive (`AmbientCapabilities=`); within `capabilityBoundingSet` when both are given. */
|
|
83
|
+
ambientCapabilities?: readonly TSystemdCapability[];
|
|
84
|
+
/** System scope only. The only socket families the service may create (`RestrictAddressFamilies=`); at least one, and `AF_UNIX` for a notify service. */
|
|
85
|
+
restrictAddressFamilies?: readonly TSystemdAddressFamily[];
|
|
36
86
|
}
|
|
37
87
|
|
|
38
88
|
export type ISystemdServiceDefinitionOptions = ISystemdServiceDefinitionBaseOptions & (
|
|
@@ -40,13 +90,43 @@ export type ISystemdServiceDefinitionOptions = ISystemdServiceDefinitionBaseOpti
|
|
|
40
90
|
{ scope: 'user'; user?: never; group?: never }
|
|
41
91
|
);
|
|
42
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The option a refusal names: a key of the definition, or `'options'` for the definition object
|
|
95
|
+
* itself (not a plain object, an unknown or accessor key, or a unit over 16384 bytes).
|
|
96
|
+
*/
|
|
97
|
+
export type TSystemdServiceDefinitionOption = keyof ISystemdServiceDefinitionOptions | 'options';
|
|
98
|
+
|
|
43
99
|
export class SystemdServiceDefinitionError extends Error {
|
|
44
|
-
|
|
45
|
-
|
|
100
|
+
public readonly option: TSystemdServiceDefinitionOption;
|
|
101
|
+
|
|
102
|
+
constructor(option: TSystemdServiceDefinitionOption) {
|
|
103
|
+
super(`Invalid direct systemd service definition: ${option}.`);
|
|
46
104
|
this.name = 'SystemdServiceDefinitionError';
|
|
105
|
+
this.option = option;
|
|
47
106
|
}
|
|
48
107
|
}
|
|
49
108
|
|
|
109
|
+
/** Sandboxing switches in their rendering order, each with its directive. */
|
|
110
|
+
const sandboxSwitches = [
|
|
111
|
+
['privateTmp', 'PrivateTmp'],
|
|
112
|
+
['privateDevices', 'PrivateDevices'],
|
|
113
|
+
['protectKernelTunables', 'ProtectKernelTunables'],
|
|
114
|
+
['protectKernelModules', 'ProtectKernelModules'],
|
|
115
|
+
['protectKernelLogs', 'ProtectKernelLogs'],
|
|
116
|
+
['protectControlGroups', 'ProtectControlGroups'],
|
|
117
|
+
['protectClock', 'ProtectClock'],
|
|
118
|
+
['protectHostname', 'ProtectHostname'],
|
|
119
|
+
['restrictNamespaces', 'RestrictNamespaces'],
|
|
120
|
+
['restrictRealtime', 'RestrictRealtime'],
|
|
121
|
+
['restrictSUIDSGID', 'RestrictSUIDSGID'],
|
|
122
|
+
['lockPersonality', 'LockPersonality'],
|
|
123
|
+
] as const;
|
|
124
|
+
|
|
125
|
+
/** Options a user manager's service cannot carry: they need the system manager's privileges. */
|
|
126
|
+
const systemOnlyOptions = ['dynamicUser', 'loadCredentials', 'loadCredentialsEncrypted', 'noNewPrivileges',
|
|
127
|
+
'protectSystem', 'protectHome', ...sandboxSwitches.map(([option]) => option), 'capabilityBoundingSet',
|
|
128
|
+
'ambientCapabilities', 'restrictAddressFamilies'] as const;
|
|
129
|
+
|
|
50
130
|
/** Literal direct-execution definition; never introduces a shell or environment expansion. */
|
|
51
131
|
export class SystemdServiceDefinition {
|
|
52
132
|
public readonly unitName: string;
|
|
@@ -55,26 +135,27 @@ export class SystemdServiceDefinition {
|
|
|
55
135
|
public readonly sha256: string;
|
|
56
136
|
|
|
57
137
|
constructor(options: ISystemdServiceDefinitionOptions) {
|
|
58
|
-
const
|
|
59
|
-
|
|
138
|
+
const fail = (option: TSystemdServiceDefinitionOption): never => {
|
|
139
|
+
throw new SystemdServiceDefinitionError(option);
|
|
140
|
+
};
|
|
141
|
+
const requireValue = (value: unknown, option: TSystemdServiceDefinitionOption): void => {
|
|
142
|
+
if (!value) fail(option);
|
|
60
143
|
};
|
|
61
144
|
requireValue(options && !plugins.util.types.isProxy(options) &&
|
|
62
|
-
Object.getPrototypeOf(options) === Object.prototype);
|
|
145
|
+
Object.getPrototypeOf(options) === Object.prototype, 'options');
|
|
63
146
|
const scopeDescriptor = Object.getOwnPropertyDescriptor(options, 'scope');
|
|
64
147
|
requireValue(scopeDescriptor === undefined ||
|
|
65
|
-
scopeDescriptor.enumerable && 'value' in scopeDescriptor);
|
|
148
|
+
scopeDescriptor.enumerable && 'value' in scopeDescriptor, 'scope');
|
|
66
149
|
const scopeValue: unknown = scopeDescriptor?.value ?? 'system';
|
|
67
|
-
if (scopeValue !== 'system' && scopeValue !== 'user')
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
const scope = scopeValue;
|
|
150
|
+
if (scopeValue !== 'system' && scopeValue !== 'user') fail('scope');
|
|
151
|
+
const scope = scopeValue as 'system' | 'user';
|
|
71
152
|
// Systemd ignores an assignment whose text is not clean UTF-8, leaving the unit unloadable.
|
|
72
153
|
const text = (value: unknown, maximum = 4096): value is string =>
|
|
73
154
|
typeof value === 'string' && value.length <= maximum && isCleanText(value);
|
|
74
|
-
const absolute = (value: unknown) =>
|
|
155
|
+
const absolute = (value: unknown): value is string =>
|
|
75
156
|
text(value) && plugins.path.isAbsolute(value) &&
|
|
76
157
|
plugins.path.normalize(value) === value;
|
|
77
|
-
const keys = [
|
|
158
|
+
const keys: TSystemdServiceDefinitionOption[] = [
|
|
78
159
|
'unitName',
|
|
79
160
|
'description',
|
|
80
161
|
'executable',
|
|
@@ -83,31 +164,40 @@ export class SystemdServiceDefinition {
|
|
|
83
164
|
'restart',
|
|
84
165
|
'killMode',
|
|
85
166
|
'timeoutStopSeconds',
|
|
86
|
-
...(scope === 'system' ? ['user', 'group'] : []),
|
|
167
|
+
...(scope === 'system' ? ['user', 'group'] as const : []),
|
|
87
168
|
];
|
|
88
|
-
const optionalKeys = ['scope', 'startup', 'dependencies', 'install',
|
|
89
|
-
'limitNoFile', 'tasksMax', 'oomScoreAdjust', 'environment'
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
169
|
+
const optionalKeys: TSystemdServiceDefinitionOption[] = ['scope', 'startup', 'dependencies', 'install',
|
|
170
|
+
'restartSeconds', 'delegate', 'limitNoFile', 'tasksMax', 'oomScoreAdjust', 'environment',
|
|
171
|
+
...systemOnlyOptions];
|
|
172
|
+
// Every own key is a known option held by an enumerable data property, so no accessor or trap runs.
|
|
173
|
+
for (const key of Reflect.ownKeys(options)) {
|
|
174
|
+
requireValue(typeof key === 'string' && [...keys, ...optionalKeys, 'user', 'group'].includes(key as never), 'options');
|
|
175
|
+
const option = key as TSystemdServiceDefinitionOption;
|
|
176
|
+
const descriptor = Object.getOwnPropertyDescriptor(options, key);
|
|
177
|
+
requireValue(keys.includes(option) || optionalKeys.includes(option), option);
|
|
178
|
+
requireValue(descriptor?.enumerable && 'value' in descriptor, option);
|
|
179
|
+
}
|
|
180
|
+
for (const key of keys) requireValue(Object.getOwnPropertyDescriptor(options, key), key);
|
|
97
181
|
requireValue(
|
|
98
182
|
typeof options.unitName === 'string' &&
|
|
99
183
|
/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,230}\.service$/.test(options.unitName),
|
|
184
|
+
'unitName',
|
|
100
185
|
);
|
|
101
186
|
requireValue(
|
|
102
187
|
text(options.description, 1024) && options.description.length > 0 &&
|
|
103
188
|
options.description.trim() === options.description && !options.description.includes('\\'),
|
|
189
|
+
'description',
|
|
104
190
|
);
|
|
105
191
|
requireValue(
|
|
106
192
|
absolute(options.executable) && options.executable !== '/' &&
|
|
107
|
-
!/[\\"']/.test(options.executable) && !options.executable.endsWith('/')
|
|
108
|
-
|
|
193
|
+
!/[\\"']/.test(options.executable) && !options.executable.endsWith('/'),
|
|
194
|
+
'executable',
|
|
195
|
+
);
|
|
196
|
+
requireValue(
|
|
197
|
+
absolute(options.workingDirectory) &&
|
|
109
198
|
options.workingDirectory.trim() === options.workingDirectory &&
|
|
110
199
|
!options.workingDirectory.includes('\\'),
|
|
200
|
+
'workingDirectory',
|
|
111
201
|
);
|
|
112
202
|
requireValue(
|
|
113
203
|
Array.isArray(options.args) && !plugins.util.types.isProxy(options.args) && Object.getPrototypeOf(options.args) === Array.prototype &&
|
|
@@ -118,43 +208,44 @@ export class SystemdServiceDefinition {
|
|
|
118
208
|
(_, index) => Object.getOwnPropertyDescriptor(options.args, String(index)),
|
|
119
209
|
)
|
|
120
210
|
.every((descriptor) => descriptor && 'value' in descriptor && text(descriptor.value)),
|
|
211
|
+
'args',
|
|
121
212
|
);
|
|
122
213
|
if (scope === 'system') {
|
|
123
|
-
|
|
124
|
-
[
|
|
125
|
-
|
|
126
|
-
),
|
|
127
|
-
);
|
|
214
|
+
for (const key of ['user', 'group'] as const) {
|
|
215
|
+
requireValue(typeof options[key] === 'string' && /^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$/.test(options[key]), key);
|
|
216
|
+
}
|
|
128
217
|
}
|
|
218
|
+
requireValue(['no', 'on-failure', 'always'].includes(options.restart), 'restart');
|
|
219
|
+
requireValue(['mixed', 'control-group', 'process'].includes(options.killMode), 'killMode');
|
|
129
220
|
requireValue(
|
|
130
|
-
|
|
131
|
-
['mixed', 'control-group', 'process'].includes(options.killMode) &&
|
|
132
|
-
Number.isSafeInteger(options.timeoutStopSeconds) &&
|
|
221
|
+
Number.isSafeInteger(options.timeoutStopSeconds) &&
|
|
133
222
|
options.timeoutStopSeconds >= 1 && options.timeoutStopSeconds <= 600,
|
|
223
|
+
'timeoutStopSeconds',
|
|
134
224
|
);
|
|
135
225
|
const integer = (value: unknown, minimum: number, maximum: number): value is number =>
|
|
136
226
|
Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum;
|
|
137
227
|
const limit = (value: unknown): value is number | 'infinity' =>
|
|
138
228
|
value === 'infinity' || integer(value, 1, Number.MAX_SAFE_INTEGER);
|
|
229
|
+
const yesNo = (value: boolean) => value ? 'yes' : 'no';
|
|
139
230
|
// Each optional directive is absent unless supplied, so existing definitions keep their bytes.
|
|
140
231
|
const service: string[] = [];
|
|
141
232
|
if (options.restartSeconds !== undefined) {
|
|
142
|
-
requireValue(integer(options.restartSeconds, 1, 600) && options.restart !== 'no');
|
|
233
|
+
requireValue(integer(options.restartSeconds, 1, 600) && options.restart !== 'no', 'restartSeconds');
|
|
143
234
|
}
|
|
144
235
|
if (options.delegate !== undefined) {
|
|
145
|
-
requireValue(typeof options.delegate === 'boolean');
|
|
146
|
-
service.push(`Delegate=${options.delegate
|
|
236
|
+
requireValue(typeof options.delegate === 'boolean', 'delegate');
|
|
237
|
+
service.push(`Delegate=${yesNo(options.delegate)}`);
|
|
147
238
|
}
|
|
148
239
|
if (options.limitNoFile !== undefined) {
|
|
149
|
-
requireValue(limit(options.limitNoFile));
|
|
240
|
+
requireValue(limit(options.limitNoFile), 'limitNoFile');
|
|
150
241
|
service.push(`LimitNOFILE=${options.limitNoFile}`);
|
|
151
242
|
}
|
|
152
243
|
if (options.tasksMax !== undefined) {
|
|
153
|
-
requireValue(limit(options.tasksMax));
|
|
244
|
+
requireValue(limit(options.tasksMax), 'tasksMax');
|
|
154
245
|
service.push(`TasksMax=${options.tasksMax}`);
|
|
155
246
|
}
|
|
156
247
|
if (options.oomScoreAdjust !== undefined) {
|
|
157
|
-
requireValue(integer(options.oomScoreAdjust, -1000, 1000));
|
|
248
|
+
requireValue(integer(options.oomScoreAdjust, -1000, 1000), 'oomScoreAdjust');
|
|
158
249
|
service.push(`OOMScoreAdjust=${options.oomScoreAdjust}`);
|
|
159
250
|
}
|
|
160
251
|
// Quoting and percent/dollar escaping follow systemd.syntax and systemd.service.
|
|
@@ -163,26 +254,135 @@ export class SystemdServiceDefinition {
|
|
|
163
254
|
.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')
|
|
164
255
|
.replaceAll('$', () => command ? '$$' : '$') +
|
|
165
256
|
'"';
|
|
257
|
+
const plainObject = (value: unknown, option: TSystemdServiceDefinitionOption) =>
|
|
258
|
+
requireValue(value !== null && typeof value === 'object' && !plugins.util.types.isProxy(value) &&
|
|
259
|
+
Object.getPrototypeOf(value) === Object.prototype, option);
|
|
166
260
|
if (options.environment !== undefined) {
|
|
167
261
|
// Read each variable once through its own data descriptor, so no accessor or trap runs.
|
|
168
262
|
const variables: unknown = options.environment;
|
|
169
|
-
|
|
170
|
-
!plugins.util.types.isProxy(variables) && Object.getPrototypeOf(variables) === Object.prototype);
|
|
263
|
+
plainObject(variables, 'environment');
|
|
171
264
|
const names = Reflect.ownKeys(variables as object);
|
|
172
|
-
requireValue(names.length <= environmentVariableLimit);
|
|
265
|
+
requireValue(names.length <= environmentVariableLimit, 'environment');
|
|
173
266
|
const assignments = new Map<string, string>();
|
|
174
267
|
for (const name of names) {
|
|
175
268
|
const descriptor = Object.getOwnPropertyDescriptor(variables, name);
|
|
176
269
|
requireValue(typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(name) &&
|
|
177
|
-
descriptor?.enumerable && 'value' in descriptor && text(descriptor.value));
|
|
270
|
+
descriptor?.enumerable && 'value' in descriptor && text(descriptor.value), 'environment');
|
|
178
271
|
assignments.set(name as string, `${name as string}=${descriptor!.value as string}`);
|
|
179
272
|
}
|
|
180
|
-
requireValue(Buffer.byteLength([...assignments.values()].join('')) <= environmentByteLimit);
|
|
273
|
+
requireValue(Buffer.byteLength([...assignments.values()].join('')) <= environmentByteLimit, 'environment');
|
|
181
274
|
// Environment= unquotes and unescapes each word and expands `%` specifiers, never `$`.
|
|
182
275
|
service.push(...[...assignments.keys()].sort()
|
|
183
276
|
.map((name) => `Environment=${quote(assignments.get(name)!)}`));
|
|
184
277
|
}
|
|
185
|
-
|
|
278
|
+
// Every system-only option is refused by name in a user manager's service.
|
|
279
|
+
for (const option of systemOnlyOptions) {
|
|
280
|
+
requireValue(scope === 'system' || options[option] === undefined, option);
|
|
281
|
+
}
|
|
282
|
+
/** Reads a plain array once through its data descriptors: distinct items, sorted. */
|
|
283
|
+
const list = <T>(value: unknown, option: TSystemdServiceDefinitionOption, maximum: number,
|
|
284
|
+
item: (entry: unknown) => T, identity: (entry: T) => string): T[] => {
|
|
285
|
+
requireValue(Array.isArray(value) && !plugins.util.types.isProxy(value) &&
|
|
286
|
+
Object.getPrototypeOf(value) === Array.prototype && value.length <= maximum &&
|
|
287
|
+
Reflect.ownKeys(value).length === value.length + 1, option);
|
|
288
|
+
const result = Array.from({ length: (value as unknown[]).length }, (_, index) => {
|
|
289
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
290
|
+
requireValue(descriptor?.enumerable && 'value' in descriptor, option);
|
|
291
|
+
return item(descriptor!.value);
|
|
292
|
+
});
|
|
293
|
+
const identities = result.map(identity);
|
|
294
|
+
requireValue(new Set(identities).size === identities.length, option);
|
|
295
|
+
return result.sort((left, right) => identity(left) < identity(right) ? -1 : 1);
|
|
296
|
+
};
|
|
297
|
+
// Credential names and sources hold no `%`, quote, backslash, whitespace or `:`, so systemd's
|
|
298
|
+
// `NAME[:SOURCE]` parser and its specifier expansion read them exactly as written.
|
|
299
|
+
const credentialName = (value: unknown): value is string =>
|
|
300
|
+
typeof value === 'string' && /^[A-Za-z0-9_@-][A-Za-z0-9_.@-]{0,254}$/.test(value);
|
|
301
|
+
const credentials = (value: unknown, option: 'loadCredentials' | 'loadCredentialsEncrypted') =>
|
|
302
|
+
list(value, option, 64, (entry): ISystemdServiceCredential => {
|
|
303
|
+
plainObject(entry, option);
|
|
304
|
+
const entryKeys = Reflect.ownKeys(entry as object);
|
|
305
|
+
requireValue(entryKeys.includes('name') && entryKeys.every((key) => (key === 'name' || key === 'source') &&
|
|
306
|
+
Object.getOwnPropertyDescriptor(entry, key)?.enumerable &&
|
|
307
|
+
'value' in Object.getOwnPropertyDescriptor(entry, key)!), option);
|
|
308
|
+
const { name, source } = entry as Record<'name' | 'source', unknown>;
|
|
309
|
+
requireValue(credentialName(name), option);
|
|
310
|
+
if (entryKeys.includes('source')) {
|
|
311
|
+
requireValue(source !== name && (credentialName(source) || absolute(source) && !source.endsWith('/') &&
|
|
312
|
+
/^[A-Za-z0-9_.@/+-]+$/.test(source)), option);
|
|
313
|
+
}
|
|
314
|
+
return entryKeys.includes('source') ? { name: name as string, source: source as string } : { name: name as string };
|
|
315
|
+
}, (entry) => entry.name);
|
|
316
|
+
const plainCredentials = options.loadCredentials === undefined ? [] : credentials(options.loadCredentials, 'loadCredentials');
|
|
317
|
+
const encryptedCredentials = options.loadCredentialsEncrypted === undefined
|
|
318
|
+
? [] : credentials(options.loadCredentialsEncrypted, 'loadCredentialsEncrypted');
|
|
319
|
+
// Systemd keeps both kinds in one table by name, so a name loads from exactly one of them.
|
|
320
|
+
requireValue(encryptedCredentials.every((entry) => !plainCredentials.some(({ name }) => name === entry.name)),
|
|
321
|
+
'loadCredentialsEncrypted');
|
|
322
|
+
const credential = ({ name, source }: ISystemdServiceCredential) => source === undefined ? name : `${name}:${source}`;
|
|
323
|
+
service.push(...plainCredentials.map((entry) => `LoadCredential=${credential(entry)}`));
|
|
324
|
+
service.push(...encryptedCredentials.map((entry) => `LoadCredentialEncrypted=${credential(entry)}`));
|
|
325
|
+
const dynamicUser = options.dynamicUser;
|
|
326
|
+
if (dynamicUser !== undefined) {
|
|
327
|
+
requireValue(typeof dynamicUser === 'boolean', 'dynamicUser');
|
|
328
|
+
// A static account of the same name would be used instead, so the name must not be root's,
|
|
329
|
+
// and systemd allocates only names that fit utmp.
|
|
330
|
+
requireValue(!dynamicUser || [options.user, options.group].every((name) =>
|
|
331
|
+
name !== 'root' && typeof name === 'string' && name.length <= 31), 'dynamicUser');
|
|
332
|
+
}
|
|
333
|
+
// Settings systemd forces on for a dynamic user, so the unit never states a weaker one.
|
|
334
|
+
const forced = (option: TSystemdServiceDefinitionOption, weakened: boolean) =>
|
|
335
|
+
requireValue(!(dynamicUser === true && weakened), option);
|
|
336
|
+
if (options.noNewPrivileges !== undefined) {
|
|
337
|
+
requireValue(typeof options.noNewPrivileges === 'boolean', 'noNewPrivileges');
|
|
338
|
+
forced('noNewPrivileges', !options.noNewPrivileges);
|
|
339
|
+
service.push(`NoNewPrivileges=${yesNo(options.noNewPrivileges)}`);
|
|
340
|
+
}
|
|
341
|
+
if (options.protectSystem !== undefined) {
|
|
342
|
+
requireValue([true, false, 'full', 'strict'].includes(options.protectSystem), 'protectSystem');
|
|
343
|
+
forced('protectSystem', options.protectSystem !== 'strict');
|
|
344
|
+
service.push(`ProtectSystem=${typeof options.protectSystem === 'boolean'
|
|
345
|
+
? yesNo(options.protectSystem) : options.protectSystem}`);
|
|
346
|
+
}
|
|
347
|
+
if (options.protectHome !== undefined) {
|
|
348
|
+
requireValue([true, false, 'read-only', 'tmpfs'].includes(options.protectHome), 'protectHome');
|
|
349
|
+
forced('protectHome', options.protectHome === false);
|
|
350
|
+
service.push(`ProtectHome=${typeof options.protectHome === 'boolean'
|
|
351
|
+
? yesNo(options.protectHome) : options.protectHome}`);
|
|
352
|
+
}
|
|
353
|
+
for (const [option, directive] of sandboxSwitches) {
|
|
354
|
+
const value = options[option];
|
|
355
|
+
if (value === undefined) continue;
|
|
356
|
+
requireValue(typeof value === 'boolean', option);
|
|
357
|
+
forced(option, (option === 'privateTmp' || option === 'restrictSUIDSGID') && !value);
|
|
358
|
+
service.push(`${directive}=${yesNo(value)}`);
|
|
359
|
+
}
|
|
360
|
+
const capabilities = (value: unknown, option: 'capabilityBoundingSet' | 'ambientCapabilities') =>
|
|
361
|
+
list(value, option, systemdCapabilities.length, (entry) => {
|
|
362
|
+
requireValue((systemdCapabilities as readonly unknown[]).includes(entry), option);
|
|
363
|
+
return entry as TSystemdCapability;
|
|
364
|
+
}, (entry) => entry);
|
|
365
|
+
const bounding = options.capabilityBoundingSet === undefined
|
|
366
|
+
? undefined : capabilities(options.capabilityBoundingSet, 'capabilityBoundingSet');
|
|
367
|
+
const ambient = options.ambientCapabilities === undefined
|
|
368
|
+
? undefined : capabilities(options.ambientCapabilities, 'ambientCapabilities');
|
|
369
|
+
// An ambient capability outside the bounding set would fail every start of the service.
|
|
370
|
+
requireValue(!bounding || !ambient || ambient.every((entry) => bounding.includes(entry)), 'ambientCapabilities');
|
|
371
|
+
// An empty CapabilityBoundingSet= empties the bounding set; an empty AmbientCapabilities= grants none.
|
|
372
|
+
if (bounding) service.push(`CapabilityBoundingSet=${bounding.join(' ')}`);
|
|
373
|
+
if (ambient) service.push(`AmbientCapabilities=${ambient.join(' ')}`);
|
|
374
|
+
const families = options.restrictAddressFamilies === undefined ? undefined
|
|
375
|
+
: list(options.restrictAddressFamilies, 'restrictAddressFamilies', systemdAddressFamilies.length, (entry) => {
|
|
376
|
+
requireValue((systemdAddressFamilies as readonly unknown[]).includes(entry), 'restrictAddressFamilies');
|
|
377
|
+
return entry as TSystemdAddressFamily;
|
|
378
|
+
}, (entry) => entry);
|
|
379
|
+
// An empty RestrictAddressFamilies= would lift the restriction instead of denying every family.
|
|
380
|
+
requireValue(!families || families.length > 0, 'restrictAddressFamilies');
|
|
381
|
+
if (families) service.push(`RestrictAddressFamilies=${families.join(' ')}`);
|
|
382
|
+
const relations = renderServiceRelations(options, options.unitName, options.restart, scope, fail);
|
|
383
|
+
// A notify service sends READY=1 over an AF_UNIX socket it creates itself.
|
|
384
|
+
requireValue(!families || !relations.service.includes('Type=notify') || families.includes('AF_UNIX'),
|
|
385
|
+
'restrictAddressFamilies');
|
|
186
386
|
this.unitName = options.unitName;
|
|
187
387
|
this.scope = scope;
|
|
188
388
|
this.content = [
|
|
@@ -193,6 +393,7 @@ export class SystemdServiceDefinition {
|
|
|
193
393
|
'[Service]',
|
|
194
394
|
...relations.service,
|
|
195
395
|
...(scope === 'system' ? [`User=${options.user}`, `Group=${options.group}`] : []),
|
|
396
|
+
...(dynamicUser !== undefined ? [`DynamicUser=${yesNo(dynamicUser)}`] : []),
|
|
196
397
|
`ExecStart=${
|
|
197
398
|
[quote(options.executable), ...options.args.map((value) => quote(value, true))].join(' ')
|
|
198
399
|
}`,
|
|
@@ -215,7 +416,7 @@ export class SystemdServiceDefinition {
|
|
|
215
416
|
...relations.install,
|
|
216
417
|
'',
|
|
217
418
|
].join('\n');
|
|
218
|
-
requireValue(Buffer.byteLength(this.content) <= 16384);
|
|
419
|
+
requireValue(Buffer.byteLength(this.content) <= 16384, 'options');
|
|
219
420
|
this.sha256 = plugins.crypto.createHash('sha256').update(this.content).digest('hex');
|
|
220
421
|
Object.freeze(this);
|
|
221
422
|
}
|