@homeflare/alchemy 0.6.0 → 0.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/README.md +74 -41
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +91 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +34 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +54 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +22 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +970 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +51 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +16 -12
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +36 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +393 -234
- package/dist/openbao/index.js.map +23 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +107 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +199 -0
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/mesh-node.md +188 -0
- package/docs/peers.md +39 -0
- package/package.json +7 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +196 -0
- package/src/caddy/config-lifecycle.ts +239 -0
- package/src/caddy/config.ts +153 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +179 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +26 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +229 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +66 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +126 -22
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +22 -8
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +126 -0
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +52 -36
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +166 -0
- package/src/openbao/role-rename.test.ts +205 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
package/dist/launchd/index.js
CHANGED
|
@@ -6,7 +6,11 @@ import * as Provider from "alchemy/Provider";
|
|
|
6
6
|
import * as Effect2 from "effect/Effect";
|
|
7
7
|
|
|
8
8
|
// src/launchd/host-effect.ts
|
|
9
|
+
import { AdoptPolicy } from "alchemy/AdoptPolicy";
|
|
10
|
+
import { AlchemyContext } from "alchemy/AlchemyContext";
|
|
11
|
+
import { Stack } from "alchemy/Stack";
|
|
9
12
|
import * as Effect from "effect/Effect";
|
|
13
|
+
import * as Option from "effect/Option";
|
|
10
14
|
var lift = (step) => Effect.tryPromise({
|
|
11
15
|
catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
|
|
12
16
|
try: step
|
|
@@ -17,6 +21,17 @@ var resolvedString = (news, key) => {
|
|
|
17
21
|
const value = news[key];
|
|
18
22
|
return typeof value === "string" ? value : undefined;
|
|
19
23
|
};
|
|
24
|
+
var adoptEnabled = (fqn) => Effect.gen(function* () {
|
|
25
|
+
const stack = yield* Effect.serviceOption(Stack);
|
|
26
|
+
const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
|
|
27
|
+
if (scoped !== undefined)
|
|
28
|
+
return scoped;
|
|
29
|
+
const policy = yield* Effect.serviceOption(AdoptPolicy);
|
|
30
|
+
if (Option.isSome(policy))
|
|
31
|
+
return policy.value;
|
|
32
|
+
const context = yield* Effect.serviceOption(AlchemyContext);
|
|
33
|
+
return Option.isSome(context) ? context.value.adopt : false;
|
|
34
|
+
});
|
|
20
35
|
|
|
21
36
|
// src/launchd/secret-tripwire.ts
|
|
22
37
|
var ALTERNATIVE = "props are stored unencrypted in Alchemy state. Have a secret renderer (e.g. openbao-agent) " + "write it to a root-owned file and declare the file path instead (FOO_FILE=/path, --foo-file /path).";
|
|
@@ -69,9 +84,8 @@ var identityProblems = (name, value) => {
|
|
|
69
84
|
}
|
|
70
85
|
return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value) || /^\d+$/.test(value) ? [] : [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
|
|
71
86
|
};
|
|
72
|
-
var
|
|
87
|
+
var pathProblems = (path) => {
|
|
73
88
|
const found = [];
|
|
74
|
-
const { path } = props;
|
|
75
89
|
if (!path.startsWith("/"))
|
|
76
90
|
found.push("path must be absolute");
|
|
77
91
|
if (path.endsWith("/") || path.split("/").slice(1).some((part) => part === "" || part === "." || part === "..")) {
|
|
@@ -79,6 +93,10 @@ var fileProblems = (props) => {
|
|
|
79
93
|
}
|
|
80
94
|
if (path.includes("\x00"))
|
|
81
95
|
found.push("path contains NUL");
|
|
96
|
+
return found;
|
|
97
|
+
};
|
|
98
|
+
var fileProblems = (props) => {
|
|
99
|
+
const found = pathProblems(props.path);
|
|
82
100
|
const mode = props.mode ?? DEFAULT_MODE;
|
|
83
101
|
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 4095)
|
|
84
102
|
found.push("mode must be 0–0o7777");
|
|
@@ -341,7 +359,7 @@ var diffFile = async (runner, news, output) => {
|
|
|
341
359
|
var reconcileFile = async (runner, props, output) => {
|
|
342
360
|
const want = await desiredFile(runner, props);
|
|
343
361
|
if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
|
|
344
|
-
throw refuse(props.path, `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` + "or through a privileged HostRunner (
|
|
362
|
+
throw refuse(props.path, `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` + "or through a privileged HostRunner (sudoRunner() with this path under a prefix). This " + "provider never calls sudo itself.");
|
|
345
363
|
}
|
|
346
364
|
const stat = await runner.stat(props.path);
|
|
347
365
|
if (stat !== undefined && stat.kind !== "file") {
|
|
@@ -626,7 +644,7 @@ var assertMayWrite = (runner, label, domain) => {
|
|
|
626
644
|
if (canActAsRoot(runner))
|
|
627
645
|
return;
|
|
628
646
|
if (domain.kind === "system") {
|
|
629
|
-
throw refuse2(label, "the system domain needs root. Run the deploy as root, or
|
|
647
|
+
throw refuse2(label, "the system domain needs root. Run the deploy as root, or pass a deliberately privileged " + "HostRunner: sudoRunner() elevates exactly the calls this needs. This provider never " + "calls sudo itself.");
|
|
630
648
|
}
|
|
631
649
|
if (domain.uid !== runner.effectiveUid()) {
|
|
632
650
|
throw refuse2(label, `gui/${String(domain.uid)} belongs to another user; only root may write it. ` + "Deploy as that user, as root, or through a privileged HostRunner.");
|
|
@@ -835,8 +853,8 @@ var parseGroup = (platform, stdout) => {
|
|
|
835
853
|
};
|
|
836
854
|
|
|
837
855
|
// src/launchd/local-runner.ts
|
|
838
|
-
var
|
|
839
|
-
var absent = (cause) =>
|
|
856
|
+
var errnoCode = (cause) => typeof cause === "object" && cause !== null && ("code" in cause) ? String(cause.code) : undefined;
|
|
857
|
+
var absent = (cause) => errnoCode(cause) === "ENOENT" || errnoCode(cause) === "ENOTDIR";
|
|
840
858
|
var run = (argv, timeoutMs) => new Promise((resolve, reject) => {
|
|
841
859
|
const [program, ...args] = argv;
|
|
842
860
|
if (program === undefined) {
|
|
@@ -951,6 +969,320 @@ var localRunner = (options = {}) => {
|
|
|
951
969
|
// src/launchd/providers.ts
|
|
952
970
|
import * as Layer2 from "effect/Layer";
|
|
953
971
|
var launchdProviders = (runner = localRunner()) => Layer2.mergeAll(LaunchdJobProvider(), HostFileProvider()).pipe(Layer2.provide(hostRunnerLayer(runner)));
|
|
972
|
+
// src/launchd/sudo-allowlist.ts
|
|
973
|
+
class SudoRefusedError extends Error {
|
|
974
|
+
constructor(message) {
|
|
975
|
+
super(message);
|
|
976
|
+
this.name = "SudoRefusedError";
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
var SUDO = "/usr/bin/sudo";
|
|
980
|
+
var INSTALL = "/usr/bin/install";
|
|
981
|
+
var RM = "/bin/rm";
|
|
982
|
+
var WRITES = new Set(["bootstrap", "bootout", "kickstart"]);
|
|
983
|
+
var octalMode = (mode) => Number.isSafeInteger(mode) && mode >= 0 && mode <= 4095 ? mode.toString(8).padStart(4, "0") : undefined;
|
|
984
|
+
var checkPrefixes = (prefixes) => {
|
|
985
|
+
if (prefixes.length === 0) {
|
|
986
|
+
throw new Error("sudoRunner: no prefixes. Name the directories root may write, e.g. /Library/LaunchDaemons.");
|
|
987
|
+
}
|
|
988
|
+
for (const prefix of prefixes) {
|
|
989
|
+
const found = prefix === "/" ? ['"/" would allow every path'] : pathProblems(prefix);
|
|
990
|
+
if (found.length > 0) {
|
|
991
|
+
throw new Error(`sudoRunner: prefix ${JSON.stringify(prefix)}: ${found.join("; ")}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return [...prefixes];
|
|
995
|
+
};
|
|
996
|
+
var prefixOf = (path, prefixes) => pathProblems(path).length > 0 ? undefined : prefixes.find((prefix) => path.startsWith(`${prefix}/`));
|
|
997
|
+
var labelOk = (label) => LABEL.test(label) && !RESERVED_LABEL_PREFIXES.some((prefix) => label.startsWith(prefix));
|
|
998
|
+
var systemLabel = (target) => {
|
|
999
|
+
const label = target?.startsWith("system/") === true ? target.slice("system/".length) : undefined;
|
|
1000
|
+
return label !== undefined && labelOk(label) ? label : undefined;
|
|
1001
|
+
};
|
|
1002
|
+
var daemonPlist = (label, prefixes) => {
|
|
1003
|
+
const path = plistPathFor(label, { kind: "system" }, undefined);
|
|
1004
|
+
return prefixOf(path, prefixes) === undefined ? undefined : path;
|
|
1005
|
+
};
|
|
1006
|
+
var NO_DAEMON_PREFIX = "needs /Library/LaunchDaemons among the prefixes, where the job’s plist is written and removed";
|
|
1007
|
+
var launchctlProblem = (args, prefixes) => {
|
|
1008
|
+
const [sub, ...rest] = args;
|
|
1009
|
+
if (sub === "bootstrap") {
|
|
1010
|
+
const given = rest[1] ?? "";
|
|
1011
|
+
const name = given.slice(given.lastIndexOf("/") + 1);
|
|
1012
|
+
const label = name.endsWith(".plist") ? name.slice(0, -".plist".length) : "";
|
|
1013
|
+
const shaped = rest.length === 2 && rest[0] === "system" && labelOk(label);
|
|
1014
|
+
if (!shaped)
|
|
1015
|
+
return "bootstrap takes exactly `system /Library/LaunchDaemons/<label>.plist`";
|
|
1016
|
+
const plist = daemonPlist(label, prefixes);
|
|
1017
|
+
if (plist === undefined)
|
|
1018
|
+
return `bootstrap ${NO_DAEMON_PREFIX}`;
|
|
1019
|
+
return given === plist ? undefined : `bootstrap loads only ${plist}, never another path`;
|
|
1020
|
+
}
|
|
1021
|
+
if (sub === "bootout") {
|
|
1022
|
+
const label = rest.length === 1 ? systemLabel(rest[0]) : undefined;
|
|
1023
|
+
if (label === undefined)
|
|
1024
|
+
return "bootout takes exactly `system/<label>`, never a bare domain";
|
|
1025
|
+
return daemonPlist(label, prefixes) === undefined ? `bootout ${NO_DAEMON_PREFIX}` : undefined;
|
|
1026
|
+
}
|
|
1027
|
+
if (sub === "kickstart") {
|
|
1028
|
+
const flags = rest.slice(0, -1);
|
|
1029
|
+
const flagsOk = flags.every((flag) => flag === "-k" || flag === "-p") && new Set(flags).size === flags.length;
|
|
1030
|
+
return flagsOk && systemLabel(rest[rest.length - 1]) !== undefined ? undefined : "kickstart takes only -k / -p, then `system/<label>`";
|
|
1031
|
+
}
|
|
1032
|
+
return `launchctl ${String(sub)} is never run as root`;
|
|
1033
|
+
};
|
|
1034
|
+
var ID = /^\d+$/;
|
|
1035
|
+
var idsOk = (ids) => {
|
|
1036
|
+
const [flag, id, second, secondId] = ids;
|
|
1037
|
+
if (ids.length === 0)
|
|
1038
|
+
return true;
|
|
1039
|
+
if (ids.length === 2)
|
|
1040
|
+
return (flag === "-o" || flag === "-g") && ID.test(id ?? "");
|
|
1041
|
+
return ids.length === 4 && flag === "-o" && ID.test(id ?? "") && second === "-g" && ID.test(secondId ?? "");
|
|
1042
|
+
};
|
|
1043
|
+
var privilegedProblem = (argv, context) => {
|
|
1044
|
+
const [program, ...args] = argv;
|
|
1045
|
+
const under = (path) => path !== undefined && prefixOf(path, context.prefixes) !== undefined;
|
|
1046
|
+
if (program === LAUNCHCTL)
|
|
1047
|
+
return launchctlProblem(args, context.prefixes);
|
|
1048
|
+
if (program === INSTALL) {
|
|
1049
|
+
const [safe, m, mode, ...rest] = args;
|
|
1050
|
+
if (safe !== "-S" || m !== "-m" || mode === undefined || !/^[0-7]{4}$/.test(mode)) {
|
|
1051
|
+
return "install takes exactly `-S -m <4 octal digits> [-o <uid>] [-g <gid>] <staged> <dest>`";
|
|
1052
|
+
}
|
|
1053
|
+
if (rest.length < 2 || !idsOk(rest.slice(0, -2)))
|
|
1054
|
+
return "install takes only -o <uid> -g <gid>";
|
|
1055
|
+
const [source, dest] = rest.slice(-2);
|
|
1056
|
+
if (source === undefined || source !== context.staged) {
|
|
1057
|
+
return "install copies only the file this runner staged";
|
|
1058
|
+
}
|
|
1059
|
+
return under(dest) ? undefined : "install destination is not under a declared prefix";
|
|
1060
|
+
}
|
|
1061
|
+
if (program === RM) {
|
|
1062
|
+
const [force, end, path, ...extra] = args;
|
|
1063
|
+
return force === "-f" && end === "--" && extra.length === 0 && under(path) ? undefined : "rm takes exactly `-f -- <path under a declared prefix>`";
|
|
1064
|
+
}
|
|
1065
|
+
return `${String(program)} is not on the sudo allowlist`;
|
|
1066
|
+
};
|
|
1067
|
+
var routeExec = (argv, operatorUid) => {
|
|
1068
|
+
const [program, sub] = argv;
|
|
1069
|
+
if (program === "sudo" || program?.endsWith("/sudo") === true) {
|
|
1070
|
+
return {
|
|
1071
|
+
refuse: "sudoRunner never passes a sudo argv through; it elevates only its allowlist"
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
if (program !== LAUNCHCTL || sub === undefined || !WRITES.has(sub))
|
|
1075
|
+
return { as: "operator" };
|
|
1076
|
+
const target = sub === "kickstart" ? argv[argv.length - 1] : argv[2];
|
|
1077
|
+
if (target === "system" || target?.startsWith("system/") === true)
|
|
1078
|
+
return { as: "root" };
|
|
1079
|
+
const owner = /^(?:gui|user)\/(\d+)(?:\/|$)/.exec(target ?? "")?.[1];
|
|
1080
|
+
if (owner === undefined || Number(owner) === operatorUid)
|
|
1081
|
+
return { as: "operator" };
|
|
1082
|
+
return {
|
|
1083
|
+
refuse: `launchctl ${sub} ${String(target)}: that domain belongs to uid ${owner}, and sudoRunner ` + "elevates only the system domain. Deploy as that user, or as root."
|
|
1084
|
+
};
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
// src/launchd/sudo-guard.ts
|
|
1088
|
+
var refuse3 = (path, message) => new SudoRefusedError(`sudoRunner ${path}: ${message}`);
|
|
1089
|
+
var betweenDirs = (prefix, path) => {
|
|
1090
|
+
const parts = path.slice(prefix.length + 1).split("/").slice(0, -1);
|
|
1091
|
+
return parts.map((_, index) => `${prefix}/${parts.slice(0, index + 1).join("/")}`);
|
|
1092
|
+
};
|
|
1093
|
+
var prefixProblem = (stat) => {
|
|
1094
|
+
if (stat === undefined)
|
|
1095
|
+
return "is missing";
|
|
1096
|
+
if (stat.kind !== "directory")
|
|
1097
|
+
return `is a ${stat.kind}, not a directory`;
|
|
1098
|
+
if (stat.uid !== 0)
|
|
1099
|
+
return `is owned by uid ${String(stat.uid)}, not root`;
|
|
1100
|
+
if ((stat.mode & 18) !== 0) {
|
|
1101
|
+
return `is writable by group or other (mode ${stat.mode.toString(8).padStart(4, "0")})`;
|
|
1102
|
+
}
|
|
1103
|
+
return;
|
|
1104
|
+
};
|
|
1105
|
+
var assertPlainPath = async (base, prefix, path, expect) => {
|
|
1106
|
+
const dirs = betweenDirs(prefix, path);
|
|
1107
|
+
const [prefixStat, ...stats] = await Promise.all([prefix, ...dirs].map((dir) => base.stat(dir)));
|
|
1108
|
+
const bad = prefixProblem(prefixStat);
|
|
1109
|
+
if (bad !== undefined) {
|
|
1110
|
+
throw refuse3(path, `the declared prefix ${prefix} ${bad}. Declare only a real directory that root owns and ` + "only root may write. Nothing ran as root.");
|
|
1111
|
+
}
|
|
1112
|
+
for (const [index, stat] of stats.entries()) {
|
|
1113
|
+
if (stat?.kind !== "directory") {
|
|
1114
|
+
throw refuse3(path, `${String(dirs[index])} is ${stat === undefined ? "missing" : `a ${stat.kind}`}; root ` + "writes only through real directories under a declared prefix.");
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const target = await base.stat(path);
|
|
1118
|
+
if (target === undefined && expect === "file-or-absent")
|
|
1119
|
+
return "absent";
|
|
1120
|
+
if (target?.kind === "file")
|
|
1121
|
+
return "file";
|
|
1122
|
+
throw refuse3(path, `is ${target === undefined ? "missing" : `a ${target.kind}`}, not a file`);
|
|
1123
|
+
};
|
|
1124
|
+
var canRead = (file, uid, groups) => {
|
|
1125
|
+
if (uid === 0)
|
|
1126
|
+
return true;
|
|
1127
|
+
if (file.uid === uid)
|
|
1128
|
+
return (file.mode & 256) !== 0;
|
|
1129
|
+
const groupRead = (file.mode & 32) !== 0;
|
|
1130
|
+
const otherRead = (file.mode & 4) !== 0;
|
|
1131
|
+
if (groupRead === otherRead)
|
|
1132
|
+
return groupRead;
|
|
1133
|
+
if (groups === undefined)
|
|
1134
|
+
return;
|
|
1135
|
+
return groups.includes(file.gid) ? groupRead : otherRead;
|
|
1136
|
+
};
|
|
1137
|
+
var assertReadableBack = async (base, path, options, groups) => {
|
|
1138
|
+
const operator = base.effectiveUid();
|
|
1139
|
+
const parent = path.slice(0, path.lastIndexOf("/")) || "/";
|
|
1140
|
+
const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
|
|
1141
|
+
const file = { gid, mode: options.mode, uid: options.uid ?? 0 };
|
|
1142
|
+
if (canRead(file, operator, groups) !== false)
|
|
1143
|
+
return;
|
|
1144
|
+
throw refuse3(path, `owner ${String(file.uid)}, group ${String(gid)}, mode ${file.mode.toString(8)} would hide it ` + `from the deploying user (uid ${String(operator)}), who reads every file back. Declare a ` + "mode that user can read, or deploy as root. Nothing was written.");
|
|
1145
|
+
};
|
|
1146
|
+
var operatorGroups = async (base) => {
|
|
1147
|
+
const result = await base.exec(["/usr/bin/id", "-G"]);
|
|
1148
|
+
const ids = result.stdout.trim().split(/\s+/);
|
|
1149
|
+
return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
// src/launchd/sudo-stage.ts
|
|
1153
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
1154
|
+
import { tmpdir } from "node:os";
|
|
1155
|
+
import { join as join2 } from "node:path";
|
|
1156
|
+
var stageFile = async (bytes) => {
|
|
1157
|
+
const dir = await mkdtemp(join2(tmpdir(), "hf-sudo-"));
|
|
1158
|
+
const dispose = () => rm(dir, { force: true, recursive: true });
|
|
1159
|
+
try {
|
|
1160
|
+
const path = join2(dir, "staged");
|
|
1161
|
+
await writeFile(path, bytes, { flag: "wx", mode: 384 });
|
|
1162
|
+
return { dispose, path };
|
|
1163
|
+
} catch (cause) {
|
|
1164
|
+
await dispose();
|
|
1165
|
+
throw cause;
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
// src/launchd/sudo-runner.ts
|
|
1170
|
+
var PASSWORD = /^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
|
|
1171
|
+
var NOT_ALLOWED = /^(?:Sorry, user \S+ (?:is not allowed to execute|may not run sudo)|\S+ is not (?:in the sudoers file|allowed to run sudo on ))/m;
|
|
1172
|
+
var undeclared = (path, prefixes) => (cause) => {
|
|
1173
|
+
const code = errnoCode(cause);
|
|
1174
|
+
if (code !== "EACCES" && code !== "EPERM")
|
|
1175
|
+
throw cause;
|
|
1176
|
+
throw new Error(`sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` + `(${prefixes.join(", ")}). If root owns its directory, declare that directory as a prefix.`, { cause });
|
|
1177
|
+
};
|
|
1178
|
+
var makeSudoRunner = (prefixList, deps) => {
|
|
1179
|
+
const prefixes = checkPrefixes(prefixList);
|
|
1180
|
+
const { base } = deps;
|
|
1181
|
+
const elevate = async (argv, { before, staged } = {}) => {
|
|
1182
|
+
const shown = JSON.stringify(argv);
|
|
1183
|
+
const problem = privilegedProblem(argv, {
|
|
1184
|
+
prefixes,
|
|
1185
|
+
...staged === undefined ? {} : { staged }
|
|
1186
|
+
});
|
|
1187
|
+
if (problem !== undefined)
|
|
1188
|
+
throw new SudoRefusedError(`sudoRunner refused ${shown}: ${problem}`);
|
|
1189
|
+
await before?.();
|
|
1190
|
+
deps.log(`homeflare/launchd sudo -n ${shown}`);
|
|
1191
|
+
const result = await base.exec([SUDO, "-n", "--", ...argv]);
|
|
1192
|
+
if (result.exitCode !== 1)
|
|
1193
|
+
return result;
|
|
1194
|
+
if (PASSWORD.test(result.stderr)) {
|
|
1195
|
+
throw new SudoRefusedError(`sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` + "in the deploying terminal just before the deploy, or grant exactly these commands " + "NOPASSWD (docs/launchd-sudo.md). The command did not run.");
|
|
1196
|
+
}
|
|
1197
|
+
if (NOT_ALLOWED.test(result.stderr)) {
|
|
1198
|
+
throw new SudoRefusedError(`sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`);
|
|
1199
|
+
}
|
|
1200
|
+
return result;
|
|
1201
|
+
};
|
|
1202
|
+
const mustSucceed = async (argv, staged) => {
|
|
1203
|
+
const result = await elevate(argv, staged === undefined ? {} : { staged });
|
|
1204
|
+
if (result.exitCode !== 0) {
|
|
1205
|
+
const stderr = result.stderr.trim().slice(0, 300);
|
|
1206
|
+
throw new Error(`sudo -n ${JSON.stringify(argv)} -> ${String(result.exitCode)}: ${stderr}`);
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
return {
|
|
1210
|
+
effectiveUid: () => base.effectiveUid(),
|
|
1211
|
+
exec: async (argv) => {
|
|
1212
|
+
const route = routeExec(argv, base.effectiveUid());
|
|
1213
|
+
if ("refuse" in route)
|
|
1214
|
+
throw new SudoRefusedError(`sudoRunner: ${route.refuse}`);
|
|
1215
|
+
if (route.as === "operator")
|
|
1216
|
+
return base.exec(argv);
|
|
1217
|
+
const plist = argv[1] === "bootstrap" ? argv[3] : undefined;
|
|
1218
|
+
const prefix = plist === undefined ? undefined : prefixOf(plist, prefixes);
|
|
1219
|
+
return elevate(argv, {
|
|
1220
|
+
before: async () => {
|
|
1221
|
+
if (plist !== undefined && prefix !== undefined) {
|
|
1222
|
+
await assertPlainPath(base, prefix, plist, "file");
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
},
|
|
1227
|
+
lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
|
|
1228
|
+
lookupUser: (nameOrId) => base.lookupUser(nameOrId),
|
|
1229
|
+
privileged: true,
|
|
1230
|
+
readFile: (path) => base.readFile(path),
|
|
1231
|
+
removeFile: async (path) => {
|
|
1232
|
+
const prefix = prefixOf(path, prefixes);
|
|
1233
|
+
if (prefix === undefined)
|
|
1234
|
+
return base.removeFile(path).catch(undeclared(path, prefixes));
|
|
1235
|
+
if (await base.stat(path) === undefined)
|
|
1236
|
+
return;
|
|
1237
|
+
await assertPlainPath(base, prefix, path, "file");
|
|
1238
|
+
await mustSucceed([RM, "-f", "--", path]);
|
|
1239
|
+
},
|
|
1240
|
+
sleep: (ms) => base.sleep(ms),
|
|
1241
|
+
stat: (path) => base.stat(path),
|
|
1242
|
+
writeFileAtomic: async (path, bytes, options) => {
|
|
1243
|
+
const prefix = prefixOf(path, prefixes);
|
|
1244
|
+
const operator = base.effectiveUid();
|
|
1245
|
+
if (prefix === undefined) {
|
|
1246
|
+
if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
|
|
1247
|
+
throw new SudoRefusedError(`sudoRunner ${path}: owner ${String(options.uid)} needs root, and the path is under no ` + `declared prefix (${prefixes.join(", ")}). Declare its directory, or deploy as root. ` + "Nothing was written.");
|
|
1248
|
+
}
|
|
1249
|
+
return base.writeFileAtomic(path, bytes, options).catch(undeclared(path, prefixes));
|
|
1250
|
+
}
|
|
1251
|
+
const mode = octalMode(options.mode);
|
|
1252
|
+
if (mode === undefined)
|
|
1253
|
+
throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
|
|
1254
|
+
await assertPlainPath(base, prefix, path, "file-or-absent");
|
|
1255
|
+
await assertReadableBack(base, path, options, await deps.groups());
|
|
1256
|
+
const staged = await deps.stage(bytes);
|
|
1257
|
+
try {
|
|
1258
|
+
await mustSucceed([
|
|
1259
|
+
INSTALL,
|
|
1260
|
+
"-S",
|
|
1261
|
+
"-m",
|
|
1262
|
+
mode,
|
|
1263
|
+
...options.uid === undefined ? [] : ["-o", String(options.uid)],
|
|
1264
|
+
...options.gid === undefined ? [] : ["-g", String(options.gid)],
|
|
1265
|
+
staged.path,
|
|
1266
|
+
path
|
|
1267
|
+
], staged.path);
|
|
1268
|
+
} finally {
|
|
1269
|
+
await staged.dispose();
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
};
|
|
1274
|
+
var sudoRunner = (options) => {
|
|
1275
|
+
const base = localRunner(options);
|
|
1276
|
+
return makeSudoRunner(options.prefixes, {
|
|
1277
|
+
base,
|
|
1278
|
+
groups: () => operatorGroups(base),
|
|
1279
|
+
log: options.log ?? ((line) => {
|
|
1280
|
+
process.stderr.write(`${line}
|
|
1281
|
+
`);
|
|
1282
|
+
}),
|
|
1283
|
+
stage: stageFile
|
|
1284
|
+
});
|
|
1285
|
+
};
|
|
954
1286
|
export {
|
|
955
1287
|
HostFile,
|
|
956
1288
|
HostFileProvider,
|
|
@@ -958,12 +1290,14 @@ export {
|
|
|
958
1290
|
LaunchdJob,
|
|
959
1291
|
LaunchdJobProvider,
|
|
960
1292
|
PlistError,
|
|
1293
|
+
SudoRefusedError,
|
|
961
1294
|
canActAsRoot,
|
|
962
1295
|
hostRunnerLayer,
|
|
963
1296
|
launchdProviders,
|
|
964
1297
|
localRunner,
|
|
965
|
-
renderPlist
|
|
1298
|
+
renderPlist,
|
|
1299
|
+
sudoRunner
|
|
966
1300
|
};
|
|
967
1301
|
|
|
968
|
-
//# debugId=
|
|
1302
|
+
//# debugId=750ACEF7A88B083E64756E2164756E21
|
|
969
1303
|
//# sourceMappingURL=index.js.map
|