@homeflare/alchemy 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -41
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +91 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +34 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +54 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +22 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +970 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +51 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +16 -12
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +36 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +393 -234
- package/dist/openbao/index.js.map +23 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +107 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +199 -0
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/mesh-node.md +188 -0
- package/docs/peers.md +39 -0
- package/package.json +7 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +196 -0
- package/src/caddy/config-lifecycle.ts +239 -0
- package/src/caddy/config.ts +153 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +179 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +26 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +229 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +66 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +126 -22
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +22 -8
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +126 -0
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +52 -36
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +166 -0
- package/src/openbao/role-rename.test.ts +205 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -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,26 @@
|
|
|
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
|
+
* ⛔ NOTHING IS ADOPTED SILENTLY: a running config that is not the declared one needs `--adopt`.
|
|
10
|
+
* Guide, order of file and load, adoption, `--resume` and secrets: docs/caddy.md.
|
|
11
|
+
*/
|
|
12
|
+
export type {
|
|
13
|
+
CaddyAdmin,
|
|
14
|
+
CaddyAdminListener,
|
|
15
|
+
CaddyAdminRequest,
|
|
16
|
+
CaddyAdminResponse,
|
|
17
|
+
} from './admin.ts';
|
|
18
|
+
export { CaddyAdminService, CaddyUnreachableError, caddyAdminLayer } from './admin.ts';
|
|
19
|
+
export { CaddyAdminError } from './admin-calls.ts';
|
|
20
|
+
export type { CaddyConfigAttributes, CaddyConfigProps } from './config.ts';
|
|
21
|
+
export { CaddyConfig, CaddyConfigProvider } from './config.ts';
|
|
22
|
+
export type { LocalCaddyAdminOptions } from './local-admin.ts';
|
|
23
|
+
export { DEFAULT_ADMIN_ADDRESS, localCaddyAdmin } from './local-admin.ts';
|
|
24
|
+
export { caddyProviders } from './providers.ts';
|
|
25
|
+
export type { CaddyWithFileProps } from './with-file.ts';
|
|
26
|
+
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
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real CaddyAdmin: this machine's Caddy, over loopback TCP or a unix socket.
|
|
3
|
+
*
|
|
4
|
+
* ★ node:http, NOT fetch. MEASURED 2026-09-21 on bun 1.4.0 and node 26.7.0: node:http sends a Host
|
|
5
|
+
* override and dials a unix socket on BOTH runtimes, while node's fetch silently drops a `Host`
|
|
6
|
+
* header (it sent the URL's host) and only Bun's fetch has a `unix` option. The published dist
|
|
7
|
+
* loads under node too (launchd's local-runner.ts makes the same call for the same reason).
|
|
8
|
+
* ⛔ LOOPBACK OR A UNIX SOCKET, NOTHING ELSE. The admin API is unauthenticated plaintext; an
|
|
9
|
+
* address off this machine means it is reachable from the network, which decision 23 forbids.
|
|
10
|
+
* Reaching another host's Caddy means forwarding its socket or port to here (SSH), never
|
|
11
|
+
* exposing :2019.
|
|
12
|
+
* ★ HOST AND ORIGIN, AS CADDY's OWN CLI SENDS THEM (cmd/commandfuncs.go AdminAPIRequest): over
|
|
13
|
+
* TCP, `Host` and `Origin: http://<host>`; over a socket, `Host: 127.0.0.1` and no Origin, because
|
|
14
|
+
* Caddy skips the Host check on unix listeners (admin.go allowedOrigins). MEASURED against the
|
|
15
|
+
* mini's Caddy 2.11.4: any other Host gets 403 `host not allowed`, a forwarded port included —
|
|
16
|
+
* hence `hostHeader`.
|
|
17
|
+
*/
|
|
18
|
+
import { request as httpRequest } from 'node:http';
|
|
19
|
+
import {
|
|
20
|
+
type CaddyAdmin,
|
|
21
|
+
type CaddyAdminListener,
|
|
22
|
+
type CaddyAdminResponse,
|
|
23
|
+
CaddyUnreachableError,
|
|
24
|
+
} from './admin.ts';
|
|
25
|
+
|
|
26
|
+
export type LocalCaddyAdminOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* `http://<loopback>:<port>` or `unix:///absolute/path.sock`.
|
|
29
|
+
* @default 'http://127.0.0.1:2019' — Caddy's default admin listener (`localhost:2019`).
|
|
30
|
+
*/
|
|
31
|
+
readonly address?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The Host (and Origin) to send over TCP, when it is not the address's own: the Caddyfile
|
|
34
|
+
* narrowed `admin { origins … }`, or the port is an SSH forward of the real one (Caddy checks
|
|
35
|
+
* the Host against ITS port, not yours). Form `host:port`.
|
|
36
|
+
*/
|
|
37
|
+
readonly hostHeader?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Abort an exchange after this long. @default 60_000
|
|
40
|
+
* ⚠️ A `shutdown_delay` in the Caddyfile makes `/load` block that long (caddyhttp app.go Stop);
|
|
41
|
+
* a timeout then fails the deploy while Caddy goes on to apply — the next plan converges.
|
|
42
|
+
*/
|
|
43
|
+
readonly timeoutMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Retries when the connection is REFUSED (nothing listening yet — a Caddy launchd job that was
|
|
46
|
+
* bootstrapped a moment ago). Only refusals: those never reached Caddy, so re-sending a
|
|
47
|
+
* `POST /load` cannot apply it twice. @default 2
|
|
48
|
+
*/
|
|
49
|
+
readonly retries?: number;
|
|
50
|
+
/** @default 500 */
|
|
51
|
+
readonly retryDelayMs?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_ADMIN_ADDRESS = 'http://127.0.0.1:2019';
|
|
55
|
+
|
|
56
|
+
type Target =
|
|
57
|
+
| { readonly kind: 'tcp'; readonly host: string; readonly port: number }
|
|
58
|
+
| { readonly kind: 'unix'; readonly socketPath: string };
|
|
59
|
+
|
|
60
|
+
const UNIX = 'unix://';
|
|
61
|
+
const HOST_PORT = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}$/;
|
|
62
|
+
|
|
63
|
+
const refuse = (address: string, why: string): Error =>
|
|
64
|
+
new Error(`Caddy admin address ${JSON.stringify(address)}: ${why}`);
|
|
65
|
+
|
|
66
|
+
const isLoopback = (hostname: string): boolean =>
|
|
67
|
+
hostname === 'localhost' || hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
68
|
+
|
|
69
|
+
/** The dial target and listener for an address, or a refusal naming what is wrong with it. */
|
|
70
|
+
export const parseAdminAddress = (
|
|
71
|
+
address: string,
|
|
72
|
+
hostHeader?: string,
|
|
73
|
+
): { target: Target; listener: CaddyAdminListener } => {
|
|
74
|
+
if (hostHeader !== undefined && !HOST_PORT.test(hostHeader)) {
|
|
75
|
+
throw refuse(address, `hostHeader ${JSON.stringify(hostHeader)} must be host:port`);
|
|
76
|
+
}
|
|
77
|
+
if (address.startsWith(UNIX)) {
|
|
78
|
+
const socketPath = address.slice(UNIX.length);
|
|
79
|
+
if (!socketPath.startsWith('/')) throw refuse(address, 'the socket path must be absolute');
|
|
80
|
+
return { listener: { kind: 'unix', path: socketPath }, target: { kind: 'unix', socketPath } };
|
|
81
|
+
}
|
|
82
|
+
let url: URL;
|
|
83
|
+
try {
|
|
84
|
+
url = new URL(address);
|
|
85
|
+
} catch {
|
|
86
|
+
throw refuse(address, 'not a URL (http://127.0.0.1:2019 or unix:///path)');
|
|
87
|
+
}
|
|
88
|
+
// ⛔ Caddy's local admin endpoint is plaintext by design; its TLS one (`admin.remote`, mTLS on
|
|
89
|
+
// :2021) is a different, network-facing API this transport deliberately does not speak.
|
|
90
|
+
if (url.protocol !== 'http:') throw refuse(address, 'only http:// (loopback) or unix://');
|
|
91
|
+
if (!isLoopback(url.hostname)) {
|
|
92
|
+
throw refuse(address, 'not loopback — the admin API is unauthenticated; forward it to here');
|
|
93
|
+
}
|
|
94
|
+
if (url.pathname !== '/' || url.search !== '' || url.username !== '') {
|
|
95
|
+
throw refuse(address, 'no path, query or credentials — the API lives at the root');
|
|
96
|
+
}
|
|
97
|
+
const port = url.port === '' ? 80 : Number(url.port);
|
|
98
|
+
// ⚠️ NOT `url.host`: WHATWG URL drops a default port (`http://127.0.0.1:80` has host
|
|
99
|
+
// `127.0.0.1`), and Caddy compares the Host against `host:port` (admin.go allowedOrigins), so
|
|
100
|
+
// a portless Host is a 403 on every call. The Caddy CLI always sends the port (JoinHostPort).
|
|
101
|
+
const host = hostHeader ?? `${url.hostname}:${String(port)}`;
|
|
102
|
+
return {
|
|
103
|
+
listener: {
|
|
104
|
+
hostHeader: host,
|
|
105
|
+
kind: 'tcp',
|
|
106
|
+
port: Number(host.slice(host.lastIndexOf(':') + 1)),
|
|
107
|
+
},
|
|
108
|
+
// ★ `hostname` keeps IPv6 brackets; node:http wants them off.
|
|
109
|
+
target: { host: url.hostname.replace(/^\[(.*)\]$/, '$1'), kind: 'tcp', port },
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const code = (cause: unknown): string | undefined =>
|
|
114
|
+
typeof cause === 'object' && cause !== null && 'code' in cause
|
|
115
|
+
? String((cause as { code: unknown }).code)
|
|
116
|
+
: undefined;
|
|
117
|
+
|
|
118
|
+
/** Nothing accepted the connection: refused TCP, or no socket file yet. The request never left. */
|
|
119
|
+
const neverConnected = (cause: unknown): boolean =>
|
|
120
|
+
code(cause) === 'ECONNREFUSED' || code(cause) === 'ENOENT';
|
|
121
|
+
|
|
122
|
+
const exchange = (
|
|
123
|
+
target: Target,
|
|
124
|
+
method: string,
|
|
125
|
+
path: string,
|
|
126
|
+
headers: Record<string, string>,
|
|
127
|
+
body: string | undefined,
|
|
128
|
+
timeoutMs: number,
|
|
129
|
+
): Promise<CaddyAdminResponse> =>
|
|
130
|
+
new Promise((resolve, reject) => {
|
|
131
|
+
const bytes = body === undefined ? undefined : Buffer.from(body, 'utf8');
|
|
132
|
+
const request = httpRequest(
|
|
133
|
+
{
|
|
134
|
+
...(target.kind === 'tcp'
|
|
135
|
+
? { host: target.host, port: target.port }
|
|
136
|
+
: { socketPath: target.socketPath }),
|
|
137
|
+
headers: bytes === undefined ? headers : { ...headers, 'Content-Length': bytes.length },
|
|
138
|
+
method,
|
|
139
|
+
path,
|
|
140
|
+
timeout: timeoutMs,
|
|
141
|
+
},
|
|
142
|
+
(response) => {
|
|
143
|
+
const chunks: Buffer[] = [];
|
|
144
|
+
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
145
|
+
response.on('error', reject);
|
|
146
|
+
response.on('end', () =>
|
|
147
|
+
resolve({
|
|
148
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
149
|
+
status: response.statusCode ?? 0,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
request.on('timeout', () => request.destroy(new Error(`no answer in ${String(timeoutMs)} ms`)));
|
|
155
|
+
request.on('error', reject);
|
|
156
|
+
request.end(bytes);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export const localCaddyAdmin = (options: LocalCaddyAdminOptions = {}): CaddyAdmin => {
|
|
160
|
+
const address = options.address ?? DEFAULT_ADMIN_ADDRESS;
|
|
161
|
+
const { listener, target } = parseAdminAddress(address, options.hostHeader);
|
|
162
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
163
|
+
const retries = options.retries ?? 2;
|
|
164
|
+
const delay = options.retryDelayMs ?? 500;
|
|
165
|
+
const fixed: Record<string, string> =
|
|
166
|
+
listener.kind === 'tcp'
|
|
167
|
+
? { Host: listener.hostHeader, Origin: `http://${listener.hostHeader}` }
|
|
168
|
+
: { Host: '127.0.0.1' };
|
|
169
|
+
return {
|
|
170
|
+
endpoint: address,
|
|
171
|
+
listener,
|
|
172
|
+
request: async ({ body, headers, method, path }) => {
|
|
173
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
174
|
+
try {
|
|
175
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential by design: retry after a wait
|
|
176
|
+
return await exchange(target, method, path, { ...headers, ...fixed }, body, timeoutMs);
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
if (!neverConnected(cause) || attempt >= retries) {
|
|
179
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
180
|
+
const message = `Caddy admin ${method} ${path} at ${address}: ${reason}`;
|
|
181
|
+
throw neverConnected(cause) ? new CaddyUnreachableError(message) : new Error(message);
|
|
182
|
+
}
|
|
183
|
+
// oxlint-disable-next-line no-await-in-loop -- the wait between attempts
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
};
|