@homeflare/alchemy 0.5.0 → 0.7.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 +68 -55
- 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 +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -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 +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -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 +47 -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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +24 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.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 +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1518 -310
- package/dist/openbao/index.js.map +40 -17
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.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/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +106 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/caddy.md +180 -0
- package/docs/launchd.md +167 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +12 -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/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -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 +2 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +163 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- 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 +62 -37
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read / plan / reconcile loop the newer role families share (Bao.JwtRole, Bao.KubernetesRole,
|
|
3
|
+
* Bao.JwtAuthConfig): one path, one body, one comparison. Each family supplies its own form; this
|
|
4
|
+
* owns the order of operations, which is where the estate's providers have gone wrong before.
|
|
5
|
+
*
|
|
6
|
+
* ★ THE ORDER, AND WHY EACH STEP IS THERE:
|
|
7
|
+
* 1. Refuse a declaration the family knows is wrong, before any call (`problems`).
|
|
8
|
+
* 2. Read live. Write only when it differs — an identical write is still an audit line.
|
|
9
|
+
* 3. Before writing over a live object, refuse if the write would silently destroy something
|
|
10
|
+
* this resource does not model (`wouldErase`, the ssh-role.ts pattern).
|
|
11
|
+
* 4. Re-read, and refuse unless live now MATCHES. A 2xx is not proof: the server can accept a
|
|
12
|
+
* field and store something else (pki-role.ts has the history).
|
|
13
|
+
* ⛔ A DECLARATION reconcile WOULD REFUSE NEVER PLANS `noop` — `planRole` routes it to `update`,
|
|
14
|
+
* so the refusal is seen at the next deploy instead of hiding behind a clean plan.
|
|
15
|
+
*/
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
18
|
+
import type { BaoError } from './bao-status.ts';
|
|
19
|
+
export interface RoleSpec<A> {
|
|
20
|
+
/** `Bao.JwtRole` etc., for messages. */
|
|
21
|
+
readonly family: string;
|
|
22
|
+
/** The API path, e.g. `auth/jwt/role/humans`. */
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly problems: readonly string[];
|
|
25
|
+
readonly attributesOf: (live: Record<string, unknown>) => A;
|
|
26
|
+
readonly matches: (attributes: A) => boolean;
|
|
27
|
+
readonly body: Readonly<Record<string, unknown>>;
|
|
28
|
+
/** Live fields a write would reset that the declaration does not model. */
|
|
29
|
+
readonly wouldErase?: (live: Record<string, unknown>) => readonly string[];
|
|
30
|
+
}
|
|
31
|
+
type Found<A> = {
|
|
32
|
+
readonly attributes: A;
|
|
33
|
+
readonly live: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
export declare const readRoleAt: <A>(spec: RoleSpec<A>) => Effect.Effect<Found<A> | undefined, BaoError, HttpClient.HttpClient>;
|
|
36
|
+
/** `update` or `noop` — the part of a diff after the identity checks. */
|
|
37
|
+
export declare const planRole: <A>(spec: RoleSpec<A>) => Effect.Effect<'noop' | 'update', BaoError, HttpClient.HttpClient>;
|
|
38
|
+
export declare const reconcileRole: <A>(spec: RoleSpec<A>) => Effect.Effect<A, BaoError, HttpClient.HttpClient>;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=role-reconcile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-reconcile.d.ts","sourceRoot":"","sources":["../../src/openbao/role-reconcile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEhD,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,wCAAwC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,iDAAiD;IACjD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAC5D,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,CAAC,KAAK,OAAO,CAAC;IAC7C,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,SAAS,MAAM,EAAE,CAAC;CAC5E;AAED,KAAK,KAAK,CAAC,CAAC,IAAI;IAAE,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC;AAEnF,eAAO,MAAM,UAAU,GAAI,CAAC,QACpB,QAAQ,CAAC,CAAC,CAAC,KAChB,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAGnE,CAAC;AAEJ,yEAAyE;AACzE,eAAO,MAAM,QAAQ,GAAI,CAAC,QAClB,QAAQ,CAAC,CAAC,CAAC,KAChB,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,QAAQ,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAK/D,CAAC;AAKL,eAAO,MAAM,aAAa,GAAI,CAAC,QACvB,QAAQ,CAAC,CAAC,CAAC,KAChB,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CA0B/C,CAAC"}
|
package/docs/caddy.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Caddy — `@homeflare/alchemy/caddy`
|
|
2
|
+
|
|
3
|
+
Declare a running Caddy's config as Caddyfile text and apply it through Caddy's own admin API:
|
|
4
|
+
`POST /load` to apply (a graceful reload), `GET /config/` to detect drift. `caddyWithFile()` also
|
|
5
|
+
writes the same Caddyfile to the file Caddy starts from, with launchd's `HostFile`, so a restart
|
|
6
|
+
keeps it. Every admin call goes through one injectable `CaddyAdmin` transport.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import { caddyProviders, caddyWithFile } from '@homeflare/alchemy/caddy';
|
|
12
|
+
import { LaunchdJob, launchdProviders } from '@homeflare/alchemy/launchd';
|
|
13
|
+
|
|
14
|
+
const caddyfile = `app.example.com {
|
|
15
|
+
\treverse_proxy 127.0.0.1:8080
|
|
16
|
+
\ttls {
|
|
17
|
+
\t\tdns cloudflare {env.CF_API_TOKEN}
|
|
18
|
+
\t}
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export const edge = Effect.gen(function* () {
|
|
23
|
+
const { file } = yield* caddyWithFile('caddy', { caddyfile, path: '/usr/local/etc/Caddyfile' });
|
|
24
|
+
yield* LaunchdJob('caddy-daemon', {
|
|
25
|
+
label: 'com.example.caddy',
|
|
26
|
+
domain: 'system',
|
|
27
|
+
// --envfile: a file a secret renderer (openbao-agent) writes; {env.CF_API_TOKEN} reads it.
|
|
28
|
+
programArguments: [
|
|
29
|
+
'/usr/local/bin/caddy',
|
|
30
|
+
'run',
|
|
31
|
+
'--config',
|
|
32
|
+
file.path,
|
|
33
|
+
'--adapter',
|
|
34
|
+
'caddyfile',
|
|
35
|
+
'--envfile',
|
|
36
|
+
'/usr/local/etc/caddy.env',
|
|
37
|
+
],
|
|
38
|
+
runAtLoad: true,
|
|
39
|
+
keepAlive: true,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
// ★ launchdProviders() for the HostFile and the job; caddyProviders() for the config.
|
|
43
|
+
export const providers = Layer.mergeAll(caddyProviders(), launchdProviders());
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## CaddyConfig
|
|
47
|
+
|
|
48
|
+
| prop | notes |
|
|
49
|
+
| ------------ | ---------------------------------------------------------------------------- |
|
|
50
|
+
| `caddyfile` | the whole Caddyfile; it REPLACES the running config. ⛔ never a secret |
|
|
51
|
+
| `sourceFile` | the file Caddy was started with; `caddyWithFile()` sets it from its HostFile |
|
|
52
|
+
|
|
53
|
+
| attribute | what |
|
|
54
|
+
| -------------- | ------------------------------------------------------------------------------------ |
|
|
55
|
+
| `configSha256` | SHA-256 of the adapted JSON Caddy reported after the apply — never the config itself |
|
|
56
|
+
| `endpoint` | where the admin API was reached |
|
|
57
|
+
| `sourceFile` | as applied |
|
|
58
|
+
|
|
59
|
+
| step | what happens |
|
|
60
|
+
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
61
|
+
| plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
|
|
62
|
+
| apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
|
|
63
|
+
| refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
|
|
64
|
+
| read | the digest of `GET /config/` — with no state, a running Caddy is **adopted** (its live config becomes the baseline) |
|
|
65
|
+
| drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
|
|
66
|
+
| replace | never — a new Caddyfile is a reload of the same Caddy |
|
|
67
|
+
| delete | ⛔ nothing. See [Removing it](#removing-it) |
|
|
68
|
+
|
|
69
|
+
The digests compare adapted JSON, not bytes: `/adapt` answers Go struct order, `GET /config/` sorted
|
|
70
|
+
keys with `<>&` escaped and a trailing newline. `digest.ts` erases all three. ★ Measured 2026-09-21
|
|
71
|
+
against a throwaway Caddy 2.11.4: the digests agree after a load and for a Caddy started from the
|
|
72
|
+
same file, and a refused load kept serving the old config and answered **200** with the error after
|
|
73
|
+
the adapter's warnings, which the provider still reports as a refusal.
|
|
74
|
+
|
|
75
|
+
⚠️ **A stopped Caddy does not fail the plan.** Its launchd job may be the fix in the same stack, so
|
|
76
|
+
`read` and `diff` plan a create/update with a warning (the plan-time `/adapt` did not run). The apply
|
|
77
|
+
still needs Caddy, and fails loudly until it answers.
|
|
78
|
+
|
|
79
|
+
## Order: file, then `/load`
|
|
80
|
+
|
|
81
|
+
`caddyWithFile()` passes the HostFile's `path` Output into the CaddyConfig, so Alchemy writes the
|
|
82
|
+
file first. It is the only order that bootstraps: a new host's Caddy job starts from the file,
|
|
83
|
+
before any admin API exists to load into. What bounds the cost:
|
|
84
|
+
|
|
85
|
+
- ★ **A Caddyfile that does not adapt never reaches disk** once the config has state: the plan-time
|
|
86
|
+
`/adapt` fails the plan. On the very first deploy the path is still an Output, Alchemy skips the
|
|
87
|
+
adoption probe, and the check runs at apply instead.
|
|
88
|
+
- ⚠️ **A Caddyfile that adapts but that Caddy refuses** (a port in use, a missing cert file) is on
|
|
89
|
+
disk when `/load` fails. Caddy keeps serving the old config — but a **restart before the fix loads
|
|
90
|
+
the refused file**. Fix and redeploy, or run Caddy with `--resume` (below).
|
|
91
|
+
- ⛔ Secrets are refused at declaration, before the HostFile exists (its `content` is a prop too).
|
|
92
|
+
- ★ Both resources retain: removing them from a stack leaves the file a restart needs.
|
|
93
|
+
|
|
94
|
+
## Autosave and `--resume`
|
|
95
|
+
|
|
96
|
+
After every successful load (ours included) Caddy writes the config JSON to `autosave.json`
|
|
97
|
+
(`$XDG_CONFIG_HOME/caddy/`, else `~/Library/Application Support/Caddy/` on macOS, `~/.config/caddy/`
|
|
98
|
+
on Linux), unless the Caddyfile says `persist_config off`. ⚠️ A daemon started with no `HOME` and no
|
|
99
|
+
`XDG_CONFIG_HOME` falls back to `./caddy/` under its working directory (storage.go AppConfigDir):
|
|
100
|
+
set `XDG_CONFIG_HOME` in the launchd job so autosave lands where you expect.
|
|
101
|
+
|
|
102
|
+
- **Without `--resume`** (the mini's Caddy today): a restart loads `--config`. The file and the
|
|
103
|
+
running config agree after every successful deploy; see the ⚠️ above for a failed one.
|
|
104
|
+
- **With `caddy run --resume --config <file>`**: a restart loads `autosave.json` — the last config
|
|
105
|
+
Caddy **accepted** — and `--config` only when no autosave exists. A refused file on disk can then
|
|
106
|
+
never take sites down. The trade: a hand change through the API also survives a restart; the next
|
|
107
|
+
plan reports it as drift and the deploy puts the declaration back.
|
|
108
|
+
- `--watch` reloads the file on change; our `/load` right after is then a no-op ("config is
|
|
109
|
+
unchanged").
|
|
110
|
+
- `sourceFile` rides as `Caddy-Config-Source-File`, as `caddy reload` sends it. Without it a load
|
|
111
|
+
makes Caddy forget its source file, and SIGUSR1 stops reloading from it. The header only KEEPS the
|
|
112
|
+
file Caddy started with (caddy.go ClearLastConfigIfDifferent); it never sets a new one. ⚠️ Under
|
|
113
|
+
`--resume` with an autosave, Caddy records no source file at all, so SIGUSR1 has nothing to reload.
|
|
114
|
+
|
|
115
|
+
## Secrets
|
|
116
|
+
|
|
117
|
+
Alchemy stores props unencrypted, and the Caddyfile is a prop. Use RUNTIME placeholders, which Caddy
|
|
118
|
+
resolves in its own process, so the value never enters the state, the adapted JSON or the autosave:
|
|
119
|
+
|
|
120
|
+
- `{env.NAME}` — start Caddy with `--envfile <path>`; a secret renderer (openbao-agent) writes that
|
|
121
|
+
file. A rotated value needs a Caddy restart.
|
|
122
|
+
- `{file./path}` — the file's contents (trailing newline trimmed), read when the placeholder runs.
|
|
123
|
+
- ⚠️ `{$NAME}` is substituted at **adapt** time: the value is in the adapted JSON, readable at
|
|
124
|
+
`GET /config/` and written to `autosave.json`. Not refused, but worse.
|
|
125
|
+
- ⛔ `{$NAME:default}` puts the default in the prop, and Caddy uses it whenever `NAME` is unset. The
|
|
126
|
+
tripwire checks the default as a literal.
|
|
127
|
+
- `basic_auth` replaces placeholders in the hash too: `admin {env.ADMIN_HASH}`.
|
|
128
|
+
|
|
129
|
+
The tripwire refuses a PEM private key, a literal after `dns`/`acme_dns <provider>`, a secret-named
|
|
130
|
+
subdirective (`api_token`, `client_secret`, `password`, …) with a literal value, a literal
|
|
131
|
+
`Authorization`/`Cookie`/`X-Api-Key` header, and token shapes (GitHub, AWS, Slack, `sk-…`, JWT,
|
|
132
|
+
bcrypt/argon2 hashes). It never echoes what it found. It is a tripwire, not a scanner.
|
|
133
|
+
|
|
134
|
+
## The admin endpoint
|
|
135
|
+
|
|
136
|
+
⛔ The admin API has no authentication. `localCaddyAdmin()` accepts only `http://` loopback
|
|
137
|
+
(`127.0.0.0/8`, `localhost`, `[::1]`) or `unix:///path`; the default is `http://127.0.0.1:2019`.
|
|
138
|
+
|
|
139
|
+
- **Host and Origin.** Caddy refuses a request whose `Host` is not an allowed origin (DNS-rebinding
|
|
140
|
+
guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
|
|
141
|
+
`enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
|
|
142
|
+
Host check entirely.
|
|
143
|
+
- **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
|
|
144
|
+
ITS port): pass `hostHeader: 'localhost:2019'`.
|
|
145
|
+
- **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
|
|
146
|
+
`localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
|
|
147
|
+
- **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
|
|
148
|
+
if it turns the API off, listens off loopback or somewhere the transport does not reach (another
|
|
149
|
+
port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
|
|
150
|
+
no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
|
|
151
|
+
`enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
|
|
152
|
+
- ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
|
|
153
|
+
load. That is refused unless the transport is at that default: a Caddy reached on a socket or
|
|
154
|
+
another port declares `admin <address>` in its Caddyfile.
|
|
155
|
+
|
|
156
|
+
## Adoption
|
|
157
|
+
|
|
158
|
+
With no state, `read` reports the running Caddy's live config as plain (owned) attributes, so the
|
|
159
|
+
first deploy adopts it silently and its forced update loads the declared Caddyfile — a full replace
|
|
160
|
+
of whatever ran before. ⚠️ Review the plan: the Caddy on this loopback admin API is the one you get.
|
|
161
|
+
|
|
162
|
+
## Removing it
|
|
163
|
+
|
|
164
|
+
⛔ Delete never touches Caddy, under either removal policy. There is no "less config" to fall back
|
|
165
|
+
to, and an empty config stops every server. `retain` is the default; with `destroy`, delete still
|
|
166
|
+
only forgets. It never calls `/stop` or `DELETE /config/`. An empty Caddyfile, or one that adapts
|
|
167
|
+
to no apps (only comments or global options), is refused for the same reason. To empty a Caddy, do
|
|
168
|
+
it by hand at its admin API.
|
|
169
|
+
|
|
170
|
+
## Traps
|
|
171
|
+
|
|
172
|
+
- ⚠️ `import` paths resolve against Caddy's working directory (the API adapts with no file name).
|
|
173
|
+
Keep them absolute.
|
|
174
|
+
- ⚠️ `/adapt` runs on the **running** binary. A Caddyfile that uses a plugin's directive (Cloudflare
|
|
175
|
+
DNS, caddy-security, layer4) needs that custom build running before it will validate.
|
|
176
|
+
- ⚠️ **Bootstrap race.** Where Caddy is not running yet, the config can reach the admin API before
|
|
177
|
+
its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
|
|
178
|
+
`retryDelayMs`; default 2 × 500 ms) — raise them, or rerun the deploy.
|
|
179
|
+
- ⚠️ One `CaddyAdmin` per stack: the transport picks the Caddy, not a prop. Pointing it at another
|
|
180
|
+
Caddy plans an update that loads there; the old one keeps what it had.
|
package/docs/launchd.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# launchd — `@homeflare/alchemy/launchd`
|
|
2
|
+
|
|
3
|
+
Declare a Mac host with Alchemy: its launchd jobs (`LaunchdJob`) and the config files they read
|
|
4
|
+
(`HostFile`). launchd has no SDK, so the provider renders the plist itself and drives
|
|
5
|
+
`launchctl`. Every file read, file write and `launchctl` call goes through one injectable
|
|
6
|
+
`HostRunner`.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { HostFile, LaunchdJob, launchdProviders } from '@homeflare/alchemy/launchd';
|
|
11
|
+
|
|
12
|
+
export const exporter = Effect.gen(function* () {
|
|
13
|
+
const config = yield* HostFile('exporter-config', {
|
|
14
|
+
path: '/usr/local/etc/example-exporter.yml',
|
|
15
|
+
content: 'listen: 127.0.0.1:9100\n',
|
|
16
|
+
mode: 0o644,
|
|
17
|
+
owner: 'root',
|
|
18
|
+
group: 'wheel',
|
|
19
|
+
});
|
|
20
|
+
yield* LaunchdJob('exporter', {
|
|
21
|
+
label: 'com.example.exporter',
|
|
22
|
+
domain: 'system', // or 'gui/<uid>' for a LaunchAgent
|
|
23
|
+
// ★ config.path, not the literal: the Output orders the job after its file is written.
|
|
24
|
+
programArguments: ['/usr/local/bin/example-exporter', '--config', config.path],
|
|
25
|
+
environment: { TZ: 'UTC' }, // ⛔ non-secret values only
|
|
26
|
+
runAtLoad: true,
|
|
27
|
+
keepAlive: { successfulExit: false },
|
|
28
|
+
throttleInterval: 30,
|
|
29
|
+
standardOutPath: '/var/log/example-exporter.log',
|
|
30
|
+
standardErrorPath: '/var/log/example-exporter.log',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
// Provide launchdProviders() alongside the stack's other provider layers.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## LaunchdJob
|
|
37
|
+
|
|
38
|
+
| prop | plist key | notes |
|
|
39
|
+
| ----------------------------- | -------------------------------- | ------------------------------------------------------ |
|
|
40
|
+
| `label` | `Label` | also the file name; `[A-Za-z0-9._-]`, no `/` |
|
|
41
|
+
| `domain` | — | `system` (LaunchDaemons) or `gui/<uid>` (LaunchAgents) |
|
|
42
|
+
| `programArguments` | `ProgramArguments` | first element must be an absolute path |
|
|
43
|
+
| `environment` | `EnvironmentVariables` | ⛔ non-secret only (see below) |
|
|
44
|
+
| `runAtLoad` | `RunAtLoad` | |
|
|
45
|
+
| `keepAlive` | `KeepAlive` | `true`/`false` or `{ successfulExit, crashed }` |
|
|
46
|
+
| `startInterval` | `StartInterval` | seconds, > 0 |
|
|
47
|
+
| `startCalendarInterval` | `StartCalendarInterval` | one entry or a list; fields are range-checked |
|
|
48
|
+
| `throttleInterval` | `ThrottleInterval` | seconds, ≥ 0 |
|
|
49
|
+
| `standardOutPath` / `…Error…` | `StandardOutPath` / `…ErrorPath` | absolute |
|
|
50
|
+
| `workingDirectory` | `WorkingDirectory` | absolute |
|
|
51
|
+
| `userName` / `groupName` | `UserName` / `GroupName` | system domain only — launchd ignores them for agents |
|
|
52
|
+
| `extraKeys` | anything else, verbatim | may not repeat a key a typed prop owns |
|
|
53
|
+
|
|
54
|
+
The plist path is derived, never declared: `/Library/LaunchDaemons/<label>.plist` for `system`,
|
|
55
|
+
`<home of uid>/Library/LaunchAgents/<label>.plist` for `gui/<uid>`.
|
|
56
|
+
|
|
57
|
+
| step | what happens |
|
|
58
|
+
| ------- | --------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| create | write the plist atomically (system: `root:wheel 0644`), then `launchctl bootstrap` |
|
|
60
|
+
| update | write, `bootout` the running job (and wait until launchd lets go), `bootstrap` — **a restart** |
|
|
61
|
+
| read | `launchctl print` (loaded? state, pid, last exit) plus the SHA-256 of the plist on disk |
|
|
62
|
+
| diff | the rendered plist's SHA-256 against the stored digest **and** the on-disk one, plus "is it loaded" |
|
|
63
|
+
| replace | only when `label` or `domain` changes — **delete first**, so two copies never run at once |
|
|
64
|
+
| delete | `bootout` if loaded, remove the plist; idempotent |
|
|
65
|
+
|
|
66
|
+
- A converged job is not restarted: a retried deploy that finds the right plist loaded does nothing.
|
|
67
|
+
- ⛔ **A rename is checked at plan time.** Alchemy deletes the old job before it creates the new
|
|
68
|
+
one, so everything the new one would be refused for (an invalid or reserved label, a domain you
|
|
69
|
+
may not write, a disabled label, a label another job already holds) fails the **plan**, with the
|
|
70
|
+
old job still running. A rename is seen even while other props are unresolved Outputs.
|
|
71
|
+
- ⚠️ A failed `bootstrap` leaves the job **down**. On an update the new plist stays on disk, the
|
|
72
|
+
state keeps the old digest, and the next deploy retries. On a first create the plist is removed
|
|
73
|
+
again, so the next plan does not find its own file and call it someone else's.
|
|
74
|
+
There is no rollback to an older plist.
|
|
75
|
+
- ⛔ A label disabled with `launchctl disable` is refused, not re-enabled: that was someone's
|
|
76
|
+
decision. The error names the `launchctl enable` command to run if it is stale.
|
|
77
|
+
- ⚠️ A `gui/<uid>` job needs that user logged in: without a login session the domain does not
|
|
78
|
+
exist (`launchctl print` exits 112, "Could not find domain"; measured). Deploying refuses before
|
|
79
|
+
it writes anything. Deleting still removes the plist, which stops launchd loading it at login.
|
|
80
|
+
- ⚠️ The digest covers the rendered bytes, so a kit upgrade that changes the renderer's output
|
|
81
|
+
restarts every declared job on the next deploy. Such a release says so in its changelog.
|
|
82
|
+
|
|
83
|
+
## HostFile
|
|
84
|
+
|
|
85
|
+
`path` (absolute, normalised), `content` (UTF-8 text), `mode` (default `0o644`), `owner` and
|
|
86
|
+
`group` (names or ids). Written as a temp file in the same directory — mode and owner set before
|
|
87
|
+
it becomes visible — then renamed over the path. Read back after every write. Diffed by SHA-256,
|
|
88
|
+
mode and owner. A new `path` is a create-before-delete replace. Delete removes the file.
|
|
89
|
+
|
|
90
|
+
- ⛔ A different file already at a new `path` is refused, not overwritten: the adoption probe
|
|
91
|
+
never looks at a replace's new path, so a typo there would otherwise overwrite a system file.
|
|
92
|
+
|
|
93
|
+
- ⛔ A symlink or directory at the path is refused, never replaced: rename over a symlink replaces
|
|
94
|
+
the link, which takes the path from whatever tool owns it (nix-darwin's `/etc` entries point
|
|
95
|
+
into `/nix/store`).
|
|
96
|
+
- ⚠️ The parent directory must exist. Creating it would invent an owner and mode nobody declared.
|
|
97
|
+
|
|
98
|
+
## ⛔ No secrets in props
|
|
99
|
+
|
|
100
|
+
Alchemy stores props and attributes **unencrypted** in its state store. A job's `environment`,
|
|
101
|
+
`programArguments` and `extraKeys`, and a file's `content`, land there, in every plan diff, and on
|
|
102
|
+
disk.
|
|
103
|
+
|
|
104
|
+
**Keep secret files rendered by a secret renderer** — `openbao-agent` templates on a HomeFlare
|
|
105
|
+
host — and declare only the path: `FOO_TOKEN_FILE=/path`, `--token-file /path`. A tripwire refuses
|
|
106
|
+
the obvious mistakes (a `*_TOKEN`/`*_PASSWORD`-style variable, `--token=…`, a PEM private key); it
|
|
107
|
+
is not a scanner, and passing it does not make a value safe to declare.
|
|
108
|
+
|
|
109
|
+
## ⛔ No silent sudo
|
|
110
|
+
|
|
111
|
+
The `system` domain, and another user's `gui/<uid>`, need root. The provider refuses up front
|
|
112
|
+
unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
|
|
113
|
+
`privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
|
|
114
|
+
|
|
115
|
+
- **system daemons:** start the whole deploy as root; or pass `launchdProviders(yourRunner)` where
|
|
116
|
+
`yourRunner` is a `HostRunner` you wrote that already holds root (a root helper, say).
|
|
117
|
+
- **your own agents** (`gui/<your uid>`): no root needed.
|
|
118
|
+
|
|
119
|
+
Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
|
|
120
|
+
from the planning user (a root-owned `0600`, say) fails the plan with `EACCES`: plan as the user
|
|
121
|
+
you deploy as.
|
|
122
|
+
|
|
123
|
+
## Adoption: nothing is taken over silently
|
|
124
|
+
|
|
125
|
+
`read` with no prior state reports anything it finds — a loaded label, a plist, a file — as
|
|
126
|
+
`Unowned`, so Alchemy refuses to take it over without `--adopt`.
|
|
127
|
+
|
|
128
|
+
Labels under `org.nixos.`, `com.apple.` and `homebrew.mxcl.` are **refused outright**. nix-darwin
|
|
129
|
+
rewrites its plists on every activation and unloads any it no longer lists, macOS owns
|
|
130
|
+
`com.apple.*`, and `brew services` owns `homebrew.mxcl.*`. Adopting one would give a file two
|
|
131
|
+
owners. Move the job instead.
|
|
132
|
+
|
|
133
|
+
## Cutover from nix-darwin
|
|
134
|
+
|
|
135
|
+
Nix-managed `org.nixos.*` jobs are **not adopted**. The host stack declares a **new** label, and the
|
|
136
|
+
Nix job is booted out first. Per job:
|
|
137
|
+
|
|
138
|
+
1. **Declare** the replacement in the host stack under a new label (`com.example.<job>`), with its
|
|
139
|
+
config as `HostFile`s. ⚠️ Nothing it references may live in `/nix/store` — the next garbage
|
|
140
|
+
collection deletes it. Plan it: expect creates only.
|
|
141
|
+
2. **Remove the job from the nix-darwin config and activate.** Activation unloads and deletes every
|
|
142
|
+
daemon it no longer lists — checked 2026-09-21 in a nix-darwin activation script: it prints
|
|
143
|
+
`removing service <label>`, runs `launchctl unload`, then removes the plist. Confirm with
|
|
144
|
+
`launchctl print system/org.nixos.<job>` → exit 113 ("Could not find service").
|
|
145
|
+
3. **Deploy** the host stack. The new label bootstraps.
|
|
146
|
+
4. **Verify** `launchctl print system/com.example.<job>` shows `state = running`, then check the
|
|
147
|
+
service itself (port, metrics, logs).
|
|
148
|
+
|
|
149
|
+
- ★ **Why this order.** Both jobs usually bind the same port and write the same files; the old one
|
|
150
|
+
must be gone before the new one starts. Downtime is one deploy.
|
|
151
|
+
- ⚠️ **`sudo launchctl bootout system/org.nixos.<job>` is not a cutover.** The plist stays in
|
|
152
|
+
`/Library/LaunchDaemons`, so launchd loads it again at the next boot; and activation (checked in
|
|
153
|
+
the same script) copies back and `load -w`s any Nix plist that is missing or differs from the
|
|
154
|
+
store. Either way two copies end up fighting over the port.
|
|
155
|
+
- ⚠️ **Check the host's own activation for labels it removes.** An activation script can boot out,
|
|
156
|
+
`disable` and delete jobs by name on every run; a new label that collides with one is disabled
|
|
157
|
+
under you. `grep -n 'bootout\|disable' /run/current-system/activate` before choosing labels.
|
|
158
|
+
- **Rolling back** is the same three steps reversed: destroy (or remove) the resource, restore the
|
|
159
|
+
job in the Nix config, activate.
|
|
160
|
+
|
|
161
|
+
## The runner
|
|
162
|
+
|
|
163
|
+
`HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
|
|
164
|
+
`stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
|
|
165
|
+
`privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
|
|
166
|
+
`node:child_process`; tests use an in-memory fake, so nothing in the test suite runs `launchctl` or
|
|
167
|
+
writes outside a temp directory.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# MeshNode — a Cloudflare Mesh node without its token in state
|
|
2
|
+
|
|
3
|
+
`MeshNode` (`Cloudflare.MeshNode`) declares a Cloudflare Mesh node: the `warp_connector` object
|
|
4
|
+
that a Linux host joins with the Cloudflare One Client. `fetchMeshNodeToken` fetches the node's
|
|
5
|
+
enrolment token on demand, for a one-off enrolment step. Read 2026-09-21 against
|
|
6
|
+
`alchemy@2.0.0-beta.79`, `@distilled.cloud/cloudflare@1.0.0-rc.12` and Cloudflare's Mesh docs.
|
|
7
|
+
|
|
8
|
+
## Why not `Cloudflare.Tunnel.WarpConnector`
|
|
9
|
+
|
|
10
|
+
- ⛔ **It puts the node token in state.** Its `toAttributes` calls the token endpoint on every
|
|
11
|
+
`read`, `reconcile` and `list`, and stores the result as an attribute. `Redacted` keeps it out
|
|
12
|
+
of logs, not out of the state store. The token lets any Linux host join the account's Mesh as
|
|
13
|
+
that node.
|
|
14
|
+
- **It has no `ha` prop.** High availability can only be chosen when the node is created.
|
|
15
|
+
- **Its plans need a Write token.** The token endpoint requires "Cloudflare One Connectors Write"
|
|
16
|
+
(or the cloudflared or Tunnel Write groups). `MeshNode` never calls it, so a plan can run on a
|
|
17
|
+
read token.
|
|
18
|
+
|
|
19
|
+
## Declaring a node
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
23
|
+
import { MeshNode, providers as homeflareCloudflare } from '@homeflare/alchemy/cloudflare';
|
|
24
|
+
|
|
25
|
+
// providers: Layer.mergeAll(Cloudflare.providers(), homeflareCloudflare()),
|
|
26
|
+
const door = yield * MeshNode('vault-door', { name: 'door-a', ha: false });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| prop | type | notes |
|
|
30
|
+
| ------ | --------- | --------------------------------------------------------------------------------- |
|
|
31
|
+
| `name` | `string` | Unique per account. The identity used for adoption. Renames in place. |
|
|
32
|
+
| `ha` | `boolean` | **Required, create-only.** The API default is `false`, the dashboard's is `true`. |
|
|
33
|
+
|
|
34
|
+
Attributes: `id` (the node UUID), `accountId`, `name`, `status` (`inactive`, `degraded`,
|
|
35
|
+
`healthy` or `down`, as of the last read or write) and `ha`. ⛔ There is no token attribute.
|
|
36
|
+
|
|
37
|
+
The account and credentials come from Alchemy's own Cloudflare environment
|
|
38
|
+
(`Cloudflare.CloudflareApiLive()`): an Alchemy profile, or `CLOUDFLARE_API_TOKEN` and
|
|
39
|
+
`CLOUDFLARE_ACCOUNT_ID` in CI. So the node lands in the same account as the `Gateway.Rule` and
|
|
40
|
+
`DNS.Record` resources that refer to it. `providers()` builds that layer itself, so the provider
|
|
41
|
+
works whether or not the stack also merges `Cloudflare.providers()`.
|
|
42
|
+
|
|
43
|
+
⛔ **Removal policy: `retain` by default**, like the kit's other resources whose deletion breaks
|
|
44
|
+
their consumers. Dropping the declaration or `alchemy destroy` leaves the node live and only drops
|
|
45
|
+
it from state. Opt a declaration into deletion with `.pipe(RemovalPolicy.destroy())`.
|
|
46
|
+
|
|
47
|
+
### Choosing `ha`: a door is `ha: false`
|
|
48
|
+
|
|
49
|
+
HA fails over the CIDR **routes** a node advertises. A node with no routes (a door: callers dial
|
|
50
|
+
its Mesh IP) gains nothing from it, because the Mesh IP belongs to each replica: "Nodes without
|
|
51
|
+
routes do not benefit from HA failover" (Cloudflare's Mesh HA page, read 2026-09-21). After a
|
|
52
|
+
failover the promoted replica answers on its own Mesh IP, so nothing dialling the first one
|
|
53
|
+
follows it.
|
|
54
|
+
|
|
55
|
+
So a door is `ha: false`, and **a second door is a second `MeshNode`** with its own name, token
|
|
56
|
+
and Mesh IP. Callers list both addresses. Keep `ha: true` for a subnet gateway with routes, on a
|
|
57
|
+
device profile that uses MASQUE.
|
|
58
|
+
|
|
59
|
+
## What each change does
|
|
60
|
+
|
|
61
|
+
| change | answer | what happens on Cloudflare |
|
|
62
|
+
| ------------------------ | --------------------------- | ------------------------------------------------------------------------------------------- |
|
|
63
|
+
| `name` | `update` | `PATCH`. The id, token, enrolled replicas and Mesh IPs all survive. |
|
|
64
|
+
| `ha`, same `name` | `replace`, **delete-first** | `retain` (default): **refused**, nothing written. `destroy`: old deleted, then new created. |
|
|
65
|
+
| `ha` and `name` together | `replace`, create-first | The new node is created. Under `retain` the old one stays live. |
|
|
66
|
+
| account (provider env) | `replace`, create-first | As above; names are unique per account, so the two cannot collide. |
|
|
67
|
+
|
|
68
|
+
⛔ **An `ha` replace is an outage for that node.** The new node has a new id and a new token, and
|
|
69
|
+
each replica gets a new Mesh IP. Re-enrol every replica with the new token, and update anything
|
|
70
|
+
that pins the old Mesh IP (Gateway rules, listener allow-lists) in the same change.
|
|
71
|
+
|
|
72
|
+
★ **Why delete-first only when the name stays.** The new node needs the name the old one holds, so
|
|
73
|
+
creating first would fail with code 1013 (`DuplicateTunnelName`). ⚠️ Unmeasured: whether a
|
|
74
|
+
just-deleted node frees its name at once. If it does not, the create fails with code 1013 and a
|
|
75
|
+
sentence; deploy again once the name is free. An invalid declaration is refused at plan time,
|
|
76
|
+
before anything is deleted.
|
|
77
|
+
|
|
78
|
+
### An `ha` change under `retain`
|
|
79
|
+
|
|
80
|
+
`retain` also skips the old node's delete inside a replace. So a same-name `ha` change keeps the old
|
|
81
|
+
node, which still holds the name, and the create **refuses** with a sentence and writes nothing. It
|
|
82
|
+
never quietly reuses the old node, because that node still has the old `ha`. Measured through
|
|
83
|
+
Alchemy's own plan/apply in `mesh-node-policy.test.ts`:
|
|
84
|
+
|
|
85
|
+
| you want | do |
|
|
86
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
87
|
+
| the replace | Deploy once with `.pipe(RemovalPolicy.destroy())`: delete, then create. Then drop it. |
|
|
88
|
+
| the replace, by hand | Delete the old node yourself, then deploy again. |
|
|
89
|
+
| to keep the old node | `alchemy state rm <stack>/<stage>/<fqn>` (state only, not the node), then deploy the old declaration + `adopt(true)`. |
|
|
90
|
+
|
|
91
|
+
⚠️ **Reverting `ha` is not enough.** The refused deploy leaves a `replacing` row, and the next
|
|
92
|
+
plan resumes that replace whatever the props say, so it refuses again.
|
|
93
|
+
|
|
94
|
+
⚠️ **`adopt(true)` alone does nothing to a `replacing` row.** The plan asks `read` to adopt only
|
|
95
|
+
when a resource has no row, or after an interrupted first create. So every way back from inside a
|
|
96
|
+
replace starts with `alchemy state rm`.
|
|
97
|
+
|
|
98
|
+
⛔ **The old-node advice holds only when the name stayed.** After an `ha` + `name` change (a
|
|
99
|
+
create-first replace), a node already holding the new name is another node, not the old one.
|
|
100
|
+
`RemovalPolicy.destroy()` never touches it, and the refusal says not to delete it: choose another
|
|
101
|
+
name, or check its HA badge, drop the row and adopt it.
|
|
102
|
+
|
|
103
|
+
⚠️ Never deploy with `DISTILLED_DEBUG_HTTP` set: distilled prints the start of every response,
|
|
104
|
+
and the create response carries the node's `token` field (Cloudflare's HA page says so).
|
|
105
|
+
|
|
106
|
+
⚠️ Cloudflare may refuse to delete a node while replicas are connected. This is noted in Alchemy's
|
|
107
|
+
WarpConnector and was not measured here. Stop the client on every replica before a destroy or an
|
|
108
|
+
`ha` replace.
|
|
109
|
+
|
|
110
|
+
## Adoption
|
|
111
|
+
|
|
112
|
+
An existing node is found by **exact** name and returned `Unowned`. Alchemy refuses to take it
|
|
113
|
+
over until the resource is wrapped in `adopt(true)` (or `--adopt` is passed). A deleted node is
|
|
114
|
+
never matched, and a name that matches two live nodes is refused.
|
|
115
|
+
|
|
116
|
+
An interrupted create (the node was made, its state was not saved) is found the same way on the
|
|
117
|
+
next plan, so it too needs `adopt(true)`. ⚠️ distilled retries a create after a lost response or a
|
|
118
|
+
5xx, and the retry is answered code 1013 by the node the first attempt made: the error then names
|
|
119
|
+
that node's id. It is never adopted silently, because a concurrent creator looks the same. Inside
|
|
120
|
+
a replace, drop the row before `adopt(true)`, as above.
|
|
121
|
+
|
|
122
|
+
⚠️ **Adoption records the declared `ha`.** No documented read returns a node's HA flag:
|
|
123
|
+
`GET /warp_connector/{id}` has no such field. Check the node's **HA** badge in the dashboard
|
|
124
|
+
before adopting. A wrong value cannot be detected by any later plan.
|
|
125
|
+
|
|
126
|
+
## Enrolling a host: `fetchMeshNodeToken`
|
|
127
|
+
|
|
128
|
+
The token is a runtime credential. It never goes into Alchemy state or the vault; it goes straight
|
|
129
|
+
to the host that runs the node. A one-off Bun `mesh-enroll` step:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { fromApiToken } from '@distilled.cloud/cloudflare/Credentials';
|
|
133
|
+
import { fetchMeshNodeToken } from '@homeflare/alchemy/cloudflare';
|
|
134
|
+
import * as Effect from 'effect/Effect';
|
|
135
|
+
import * as Layer from 'effect/Layer';
|
|
136
|
+
import * as Redacted from 'effect/Redacted';
|
|
137
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
138
|
+
import { open } from 'node:fs/promises';
|
|
139
|
+
|
|
140
|
+
const token = await Effect.runPromise(
|
|
141
|
+
fetchMeshNodeToken({ accountId, id: nodeId }).pipe(
|
|
142
|
+
Effect.provide(Layer.mergeAll(fromApiToken({ apiToken }), FetchHttpClient.layer)),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
// ⛔ Created 0600 from the start, as root: no window where a chmod has not happened yet.
|
|
146
|
+
const file = await open('/root/mesh-node.token', 'wx', 0o600);
|
|
147
|
+
await file.writeFile(Redacted.value(token));
|
|
148
|
+
await file.close();
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- `{ accountId, name }` works too; the name is matched exactly.
|
|
152
|
+
- The value comes back `Redacted`: `console.log`, `Effect.log` and `JSON.stringify` show
|
|
153
|
+
`<redacted>`. Call `Redacted.value` only at the write.
|
|
154
|
+
- ⛔ **`fromApiToken`, not `Credentials.fromEnv()`.** `fromEnv` falls back to the Global API Key
|
|
155
|
+
(`CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) when no token is set.
|
|
156
|
+
- ⚠️ **The endpoint needs a Write permission.** Mint a short-lived token for this step alone. A
|
|
157
|
+
read token gets a 403, and the error names the permission.
|
|
158
|
+
- ⚠️ `warp-cli --accept-tos connector new <token>` (Mesh get-started page) takes the token as an
|
|
159
|
+
**argument**, so it is visible in the host's process list while that command runs. Run it as
|
|
160
|
+
root on the node itself, in a shell that does not keep history.
|
|
161
|
+
- An empty token fails closed. An empty file would otherwise "enrol" a host that never joined.
|
|
162
|
+
- ⛔ **Refused before any request:** `DISTILLED_DEBUG_HTTP` set (distilled then prints the first
|
|
163
|
+
400 characters of every response body to stderr, which is the whole token), the Global API Key
|
|
164
|
+
(`apiKey` credentials), and an empty API token (a denied secrets grant, not a permission).
|
|
165
|
+
|
|
166
|
+
## Permissions
|
|
167
|
+
|
|
168
|
+
| step | needs (any one, per the API reference) |
|
|
169
|
+
| ----------------------- | ------------------------------------------------------------------------ |
|
|
170
|
+
| plan / read (get, list) | Cloudflare One Connectors Read, or Cloudflare One Connector: WARP Read |
|
|
171
|
+
| create, rename, delete | Cloudflare One Connectors Write, or Cloudflare One Connector: WARP Write |
|
|
172
|
+
| `fetchMeshNodeToken` | Cloudflare One Connectors Write (see below) |
|
|
173
|
+
|
|
174
|
+
The token endpoint's API reference lists Connectors Write, "Cloudflare One Connector: cloudflared
|
|
175
|
+
Write" and "Cloudflare Tunnel Write"; the Mesh get-started guide lists Connectors Write and
|
|
176
|
+
"Cloudflare One Connector: WARP Write". Connectors Write is the one both name.
|
|
177
|
+
|
|
178
|
+
## Not covered here
|
|
179
|
+
|
|
180
|
+
- **Routes, device profiles and Gateway rules:** use Alchemy's `Tunnel.Route`,
|
|
181
|
+
`Devices.CustomProfile` and `Gateway.Rule`. ⚠️ HA needs MASQUE on the node's device profile.
|
|
182
|
+
- **The HA provider configuration** (`/warp_connector/{id}/configurations`: `ha_mode` of `none`,
|
|
183
|
+
`disabled`, `aws` or `local` with VIPs) and **manual failover**. Its semantics for a node
|
|
184
|
+
created without `ha` are undocumented, so this resource neither reads nor writes it.
|
|
185
|
+
- **Mesh IPs.** They belong to each replica (the `/connections` endpoint), not to the node, so
|
|
186
|
+
they are not attributes. Pin them in site config and check them against the API.
|
|
187
|
+
- **`alchemy unsafe nuke`.** `list` is empty and nuke skips this type, because Alchemy's own
|
|
188
|
+
WarpConnector provider already lists every `warp_connector` in the account.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo and
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
7
7
|
"license": "MIT",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
+
"docs",
|
|
16
17
|
"src",
|
|
17
18
|
"README.md",
|
|
18
19
|
"LICENSE"
|
|
@@ -44,12 +45,21 @@
|
|
|
44
45
|
"types": "./dist/proxmox/index.d.ts",
|
|
45
46
|
"default": "./dist/proxmox/index.js"
|
|
46
47
|
},
|
|
48
|
+
"./launchd": {
|
|
49
|
+
"types": "./dist/launchd/index.d.ts",
|
|
50
|
+
"default": "./dist/launchd/index.js"
|
|
51
|
+
},
|
|
52
|
+
"./caddy": {
|
|
53
|
+
"types": "./dist/caddy/index.d.ts",
|
|
54
|
+
"default": "./dist/caddy/index.js"
|
|
55
|
+
},
|
|
47
56
|
"./package.json": "./package.json"
|
|
48
57
|
},
|
|
49
58
|
"publishConfig": {
|
|
50
59
|
"access": "public"
|
|
51
60
|
},
|
|
52
61
|
"peerDependencies": {
|
|
62
|
+
"@distilled.cloud/cloudflare": "1.0.0-rc.12",
|
|
53
63
|
"@effect/platform-node": "4.0.0-rc.115",
|
|
54
64
|
"alchemy": "2.0.0-beta.79",
|
|
55
65
|
"cloudflare": ">=4.5.0",
|