dsh-home-hosted 0.6.1 → 0.6.2
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/README.md +7 -0
- package/docs/DESIGN.md +75 -2
- package/lib/index.js +276 -104
- package/lib/index.js.map +4 -4
- package/lib/types/boot/common.d.ts +81 -4
- package/lib/types/boot/escape.d.ts +9 -0
- package/lib/types/boot/index.d.ts +3 -1
- package/lib/types/boot/systemd.d.ts +6 -2
- package/lib/types/boot/types.d.ts +43 -0
- package/lib/types/home-hosted/launch.d.ts +29 -0
- package/lib/types/service.d.ts +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,6 +114,13 @@ A tool that changes something asks for approval **only** when the session is not
|
|
|
114
114
|
When the process cannot elevate, the page prints the exact commands instead — including the
|
|
115
115
|
`launchd-daemon` that starts a Mac **before** login.
|
|
116
116
|
|
|
117
|
+
A boot entry runs the panel as the person who installed it: the account comes from the login that
|
|
118
|
+
elevated (`SUDO_UID`) or from the panel root's owner, not from `$USER`.
|
|
119
|
+
<sub>An entry with no account to name runs the panel as root — that is the default, and the page
|
|
120
|
+
says so rather than hiding it. A `User=` systemd cannot resolve is refused too, because it makes
|
|
121
|
+
the unit fail to start. Moving an install from root to a user later needs
|
|
122
|
+
`chown -R <user> <home>`.</sub>
|
|
123
|
+
|
|
117
124
|
</details>
|
|
118
125
|
|
|
119
126
|
<details>
|
package/docs/DESIGN.md
CHANGED
|
@@ -292,6 +292,71 @@ The settings stamp moved to 3 for `instancesNotice`, while the tool-name
|
|
|
292
292
|
migration stays bound to the release that merged the tools: a stamp bump must
|
|
293
293
|
never re-read a current file's explicit two-tool allowlist as "nobody chose".
|
|
294
294
|
|
|
295
|
+
## Boot entries run the panel as a person
|
|
296
|
+
|
|
297
|
+
Two mechanisms drop privilege by name — a systemd `User=` and a launchd
|
|
298
|
+
`UserName` — and both used to take that name from `$USER`. That is right in the
|
|
299
|
+
common case and wrong in the one case it exists for: `sudo` and `pkexec` rewrite
|
|
300
|
+
`USER`, `LOGNAME` and `HOME` to the *target* account, so a panel started from a
|
|
301
|
+
root shell or a root unit read `USER=root`, and a mechanism that already runs as
|
|
302
|
+
root got an entry that either repeated root or (with the name dropped) silently
|
|
303
|
+
kept it. `os.userInfo()` is no help: it reports the euid.
|
|
304
|
+
|
|
305
|
+
So the account comes from whoever this process is. An elevating tool's
|
|
306
|
+
`SUDO_UID`/`PKEXEC_UID` is an instruction and is used while it resolves, so a
|
|
307
|
+
stale value inherited from an ancestor cannot collapse the entry to root;
|
|
308
|
+
otherwise this session's own login — its database row, else the row for this uid;
|
|
309
|
+
otherwise the owner of the state root or the panel root, which names the person a
|
|
310
|
+
root-launched panel belongs to; and only then, honestly, root.
|
|
311
|
+
|
|
312
|
+
The uid is the fact and `$UID` is a claim: it is a shell variable rather than an
|
|
313
|
+
exported one, so when it is present at all it was inherited, and it also feeds the
|
|
314
|
+
launchd domain (`gui/$UID`). It is read only when the platform gives no uid.
|
|
315
|
+
|
|
316
|
+
Two answers are refused rather than written, and both fail loudly:
|
|
317
|
+
|
|
318
|
+
- **`User=root`**, because an entry with no `User=` already runs as root — naming
|
|
319
|
+
it only dresses up the silent default this change exists to surface;
|
|
320
|
+
- **a name the user database cannot place.** systemd matches names
|
|
321
|
+
case-sensitively and cannot resolve what it does not know, so
|
|
322
|
+
`User=<such a name>` makes the unit refuse to start (`status=217/USER`) and
|
|
323
|
+
`Restart=always` turns that into a crash-loop that never mentions the account —
|
|
324
|
+
worse than running as root, and the same unexplained failure in a new costume.
|
|
325
|
+
An LDAP or NIS login has no `/etc/passwd` row, so those operators set `User=`
|
|
326
|
+
themselves; the warning says so.
|
|
327
|
+
|
|
328
|
+
`HOME` is written into every spec, because `Environment=HOME=` **overrides
|
|
329
|
+
`User=`** — verified on real systemd. So it has to be the account's home: left at
|
|
330
|
+
the installer's it is root's under `sudo`, and the panel would run as the person
|
|
331
|
+
with `~` pointing at `/root`, dying with a permission error that never mentions
|
|
332
|
+
its own user. It is written only when it differs, so an unchanged install stays
|
|
333
|
+
byte-identical and startup `reconcile()` does not rewrite the entry. When an
|
|
334
|
+
account resolves but its home is not knowable, the line is **omitted** instead:
|
|
335
|
+
systemd then supplies the right one from NSS, which leaving root's in place would
|
|
336
|
+
have prevented.
|
|
337
|
+
|
|
338
|
+
An installed entry that already runs as the wrong account is reported by
|
|
339
|
+
`status()`, and the `enabled-failing` repair heals it by installing again. A
|
|
340
|
+
*working* entry is deliberately left alone: re-installing stops the panel and
|
|
341
|
+
hands it to the entry, which is a person's decision and not a startup side
|
|
342
|
+
effect.
|
|
343
|
+
|
|
344
|
+
## KillMode=process: a restart must not take the entries with it
|
|
345
|
+
|
|
346
|
+
`persistent: true` puts an entry under a nanny the panel spawns, and the nanny is
|
|
347
|
+
what makes it outlive the panel: it owns the child's pipes, so a panel stop,
|
|
348
|
+
restart or kill cannot break them. The nanny is spawned `detached`, which gives it
|
|
349
|
+
its own session — but *not* its own cgroup, and systemd's default
|
|
350
|
+
`KillMode=control-group` signals every process in the unit's cgroup. So a
|
|
351
|
+
`systemctl stop`/`restart` — which is what this plugin's own activation, and any
|
|
352
|
+
`Restart=always`, runs — takes the nanny down with the panel, quietly falsifying
|
|
353
|
+
the guarantee the design depends on. `KillMode=process` signals the panel alone.
|
|
354
|
+
|
|
355
|
+
An ordinary entry is spawned `detached` too, so the same applies to every
|
|
356
|
+
supervised server and not only the nannies: the panel re-adopts a survivor by its
|
|
357
|
+
port, which is what makes keeping them the right answer rather than a leak.
|
|
358
|
+
It belongs on both units.
|
|
359
|
+
|
|
295
360
|
## macOS: agent or daemon
|
|
296
361
|
|
|
297
362
|
A `LaunchAgent` starts at login; starting before login means a `LaunchDaemon` in
|
|
@@ -299,8 +364,9 @@ A `LaunchAgent` starts at login; starting before login means a `LaunchDaemon` in
|
|
|
299
364
|
install. The daemon is therefore offered even when this process cannot elevate:
|
|
300
365
|
install stages the plist and returns the three commands to run. The daemon plist
|
|
301
366
|
names the invoking user in `UserName`, Apple's sanctioned way to avoid running
|
|
302
|
-
the panel as root
|
|
303
|
-
can install on its own so
|
|
367
|
+
the panel as root — resolved by the rule above, never from `$USER`. The
|
|
368
|
+
recommendation still prefers a mechanism this process can install on its own so
|
|
369
|
+
"Automatic" stays a single click.
|
|
304
370
|
|
|
305
371
|
## macOS: reachable launchd is not a requirement
|
|
306
372
|
|
|
@@ -491,6 +557,13 @@ as the status moves: a person runs the commands and presses Re-check, and the no
|
|
|
491
557
|
must not outlive the state it described. The persisted last attempt follows the
|
|
492
558
|
same rule, so a failed install is not re-shown once an entry exists.
|
|
493
559
|
|
|
560
|
+
Switching those entries to a `User=` later is not just a unit edit. The moment a
|
|
561
|
+
panel runs as root, everything it writes — `<home>/.hh`, its workspaces, logs and
|
|
562
|
+
`snapshots.json` — is root-owned, so the unprivileged account the unit now names
|
|
563
|
+
cannot write its own state and the panel fails to boot with a permission error
|
|
564
|
+
that never mentions ownership. Moving an install from root to a user therefore
|
|
565
|
+
needs `chown -R <user> <home>` (and the plugin state dir) first.
|
|
566
|
+
|
|
494
567
|
## Tests
|
|
495
568
|
|
|
496
569
|
No fixed ports: every stub listener binds `127.0.0.1` with port 0 and reads the
|
package/lib/index.js
CHANGED
|
@@ -70,77 +70,6 @@ import path15 from "node:path";
|
|
|
70
70
|
import process10 from "node:process";
|
|
71
71
|
import { fileURLToPath } from "node:url";
|
|
72
72
|
|
|
73
|
-
// src/boot/ladder.ts
|
|
74
|
-
import os2 from "node:os";
|
|
75
|
-
import process3 from "node:process";
|
|
76
|
-
|
|
77
|
-
// src/util/exec.ts
|
|
78
|
-
import { spawn } from "node:child_process";
|
|
79
|
-
async function run(command, args = [], options = {}) {
|
|
80
|
-
const maxBytes = options.maxBytes ?? 256 * 1024;
|
|
81
|
-
const timeoutMs = options.timeoutMs ?? 2e4;
|
|
82
|
-
return await new Promise((resolve) => {
|
|
83
|
-
let settled = false;
|
|
84
|
-
let stdout = "";
|
|
85
|
-
let stderr = "";
|
|
86
|
-
let timedOut = false;
|
|
87
|
-
const finish = (result) => {
|
|
88
|
-
if (settled)
|
|
89
|
-
return;
|
|
90
|
-
settled = true;
|
|
91
|
-
resolve({
|
|
92
|
-
command,
|
|
93
|
-
args,
|
|
94
|
-
code: result.code ?? null,
|
|
95
|
-
signal: result.signal ?? null,
|
|
96
|
-
stdout: result.stdout ?? stdout,
|
|
97
|
-
stderr: result.stderr ?? stderr,
|
|
98
|
-
timedOut,
|
|
99
|
-
error: result.error ?? null
|
|
100
|
-
});
|
|
101
|
-
};
|
|
102
|
-
let child;
|
|
103
|
-
try {
|
|
104
|
-
child = spawn(command, args, {
|
|
105
|
-
cwd: options.cwd,
|
|
106
|
-
env: { ...process.env, ...options.env },
|
|
107
|
-
shell: false,
|
|
108
|
-
windowsHide: true,
|
|
109
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
110
|
-
});
|
|
111
|
-
} catch (error) {
|
|
112
|
-
finish({ error: error instanceof Error ? error.message : String(error) });
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
const timer = setTimeout(() => {
|
|
116
|
-
timedOut = true;
|
|
117
|
-
child.kill("SIGKILL");
|
|
118
|
-
}, timeoutMs);
|
|
119
|
-
child.stdout?.on("data", (chunk) => {
|
|
120
|
-
if (stdout.length < maxBytes)
|
|
121
|
-
stdout += chunk.toString("utf8").slice(0, maxBytes - stdout.length);
|
|
122
|
-
});
|
|
123
|
-
child.stderr?.on("data", (chunk) => {
|
|
124
|
-
if (stderr.length < maxBytes)
|
|
125
|
-
stderr += chunk.toString("utf8").slice(0, maxBytes - stderr.length);
|
|
126
|
-
});
|
|
127
|
-
child.on("error", (error) => {
|
|
128
|
-
clearTimeout(timer);
|
|
129
|
-
finish({ error: error.message });
|
|
130
|
-
});
|
|
131
|
-
child.on("close", (code, signal) => {
|
|
132
|
-
clearTimeout(timer);
|
|
133
|
-
finish({ code, signal });
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
async function sudoAvailable() {
|
|
138
|
-
if (process.getuid?.() === 0)
|
|
139
|
-
return true;
|
|
140
|
-
const result = await run("sudo", ["-n", "true"], { timeoutMs: 5e3 });
|
|
141
|
-
return result.code === 0;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
73
|
// src/boot/common.ts
|
|
145
74
|
import fs2 from "node:fs";
|
|
146
75
|
import os from "node:os";
|
|
@@ -211,32 +140,129 @@ function tempDir(ctx) {
|
|
|
211
140
|
const value = ctx.env.TMPDIR?.trim() || ctx.env.TEMP?.trim() || ctx.env.TMP?.trim();
|
|
212
141
|
return value || os.tmpdir();
|
|
213
142
|
}
|
|
214
|
-
function
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
143
|
+
function parsePasswd(text) {
|
|
144
|
+
return text.split("\n").map((line) => {
|
|
145
|
+
const fields = line.split(":");
|
|
146
|
+
const uid = Number.parseInt(fields[2] ?? "", 10);
|
|
147
|
+
return fields.length < 6 || !Number.isFinite(uid) || (fields[0] ?? "").length === 0 ? null : { name: fields[0] ?? "", uid, home: fields[5] ?? "" };
|
|
148
|
+
}).filter((entry) => entry !== null);
|
|
149
|
+
}
|
|
150
|
+
function readPasswd() {
|
|
218
151
|
try {
|
|
219
|
-
return
|
|
152
|
+
return parsePasswd(fs2.readFileSync("/etc/passwd", "utf8"));
|
|
220
153
|
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function numberFrom(value) {
|
|
158
|
+
const text = value?.trim();
|
|
159
|
+
if (text === void 0 || text.length === 0)
|
|
160
|
+
return null;
|
|
161
|
+
return /^\d+$/.test(text) ? Number.parseInt(text, 10) : null;
|
|
162
|
+
}
|
|
163
|
+
function accountFrom(row, ambientHome) {
|
|
164
|
+
const home = row.home.length > 0 ? row.home : ambientHome?.trim() || null;
|
|
165
|
+
return { name: row.name, uid: row.uid, home, root: row.uid === 0, verified: true };
|
|
166
|
+
}
|
|
167
|
+
function declaredName(env) {
|
|
168
|
+
const value = env.SUDO_USER?.trim() || env.PKEXEC_USER?.trim();
|
|
169
|
+
return value !== void 0 && value.length > 0 ? value : null;
|
|
170
|
+
}
|
|
171
|
+
function sessionName(env) {
|
|
172
|
+
const value = env.LOGNAME?.trim() || env.USER?.trim() || env.USERNAME?.trim();
|
|
173
|
+
return value !== void 0 && value.length > 0 ? value : null;
|
|
174
|
+
}
|
|
175
|
+
function ownerAccount(paths, passwd) {
|
|
176
|
+
for (const path17 of paths ?? []) {
|
|
177
|
+
try {
|
|
178
|
+
const uid = fs2.statSync(path17).uid;
|
|
179
|
+
if (uid === 0)
|
|
180
|
+
continue;
|
|
181
|
+
const row = passwd.find((entry) => entry.uid === uid);
|
|
182
|
+
if (row !== void 0)
|
|
183
|
+
return accountFrom(row);
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
function accountOf(input) {
|
|
190
|
+
const passwd = input.passwd ?? readPasswd();
|
|
191
|
+
const byUid = (uid) => uid === null ? null : passwd.find((entry) => entry.uid === uid) ?? null;
|
|
192
|
+
const byName = (name2) => name2 === null ? null : passwd.find((entry) => entry.name === name2) ?? null;
|
|
193
|
+
const elevatedUid = numberFrom(input.env.SUDO_UID) ?? numberFrom(input.env.PKEXEC_UID);
|
|
194
|
+
const elevatedName = declaredName(input.env);
|
|
195
|
+
if (elevatedUid !== null || elevatedName !== null) {
|
|
196
|
+
const row = byUid(elevatedUid) ?? byName(elevatedName);
|
|
197
|
+
if (row !== null)
|
|
198
|
+
return accountFrom(row);
|
|
199
|
+
if (elevatedUid !== null && elevatedUid !== 0 && elevatedName !== null)
|
|
200
|
+
return { name: elevatedName, uid: elevatedUid, home: null, root: false, verified: false };
|
|
201
|
+
}
|
|
202
|
+
const euid = input.uid === void 0 ? process.getuid?.() ?? null : input.uid;
|
|
203
|
+
const ownUid = euid ?? numberFrom(input.env.UID);
|
|
204
|
+
const session = sessionName(input.env);
|
|
205
|
+
if (session !== null && session.toLowerCase() !== "root") {
|
|
206
|
+
const sessionRow = byName(session);
|
|
207
|
+
if (sessionRow !== null)
|
|
208
|
+
return accountFrom(sessionRow, input.env.HOME);
|
|
209
|
+
const uidRow = byUid(ownUid === 0 ? null : ownUid);
|
|
210
|
+
if (uidRow !== null)
|
|
211
|
+
return accountFrom(uidRow, input.env.HOME);
|
|
212
|
+
return { name: session, uid: ownUid, home: input.env.HOME?.trim() || null, root: false, verified: false };
|
|
213
|
+
}
|
|
214
|
+
const ownRow = ownUid === 0 ? null : byUid(ownUid);
|
|
215
|
+
if (ownRow !== null)
|
|
216
|
+
return accountFrom(ownRow);
|
|
217
|
+
const owner = ownerAccount(input.ownerPaths, passwd);
|
|
218
|
+
if (owner !== null)
|
|
219
|
+
return owner;
|
|
220
|
+
if (ownUid === 0 || session?.toLowerCase() === "root")
|
|
221
|
+
return { name: "root", uid: 0, home: input.env.HOME?.trim() || "/root", root: true, verified: true };
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
function currentUser(ctx) {
|
|
225
|
+
const account = accountOf(ctx);
|
|
226
|
+
if (account !== null && !account.root)
|
|
227
|
+
return account.name;
|
|
228
|
+
const value = sessionName(ctx.env);
|
|
229
|
+
return value !== null && value.toLowerCase() !== "root" ? value : null;
|
|
230
|
+
}
|
|
231
|
+
function bootUserName(ctx) {
|
|
232
|
+
const account = accountOf(ctx);
|
|
233
|
+
if (account === null) {
|
|
234
|
+
warnAccount(ctx, "no account could be resolved for the entry, so a system unit would run the panel as root. Install it from the account that should own the panel, or set User= yourself.");
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
if (account.root) {
|
|
238
|
+
warnAccount(ctx, "the only account available is root; a system unit would run the panel as root. Install it from the account that should own the panel, or set User= yourself.");
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (!isUserName(account.name)) {
|
|
242
|
+
warnAccount(ctx, `the account name ${JSON.stringify(account.name)} is not a usable user name, so no User= line was written`);
|
|
221
243
|
return null;
|
|
222
244
|
}
|
|
245
|
+
if (!account.verified) {
|
|
246
|
+
warnAccount(ctx, `the account ${JSON.stringify(account.name)} could not be found in the user database, so no User= line was written: systemd would refuse to start the unit (status=217/USER). Set User= yourself if that account resolves through LDAP or NIS.`);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return account.name;
|
|
250
|
+
}
|
|
251
|
+
function isUserName(name2) {
|
|
252
|
+
return /^[A-Za-z_][A-Za-z0-9._-]*$/.test(name2);
|
|
223
253
|
}
|
|
224
|
-
function
|
|
225
|
-
const configured = ctx.env.USER?.trim() ?? ctx.env.USERNAME?.trim();
|
|
226
|
-
if (configured !== void 0 && configured.length > 0)
|
|
227
|
-
return configured;
|
|
254
|
+
function warnAccount(ctx, message) {
|
|
228
255
|
try {
|
|
229
|
-
|
|
256
|
+
(ctx.warn ?? ((line) => console.warn(`[dsh-home-hosted] ${line}`)))(message);
|
|
230
257
|
} catch {
|
|
231
|
-
return null;
|
|
232
258
|
}
|
|
233
259
|
}
|
|
234
260
|
function uidOf(ctx) {
|
|
235
|
-
const configured = ctx.env.UID
|
|
236
|
-
if (configured)
|
|
237
|
-
return configured;
|
|
238
|
-
const uid = process.getuid?.();
|
|
239
|
-
return uid ===
|
|
261
|
+
const configured = numberFrom(ctx.env.UID);
|
|
262
|
+
if (configured !== null)
|
|
263
|
+
return String(configured);
|
|
264
|
+
const uid = ctx.uid === void 0 ? process.getuid?.() ?? null : ctx.uid;
|
|
265
|
+
return uid === null ? null : String(uid);
|
|
240
266
|
}
|
|
241
267
|
function inspectOwned(file, marker) {
|
|
242
268
|
if (!fs2.existsSync(file))
|
|
@@ -284,6 +310,77 @@ function isInstalledState(state) {
|
|
|
284
310
|
return state === "installed-disabled" || state === "enabled-running" || state === "enabled-failing";
|
|
285
311
|
}
|
|
286
312
|
|
|
313
|
+
// src/boot/ladder.ts
|
|
314
|
+
import os2 from "node:os";
|
|
315
|
+
import process3 from "node:process";
|
|
316
|
+
|
|
317
|
+
// src/util/exec.ts
|
|
318
|
+
import { spawn } from "node:child_process";
|
|
319
|
+
async function run(command, args = [], options = {}) {
|
|
320
|
+
const maxBytes = options.maxBytes ?? 256 * 1024;
|
|
321
|
+
const timeoutMs = options.timeoutMs ?? 2e4;
|
|
322
|
+
return await new Promise((resolve) => {
|
|
323
|
+
let settled = false;
|
|
324
|
+
let stdout = "";
|
|
325
|
+
let stderr = "";
|
|
326
|
+
let timedOut = false;
|
|
327
|
+
const finish = (result) => {
|
|
328
|
+
if (settled)
|
|
329
|
+
return;
|
|
330
|
+
settled = true;
|
|
331
|
+
resolve({
|
|
332
|
+
command,
|
|
333
|
+
args,
|
|
334
|
+
code: result.code ?? null,
|
|
335
|
+
signal: result.signal ?? null,
|
|
336
|
+
stdout: result.stdout ?? stdout,
|
|
337
|
+
stderr: result.stderr ?? stderr,
|
|
338
|
+
timedOut,
|
|
339
|
+
error: result.error ?? null
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
let child;
|
|
343
|
+
try {
|
|
344
|
+
child = spawn(command, args, {
|
|
345
|
+
cwd: options.cwd,
|
|
346
|
+
env: { ...process.env, ...options.env },
|
|
347
|
+
shell: false,
|
|
348
|
+
windowsHide: true,
|
|
349
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
350
|
+
});
|
|
351
|
+
} catch (error) {
|
|
352
|
+
finish({ error: error instanceof Error ? error.message : String(error) });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const timer = setTimeout(() => {
|
|
356
|
+
timedOut = true;
|
|
357
|
+
child.kill("SIGKILL");
|
|
358
|
+
}, timeoutMs);
|
|
359
|
+
child.stdout?.on("data", (chunk) => {
|
|
360
|
+
if (stdout.length < maxBytes)
|
|
361
|
+
stdout += chunk.toString("utf8").slice(0, maxBytes - stdout.length);
|
|
362
|
+
});
|
|
363
|
+
child.stderr?.on("data", (chunk) => {
|
|
364
|
+
if (stderr.length < maxBytes)
|
|
365
|
+
stderr += chunk.toString("utf8").slice(0, maxBytes - stderr.length);
|
|
366
|
+
});
|
|
367
|
+
child.on("error", (error) => {
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
finish({ error: error.message });
|
|
370
|
+
});
|
|
371
|
+
child.on("close", (code, signal) => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
finish({ code, signal });
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
async function sudoAvailable() {
|
|
378
|
+
if (process.getuid?.() === 0)
|
|
379
|
+
return true;
|
|
380
|
+
const result = await run("sudo", ["-n", "true"], { timeoutMs: 5e3 });
|
|
381
|
+
return result.code === 0;
|
|
382
|
+
}
|
|
383
|
+
|
|
287
384
|
// src/boot/systemd.ts
|
|
288
385
|
import fs3 from "node:fs";
|
|
289
386
|
|
|
@@ -306,6 +403,11 @@ function assertMarker(marker) {
|
|
|
306
403
|
throw new Error("marker must not be empty");
|
|
307
404
|
return assertNoControl(marker, "marker");
|
|
308
405
|
}
|
|
406
|
+
function assertUserName(name2) {
|
|
407
|
+
if (!/^[A-Za-z_][A-Za-z0-9._-]*$/.test(name2))
|
|
408
|
+
throw new Error(`user name ${JSON.stringify(name2)} must match ^[A-Za-z_][A-Za-z0-9._-]*$`);
|
|
409
|
+
return name2;
|
|
410
|
+
}
|
|
309
411
|
function assertEnvKey(key) {
|
|
310
412
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
311
413
|
throw new Error(`environment key ${JSON.stringify(key)} is not a valid name`);
|
|
@@ -462,6 +564,15 @@ function envLines(spec) {
|
|
|
462
564
|
return Object.entries(spec.env).map(([key, value]) => `Environment=${systemdEnvLine(key, value)}`);
|
|
463
565
|
}
|
|
464
566
|
var MANAGED = (spec) => `# Managed by ${spec.marker}`;
|
|
567
|
+
function codeOf(result) {
|
|
568
|
+
return result.error ? null : result.code;
|
|
569
|
+
}
|
|
570
|
+
function accountNote(own, ctx) {
|
|
571
|
+
if (!own.owned || own.text === null)
|
|
572
|
+
return "";
|
|
573
|
+
const declared = /^User=(.*)$/m.exec(own.text)?.[1]?.trim() ?? "";
|
|
574
|
+
return declared === "" || declared === "root" ? "; the unit names no other user, so the panel runs as root \u2014 install again from the account that should own it" : "";
|
|
575
|
+
}
|
|
465
576
|
function systemdUserUnit(spec) {
|
|
466
577
|
validate(spec);
|
|
467
578
|
return `${[
|
|
@@ -474,6 +585,14 @@ function systemdUserUnit(spec) {
|
|
|
474
585
|
"Type=exec",
|
|
475
586
|
`ExecStart=${[spec.command, ...spec.args].map(systemdExecWord).join(" ")}`,
|
|
476
587
|
`WorkingDirectory=${systemdPath(spec.cwd, "spec.cwd")}`,
|
|
588
|
+
// A persistent entry survives a panel restart only because its nanny is not
|
|
589
|
+
// the panel. The nanny is spawned `detached`, which gives it its own session —
|
|
590
|
+
// but *not* its own cgroup, so the default `KillMode=control-group` kills it
|
|
591
|
+
// with the panel and takes the entry down too, quietly falsifying what
|
|
592
|
+
// `persistent: true` promises. `process` signals the panel alone. (An ordinary
|
|
593
|
+
// entry is spawned detached as well, so this keeps every supervised server, not
|
|
594
|
+
// only the nannies — the panel re-adopts such a survivor by its port.)
|
|
595
|
+
"KillMode=process",
|
|
477
596
|
"Restart=always",
|
|
478
597
|
"RestartSec=5",
|
|
479
598
|
...envLines(spec),
|
|
@@ -494,10 +613,12 @@ function systemdSystemUnit(spec, user = null) {
|
|
|
494
613
|
"StartLimitBurst=5",
|
|
495
614
|
"",
|
|
496
615
|
"[Service]",
|
|
497
|
-
...user ? [`User=${user}`]
|
|
616
|
+
...user === null || user === "" || user === "root" ? [] : [`User=${assertUserName(user)}`],
|
|
498
617
|
"Type=exec",
|
|
499
618
|
`ExecStart=${[spec.command, ...spec.args].map(systemdExecWord).join(" ")}`,
|
|
500
619
|
`WorkingDirectory=${systemdPath(spec.cwd, "spec.cwd")}`,
|
|
620
|
+
// See the user unit: `control-group` would kill the nannies too.
|
|
621
|
+
"KillMode=process",
|
|
501
622
|
"Restart=always",
|
|
502
623
|
"RestartSec=5",
|
|
503
624
|
...envLines(spec),
|
|
@@ -507,9 +628,6 @@ function systemdSystemUnit(spec, user = null) {
|
|
|
507
628
|
""
|
|
508
629
|
].join("\n")}`;
|
|
509
630
|
}
|
|
510
|
-
function codeOf(result) {
|
|
511
|
-
return result.error ? null : result.code;
|
|
512
|
-
}
|
|
513
631
|
function parseKeyValues(stdout) {
|
|
514
632
|
const out = {};
|
|
515
633
|
for (const line of stdout.split("\n")) {
|
|
@@ -734,10 +852,6 @@ function createSystemdUserProvider(ctx) {
|
|
|
734
852
|
}
|
|
735
853
|
};
|
|
736
854
|
}
|
|
737
|
-
function systemUserName(ctx) {
|
|
738
|
-
const user = currentUser(ctx);
|
|
739
|
-
return user && /^[A-Za-z_][A-Za-z0-9._-]*$/.test(user) ? user : null;
|
|
740
|
-
}
|
|
741
855
|
function stageUnit(ctx, unit, content) {
|
|
742
856
|
const staged = posixJoin(tempDir(ctx), `home-hosted-${unit}`);
|
|
743
857
|
fs3.writeFileSync(staged, content, { mode: 420 });
|
|
@@ -747,7 +861,7 @@ function createSystemdSystemProvider(ctx) {
|
|
|
747
861
|
const mechanism = "systemd-system";
|
|
748
862
|
const unitOf = (spec) => `${assertUnitName(spec.unitName)}.service`;
|
|
749
863
|
const pathOf = (spec) => posixJoin("/etc", "systemd", "system", unitOf(spec));
|
|
750
|
-
const contentOf = (spec) => systemdSystemUnit(spec,
|
|
864
|
+
const contentOf = (spec) => systemdSystemUnit(spec, bootUserName(ctx));
|
|
751
865
|
const installCommands = (staged, file, unit) => [
|
|
752
866
|
shellCommand("sudo", ["install", "-m", "0644", staged, file]),
|
|
753
867
|
shellCommand("sudo", ["systemctl", "daemon-reload"]),
|
|
@@ -761,12 +875,14 @@ function createSystemdSystemProvider(ctx) {
|
|
|
761
875
|
if (!existsOf(ctx, "/run/systemd/system"))
|
|
762
876
|
return { mechanism, available: false, bootCapable: false, privileged: false, reason: "systemd is not the init system here (no /run/systemd/system)" };
|
|
763
877
|
const privileged = ctx.isRoot || await ctx.sudo();
|
|
878
|
+
const who = accountOf(ctx);
|
|
879
|
+
const scope = who === null || who.root ? "no account was resolved for the entry, so it would run the panel as root" : `the entry runs the panel as ${who.name}`;
|
|
764
880
|
return {
|
|
765
881
|
mechanism,
|
|
766
882
|
available: privileged,
|
|
767
883
|
bootCapable: privileged,
|
|
768
884
|
privileged,
|
|
769
|
-
reason: privileged ?
|
|
885
|
+
reason: privileged ? `systemd is PID 1 and this process can use root, so a system unit starts at boot; ${scope}` : "systemd is PID 1, but installing a system unit needs root or passwordless sudo"
|
|
770
886
|
};
|
|
771
887
|
},
|
|
772
888
|
async status(spec) {
|
|
@@ -786,7 +902,7 @@ function createSystemdSystemProvider(ctx) {
|
|
|
786
902
|
return {
|
|
787
903
|
state,
|
|
788
904
|
unitPath: file,
|
|
789
|
-
detail: `${probe.detail}${privileged ? "" : "; needs root to change"}`,
|
|
905
|
+
detail: `${probe.detail}${privileged ? "" : "; needs root to change"}${accountNote(own, ctx)}`,
|
|
790
906
|
commands
|
|
791
907
|
};
|
|
792
908
|
} catch (error) {
|
|
@@ -983,7 +1099,8 @@ function launchdLabel(spec) {
|
|
|
983
1099
|
function launchdPlist(spec, options = {}) {
|
|
984
1100
|
validate2(spec);
|
|
985
1101
|
const label = launchdLabel(spec);
|
|
986
|
-
const
|
|
1102
|
+
const named = options.userName?.trim();
|
|
1103
|
+
const userName = named === void 0 || named.length === 0 ? void 0 : assertUserName(named);
|
|
987
1104
|
const out = path3.posix.join(spec.logDir, `${label}.out.log`);
|
|
988
1105
|
const err = path3.posix.join(spec.logDir, `${label}.err.log`);
|
|
989
1106
|
const envKeys = Object.keys(spec.env);
|
|
@@ -1127,7 +1244,7 @@ function createLaunchdProvider(ctx, mode) {
|
|
|
1127
1244
|
validate2(spec);
|
|
1128
1245
|
const file = pathOf(spec);
|
|
1129
1246
|
const label = launchdLabel(spec);
|
|
1130
|
-
const content = launchdPlist(spec, mode === "daemon" ? { userName:
|
|
1247
|
+
const content = launchdPlist(spec, mode === "daemon" ? { userName: bootUserName(ctx) } : {});
|
|
1131
1248
|
const own = inspectOwned(file, spec.marker);
|
|
1132
1249
|
if (own.exists && !own.owned)
|
|
1133
1250
|
return failed(own.reason ?? "foreign file");
|
|
@@ -2025,7 +2142,8 @@ function bootProviders(ctx, platform = ctx.platform) {
|
|
|
2025
2142
|
}
|
|
2026
2143
|
function createBootLadder(options = {}) {
|
|
2027
2144
|
const platform = options.platform ?? process3.platform;
|
|
2028
|
-
const
|
|
2145
|
+
const uid = options.uid === void 0 ? process3.getuid?.() ?? null : options.uid;
|
|
2146
|
+
const isRoot = uid === 0;
|
|
2029
2147
|
const ctx = {
|
|
2030
2148
|
platform,
|
|
2031
2149
|
home: options.home ?? os2.homedir(),
|
|
@@ -2033,6 +2151,10 @@ function createBootLadder(options = {}) {
|
|
|
2033
2151
|
run: options.run ?? run,
|
|
2034
2152
|
sudo: options.sudo ?? (async () => isRoot ? true : await sudoAvailable()),
|
|
2035
2153
|
isRoot,
|
|
2154
|
+
uid: options.uid,
|
|
2155
|
+
ownerPaths: options.ownerPaths,
|
|
2156
|
+
passwd: options.passwd,
|
|
2157
|
+
warn: options.warn,
|
|
2036
2158
|
exists: options.exists
|
|
2037
2159
|
};
|
|
2038
2160
|
const providers = bootProviders(ctx, platform);
|
|
@@ -2525,6 +2647,13 @@ function homeHostedEnv(facts, launch, extra = {}) {
|
|
|
2525
2647
|
HHOSTED_HOME: facts.home,
|
|
2526
2648
|
...extra
|
|
2527
2649
|
};
|
|
2650
|
+
const accountHome = facts.accountHome?.trim();
|
|
2651
|
+
if (accountHome !== void 0 && accountHome.length > 0) {
|
|
2652
|
+
if (accountHome !== env.HOME)
|
|
2653
|
+
env.HOME = accountHome;
|
|
2654
|
+
} else if (facts.accountHome === null) {
|
|
2655
|
+
delete env.HOME;
|
|
2656
|
+
}
|
|
2528
2657
|
if (facts.projectDir)
|
|
2529
2658
|
env.HHOSTED_PROJECT = facts.projectDir;
|
|
2530
2659
|
return env;
|
|
@@ -2539,7 +2668,16 @@ function buildHomeHostedBootSpec(facts, launch, options) {
|
|
|
2539
2668
|
command: launch.program,
|
|
2540
2669
|
args,
|
|
2541
2670
|
cwd: facts.projectDir ?? facts.home,
|
|
2542
|
-
env: homeHostedEnv(
|
|
2671
|
+
env: homeHostedEnv(
|
|
2672
|
+
{
|
|
2673
|
+
...facts,
|
|
2674
|
+
// `undefined` means the option was not given; `null` is a decision (an
|
|
2675
|
+
// account whose home is unknowable), and `??` would erase it.
|
|
2676
|
+
accountHome: options.accountHome === void 0 ? facts.accountHome : options.accountHome,
|
|
2677
|
+
env: options.env ?? facts.env
|
|
2678
|
+
},
|
|
2679
|
+
launch
|
|
2680
|
+
),
|
|
2543
2681
|
marker: options.marker ?? "managed by dsh-home-hosted",
|
|
2544
2682
|
// One artifact per state root, so two plugin instances cannot overwrite or
|
|
2545
2683
|
// delete each other's entry. The machine-default install keeps the old name.
|
|
@@ -5006,10 +5144,34 @@ var HomeHostedService = class extends Service {
|
|
|
5006
5144
|
// -------------------------------------------------------------------------
|
|
5007
5145
|
// Boot autostart
|
|
5008
5146
|
// -------------------------------------------------------------------------
|
|
5147
|
+
/**
|
|
5148
|
+
* Everything account resolution reads.
|
|
5149
|
+
*
|
|
5150
|
+
* The state paths matter: they name the person the panel belongs to, so a root
|
|
5151
|
+
* process launched at boot (a system unit that names no `User=`) can still be
|
|
5152
|
+
* told whose panel it is running.
|
|
5153
|
+
*/
|
|
5154
|
+
/** This process's environment, from the test seam when one is supplied. */
|
|
5155
|
+
processEnv() {
|
|
5156
|
+
return this.options.env ?? process10.env;
|
|
5157
|
+
}
|
|
5158
|
+
accountFacts() {
|
|
5159
|
+
return {
|
|
5160
|
+
env: this.processEnv(),
|
|
5161
|
+
uid: this.options.uid,
|
|
5162
|
+
ownerPaths: [this.options.home, this.options.stateDir],
|
|
5163
|
+
passwd: this.options.passwd
|
|
5164
|
+
};
|
|
5165
|
+
}
|
|
5166
|
+
/**
|
|
5167
|
+
* The ladder resolves the account per provider, and the spec below resolves it
|
|
5168
|
+
* for the entry's environment. Both are handed the *same* facts, so the `User=`
|
|
5169
|
+
* line and the `HOME=` line can never describe two different people.
|
|
5170
|
+
*/
|
|
5009
5171
|
ladder() {
|
|
5010
5172
|
if (this.options.createLadder !== void 0)
|
|
5011
5173
|
return this.options.createLadder();
|
|
5012
|
-
return createBootLadder(
|
|
5174
|
+
return createBootLadder(this.accountFacts());
|
|
5013
5175
|
}
|
|
5014
5176
|
async bootSpec() {
|
|
5015
5177
|
const { resolution, launcher } = await this.cli();
|
|
@@ -5017,6 +5179,7 @@ var HomeHostedService = class extends Service {
|
|
|
5017
5179
|
if (launch === null)
|
|
5018
5180
|
return null;
|
|
5019
5181
|
const runtime = this.runtime();
|
|
5182
|
+
const account = accountOf(this.accountFacts());
|
|
5020
5183
|
const spec = buildHomeHostedBootSpec(
|
|
5021
5184
|
{
|
|
5022
5185
|
home: this.options.home,
|
|
@@ -5024,7 +5187,16 @@ var HomeHostedService = class extends Service {
|
|
|
5024
5187
|
version: runtime?.version ?? null
|
|
5025
5188
|
},
|
|
5026
5189
|
launch,
|
|
5027
|
-
{
|
|
5190
|
+
{
|
|
5191
|
+
stateDir: this.options.stateDir,
|
|
5192
|
+
unitName: bootUnitName(this.options.stateDir),
|
|
5193
|
+
// `undefined` when no account was resolved: the entry then runs as this
|
|
5194
|
+
// process (or as root), and this process's own HOME is the right one. A
|
|
5195
|
+
// spec that merely repeats this environment stays byte-identical, so
|
|
5196
|
+
// reconcile does not rewrite the entry on every start.
|
|
5197
|
+
accountHome: account === null ? void 0 : account.home,
|
|
5198
|
+
env: this.processEnv()
|
|
5199
|
+
}
|
|
5028
5200
|
);
|
|
5029
5201
|
if (launcher !== null) {
|
|
5030
5202
|
const cliArgs2 = spec.args.slice(launch.args.length);
|