@push.rocks/smartdaemon 2.20.1 → 2.21.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.
@@ -1,11 +1,12 @@
1
1
  import * as plugins from './smartdaemon.plugins.js';
2
2
  import { environmentByteLimit, environmentVariableLimit, isCleanText, renderServiceRelations, systemdAddressFamilies,
3
3
  systemdCapabilities, type TSystemdServiceStartup, type ISystemdServiceDependencies, type ISystemdServiceInstallTargets,
4
- type ISystemdServiceCredential, type TSystemdCapability, type TSystemdAddressFamily } from './systemd.definition.js';
5
- import { fileDescriptorStorePreserveMajor } from './systemd.managerversion.js';
4
+ type ISystemdServiceCredential, type TSystemdCapability, type TSystemdAddressFamily,
5
+ type TSystemdServiceOutput } from './systemd.definition.js';
6
+ import { fileDescriptorStorePreserveMajor, outputAppendMajor } from './systemd.managerversion.js';
6
7
  export type { ISystemdServiceStartup, ISystemdServiceNotifyStartup, TSystemdServiceStartup,
7
8
  ISystemdServiceDependencies, ISystemdServiceInstallTargets, ISystemdServiceCredential, TSystemdCapability,
8
- TSystemdAddressFamily } from './systemd.definition.js';
9
+ TSystemdAddressFamily, TSystemdServiceOutput } from './systemd.definition.js';
9
10
  export { systemdCapabilities, systemdAddressFamilies } from './systemd.definition.js';
10
11
 
