@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.
- 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 +37 -7
- package/dist_ts/classes.systemdunit.js +106 -30
- package/dist_ts/classes.systemdunitfile.d.ts +13 -2
- package/dist_ts/classes.systemdunitfile.js +62 -35
- package/dist_ts/systemd.definition.d.ts +11 -1
- package/dist_ts/systemd.definition.js +18 -7
- package/dist_ts/systemd.userenvironment.d.ts +33 -0
- package/dist_ts/systemd.userenvironment.js +33 -0
- package/package.json +3 -3
- package/readme.md +106 -15
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.systemdservicedefinition.ts +46 -6
- package/ts/classes.systemdunit.ts +136 -35
- package/ts/classes.systemdunitfile.ts +76 -43
- package/ts/systemd.definition.ts +29 -7
- package/ts/systemd.userenvironment.ts +63 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as plugins from './smartdaemon.plugins.js';
|
|
2
|
-
import { renderServiceRelations, type
|
|
2
|
+
import { renderServiceRelations, type TSystemdServiceStartup, type ISystemdServiceDependencies,
|
|
3
3
|
type ISystemdServiceInstallTargets } from './systemd.definition.js';
|
|
4
|
-
export type { ISystemdServiceStartup,
|
|
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?:
|
|
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,
|
|
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.
|
|
9
|
-
* construction
|
|
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
|
-
|
|
78
|
-
private readonly
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
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:
|
|
360
|
+
env: userEnvironment
|
|
262
361
|
? { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C',
|
|
263
|
-
HOME:
|
|
264
|
-
...(
|
|
265
|
-
|
|
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
|
-
|
|
61
|
-
private readonly
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
?
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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(
|
|
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 =
|
|
227
|
-
?
|
|
228
|
-
|
|
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)
|
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;
|