@push.rocks/smartdaemon 2.7.2 → 2.9.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 +14 -4
- package/dist_ts/classes.systemdservicedefinition.js +30 -3
- package/dist_ts/classes.systemdunit.d.ts +27 -0
- package/dist_ts/classes.systemdunit.js +78 -1
- package/dist_ts/classes.systemdunitfile.d.ts +34 -1
- package/dist_ts/classes.systemdunitfile.js +38 -16
- package/dist_ts/systemd.definition.d.ts +11 -1
- package/dist_ts/systemd.definition.js +18 -7
- package/package.json +4 -4
- package/readme.md +110 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.systemdservicedefinition.ts +46 -6
- package/ts/classes.systemdunit.ts +98 -0
- package/ts/classes.systemdunitfile.ts +81 -20
- package/ts/systemd.definition.ts +29 -7
|
@@ -37,14 +37,65 @@ export class SystemdUnitFileError extends Error {
|
|
|
37
37
|
| 'unsafe_unit'
|
|
38
38
|
| 'write_failed'
|
|
39
39
|
| 'reload_failed',
|
|
40
|
+
options?: ErrorOptions,
|
|
40
41
|
) {
|
|
41
|
-
super(`Systemd unit definition installation failed: ${code}
|
|
42
|
+
super(`Systemd unit definition installation failed: ${code}.`, options);
|
|
42
43
|
this.name = 'SystemdUnitFileError';
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
/** Why a path was refused with `unsafe_path`. */
|
|
47
|
+
export type TSystemdUnitFileUnsafePathReason =
|
|
48
|
+
/** The process is not running on Linux. */
|
|
49
|
+
| 'unsupported_platform'
|
|
50
|
+
/** The process does not run as the uid that must own the unit directory and file. */
|
|
51
|
+
| 'foreign_process'
|
|
52
|
+
/** The unit directory or one of its ancestors is a symbolic link, or the unit file is one. */
|
|
53
|
+
| 'symlink'
|
|
54
|
+
/** The unit directory or one of its ancestors exists but is not a directory. */
|
|
55
|
+
| 'not_directory'
|
|
56
|
+
/** A directory is owned by neither root nor the expected uid, or the unit file not by the expected uid. */
|
|
57
|
+
| 'foreign_owner'
|
|
58
|
+
/** A directory is group- or world-writable; only a root-owned sticky mode-1777 `/tmp` is admitted. */
|
|
59
|
+
| 'group_or_world_writable'
|
|
60
|
+
/** The unit file exists but cannot be opened for reading. */
|
|
61
|
+
| 'unreadable'
|
|
62
|
+
/** The unit file is not a regular file. */
|
|
63
|
+
| 'not_regular_file'
|
|
64
|
+
/** The unit file has more than one hard link. */
|
|
65
|
+
| 'hard_linked'
|
|
66
|
+
/** The unit file's mode is not exactly 0644. */
|
|
67
|
+
| 'wrong_mode'
|
|
68
|
+
/** The unit file is larger than 16384 bytes. */
|
|
69
|
+
| 'too_large'
|
|
70
|
+
/** A checked directory or file was replaced by another between two of its own checks. */
|
|
71
|
+
| 'replaced';
|
|
72
|
+
/** Every `unsafe_path` refusal: names the offending path and why it was refused. */
|
|
73
|
+
export class SystemdUnitFileUnsafePathError extends SystemdUnitFileError {
|
|
74
|
+
declare public readonly code: 'unsafe_path';
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
public readonly path: string,
|
|
78
|
+
public readonly reason: TSystemdUnitFileUnsafePathReason,
|
|
79
|
+
options?: ErrorOptions,
|
|
80
|
+
) {
|
|
81
|
+
super('unsafe_path', options);
|
|
82
|
+
this.message = `Systemd unit definition installation failed: unsafe_path (${reason}: ${path}).`;
|
|
83
|
+
this.name = 'SystemdUnitFileUnsafePathError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const requireValue = (
|
|
87
|
+
value: unknown,
|
|
88
|
+
code: Exclude<SystemdUnitFileError['code'], 'unsafe_path'>,
|
|
89
|
+
): void => {
|
|
46
90
|
if (!value) throw new SystemdUnitFileError(code);
|
|
47
91
|
};
|
|
92
|
+
const requireSafePath = (
|
|
93
|
+
value: unknown,
|
|
94
|
+
path: string,
|
|
95
|
+
reason: TSystemdUnitFileUnsafePathReason,
|
|
96
|
+
): void => {
|
|
97
|
+
if (!value) throw new SystemdUnitFileUnsafePathError(path, reason);
|
|
98
|
+
};
|
|
48
99
|
const digest = (bytes: Uint8Array) =>
|
|
49
100
|
plugins.crypto.createHash('sha256').update(bytes).digest('hex');
|
|
50
101
|
const quiet = (state: ISystemdUnitState) =>
|
|
@@ -195,7 +246,9 @@ export class SystemdUnitFile {
|
|
|
195
246
|
(!current || previous && sameFile(current.info, previous.info)),
|
|
196
247
|
'changed',
|
|
197
248
|
);
|
|
198
|
-
|
|
249
|
+
requireSafePath(
|
|
250
|
+
sameFile(parent, await plugins.fs.lstat(this.directory)), this.directory, 'replaced',
|
|
251
|
+
);
|
|
199
252
|
const rechecked = await this.unit.inspect();
|
|
200
253
|
requireValue(JSON.stringify(rechecked) === JSON.stringify(before), 'changed');
|
|
201
254
|
const recheckedConfiguration = await this.admit(rechecked, maskDirectories);
|
|
@@ -240,7 +293,7 @@ export class SystemdUnitFile {
|
|
|
240
293
|
try {
|
|
241
294
|
if (temporary && temporaryInfo) {
|
|
242
295
|
const named = await plugins.fs.lstat(temporary);
|
|
243
|
-
|
|
296
|
+
requireSafePath(sameFile(temporaryInfo, named), temporary, 'replaced');
|
|
244
297
|
await plugins.fs.unlink(temporary);
|
|
245
298
|
}
|
|
246
299
|
} finally {
|
|
@@ -276,7 +329,8 @@ export class SystemdUnitFile {
|
|
|
276
329
|
}
|
|
277
330
|
|
|
278
331
|
private async directories(create: boolean): Promise<boolean> {
|
|
279
|
-
|
|
332
|
+
requireSafePath(process.platform === 'linux', this.directory, 'unsupported_platform');
|
|
333
|
+
requireSafePath(process.getuid?.() === this.uid, this.directory, 'foreign_process');
|
|
280
334
|
let directory = '/';
|
|
281
335
|
for (const part of this.directory.split('/').filter(Boolean)) {
|
|
282
336
|
directory = plugins.path.join(directory, part);
|
|
@@ -299,11 +353,14 @@ export class SystemdUnitFile {
|
|
|
299
353
|
}
|
|
300
354
|
info = await plugins.fs.lstat(directory);
|
|
301
355
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
356
|
+
requireSafePath(!info.isSymbolicLink(), directory, 'symlink');
|
|
357
|
+
requireSafePath(info.isDirectory(), directory, 'not_directory');
|
|
358
|
+
requireSafePath([0, this.uid].includes(info.uid), directory, 'foreign_owner');
|
|
359
|
+
requireSafePath(
|
|
360
|
+
(info.mode & 0o022) === 0 ||
|
|
361
|
+
directory === '/tmp' && info.uid === 0 && (info.mode & 0o7777) === 0o1777,
|
|
362
|
+
directory,
|
|
363
|
+
'group_or_world_writable',
|
|
307
364
|
);
|
|
308
365
|
}
|
|
309
366
|
return true;
|
|
@@ -318,23 +375,27 @@ export class SystemdUnitFile {
|
|
|
318
375
|
plugins.fsConstants.O_NONBLOCK,
|
|
319
376
|
);
|
|
320
377
|
} catch (error) {
|
|
321
|
-
|
|
322
|
-
|
|
378
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
379
|
+
if (code === 'ENOENT') return null;
|
|
380
|
+
// O_NOFOLLOW refuses a symbolic link as the final component with ELOOP.
|
|
381
|
+
throw new SystemdUnitFileUnsafePathError(
|
|
382
|
+
path, code === 'ELOOP' ? 'symlink' : 'unreadable', { cause: error },
|
|
383
|
+
);
|
|
323
384
|
}
|
|
324
385
|
try {
|
|
325
386
|
const info = await file.stat();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
);
|
|
387
|
+
requireSafePath(info.isFile(), path, 'not_regular_file');
|
|
388
|
+
requireSafePath(info.uid === this.uid, path, 'foreign_owner');
|
|
389
|
+
requireSafePath(info.nlink === 1, path, 'hard_linked');
|
|
390
|
+
requireSafePath((info.mode & 0o7777) === 0o644, path, 'wrong_mode');
|
|
391
|
+
requireSafePath(info.size <= 16384, path, 'too_large');
|
|
331
392
|
const bytes = Buffer.alloc(16385);
|
|
332
393
|
let size = 0;
|
|
333
394
|
for (;;) {
|
|
334
395
|
const result = await file.read(bytes, size, bytes.length - size, size);
|
|
335
396
|
if (result.bytesRead === 0) break;
|
|
336
397
|
size += result.bytesRead;
|
|
337
|
-
|
|
398
|
+
requireSafePath(size <= 16384, path, 'too_large');
|
|
338
399
|
}
|
|
339
400
|
const after = await file.stat(), named = await plugins.fs.lstat(path);
|
|
340
401
|
requireValue(
|
|
@@ -368,7 +429,7 @@ export class SystemdUnitFile {
|
|
|
368
429
|
}
|
|
369
430
|
} catch (error) {
|
|
370
431
|
if (created) {
|
|
371
|
-
|
|
432
|
+
requireSafePath(sameFile(created, await plugins.fs.lstat(path)), path, 'replaced');
|
|
372
433
|
await plugins.fs.unlink(path);
|
|
373
434
|
}
|
|
374
435
|
throw error;
|
|
@@ -382,7 +443,7 @@ export class SystemdUnitFile {
|
|
|
382
443
|
plugins.fsConstants.O_DIRECTORY | plugins.fsConstants.O_NOFOLLOW,
|
|
383
444
|
);
|
|
384
445
|
try {
|
|
385
|
-
|
|
446
|
+
requireSafePath(sameFile(expected, await directory.stat()), this.directory, 'replaced');
|
|
386
447
|
await directory.sync();
|
|
387
448
|
} finally {
|
|
388
449
|
await directory.close();
|
package/ts/systemd.definition.ts
CHANGED
|
@@ -6,6 +6,18 @@ export interface ISystemdServiceStartup {
|
|
|
6
6
|
timeoutStartSeconds: number;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Ready when a process admitted by `notifyAccess` sends `READY=1`. `NotifyAccess=none` is not
|
|
11
|
+
* offered: systemd would discard every notification and the start could only time out.
|
|
12
|
+
*/
|
|
13
|
+
export interface ISystemdServiceNotifyStartup {
|
|
14
|
+
type: 'notify';
|
|
15
|
+
notifyAccess: 'main' | 'exec' | 'all';
|
|
16
|
+
timeoutStartSeconds: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type TSystemdServiceStartup = ISystemdServiceStartup | ISystemdServiceNotifyStartup;
|
|
20
|
+
|
|
9
21
|
/** Complete literal relations. System services always retain After=local-fs.target. */
|
|
10
22
|
export interface ISystemdServiceDependencies {
|
|
11
23
|
defaultDependencies: boolean;
|
|
@@ -24,16 +36,17 @@ export interface ISystemdServiceInstallTargets {
|
|
|
24
36
|
|
|
25
37
|
/** Internal validated renderer; no systemd/process/filesystem effects. */
|
|
26
38
|
export const renderServiceRelations = (options: {
|
|
27
|
-
startup?:
|
|
39
|
+
startup?: TSystemdServiceStartup;
|
|
28
40
|
dependencies?: ISystemdServiceDependencies;
|
|
29
41
|
install?: ISystemdServiceInstallTargets;
|
|
30
42
|
}, unitName: string, restart: string, scope: 'system' | 'user', fail: () => never): {
|
|
31
43
|
unit: string[]; service: string[]; install: string[];
|
|
32
44
|
} => {
|
|
33
45
|
const requireValue = (value: unknown): void => { if (!value) fail(); };
|
|
46
|
+
const plain = (value: unknown): void => requireValue(value !== null && typeof value === 'object'
|
|
47
|
+
&& !plugins.util.types.isProxy(value) && Object.getPrototypeOf(value) === Object.prototype);
|
|
34
48
|
const record = (value: unknown, keys: readonly string[]): void => {
|
|
35
|
-
|
|
36
|
-
&& Object.getPrototypeOf(value) === Object.prototype);
|
|
49
|
+
plain(value);
|
|
37
50
|
const actual = Reflect.ownKeys(value as object);
|
|
38
51
|
requireValue(actual.length === keys.length && actual.every(key => typeof key === 'string' && keys.includes(key)
|
|
39
52
|
&& Object.getOwnPropertyDescriptor(value, key)?.enumerable && 'value' in Object.getOwnPropertyDescriptor(value, key)!));
|
|
@@ -62,10 +75,19 @@ export const renderServiceRelations = (options: {
|
|
|
62
75
|
};
|
|
63
76
|
if (options.startup !== undefined) {
|
|
64
77
|
const value = options.startup;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
plain(value);
|
|
79
|
+
// Select the shape from the data descriptor so no accessor runs before record() refuses it.
|
|
80
|
+
const kind = Object.getOwnPropertyDescriptor(value, 'type');
|
|
81
|
+
record(value, kind !== undefined && 'value' in kind && kind.value === 'notify'
|
|
82
|
+
? ['type', 'notifyAccess', 'timeoutStartSeconds'] : ['type', 'remainAfterExit', 'timeoutStartSeconds']);
|
|
83
|
+
requireValue(Number.isSafeInteger(value.timeoutStartSeconds) && value.timeoutStartSeconds >= 1 && value.timeoutStartSeconds <= 600);
|
|
84
|
+
if (value.type === 'notify') {
|
|
85
|
+
requireValue(['main', 'exec', 'all'].includes(value.notifyAccess));
|
|
86
|
+
result.service = ['Type=notify', `NotifyAccess=${value.notifyAccess}`, `TimeoutStartSec=${value.timeoutStartSeconds}s`];
|
|
87
|
+
} else {
|
|
88
|
+
requireValue(value.type === 'oneshot' && typeof value.remainAfterExit === 'boolean' && restart !== 'always');
|
|
89
|
+
result.service = ['Type=oneshot', `RemainAfterExit=${value.remainAfterExit ? 'yes' : 'no'}`, `TimeoutStartSec=${value.timeoutStartSeconds}s`];
|
|
90
|
+
}
|
|
69
91
|
}
|
|
70
92
|
if (options.dependencies !== undefined) {
|
|
71
93
|
const value = options.dependencies;
|