@edgehero/pi-dispatch 1.10.3 → 2.1.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/.env.example +303 -150
- package/README.md +52 -0
- package/deploy/com.pi-dispatch.worker.plist +10 -4
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +12 -1
- package/deploy/worker-env-wrapper.sh +63 -37
- package/deploy/worker.service +18 -8
- package/package.json +15 -5
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4756 -394
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +456 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +245 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-pi.mjs +19 -3
- package/src/host-registry.mjs +29 -2
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +363 -13
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/packages.mjs +2 -2
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/pricing.mjs +9 -5
- package/src/processor.mjs +506 -26
- package/src/provider-key.mjs +66 -0
- package/src/provider-steering.mjs +185 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +24 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1348 -326
- package/src/subscriptions.mjs +7 -3
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +179 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- package/src/watch-closer.mjs +158 -0
package/src/schedules.mjs
CHANGED
|
@@ -80,6 +80,18 @@ export function servedSchedules(schedules) {
|
|
|
80
80
|
return { served, unserved };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* WHERE a cron trigger runs, from this host's point of view (issue #57): `"here"` when its folder exists on this
|
|
85
|
+
* machine, `"elsewhere"` when it does not and this host is on a fleet (another machine's folder, not scheduled here),
|
|
86
|
+
* and `"refused"` when it does not and this host is alone (a typo the worker refuses to boot on). The rule and its
|
|
87
|
+
* reasons are `normalizeCronSchedule`'s, below, which is its first caller. Exported so `pi-dispatch doctor` asks the
|
|
88
|
+
* same question the same way (issue #433) rather than keeping a copy that could drift.
|
|
89
|
+
*/
|
|
90
|
+
export function cronPlacement(run, { existsSync = fsExistsSync, fleet = false } = {}) {
|
|
91
|
+
if (existsSync(run.folder)) return "here";
|
|
92
|
+
return fleet ? "elsewhere" : "refused";
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
|
|
84
96
|
// The pure validator already guaranteed a non-empty, `:`-free, charset-valid, unique id and a
|
|
85
97
|
// well-formed pattern; folder existence is the one fs-dependent check it deferred to here.
|
|
@@ -97,8 +109,9 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
|
|
|
97
109
|
// -- so a single-host deployment with one typo'd folder would reap containers, prune history and delete
|
|
98
110
|
// sandboxes on every restart before refusing. Declaring a name is the operator saying "this is a
|
|
99
111
|
// fleet", it is known before anything runs, and it keeps a single-host deployment byte-identical.
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
const placement = cronPlacement(run, { existsSync, fleet });
|
|
113
|
+
if (placement !== "here") {
|
|
114
|
+
if (placement === "refused") {
|
|
102
115
|
throw configError(`cron trigger "${on.id}": run.folder does not exist: ${run.folder} (${path})`);
|
|
103
116
|
}
|
|
104
117
|
// Not mine. Its skillsDir is not my business either: `isAbsolute` is OS-dependent, and judging
|
|
@@ -137,7 +150,7 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
|
|
|
137
150
|
// key. A command trigger carries no flow/task at all (the validator enforces the XOR), so those two
|
|
138
151
|
// keys hold undefined here and drop at JSON serialization -- the command schedule's data is exactly
|
|
139
152
|
// kind/folder/command plus the shared fields.
|
|
140
|
-
const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
|
|
153
|
+
const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
|
|
141
154
|
// Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
|
|
142
155
|
// scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
|
|
143
156
|
const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
|
package/src/secret-profiles.mjs
CHANGED
|
@@ -82,7 +82,8 @@ function isAbsolutePath(path) {
|
|
|
82
82
|
* NEITHER SOURCE WINS, and that is a deliberate third answer. `runtime-settings.mjs` documents the
|
|
83
83
|
* overlay's precedence as `overlay > env`, so quietly inverting it for this one key would leave two rules
|
|
84
84
|
* in the codebase disagreeing about what an overlay is. But honouring it would let a settings file -- which
|
|
85
|
-
*
|
|
85
|
+
* PI_SETTINGS_FILE can point anywhere, and which defaulted into the OS temp directory until issue #290 --
|
|
86
|
+
* redirect a profile the operator wrote in `.env`. So a collision is
|
|
86
87
|
* refused instead, per delivery and naming only the profile name. This project already refuses ambiguity
|
|
87
88
|
* rather than resolving it: `PI_EGRESS` refuses any value but 0 or 1 because "a typo must never leave you
|
|
88
89
|
* believing you have a policy you do not", and two declarations of one profile is exactly that.
|
package/src/secrets.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { realpathSync, statSync } from "node:fs";
|
|
28
|
-
import {
|
|
28
|
+
import { providerKeyCandidates } from "./env-allowlist.mjs";
|
|
29
29
|
import { EXIT_POLICY } from "./exit-code.mjs";
|
|
30
30
|
import { mergeSecretProfiles, withinRoots } from "./secret-profiles.mjs";
|
|
31
31
|
|
|
@@ -261,8 +261,8 @@ export function makeSecretsResolver({
|
|
|
261
261
|
}
|
|
262
262
|
if (!path) return { profileUnknown: wanted };
|
|
263
263
|
// The bound is checked on the REALPATH, and in the WORKER rather than only where a profile is
|
|
264
|
-
// written. A panel-side check alone would be cosmetic:
|
|
265
|
-
//
|
|
264
|
+
// written. A panel-side check alone would be cosmetic: PI_SETTINGS_FILE can point the overlay
|
|
265
|
+
// anywhere, and its default was a world-writable OS temp path until issue #290. Checking here
|
|
266
266
|
// caps a tampered overlay at "choose among scripts the operator allowlisted" instead of "name any
|
|
267
267
|
// executable on the host". An env-declared profile is exempt: PI_SECRET_PROFILES already lives
|
|
268
268
|
// beside the forge tokens and the App key, so requiring roots for it would bound nothing and would
|
|
@@ -276,8 +276,7 @@ export function makeSecretsResolver({
|
|
|
276
276
|
// STATICALLY knowable name (the mint's, each forge's host var, the worker-only secrets, the egress
|
|
277
277
|
// policy's, and the closed map's own PI_*/PLAYWRIGHT_*), but two more sets are DEPLOYMENT STATE:
|
|
278
278
|
//
|
|
279
|
-
// - the provider credential's variable names, which
|
|
280
|
-
// depend on the job's resolved provider AND on what this host has set;
|
|
279
|
+
// - the provider credential's variable names, which depend on the job's resolved provider;
|
|
281
280
|
// - PI_FORWARD_ENV, which is an operator env list.
|
|
282
281
|
//
|
|
283
282
|
// Both are load-bearing rather than tidy. buildContainerEnv writes the provider credential BEFORE this
|
|
@@ -288,7 +287,26 @@ export function makeSecretsResolver({
|
|
|
288
287
|
//
|
|
289
288
|
// Refused rather than resolved by ordering, which is the division of labour the mint already keeps:
|
|
290
289
|
// ordering is the backstop, the refusal is the gate.
|
|
291
|
-
|
|
290
|
+
//
|
|
291
|
+
// **`providerKeyCandidates`, not `providerKeyVars`** (issue #309). The question a reserved-name set
|
|
292
|
+
// asks is what a trigger may NOT NAME, which is a property of the provider. `providerKeyVars` answers
|
|
293
|
+
// a different one, what this host HOLDS, because it is presence-filtered -- and it returns `undefined`
|
|
294
|
+
// when the answer is none, which `?? []` made silent. Under PI_AUTH_FROM_PI, ON BY DEFAULT, the
|
|
295
|
+
// credential comes from pi's auth.json rather than the environment, so this set was EMPTY and the gate
|
|
296
|
+
// reserved nothing at all: a trigger could bind the provider's own credential variable and spend the
|
|
297
|
+
// trigger author's key, every job, which is the exact failure the paragraph above says this exists to
|
|
298
|
+
// prevent. Same conflated `undefined` as issue #286, one module over.
|
|
299
|
+
//
|
|
300
|
+
// It also closes a second hole that never needed auth.json. The worker never WRITES the OAuth variable
|
|
301
|
+
// or (from the 0.99.1 pin, issue #509) the bearer ANTHROPIC_AUTH_TOKEN (`apiKeyVariable` skips both
|
|
302
|
+
// deliberately) and pi reads both BEFORE `ANTHROPIC_API_KEY`, so a presence
|
|
303
|
+
// filter held it only on hosts that happened to export it, and a trigger binding it outranked the
|
|
304
|
+
// operator's own key on the pure-env path too.
|
|
305
|
+
//
|
|
306
|
+
// The widening is bounded: it reserves the variables of THIS JOB'S provider and nothing else, so an
|
|
307
|
+
// anthropic job binding OPENAI_API_KEY for a flow that talks to OpenAI itself is still allowed. That
|
|
308
|
+
// is deliberate, and tested; refusing it would be this project inventing a namespace it does not own.
|
|
309
|
+
const reserved = new Set([...providerKeyCandidates(job?.provider), ...forwardEnv]);
|
|
292
310
|
for (const name of Object.keys(references)) {
|
|
293
311
|
if (reserved.has(name)) return { reserved: name };
|
|
294
312
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE resolver for the keys a pi-dispatch service reads from its deployment's `.env` (issue #471), shared by `doctor`
|
|
3
|
+
* and the admin panel so the two cannot answer "what does the service run with" two ways.
|
|
4
|
+
*
|
|
5
|
+
* The service never sees the environment of the shell an operator runs a command in: under systemd its environment is
|
|
6
|
+
* the unit's plus `EnvironmentFile=`, and the launchd and Windows wrappers source the file inside the child. A worker
|
|
7
|
+
* started by hand (`pi-dispatch worker`) sees that shell and no file at all. A command asked about the deployment
|
|
8
|
+
* cannot tell which of the two runs it, and does not guess. Its rule, the one `up` and doctor already apply to the
|
|
9
|
+
* venue keys (`deploymentVenueEnv`, issue #453) and doctor to `PI_WORKER_NAME` (#464), now for every key:
|
|
10
|
+
*
|
|
11
|
+
* - this shell's value where it sets the key (an operator who starts the worker by hand from this shell runs it);
|
|
12
|
+
* - else the `.env` value, read with the service's own loader, from a line that loader reads exactly as written;
|
|
13
|
+
* - both set and different is a DISAGREEMENT, returned for the caller to report: it is never resolved in silence;
|
|
14
|
+
* - a line naming the key that the loader reads differently from this reader (a quote, a `$`, a space) is UNREAD:
|
|
15
|
+
* its value is never used, and the line is named;
|
|
16
|
+
* - a file the service's loader reads differently somewhere, or refuses to load (`hazard`), gives no value for any
|
|
17
|
+
* key the file spells, and each such key is named.
|
|
18
|
+
*
|
|
19
|
+
* A key only this shell sets is taken as the shell's and not reported: the service may be given it by its
|
|
20
|
+
* `--env-setup` script or its unit, neither of which a command can read in general.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here loads a `.env` into a process (`docs/secrets.md`): the result is an object the caller judges from.
|
|
23
|
+
*/
|
|
24
|
+
import { constants as fsConstants } from "node:fs";
|
|
25
|
+
import { userInfo } from "node:os";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { SYSTEMD_HAZARD_SHAPES, decodeEnvFile, envFileHazard, readEnvAssignments } from "./env-file.mjs";
|
|
28
|
+
|
|
29
|
+
/** The loader of the deployment's `.env` on this platform, as `service.mjs` renders it: systemd, the cmd wrapper, or a
|
|
30
|
+
* sourcing shell everywhere else. ONE mapping for every `.env` read. */
|
|
31
|
+
export function serviceEnvLoader(platform) {
|
|
32
|
+
return platform === "linux" ? "systemd" : platform === "win32" ? "cmd" : "shell";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The deployment `.env` as the service is judged by (issue #464, gate round 3): decoded from its BYTES with the
|
|
37
|
+
* hardened reader, and `hazard` set to the first thing the service's loader reads differently from this reader, or
|
|
38
|
+
* refuses to load (a NUL, invalid UTF-8, an environment too big to exec, a lone CR), as `{ line, what, fix }`, or null.
|
|
39
|
+
*/
|
|
40
|
+
export function serviceEnvFileOf(raw, path, loader) {
|
|
41
|
+
const { text, loadHazard } = decodeEnvFile(raw, { loader });
|
|
42
|
+
const h = loadHazard ?? envFileHazard(text, { loader });
|
|
43
|
+
let hazard = null;
|
|
44
|
+
if (h !== null) {
|
|
45
|
+
const shape = h.shape !== undefined ? SYSTEMD_HAZARD_SHAPES[h.shape] : null;
|
|
46
|
+
hazard = shape ? { line: h.line, what: `has ${shape.what}${h.detail ? ` (${h.detail})` : ""}`, fix: shape.fix } : { line: h.line, what: "is one this command cannot read the way the service's loader will (an open quote, a continuation, or a line that runs)", fix: "fix that line, then re-run doctor" };
|
|
47
|
+
}
|
|
48
|
+
return { text, path, loader, hazard };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve `keys` by the rule in the header. `env` is this shell's environment, `file` a `serviceEnvFileOf` result or
|
|
53
|
+
* null (no file, or none this command could read). Returns
|
|
54
|
+
* `{ env, fromFile, disagreements, unread, hazardSkipped, shellOnly }`: `env` a copy of this shell's with the file's
|
|
55
|
+
* values filled in, `fromFile` only what the file supplied, `disagreements` `[{ key, shell, file }]`, `unread` `[{ key,
|
|
56
|
+
* line, shellSet }]`, `hazardSkipped` the keys a hazard kept this from reading while this shell sets none of them, and
|
|
57
|
+
* `shellOnly` the keys this shell sets and the file does not.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveServiceEnv({ env, file, keys }) {
|
|
60
|
+
const resolved = { ...env };
|
|
61
|
+
const out = { env: resolved, fromFile: {}, disagreements: [], unread: [], hazardSkipped: [], shellOnly: [] };
|
|
62
|
+
const wanted = [...new Set(keys)];
|
|
63
|
+
if (!file || wanted.length === 0) return out;
|
|
64
|
+
const reads = readEnvAssignments(file.text, wanted, { loader: file.loader });
|
|
65
|
+
for (const key of wanted) {
|
|
66
|
+
const shell = typeof env[key] === "string" ? env[key] : undefined;
|
|
67
|
+
// The hardened reader's verdict on the whole file: where it has a line the service's loader reads differently, or
|
|
68
|
+
// refuses to load, and spells this key, no value is taken from it, and that is said, never read as "unset".
|
|
69
|
+
if (file.hazard && file.text.includes(key)) {
|
|
70
|
+
if (shell === undefined) out.hazardSkipped.push(key);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// An empty value UNSETS under the cmd wrapper (`set "K="`), so there the service runs without the key.
|
|
74
|
+
const own = file.loader === "cmd" && reads[key]?.plain && reads[key].value === "" ? undefined : reads[key];
|
|
75
|
+
if (own === undefined) {
|
|
76
|
+
// Set only in this shell (issue #471, gate round 1): a worker started from this shell runs with it, a service
|
|
77
|
+
// reading this file does not, so the caller can name it where it knows a service is installed.
|
|
78
|
+
if (shell !== undefined) out.shellOnly.push(key);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!own.plain || typeof own.value !== "string") {
|
|
82
|
+
out.unread.push({ key, line: own.line, shellSet: shell !== undefined });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (shell !== undefined) {
|
|
86
|
+
if (shell !== own.value) out.disagreements.push({ key, shell, file: own.value });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
resolved[key] = own.value;
|
|
90
|
+
out.fromFile[key] = own.value;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Account and group names from `/etc/passwd` and `/etc/group`, for a message that names an owner (gate round 3: the
|
|
97
|
+
* panel printed a bare uid). A name that cannot be read is the number, said as such.
|
|
98
|
+
*/
|
|
99
|
+
export function accountNames(fs) {
|
|
100
|
+
const table = (path) => {
|
|
101
|
+
try {
|
|
102
|
+
return String(fs.readFileSync(path, "utf8")).split("\n").map((l) => l.split(":")).filter((f) => f.length >= 3 && f[0] !== "");
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
let users = null;
|
|
108
|
+
let groups = null;
|
|
109
|
+
return {
|
|
110
|
+
// This account's own name from the OS where /etc/passwd does not list it (macOS keeps accounts in a directory
|
|
111
|
+
// service), so the panel's line names the owner rather than a number.
|
|
112
|
+
ownerName: (id) => ((users ??= table("/etc/passwd")).find((f) => f[2] === String(id))?.[0] ?? (id === process.geteuid?.() ? safeUserName() : null) ?? `uid ${id}`),
|
|
113
|
+
groupName: (id) => ((groups ??= table("/etc/group")).find((f) => f[2] === String(id))?.[0] ?? `gid ${id}`),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function safeUserName() {
|
|
118
|
+
try {
|
|
119
|
+
return userInfo().username || null;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Why `stat` fails the trust rule, in words that are true of it, or null (gate round 3 fixed the wording): owned by `uid`
|
|
127
|
+
* or root and writable by neither its group nor every account. A group-writable file is named with its group, and when
|
|
128
|
+
* that group has the owner's own name (a user-private group, what a umask 002 login makes) the line says so: its
|
|
129
|
+
* members may be the owner alone, which doctor cannot see, and it trusts only a file no group can write.
|
|
130
|
+
*/
|
|
131
|
+
function trustReason(stat, uid, { ownerName = (id) => `uid ${id}`, groupName = (id) => `gid ${id}` } = {}, path = "") {
|
|
132
|
+
const owned = stat.uid === uid || stat.uid === 0;
|
|
133
|
+
const worldWritable = (stat.mode & 0o002) !== 0;
|
|
134
|
+
const groupWritable = (stat.mode & 0o020) !== 0;
|
|
135
|
+
const stickyRoot = stat.uid === 0 && (stat.mode & 0o1000) !== 0 && stat.isDirectory?.();
|
|
136
|
+
if (owned && ((!worldWritable && !groupWritable) || stickyRoot)) return null;
|
|
137
|
+
const mode = (stat.mode & 0o7777).toString(8).padStart(4, "0");
|
|
138
|
+
const owner = ownerName(stat.uid);
|
|
139
|
+
const at = path ? `${path} ` : "";
|
|
140
|
+
if (!owned) return `${at}is owned by ${owner} with mode ${mode}, not by this account or root`;
|
|
141
|
+
if (worldWritable) return `${at}is owned by ${owner} with mode ${mode}, writable by every account`;
|
|
142
|
+
const group = groupName(stat.gid);
|
|
143
|
+
return `${at}is owned by ${owner} with mode ${mode}, writable by the members of its group ${group}${group === owner ? ` (${owner}'s own group, which may have no other member; doctor trusts only a file no group can write)` : ""}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a `.env` may steer anything (issue #471, gate round 1): `null` when it is owned by `uid` (the account asking)
|
|
148
|
+
* or by root and neither its group nor others may write it, else the reason, naming the owner, the mode and, for a
|
|
149
|
+
* group-writable file, the group. A value in a file another account can write is that account's choice: through a
|
|
150
|
+
* CONTAINERS_CONF naming a containers.conf with a `conmon_path`, a `.env` made doctor's own `podman info` run a program
|
|
151
|
+
* (measured on Podman 5.8.1). `uid` undefined (a platform with no uids) trusts the file. `names` is `accountNames`'s.
|
|
152
|
+
*/
|
|
153
|
+
export function envFileTrust(stat, uid, names = {}) {
|
|
154
|
+
if (!Number.isInteger(uid) || !stat) return null;
|
|
155
|
+
return trustReason(stat, uid, typeof names === "function" ? { ownerName: names } : names);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Issue #471 (gate round 2): whether nobody but `uid` and root can change what `realPath` names, as `null` or the reason.
|
|
160
|
+
* Every directory from `/` down to it, and it, must be owned by `uid` or root and writable by neither group nor others;
|
|
161
|
+
* a root-owned directory with the sticky bit (`/tmp`) is the one writable exception, since there only an entry's owner
|
|
162
|
+
* can rename or remove it, and the next component is then judged on its own. Judged with `lstat` on a path `realpath`
|
|
163
|
+
* already resolved, so a component that is a symlink now was swapped in since, and refuses. One trusted-once check of a
|
|
164
|
+
* symlink target was measured racing: another account flipped a DOCKER_CONFIG link between the check and docker's own
|
|
165
|
+
* open, and its plugin ran as the doctor account. A chain nobody else can write cannot be flipped.
|
|
166
|
+
*/
|
|
167
|
+
export function trustedChain(realPath, { fs, uid, names = {} }) {
|
|
168
|
+
if (!Number.isInteger(uid)) return null;
|
|
169
|
+
const parts = realPath.split("/").filter(Boolean);
|
|
170
|
+
for (let n = 0; n <= parts.length; n++) {
|
|
171
|
+
const here = `/${parts.slice(0, n).join("/")}`;
|
|
172
|
+
let st;
|
|
173
|
+
try {
|
|
174
|
+
st = fs.lstatSync(here);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return `${here} could not be read (${err?.code ?? err?.message})`;
|
|
177
|
+
}
|
|
178
|
+
if (st.isSymbolicLink?.()) return `${here} became a symbolic link after it was resolved`;
|
|
179
|
+
const why = trustReason(st, uid, names, here);
|
|
180
|
+
if (why !== null) return why;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The deployment `.env` in `dir`, read ONCE through one descriptor (issue #471, gate round 2), shared by doctor and the
|
|
187
|
+
* panel: `{ path, bytes, absent, unreadable, untrusted }`. Measured before: judged by `stat` and then read by path,
|
|
188
|
+
* another account's folder swapped a symlink between the two and its value was taken (2 of 20000 reads). Now the
|
|
189
|
+
* real path is opened with O_NOFOLLOW and O_NONBLOCK (a FIFO then cannot hang the read), judged by `fstat` of that same
|
|
190
|
+
* descriptor, and read from it; the file itself must be this account's or root's and closed (`envFileTrust`), and the
|
|
191
|
+
* deployment folder and the file's own folder, with every ancestor, must pass `trustedChain`, since an account that can
|
|
192
|
+
* write a folder can replace what is in it. `bytes` comes back even from an untrusted file, for a caller that only
|
|
193
|
+
* decides what to SAY from it; `untrusted` is then the reason, and no value may be taken.
|
|
194
|
+
*/
|
|
195
|
+
export function readDeploymentEnv({ dir, fs, uid = process.geteuid?.(), names = accountNames(fs) }) {
|
|
196
|
+
const path = join(dir, ".env");
|
|
197
|
+
let real;
|
|
198
|
+
try {
|
|
199
|
+
real = fs.realpathSync(path);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err?.code === "ENOENT") return { path, bytes: null, absent: true, unreadable: null, untrusted: null };
|
|
202
|
+
return { path, bytes: null, absent: false, unreadable: err?.code ?? err?.message ?? "error", untrusted: null };
|
|
203
|
+
}
|
|
204
|
+
let fd;
|
|
205
|
+
try {
|
|
206
|
+
fd = fs.openSync(real, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return { path, bytes: null, absent: false, unreadable: err?.code ?? err?.message ?? "error", untrusted: null };
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const st = fs.fstatSync(fd);
|
|
212
|
+
if (!st.isFile()) return { path, bytes: null, absent: false, unreadable: "not a regular file", untrusted: null };
|
|
213
|
+
const bytes = fs.readFileSync(fd);
|
|
214
|
+
let untrusted = envFileTrust(st, uid, names);
|
|
215
|
+
if (untrusted === null && Number.isInteger(uid)) {
|
|
216
|
+
let folder = null;
|
|
217
|
+
try {
|
|
218
|
+
folder = fs.realpathSync(dir);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
untrusted = `${dir} could not be resolved (${err?.code ?? err?.message})`;
|
|
221
|
+
}
|
|
222
|
+
const why = folder === null ? null : (trustedChain(folder, { fs, uid, names }) ?? trustedChain(dirname(real), { fs, uid, names }));
|
|
223
|
+
if (why) untrusted = `is in a folder another account can change: ${why}`;
|
|
224
|
+
}
|
|
225
|
+
return { path, bytes, absent: false, unreadable: null, untrusted };
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return { path, bytes: null, absent: false, unreadable: err?.code ?? err?.message ?? "error", untrusted: null };
|
|
228
|
+
} finally {
|
|
229
|
+
try {
|
|
230
|
+
fs.closeSync(fd);
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The `.env` of the deployment in `dir`, read the way doctor reads it (a regular file only, as bytes, one read) and
|
|
237
|
+
* resolved for `keys` (`resolveServiceEnv`). For a caller that has no file of its own in hand (the admin panel).
|
|
238
|
+
* Returns the resolution plus `path`, `unreadable` (a reason, when something is at the path and could not be read) and
|
|
239
|
+
* `untrusted` (`envFileTrust`'s reason); in either case nothing was taken from it.
|
|
240
|
+
*/
|
|
241
|
+
export function deploymentServiceEnv({ env, dir, keys, platform = process.platform, fs, uid = process.geteuid?.() }) {
|
|
242
|
+
// Issue #471 (gate rounds 1 and 2): one descriptor, and nothing taken from a file another account can change.
|
|
243
|
+
const { path, bytes, unreadable, untrusted } = readDeploymentEnv({ dir, fs, uid });
|
|
244
|
+
const raw = untrusted ? null : bytes;
|
|
245
|
+
const file = raw === null ? null : serviceEnvFileOf(raw, path, serviceEnvLoader(platform));
|
|
246
|
+
return { ...resolveServiceEnv({ env, file, keys }), path, unreadable, untrusted, hazard: file?.hazard ?? null };
|
|
247
|
+
}
|