@push.rocks/smartdaemon 2.7.1 → 2.8.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,7 +1,8 @@
1
1
  import * as plugins from './smartdaemon.plugins.js';
2
- import { renderServiceRelations, type ISystemdServiceStartup, type ISystemdServiceDependencies,
2
+ import { renderServiceRelations, type TSystemdServiceStartup, type ISystemdServiceDependencies,
3
3
  type ISystemdServiceInstallTargets } from './systemd.definition.js';
4
- export type { ISystemdServiceStartup, ISystemdServiceDependencies, ISystemdServiceInstallTargets } from './systemd.definition.js';
4
+ export type { ISystemdServiceStartup, ISystemdServiceNotifyStartup, TSystemdServiceStartup,
5
+ ISystemdServiceDependencies, ISystemdServiceInstallTargets } from './systemd.definition.js';
5
6
 
6
7
  interface ISystemdServiceDefinitionBaseOptions {
7
8
  unitName: string;
@@ -10,11 +11,21 @@ interface ISystemdServiceDefinitionBaseOptions {
10
11
  args: readonly string[];
11
12
  workingDirectory: string;
12
13
  restart: 'no' | 'on-failure' | 'always';
13
- killMode: 'mixed' | 'control-group';
14
+ killMode: 'mixed' | 'control-group' | 'process';
14
15
  timeoutStopSeconds: number;
15
- startup?: ISystemdServiceStartup;
16
+ startup?: TSystemdServiceStartup;
16
17
  dependencies?: ISystemdServiceDependencies;
17
18
  install?: ISystemdServiceInstallTargets;
19
+ /** Integer seconds from 1 to 600 before a restart (`RestartSec=`). Refused with `restart: 'no'`. */
20
+ restartSeconds?: number;
21
+ /** Leaves the cgroups of the processes this service starts to them (`Delegate=`). */
22
+ delegate?: boolean;
23
+ /** Soft and hard open-file limit (`LimitNOFILE=`): a positive integer or `'infinity'`. */
24
+ limitNoFile?: number | 'infinity';
25
+ /** Task limit of the unit's cgroup (`TasksMax=`): a positive integer or `'infinity'`. */
26
+ tasksMax?: number | 'infinity';
27
+ /** Integer from -1000 to 1000 (`OOMScoreAdjust=`). */
28
+ oomScoreAdjust?: number;
18
29
  }
19
30
 
20
31
  export type ISystemdServiceDefinitionOptions = ISystemdServiceDefinitionBaseOptions & (
@@ -67,8 +78,10 @@ export class SystemdServiceDefinition {
67
78
  'timeoutStopSeconds',
68
79
  ...(scope === 'system' ? ['user', 'group'] : []),
69
80
  ];
81
+ const optionalKeys = ['scope', 'startup', 'dependencies', 'install', 'restartSeconds', 'delegate',
82
+ 'limitNoFile', 'tasksMax', 'oomScoreAdjust'];
70
83
  requireValue(
71
- Reflect.ownKeys(options).every(key => typeof key === 'string' && [...keys, 'scope', 'startup', 'dependencies', 'install'].includes(key)
84
+ Reflect.ownKeys(options).every(key => typeof key === 'string' && [...keys, ...optionalKeys].includes(key)
72
85
  && Object.getOwnPropertyDescriptor(options, key)?.enumerable && 'value' in Object.getOwnPropertyDescriptor(options, key)!) && keys.every((key) => {
73
86
  const descriptor = Object.getOwnPropertyDescriptor(options, key);
74
87
  return descriptor && 'value' in descriptor;
@@ -108,10 +121,35 @@ export class SystemdServiceDefinition {
108
121
  }
109
122
  requireValue(
110
123
  ['no', 'on-failure', 'always'].includes(options.restart) &&
111
- ['mixed', 'control-group'].includes(options.killMode) &&
124
+ ['mixed', 'control-group', 'process'].includes(options.killMode) &&
112
125
  Number.isSafeInteger(options.timeoutStopSeconds) &&
113
126
  options.timeoutStopSeconds >= 1 && options.timeoutStopSeconds <= 600,
114
127
  );
128
+ const integer = (value: unknown, minimum: number, maximum: number): value is number =>
129
+ Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum;
130
+ const limit = (value: unknown): value is number | 'infinity' =>
131
+ value === 'infinity' || integer(value, 1, Number.MAX_SAFE_INTEGER);
132
+ // Each optional directive is absent unless supplied, so existing definitions keep their bytes.
133
+ const service: string[] = [];
134
+ if (options.restartSeconds !== undefined) {
135
+ requireValue(integer(options.restartSeconds, 1, 600) && options.restart !== 'no');
136
+ }
137
+ if (options.delegate !== undefined) {
138
+ requireValue(typeof options.delegate === 'boolean');
139
+ service.push(`Delegate=${options.delegate ? 'yes' : 'no'}`);
140
+ }
141
+ if (options.limitNoFile !== undefined) {
142
+ requireValue(limit(options.limitNoFile));
143
+ service.push(`LimitNOFILE=${options.limitNoFile}`);
144
+ }
145
+ if (options.tasksMax !== undefined) {
146
+ requireValue(limit(options.tasksMax));
147
+ service.push(`TasksMax=${options.tasksMax}`);
148
+ }
149
+ if (options.oomScoreAdjust !== undefined) {
150
+ requireValue(integer(options.oomScoreAdjust, -1000, 1000));
151
+ service.push(`OOMScoreAdjust=${options.oomScoreAdjust}`);
152
+ }
115
153
  // Quoting and percent/dollar escaping follow systemd.syntax and systemd.service.
116
154
  const quote = (value: string, command = false) =>
117
155
  '"' + value
@@ -135,12 +173,14 @@ export class SystemdServiceDefinition {
135
173
  // WorkingDirectory is a raw path value, not an ExecStart word list.
136
174
  `WorkingDirectory=${options.workingDirectory.replaceAll('%', '%%')}`,
137
175
  `Restart=${options.restart}`,
176
+ ...(options.restartSeconds !== undefined ? [`RestartSec=${options.restartSeconds}s`] : []),
138
177
  `KillMode=${options.killMode}`,
139
178
  'KillSignal=SIGTERM',
140
179
  'FinalKillSignal=SIGKILL',
141
180
  'SendSIGKILL=yes',
142
181
  'SendSIGHUP=no',
143
182
  `TimeoutStopSec=${options.timeoutStopSeconds}s`,
183
+ ...service,
144
184
  'StandardInput=null',
145
185
  'StandardOutput=journal',
146
186
  'StandardError=journal',
@@ -1,12 +1,20 @@
1
1
  import * as plugins from './smartdaemon.plugins.js';
2
+ import {
3
+ type ISystemdUserEnvironment,
4
+ type TSystemdUserManagerEnvironment,
5
+ captureUserEnvironment,
6
+ isSafeDirectory,
7
+ isUserManagerEnvironment,
8
+ } from './systemd.userenvironment.js';
2
9
 
3
10
  export interface ISystemdUnitOptions {
4
11
  unitName: string;
5
12
  /** Defaults to the system manager. User scope addresses the calling user's manager only. */
6
13
  scope?: 'system' | 'user';
7
14
  /**
8
- * User-manager socket directory. Defaults to a non-empty XDG_RUNTIME_DIR. May be absent at
9
- * construction; every call that contacts the user manager then refuses with `invalid_options`.
15
+ * User-manager socket directory. Defaults to a non-empty XDG_RUNTIME_DIR. A supplied option must
16
+ * be well formed at construction. The process default may be absent or malformed there; every
17
+ * call that contacts the user manager then refuses with `invalid_options`.
10
18
  */
11
19
  runtimeDirectory?: string;
12
20
  /** Absolute executable path supplied by trusted owning code. */
@@ -48,6 +56,29 @@ export interface ISystemdUnitRelationships {
48
56
  readonly wantedBy: readonly string[];
49
57
  }
50
58
 
59
+ /**
60
+ * Service settings as the manager loaded them, drop-ins included. Timespans are exact integer
61
+ * microseconds; a limit systemd reports as unbounded is `'infinity'`. Mode strings are systemd's
62
+ * own vocabulary, so a value this package does not render is still reported; `type` is empty for
63
+ * a unit systemd could not load.
64
+ */
65
+ export interface ISystemdServiceSettings {
66
+ readonly unitName: string;
67
+ readonly type: string;
68
+ readonly notifyAccess: string;
69
+ readonly restart: string;
70
+ readonly restartMicroseconds: number | 'infinity';
71
+ readonly killMode: string;
72
+ readonly timeoutStopMicroseconds: number | 'infinity';
73
+ readonly delegate: boolean;
74
+ /** Hard open-file limit (`LimitNOFILE`). */
75
+ readonly limitNoFile: number | 'infinity';
76
+ /** Soft open-file limit (`LimitNOFILESoft`). */
77
+ readonly limitNoFileSoft: number | 'infinity';
78
+ readonly tasksMax: number | 'infinity';
79
+ readonly oomScoreAdjust: number;
80
+ }
81
+
51
82
  export class SystemdUnitError extends Error {
52
83
  constructor(public readonly code: 'invalid_options' | 'command_failed' | 'invalid_state') {
53
84
  super(`Systemd unit operation failed: ${code}.`);
@@ -55,6 +86,37 @@ export class SystemdUnitError extends Error {
55
86
  }
56
87
  }
57
88
 
89
+ // systemd's format_timespan() units, largest first; a year and a month are 365.25 and 30.44 days.
90
+ const timespanUnits = new Map<string, bigint>([
91
+ ['y', 31557600000000n], ['month', 2629800000000n], ['w', 604800000000n], ['d', 86400000000n],
92
+ ['h', 3600000000n], ['min', 60000000n], ['s', 1000000n], ['ms', 1000n], ['us', 1n],
93
+ ]);
94
+
95
+ /** Parses `systemctl show` timespans such as `0`, `100ms`, `1min 30s`, `1.500000s` or `infinity`. */
96
+ const parseTimespan = (text: string): number | 'infinity' | undefined => {
97
+ if (text === 'infinity') return 'infinity';
98
+ if (text === '0') return 0;
99
+ const names = [...timespanUnits.keys()];
100
+ let total = 0n;
101
+ let previous = -1;
102
+ for (const part of text.split(' ')) {
103
+ const match = /^(0|[1-9]\d{0,19})(?:\.(\d{1,6}))?([a-z]+)$/.exec(part);
104
+ const index = match ? names.indexOf(match[3]) : -1;
105
+ if (!match || index <= previous) return undefined;
106
+ previous = index;
107
+ const scale = timespanUnits.get(match[3])!;
108
+ const fraction = match[2] === undefined ? 0n : BigInt(match[2].padEnd(6, '0')) * scale;
109
+ if (fraction % 1000000n !== 0n) return undefined;
110
+ total += BigInt(match[1]) * scale + fraction / 1000000n;
111
+ }
112
+ return total > 0n && total <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(total) : undefined;
113
+ };
114
+
115
+ /** `infinity` or a decimal unsigned integer within the safe integer range. */
116
+ const parseLimit = (text: string): number | 'infinity' | undefined =>
117
+ text === 'infinity' ? 'infinity'
118
+ : /^(0|[1-9]\d*)$/.test(text) && Number.isSafeInteger(Number(text)) ? Number(text) : undefined;
119
+
58
120
  const properties = [
59
121
  'Id',
60
122
  'LoadState',
@@ -74,30 +136,21 @@ export class SystemdUnit {
74
136
  private readonly executable: string;
75
137
  private readonly timeout: number;
76
138
  private readonly scope: 'system' | 'user';
77
- private readonly runtimeDirectory?: string;
78
- private readonly homeDirectory?: string;
79
- private readonly configDirectory?: string;
80
- private readonly dataDirectory?: string;
139
+ /** Present exactly for user scope. */
140
+ private readonly userEnvironment?: ISystemdUserEnvironment;
81
141
 
82
142
  constructor(optionsArg: ISystemdUnitOptions) {
83
143
  this.unitName = optionsArg?.unitName;
84
144
  this.executable = optionsArg?.systemctlPath ?? '/usr/bin/systemctl';
85
145
  this.timeout = optionsArg?.commandTimeoutMs ?? 120000;
86
146
  this.scope = optionsArg?.scope ?? 'system';
87
- // An empty XDG_RUNTIME_DIR counts as unset, like the sibling XDG variables below. An explicit
88
- // option is never reinterpreted, so an empty or relative option stays malformed.
89
- this.runtimeDirectory = this.scope === 'user'
90
- ? optionsArg?.runtimeDirectory ?? (process.env.XDG_RUNTIME_DIR || undefined) : undefined;
91
- this.homeDirectory = this.scope === 'user' ? plugins.os.userInfo().homedir : undefined;
92
- this.configDirectory = this.scope === 'user' ? process.env.XDG_CONFIG_HOME || undefined : undefined;
93
- this.dataDirectory = this.scope === 'user' ? process.env.XDG_DATA_HOME || undefined : undefined;
94
- const safeDirectory = (value: unknown): value is string =>
95
- typeof value === 'string' && value !== '/' && plugins.path.isAbsolute(value) &&
96
- plugins.path.normalize(value) === value && !value.endsWith('/') &&
97
- value.length <= 4000 && !/[\x00-\x1f\x7f]/.test(value);
98
- // Only a supplied runtime directory is validated here. Its absence matters solely to calls that
99
- // reach the user manager, and those refuse it in validateUserRuntime(). Admitting absence lets
100
- // owners read protected unit files from contexts without a login session (cron, plain ssh).
147
+ this.userEnvironment = this.scope === 'user'
148
+ ? captureUserEnvironment(optionsArg?.runtimeDirectory) : undefined;
149
+ // Construction validates only the options it is given. The process-derived user environment
150
+ // (home, XDG config/data/runtime roots) is validated by userManagerEnvironment() before every
151
+ // systemctl call, because only those calls read it. SystemdUnitFile builds its unit at
152
+ // construction, so refusing that environment here would also refuse SystemdUnitFile.inspect(),
153
+ // which never contacts the manager.
101
154
  if (
102
155
  typeof this.unitName !== 'string' ||
103
156
  !/^[a-zA-Z0-9][a-zA-Z0-9_.:@-]{0,230}\.service$/.test(this.unitName) ||
@@ -106,11 +159,8 @@ export class SystemdUnit {
106
159
  this.timeout < 1 || this.timeout > 600000 ||
107
160
  !['system', 'user'].includes(this.scope) ||
108
161
  (this.scope === 'system' && optionsArg?.runtimeDirectory !== undefined) ||
109
- (this.scope === 'user' &&
110
- (this.runtimeDirectory !== undefined && !safeDirectory(this.runtimeDirectory) ||
111
- !safeDirectory(this.homeDirectory) ||
112
- this.configDirectory !== undefined && !safeDirectory(this.configDirectory) ||
113
- this.dataDirectory !== undefined && !safeDirectory(this.dataDirectory)))
162
+ (this.scope === 'user' && optionsArg?.runtimeDirectory !== undefined &&
163
+ !isSafeDirectory(optionsArg.runtimeDirectory))
114
164
  ) throw new SystemdUnitError('invalid_options');
115
165
  }
116
166
 
@@ -221,17 +271,65 @@ export class SystemdUnit {
221
271
  });
222
272
  }
223
273
 
274
+ /**
275
+ * Reports the loaded service settings that a definition renders, so an owner can verify an
276
+ * installed unit carries them. Reads only; the manager must have reloaded changed files first.
277
+ */
278
+ public async inspectServiceSettings(): Promise<ISystemdServiceSettings> {
279
+ const keys = ['Id', 'Type', 'NotifyAccess', 'Restart', 'RestartUSec', 'KillMode', 'TimeoutStopUSec',
280
+ 'Delegate', 'LimitNOFILE', 'LimitNOFILESoft', 'TasksMax', 'OOMScoreAdjust'];
281
+ const stdout = await this.command(['show', this.unitName, `--property=${keys.join(',')}`, '--all']);
282
+ const fields = new Map<string, string>();
283
+ for (const line of (stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout).split('\n')) {
284
+ const separator = line.indexOf('='), key = line.slice(0, separator);
285
+ if (separator < 1 || !keys.includes(key) || fields.has(key)) throw new SystemdUnitError('invalid_state');
286
+ fields.set(key, line.slice(separator + 1));
287
+ }
288
+ // An older manager omits a property it does not know; the count check refuses that snapshot.
289
+ if (fields.size !== keys.length || fields.get('Id') !== this.unitName) throw new SystemdUnitError('invalid_state');
290
+ const mode = (key: string, empty = false): string => {
291
+ const value = fields.get(key)!;
292
+ if (!(empty && value === '') && !/^[a-z][a-z-]{0,31}$/.test(value)) throw new SystemdUnitError('invalid_state');
293
+ return value;
294
+ };
295
+ const parsed = <T>(key: string, parse: (text: string) => T | undefined): T => {
296
+ const value = parse(fields.get(key)!);
297
+ if (value === undefined) throw new SystemdUnitError('invalid_state');
298
+ return value;
299
+ };
300
+ const oomScoreAdjust = parsed('OOMScoreAdjust', (text) =>
301
+ /^(0|-?[1-9]\d{0,3})$/.test(text) && Math.abs(Number(text)) <= 1000 ? Number(text) : undefined);
302
+ return Object.freeze({
303
+ unitName: this.unitName,
304
+ type: mode('Type', true),
305
+ notifyAccess: mode('NotifyAccess'),
306
+ restart: mode('Restart'),
307
+ restartMicroseconds: parsed('RestartUSec', parseTimespan),
308
+ killMode: mode('KillMode'),
309
+ timeoutStopMicroseconds: parsed('TimeoutStopUSec', parseTimespan),
310
+ delegate: parsed('Delegate', (text) => text === 'yes' ? true : text === 'no' ? false : undefined),
311
+ limitNoFile: parsed('LimitNOFILE', parseLimit),
312
+ limitNoFileSoft: parsed('LimitNOFILESoft', parseLimit),
313
+ tasksMax: parsed('TasksMax', parseLimit),
314
+ oomScoreAdjust,
315
+ });
316
+ }
317
+
224
318
  /** Reloads unit definitions globally; does not enable, unmask, start or stop a unit. */
225
319
  public async reloadConfiguration(): Promise<ISystemdUnitConfigurationState> {
226
320
  await this.command(['daemon-reload']);
227
321
  return this.inspectConfiguration();
228
322
  }
229
323
 
230
- private async validateUserRuntime(): Promise<void> {
231
- if (this.scope !== 'user') return;
232
- // Runs before every systemctl invocation, so a missing directory refuses without side effects.
233
- const runtimeDirectory = this.runtimeDirectory;
234
- if (runtimeDirectory === undefined) throw new SystemdUnitError('invalid_options');
324
+ /**
325
+ * Runs before every systemctl invocation, so an absent or malformed user environment and a
326
+ * missing or unsafe runtime directory refuse without side effects. Undefined for system scope.
327
+ */
328
+ private async userManagerEnvironment(): Promise<TSystemdUserManagerEnvironment | undefined> {
329
+ const environment = this.userEnvironment;
330
+ if (environment === undefined) return undefined;
331
+ if (!isUserManagerEnvironment(environment)) throw new SystemdUnitError('invalid_options');
332
+ const runtimeDirectory = environment.runtimeDirectory;
235
333
  try {
236
334
  const uid = process.getuid?.();
237
335
  if (process.platform !== 'linux' || uid === undefined || process.geteuid?.() !== uid ||
@@ -244,10 +342,11 @@ export class SystemdUnit {
244
342
  } catch {
245
343
  throw new SystemdUnitError('invalid_options');
246
344
  }
345
+ return environment;
247
346
  }
248
347
 
249
348
  private async command(argsArg: string[]): Promise<string> {
250
- await this.validateUserRuntime();
349
+ const userEnvironment = await this.userManagerEnvironment();
251
350
  return new Promise((resolve, reject) => {
252
351
  plugins.childProcess.execFile(
253
352
  this.executable,
@@ -258,11 +357,13 @@ export class SystemdUnit {
258
357
  killSignal: 'SIGKILL',
259
358
  maxBuffer: 16384,
260
359
  windowsHide: true,
261
- env: this.scope === 'user'
360
+ env: userEnvironment
262
361
  ? { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C',
263
- HOME: this.homeDirectory!, XDG_RUNTIME_DIR: this.runtimeDirectory!,
264
- ...(this.configDirectory ? { XDG_CONFIG_HOME: this.configDirectory } : {}),
265
- ...(this.dataDirectory ? { XDG_DATA_HOME: this.dataDirectory } : {}) }
362
+ HOME: userEnvironment.homeDirectory, XDG_RUNTIME_DIR: userEnvironment.runtimeDirectory,
363
+ ...(userEnvironment.configDirectory
364
+ ? { XDG_CONFIG_HOME: userEnvironment.configDirectory } : {}),
365
+ ...(userEnvironment.dataDirectory
366
+ ? { XDG_DATA_HOME: userEnvironment.dataDirectory } : {}) }
266
367
  : { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' },
267
368
  },
268
369
  (error, stdout) => {
@@ -6,6 +6,12 @@ import {
6
6
  type ISystemdUnitState,
7
7
  SystemdUnit,
8
8
  } from './classes.systemdunit.js';
9
+ import {
10
+ type ISystemdUserEnvironment,
11
+ captureUserEnvironment,
12
+ isSafeDirectory,
13
+ isUserManagerEnvironment,
14
+ } from './systemd.userenvironment.js';
9
15
 
10
16
  export interface ISystemdUnitFileOptions extends ISystemdUnitOptions {
11
17
  /** Local vendor definitions can be installed without removing an administrator's mask. */
@@ -13,6 +19,10 @@ export interface ISystemdUnitFileOptions extends ISystemdUnitOptions {
13
19
  /** Trusted owning-code qualification only. Defaults to root for system, current uid for user. */
14
20
  expectedUid?: number;
15
21
  }
22
+ interface IMaskDirectories {
23
+ readonly persistent: readonly string[];
24
+ readonly runtime: readonly string[];
25
+ }
16
26
  export interface ISystemdUnitFileRecord {
17
27
  readonly path: string;
18
28
  readonly sha256: string;
@@ -57,42 +67,65 @@ export class SystemdUnitFile {
57
67
  private readonly directory: string;
58
68
  private readonly uid: number;
59
69
  private readonly scope: 'system' | 'user';
60
- private readonly configDirectory?: string;
61
- private readonly runtimeDirectory?: string;
70
+ /** Present exactly for user scope; read only by the default path and by install(). */
71
+ private readonly userEnvironment?: ISystemdUserEnvironment;
62
72
  private readonly unit: SystemdUnit;
63
73
  private readonly name: string;
64
74
  private busy = false;
65
75
 
66
76
  constructor(options: ISystemdUnitFileOptions) {
77
+ // Validates the unit's explicit options, including a supplied runtime directory. Like this
78
+ // class, it captures but does not validate the process-derived user environment.
67
79
  this.unit = new SystemdUnit(options);
68
80
  this.name = options.unitName;
69
81
  this.scope = options.scope ?? 'system';
70
- const home = this.scope === 'user' ? plugins.os.userInfo().homedir : undefined;
71
- const dataDirectory = this.scope === 'user'
72
- ? process.env.XDG_DATA_HOME || plugins.path.join(home!, '.local/share') : undefined;
73
- this.configDirectory = this.scope === 'user'
74
- ? process.env.XDG_CONFIG_HOME || plugins.path.join(home!, '.config') : undefined;
75
- // Resolved exactly as SystemdUnit resolves it, so both agree on presence.
76
- this.runtimeDirectory = this.scope === 'user'
77
- ? options.runtimeDirectory ?? (process.env.XDG_RUNTIME_DIR || undefined) : undefined;
78
- this.directory = options.unitDirectory ?? (this.scope === 'user'
79
- ? plugins.path.join(dataDirectory!, 'systemd/user') : '/usr/local/lib/systemd/system');
82
+ // Captured in the same synchronous step as the unit's own snapshot, so both read equal values.
83
+ this.userEnvironment = this.scope === 'user'
84
+ ? captureUserEnvironment(options.runtimeDirectory) : undefined;
85
+ const directory = options.unitDirectory ?? this.defaultDirectory();
80
86
  this.uid = options.expectedUid ?? (this.scope === 'user' ? process.getuid?.() ?? -1 : 0);
81
- const validDirectory = (directory: unknown): directory is string =>
82
- typeof directory === 'string' && directory !== '/' &&
83
- plugins.path.isAbsolute(directory) && plugins.path.normalize(directory) === directory &&
84
- !directory.endsWith('/') && !/[\x00-\x1f\x7f]/.test(directory) &&
85
- directory.length <= 4000;
86
- requireValue(
87
- validDirectory(this.directory) && Number.isSafeInteger(this.uid) && this.uid >= 0 &&
88
- (this.scope === 'system' ||
89
- this.uid === process.getuid?.() && validDirectory(this.configDirectory) &&
90
- // inspect() reads only the unit directory, so an absent runtime directory is refused
91
- // later, by install(); a supplied one must still be well formed.
92
- (this.runtimeDirectory === undefined || validDirectory(this.runtimeDirectory))),
93
- 'invalid_options',
94
- );
95
- this.path = plugins.path.join(this.directory, this.name);
87
+ // inspect() reads the unit directory as this uid and nothing else. The config and runtime
88
+ // roots and the manager environment are refused later, by install(), before any effect.
89
+ if (
90
+ !isSafeDirectory(directory) || !Number.isSafeInteger(this.uid) || this.uid < 0 ||
91
+ this.scope === 'user' && this.uid !== process.getuid?.()
92
+ ) throw new SystemdUnitFileError('invalid_options');
93
+ this.directory = directory;
94
+ this.path = plugins.path.join(directory, this.name);
95
+ }
96
+
97
+ /**
98
+ * Only the default location reads XDG_DATA_HOME, or the home directory when that is unset. The
99
+ * public path exists from construction, so a malformed source refuses there: joining would
100
+ * otherwise normalize a value such as `/data/../elsewhere` into a plausible directory.
101
+ */
102
+ private defaultDirectory(): string | undefined {
103
+ const environment = this.userEnvironment;
104
+ if (environment === undefined) return '/usr/local/lib/systemd/system';
105
+ const dataDirectory = environment.dataDirectory ?? (isSafeDirectory(environment.homeDirectory)
106
+ ? plugins.path.join(environment.homeDirectory, '.local/share') : undefined);
107
+ return isSafeDirectory(dataDirectory) ? plugins.path.join(dataDirectory, 'systemd/user') : undefined;
108
+ }
109
+
110
+ /**
111
+ * Directories whose entry for this unit is a mask the manager may report. Only install() reads
112
+ * them. Null when the user environment cannot drive the manager or name the config root.
113
+ */
114
+ private maskDirectories(): IMaskDirectories | null {
115
+ const environment = this.userEnvironment;
116
+ if (environment === undefined) {
117
+ return { persistent: ['/etc/systemd/system'], runtime: ['/run/systemd/system'] };
118
+ }
119
+ const configDirectory = environment.configDirectory ??
120
+ (isSafeDirectory(environment.homeDirectory)
121
+ ? plugins.path.join(environment.homeDirectory, '.config') : undefined);
122
+ if (!isUserManagerEnvironment(environment) || !isSafeDirectory(configDirectory)) return null;
123
+ const userDirectories = (roots: readonly string[]) =>
124
+ roots.map(root => plugins.path.join(root, 'systemd/user'));
125
+ return {
126
+ persistent: userDirectories([configDirectory, '/etc']),
127
+ runtime: userDirectories([environment.runtimeDirectory, '/run']),
128
+ };
96
129
  }
97
130
 
98
131
  /**
@@ -117,20 +150,22 @@ export class SystemdUnitFile {
117
150
  definition instanceof SystemdServiceDefinition && definition.unitName === this.name &&
118
151
  definition.scope === this.scope &&
119
152
  (expectedSha256 === null ||
120
- typeof expectedSha256 === 'string' && /^[a-f0-9]{64}$/.test(expectedSha256)) &&
121
- // Installation admits, reloads and verifies through the user manager and recognizes its
122
- // runtime mask path. Refusing here, before the busy latch and any directory or systemctl
123
- // effect, keeps the local error code instead of a wrapped write_failed.
124
- (this.scope === 'system' || this.runtimeDirectory !== undefined),
153
+ typeof expectedSha256 === 'string' && /^[a-f0-9]{64}$/.test(expectedSha256)),
125
154
  'invalid_options',
126
155
  );
156
+ // Installation admits, reloads and verifies through the user manager and recognizes its
157
+ // persistent and runtime mask paths, so it needs the whole user environment. Refusing here,
158
+ // before the busy latch and any directory or systemctl effect, keeps the local error code
159
+ // instead of a wrapped write_failed.
160
+ const maskDirectories = this.maskDirectories();
161
+ if (maskDirectories === null) throw new SystemdUnitFileError('invalid_options');
127
162
  requireValue(!this.busy, 'busy');
128
163
  this.busy = true;
129
164
  let temporary: string | undefined;
130
165
  let temporaryInfo: plugins.Stats | undefined;
131
166
  try {
132
167
  const before = await this.unit.inspect();
133
- const beforeConfiguration = await this.admit(before);
168
+ const beforeConfiguration = await this.admit(before, maskDirectories);
134
169
  await this.directories(true);
135
170
  const parent = await plugins.fs.lstat(this.directory);
136
171
  const previous = await this.read(this.path);
@@ -163,7 +198,7 @@ export class SystemdUnitFile {
163
198
  requireValue(sameFile(parent, await plugins.fs.lstat(this.directory)), 'unsafe_path');
164
199
  const rechecked = await this.unit.inspect();
165
200
  requireValue(JSON.stringify(rechecked) === JSON.stringify(before), 'changed');
166
- const recheckedConfiguration = await this.admit(rechecked);
201
+ const recheckedConfiguration = await this.admit(rechecked, maskDirectories);
167
202
  requireValue(
168
203
  recheckedConfiguration.fragmentPath === beforeConfiguration.fragmentPath,
169
204
  'changed',
@@ -214,7 +249,10 @@ export class SystemdUnitFile {
214
249
  }
215
250
  }
216
251
 
217
- private async admit(state: ISystemdUnitState): Promise<ISystemdUnitConfigurationState> {
252
+ private async admit(
253
+ state: ISystemdUnitState,
254
+ maskDirectories: IMaskDirectories,
255
+ ): Promise<ISystemdUnitConfigurationState> {
218
256
  requireValue(
219
257
  quiet(state) && (masked(state) ||
220
258
  state.loadState === 'loaded' &&
@@ -223,14 +261,9 @@ export class SystemdUnitFile {
223
261
  'unsafe_unit',
224
262
  );
225
263
  const configuration = await this.unit.inspectConfiguration();
226
- const maskPaths = this.scope === 'system'
227
- ? [plugins.path.join(
228
- state.unitFileState === 'masked-runtime' ? '/run/systemd/system' : '/etc/systemd/system',
229
- this.name,
230
- )]
231
- : (state.unitFileState === 'masked-runtime'
232
- ? [this.runtimeDirectory!, '/run'].map(root => plugins.path.join(root, 'systemd/user', this.name))
233
- : [this.configDirectory!, '/etc'].map(root => plugins.path.join(root, 'systemd/user', this.name)));
264
+ const maskPaths = (state.unitFileState === 'masked-runtime'
265
+ ? maskDirectories.runtime : maskDirectories.persistent)
266
+ .map(directory => plugins.path.join(directory, this.name));
234
267
  requireValue(
235
268
  !configuration.needsReload && configuration.dropInPaths === '' &&
236
269
  (masked(state)
@@ -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;