@homeflare/alchemy 0.6.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.
Files changed (120) hide show
  1. package/README.md +54 -10
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/index.js.map +2 -2
  53. package/dist/launchd/secret-tripwire.d.ts +1 -0
  54. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  55. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  56. package/dist/openbao/fake-engines.d.ts +30 -0
  57. package/dist/openbao/fake-engines.d.ts.map +1 -0
  58. package/dist/openbao/fake-stack.d.ts +29 -0
  59. package/dist/openbao/fake-stack.d.ts.map +1 -0
  60. package/dist/openbao/index.js +231 -151
  61. package/dist/openbao/index.js.map +9 -7
  62. package/dist/openbao/policy.d.ts +2 -0
  63. package/dist/openbao/policy.d.ts.map +1 -1
  64. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  65. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  67. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename.d.ts +106 -0
  70. package/dist/openbao/rename.d.ts.map +1 -0
  71. package/docs/caddy.md +180 -0
  72. package/docs/mesh-node.md +188 -0
  73. package/package.json +7 -2
  74. package/src/caddy/admin-calls.ts +128 -0
  75. package/src/caddy/admin-guard.ts +170 -0
  76. package/src/caddy/admin.ts +74 -0
  77. package/src/caddy/caddyfile-tripwire.ts +133 -0
  78. package/src/caddy/config-form.ts +64 -0
  79. package/src/caddy/config-lifecycle.test.ts +218 -0
  80. package/src/caddy/config-lifecycle.ts +148 -0
  81. package/src/caddy/config.ts +116 -0
  82. package/src/caddy/digest.ts +44 -0
  83. package/src/caddy/fake-caddy.ts +160 -0
  84. package/src/caddy/guards.test.ts +156 -0
  85. package/src/caddy/index.ts +25 -0
  86. package/src/caddy/local-admin.test.ts +160 -0
  87. package/src/caddy/local-admin.ts +189 -0
  88. package/src/caddy/providers.test.ts +244 -0
  89. package/src/caddy/providers.ts +17 -0
  90. package/src/caddy/with-file.ts +62 -0
  91. package/src/cloudflare/fake-mesh.ts +181 -0
  92. package/src/cloudflare/index.ts +9 -3
  93. package/src/cloudflare/mesh-node-api.ts +127 -0
  94. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  95. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  96. package/src/cloudflare/mesh-node-form.ts +115 -0
  97. package/src/cloudflare/mesh-node-harness.ts +124 -0
  98. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  99. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  100. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  101. package/src/cloudflare/mesh-node-token.ts +125 -0
  102. package/src/cloudflare/mesh-node.test.ts +172 -0
  103. package/src/cloudflare/mesh-node.ts +120 -0
  104. package/src/cloudflare/providers.ts +20 -2
  105. package/src/index.ts +1 -0
  106. package/src/launchd/secret-tripwire.ts +1 -1
  107. package/src/openbao/REPLACE.md +97 -21
  108. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  109. package/src/openbao/cloudflare-role.ts +30 -8
  110. package/src/openbao/fake-engines.ts +119 -0
  111. package/src/openbao/fake-stack.ts +112 -0
  112. package/src/openbao/policy-rename.test.ts +112 -0
  113. package/src/openbao/policy-wire.test.ts +7 -26
  114. package/src/openbao/policy.ts +27 -4
  115. package/src/openbao/proxmox-role-form.ts +27 -8
  116. package/src/openbao/proxmox-role-wire.ts +24 -0
  117. package/src/openbao/proxmox-role.ts +62 -37
  118. package/src/openbao/rename-occupied.test.ts +166 -0
  119. package/src/openbao/rename.ts +162 -0
  120. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The three admin API calls the provider makes, each classified into a value or a readable Error
