@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.
@@ -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
- const requireValue = (value: unknown, code: SystemdUnitFileError['code']): void => {
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
- requireValue(sameFile(parent, await plugins.fs.lstat(this.directory)), 'unsafe_path');
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
- requireValue(sameFile(temporaryInfo, named), 'unsafe_path');
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
- requireValue(process.platform === 'linux' && process.getuid?.() === this.uid, 'unsafe_path');
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
- requireValue(
303
- info.isDirectory() && !info.isSymbolicLink() && [0, this.uid].includes(info.uid) &&
304
- ((info.mode & 0o022) === 0 || directory === '/tmp' && info.uid === 0 &&
305
- (info.mode & 0o7777) === 0o1777),
306
- 'unsafe_path',
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
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
322
- throw new SystemdUnitFileError('unsafe_path');
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
- requireValue(
327
- info.isFile() && info.uid === this.uid && info.nlink === 1 &&
328
- (info.mode & 0o7777) === 0o644 && info.size <= 16384,
329
- 'unsafe_path',
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
- requireValue(size <= 16384, 'unsafe_path');
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
- requireValue(sameFile(created, await plugins.fs.lstat(path)), 'unsafe_path');
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
- requireValue(sameFile(expected, await directory.stat()), 'unsafe_path');
446
+ requireSafePath(sameFile(expected, await directory.stat()), this.directory, 'replaced');
386
447
  await directory.sync();
387
448
  } finally {
388
449
  await directory.close();
@@ -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?: ISystemdServiceStartup;
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
- requireValue(value !== null && typeof value === 'object' && !plugins.util.types.isProxy(value)
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
- record(value, ['type', 'remainAfterExit', 'timeoutStartSeconds']);
66
- requireValue(value.type === 'oneshot' && typeof value.remainAfterExit === 'boolean' && restart !== 'always'
67
- && Number.isSafeInteger(value.timeoutStartSeconds) && value.timeoutStartSeconds >= 1 && value.timeoutStartSeconds <= 600);
68
- result.service = ['Type=oneshot', `RemainAfterExit=${value.remainAfterExit ? 'yes' : 'no'}`, `TimeoutStartSec=${value.timeoutStartSeconds}s`];
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;