@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,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CaddyConfig's read / diff / reconcile as plain async functions over a CaddyAdmin, so the
|
|
3
|
+
* lifecycle runs against a fake admin server in tests.
|
|
4
|
+
*
|
|
5
|
+
* The comparison is always between DIGESTS OF ADAPTED JSON (digest.ts): what `POST /adapt` makes
|
|
6
|
+
* of the declared Caddyfile, what `GET /config/` reports, and what was stored after the last
|
|
7
|
+
* apply. Three digests, because they answer different questions:
|
|
8
|
+
* · declared ≠ stored — the declaration changed.
|
|
9
|
+
* · live ≠ stored — DRIFT: a hand `curl` to the API, or a restart that loaded a different file
|
|
10
|
+
* (or an `autosave.json` holding someone else's change, under `--resume`).
|
|
11
|
+
* · declared = live — nothing to load, whatever the state says.
|
|
12
|
+
* With NO state there is no stored digest, and the question is whose config is running: see
|
|
13
|
+
* `claimable` below, which both the adoption probe and the apply ask.
|
|
14
|
+
*/
|
|
15
|
+
import type { Diff } from 'alchemy/Diff';
|
|
16
|
+
import { type CaddyAdmin, CaddyUnreachableError } from './admin.ts';
|
|
17
|
+
import {
|
|
18
|
+
CaddyAdminError,
|
|
19
|
+
adaptCaddyfile,
|
|
20
|
+
loadCaddyfile,
|
|
21
|
+
readRunningConfig,
|
|
22
|
+
} from './admin-calls.ts';
|
|
23
|
+
import { adminProblems } from './admin-guard.ts';
|
|
24
|
+
import {
|
|
25
|
+
type CaddyConfigAttributes,
|
|
26
|
+
type CaddyConfigProps,
|
|
27
|
+
configProblems,
|
|
28
|
+
} from './config-form.ts';
|
|
29
|
+
import { configDigest } from './digest.ts';
|
|
30
|
+
|
|
31
|
+
const refuse = (admin: CaddyAdmin, message: string): Error =>
|
|
32
|
+
new Error(`Caddy.Config at ${admin.endpoint}: ${message}`);
|
|
33
|
+
|
|
34
|
+
const short = (digest: string): string => digest.slice(0, 12);
|
|
35
|
+
|
|
36
|
+
type Desired = { readonly digest: string; readonly warnings: readonly string[] };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ⛔ A CONFIG WITH NO APPS SERVES NOTHING. A Caddyfile of only comments or only global options
|
|
40
|
+
* passes the empty-text check (config-form.ts) yet adapts to `{}` or `{"admin":…}` — MEASURED
|
|
41
|
+
* 2026-09-21 on a throwaway Caddy 2.11.4 — and loading it stops every server: the same outage,
|
|
42
|
+
* from the same templating bug, that the empty check exists to prevent.
|
|
43
|
+
*/
|
|
44
|
+
const servesNothing = (config: unknown): boolean => {
|
|
45
|
+
const apps =
|
|
46
|
+
typeof config === 'object' && config !== null ? (config as { apps?: unknown }).apps : undefined;
|
|
47
|
+
return typeof apps !== 'object' || apps === null || Object.keys(apps).length === 0;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate the props, adapt the Caddyfile on the running Caddy, and check its `admin` block.
|
|
52
|
+
* ★ Every refusal happens HERE, before anything is loaded — and diff calls this at plan time, so a
|
|
53
|
+
* Caddyfile that does not adapt, carries a literal secret, or would strand the admin API fails the
|
|
54
|
+
* PLAN, before any resource (a HostFile holding the same text included) is applied.
|
|
55
|
+
*/
|
|
56
|
+
export const desiredConfig = async (
|
|
57
|
+
admin: CaddyAdmin,
|
|
58
|
+
props: CaddyConfigProps,
|
|
59
|
+
): Promise<Desired> => {
|
|
60
|
+
const found = configProblems(props);
|
|
61
|
+
if (found.length > 0) throw refuse(admin, found.join('; '));
|
|
62
|
+
const adapted = await adaptCaddyfile(admin, props.caddyfile);
|
|
63
|
+
if (servesNothing(adapted.config)) {
|
|
64
|
+
throw refuse(admin, 'the Caddyfile adapts to no apps — loading it would stop every site');
|
|
65
|
+
}
|
|
66
|
+
const guard = adminProblems(adapted.config, admin.listener);
|
|
67
|
+
if (guard.length > 0) throw refuse(admin, guard.join('; '));
|
|
68
|
+
return { digest: configDigest(adapted.config), warnings: adapted.warnings };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const attributesOf = (
|
|
72
|
+
admin: CaddyAdmin,
|
|
73
|
+
running: unknown,
|
|
74
|
+
sourceFile?: string,
|
|
75
|
+
): CaddyConfigAttributes => ({
|
|
76
|
+
configSha256: configDigest(running),
|
|
77
|
+
endpoint: admin.endpoint,
|
|
78
|
+
...(sourceFile === undefined ? {} : { sourceFile }),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
|
|
82
|
+
export const readLive = async (
|
|
83
|
+
admin: CaddyAdmin,
|
|
84
|
+
sourceFile?: string,
|
|
85
|
+
): Promise<CaddyConfigAttributes> =>
|
|
86
|
+
attributesOf(admin, await readRunningConfig(admin), sourceFile);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ⛔ WITH NO STATE, A RUNNING CONFIG IS THIS STACK'S ONLY IF IT IS THE DECLARED ONE (decision,
|
|
90
|
+
* 2026-09-21: CaddyConfig must not adopt silently — the house rule HostFile and LaunchdJob keep).
|
|
91
|
+
* Anything else was put there by a person, another tool or another stack, and loading over it
|
|
92
|
+
* replaces every site it serves. ★ A config that serves nothing (`null`, or no apps) is claimable
|
|
93
|
+
* too: there is nothing to take over, as an empty R2 lock rule set reads as no lock.
|
|
94
|
+
*/
|
|
95
|
+
export const claimable = (running: unknown, declaredDigest: string): boolean =>
|
|
96
|
+
servesNothing(running) || configDigest(running) === declaredDigest;
|
|
97
|
+
|
|
98
|
+
export type Probe = {
|
|
99
|
+
readonly attributes: CaddyConfigAttributes;
|
|
100
|
+
/** The live config IS the declared one: adopting it changes nothing. */
|
|
101
|
+
readonly ours: boolean;
|
|
102
|
+
/** Why the declaration could not be compared, when it could not — the Caddy is then not ours. */
|
|
103
|
+
readonly unchecked?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The adoption probe — `read` with no state: `undefined` when Caddy serves nothing (plan a create),
|
|
108
|
+
* else the live attributes and whether they are provably ours (the provider brands the rest
|
|
109
|
+
* `Unowned`, and the plan refuses them without `--adopt`).
|
|
110
|
+
* ⛔ IT NEVER THROWS OVER THE DECLARATION. The engine also runs this read to recover an interrupted
|
|
111
|
+
* create, with THAT deploy's props (Plan.ts, `status: "creating"`) — so a Caddyfile that failed to
|
|
112
|
+
* adapt then would fail every later plan, the one carrying the fix included. What cannot be
|
|
113
|
+
* compared is "not proven ours", with the reason; the next diff or apply validates the Caddyfile.
|
|
114
|
+
*/
|
|
115
|
+
export const probeLive = async (
|
|
116
|
+
admin: CaddyAdmin,
|
|
117
|
+
caddyfile: string | undefined,
|
|
118
|
+
sourceFile?: string,
|
|
119
|
+
): Promise<Probe | undefined> => {
|
|
120
|
+
const running = await readRunningConfig(admin);
|
|
121
|
+
if (servesNothing(running)) return undefined;
|
|
122
|
+
const attributes = attributesOf(admin, running, sourceFile);
|
|
123
|
+
if (caddyfile === undefined) {
|
|
124
|
+
return { attributes, ours: false, unchecked: 'the stored Caddyfile is not plain text' };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const want = await desiredConfig(admin, { caddyfile });
|
|
128
|
+
return { attributes, ours: claimable(running, want.digest) };
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
// ⚠️ Unreachable mid-probe is still "no Caddy" (config.ts plans without it), not "not ours".
|
|
131
|
+
if (cause instanceof CaddyUnreachableError) throw cause;
|
|
132
|
+
return {
|
|
133
|
+
attributes,
|
|
134
|
+
ours: false,
|
|
135
|
+
unchecked: cause instanceof Error ? cause.message : String(cause),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
|
|
142
|
+
* the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
|
|
143
|
+
* the ⚠️ on `endpoint` in the diff.)
|
|
144
|
+
*/
|
|
145
|
+
export const diffConfig = async (
|
|
146
|
+
admin: CaddyAdmin,
|
|
147
|
+
news: CaddyConfigProps,
|
|
148
|
+
output: CaddyConfigAttributes,
|
|
149
|
+
): Promise<Diff> => {
|
|
150
|
+
const want = await desiredConfig(admin, news);
|
|
151
|
+
const live = await readLive(admin);
|
|
152
|
+
const converged =
|
|
153
|
+
want.digest === live.configSha256 &&
|
|
154
|
+
live.configSha256 === output.configSha256 &&
|
|
155
|
+
news.sourceFile === output.sourceFile &&
|
|
156
|
+
// ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
|
|
157
|
+
// old one keeps what it has — the same as a delete, which never unloads (see config.ts).
|
|
158
|
+
// Planned as an update, but the state does not vouch for that Caddy: see Authority.
|
|
159
|
+
admin.endpoint === output.endpoint;
|
|
160
|
+
return converged ? { action: 'noop' } : { action: 'update' };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type Applied = {
|
|
164
|
+
readonly attributes: CaddyConfigAttributes;
|
|
165
|
+
/** The adapter's warnings (e.g. "Caddyfile input is not formatted") — log them, never refuse. */
|
|
166
|
+
readonly warnings: readonly string[];
|
|
167
|
+
/** Whether a `POST /load` was sent (false when Caddy already ran this config). */
|
|
168
|
+
readonly loaded: boolean;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What this apply may load over — config.ts decides from the state and the adopt setting:
|
|
173
|
+
* · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
|
|
174
|
+
* correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
|
|
175
|
+
* resource's own `adopt(true)` — host-effect.ts adoptEnabled).
|
|
176
|
+
* · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
|
|
177
|
+
* Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
|
|
178
|
+
*/
|
|
179
|
+
export type Authority = { readonly takeOver: boolean; readonly stored?: string };
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches.
|
|
183
|
+
* ⛔ WITHOUT `takeOver`, A CONFIG THE STACK CANNOT CLAIM IS NEVER LOADED OVER. The engine's adoption
|
|
184
|
+
* probe cannot guard every create: it is skipped while `news` holds an Output — ALWAYS on
|
|
185
|
+
* caddyWithFile's first deploy (`sourceFile` is its HostFile's path) — and a Caddy that was down at
|
|
186
|
+
* plan time read as nothing. Nor does state vouch for a Caddy the transport NOW reaches at another
|
|
187
|
+
* endpoint (diff plans that as an update). Refused here, before any `/load`, like HostFile's.
|
|
188
|
+
*/
|
|
189
|
+
export const reconcileConfig = async (
|
|
190
|
+
admin: CaddyAdmin,
|
|
191
|
+
props: CaddyConfigProps,
|
|
192
|
+
authority: Authority,
|
|
193
|
+
): Promise<Applied> => {
|
|
194
|
+
const want = await desiredConfig(admin, props);
|
|
195
|
+
const running = await readRunningConfig(admin);
|
|
196
|
+
const before = configDigest(running);
|
|
197
|
+
const known = before === authority.stored || claimable(running, want.digest);
|
|
198
|
+
if (!authority.takeOver && !known) {
|
|
199
|
+
throw refuse(
|
|
200
|
+
admin,
|
|
201
|
+
`Caddy runs config ${short(before)}, which this stack did not load, and the Caddyfile adapts ` +
|
|
202
|
+
`to ${short(want.digest)} — loading it would replace every site that config serves. ` +
|
|
203
|
+
'Deploy with --adopt (or wrap the resource in adopt(true)) to take this Caddy over, or ' +
|
|
204
|
+
'point caddyProviders() at the Caddy you meant.',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
let warnings = want.warnings;
|
|
208
|
+
const loaded = before !== want.digest;
|
|
209
|
+
if (loaded) {
|
|
210
|
+
try {
|
|
211
|
+
warnings = await loadCaddyfile(admin, props.caddyfile, props.sourceFile);
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
if (!(cause instanceof CaddyAdminError)) throw cause;
|
|
214
|
+
// ★ SAY WHAT IS RUNNING NOW. Caddy restores the previous config on a failed load (caddy.go
|
|
215
|
+
// changeConfig); confirm it rather than promise it, because the deploy log is where the
|
|
216
|
+
// person decides whether sites are down.
|
|
217
|
+
const after = await readLive(admin).catch(() => undefined);
|
|
218
|
+
const state =
|
|
219
|
+
after === undefined
|
|
220
|
+
? 'and the running config could not be read back'
|
|
221
|
+
: after.configSha256 === before
|
|
222
|
+
? 'Caddy kept the previous config (still serving)'
|
|
223
|
+
: `and the running config is now ${short(after.configSha256)}, not the previous ${short(before)}`;
|
|
224
|
+
throw refuse(admin, `Caddy refused the Caddyfile — ${cause.message}; ${state}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// ⚠️ READ BACK, never echo the declaration: a concurrent writer, or a load Caddy answered 200 but
|
|
228
|
+
// did not apply (admin-calls.ts), shows up here as a refusal instead of as stored state that is
|
|
229
|
+
// already wrong.
|
|
230
|
+
const attributes = await readLive(admin, props.sourceFile);
|
|
231
|
+
if (attributes.configSha256 !== want.digest) {
|
|
232
|
+
throw refuse(
|
|
233
|
+
admin,
|
|
234
|
+
`after the load Caddy runs config ${short(attributes.configSha256)}, not the ` +
|
|
235
|
+
`${short(want.digest)} this Caddyfile adapts to — another writer changed it, or it was not applied`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return { attributes, loaded, warnings };
|
|
239
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Caddy.Config` — a running Caddy's whole config, declared as Caddyfile text and applied through
|
|
3
|
+
* Caddy's own admin API (decision 23).
|
|
4
|
+
*
|
|
5
|
+
* - create / update — `POST /adapt` (validate), `POST /load` with `text/caddyfile` (a graceful
|
|
6
|
+
* reload; Caddy keeps the old config if it refuses the new one, and the error names why), then
|
|
7
|
+
* `GET /config/` and insist it matches. Skipped when Caddy already runs this config.
|
|
8
|
+
* - read — the digest of `GET /config/`. With no state it is the adoption probe (below).
|
|
9
|
+
* - diff — declared, live and stored digests (config-lifecycle.ts). Never `replace`.
|
|
10
|
+
* - delete — ⛔ NOTHING. See below.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ DELETE NEVER TOUCHES CADDY, UNDER EITHER REMOVAL POLICY. Removing this declaration must not take
|
|
13
|
+
* every site down: there is no "less config" to fall back to, and an empty config stops all
|
|
14
|
+
* servers. `retain` is the default (the engine never calls delete); with `destroy` the handler
|
|
15
|
+
* still only forgets. It never calls `/stop` (which exits the process) or `DELETE /config/`.
|
|
16
|
+
* To empty a Caddy, do it by hand at its admin API.
|
|
17
|
+
* ⛔ NOTHING IS ADOPTED SILENTLY (decision, 2026-09-21 — the house rule HostFile and LaunchdJob
|
|
18
|
+
* keep). With no state, a running Caddy whose live config IS the declared one reads as ours (the
|
|
19
|
+
* adoption changes nothing); any other config reads as `Unowned`, and the plan refuses it unless
|
|
20
|
+
* the deploy runs with `--adopt`. A Caddy serving nothing reads as absent: a create. Where the
|
|
21
|
+
* engine skips that probe, reconcile refuses the same takeover (config-lifecycle.ts).
|
|
22
|
+
* ⛔ `caddyfile` IS NEVER A SECRET — see config-form.ts; refused before anything is sent.
|
|
23
|
+
* ⚠️ WHICH CADDY is the transport's (providers.ts), not a prop: one CaddyAdmin per stack.
|
|
24
|
+
*/
|
|
25
|
+
import { Resource } from 'alchemy';
|
|
26
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
27
|
+
import { isResolved } from 'alchemy/Diff';
|
|
28
|
+
import * as Provider from 'alchemy/Provider';
|
|
29
|
+
import * as Effect from 'effect/Effect';
|
|
30
|
+
import { adoptEnabled, lift, resolvedString } from '../launchd/host-effect.ts';
|
|
31
|
+
import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
|
|
32
|
+
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
33
|
+
import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
|
|
34
|
+
|
|
35
|
+
export type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
36
|
+
|
|
37
|
+
export interface CaddyConfig extends Resource<
|
|
38
|
+
'Caddy.Config',
|
|
39
|
+
CaddyConfigProps,
|
|
40
|
+
CaddyConfigAttributes
|
|
41
|
+
> {}
|
|
42
|
+
|
|
43
|
+
export const CaddyConfig = Resource<CaddyConfig>('Caddy.Config', {
|
|
44
|
+
defaultRemovalPolicy: 'retain',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const unreachable = (error: Error): error is CaddyUnreachableError =>
|
|
48
|
+
error instanceof CaddyUnreachableError;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ⚠️ A STOPPED CADDY MUST NOT FAIL THE PLAN THAT BRINGS IT BACK. Its launchd job usually sits in the
|
|
52
|
+
* same stack; if every plan died on `ECONNREFUSED`, the fix to that job could never be deployed.
|
|
53
|
+
* So at PLAN time only (read, diff) an unreachable Caddy plans an update — with a warning, because
|
|
54
|
+
* the plan-time `/adapt` check did not run — and reconcile, which needs Caddy, still fails loudly.
|
|
55
|
+
*/
|
|
56
|
+
const planWithoutCaddy =
|
|
57
|
+
<A>(admin: { endpoint: string }, fallback: A) =>
|
|
58
|
+
(error: Error) =>
|
|
59
|
+
Effect.as(
|
|
60
|
+
Effect.logWarning(
|
|
61
|
+
`Caddy.Config: no Caddy at ${admin.endpoint} (${error.message}); planning without it — ` +
|
|
62
|
+
'the Caddyfile is validated when the deploy reaches Caddy',
|
|
63
|
+
),
|
|
64
|
+
fallback,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export const CaddyConfigProvider = () =>
|
|
68
|
+
Provider.effect(
|
|
69
|
+
CaddyConfig,
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
const admin = yield* CaddyAdminService;
|
|
72
|
+
/** The probe, branded for the engine: plain when ours, `Unowned` otherwise. */
|
|
73
|
+
const probe = (caddyfile: string | undefined, sourceFile: string | undefined) =>
|
|
74
|
+
Effect.flatMap(
|
|
75
|
+
lift(() => probeLive(admin, caddyfile, sourceFile)),
|
|
76
|
+
(found) => {
|
|
77
|
+
if (found === undefined) return Effect.succeed(undefined);
|
|
78
|
+
if (found.ours) return Effect.succeed(found.attributes);
|
|
79
|
+
const unowned = Unowned(found.attributes);
|
|
80
|
+
return found.unchecked === undefined
|
|
81
|
+
? Effect.succeed(unowned)
|
|
82
|
+
: Effect.as(
|
|
83
|
+
Effect.logWarning(
|
|
84
|
+
`Caddy.Config at ${admin.endpoint}: the running config could not be compared ` +
|
|
85
|
+
`with the declared Caddyfile (${found.unchecked}), so it is not treated as ours`,
|
|
86
|
+
),
|
|
87
|
+
unowned,
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
return CaddyConfig.Provider.of({
|
|
92
|
+
/** ⛔ A Caddy has one config; there is nothing to enumerate, and nothing for nuke to delete. */
|
|
93
|
+
list: () => Effect.succeed([]),
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* With state: the live attributes, which diff compares. With none, the ADOPTION PROBE —
|
|
97
|
+
* `olds` is the declaration (Plan.ts passes `news`), and only a live config equal to it is
|
|
98
|
+
* ours. ⚠️ A running Caddy is still the one the stack means (the transport reaches only
|
|
99
|
+
* loopback), but WHOSE CONFIG it runs is not: a person, another tool or another stack.
|
|
100
|
+
*/
|
|
101
|
+
read: ({ olds, output }) =>
|
|
102
|
+
(output === undefined
|
|
103
|
+
? probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
|
|
104
|
+
: lift(() => readLive(admin, resolvedString(olds, 'sourceFile')))
|
|
105
|
+
).pipe(
|
|
106
|
+
// ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
|
|
107
|
+
Effect.catchIf(unreachable, planWithoutCaddy(admin, undefined)),
|
|
108
|
+
),
|
|
109
|
+
|
|
110
|
+
diff: ({ news, output }) => {
|
|
111
|
+
if (output === undefined) return Effect.succeed(undefined);
|
|
112
|
+
const update = planWithoutCaddy(admin, { action: 'update' as const });
|
|
113
|
+
if (isResolved(news)) {
|
|
114
|
+
return lift(() => diffConfig(admin, news, output)).pipe(
|
|
115
|
+
Effect.catchIf(unreachable, update),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* ⚠️ `sourceFile` is an Output while its HostFile changes in the same deploy
|
|
120
|
+
* (caddyWithFile). The Caddyfile itself is usually plain text, so validate and compare
|
|
121
|
+
* it now — a bad Caddyfile must fail the PLAN, before the HostFile writes it to disk —
|
|
122
|
+
* and plan an update, which reloads only if the config really differs.
|
|
123
|
+
*/
|
|
124
|
+
const caddyfile = resolvedString(news, 'caddyfile');
|
|
125
|
+
if (caddyfile === undefined) return Effect.succeed(undefined);
|
|
126
|
+
return lift(async () => {
|
|
127
|
+
await diffConfig(admin, { caddyfile }, output);
|
|
128
|
+
return { action: 'update' as const };
|
|
129
|
+
}).pipe(Effect.catchIf(unreachable, update));
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
reconcile: ({ fqn, news, output }) =>
|
|
133
|
+
Effect.gen(function* () {
|
|
134
|
+
// ⛔ State is authority over the Caddy it was applied to, and no other. With none (the
|
|
135
|
+
// engine may not have probed: see reconcileConfig), or with the transport now at
|
|
136
|
+
// another endpoint, only adoption — `--adopt`, or this resource's own `adopt(…)`,
|
|
137
|
+
// as the planner resolves it — loads over a config the stack cannot claim.
|
|
138
|
+
const sameCaddy = output !== undefined && output.endpoint === admin.endpoint;
|
|
139
|
+
const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
|
|
140
|
+
const stored = output === undefined ? {} : { stored: output.configSha256 };
|
|
141
|
+
const applied = yield* lift(() =>
|
|
142
|
+
reconcileConfig(admin, news, { takeOver, ...stored }),
|
|
143
|
+
);
|
|
144
|
+
for (const warning of applied.warnings) {
|
|
145
|
+
yield* Effect.logWarning(`Caddy.Config at ${admin.endpoint}: ${warning}`);
|
|
146
|
+
}
|
|
147
|
+
return applied.attributes;
|
|
148
|
+
}),
|
|
149
|
+
|
|
150
|
+
delete: () => Effect.void,
|
|
151
|
+
});
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one form an adapted Caddy config is hashed in — so what `POST /adapt` returns for the
|
|
3
|
+
* declared Caddyfile and what `GET /config/` reports after the load hash the same.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE TWO ARE NOT THE SAME BYTES, AND COMPARING BYTES WOULD BE A FOREVER-`update`. Read in
|
|
6
|
+
* caddyserver/caddy v2.11.4:
|
|
7
|
+
* · `/adapt` returns the adapter's `json.Marshal(cfg)` of Go STRUCTS — keys in field order
|
|
8
|
+
* (caddyconfig/caddyfile/adapter.go Adapt; load.go handleAdapt).
|
|
9
|
+
* · `/load` decodes that JSON into a generic `any` and re-marshals it (caddy.go changeConfig):
|
|
10
|
+
* Go sorts MAP keys, so the stored config has every object's keys sorted.
|
|
11
|
+
* · `GET /config/` streams that stored value through `json.Encoder`, which appends a newline
|
|
12
|
+
* (admin.go unsyncedConfigAccess), and escapes `<`, `>` and `&` as `\u003c`, `\u003e`, `\u0026`.
|
|
13
|
+
* Parsing both and re-serialising with sorted keys erases all three differences. MEASURED
|
|
14
|
+
* 2026-09-21 on a throwaway Caddy 2.11.4: the digest of `GET /config/` after `POST /load` equals
|
|
15
|
+
* the digest of `/adapt` for the same Caddyfile — and a Caddy started with `--config <file>`
|
|
16
|
+
* hashes the same as `/adapt` of that file's text, so adopting it is a noop.
|
|
17
|
+
* ★ NUMBERS AGREE BY CONSTRUCTION. Go decodes every JSON number in that generic `any` as float64
|
|
18
|
+
* (caddy.go indexConfigObjects says so), and JSON.parse does too — the same double on both sides
|
|
19
|
+
* serialises the same way here.
|
|
20
|
+
*/
|
|
21
|
+
import { sha256Hex } from '../launchd/job-form.ts';
|
|
22
|
+
|
|
23
|
+
type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json };
|
|
24
|
+
|
|
25
|
+
const sorted = (value: Json): Json => {
|
|
26
|
+
if (Array.isArray(value)) return (value as readonly Json[]).map(sorted);
|
|
27
|
+
if (typeof value === 'object' && value !== null) {
|
|
28
|
+
const object = value as { readonly [key: string]: Json };
|
|
29
|
+
const out: Record<string, Json> = {};
|
|
30
|
+
for (const key of Object.keys(object).sort()) out[key] = sorted(object[key] ?? null);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Parse a config body Caddy returned. ⚠️ An empty config is the JSON `null`, not an error. */
|
|
37
|
+
export const parseConfig = (text: string): unknown => JSON.parse(text) as unknown;
|
|
38
|
+
|
|
39
|
+
/** The canonical text of a parsed config: sorted keys, no whitespace. */
|
|
40
|
+
export const canonicalConfig = (config: unknown): string =>
|
|
41
|
+
JSON.stringify(sorted((config ?? null) as Json));
|
|
42
|
+
|
|
43
|
+
/** SHA-256 of the canonical form — what the resource stores and compares, never the config. */
|
|
44
|
+
export const configDigest = (config: unknown): string => sha256Hex(canonicalConfig(config));
|
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
import type { CaddyAdmin } from './admin.ts';
|
|
21
|
+
import { localCaddyAdmin } from './local-admin.ts';
|
|
22
|
+
|
|
23
|
+
export type Seen = {
|
|
24
|
+
readonly method: string;
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly headers: Headers;
|
|
27
|
+
readonly body: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type FakeCaddy = {
|
|
31
|
+
/** An address localCaddyAdmin() takes. */
|
|
32
|
+
readonly address: string;
|
|
33
|
+
readonly port: number;
|
|
34
|
+
readonly seen: Seen[];
|
|
35
|
+
/** The running config as a parsed object (null when empty). */
|
|
36
|
+
running: unknown;
|
|
37
|
+
/** Adapt with the toy adapter — to stage a "restart with a different file". */
|
|
38
|
+
adapt(caddyfile: string): unknown;
|
|
39
|
+
stop(): void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const json = (status: number, body: unknown) =>
|
|
43
|
+
new Response(`${JSON.stringify(body)}\n`, {
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
status,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** The toy adapter: struct key order on purpose (`routes` before `listen`, `apps` before `admin`). */
|
|
49
|
+
const toyAdapt = (text: string): { config?: unknown; error?: string; warnings: unknown[] } => {
|
|
50
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#'));
|
|
51
|
+
const at = lines.findIndex((line) => line.includes('SYNTAX_ERROR'));
|
|
52
|
+
if (at !== -1)
|
|
53
|
+
return { error: `Caddyfile:${String(at + 1)} - Error during parsing`, warnings: [] };
|
|
54
|
+
const admin: Record<string, unknown> = {};
|
|
55
|
+
const routes: unknown[] = [];
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const [word, ...rest] = line.trim().split(/\s+/);
|
|
58
|
+
if (word === 'admin' && rest[0] === 'off') admin['disabled'] = true;
|
|
59
|
+
else if (word === 'admin') admin['listen'] = rest[0];
|
|
60
|
+
else if (word === 'origins') admin['origins'] = rest;
|
|
61
|
+
else routes.push({ match: [{ host: [word] }], handle: [{ handler: 'static', body: line }] });
|
|
62
|
+
}
|
|
63
|
+
const warnings = text.includes('\t')
|
|
64
|
+
? [{ file: 'Caddyfile', line: 1, message: 'Caddyfile input is not formatted' }]
|
|
65
|
+
: [];
|
|
66
|
+
const config = {
|
|
67
|
+
...(routes.length === 0
|
|
68
|
+
? {}
|
|
69
|
+
: { apps: { http: { servers: { srv0: { routes, listen: [':443'] } } } } }),
|
|
70
|
+
...(Object.keys(admin).length === 0 ? {} : { admin }),
|
|
71
|
+
};
|
|
72
|
+
return { config, warnings };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Go's generic re-marshal: sorted keys, `<>&` escaped. */
|
|
76
|
+
const goEncode = (value: unknown): string => {
|
|
77
|
+
const sort = (v: unknown): unknown =>
|
|
78
|
+
Array.isArray(v)
|
|
79
|
+
? v.map(sort)
|
|
80
|
+
: typeof v === 'object' && v !== null
|
|
81
|
+
? Object.fromEntries(
|
|
82
|
+
Object.keys(v)
|
|
83
|
+
.sort()
|
|
84
|
+
.map((k) => [k, sort((v as Record<string, unknown>)[k])]),
|
|
85
|
+
)
|
|
86
|
+
: v;
|
|
87
|
+
return JSON.stringify(sort(value))
|
|
88
|
+
.replaceAll('<', '\\u003c')
|
|
89
|
+
.replaceAll('>', '\\u003e')
|
|
90
|
+
.replaceAll('&', '\\u0026');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const fakeCaddy = (
|
|
94
|
+
options: { unix?: string; running?: unknown; origins?: string[]; listenPort?: number } = {},
|
|
95
|
+
): FakeCaddy => {
|
|
96
|
+
const seen: Seen[] = [];
|
|
97
|
+
let port = 0;
|
|
98
|
+
const state: { running: unknown } = { running: options.running ?? null };
|
|
99
|
+
const allowedHosts = () =>
|
|
100
|
+
options.origins ??
|
|
101
|
+
['localhost', '[::1]', '127.0.0.1'].map(
|
|
102
|
+
(host) => `${host}:${String(options.listenPort ?? port)}`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const handle = async (request: Request): Promise<Response> => {
|
|
106
|
+
const url = new URL(request.url);
|
|
107
|
+
const body = await request.text();
|
|
108
|
+
seen.push({ body, headers: request.headers, method: request.method, path: url.pathname });
|
|
109
|
+
if (options.unix === undefined) {
|
|
110
|
+
const host = request.headers.get('host') ?? '';
|
|
111
|
+
if (!allowedHosts().includes(host)) return json(403, { error: `host not allowed: ${host}` });
|
|
112
|
+
const origin = request.headers.get('origin');
|
|
113
|
+
if (origin !== null && !allowedHosts().includes(new URL(origin).host)) {
|
|
114
|
+
return json(403, { error: `client is not allowed to access from origin '${origin}'` });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (url.pathname === '/config/' && request.method === 'GET') {
|
|
118
|
+
return new Response(`${goEncode(state.running)}\n`, { status: 200 });
|
|
119
|
+
}
|
|
120
|
+
const type = request.headers.get('content-type') ?? '';
|
|
121
|
+
if (request.method !== 'POST' || type !== 'text/caddyfile')
|
|
122
|
+
return json(400, { error: 'unexpected' });
|
|
123
|
+
const adapted = toyAdapt(body);
|
|
124
|
+
if (adapted.error !== undefined) return json(400, { error: adapted.error });
|
|
125
|
+
if (url.pathname === '/adapt') {
|
|
126
|
+
const out = adapted.warnings.length > 0 ? { warnings: adapted.warnings } : {};
|
|
127
|
+
return json(200, { ...out, result: adapted.config });
|
|
128
|
+
}
|
|
129
|
+
if (url.pathname !== '/load') return json(404, { error: 'not found' });
|
|
130
|
+
const prefix = adapted.warnings.length > 0 ? JSON.stringify(adapted.warnings) : '';
|
|
131
|
+
if (body.includes('PROVISION_ERROR')) {
|
|
132
|
+
const error = JSON.stringify({
|
|
133
|
+
error: 'loading config: provision http: listen tcp :443: address already in use',
|
|
134
|
+
});
|
|
135
|
+
// ⛔ load.go: warnings written first commit a 200; the error rides after them.
|
|
136
|
+
return prefix === ''
|
|
137
|
+
? new Response(`${error}\n`, { status: 400 })
|
|
138
|
+
: new Response(`${prefix}${error}\n`, { status: 200 });
|
|
139
|
+
}
|
|
140
|
+
state.running = JSON.parse(goEncode(adapted.config));
|
|
141
|
+
return new Response(prefix, { status: 200 });
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const server =
|
|
145
|
+
options.unix === undefined
|
|
146
|
+
? Bun.serve({ fetch: handle, hostname: '127.0.0.1', port: 0 })
|
|
147
|
+
: Bun.serve({ fetch: handle, unix: options.unix });
|
|
148
|
+
port = server.port ?? 0;
|
|
149
|
+
return {
|
|
150
|
+
adapt: (caddyfile) => toyAdapt(caddyfile).config,
|
|
151
|
+
address:
|
|
152
|
+
options.unix === undefined ? `http://127.0.0.1:${String(port)}` : `unix://${options.unix}`,
|
|
153
|
+
get running() {
|
|
154
|
+
return state.running;
|
|
155
|
+
},
|
|
156
|
+
set running(value: unknown) {
|
|
157
|
+
state.running = value;
|
|
158
|
+
},
|
|
159
|
+
port,
|
|
160
|
+
seen,
|
|
161
|
+
stop: () => void server.stop(true),
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A fake Caddy on its DEFAULT :2019 behind a forward (`hostHeader`), and the transport to it.
|
|
167
|
+
* ★ Addressed that way so a Caddyfile with no `admin` line — the common case — passes the guard.
|
|
168
|
+
* A Caddy reached on any other port needs `admin <address>` declared, or the first load would
|
|
169
|
+
* move it (admin-guard.ts).
|
|
170
|
+
*/
|
|
171
|
+
export const fakeDefaultCaddy = (running?: unknown): { admin: CaddyAdmin; caddy: FakeCaddy } => {
|
|
172
|
+
const caddy = fakeCaddy({ listenPort: 2019, ...(running === undefined ? {} : { running }) });
|
|
173
|
+
const admin = localCaddyAdmin({
|
|
174
|
+
address: caddy.address,
|
|
175
|
+
hostHeader: '127.0.0.1:2019',
|
|
176
|
+
retries: 0,
|
|
177
|
+
});
|
|
178
|
+
return { admin, caddy };
|
|
179
|
+
};
|