3
+ * that carries Caddy's own message.
4
+ *
5
+ * Read in caddyserver/caddy v2.11.4 (and the API docs, caddyserver.com/docs/api):
6
+ * · `POST /adapt` — adapts without running (load.go handleAdapt): 200 `{warnings?, result}`,
7
+ * or 400 `{"error": …}`. Side-effect free, so it is what plan-time validation uses.
8
+ * · `POST /load` — Content-Type `text/caddyfile` picks the adapter (load.go adaptByContentType);
9
+ * "if the new config fails for any reason, the old config is rolled back into place without
10
+ * downtime" (docs; caddy.go changeConfig restores the previous raw config). An identical
11
+ * config is a no-op without `Cache-Control: must-revalidate`.
12
+ * · `GET /config/` — the running config as JSON, `null` when empty (admin.go handleConfig).
13
+ * ⛔ A FAILED LOAD CAN ANSWER 200. load.go writes the adapter's WARNINGS to the body before it
14
+ * runs the config; if the run then fails, the error JSON is appended to a response whose status
15
+ * is already 200 (Go's first Write sends 200; the later WriteHeader(400) is ignored). The
16
+ * Caddyfile adapter warns on every unformatted input (adapter.go FormattingDifference), so this
17
+ * is the COMMON failure shape, not an edge. Hence: look for the error in the body whatever the
18
+ * status — and the lifecycle reads the config back after every load anyway.
19
+ * MEASURED 2026-09-21 on a throwaway Caddy 2.11.4 (loopback ports, isolated XDG dirs): an
20
+ * unformatted Caddyfile whose port was taken answered `200` with body
21
+ * `[{"file":"Caddyfile","line":8,"message":"Caddyfile input is not formatted; …"}]{"error":"loading
22
+ * config: … bind: address already in use"}`, and Caddy kept serving the previous config.
23
+ */
24
+ import type { CaddyAdmin, CaddyAdminResponse } from './admin.ts';
25
+ import { parseConfig } from './digest.ts';
26
+
27
+ export class CaddyAdminError extends Error {
28
+ constructor(
29
+ readonly status: number,
30
+ readonly method: string,
31
+ readonly path: string,
32
+ reason: string,
33
+ ) {
34
+ super(`Caddy ${method} ${path} → ${String(status)}: ${reason}`);
35
+ this.name = 'CaddyAdminError';
36
+ }
37
+ }
38
+
39
+ const CADDYFILE = 'text/caddyfile';
40
+ /** APIError marshals as exactly this key (admin.go APIError, `json:"error"`). */
41
+ const ERROR_OPEN = '{"error":';
42
+
43
+ type Warning = { file?: string; line?: number; message?: string };
44
+
45
+ const describe = (warning: Warning): string =>
46
+ `${warning.file ?? 'Caddyfile'}:${String(warning.line ?? 0)}: ${warning.message ?? '(no message)'}`;
47
+
48
+ /** Caddy's `error` string from a body that holds one, or `undefined`. */
49
+ const errorIn = (body: string): string | undefined => {
50
+ const at = body.lastIndexOf(ERROR_OPEN);
51
+ if (at === -1) return undefined;
52
+ try {
53
+ const parsed = JSON.parse(body.slice(at)) as { error?: unknown };
54
+ return typeof parsed.error === 'string' ? parsed.error : undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ };
59
+
60
+ const warningsIn = (text: string): string[] => {
61
+ if (text.trim() === '') return [];
62
+ try {
63
+ const parsed = JSON.parse(text) as unknown;
64
+ return Array.isArray(parsed) ? (parsed as Warning[]).map(describe) : [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ };
69
+
70
+ const fail = (response: CaddyAdminResponse, method: string, path: string): never => {
71
+ const reason = errorIn(response.body) ?? (response.body.trim().slice(0, 500) || '(empty body)');
72
+ // ⚠️ Say so when the status lied (the ⛔ above), or "→ 200" reads like success.
73
+ const shown = response.status === 200 ? `${reason} (error in a 200 body)` : reason;
74
+ throw new CaddyAdminError(response.status, method, path, shown);
75
+ };
76
+
77
+ export type Adapted = { readonly config: unknown; readonly warnings: readonly string[] };
78
+
79
+ /** Adapt a Caddyfile to JSON on the running Caddy, without loading it. */
80
+ export const adaptCaddyfile = async (admin: CaddyAdmin, caddyfile: string): Promise<Adapted> => {
81
+ const response = await admin.request({
82
+ body: caddyfile,
83
+ headers: { 'Content-Type': CADDYFILE },
84
+ method: 'POST',
85
+ path: '/adapt',
86
+ });
87
+ // ★ Status alone here: /adapt writes its body once, and a successful result is config JSON in
88
+ // which an `error` handler's key could look like an error object to a substring search.
89
+ if (response.status !== 200) fail(response, 'POST', '/adapt');
90
+ const parsed = JSON.parse(response.body) as { result?: unknown; warnings?: Warning[] };
91
+ return { config: parsed.result ?? null, warnings: (parsed.warnings ?? []).map(describe) };
92
+ };
93
+
94
+ /** The running config, parsed; `null` when Caddy runs with none. */
95
+ export const readRunningConfig = async (admin: CaddyAdmin): Promise<unknown> => {
96
+ const response = await admin.request({ method: 'GET', path: '/config/' });
97
+ if (response.status !== 200) fail(response, 'GET', '/config/');
98
+ return parseConfig(response.body);
99
+ };
100
+
101
+ /**
102
+ * Apply a Caddyfile with `POST /load`; resolves with the adapter's warnings.
103
+ *
104
+ * ★ `sourceFile` RIDES AS `Caddy-Config-Source-File` (+ `-Adapter: caddyfile`), the headers
105
+ * `caddy reload` sends (cmd/commandfuncs.go). A load WITHOUT them makes Caddy forget the file it
106
+ * was started with (caddy.go ClearLastConfigIfDifferent), and SIGUSR1 then no longer reloads
107
+ * from that file. With them, and the same path, it keeps it.
108
+ */
109
+ export const loadCaddyfile = async (
110
+ admin: CaddyAdmin,
111
+ caddyfile: string,
112
+ sourceFile?: string,
113
+ ): Promise<readonly string[]> => {
114
+ const response = await admin.request({
115
+ body: caddyfile,
116
+ headers: {
117
+ 'Content-Type': CADDYFILE,
118
+ ...(sourceFile === undefined
119
+ ? {}
120
+ : { 'Caddy-Config-Source-Adapter': 'caddyfile', 'Caddy-Config-Source-File': sourceFile }),
121
+ },
122
+ method: 'POST',
123
+ path: '/load',
124
+ });
125
+ const refused = errorIn(response.body);
126
+ if (response.status !== 200 || refused !== undefined) fail(response, 'POST', '/load');
127
+ return warningsIn(response.body);
128
+ };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Refuse a config whose `admin` block would cut the provider off from Caddy, or expose the admin
3
+ * API — checked on the ADAPTED JSON (`POST /adapt`), before anything is loaded.
4
+ *
5
+ * ⛔ A Caddyfile can move its own admin endpoint. After `POST /load`, Caddy swaps the admin server
6
+ * to the new config's (admin.go replaceLocalAdminServer), so each of these would succeed ONCE and
7
+ * then strand the resource — no read-back, no drift check, no next deploy — or worse:
8
+ * · `admin off` (`disabled`): nothing can manage this Caddy through its API again.
9
+ * · a `listen` off loopback (`:2019`, `0.0.0.0:2019`, a LAN address): the unauthenticated API is
10
+ * on the network. Decision 23: never expose :2019.
11
+ * · a `listen` on another port, socket or loopback ADDRESS than the transport's: the provider
12
+ * loses Caddy. Go binds exactly the literal address given (`[::1]:2019` is not reachable at
13
+ * `127.0.0.1:2019`), and a hostname binds its first IPv4 address (net ListenConfig.Listen,
14
+ * `addrs.first(isIPv4)`), so `localhost:2019` is `127.0.0.1:2019`. MEASURED 2026-09-21 on a
15
+ * throwaway Caddy 2.11.4: after loading `admin [::1]:<p>`, `127.0.0.1:<p>` refused the
16
+ * connection; after `admin localhost:<p>`, `[::1]:<p>` did.
17
+ * · NO `admin` address at all: Caddy falls back to DefaultAdminListen — `localhost:2019`, or
18
+ * `$CADDY_ADMIN` in ITS environment (admin.go replaceLocalAdminServer), which this side cannot
19
+ * see. A Caddy reached any other way (a socket, another port) moves there on the first load.
20
+ * Refused unless the transport is at that default; declaring `admin <address>` is the fix.
21
+ * · a Host the new config does not allow: `origins` without it, or — with no `origins` — a Host
22
+ * outside Caddy's loopback defaults (`localhost`, `[::1]`, `127.0.0.1` at the listen port).
23
+ * Every later call answers 403 (admin.go allowedOrigins / checkHost; MEASURED on the mini's
24
+ * Caddy 2.11.4 — `host not allowed`). The transport also sends `Origin: http://<Host>`, which
25
+ * Caddy checks against the same list (originAllowed), so one check covers both.
26
+ * · `enforce_origin` over a unix socket: this transport, like Caddy's CLI (cmd/commandfuncs.go
27
+ * AdminAPIRequest), sends no Origin there, and a unix listener has no default origins — so
28
+ * checkOrigin answers 403 to every call. MEASURED 2026-09-21 on a throwaway Caddy 2.11.4:
29
+ * `client is not allowed to access from origin ''`, and that Caddy could not be managed again.
30
+ * · `remote`: a second, network-facing admin listener (mTLS on :2021) — declare that deliberately,
31
+ * not through this resource.
32
+ * · `config.load`: Caddy pulls its config from elsewhere, replacing this one on a timer.
33
+ * ★ Read in caddyserver/caddy v2.11.4: admin.go AdminConfig / allowedOrigins / originAllowed, and
34
+ * httpcaddyfile/options.go parseOptAdmin (an `admin { … }` block with no address still emits
35
+ * `listen` as the default, so a present `listen` is the norm, not a change).
36
+ */
37
+ import type { CaddyAdminListener } from './admin.ts';
38
+
39
+ type AdminBlock = {
40
+ disabled?: boolean;
41
+ listen?: string;
42
+ origins?: string[];
43
+ enforce_origin?: boolean;
44
+ remote?: unknown;
45
+ config?: { load?: unknown };
46
+ };
47
+
48
+ /** Caddy's compiled-in admin port (admin.go DefaultAdminListen `localhost:2019`). */
49
+ const DEFAULT_ADMIN_PORT = 2019;
50
+
51
+ const LOOPBACK_HOST = /^(?:localhost|127(?:\.\d{1,3}){3}|\[?::1\]?)$/;
52
+
53
+ type Listen = { kind: 'tcp'; host: string; port: number } | { kind: 'unix'; path: string };
54
+
55
+ /** Caddy network address → a listener, or a description of why it is not one we can check. */
56
+ const parseListen = (listen: string): Listen | string => {
57
+ if (listen.includes('{')) return 'a placeholder, which cannot be checked before it runs';
58
+ const unix = /^unix(?:gram|packet)?\/(.+?)(?:\|[0-7]{3,4})?$/.exec(listen);
59
+ if (unix !== null) return { kind: 'unix', path: unix[1] ?? '' };
60
+ if (/^[a-z0-9]+\//.test(listen) && !/^tcp[46]?\//.test(listen)) {
61
+ return `network ${JSON.stringify(listen.split('/')[0])}, not tcp or unix`;
62
+ }
63
+ const address = listen.replace(/^tcp[46]?\//, '');
64
+ const colon = address.lastIndexOf(':');
65
+ const port = Number(address.slice(colon + 1));
66
+ if (colon === -1 || !Number.isInteger(port) || port <= 0) return 'no single port';
67
+ return { host: address.slice(0, colon), kind: 'tcp', port };
68
+ };
69
+
70
+ const unbracket = (host: string): string => host.replace(/^\[(.*)\]$/, '$1');
71
+
72
+ /**
73
+ * Whether a transport sending `hostHeader` reaches a listener bound to `listenHost`.
74
+ * ★ The Host stands in for the dial address: they are the same unless `hostHeader` was set for a
75
+ * forward, where it should name the far side's address anyway. `localhost` on the transport's
76
+ * side may resolve to either family (node tries both), so it reaches any loopback listener; a
77
+ * literal address reaches only itself.
78
+ */
79
+ const reaches = (hostHeader: string, listenHost: string): boolean => {
80
+ const dialled = unbracket(hostHeader.slice(0, hostHeader.lastIndexOf(':')));
81
+ const listening = unbracket(listenHost);
82
+ return (
83
+ dialled === 'localhost' || dialled === (listening === 'localhost' ? '127.0.0.1' : listening)
84
+ );
85
+ };
86
+
87
+ const sameListener = (listen: Listen, transport: CaddyAdminListener): boolean =>
88
+ listen.kind === 'tcp'
89
+ ? transport.kind === 'tcp' &&
90
+ listen.port === transport.port &&
91
+ reaches(transport.hostHeader, listen.host)
92
+ : transport.kind === 'unix' && listen.path === transport.path;
93
+
94
+ /** Caddy's allowed Hosts for a loopback listener with no `origins` (admin.go allowedOrigins). */
95
+ const defaultOrigins = (port: number): string[] =>
96
+ ['localhost', '[::1]', '127.0.0.1'].map((host) => `${host}:${String(port)}`);
97
+
98
+ const originHost = (origin: string): string | undefined => {
99
+ if (!origin.includes('://')) return origin;
100
+ try {
101
+ const url = new URL(origin);
102
+ // ⚠️ originAllowed compares schemes when an entry has one; the transport sends `http://`.
103
+ return url.protocol === 'http:' ? url.host : undefined;
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ };
108
+
109
+ const listenProblem = (admin: AdminBlock, transport: CaddyAdminListener): string | undefined => {
110
+ if (admin.listen === undefined) {
111
+ const atDefault =
112
+ transport.kind === 'tcp' &&
113
+ transport.port === DEFAULT_ADMIN_PORT &&
114
+ reaches(transport.hostHeader, 'localhost');
115
+ return atDefault
116
+ ? undefined
117
+ : 'no `admin` address: Caddy would fall back to its default listener (localhost:2019, or ' +
118
+ '$CADDY_ADMIN), away from where this provider reaches it — declare `admin <address>`';
119
+ }
120
+ const listen = parseListen(admin.listen);
121
+ const shown = JSON.stringify(admin.listen);
122
+ if (typeof listen === 'string') {
123
+ return `admin listen ${shown} is ${listen}; declare a literal loopback address`;
124
+ }
125
+ if (listen.kind === 'tcp' && !LOOPBACK_HOST.test(listen.host)) {
126
+ return `admin listen ${shown} is not loopback — it would put the admin API on the network`;
127
+ }
128
+ return sameListener(listen, transport)
129
+ ? undefined
130
+ : `admin listen ${shown} moves the admin API away from where this provider reaches it`;
131
+ };
132
+
133
+ /** Refusals for the adapted config's `admin` block, given where the transport reaches Caddy. */
134
+ export const adminProblems = (adapted: unknown, transport: CaddyAdminListener): string[] => {
135
+ const admin =
136
+ typeof adapted === 'object' && adapted !== null
137
+ ? ((adapted as { admin?: AdminBlock }).admin ?? {})
138
+ : {};
139
+ const found: string[] = [];
140
+ if (admin.disabled === true) {
141
+ return ['`admin off` would disable the admin API this resource manages Caddy through'];
142
+ }
143
+ if (admin.remote !== undefined) found.push('`admin.remote` opens a network-facing admin API');
144
+ if (admin.config?.load !== undefined) {
145
+ found.push(
146
+ '`admin.config.load` makes Caddy pull its config from elsewhere, replacing this one',
147
+ );
148
+ }
149
+ const moved = listenProblem(admin, transport);
150
+ if (moved !== undefined) found.push(moved);
151
+ if (transport.kind === 'tcp') {
152
+ const allowed = admin.origins?.map(originHost) ?? defaultOrigins(transport.port);
153
+ if (!allowed.includes(transport.hostHeader)) {
154
+ const which =
155
+ admin.origins === undefined
156
+ ? `Caddy's default origins (no \`origins\` declared) ${JSON.stringify(allowed)}`
157
+ : `admin origins ${JSON.stringify(admin.origins)}`;
158
+ found.push(
159
+ `${which} do not allow Host ${transport.hostHeader}, which this provider sends — ` +
160
+ 'Caddy would refuse it every call after this one',
161
+ );
162
+ }
163
+ } else if (admin.enforce_origin === true) {
164
+ found.push(
165
+ '`enforce_origin` over a unix socket: this transport (like the Caddy CLI) sends no Origin ' +
166
+ 'there, so Caddy would answer 403 to every call',
167
+ );
168
+ }
169
+ return found;
170
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The one seam between the Caddy provider and a running Caddy: every call to Caddy's admin API goes
3
+ * through a CaddyAdmin, and nothing in this directory reaches Caddy any other way.
4
+ *
5
+ * ★ CADDY's OWN MANAGEMENT API, NOT A FILE AND A SIGNAL. Decision 23 (vault consolidation plan,
6
+ * 2026-09-21): `POST /load` applies a Caddyfile with a graceful reload and rolls back a config
7
+ * Caddy refuses; `GET /config/` reports what is running. Read in caddyserver/caddy v2.11.4,
8
+ * caddyconfig/load.go and admin.go, and in the API docs (caddyserver.com/docs/api).
9
+ * ★ A SEAM, THE SAME SHAPE AS launchd's HostRunner, so the lifecycle runs against a fake admin
10
+ * server in tests and a consumer can plug in another route to the API (an SSH-forwarded socket
11
+ * today; a remote runner later) without touching the provider. PROMISES, NOT EFFECTS, for the
12
+ * same reason as HostRunner: a consumer writing their own implements plain async functions.
13
+ * ⛔ THE ADMIN API HAS NO AUTHENTICATION. Anyone who reaches it can replace every site. It stays on
14
+ * loopback or a unix socket; localCaddyAdmin() refuses anything else (local-admin.ts).
15
+ */
16
+ import * as Context from 'effect/Context';
17
+ import * as Layer from 'effect/Layer';
18
+
19
+ /** One admin API exchange. ⛔ `body` may hold the running config — never log it whole. */
20
+ export type CaddyAdminResponse = {
21
+ readonly status: number;
22
+ readonly body: string;
23
+ };
24
+
25
+ export type CaddyAdminRequest = {
26
+ readonly method: 'GET' | 'POST';
27
+ /** Absolute API path: `/load`, `/adapt`, `/config/`. */
28
+ readonly path: string;
29
+ readonly headers?: Readonly<Record<string, string>>;
30
+ readonly body?: string;
31
+ };
32
+
33
+ /**
34
+ * Where the admin listener is, in the terms Caddy's own `admin` option uses — so a Caddyfile that
35
+ * would move the listener away from where the provider talks to it can be refused (admin-guard.ts).
36
+ */
37
+ export type CaddyAdminListener =
38
+ | { readonly kind: 'tcp'; readonly hostHeader: string; readonly port: number }
39
+ | { readonly kind: 'unix'; readonly path: string };
40
+
41
+ export interface CaddyAdmin {
42
+ /** Where requests go, for error messages and the resource's attributes. Never a credential. */
43
+ readonly endpoint: string;
44
+ /**
45
+ * The listener as Caddy sees it. `hostHeader` is the Host every request carries: Caddy checks it
46
+ * against `admin.origins` (admin.go checkHost) on any loopback TCP listener.
47
+ */
48
+ readonly listener: CaddyAdminListener;
49
+ /**
50
+ * Send one request. Resolves with ANY status — classifying it is the caller's job
51
+ * (admin-calls.ts). Rejects only when there was no HTTP exchange at all: with a
52
+ * CaddyUnreachableError when nothing accepted the connection, any other Error otherwise.
53
+ */
54
+ request(request: CaddyAdminRequest): Promise<CaddyAdminResponse>;
55
+ }
56
+
57
+ /**
58
+ * Nothing accepted the connection — Caddy is not running, or not listening yet. A transport rejects
59
+ * with THIS only when the request provably never reached Caddy (refused, no socket file), because
60
+ * the provider treats it differently from every other failure: a stopped Caddy must not fail the
61
+ * PLAN that would bring it back (its launchd job in the same stack) — see config.ts.
62
+ */
63
+ export class CaddyUnreachableError extends Error {
64
+ override readonly name = 'CaddyUnreachableError';
65
+ }
66
+
67
+ /** The Effect service the Caddy provider reads its admin transport from. */
68
+ export class CaddyAdminService extends Context.Service<CaddyAdminService, CaddyAdmin>()(
69
+ 'homeflare/caddy/CaddyAdmin',
70
+ ) {}
71
+
72
+ /** Provide a transport to the provider: `Layer.provide(caddyAdminLayer(localCaddyAdmin()))`. */
73
+ export const caddyAdminLayer = (admin: CaddyAdmin): Layer.Layer<CaddyAdminService> =>
74
+ Layer.succeed(CaddyAdminService, admin);
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A tripwire for the obvious ways a secret ends up in a CaddyConfig's `caddyfile` prop.
3
+ *
4
+ * ⛔ WHY IT EXISTS. Alchemy stores every prop UNENCRYPTED in its state store and prints it in plan
5
+ * diffs (the reason launchd's secret-tripwire.ts and the metadata-only OpenBao families exist).
6
+ * The whole Caddyfile is one prop, so a token typed into it is a token in state.
7
+ * ★ THE ALTERNATIVE, NAMED IN EVERY REFUSAL: a RUNTIME placeholder, which Caddy resolves in its own
8
+ * process and which therefore never enters the Caddyfile, the state, the adapted JSON or
9
+ * `autosave.json` (caddyserver/caddy v2.11.4 replacer.go):
10
+ * · `{env.NAME}` — the Caddy process's environment; start Caddy with `--envfile <path>` where a
11
+ * secret renderer (openbao-agent) writes that file. A rotated value needs a Caddy restart.
12
+ * · `{file./path}` — the file's contents, trailing newline trimmed, read when the placeholder is
13
+ * evaluated; the renderer writes the file.
14
+ * ⚠️ `{$NAME}` IS NOT ONE OF THEM. It is substituted at ADAPT time (caddyfile/parse.go
15
+ * replaceEnvVars), so the value lands in the adapted JSON — readable at `GET /config/` and
16
+ * written to `autosave.json`. It is not refused (the prop holds only the name), but it is worse.
17
+ * `{$NAME:default}` is different: the default IS in the prop, and is checked as a literal.
18
+ * ⚠️ A TRIPWIRE, NOT A SCANNER. It catches the names and shapes people actually type; a secret in
19
+ * an innocent-looking argument passes. Nothing here makes a secret safe to declare.
20
+ */
21
+ import { PRIVATE_KEY } from '../launchd/secret-tripwire.ts';
22
+
23
+ const ALTERNATIVE =
24
+ 'props are stored unencrypted in Alchemy state. Use a runtime placeholder instead: {env.NAME} ' +
25
+ '(Caddy started with --envfile <file a secret renderer writes>) or {file./path/to/secret}.';
26
+
27
+ /** A subdirective whose whole name says "my argument is the secret". `*_file` never matches. */
28
+ const SECRET_WORD =
29
+ /^(?:token|api_token|auth_token|access_token|secret|client_secret|shared_secret|secret_key|password|passwd|passphrase|api_key|apikey|private_key|mac_key|hmac_key)$/i;
30
+
31
+ /** Directives whose second argument is a DNS provider's credential: `dns cloudflare <token>`. */
32
+ const DNS_DIRECTIVE = /^(?:dns|acme_dns)$/;
33
+
34
+ const HEADER_DIRECTIVE = /^(?:header|header_up|header_down|request_header)$/;
35
+ const CREDENTIAL_HEADER =
36
+ /^[+-]?(?:authorization|proxy-authorization|cookie|x-api-key|api-key|x-auth-token|x-vault-token)$/i;
37
+
38
+ /** Shapes that are a credential wherever they appear. The label is what the refusal says. */
39
+ const SHAPES: readonly (readonly [RegExp, string])[] = [
40
+ [/^(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,})$/, 'a GitHub token'],
41
+ [/^(?:AKIA|ASIA)[0-9A-Z]{16}$/, 'an AWS access key id'],
42
+ [/^xox[abposr]-[A-Za-z0-9-]{10,}$/, 'a Slack token'],
43
+ [/^sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}$/, 'an API secret key'],
44
+ [/^eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/, 'a JWT'],
45
+ // ★ basic_auth replaces placeholders in the hash at provision time (caddyauth/basicauth.go
46
+ // Provision), so `{env.ADMIN_HASH}` works and a literal hash never needs to be declared.
47
+ [/^\$2[abxy]?\$\d{2}\$[./A-Za-z0-9]{53}$/, 'a bcrypt password hash'],
48
+ [/^\$argon2(?:id|i|d)\$/, 'an argon2 password hash'],
49
+ ];
50
+
51
+ const PLACEHOLDER = /\{[^{}\s]+\}/g;
52
+ const AUTH_SCHEME = /^(?:bearer|basic|token|digest)$/i;
53
+
54
+ /**
55
+ * ⛔ `{$NAME:default}` IS A LITERAL. Caddy splices the default in at ADAPT time whenever NAME is
56
+ * unset (caddyfile/parse.go replaceEnvVars: SplitN on `:`), so a secret typed as the default is
57
+ * in the prop, the state and the adapted JSON. MEASURED 2026-09-21 on a throwaway Caddy 2.11.4:
58
+ * `respond "{$UNSET:literal-default}"` adapted to the body `literal-default`. So each line is
59
+ * checked as Caddy sees it with every such variable unset. The RUNTIME replacer has no default
60
+ * syntax (replacer.go looks the whole key up), so `{env.X}` stays the safe form.
61
+ */
62
+ const ENV_DEFAULT = /\{\$[^{}\s:]*:([^}]*)\}/g;
63
+ const withDefaults = (line: string): string => line.replace(ENV_DEFAULT, '$1');
64
+
65
+ /** One line's tokens: whitespace-separated, "double-quoted" and `backquoted` kept whole, # comments dropped. */
66
+ export const tokensOf = (line: string): string[] => {
67
+ const tokens: string[] = [];
68
+ let current = '';
69
+ let quote: string | undefined;
70
+ for (let index = 0; index < line.length; index += 1) {
71
+ const char = line[index] ?? '';
72
+ if (quote !== undefined) {
73
+ if (char === '\\' && quote === '"' && index + 1 < line.length) {
74
+ index += 1;
75
+ current += line[index] ?? '';
76
+ } else if (char === quote) quote = undefined;
77
+ else current += char;
78
+ } else if (char === '"' || char === '`') {
79
+ quote = char;
80
+ } else if (/\s/.test(char)) {
81
+ if (current !== '') tokens.push(current);
82
+ current = '';
83
+ } else if (char === '#' && current === '') {
84
+ break; // a comment starts only at the beginning of a token (caddyfile lexer)
85
+ } else current += char;
86
+ }
87
+ if (current !== '') tokens.push(current);
88
+ return tokens;
89
+ };
90
+
91
+ /**
92
+ * True when nothing but placeholders (and an auth scheme word) is left of these tokens.
93
+ * ⚠️ A bare `{` / `}` is a block brace, not a value: `dns cloudflare {` opens a block whose
94
+ * `api_token` line is checked on its own.
95
+ */
96
+ const placeholderOnly = (tokens: readonly string[]): boolean =>
97
+ tokens
98
+ .flatMap((token) => token.replace(PLACEHOLDER, ' ').split(/\s+/))
99
+ .filter((part) => part !== '' && part !== '{' && part !== '}' && !AUTH_SCHEME.test(part))
100
+ .length === 0;
101
+
102
+ const lineProblems = (tokens: readonly string[], where: string): string[] => {
103
+ const [first = '', second] = tokens;
104
+ const found: string[] = [];
105
+ if (SECRET_WORD.test(first) && tokens.length > 1 && !placeholderOnly(tokens.slice(1))) {
106
+ found.push(`${where}: \`${first}\` has a literal value`);
107
+ }
108
+ if (DNS_DIRECTIVE.test(first) && tokens.length > 2 && !placeholderOnly(tokens.slice(2))) {
109
+ found.push(`${where}: \`${first} ${second ?? ''}\` has a literal credential`);
110
+ }
111
+ if (HEADER_DIRECTIVE.test(first)) {
112
+ const at = tokens.findIndex((token, index) => index > 0 && CREDENTIAL_HEADER.test(token));
113
+ if (at !== -1 && tokens.length > at + 1 && !placeholderOnly(tokens.slice(at + 1))) {
114
+ found.push(`${where}: \`${first} ${tokens[at] ?? ''}\` sets a literal credential`);
115
+ }
116
+ }
117
+ for (const token of tokens) {
118
+ const shape = SHAPES.find(([pattern]) => pattern.test(token));
119
+ // ⛔ Never echo the token: if it IS a secret, the refusal would print it.
120
+ if (shape !== undefined) found.push(`${where}: holds what looks like ${shape[1]}`);
121
+ }
122
+ return found;
123
+ };
124
+
125
+ /** Refusals for a Caddyfile's text. Empty means nothing tripped. */
126
+ export const caddyfileSecretProblems = (caddyfile: string): string[] => {
127
+ const found: string[] = [];
128
+ if (PRIVATE_KEY.test(caddyfile)) found.push('the Caddyfile holds a PEM private key');
129
+ for (const [index, line] of caddyfile.split('\n').entries()) {
130
+ found.push(...lineProblems(tokensOf(withDefaults(line)), `line ${String(index + 1)}`));
131
+ }
132
+ return found.map((problem) => `${problem}: ${ALTERNATIVE}`);
133
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * CaddyConfig's props, attributes and validation — the pure half of the provider.
3
+ *
4
+ * ⛔ `caddyfile` IS NEVER A SECRET. Alchemy stores props unencrypted, so the whole Caddyfile sits in
5
+ * the state store and in every plan diff. Secrets reach Caddy as runtime placeholders
6
+ * (`{env.NAME}`, `{file./path}`) resolved from files a secret renderer writes — see
7
+ * caddyfile-tripwire.ts, which refuses the obvious literals.
8
+ */
9
+ import { caddyfileSecretProblems } from './caddyfile-tripwire.ts';
10
+
11
+ export interface CaddyConfigProps {
12
+ /**
13
+ * The whole Caddyfile. Applied with `POST /load` (Content-Type `text/caddyfile`); it REPLACES the
14
+ * running config. ⛔ Never a secret. ⚠️ `import` paths resolve against Caddy's working directory
15
+ * (the API adapts with no file name), so keep them absolute.
16
+ */
17
+ caddyfile: string;
18
+ /**
19
+ * The file Caddy was started with (`caddy run --config <this>`), when the same Caddyfile is also
20
+ * written there — `caddyWithFile()` sets it from its HostFile. Sent as
21
+ * `Caddy-Config-Source-File` so a load keeps Caddy's SIGUSR1 reload-from-file working.
22
+ */
23
+ sourceFile?: string;
24
+ }
25
+
26
+ export interface CaddyConfigAttributes {
27
+ /**
28
+ * SHA-256 of the ADAPTED JSON Caddy reported after the last apply (digest.ts canonical form) —
29
+ * never the config itself. Drift is the live config hashing to anything else.
30
+ */
31
+ configSha256: string;
32
+ /** The admin endpoint the config was applied through (no credential; the API has none). */
33
+ endpoint: string;
34
+ /** The `sourceFile` prop as applied. */
35
+ sourceFile?: string;
36
+ }
37
+
38
+ const absoluteNormalised = (path: string): boolean =>
39
+ path.startsWith('/') &&
40
+ !path.endsWith('/') &&
41
+ !path.includes('\x00') &&
42
+ !path
43
+ .split('/')
44
+ .slice(1)
45
+ .some((part) => part === '' || part === '.' || part === '..');
46
+
47
+ export const configProblems = (props: CaddyConfigProps): string[] => {
48
+ const found: string[] = [];
49
+ /**
50
+ * ⛔ AN EMPTY CADDYFILE IS NOT "NOTHING TO DO". It adapts to a config with no apps, and loading
51
+ * it stops every server Caddy runs — every site down, on a templating bug that rendered ''.
52
+ * Emptying Caddy is a deliberate act for a person at the admin API, not a declaration.
53
+ * Text that is not empty but serves nothing (only comments, only global options) is caught
54
+ * after `/adapt`, where it shows as no apps (config-lifecycle.ts servesNothing).
55
+ */
56
+ if (props.caddyfile.trim() === '') {
57
+ found.push('caddyfile is empty — loading it would stop every site Caddy serves');
58
+ }
59
+ if (props.sourceFile !== undefined && !absoluteNormalised(props.sourceFile)) {
60
+ found.push('sourceFile must be an absolute, normalised path');
61
+ }
62
+ found.push(...caddyfileSecretProblems(props.caddyfile));
63
+ return found;
64
+ };