@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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake Caddy admin API for the tests beside it — `Bun.serve` on an ephemeral 127.0.0.1 port (or a
|
|
3
|
+
* unix socket), answering `/adapt`, `/load` and `/config/` the way caddyserver/caddy v2.11.4 does.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ TEST-ONLY. No provider imports this file, and it never forwards anywhere.
|
|
6
|
+
* ★ WHAT IS FAITHFUL, because the provider depends on it (see admin-calls.ts and digest.ts):
|
|
7
|
+
* · `/adapt` answers the adapter's STRUCT key order (unsorted); `/config/` answers SORTED keys,
|
|
8
|
+
* `<` escaped as `\u003c`, and a trailing newline — the differences the digest must erase.
|
|
9
|
+
* · A refused `/load` keeps the old config. With adapter warnings, the refusal comes in a 200
|
|
10
|
+
* whose body is the warnings followed by the error (load.go).
|
|
11
|
+
* · The Host check (admin.go checkHost) and the Origin check when one is sent.
|
|
12
|
+
* ★ THE TOY ADAPTER. A Caddyfile here is lines; each becomes a route. Keywords steer the fake:
|
|
13
|
+
* `SYNTAX_ERROR` fails the adapt; `PROVISION_ERROR` adapts but fails the load; a TAB anywhere is
|
|
14
|
+
* "not formatted" (the warning real Caddy gives unformatted input); `admin <listen>`,
|
|
15
|
+
* `admin off` and `origins <a> <b>` set the adapted `admin` block. With no site lines there are
|
|
16
|
+
* no apps at all, as real Caddy adapts a Caddyfile of only comments or global options.
|
|
17
|
+
* ★ `listenPort` is the port Caddy BELIEVES it listens on — its Host check uses it — while the
|
|
18
|
+
* fake really listens on an ephemeral one: a Caddy on its default :2019 behind a forward.
|
|
19
|
+
*/
|
|
20
|
+
export type Seen = {
|
|
21
|
+
readonly method: string;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly headers: Headers;
|
|
24
|
+
readonly body: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type FakeCaddy = {
|
|
28
|
+
/** An address localCaddyAdmin() takes. */
|
|
29
|
+
readonly address: string;
|
|
30
|
+
readonly port: number;
|
|
31
|
+
readonly seen: Seen[];
|
|
32
|
+
/** The running config as a parsed object (null when empty). */
|
|
33
|
+
running: unknown;
|
|
34
|
+
/** Adapt with the toy adapter — to stage a "restart with a different file". */
|
|
35
|
+
adapt(caddyfile: string): unknown;
|
|
36
|
+
stop(): void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const json = (status: number, body: unknown) =>
|
|
40
|
+
new Response(`${JSON.stringify(body)}\n`, {
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
status,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** The toy adapter: struct key order on purpose (`routes` before `listen`, `apps` before `admin`). */
|
|
46
|
+
const toyAdapt = (text: string): { config?: unknown; error?: string; warnings: unknown[] } => {
|
|
47
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#'));
|
|
48
|
+
const at = lines.findIndex((line) => line.includes('SYNTAX_ERROR'));
|
|
49
|
+
if (at !== -1)
|
|
50
|
+
return { error: `Caddyfile:${String(at + 1)} - Error during parsing`, warnings: [] };
|
|
51
|
+
const admin: Record<string, unknown> = {};
|
|
52
|
+
const routes: unknown[] = [];
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const [word, ...rest] = line.trim().split(/\s+/);
|
|
55
|
+
if (word === 'admin' && rest[0] === 'off') admin['disabled'] = true;
|
|
56
|
+
else if (word === 'admin') admin['listen'] = rest[0];
|
|
57
|
+
else if (word === 'origins') admin['origins'] = rest;
|
|
58
|
+
else routes.push({ match: [{ host: [word] }], handle: [{ handler: 'static', body: line }] });
|
|
59
|
+
}
|
|
60
|
+
const warnings = text.includes('\t')
|
|
61
|
+
? [{ file: 'Caddyfile', line: 1, message: 'Caddyfile input is not formatted' }]
|
|
62
|
+
: [];
|
|
63
|
+
const config = {
|
|
64
|
+
...(routes.length === 0
|
|
65
|
+
? {}
|
|
66
|
+
: { apps: { http: { servers: { srv0: { routes, listen: [':443'] } } } } }),
|
|
67
|
+
...(Object.keys(admin).length === 0 ? {} : { admin }),
|
|
68
|
+
};
|
|
69
|
+
return { config, warnings };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Go's generic re-marshal: sorted keys, `<>&` escaped. */
|
|
73
|
+
const goEncode = (value: unknown): string => {
|
|
74
|
+
const sort = (v: unknown): unknown =>
|
|
75
|
+
Array.isArray(v)
|
|
76
|
+
? v.map(sort)
|
|
77
|
+
: typeof v === 'object' && v !== null
|
|
78
|
+
? Object.fromEntries(
|
|
79
|
+
Object.keys(v)
|
|
80
|
+
.sort()
|
|
81
|
+
.map((k) => [k, sort((v as Record<string, unknown>)[k])]),
|
|
82
|
+
)
|
|
83
|
+
: v;
|
|
84
|
+
return JSON.stringify(sort(value))
|
|
85
|
+
.replaceAll('<', '\\u003c')
|
|
86
|
+
.replaceAll('>', '\\u003e')
|
|
87
|
+
.replaceAll('&', '\\u0026');
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const fakeCaddy = (
|
|
91
|
+
options: { unix?: string; running?: unknown; origins?: string[]; listenPort?: number } = {},
|
|
92
|
+
): FakeCaddy => {
|
|
93
|
+
const seen: Seen[] = [];
|
|
94
|
+
let port = 0;
|
|
95
|
+
const state: { running: unknown } = { running: options.running ?? null };
|
|
96
|
+
const allowedHosts = () =>
|
|
97
|
+
options.origins ??
|
|
98
|
+
['localhost', '[::1]', '127.0.0.1'].map(
|
|
99
|
+
(host) => `${host}:${String(options.listenPort ?? port)}`,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const handle = async (request: Request): Promise<Response> => {
|
|
103
|
+
const url = new URL(request.url);
|
|
104
|
+
const body = await request.text();
|
|
105
|
+
seen.push({ body, headers: request.headers, method: request.method, path: url.pathname });
|
|
106
|
+
if (options.unix === undefined) {
|
|
107
|
+
const host = request.headers.get('host') ?? '';
|
|
108
|
+
if (!allowedHosts().includes(host)) return json(403, { error: `host not allowed: ${host}` });
|
|
109
|
+
const origin = request.headers.get('origin');
|
|
110
|
+
if (origin !== null && !allowedHosts().includes(new URL(origin).host)) {
|
|
111
|
+
return json(403, { error: `client is not allowed to access from origin '${origin}'` });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (url.pathname === '/config/' && request.method === 'GET') {
|
|
115
|
+
return new Response(`${goEncode(state.running)}\n`, { status: 200 });
|
|
116
|
+
}
|
|
117
|
+
const type = request.headers.get('content-type') ?? '';
|
|
118
|
+
if (request.method !== 'POST' || type !== 'text/caddyfile')
|
|
119
|
+
return json(400, { error: 'unexpected' });
|
|
120
|
+
const adapted = toyAdapt(body);
|
|
121
|
+
if (adapted.error !== undefined) return json(400, { error: adapted.error });
|
|
122
|
+
if (url.pathname === '/adapt') {
|
|
123
|
+
const out = adapted.warnings.length > 0 ? { warnings: adapted.warnings } : {};
|
|
124
|
+
return json(200, { ...out, result: adapted.config });
|
|
125
|
+
}
|
|
126
|
+
if (url.pathname !== '/load') return json(404, { error: 'not found' });
|
|
127
|
+
const prefix = adapted.warnings.length > 0 ? JSON.stringify(adapted.warnings) : '';
|
|
128
|
+
if (body.includes('PROVISION_ERROR')) {
|
|
129
|
+
const error = JSON.stringify({
|
|
130
|
+
error: 'loading config: provision http: listen tcp :443: address already in use',
|
|
131
|
+
});
|
|
132
|
+
// ⛔ load.go: warnings written first commit a 200; the error rides after them.
|
|
133
|
+
return prefix === ''
|
|
134
|
+
? new Response(`${error}\n`, { status: 400 })
|
|
135
|
+
: new Response(`${prefix}${error}\n`, { status: 200 });
|
|
136
|
+
}
|
|
137
|
+
state.running = JSON.parse(goEncode(adapted.config));
|
|
138
|
+
return new Response(prefix, { status: 200 });
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const server =
|
|
142
|
+
options.unix === undefined
|
|
143
|
+
? Bun.serve({ fetch: handle, hostname: '127.0.0.1', port: 0 })
|
|
144
|
+
: Bun.serve({ fetch: handle, unix: options.unix });
|
|
145
|
+
port = server.port ?? 0;
|
|
146
|
+
return {
|
|
147
|
+
adapt: (caddyfile) => toyAdapt(caddyfile).config,
|
|
148
|
+
address:
|
|
149
|
+
options.unix === undefined ? `http://127.0.0.1:${String(port)}` : `unix://${options.unix}`,
|
|
150
|
+
get running() {
|
|
151
|
+
return state.running;
|
|
152
|
+
},
|
|
153
|
+
set running(value: unknown) {
|
|
154
|
+
state.running = value;
|
|
155
|
+
},
|
|
156
|
+
port,
|
|
157
|
+
seen,
|
|
158
|
+
stop: () => void server.stop(true),
|
|
159
|
+
};
|
|
160
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure checks: the digest's canonical form, the Caddyfile secret tripwire, and the admin-block
|
|
3
|
+
* guard. Each refusal here is one a deploy would otherwise learn about from a stranded Caddy or a
|
|
4
|
+
* token in the state store.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { adminProblems } from './admin-guard.ts';
|
|
8
|
+
import type { CaddyAdminListener } from './admin.ts';
|
|
9
|
+
import { caddyfileSecretProblems, tokensOf } from './caddyfile-tripwire.ts';
|
|
10
|
+
import { canonicalConfig, configDigest, parseConfig } from './digest.ts';
|
|
11
|
+
|
|
12
|
+
describe('digest', () => {
|
|
13
|
+
test('struct order (/adapt) and sorted, escaped, newline-terminated (/config/) hash the same', () => {
|
|
14
|
+
const adapted = {
|
|
15
|
+
apps: { http: { servers: { srv0: { routes: ['<a> & b'], listen: [':443'] } } } },
|
|
16
|
+
};
|
|
17
|
+
const reported =
|
|
18
|
+
'{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":["\\u003ca\\u003e \\u0026 b"]}}}}}\n';
|
|
19
|
+
expect(configDigest(parseConfig(reported))).toBe(configDigest(adapted));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('an empty config is null, and hashes as null', () => {
|
|
23
|
+
expect(parseConfig('null\n')).toBeNull();
|
|
24
|
+
expect(canonicalConfig(undefined)).toBe('null');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a real difference is a different digest', () => {
|
|
28
|
+
expect(configDigest({ a: [1, 2] })).not.toBe(configDigest({ a: [2, 1] }));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('tokensOf', () => {
|
|
33
|
+
test('quotes kept whole, comments dropped only at a token start', () => {
|
|
34
|
+
expect(tokensOf('header Authorization "Bearer {env.T}" # note')).toEqual([
|
|
35
|
+
'header',
|
|
36
|
+
'Authorization',
|
|
37
|
+
'Bearer {env.T}',
|
|
38
|
+
]);
|
|
39
|
+
expect(tokensOf('respond a#b')).toEqual(['respond', 'a#b']);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* ⚠️ ASSEMBLED AT RUN TIME. A literal token-shaped fixture trips the pre-commit gitleaks scan
|
|
45
|
+
* (MEASURED 2026-09-21: its `jwt` rule flagged the first draft of this file). Joined parts keep
|
|
46
|
+
* the shape the tripwire must catch out of the source text gitleaks reads.
|
|
47
|
+
*/
|
|
48
|
+
const GITHUB_TOKEN = ['ghp', 'a1B2'.repeat(9)].join('_');
|
|
49
|
+
const JWT = ['eyJhbGciOiJub25lIn0', 'eyJzdWIiOiJ4In0x', 'c2lnbmF0dXJl'].join('.');
|
|
50
|
+
const BCRYPT = ['', '2a', '14', 'x'.repeat(53)].join('$');
|
|
51
|
+
|
|
52
|
+
describe('caddyfileSecretProblems', () => {
|
|
53
|
+
test.each([
|
|
54
|
+
['dns provider token', 'tls {\n dns cloudflare abcdef0123456789\n}'],
|
|
55
|
+
['acme_dns token', 'acme_dns cloudflare abcdef0123456789'],
|
|
56
|
+
['a secret-named subdirective', 'oauth {\n client_secret s3cr3t\n}'],
|
|
57
|
+
[
|
|
58
|
+
'a literal Authorization header',
|
|
59
|
+
'reverse_proxy up:80 {\n header_up Authorization "Bearer abc123"\n}',
|
|
60
|
+
],
|
|
61
|
+
['a GitHub token anywhere', `respond ${GITHUB_TOKEN}`],
|
|
62
|
+
['a JWT', `header X-Id ${JWT}`],
|
|
63
|
+
['a bcrypt hash in basic_auth', `basic_auth {\n admin ${BCRYPT}\n}`],
|
|
64
|
+
['a PEM private key', 'x {\n-----BEGIN EC PRIVATE KEY-----\n}'],
|
|
65
|
+
// ⛔ An adapt-time default is spliced in whenever the variable is unset: it is a literal.
|
|
66
|
+
['a literal default for a dns token', 'dns cloudflare {$CF_API_TOKEN:abcdef0123456789}'],
|
|
67
|
+
['a token-shaped default anywhere', `respond "{$GREETING:${GITHUB_TOKEN}}"`],
|
|
68
|
+
])('refuses %s', (_, caddyfile) => {
|
|
69
|
+
const found = caddyfileSecretProblems(caddyfile);
|
|
70
|
+
expect(found.length).toBeGreaterThan(0);
|
|
71
|
+
expect(found.join(' ')).toContain('{env.NAME}');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('never echoes the secret it found', () => {
|
|
75
|
+
const found = caddyfileSecretProblems('dns cloudflare abcdef0123456789').join(' ');
|
|
76
|
+
expect(found).not.toContain('abcdef0123456789');
|
|
77
|
+
expect(found).toContain('line 1');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test.each([
|
|
81
|
+
['env placeholder', 'tls {\n dns cloudflare {env.CF_API_TOKEN}\n}'],
|
|
82
|
+
['file placeholder', 'client_secret {file./run/secrets/oidc}'],
|
|
83
|
+
['adapt-time env (the name only)', 'dns cloudflare {$CF_API_TOKEN}'],
|
|
84
|
+
['an adapt-time default that is not a secret', 'reverse_proxy {$UPSTREAM:127.0.0.1:8080}'],
|
|
85
|
+
['a placeholder bearer', 'header_up Authorization "Bearer {env.UPSTREAM_TOKEN}"'],
|
|
86
|
+
['a forwarded header', 'header_up Authorization {http.request.header.Authorization}'],
|
|
87
|
+
['a block opener', 'dns cloudflare {\n api_token {env.CF}\n}'],
|
|
88
|
+
['a deleted header', 'header -Authorization'],
|
|
89
|
+
['a *_file path', 'api_key_file /run/secrets/key'],
|
|
90
|
+
['a hash from the env', 'basic_auth {\n admin {env.ADMIN_HASH}\n}'],
|
|
91
|
+
['an ordinary site', 'app.example.com {\n reverse_proxy 127.0.0.1:8080\n}'],
|
|
92
|
+
])('allows %s', (_, caddyfile) => {
|
|
93
|
+
expect(caddyfileSecretProblems(caddyfile)).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('adminProblems', () => {
|
|
98
|
+
const tcp: CaddyAdminListener = { hostHeader: '127.0.0.1:2019', kind: 'tcp', port: 2019 };
|
|
99
|
+
const unix: CaddyAdminListener = { kind: 'unix', path: '/run/caddy/admin.sock' };
|
|
100
|
+
const admin = (block: object) => ({ admin: block, apps: {} });
|
|
101
|
+
|
|
102
|
+
const v6: CaddyAdminListener = { hostHeader: '[::1]:2019', kind: 'tcp', port: 2019 };
|
|
103
|
+
const named: CaddyAdminListener = { hostHeader: 'localhost:2019', kind: 'tcp', port: 2019 };
|
|
104
|
+
|
|
105
|
+
test.each([
|
|
106
|
+
[{}, tcp],
|
|
107
|
+
[{ listen: 'localhost:2019' }, tcp],
|
|
108
|
+
[{ listen: '127.0.0.1:2019', origins: ['127.0.0.1:2019'] }, tcp],
|
|
109
|
+
[{ listen: 'tcp/[::1]:2019' }, v6],
|
|
110
|
+
[{ listen: '127.0.0.1:2019' }, named],
|
|
111
|
+
[{ listen: 'unix//run/caddy/admin.sock|0660' }, unix],
|
|
112
|
+
[{ origins: ['http://127.0.0.1:2019'] }, tcp],
|
|
113
|
+
])('allows %j', (block, transport) => {
|
|
114
|
+
expect(adminProblems(admin(block), transport)).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('no admin block at all, and an empty config, pass at Caddy’s default listener', () => {
|
|
118
|
+
expect(adminProblems({ apps: {} }, tcp)).toEqual([]);
|
|
119
|
+
expect(adminProblems(null, tcp)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* ⛔ No `admin` address means DefaultAdminListen (localhost:2019) after the load — so a Caddy
|
|
124
|
+
* reached anywhere else would move away from the provider (admin.go replaceLocalAdminServer).
|
|
125
|
+
*/
|
|
126
|
+
test.each([
|
|
127
|
+
['a unix socket', unix],
|
|
128
|
+
['another port', { hostHeader: '127.0.0.1:2999', kind: 'tcp', port: 2999 } as const],
|
|
129
|
+
['[::1] (localhost binds IPv4)', v6],
|
|
130
|
+
])('no admin address is refused for a Caddy reached on %s', (_, transport) => {
|
|
131
|
+
expect(adminProblems({ apps: {} }, transport).join(' ')).toMatch(/declare `admin <address>`/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test.each([
|
|
135
|
+
[{ disabled: true }, tcp, /admin off/],
|
|
136
|
+
[{ listen: ':2019' }, tcp, /not loopback/],
|
|
137
|
+
[{ listen: '0.0.0.0:2019' }, tcp, /not loopback/],
|
|
138
|
+
[{ listen: '192.0.2.5:2019' }, tcp, /not loopback/],
|
|
139
|
+
[{ listen: 'localhost:2020' }, tcp, /moves the admin API/],
|
|
140
|
+
[{ listen: 'tcp/[::1]:2019' }, tcp, /moves the admin API/],
|
|
141
|
+
[{ listen: '127.0.0.2:2019' }, tcp, /moves the admin API/],
|
|
142
|
+
[{ listen: 'localhost:2019' }, v6, /moves the admin API/],
|
|
143
|
+
[{ listen: 'unix//run/caddy/admin.sock', enforce_origin: true }, unix, /sends no Origin/],
|
|
144
|
+
[{ listen: 'unix//run/caddy/admin.sock' }, tcp, /moves the admin API/],
|
|
145
|
+
[{ listen: 'unix//run/other.sock' }, unix, /moves the admin API/],
|
|
146
|
+
[{ listen: '{env.CADDY_ADMIN}' }, tcp, /placeholder/],
|
|
147
|
+
[{ origins: ['caddy.example:2019'] }, tcp, /do not allow Host 127\.0\.0\.1:2019/],
|
|
148
|
+
[{ origins: ['https://127.0.0.1:2019'] }, tcp, /do not allow Host/],
|
|
149
|
+
[{ origins: [] }, tcp, /do not allow Host/],
|
|
150
|
+
[{}, { hostHeader: 'caddy.example:2019', kind: 'tcp', port: 2019 } as const, /default origins/],
|
|
151
|
+
[{ remote: { listen: ':2021' } }, tcp, /network-facing/],
|
|
152
|
+
[{ config: { load: { module: 'http' } } }, tcp, /pull its config/],
|
|
153
|
+
])('refuses %j', (block, transport, reason) => {
|
|
154
|
+
expect(adminProblems(admin(block), transport).join(' ')).toMatch(reason);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caddy for Alchemy — a running Caddy's config, declared as Caddyfile text and applied through
|
|
3
|
+
* Caddy's own admin API (`POST /load`, `GET /config/`).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. The lifecycle,
|
|
6
|
+
* the digest, the guards and the admin calls are internals; an `export *` would publish them.
|
|
7
|
+
* ⛔ THE ADMIN API STAYS ON LOOPBACK OR A UNIX SOCKET. It has no authentication; localCaddyAdmin()
|
|
8
|
+
* refuses any other address, and a Caddyfile that would move or expose it is refused before load.
|
|
9
|
+
* Guide, order of file and load, `--resume` and secrets: docs/caddy.md.
|
|
10
|
+
*/
|
|
11
|
+
export type {
|
|
12
|
+
CaddyAdmin,
|
|
13
|
+
CaddyAdminListener,
|
|
14
|
+
CaddyAdminRequest,
|
|
15
|
+
CaddyAdminResponse,
|
|
16
|
+
} from './admin.ts';
|
|
17
|
+
export { CaddyAdminService, CaddyUnreachableError, caddyAdminLayer } from './admin.ts';
|
|
18
|
+
export { CaddyAdminError } from './admin-calls.ts';
|
|
19
|
+
export type { CaddyConfigAttributes, CaddyConfigProps } from './config.ts';
|
|
20
|
+
export { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
21
|
+
export type { LocalCaddyAdminOptions } from './local-admin.ts';
|
|
22
|
+
export { DEFAULT_ADMIN_ADDRESS, localCaddyAdmin } from './local-admin.ts';
|
|
23
|
+
export { caddyProviders } from './providers.ts';
|
|
24
|
+
export type { CaddyWithFileProps } from './with-file.ts';
|
|
25
|
+
export { caddyWithFile } from './with-file.ts';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localCaddyAdmin: which addresses it accepts, the Host and Origin it sends (Caddy checks both),
|
|
3
|
+
* the unix-socket path, and retrying only a connection nothing accepted.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { rmSync } from 'node:fs';
|
|
7
|
+
import { createServer } from 'node:net';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { CaddyUnreachableError } from './admin.ts';
|
|
11
|
+
import { readRunningConfig } from './admin-calls.ts';
|
|
12
|
+
import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
|
|
13
|
+
import { DEFAULT_ADMIN_ADDRESS, localCaddyAdmin, parseAdminAddress } from './local-admin.ts';
|
|
14
|
+
|
|
15
|
+
let fake: FakeCaddy | undefined;
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fake?.stop();
|
|
18
|
+
fake = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** ⚠️ Short on purpose: macOS caps a socket path at 104 bytes, and node then fails EINVAL. */
|
|
22
|
+
const socketPath = (label: string) => {
|
|
23
|
+
const path = join(tmpdir(), `fc-${label}-${String(process.pid)}.sock`);
|
|
24
|
+
rmSync(path, { force: true });
|
|
25
|
+
return path;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('parseAdminAddress', () => {
|
|
29
|
+
test("the default is Caddy's own default listener, over loopback TCP", () => {
|
|
30
|
+
expect(DEFAULT_ADMIN_ADDRESS).toBe('http://127.0.0.1:2019');
|
|
31
|
+
expect(parseAdminAddress(DEFAULT_ADMIN_ADDRESS).listener).toEqual({
|
|
32
|
+
hostHeader: '127.0.0.1:2019',
|
|
33
|
+
kind: 'tcp',
|
|
34
|
+
port: 2019,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test.each(['http://localhost:2019', 'http://[::1]:2019', 'http://127.0.0.2:2020'])(
|
|
39
|
+
'accepts loopback %s',
|
|
40
|
+
(address) => {
|
|
41
|
+
expect(parseAdminAddress(address).listener.kind).toBe('tcp');
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
test('a unix socket, which Caddy exempts from the Host check', () => {
|
|
46
|
+
expect(parseAdminAddress('unix:///run/caddy/admin.sock').listener).toEqual({
|
|
47
|
+
kind: 'unix',
|
|
48
|
+
path: '/run/caddy/admin.sock',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test.each([
|
|
53
|
+
['http://192.0.2.10:2019', /not loopback/],
|
|
54
|
+
['http://caddy.example.com:2019', /not loopback/],
|
|
55
|
+
['https://127.0.0.1:2019', /only http/],
|
|
56
|
+
['http://127.0.0.1:2019/config', /no path/],
|
|
57
|
+
['http://user:pw@127.0.0.1:2019', /credentials/],
|
|
58
|
+
['unix://relative.sock', /absolute/],
|
|
59
|
+
['127.0.0.1:2019', /not a URL|only http/],
|
|
60
|
+
])('refuses %s', (address, reason) => {
|
|
61
|
+
expect(() => parseAdminAddress(address)).toThrow(reason);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the Host always carries the port, as Caddy compares host:port (a default port too)', () => {
|
|
65
|
+
expect(parseAdminAddress('http://127.0.0.1').listener).toEqual({
|
|
66
|
+
hostHeader: '127.0.0.1:80',
|
|
67
|
+
kind: 'tcp',
|
|
68
|
+
port: 80,
|
|
69
|
+
});
|
|
70
|
+
expect(parseAdminAddress('http://[::1]:2019').listener).toMatchObject({
|
|
71
|
+
hostHeader: '[::1]:2019',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('hostHeader is what Caddy checks: its port is the listener port, not the dialled one', () => {
|
|
76
|
+
const parsed = parseAdminAddress('http://127.0.0.1:12019', '127.0.0.1:2019');
|
|
77
|
+
expect(parsed.listener).toEqual({ hostHeader: '127.0.0.1:2019', kind: 'tcp', port: 2019 });
|
|
78
|
+
expect(() => parseAdminAddress('http://127.0.0.1:2019', 'no port')).toThrow(/host:port/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('requests', () => {
|
|
83
|
+
test('over TCP: Host and Origin as the Caddy CLI sends them, so the checks pass', async () => {
|
|
84
|
+
fake = fakeCaddy();
|
|
85
|
+
const admin = localCaddyAdmin({ address: fake.address, retries: 0 });
|
|
86
|
+
expect(await readRunningConfig(admin)).toBeNull();
|
|
87
|
+
const host = `127.0.0.1:${String(fake.port)}`;
|
|
88
|
+
expect(fake.seen[0]?.headers.get('host')).toBe(host);
|
|
89
|
+
expect(fake.seen[0]?.headers.get('origin')).toBe(`http://${host}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a narrowed `origins` needs hostHeader — without it Caddy answers 403', async () => {
|
|
93
|
+
fake = fakeCaddy({ origins: ['caddy-admin.example:2019'] });
|
|
94
|
+
const plain = localCaddyAdmin({ address: fake.address, retries: 0 });
|
|
95
|
+
await expect(readRunningConfig(plain)).rejects.toThrow(/403: host not allowed/);
|
|
96
|
+
const named = localCaddyAdmin({
|
|
97
|
+
address: fake.address,
|
|
98
|
+
hostHeader: 'caddy-admin.example:2019',
|
|
99
|
+
retries: 0,
|
|
100
|
+
});
|
|
101
|
+
expect(await readRunningConfig(named)).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a caller cannot override the Host and Origin the checks depend on', async () => {
|
|
105
|
+
fake = fakeCaddy();
|
|
106
|
+
const admin = localCaddyAdmin({ address: fake.address, retries: 0 });
|
|
107
|
+
const headers = { Host: 'evil.example:2019', Origin: 'http://evil.example:2019' };
|
|
108
|
+
expect((await admin.request({ headers, method: 'GET', path: '/config/' })).status).toBe(200);
|
|
109
|
+
expect(fake.seen[0]?.headers.get('host')).toBe(`127.0.0.1:${String(fake.port)}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('over a unix socket, with no Origin', async () => {
|
|
113
|
+
const path = socketPath('req');
|
|
114
|
+
fake = fakeCaddy({ running: { apps: {} }, unix: path });
|
|
115
|
+
const admin = localCaddyAdmin({ address: fake.address, retries: 0 });
|
|
116
|
+
expect(await readRunningConfig(admin)).toEqual({ apps: {} });
|
|
117
|
+
expect(fake.seen[0]?.headers.has('origin')).toBe(false);
|
|
118
|
+
rmSync(path, { force: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('retries a socket nobody listens on yet, then reaches the Caddy that came up', async () => {
|
|
122
|
+
const path = socketPath('late');
|
|
123
|
+
const admin = localCaddyAdmin({ address: `unix://${path}`, retries: 5, retryDelayMs: 40 });
|
|
124
|
+
const late = setTimeout(() => {
|
|
125
|
+
fake = fakeCaddy({ unix: path });
|
|
126
|
+
}, 60);
|
|
127
|
+
expect(await readRunningConfig(admin)).toBeNull();
|
|
128
|
+
clearTimeout(late);
|
|
129
|
+
rmSync(path, { force: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a connection that was ACCEPTED is never re-sent, however many retries remain', async () => {
|
|
133
|
+
// ⛔ It may have reached Caddy: re-sending a `POST /load` could apply it twice.
|
|
134
|
+
let accepted = 0;
|
|
135
|
+
const server = createServer((socket) => {
|
|
136
|
+
accepted += 1;
|
|
137
|
+
socket.on('data', () => socket.destroy());
|
|
138
|
+
});
|
|
139
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
140
|
+
const { port } = server.address() as { port: number };
|
|
141
|
+
const admin = localCaddyAdmin({
|
|
142
|
+
address: `http://127.0.0.1:${String(port)}`,
|
|
143
|
+
retries: 3,
|
|
144
|
+
retryDelayMs: 1,
|
|
145
|
+
});
|
|
146
|
+
const failure = admin.request({ body: 'x', method: 'POST', path: '/load' });
|
|
147
|
+
await expect(failure).rejects.toThrow(/Caddy admin POST \/load/);
|
|
148
|
+
await expect(failure).rejects.not.toBeInstanceOf(CaddyUnreachableError);
|
|
149
|
+
expect(accepted).toBe(1);
|
|
150
|
+
server.close();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('gives up after the retries with CaddyUnreachableError, naming the address', async () => {
|
|
154
|
+
const path = socketPath('never');
|
|
155
|
+
const admin = localCaddyAdmin({ address: `unix://${path}`, retries: 1, retryDelayMs: 1 });
|
|
156
|
+
const failure = readRunningConfig(admin);
|
|
157
|
+
await expect(failure).rejects.toBeInstanceOf(CaddyUnreachableError);
|
|
158
|
+
await expect(failure).rejects.toThrow(/Caddy admin GET \/config\/ at unix:\/\//);
|
|
159
|
+
});
|
|
160
|
+
});
|