@push.rocks/smartdaemon 2.12.0 → 2.14.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.systemdunit.d.ts +32 -5
- package/dist_ts/classes.systemdunit.js +35 -11
- package/dist_ts/systemd.definition.d.ts +24 -1
- package/dist_ts/systemd.definition.js +19 -2
- package/package.json +2 -2
- package/readme.md +120 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.systemdservicedefinition.ts +249 -48
- package/ts/classes.systemdunit.ts +58 -11
- package/ts/systemd.definition.ts +42 -2
|
@@ -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
|
}
|
|
@@ -35,8 +35,28 @@ export interface ISystemdUnitState {
|
|
|
35
35
|
readonly controlPid: number;
|
|
36
36
|
readonly controlGroup: string;
|
|
37
37
|
readonly killMode: string;
|
|
38
|
-
/** Empty when no systemd job is pending; otherwise
|
|
38
|
+
/** Empty when no systemd job is pending; otherwise the pending job's id as systemctl prints it. */
|
|
39
39
|
readonly job: string;
|
|
40
|
+
/**
|
|
41
|
+
* The service's last `STATUS=` notification (`StatusText`), verbatim; empty when it sent none or
|
|
42
|
+
* cleared it. Text the service chose: UTF-8 without a line break, at most 16 KiB.
|
|
43
|
+
*/
|
|
44
|
+
readonly statusText: string;
|
|
45
|
+
/**
|
|
46
|
+
* The service's `Result`, in systemd's own words: `success`, or why it last failed: `resources`,
|
|
47
|
+
* `protocol`, `timeout`, `exit-code`, `signal`, `core-dump`, `watchdog`, `start-limit-hit`,
|
|
48
|
+
* `oom-kill` or `exec-condition` (systemd 255). A newer manager's word is still reported.
|
|
49
|
+
*/
|
|
50
|
+
readonly result: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Lifecycle call options. */
|
|
54
|
+
export interface ISystemdUnitJobOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Defaults to true: wait for the job to finish. False passes `--no-block`: systemd verifies and
|
|
57
|
+
* enqueues the job, and the returned snapshot is read while it may still be pending.
|
|
58
|
+
*/
|
|
59
|
+
wait?: boolean;
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
export interface ISystemdUnitConfigurationState {
|
|
@@ -72,6 +92,8 @@ export interface ISystemdServiceSettings {
|
|
|
72
92
|
readonly restartMicroseconds: number | 'infinity';
|
|
73
93
|
readonly killMode: string;
|
|
74
94
|
readonly timeoutStopMicroseconds: number | 'infinity';
|
|
95
|
+
/** `TimeoutStartUSec`. Extensions a service requests with `EXTEND_TIMEOUT_USEC=` are not included. */
|
|
96
|
+
readonly timeoutStartMicroseconds: number | 'infinity';
|
|
75
97
|
readonly delegate: boolean;
|
|
76
98
|
/** Hard open-file limit (`LimitNOFILE`). */
|
|
77
99
|
readonly limitNoFile: number | 'infinity';
|
|
@@ -155,7 +177,8 @@ const parseEnvironment = (text: string): Readonly<Record<string, string>> | unde
|
|
|
155
177
|
* through shell_maybe_quote(), which backslash-escapes `"`, `\\`, `` ` `` and `$`, so each of the at
|
|
156
178
|
* most environmentByteLimit `NAME=value` bytes prints as at most two and each of the at most
|
|
157
179
|
* environmentVariableLimit words gains two quotes and a separator. The property names and every
|
|
158
|
-
* other requested property, a 239-character unit name included, print in under 1024 bytes.
|
|
180
|
+
* other requested property, a 239-character unit name included, print in under 1024 bytes. The
|
|
181
|
+
* next largest is inspect(): systemd keeps a StatusText of at most 16384 bytes, printed verbatim.
|
|
159
182
|
*/
|
|
160
183
|
const outputLimit = 2 * environmentByteLimit + 3 * environmentVariableLimit + 1024;
|
|
161
184
|
|
|
@@ -170,8 +193,21 @@ const properties = [
|
|
|
170
193
|
'ControlGroup',
|
|
171
194
|
'KillMode',
|
|
172
195
|
'Job',
|
|
196
|
+
'StatusText',
|
|
197
|
+
'Result',
|
|
173
198
|
] as const;
|
|
174
199
|
|
|
200
|
+
/** Arguments preceding the verb; refuses anything but an absent or boolean `wait`. */
|
|
201
|
+
const jobArguments = (optionsArg: ISystemdUnitJobOptions | undefined): string[] => {
|
|
202
|
+
if (optionsArg === undefined) return [];
|
|
203
|
+
if (
|
|
204
|
+
typeof optionsArg !== 'object' || optionsArg === null ||
|
|
205
|
+
Object.keys(optionsArg).some((key) => key !== 'wait') ||
|
|
206
|
+
(optionsArg.wait !== undefined && typeof optionsArg.wait !== 'boolean')
|
|
207
|
+
) throw new SystemdUnitError('invalid_options');
|
|
208
|
+
return optionsArg.wait === false ? ['--no-block'] : [];
|
|
209
|
+
};
|
|
210
|
+
|
|
175
211
|
/** Controls an existing system unit without rewriting its definition or escalating privileges. */
|
|
176
212
|
export class SystemdUnit {
|
|
177
213
|
private readonly unitName: string;
|
|
@@ -215,7 +251,8 @@ export class SystemdUnit {
|
|
|
215
251
|
'--all',
|
|
216
252
|
]);
|
|
217
253
|
const fields = new Map<string, string>();
|
|
218
|
-
|
|
254
|
+
// StatusText is printed verbatim, so only the final line break is systemctl's own.
|
|
255
|
+
for (const line of (stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout).split('\n')) {
|
|
219
256
|
const separator = line.indexOf('=');
|
|
220
257
|
const key = line.slice(0, separator);
|
|
221
258
|
if (separator < 1 || !(properties as readonly string[]).includes(key) || fields.has(key)) {
|
|
@@ -226,6 +263,7 @@ export class SystemdUnit {
|
|
|
226
263
|
if (
|
|
227
264
|
fields.size !== properties.length || fields.get('Id') !== this.unitName ||
|
|
228
265
|
['LoadState', 'ActiveState', 'SubState'].some((key) => !fields.get(key)) ||
|
|
266
|
+
!/^[a-z][a-z-]{0,31}$/.test(fields.get('Result')!) ||
|
|
229
267
|
['MainPID', 'ControlPID'].some((key) =>
|
|
230
268
|
!/^(0|[1-9]\d*)$/.test(fields.get(key)!) || !Number.isSafeInteger(Number(fields.get(key)))
|
|
231
269
|
)
|
|
@@ -241,12 +279,17 @@ export class SystemdUnit {
|
|
|
241
279
|
controlGroup: fields.get('ControlGroup')!,
|
|
242
280
|
killMode: fields.get('KillMode')!,
|
|
243
281
|
job: fields.get('Job')!,
|
|
282
|
+
statusText: fields.get('StatusText')!,
|
|
283
|
+
result: fields.get('Result')!,
|
|
244
284
|
});
|
|
245
285
|
}
|
|
246
286
|
|
|
247
|
-
/**
|
|
248
|
-
|
|
249
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Await the systemd job, or with `wait: false` only its enqueueing, then inspect. This does not
|
|
289
|
+
* enable or unmask the unit.
|
|
290
|
+
*/
|
|
291
|
+
public async start(optionsArg?: ISystemdUnitJobOptions): Promise<ISystemdUnitState> {
|
|
292
|
+
await this.command([...jobArguments(optionsArg), 'start', this.unitName]);
|
|
250
293
|
return this.inspect();
|
|
251
294
|
}
|
|
252
295
|
|
|
@@ -283,9 +326,12 @@ export class SystemdUnit {
|
|
|
283
326
|
requiredBy: units('RequiredBy'), wantedBy: units('WantedBy') });
|
|
284
327
|
}
|
|
285
328
|
|
|
286
|
-
/**
|
|
287
|
-
|
|
288
|
-
|
|
329
|
+
/**
|
|
330
|
+
* Await the systemd job, or with `wait: false` only its enqueueing, then inspect. The snapshot
|
|
331
|
+
* alone is not an application data-drain proof.
|
|
332
|
+
*/
|
|
333
|
+
public async stop(optionsArg?: ISystemdUnitJobOptions): Promise<ISystemdUnitState> {
|
|
334
|
+
await this.command([...jobArguments(optionsArg), 'stop', this.unitName]);
|
|
289
335
|
return this.inspect();
|
|
290
336
|
}
|
|
291
337
|
|
|
@@ -294,7 +340,7 @@ export class SystemdUnit {
|
|
|
294
340
|
const keys = ['Id', 'FragmentPath', 'DropInPaths', 'NeedDaemonReload'];
|
|
295
341
|
const stdout = await this.command(['show', this.unitName, `--property=${keys.join(',')}`, '--all']);
|
|
296
342
|
const fields = new Map<string, string>();
|
|
297
|
-
for (const line of stdout.
|
|
343
|
+
for (const line of (stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout).split('\n')) {
|
|
298
344
|
const separator = line.indexOf('='), key = line.slice(0, separator);
|
|
299
345
|
if (separator < 1 || !keys.includes(key) || fields.has(key)) {
|
|
300
346
|
throw new SystemdUnitError('invalid_state');
|
|
@@ -319,7 +365,7 @@ export class SystemdUnit {
|
|
|
319
365
|
*/
|
|
320
366
|
public async inspectServiceSettings(): Promise<ISystemdServiceSettings> {
|
|
321
367
|
const keys = ['Id', 'Type', 'NotifyAccess', 'Restart', 'RestartUSec', 'KillMode', 'TimeoutStopUSec',
|
|
322
|
-
'Delegate', 'LimitNOFILE', 'LimitNOFILESoft', 'TasksMax', 'OOMScoreAdjust', 'Environment'];
|
|
368
|
+
'TimeoutStartUSec', 'Delegate', 'LimitNOFILE', 'LimitNOFILESoft', 'TasksMax', 'OOMScoreAdjust', 'Environment'];
|
|
323
369
|
const stdout = await this.command(['show', this.unitName, `--property=${keys.join(',')}`, '--all']);
|
|
324
370
|
const fields = new Map<string, string>();
|
|
325
371
|
for (const line of (stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout).split('\n')) {
|
|
@@ -349,6 +395,7 @@ export class SystemdUnit {
|
|
|
349
395
|
restartMicroseconds: parsed('RestartUSec', parseTimespan),
|
|
350
396
|
killMode: mode('KillMode'),
|
|
351
397
|
timeoutStopMicroseconds: parsed('TimeoutStopUSec', parseTimespan),
|
|
398
|
+
timeoutStartMicroseconds: parsed('TimeoutStartUSec', parseTimespan),
|
|
352
399
|
delegate: parsed('Delegate', (text) => text === 'yes' ? true : text === 'no' ? false : undefined),
|
|
353
400
|
limitNoFile: parsed('LimitNOFILE', parseLimit),
|
|
354
401
|
limitNoFileSoft: parsed('LimitNOFILESoft', parseLimit),
|
package/ts/systemd.definition.ts
CHANGED
|
@@ -34,6 +34,41 @@ export interface ISystemdServiceInstallTargets {
|
|
|
34
34
|
requiredBy: readonly string[];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* One credential a system service reads from `$CREDENTIALS_DIRECTORY/<name>` (`LoadCredential=` or
|
|
39
|
+
* `LoadCredentialEncrypted=`). The value never appears in the unit, its environment or
|
|
40
|
+
* `systemctl show`; it is readable by the service's user only.
|
|
41
|
+
*/
|
|
42
|
+
export interface ISystemdServiceCredential {
|
|
43
|
+
/** The credential's name inside the service: printable ASCII from `[A-Za-z0-9_.@-]`, 1–255 characters, not starting with `.`. */
|
|
44
|
+
name: string;
|
|
45
|
+
/**
|
|
46
|
+
* Where systemd reads it: an absolute normalized path (a file, a directory of files or an
|
|
47
|
+
* `AF_UNIX` socket), or a credential name that systemd searches among its own credentials and
|
|
48
|
+
* in the credstore directories (`/etc/credstore/`, `/run/credstore/`, `/usr/lib/credstore/`, and
|
|
49
|
+
* for encrypted credentials also their `.encrypted` counterparts). Omitted, systemd searches
|
|
50
|
+
* under `name` itself.
|
|
51
|
+
*/
|
|
52
|
+
source?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Linux capabilities systemd 255 knows by name, for `capabilityBoundingSet` and `ambientCapabilities`. */
|
|
56
|
+
export const systemdCapabilities = [
|
|
57
|
+
'CAP_AUDIT_CONTROL', 'CAP_AUDIT_READ', 'CAP_AUDIT_WRITE', 'CAP_BLOCK_SUSPEND', 'CAP_BPF',
|
|
58
|
+
'CAP_CHECKPOINT_RESTORE', 'CAP_CHOWN', 'CAP_DAC_OVERRIDE', 'CAP_DAC_READ_SEARCH', 'CAP_FOWNER',
|
|
59
|
+
'CAP_FSETID', 'CAP_IPC_LOCK', 'CAP_IPC_OWNER', 'CAP_KILL', 'CAP_LEASE', 'CAP_LINUX_IMMUTABLE',
|
|
60
|
+
'CAP_MAC_ADMIN', 'CAP_MAC_OVERRIDE', 'CAP_MKNOD', 'CAP_NET_ADMIN', 'CAP_NET_BIND_SERVICE',
|
|
61
|
+
'CAP_NET_BROADCAST', 'CAP_NET_RAW', 'CAP_PERFMON', 'CAP_SETFCAP', 'CAP_SETGID', 'CAP_SETPCAP',
|
|
62
|
+
'CAP_SETUID', 'CAP_SYSLOG', 'CAP_SYS_ADMIN', 'CAP_SYS_BOOT', 'CAP_SYS_CHROOT', 'CAP_SYS_MODULE',
|
|
63
|
+
'CAP_SYS_NICE', 'CAP_SYS_PACCT', 'CAP_SYS_PTRACE', 'CAP_SYS_RAWIO', 'CAP_SYS_RESOURCE',
|
|
64
|
+
'CAP_SYS_TIME', 'CAP_SYS_TTY_CONFIG', 'CAP_WAKE_ALARM',
|
|
65
|
+
] as const;
|
|
66
|
+
export type TSystemdCapability = typeof systemdCapabilities[number];
|
|
67
|
+
|
|
68
|
+
/** Socket address families a definition may allow with `restrictAddressFamilies`. */
|
|
69
|
+
export const systemdAddressFamilies = ['AF_INET', 'AF_INET6', 'AF_NETLINK', 'AF_PACKET', 'AF_UNIX', 'AF_VSOCK'] as const;
|
|
70
|
+
export type TSystemdAddressFamily = typeof systemdAddressFamilies[number];
|
|
71
|
+
|
|
37
72
|
/**
|
|
38
73
|
* Internal bounds of a definition's `environment`: variables, and UTF-8 bytes of `NAME=value` in
|
|
39
74
|
* total. SystemdUnit sizes its systemctl output limit from them, so every accepted definition reads back.
|
|
@@ -59,10 +94,13 @@ export const renderServiceRelations = (options: {
|
|
|
59
94
|
startup?: TSystemdServiceStartup;
|
|
60
95
|
dependencies?: ISystemdServiceDependencies;
|
|
61
96
|
install?: ISystemdServiceInstallTargets;
|
|
62
|
-
}, unitName: string, restart: string, scope: 'system' | 'user',
|
|
97
|
+
}, unitName: string, restart: string, scope: 'system' | 'user',
|
|
98
|
+
fail: (option: 'startup' | 'dependencies' | 'install') => never): {
|
|
63
99
|
unit: string[]; service: string[]; install: string[];
|
|
64
100
|
} => {
|
|
65
|
-
|
|
101
|
+
// Every check names the option it belongs to; `option` is set before each option is read.
|
|
102
|
+
let option: 'startup' | 'dependencies' | 'install' = 'startup';
|
|
103
|
+
const requireValue = (value: unknown): void => { if (!value) fail(option); };
|
|
66
104
|
const plain = (value: unknown): void => requireValue(value !== null && typeof value === 'object'
|
|
67
105
|
&& !plugins.util.types.isProxy(value) && Object.getPrototypeOf(value) === Object.prototype);
|
|
68
106
|
const record = (value: unknown, keys: readonly string[]): void => {
|
|
@@ -110,6 +148,7 @@ export const renderServiceRelations = (options: {
|
|
|
110
148
|
}
|
|
111
149
|
}
|
|
112
150
|
if (options.dependencies !== undefined) {
|
|
151
|
+
option = 'dependencies';
|
|
113
152
|
const value = options.dependencies;
|
|
114
153
|
record(value, ['defaultDependencies', 'after', 'before', 'requires', 'wants', 'conflicts']);
|
|
115
154
|
requireValue(typeof value.defaultDependencies === 'boolean');
|
|
@@ -120,6 +159,7 @@ export const renderServiceRelations = (options: {
|
|
|
120
159
|
...line('Before', before), ...line('Requires', requires), ...line('Wants', wants), ...line('Conflicts', conflicts)];
|
|
121
160
|
}
|
|
122
161
|
if (options.install !== undefined) {
|
|
162
|
+
option = 'install';
|
|
123
163
|
const value = options.install;
|
|
124
164
|
record(value, ['wantedBy', 'requiredBy']);
|
|
125
165
|
const wanted = units(value.wantedBy), required = units(value.requiredBy);
|