11
12
  interface ISystemdServiceDefinitionBaseOptions {
@@ -78,6 +79,17 @@ interface ISystemdServiceDefinitionBaseOptions {
78
79
  * 64 variables and 8192 UTF-8 bytes of `NAME=value` in total. They are taken literally: `%` is escaped, and `Environment=` never expands `$`.
79
80
  */
80
81
  environment?: Readonly<Record<string, string>>;
82
+ /**
83
+ * Where the service's standard output goes (`StandardOutput=`): `'journal'` (the default),
84
+ * `'null'`, or `{ append: path }`, an absolute normalized path without a backslash, surrounding
85
+ * whitespace or trailing slash, whose file systemd appends to (`append:`, systemd 240 and later).
86
+ * An `append` output sets `requiredManagerMajor` to at least 240, so `SystemdUnitFile.install()`
87
+ * refuses an older manager, which would ignore the line. Omitted, the unit renders
88
+ * `StandardOutput=journal` as before.
89
+ */
90
+ standardOutput?: TSystemdServiceOutput;
91
+ /** Where the service's standard error goes (`StandardError=`), as `standardOutput`; omitted, `journal`. */
92
+ standardError?: TSystemdServiceOutput;
81
93
  /**
82
94
  * System scope only. Runs the service as a transient user and group allocated at start
83
95
  * (`DynamicUser=`), named by `user` and `group`, which then must not be `root` and have at most
@@ -177,8 +189,9 @@ export class SystemdServiceDefinition {
177
189
  public readonly sha256: string;
178
190
  /**
179
191
  * The oldest systemd major whose manager reads every directive this definition renders, when it
180
- * renders one an older manager would ignore: 254 with `fileDescriptorStorePreserve`; otherwise
181
- * null. `SystemdUnitFile.install()` refuses a definition on an older manager.
192
+ * renders one an older manager would ignore: the highest of 254 with `fileDescriptorStorePreserve`
193
+ * and 240 with an `append` output; otherwise null. `SystemdUnitFile.install()` refuses a
194
+ * definition on an older manager.
182
195
  */
183
196
  public readonly requiredManagerMajor: number | null;
184
197
 
@@ -217,7 +230,8 @@ export class SystemdServiceDefinition {
217
230
  const optionalKeys: TSystemdServiceDefinitionOption[] = ['scope', 'startup', 'dependencies', 'install',
218
231
  'restartSeconds', 'restartPreventExitStatus', 'successExitStatus', 'startLimitIntervalSeconds',
219
232
  'startLimitBurst', 'delegate', 'limitNoFile', 'tasksMax', 'oomScoreAdjust', 'notifyAccess',
220
- 'fileDescriptorStoreMax', 'fileDescriptorStorePreserve', 'environment', ...systemOnlyOptions];
233
+ 'fileDescriptorStoreMax', 'fileDescriptorStorePreserve', 'environment', 'standardOutput', 'standardError',
234
+ ...systemOnlyOptions];
221
235
  // Every own key is a known option held by an enumerable data property, so no accessor or trap runs.
222
236
  for (const key of Reflect.ownKeys(options)) {
223
237
  requireValue(typeof key === 'string' && [...keys, ...optionalKeys, 'user', 'group'].includes(key as never), 'options');
@@ -334,6 +348,25 @@ export class SystemdServiceDefinition {
334
348
  service.push(...[...assignments.keys()].sort()
335
349
  .map((name) => `Environment=${quote(assignments.get(name)!)}`));
336
350
  }
351
+ /**
352
+ * An output stream's directive value, read once through the object's own data descriptor. An
353
+ * `append:` path is taken raw after specifier expansion, so only `%` is escaped.
354
+ */
355
+ const output = (value: unknown, option: 'standardOutput' | 'standardError'): string => {
356
+ if (value === undefined || value === 'journal') return 'journal';
357
+ if (value === 'null') return 'null';
358
+ plainObject(value, option);
359
+ const entryKeys = Reflect.ownKeys(value as object);
360
+ const descriptor = Object.getOwnPropertyDescriptor(value, 'append');
361
+ requireValue(entryKeys.length === 1 && entryKeys[0] === 'append' && descriptor?.enumerable &&
362
+ 'value' in descriptor, option);
363
+ const file: unknown = descriptor!.value;
364
+ requireValue(absolute(file) && !file.endsWith('/') && file.trim() === file &&
365
+ !file.includes('\\'), option);
366
+ return `append:${(file as string).replaceAll('%', '%%')}`;
367
+ };
368
+ const standardOutput = output(options.standardOutput, 'standardOutput');
369
+ const standardError = output(options.standardError, 'standardError');
337
370
  // Every system-only option is refused by name in a user manager's service.
338
371
  for (const option of systemOnlyOptions) {
339
372
  requireValue(scope === 'system' || options[option] === undefined, option);
@@ -508,8 +541,8 @@ export class SystemdServiceDefinition {
508
541
  `TimeoutStopSec=${options.timeoutStopSeconds}s`,
509
542
  ...service,
510
543
  'StandardInput=null',
511
- 'StandardOutput=journal',
512
- 'StandardError=journal',
544
+ `StandardOutput=${standardOutput}`,
545
+ `StandardError=${standardError}`,
513
546
  '',
514
547
  '[Install]',
515
548
  ...relations.install,
@@ -517,8 +550,12 @@ export class SystemdServiceDefinition {
517
550
  ].join('\n');
518
551
  requireValue(Buffer.byteLength(this.content) <= 16384, 'options');
519
552
  this.sha256 = plugins.crypto.createHash('sha256').update(this.content).digest('hex');
520
- this.requiredManagerMajor = options.fileDescriptorStorePreserve !== undefined
521
- ? fileDescriptorStorePreserveMajor : null;
553
+ // An older manager ignores a directive it does not read, so the newest one any line needs counts.
554
+ const requiredMajors = [
555
+ ...(options.fileDescriptorStorePreserve !== undefined ? [fileDescriptorStorePreserveMajor] : []),
556
+ ...([standardOutput, standardError].some((value) => value.startsWith('append:')) ? [outputAppendMajor] : []),
557
+ ];
558
+ this.requiredManagerMajor = requiredMajors.length > 0 ? Math.max(...requiredMajors) : null;
522
559
  Object.freeze(this);
523
560
  }
524
561
  }
@@ -539,6 +539,17 @@ export class SystemdUnit {
539
539
  return this.inspect();
540
540
  }
541
541
 
542
+ /**
543
+ * Persistently disables the unit: removes the enablement links its `[Install]` section names
544
+ * (`systemctl disable`, which reloads the manager), then inspects. Never stops, masks, unmasks or
545
+ * removes the definition; a running unit keeps running. A failed or timed out command can leave
546
+ * partial enablement: inspect and retry the exact operation.
547
+ */
548
+ public async disable(): Promise<ISystemdUnitState> {
549
+ await this.command(['disable', this.unitName]);
550
+ return this.inspect();
551
+ }
552
+
542
553
  /** Loaded manager graph, including reverse edges. Enablement alone does not prove every required link. */
543
554
  public async inspectRelationships(): Promise<ISystemdUnitRelationships> {
544
555
  const keys = ['Id', 'Before', 'After', 'Requires', 'Wants', 'Conflicts', 'RequiredBy', 'WantedBy'];
@@ -157,15 +157,27 @@ const admittedFileStates: Readonly<Record<string, readonly string[]>> = {
157
157
  masked: ['masked', 'masked-runtime'],
158
158
  'not-found': [''],
159
159
  };
160
- /** Admits a quiet unit whose load and unit-file states installation may write behind. */
161
- const requireAdmittedState = (state: ISystemdUnitState): void => {
160
+ /**
161
+ * The unit-file states removal admits: a loaded unit only when disabled, so no enablement link is
162
+ * left naming a definition that no longer exists.
163
+ */
164
+ const removableFileStates: Readonly<Record<string, readonly string[]>> = {
165
+ loaded: ['disabled'],
166
+ masked: ['masked', 'masked-runtime'],
167
+ 'not-found': [''],
168
+ };
169
+ /** Admits a quiet unit whose load and unit-file states the operation may write behind. */
170
+ const requireAdmittedState = (
171
+ state: ISystemdUnitState,
172
+ fileStates: Readonly<Record<string, readonly string[]>>,
173
+ ): void => {
162
174
  const reason: TSystemdUnitFileUnsafeUnitReason | null = !idle(state)
163
175
  ? 'active'
164
176
  : state.job !== ''
165
177
  ? 'job_pending'
166
- : !Object.hasOwn(admittedFileStates, state.loadState)
178
+ : !Object.hasOwn(fileStates, state.loadState)
167
179
  ? 'unexpected_load_state'
168
- : !admittedFileStates[state.loadState].includes(state.unitFileState)
180
+ : !fileStates[state.loadState].includes(state.unitFileState)
169
181
  ? 'unexpected_file_state'
170
182
  : null;
171
183
  if (reason) throw new SystemdUnitFileUnsafeUnitError(reason, state, null);
@@ -310,7 +322,7 @@ export class SystemdUnitFile {
310
322
  let temporaryInfo: plugins.Stats | undefined;
311
323
  try {
312
324
  const before = await this.unit.inspect();
313
- const beforeConfiguration = await this.admit(before, maskDirectories);
325
+ const beforeConfiguration = await this.admit(before, maskDirectories, admittedFileStates);
314
326
  await this.directories(true);
315
327
  const parent = await plugins.fs.lstat(this.directory);
316
328
  const previous = await this.read(this.path);
@@ -345,7 +357,7 @@ export class SystemdUnitFile {
345
357
  );
346
358
  const rechecked = await this.unit.inspect();
347
359
  requireValue(JSON.stringify(rechecked) === JSON.stringify(before), 'changed');
348
- const recheckedConfiguration = await this.admit(rechecked, maskDirectories);
360
+ const recheckedConfiguration = await this.admit(rechecked, maskDirectories, admittedFileStates);
349
361
  requireValue(
350
362
  recheckedConfiguration.fragmentPath === beforeConfiguration.fragmentPath,
351
363
  'changed',
@@ -395,11 +407,116 @@ export class SystemdUnitFile {
395
407
  }
396
408
  }
397
409
 
410
+ /**
411
+ * Removes the definition `install()` wrote and the previous definitions it retained beside it
412
+ * (`<unit>.previous-<sha256>`), then reloads the manager. The expected SHA-256 of the installed
413
+ * definition is mandatory, so only the bytes the caller installed are removed; an absent unit
414
+ * file with no retained definition is idempotent (`removed: false`) and reloads nothing.
415
+ *
416
+ * Admits only a unit at rest with no job queued, no pending reload and no drop-ins, whose
417
+ * manager view still selects this file: a loaded unit must be disabled first
418
+ * (`SystemdUnit.disable()`), so no enablement link outlives its definition; a masked unit keeps
419
+ * its mask; a unit the manager does not find is admitted. Refusals are the `unsafe_unit` reasons
420
+ * of `install()`, `unexpected_file_state` for an enabled unit among them. Every file is checked
421
+ * like the installed one (regular, single link, mode 0644, owned by the expected uid) and each
422
+ * retained definition must hold the bytes its name digests, or nothing is removed (`changed`).
423
+ * After removal the manager must report the unit not found (or still masked) and at rest;
424
+ * otherwise `reload_failed`, with the files already removed. Never stops, disables or unmasks.
425
+ */
426
+ public async uninstall(
427
+ expectedSha256: string,
428
+ ): Promise<Readonly<{ removed: boolean; masked: boolean; removedPaths: readonly string[] }>> {
429
+ requireValue(typeof expectedSha256 === 'string' && /^[a-f0-9]{64}$/.test(expectedSha256),
430
+ 'invalid_options');
431
+ const maskDirectories = this.maskDirectories();
432
+ if (maskDirectories === null) throw new SystemdUnitFileError('invalid_options');
433
+ requireValue(!this.busy, 'busy');
434
+ this.busy = true;
435
+ try {
436
+ const before = await this.unit.inspect();
437
+ const beforeConfiguration = await this.admit(before, maskDirectories, removableFileStates);
438
+ const unchanged = Object.freeze({ removed: false, masked: masked(before), removedPaths: Object.freeze([]) });
439
+ // A loaded unit selects this file, so its absence is drift, not an earlier removal.
440
+ if (!await this.directories(false)) {
441
+ requireValue(before.loadState !== 'loaded', 'changed');
442
+ return unchanged;
443
+ }
444
+ const parent = await plugins.fs.lstat(this.directory);
445
+ const current = await this.read(this.path);
446
+ if (current) requireValue(digest(current.bytes) === expectedSha256, 'changed');
447
+ else requireValue(before.loadState !== 'loaded', 'changed');
448
+ const targets = [
449
+ ...(current ? [{ path: this.path, info: current.info }] : []),
450
+ ...await this.retainedDefinitions(),
451
+ ];
452
+ if (targets.length === 0) return unchanged;
453
+ const rechecked = await this.unit.inspect();
454
+ requireValue(JSON.stringify(rechecked) === JSON.stringify(before), 'changed');
455
+ const recheckedConfiguration = await this.admit(rechecked, maskDirectories, removableFileStates);
456
+ requireValue(recheckedConfiguration.fragmentPath === beforeConfiguration.fragmentPath, 'changed');
457
+ requireSafePath(sameFile(parent, await plugins.fs.lstat(this.directory)), this.directory, 'replaced');
458
+ // Every name must still hold the file that was checked before the first is removed, so a
459
+ // refusal never leaves a partial removal.
460
+ for (const target of targets) {
461
+ requireValue(sameFile(target.info, await plugins.fs.lstat(target.path)), 'changed');
462
+ }
463
+ for (const target of targets) await plugins.fs.unlink(target.path);
464
+ await this.flushDirectory(parent);
465
+ const removedPaths = Object.freeze(targets.map((target) => target.path));
466
+ try {
467
+ const reloadedConfiguration = await this.unit.reloadConfiguration();
468
+ const after = await this.unit.inspect();
469
+ requireValue(
470
+ quiet(after) &&
471
+ (masked(before)
472
+ ? masked(after) && after.unitFileState === before.unitFileState
473
+ : after.loadState === 'not-found' && after.unitFileState === ''),
474
+ 'changed',
475
+ );
476
+ const configuration = await this.unit.inspectConfiguration();
477
+ const expectedFragment = masked(after) ? beforeConfiguration.fragmentPath : '';
478
+ for (const snapshot of [reloadedConfiguration, configuration]) {
479
+ requireAdmittedConfiguration(after, snapshot, snapshot.fragmentPath === expectedFragment);
480
+ }
481
+ requireValue(await this.read(this.path) === null, 'changed');
482
+ return Object.freeze({ removed: current !== null, masked: masked(after), removedPaths });
483
+ } catch (error) {
484
+ // The files are already removed; the cause says why the reload was not accepted.
485
+ throw new SystemdUnitFileError('reload_failed', { cause: error });
486
+ }
487
+ } catch (error) {
488
+ if (error instanceof SystemdUnitFileError) throw error;
489
+ throw new SystemdUnitFileError('write_failed');
490
+ } finally {
491
+ this.busy = false;
492
+ }
493
+ }
494
+
495
+ /**
496
+ * The previous definitions `install()` retained beside the unit, each checked like the unit file
497
+ * and holding exactly the bytes its name digests.
498
+ */
499
+ private async retainedDefinitions(): Promise<Array<{ path: string; info: plugins.Stats }>> {
500
+ const prefix = `${this.name}.previous-`;
501
+ const retained: Array<{ path: string; info: plugins.Stats }> = [];
502
+ for (const entry of (await plugins.fs.readdir(this.directory)).sort()) {
503
+ if (!entry.startsWith(prefix)) continue;
504
+ const sha256 = entry.slice(prefix.length);
505
+ if (!/^[a-f0-9]{64}$/.test(sha256)) continue;
506
+ const path = plugins.path.join(this.directory, entry);
507
+ const file = await this.read(path);
508
+ requireValue(file !== null && digest(file.bytes) === sha256, 'changed');
509
+ retained.push({ path, info: file!.info });
510
+ }
511
+ return retained;
512
+ }
513
+
398
514
  private async admit(
399
515
  state: ISystemdUnitState,
400
516
  maskDirectories: IMaskDirectories,
517
+ fileStates: Readonly<Record<string, readonly string[]>>,
401
518
  ): Promise<ISystemdUnitConfigurationState> {
402
- requireAdmittedState(state);
519
+ requireAdmittedState(state, fileStates);
403
520
  const configuration = await this.unit.inspectConfiguration();
404
521
  const maskPaths = (state.unitFileState === 'masked-runtime'
405
522
  ? maskDirectories.runtime : maskDirectories.persistent)
@@ -28,6 +28,18 @@ export interface ISystemdServiceDependencies {
28
28
  conflicts: readonly string[];
29
29
  }
30
30
 
31
+ /**
32
+ * Where one of the service's output streams goes (`StandardOutput=`, `StandardError=`): the
33
+ * journal, nowhere (`null`), or appended to a file (`append:`, systemd 240 and later). The
34
+ * service manager opens an `append` file with `O_APPEND` before it invokes the executable and
35
+ * before it drops to the service's `User=`, so with the manager's own privileges: root for the
36
+ * system manager, the user for a user manager. It creates a missing file owned by that user
37
+ * with mode 0666 masked by the unit's umask (0644 under the usual umask 0022); its directory must
38
+ * exist. A caller that needs another owner or a private mode creates the file first: an existing
39
+ * file keeps its owner and mode.
40
+ */
41
+ export type TSystemdServiceOutput = 'journal' | 'null' | { readonly append: string };
42
+
31
43
  /** Install metadata only; rendering and file installation do not enable these links. */
32
44
  export interface ISystemdServiceInstallTargets {
33
45
  wantedBy: readonly string[];
@@ -13,6 +13,9 @@ export interface ISystemdManagerVersion {
13
13
  /** Internal: the systemd major that first reads `FileDescriptorStorePreserve=` and reports it. */
14
14
  export const fileDescriptorStorePreserveMajor = 254;
15
15
 
16
+ /** Internal: the systemd major that first reads `append:` in `StandardOutput=` and `StandardError=`. */
17
+ export const outputAppendMajor = 240;
18
+
16
19
  /** Internal: the systemd major whose `systemctl clean` first releases a file descriptor store (`--what=fdstore`). */
17
20
  export const fileDescriptorStoreCleanMajor = 254;
18
21