@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
package/docs/caddy.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Caddy — `@homeflare/alchemy/caddy`
|
|
2
|
+
|
|
3
|
+
Declare a running Caddy's config as Caddyfile text and apply it through Caddy's own admin API:
|
|
4
|
+
`POST /load` to apply (a graceful reload), `GET /config/` to detect drift. `caddyWithFile()` also
|
|
5
|
+
writes the same Caddyfile to the file Caddy starts from, with launchd's `HostFile`, so a restart
|
|
6
|
+
keeps it. Every admin call goes through one injectable `CaddyAdmin` transport.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import { caddyProviders, caddyWithFile } from '@homeflare/alchemy/caddy';
|
|
12
|
+
import { LaunchdJob, launchdProviders } from '@homeflare/alchemy/launchd';
|
|
13
|
+
|
|
14
|
+
const caddyfile = `app.example.com {
|
|
15
|
+
\treverse_proxy 127.0.0.1:8080
|
|
16
|
+
\ttls {
|
|
17
|
+
\t\tdns cloudflare {env.CF_API_TOKEN}
|
|
18
|
+
\t}
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export const edge = Effect.gen(function* () {
|
|
23
|
+
const { file } = yield* caddyWithFile('caddy', { caddyfile, path: '/usr/local/etc/Caddyfile' });
|
|
24
|
+
yield* LaunchdJob('caddy-daemon', {
|
|
25
|
+
label: 'com.example.caddy',
|
|
26
|
+
domain: 'system',
|
|
27
|
+
// --resume: a restart runs the last config Caddy accepted (see Managed Caddies below).
|
|
28
|
+
// --envfile: a file a secret renderer (openbao-agent) writes; {env.CF_API_TOKEN} reads it.
|
|
29
|
+
programArguments: [
|
|
30
|
+
'/usr/local/bin/caddy',
|
|
31
|
+
'run',
|
|
32
|
+
'--resume',
|
|
33
|
+
'--config',
|
|
34
|
+
file.path,
|
|
35
|
+
'--adapter',
|
|
36
|
+
'caddyfile',
|
|
37
|
+
'--envfile',
|
|
38
|
+
'/usr/local/etc/caddy.env',
|
|
39
|
+
],
|
|
40
|
+
// ⛔ Here, not in the envfile: Caddy fixes its autosave path before it reads --envfile.
|
|
41
|
+
environment: { XDG_CONFIG_HOME: '/usr/local/var/caddy-edge' },
|
|
42
|
+
runAtLoad: true,
|
|
43
|
+
keepAlive: true,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
// ★ launchdProviders() for the HostFile and the job; caddyProviders() for the config.
|
|
47
|
+
export const providers = Layer.mergeAll(caddyProviders(), launchdProviders());
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## CaddyConfig
|
|
51
|
+
|
|
52
|
+
| prop | notes |
|
|
53
|
+
| ------------ | ---------------------------------------------------------------------------- |
|
|
54
|
+
| `caddyfile` | the whole Caddyfile; it REPLACES the running config. ⛔ never a secret |
|
|
55
|
+
| `sourceFile` | the file Caddy was started with; `caddyWithFile()` sets it from its HostFile |
|
|
56
|
+
|
|
57
|
+
| attribute | what |
|
|
58
|
+
| -------------- | ------------------------------------------------------------------------------------ |
|
|
59
|
+
| `configSha256` | SHA-256 of the adapted JSON Caddy reported after the apply — never the config itself |
|
|
60
|
+
| `endpoint` | where the admin API was reached |
|
|
61
|
+
| `sourceFile` | as applied |
|
|
62
|
+
|
|
63
|
+
| step | what happens |
|
|
64
|
+
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
|
|
66
|
+
| apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
|
|
67
|
+
| refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
|
|
68
|
+
| read | the digest of `GET /config/`; with no state, the adoption probe — ⛔ a config that is not the declared one needs `--adopt` ([Adoption](#adoption)) |
|
|
69
|
+
| drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
|
|
70
|
+
| replace | never — a new Caddyfile is a reload of the same Caddy |
|
|
71
|
+
| delete | ⛔ nothing. See [Removing it](#removing-it) |
|
|
72
|
+
|
|
73
|
+
The digests compare adapted JSON, not bytes: `/adapt` answers Go struct order, `GET /config/` sorted
|
|
74
|
+
keys with `<>&` escaped and a trailing newline. `digest.ts` erases all three. ★ Measured 2026-09-21
|
|
75
|
+
against a throwaway Caddy 2.11.4: the digests agree after a load and for a Caddy started from the
|
|
76
|
+
same file, and a refused load kept serving the old config and answered **200** with the error after
|
|
77
|
+
the adapter's warnings, which the provider still reports as a refusal.
|
|
78
|
+
|
|
79
|
+
⚠️ **A stopped Caddy does not fail the plan.** Its launchd job may be the fix in the same stack, so
|
|
80
|
+
`read` and `diff` plan a create/update with a warning (the plan-time `/adapt` did not run). The apply
|
|
81
|
+
still needs Caddy, and fails loudly until it answers.
|
|
82
|
+
|
|
83
|
+
## Order: file, then `/load`
|
|
84
|
+
|
|
85
|
+
`caddyWithFile()` passes the HostFile's `path` Output into the CaddyConfig, so Alchemy writes the
|
|
86
|
+
file first. It is the only order that bootstraps: a new host's Caddy job starts from the file,
|
|
87
|
+
before any admin API exists to load into. What bounds the cost:
|
|
88
|
+
|
|
89
|
+
- ★ **A Caddyfile that does not adapt never reaches disk** once the config has state: the plan-time
|
|
90
|
+
`/adapt` fails the plan. On the very first deploy the path is still an Output, Alchemy skips the
|
|
91
|
+
adoption probe, and the check runs at apply instead.
|
|
92
|
+
- ⚠️ **A Caddyfile that adapts but that Caddy refuses** (a port in use, a missing cert file) is on
|
|
93
|
+
disk when `/load` fails. Caddy keeps serving the old config, and a managed Caddy's restart runs its
|
|
94
|
+
autosave, not the refused file (below). Without `--resume` a **restart before the fix loads the
|
|
95
|
+
refused file**: fix and redeploy.
|
|
96
|
+
- ⛔ Secrets are refused at declaration, before the HostFile exists (its `content` is a prop too).
|
|
97
|
+
- ★ Both resources retain: removing them from a stack leaves the file a restart needs.
|
|
98
|
+
|
|
99
|
+
## Managed Caddies: `--resume` and their own config dir
|
|
100
|
+
|
|
101
|
+
★ **Decision, 2026-09-21.** A Caddy this package manages runs `caddy run --resume --config <file>`
|
|
102
|
+
with `XDG_CONFIG_HOME` set to a directory of its own, so a restart runs the last config Caddy
|
|
103
|
+
**accepted**. After every successful load (ours included) Caddy writes the config JSON to
|
|
104
|
+
`$XDG_CONFIG_HOME/caddy/autosave.json`; `--resume` starts from it, and from `--config` only when no
|
|
105
|
+
autosave exists (cmd/commandfuncs.go cmdRun). Read in caddyserver/caddy v2.11.4:
|
|
106
|
+
|
|
107
|
+
- ★ **A refused Caddyfile on disk cannot take sites down.** The file is the first-boot fallback: a
|
|
108
|
+
new host, or an autosave someone deleted.
|
|
109
|
+
- ★ **Its own directory,** so nothing else (a Homebrew service, a person's `caddy run`) writes the
|
|
110
|
+
autosave it resumes from. With neither `HOME` nor `XDG_CONFIG_HOME` a daemon would use `./caddy/`
|
|
111
|
+
under its working directory (storage.go AppConfigDir).
|
|
112
|
+
- ⛔ **Set `XDG_CONFIG_HOME` in the job's `environment`, not the envfile.** The autosave path is
|
|
113
|
+
fixed when the binary starts (storage.go `var ConfigAutosavePath`), before `--envfile` is read.
|
|
114
|
+
- ⛔ **No `persist_config off`** in a managed Caddyfile. It stops the autosave, so `--resume` starts
|
|
115
|
+
from a stale one (the last config before it) or, with none, from the file.
|
|
116
|
+
- ⚠️ **A hand change through the API survives a restart.** The next plan reports it as drift, and the
|
|
117
|
+
deploy puts the declaration back.
|
|
118
|
+
- ⚠️ **SIGUSR1 is not a way to change config.** After a resumed start Caddy records no source file
|
|
119
|
+
(cmdRun calls `SetLastConfig` only after loading `--config`), so SIGUSR1 is ignored with the log
|
|
120
|
+
line `last config unknown`. Our `Caddy-Config-Source-File` header keeps a source Caddy already
|
|
121
|
+
has (caddy.go ClearLastConfigIfDifferent) and never sets one. After a first boot from the file,
|
|
122
|
+
SIGUSR1 still reloads it: the text the deploy wrote. Change config by deploying. Editing the file
|
|
123
|
+
and running `caddy reload` is a hand `/load`, which the next plan reports as drift.
|
|
124
|
+
- ⛔ **No `--watch`.** After a resumed start it has no file to watch, and falls back to a `Caddyfile`
|
|
125
|
+
in the working directory (cmd/main.go watchConfigFile).
|
|
126
|
+
|
|
127
|
+
A Caddy not yet moved over (no `--resume`) restarts from `--config`: see the ⚠️ in
|
|
128
|
+
[Order](#order-file-then-load).
|
|
129
|
+
|
|
130
|
+
## Secrets
|
|
131
|
+
|
|
132
|
+
Alchemy stores props unencrypted, and the Caddyfile is a prop. Use RUNTIME placeholders, which Caddy
|
|
133
|
+
resolves in its own process, so the value never enters the state, the adapted JSON or the autosave:
|
|
134
|
+
|
|
135
|
+
- `{env.NAME}` — start Caddy with `--envfile <path>`; a secret renderer (openbao-agent) writes that
|
|
136
|
+
file. A rotated value needs a Caddy restart.
|
|
137
|
+
- `{file./path}` — the file's contents (trailing newline trimmed), read when the placeholder runs.
|
|
138
|
+
- ⚠️ `{$NAME}` is substituted at **adapt** time: the value is in the adapted JSON, readable at
|
|
139
|
+
`GET /config/` and written to `autosave.json`. Not refused, but worse.
|
|
140
|
+
- ⛔ `{$NAME:default}` puts the default in the prop, and Caddy uses it whenever `NAME` is unset. The
|
|
141
|
+
tripwire checks the default as a literal.
|
|
142
|
+
- `basic_auth` replaces placeholders in the hash too: `admin {env.ADMIN_HASH}`.
|
|
143
|
+
|
|
144
|
+
The tripwire refuses a PEM private key, a literal after `dns`/`acme_dns <provider>`, a secret-named
|
|
145
|
+
subdirective (`api_token`, `client_secret`, `password`, …) with a literal value, a literal
|
|
146
|
+
`Authorization`/`Cookie`/`X-Api-Key` header, and token shapes (GitHub, AWS, Slack, `sk-…`, JWT,
|
|
147
|
+
bcrypt/argon2 hashes). It never echoes what it found. It is a tripwire, not a scanner.
|
|
148
|
+
|
|
149
|
+
## The admin endpoint
|
|
150
|
+
|
|
151
|
+
⛔ The admin API has no authentication: `localCaddyAdmin()` accepts only `http://` loopback or
|
|
152
|
+
`unix:///path` (default `http://127.0.0.1:2019`), and a Caddyfile whose `admin` block would move,
|
|
153
|
+
expose or lock out the API is refused before it loads. Host/Origin checks, narrowed `origins`, SSH
|
|
154
|
+
forwards and every refusal: [caddy-admin.md](./caddy-admin.md).
|
|
155
|
+
|
|
156
|
+
## Adoption
|
|
157
|
+
|
|
158
|
+
★ **Nothing is adopted silently** (decision, 2026-09-21) — the rule `HostFile` and `LaunchdJob` keep.
|
|
159
|
+
With no state, `read` is Alchemy's adoption probe, and what the Caddy runs decides:
|
|
160
|
+
|
|
161
|
+
| running | read | plan |
|
|
162
|
+
| ----------------------------------- | --------- | ---------------------------------------------------------- |
|
|
163
|
+
| the declared config (same digest) | ours | adopted; the forced update loads nothing |
|
|
164
|
+
| nothing (`null`, or no apps) | absent | create — nothing to take over, as an empty R2 lock is none |
|
|
165
|
+
| anything else | `Unowned` | refused unless the deploy runs with `--adopt` |
|
|
166
|
+
| a Caddyfile that cannot be compared | `Unowned` | the same, with a warning saying why |
|
|
167
|
+
| no Caddy answering | absent | create, with a warning; the apply checks again |
|
|
168
|
+
|
|
169
|
+
- ⚠️ **Alchemy skips the probe while the props hold an Output** — always on `caddyWithFile()`'s first
|
|
170
|
+
deploy, whose `sourceFile` is the HostFile's path. So the apply of a create checks too, and fails
|
|
171
|
+
before any `/load` unless the deploy runs with `--adopt`. The HostFile is written by then (a file
|
|
172
|
+
already at the path is the HostFile's own `Unowned`); under `--resume` a restart still runs the
|
|
173
|
+
autosave.
|
|
174
|
+
- ★ **That apply-time check resolves adoption as the planner does:** a resource-scoped `adopt(…)`,
|
|
175
|
+
else `--adopt`. So `.pipe(adopt(false))` still refuses under `--adopt`, and `adopt(true)` works.
|
|
176
|
+
- ★ **An uncomparable Caddyfile is `Unowned`, not an error**: the same read recovers an interrupted
|
|
177
|
+
create with that deploy's props, and a throw would fail every later plan, the fixed one included.
|
|
178
|
+
|
|
179
|
+
## Removing it
|
|
180
|
+
|
|
181
|
+
⛔ Delete never touches Caddy, under either removal policy. There is no "less config" to fall back
|
|
182
|
+
to, and an empty config stops every server. `retain` is the default; with `destroy`, delete still
|
|
183
|
+
only forgets. It never calls `/stop` or `DELETE /config/`. An empty Caddyfile, or one that adapts
|
|
184
|
+
to no apps (only comments or global options), is refused for the same reason. To empty a Caddy, do
|
|
185
|
+
it by hand at its admin API.
|
|
186
|
+
|
|
187
|
+
## Traps
|
|
188
|
+
|
|
189
|
+
- ⚠️ `import` paths resolve against Caddy's working directory (the API adapts with no file name).
|
|
190
|
+
Keep them absolute.
|
|
191
|
+
- ⚠️ `/adapt` runs on the **running** binary. A Caddyfile that uses a plugin's directive (Cloudflare
|
|
192
|
+
DNS, caddy-security, layer4) needs that custom build running before it will validate.
|
|
193
|
+
- ⚠️ **Bootstrap race.** Where Caddy is not running yet, the config can reach the admin API before
|
|
194
|
+
its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
|
|
195
|
+
`retryDelayMs`; default 2 × 500 ms) — raise them, or rerun the deploy.
|
|
196
|
+
- ⚠️ One `CaddyAdmin` per stack: the transport picks the Caddy, not a prop. Pointing it at another
|
|
197
|
+
Caddy plans an update; the old one keeps what it had. ⛔ State vouches only for the Caddy it was
|
|
198
|
+
applied to, so the apply loads there only if it serves nothing, runs the declared config or the
|
|
199
|
+
one last stored — anything else needs `--adopt`.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Deploying a Mac host as yourself — `sudoRunner()`
|
|
2
|
+
|
|
3
|
+
A host stack that declares system daemons needs root for a handful of calls. `sudoRunner()` lets
|
|
4
|
+
the deploy run as you (the operator) and sends **only those calls** through `sudo -n`, in fixed
|
|
5
|
+
argv shapes, logging each one. It is an explicit opt-in: nothing falls back to it, and
|
|
6
|
+
`localRunner()` stays the default and never elevates.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { launchdProviders, sudoRunner } from '@homeflare/alchemy/launchd';
|
|
10
|
+
|
|
11
|
+
const runner = sudoRunner({
|
|
12
|
+
// ⛔ Required: the directories root may write. Each must be a real directory that root owns
|
|
13
|
+
// and only root may write; that is checked at every privileged call.
|
|
14
|
+
prefixes: ['/Library/LaunchDaemons', '/opt/example'],
|
|
15
|
+
// log: (line) => …, // default: one line on stderr per privileged call
|
|
16
|
+
});
|
|
17
|
+
// Provide launchdProviders(runner) alongside the stack's other provider layers.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What runs as root: the whole list
|
|
21
|
+
|
|
22
|
+
Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never through a shell.
|
|
23
|
+
|
|
24
|
+
| when | argv |
|
|
25
|
+
| -------------------------------- | ---------------------------------------------------------------------- |
|
|
26
|
+
| a system job is bootstrapped | `/bin/launchctl bootstrap system /Library/LaunchDaemons/<label>.plist` |
|
|
27
|
+
| a system job is booted out | `/bin/launchctl bootout system/<label>` |
|
|
28
|
+
| (your own code, via `exec`) | `/bin/launchctl kickstart [-k] [-p] system/<label>` |
|
|
29
|
+
| a file under a prefix is written | `/usr/bin/install -S -m <0644> [-o <uid>] [-g <gid>] <staged> <path>` |
|
|
30
|
+
| a file under a prefix is removed | `/bin/rm -f -- <path>` |
|
|
31
|
+
|
|
32
|
+
- A file's bytes never reach argv or the log: they are written, as you, to a `0600` file in a
|
|
33
|
+
fresh `0700` temp directory. `install` copies that file (and only that file) into place, and
|
|
34
|
+
the temp directory is removed afterwards, whether or not the install succeeded.
|
|
35
|
+
- `install` writes a temp file beside the target and renames it (install(1) on macOS 27.2), so the
|
|
36
|
+
target is never torn. `-S` adds the fsync.
|
|
37
|
+
- Owner and group are numeric ids, resolved by the provider before the call.
|
|
38
|
+
|
|
39
|
+
## What stays as you
|
|
40
|
+
|
|
41
|
+
- `launchctl print` and `print-disabled`. launchctl(1): "Anyone may read or query the system
|
|
42
|
+
domain"; measured on macOS 27.2, too (see `src/launchd/launchctl.ts`).
|
|
43
|
+
- User and group lookups, `id -G`, every file read and `lstat`.
|
|
44
|
+
- Files outside every prefix, and your own `gui/<uid>` jobs.
|
|
45
|
+
|
|
46
|
+
★ So **a plan never calls sudo.** `read` and `diff` are reads; only an apply elevates.
|
|
47
|
+
|
|
48
|
+
## Refused before sudo is asked
|
|
49
|
+
|
|
50
|
+
- Any other argv. That includes a bare `bootout system` (which removes the whole system domain),
|
|
51
|
+
`bootstrap system <directory>` (which loads every plist in it), a plist anywhere but
|
|
52
|
+
`/Library/LaunchDaemons/<label>.plist` (the only place launchd loads daemons from at boot),
|
|
53
|
+
`rm -r`, `install -d`, a label under `org.nixos.`, `com.apple.` or `homebrew.mxcl.`, and any
|
|
54
|
+
argv whose program is `sudo`.
|
|
55
|
+
- A system job's `bootstrap` or `bootout` when `/Library/LaunchDaemons` is not a prefix. ⚠️ Without
|
|
56
|
+
it a delete would boot the job out, then fail to remove its plist, which launchd loads again at
|
|
57
|
+
the next boot.
|
|
58
|
+
- A prefix that is missing, a symlink, not owned by root, or writable by group or other. ⛔ A
|
|
59
|
+
symlinked prefix makes root write wherever it points, a path the log never names.
|
|
60
|
+
- A path outside every prefix that needs root: another user as the owner, say.
|
|
61
|
+
- A symlink or missing directory between the prefix and the file, or anything but a regular file
|
|
62
|
+
at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
|
|
63
|
+
the same.
|
|
64
|
+
- A file you could not read back. Both providers read every write back, and read again at every
|
|
65
|
+
plan, as you. A root-only `0600` would be installed, then fail with `EACCES` and stay on disk
|
|
66
|
+
with no state. Membership comes from `id -G`, because macOS caps `getgroups()` at 16 (measured
|
|
67
|
+
2026-09-21: 16 against 18).
|
|
68
|
+
- Another user's `gui/<uid>` or `user/<uid>` domain. Only the system domain is elevated.
|
|
69
|
+
|
|
70
|
+
Every refusal above throws `SudoRefusedError`: nothing ran as root. A privileged command that ran
|
|
71
|
+
and failed throws a plain `Error` with its exit code.
|
|
72
|
+
|
|
73
|
+
## When sudo wants a password
|
|
74
|
+
|
|
75
|
+
`sudo -n` never prompts. It fails at once, and the error says so: `a password is required, and
|
|
76
|
+
this runner never prompts`. The command did not run. You have two options.
|
|
77
|
+
|
|
78
|
+
1. **`sudo -v` just before the deploy.** This caches a ticket. By default it lasts 5 minutes and
|
|
79
|
+
is tied to one terminal (sudoers(5): `timestamp_timeout`, `timestamp_type`).
|
|
80
|
+
⚠️ A deploy that outlives the ticket fails at its next privileged call. Every step is
|
|
81
|
+
idempotent (Alchemy requires `delete` to be), so `sudo -v` and deploying again converges. The
|
|
82
|
+
exception is a `LaunchdJob` label or domain change: it is delete-first, so if it fails between
|
|
83
|
+
the delete and the create, the job is down until you redeploy.
|
|
84
|
+
2. **A `NOPASSWD` sudoers rule for exactly the shapes above.**
|
|
85
|
+
- ⛔ **That rule is root-equivalent.** Anyone who may `install` into `/Library/LaunchDaemons`
|
|
86
|
+
and `launchctl bootstrap system` can run any program as root. The allowlist protects you
|
|
87
|
+
from mistakes, not from the account that holds the rule.
|
|
88
|
+
- ⛔ **Never use wildcards in the arguments.** sudoers(5): in arguments, `*` also matches spaces
|
|
89
|
+
and `/`, so `/bin/rm -f -- /opt/example/*` matches `../../etc/x` and two paths at once. Use
|
|
90
|
+
`^…$` regular expressions (sudo 1.9.10 and later; macOS 27.2 ships 1.9.17p2) and forbid
|
|
91
|
+
`..`. Check the file with `visudo -c -f <file>` before you install it.
|
|
92
|
+
- ⚠️ The kit ships no tested rule. Nothing here has run sudo.
|
|
93
|
+
|
|
94
|
+
⚠️ The error text sudo prints is **reasoned, not measured**. It comes from sudoers(5) and sudo(8)
|
|
95
|
+
for sudo 1.9.17p2. A sudo refusal the runner does not recognise comes back as a failed command,
|
|
96
|
+
with sudo's own stderr.
|
|
97
|
+
|
|
98
|
+
## The log
|
|
99
|
+
|
|
100
|
+
Each privileged call logs one line before it runs:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
homeflare/launchd sudo -n ["/bin/launchctl","bootstrap","system","/Library/LaunchDaemons/com.example.exporter.plist"]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The argv is written as JSON, so a space in a path cannot hide a second argument. The line holds
|
|
107
|
+
the argv only, never file content. ⛔ A custom `log` should keep every line: the log is the only
|
|
108
|
+
record of what ran as root.
|
|
109
|
+
|
|
110
|
+
## Limits
|
|
111
|
+
|
|
112
|
+
- ⚠️ **Moving a job from `system` to another user's `gui/<uid>` passes the plan and fails at
|
|
113
|
+
apply**, after the old job was already removed. The runner reports `privileged: true`, so the
|
|
114
|
+
plan-time check lets it through, and the refusal only comes at the write. Do that move as a
|
|
115
|
+
remove, then an add.
|
|
116
|
+
- ⚠️ **Under a prefix, the runner writes as root.** An omitted owner is root, and an omitted group
|
|
117
|
+
is the directory's group (a new file's group on macOS).
|
|
118
|
+
- ⚠️ **The checks guard against a mistaken declaration, not against a hostile one.** They run as
|
|
119
|
+
you, just before the call. The prefix itself must be root-only, but a directory _below_ it that
|
|
120
|
+
another user owns is not checked for that, and that user could swap what lies under it in
|
|
121
|
+
between. Keep every directory under a prefix root-owned, too. ACLs are not read, only mode bits.
|
|
122
|
+
- ⛔ **macOS hosts.** Every argv shape was checked against macOS 27.2 man pages.
|
package/docs/launchd.md
CHANGED
|
@@ -112,8 +112,11 @@ The `system` domain, and another user's `gui/<uid>`, need root. The provider ref
|
|
|
112
112
|
unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
|
|
113
113
|
`privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
|
|
114
114
|
|
|
115
|
-
- **system daemons
|
|
116
|
-
|
|
115
|
+
- **system daemons, deployed as yourself:** `launchdProviders(sudoRunner({ prefixes }))` sends only
|
|
116
|
+
the privileged calls through `sudo -n`, from a fixed allowlist, logging each. It never prompts.
|
|
117
|
+
See [launchd-sudo.md](./launchd-sudo.md).
|
|
118
|
+
- **system daemons, deployed as root:** start the whole deploy as root; or pass
|
|
119
|
+
`launchdProviders(yourRunner)` where `yourRunner` is a `HostRunner` that already holds root.
|
|
117
120
|
- **your own agents** (`gui/<your uid>`): no root needed.
|
|
118
121
|
|
|
119
122
|
Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
|
|
@@ -163,5 +166,5 @@ Nix job is booted out first. Per job:
|
|
|
163
166
|
`HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
|
|
164
167
|
`stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
|
|
165
168
|
`privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
|
|
166
|
-
`node:child_process`;
|
|
167
|
-
writes outside a temp directory.
|
|
169
|
+
`node:child_process`; `sudoRunner()` wraps it and elevates the allowlist. Tests use an in-memory
|
|
170
|
+
fake, so nothing in the test suite runs `launchctl` or `sudo`, or writes outside a temp directory.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# MeshNode — a Cloudflare Mesh node without its token in state
|
|
2
|
+
|
|
3
|
+
`MeshNode` (`Cloudflare.MeshNode`) declares a Cloudflare Mesh node: the `warp_connector` object
|
|
4
|
+
that a Linux host joins with the Cloudflare One Client. `fetchMeshNodeToken` fetches the node's
|
|
5
|
+
enrolment token on demand, for a one-off enrolment step. Read 2026-09-21 against
|
|
6
|
+
`alchemy@2.0.0-beta.79`, `@distilled.cloud/cloudflare@1.0.0-rc.12` and Cloudflare's Mesh docs.
|
|
7
|
+
|
|
8
|
+
## Why not `Cloudflare.Tunnel.WarpConnector`
|
|
9
|
+
|
|
10
|
+
- ⛔ **It puts the node token in state.** Its `toAttributes` calls the token endpoint on every
|
|
11
|
+
`read`, `reconcile` and `list`, and stores the result as an attribute. `Redacted` keeps it out
|
|
12
|
+
of logs, not out of the state store. The token lets any Linux host join the account's Mesh as
|
|
13
|
+
that node.
|
|
14
|
+
- **It has no `ha` prop.** High availability can only be chosen when the node is created.
|
|
15
|
+
- **Its plans need a Write token.** The token endpoint requires "Cloudflare One Connectors Write"
|
|
16
|
+
(or the cloudflared or Tunnel Write groups). `MeshNode` never calls it, so a plan can run on a
|
|
17
|
+
read token.
|
|
18
|
+
|
|
19
|
+
## Declaring a node
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
23
|
+
import { MeshNode, providers as homeflareCloudflare } from '@homeflare/alchemy/cloudflare';
|
|
24
|
+
|
|
25
|
+
// providers: Layer.mergeAll(Cloudflare.providers(), homeflareCloudflare()),
|
|
26
|
+
const door = yield * MeshNode('vault-door', { name: 'door-a', ha: false });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| prop | type | notes |
|
|
30
|
+
| ------ | --------- | --------------------------------------------------------------------------------- |
|
|
31
|
+
| `name` | `string` | Unique per account. The identity used for adoption. Renames in place. |
|
|
32
|
+
| `ha` | `boolean` | **Required, create-only.** The API default is `false`, the dashboard's is `true`. |
|
|
33
|
+
|
|
34
|
+
Attributes: `id` (the node UUID), `accountId`, `name`, `status` (`inactive`, `degraded`,
|
|
35
|
+
`healthy` or `down`, as of the last read or write) and `ha`. ⛔ There is no token attribute.
|
|
36
|
+
|
|
37
|
+
The account and credentials come from Alchemy's own Cloudflare environment
|
|
38
|
+
(`Cloudflare.CloudflareApiLive()`): an Alchemy profile, or `CLOUDFLARE_API_TOKEN` and
|
|
39
|
+
`CLOUDFLARE_ACCOUNT_ID` in CI. So the node lands in the same account as the `Gateway.Rule` and
|
|
40
|
+
`DNS.Record` resources that refer to it. `providers()` builds that layer itself, so the provider
|
|
41
|
+
works whether or not the stack also merges `Cloudflare.providers()`.
|
|
42
|
+
|
|
43
|
+
⛔ **Removal policy: `retain` by default**, like the kit's other resources whose deletion breaks
|
|
44
|
+
their consumers. Dropping the declaration or `alchemy destroy` leaves the node live and only drops
|
|
45
|
+
it from state. Opt a declaration into deletion with `.pipe(RemovalPolicy.destroy())`.
|
|
46
|
+
|
|
47
|
+
### Choosing `ha`: a door is `ha: false`
|
|
48
|
+
|
|
49
|
+
HA fails over the CIDR **routes** a node advertises. A node with no routes (a door: callers dial
|
|
50
|
+
its Mesh IP) gains nothing from it, because the Mesh IP belongs to each replica: "Nodes without
|
|
51
|
+
routes do not benefit from HA failover" (Cloudflare's Mesh HA page, read 2026-09-21). After a
|
|
52
|
+
failover the promoted replica answers on its own Mesh IP, so nothing dialling the first one
|
|
53
|
+
follows it.
|
|
54
|
+
|
|
55
|
+
So a door is `ha: false`, and **a second door is a second `MeshNode`** with its own name, token
|
|
56
|
+
and Mesh IP. Callers list both addresses. Keep `ha: true` for a subnet gateway with routes, on a
|
|
57
|
+
device profile that uses MASQUE.
|
|
58
|
+
|
|
59
|
+
## What each change does
|
|
60
|
+
|
|
61
|
+
| change | answer | what happens on Cloudflare |
|
|
62
|
+
| ------------------------ | --------------------------- | ------------------------------------------------------------------------------------------- |
|
|
63
|
+
| `name` | `update` | `PATCH`. The id, token, enrolled replicas and Mesh IPs all survive. |
|
|
64
|
+
| `ha`, same `name` | `replace`, **delete-first** | `retain` (default): **refused**, nothing written. `destroy`: old deleted, then new created. |
|
|
65
|
+
| `ha` and `name` together | `replace`, create-first | The new node is created. Under `retain` the old one stays live. |
|
|
66
|
+
| account (provider env) | `replace`, create-first | As above; names are unique per account, so the two cannot collide. |
|
|
67
|
+
|
|
68
|
+
⛔ **An `ha` replace is an outage for that node.** The new node has a new id and a new token, and
|
|
69
|
+
each replica gets a new Mesh IP. Re-enrol every replica with the new token, and update anything
|
|
70
|
+
that pins the old Mesh IP (Gateway rules, listener allow-lists) in the same change.
|
|
71
|
+
|
|
72
|
+
★ **Why delete-first only when the name stays.** The new node needs the name the old one holds, so
|
|
73
|
+
creating first would fail with code 1013 (`DuplicateTunnelName`). ⚠️ Unmeasured: whether a
|
|
74
|
+
just-deleted node frees its name at once. If it does not, the create fails with code 1013 and a
|
|
75
|
+
sentence; deploy again once the name is free. An invalid declaration is refused at plan time,
|
|
76
|
+
before anything is deleted.
|
|
77
|
+
|
|
78
|
+
### An `ha` change under `retain`
|
|
79
|
+
|
|
80
|
+
`retain` also skips the old node's delete inside a replace. So a same-name `ha` change keeps the old
|
|
81
|
+
node, which still holds the name, and the create **refuses** with a sentence and writes nothing. It
|
|
82
|
+
never quietly reuses the old node, because that node still has the old `ha`. Measured through
|
|
83
|
+
Alchemy's own plan/apply in `mesh-node-policy.test.ts`:
|
|
84
|
+
|
|
85
|
+
| you want | do |
|
|
86
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
87
|
+
| the replace | Deploy once with `.pipe(RemovalPolicy.destroy())`: delete, then create. Then drop it. |
|
|
88
|
+
| the replace, by hand | Delete the old node yourself, then deploy again. |
|
|
89
|
+
| to keep the old node | `alchemy state rm <stack>/<stage>/<fqn>` (state only, not the node), then deploy the old declaration + `adopt(true)`. |
|
|
90
|
+
|
|
91
|
+
⚠️ **Reverting `ha` is not enough.** The refused deploy leaves a `replacing` row, and the next
|
|
92
|
+
plan resumes that replace whatever the props say, so it refuses again.
|
|
93
|
+
|
|
94
|
+
⚠️ **`adopt(true)` alone does nothing to a `replacing` row.** The plan asks `read` to adopt only
|
|
95
|
+
when a resource has no row, or after an interrupted first create. So every way back from inside a
|
|
96
|
+
replace starts with `alchemy state rm`.
|
|
97
|
+
|
|
98
|
+
⛔ **The old-node advice holds only when the name stayed.** After an `ha` + `name` change (a
|
|
99
|
+
create-first replace), a node already holding the new name is another node, not the old one.
|
|
100
|
+
`RemovalPolicy.destroy()` never touches it, and the refusal says not to delete it: choose another
|
|
101
|
+
name, or check its HA badge, drop the row and adopt it.
|
|
102
|
+
|
|
103
|
+
⚠️ Never deploy with `DISTILLED_DEBUG_HTTP` set: distilled prints the start of every response,
|
|
104
|
+
and the create response carries the node's `token` field (Cloudflare's HA page says so).
|
|
105
|
+
|
|
106
|
+
⚠️ Cloudflare may refuse to delete a node while replicas are connected. This is noted in Alchemy's
|
|
107
|
+
WarpConnector and was not measured here. Stop the client on every replica before a destroy or an
|
|
108
|
+
`ha` replace.
|
|
109
|
+
|
|
110
|
+
## Adoption
|
|
111
|
+
|
|
112
|
+
An existing node is found by **exact** name and returned `Unowned`. Alchemy refuses to take it
|
|
113
|
+
over until the resource is wrapped in `adopt(true)` (or `--adopt` is passed). A deleted node is
|
|
114
|
+
never matched, and a name that matches two live nodes is refused.
|
|
115
|
+
|
|
116
|
+
An interrupted create (the node was made, its state was not saved) is found the same way on the
|
|
117
|
+
next plan, so it too needs `adopt(true)`. ⚠️ distilled retries a create after a lost response or a
|
|
118
|
+
5xx, and the retry is answered code 1013 by the node the first attempt made: the error then names
|
|
119
|
+
that node's id. It is never adopted silently, because a concurrent creator looks the same. Inside
|
|
120
|
+
a replace, drop the row before `adopt(true)`, as above.
|
|
121
|
+
|
|
122
|
+
⚠️ **Adoption records the declared `ha`.** No documented read returns a node's HA flag:
|
|
123
|
+
`GET /warp_connector/{id}` has no such field. Check the node's **HA** badge in the dashboard
|
|
124
|
+
before adopting. A wrong value cannot be detected by any later plan.
|
|
125
|
+
|
|
126
|
+
## Enrolling a host: `fetchMeshNodeToken`
|
|
127
|
+
|
|
128
|
+
The token is a runtime credential. It never goes into Alchemy state or the vault; it goes straight
|
|
129
|
+
to the host that runs the node. A one-off Bun `mesh-enroll` step:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { fromApiToken } from '@distilled.cloud/cloudflare/Credentials';
|
|
133
|
+
import { fetchMeshNodeToken } from '@homeflare/alchemy/cloudflare';
|
|
134
|
+
import * as Effect from 'effect/Effect';
|
|
135
|
+
import * as Layer from 'effect/Layer';
|
|
136
|
+
import * as Redacted from 'effect/Redacted';
|
|
137
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
138
|
+
import { open } from 'node:fs/promises';
|
|
139
|
+
|
|
140
|
+
const token = await Effect.runPromise(
|
|
141
|
+
fetchMeshNodeToken({ accountId, id: nodeId }).pipe(
|
|
142
|
+
Effect.provide(Layer.mergeAll(fromApiToken({ apiToken }), FetchHttpClient.layer)),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
// ⛔ Created 0600 from the start, as root: no window where a chmod has not happened yet.
|
|
146
|
+
const file = await open('/root/mesh-node.token', 'wx', 0o600);
|
|
147
|
+
await file.writeFile(Redacted.value(token));
|
|
148
|
+
await file.close();
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- `{ accountId, name }` works too; the name is matched exactly.
|
|
152
|
+
- The value comes back `Redacted`: `console.log`, `Effect.log` and `JSON.stringify` show
|
|
153
|
+
`<redacted>`. Call `Redacted.value` only at the write.
|
|
154
|
+
- ⛔ **`fromApiToken`, not `Credentials.fromEnv()`.** `fromEnv` falls back to the Global API Key
|
|
155
|
+
(`CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) when no token is set.
|
|
156
|
+
- ⚠️ **The endpoint needs a Write permission.** Mint a short-lived token for this step alone. A
|
|
157
|
+
read token gets a 403, and the error names the permission.
|
|
158
|
+
- ⚠️ `warp-cli --accept-tos connector new <token>` (Mesh get-started page) takes the token as an
|
|
159
|
+
**argument**, so it is visible in the host's process list while that command runs. Run it as
|
|
160
|
+
root on the node itself, in a shell that does not keep history.
|
|
161
|
+
- An empty token fails closed. An empty file would otherwise "enrol" a host that never joined.
|
|
162
|
+
- ⛔ **Refused before any request:** `DISTILLED_DEBUG_HTTP` set (distilled then prints the first
|
|
163
|
+
400 characters of every response body to stderr, which is the whole token), the Global API Key
|
|
164
|
+
(`apiKey` credentials), and an empty API token (a denied secrets grant, not a permission).
|
|
165
|
+
|
|
166
|
+
## Permissions
|
|
167
|
+
|
|
168
|
+
| step | needs (any one, per the API reference) |
|
|
169
|
+
| ----------------------- | ------------------------------------------------------------------------ |
|
|
170
|
+
| plan / read (get, list) | Cloudflare One Connectors Read, or Cloudflare One Connector: WARP Read |
|
|
171
|
+
| create, rename, delete | Cloudflare One Connectors Write, or Cloudflare One Connector: WARP Write |
|
|
172
|
+
| `fetchMeshNodeToken` | Cloudflare One Connectors Write (see below) |
|
|
173
|
+
|
|
174
|
+
The token endpoint's API reference lists Connectors Write, "Cloudflare One Connector: cloudflared
|
|
175
|
+
Write" and "Cloudflare Tunnel Write"; the Mesh get-started guide lists Connectors Write and
|
|
176
|
+
"Cloudflare One Connector: WARP Write". Connectors Write is the one both name.
|
|
177
|
+
|
|
178
|
+
## Not covered here
|
|
179
|
+
|
|
180
|
+
- **Routes, device profiles and Gateway rules:** use Alchemy's `Tunnel.Route`,
|
|
181
|
+
`Devices.CustomProfile` and `Gateway.Rule`. ⚠️ HA needs MASQUE on the node's device profile.
|
|
182
|
+
- **The HA provider configuration** (`/warp_connector/{id}/configurations`: `ha_mode` of `none`,
|
|
183
|
+
`disabled`, `aws` or `local` with VIPs) and **manual failover**. Its semantics for a node
|
|
184
|
+
created without `ha` are undocumented, so this resource neither reads nor writes it.
|
|
185
|
+
- **Mesh IPs.** They belong to each replica (the `/connections` endpoint), not to the node, so
|
|
186
|
+
they are not attributes. Pin them in site config and check them against the API.
|
|
187
|
+
- **`alchemy unsafe nuke`.** `list` is empty and nuke skips this type, because Alchemy's own
|
|
188
|
+
WarpConnector provider already lists every `warp_connector` in the account.
|
package/docs/peers.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Peers — and why each pin exists
|
|
2
|
+
|
|
3
|
+
The install line for `@homeflare/alchemy`, and why every peer on it and every entry in the
|
|
4
|
+
`overrides` block is required. The block itself, which every consumer copies, is in the
|
|
5
|
+
[README](../README.md#peers--and-one-override-you-need).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @homeflare/alchemy alchemy@2.0.0-beta.79 effect@4.0.0-rc.115 \
|
|
9
|
+
@effect/platform-node@4.0.0-rc.115 cloudflare@4.5.0 mime@4.1.0 \
|
|
10
|
+
@distilled.cloud/cloudflare@1.0.0-rc.12
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
🔴 **Why, measured 2026-09-16 on 0.1.0 and re-checked 2026-09-17 against Alchemy 78.**
|
|
14
|
+
Effect's `rc` line is not semver-compatible with itself. Alchemy 78's peer is
|
|
15
|
+
`effect >= 4.0.0-rc.115`; an unlocked `@effect/platform-node-shared` still floats to the
|
|
16
|
+
next rc and breaks at import. Pin the whole set.
|
|
17
|
+
|
|
18
|
+
| what resolves | what happens |
|
|
19
|
+
| ------------------------------------------------------------- | ------------------------------------------------------ |
|
|
20
|
+
| Alchemy 77 + `effect` → rc.115 (measured 2026-09-16) | `TypeError: Config.string is not a function` at import |
|
|
21
|
+
| `@effect/platform-node-shared` newer than the pinned `effect` | `Cannot find module 'effect/ByteSize'` |
|
|
22
|
+
| `rolldown` → 1.2.9 via vite's `~1.2.6` (measured 2026-09-16) | `GET …/rolldown-1.2.9.tgz - 404` at `bun add` |
|
|
23
|
+
| no `mime` (measured 2026-09-17 against Alchemy 78) | `Cannot find package 'mime'` from cloudflare-runtime |
|
|
24
|
+
|
|
25
|
+
Alchemy 78 adapted to rc.115 — that first row is why we used to pin 112, not a reason to
|
|
26
|
+
stay there. The override is still the only thing that holds the set together.
|
|
27
|
+
|
|
28
|
+
⚠️ `@effect/platform-node` is **required, not optional**: Alchemy's module graph reaches
|
|
29
|
+
`Cloudflare/Workers/WorkerBridge → @effect/platform-node/NodeServices` even when you only
|
|
30
|
+
import the Proxmox subpath.
|
|
31
|
+
|
|
32
|
+
⚠️ So is `mime`. Alchemy 78's `@alchemy.run/cloudflare-runtime` imported it without
|
|
33
|
+
declaring it (measured 2026-09-17). 79 declares it; the peer stays so a consumer
|
|
34
|
+
that followed the 78 README does not drop a required line.
|
|
35
|
+
|
|
36
|
+
⚠️ So is `cloudflare`. It was marked optional in 0.1.1, which claimed the `/cloudflare`
|
|
37
|
+
subpath would degrade without it — measured 2026-09-16, the subpath does not load at all:
|
|
38
|
+
`Cannot find package 'cloudflare'`. An optional peer should mean a feature is absent, not
|
|
39
|
+
that an import fails.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos and
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
7
7
|
"license": "MIT",
|
|
@@ -49,12 +49,17 @@
|
|
|
49
49
|
"types": "./dist/launchd/index.d.ts",
|
|
50
50
|
"default": "./dist/launchd/index.js"
|
|
51
51
|
},
|
|
52
|
+
"./caddy": {
|
|
53
|
+
"types": "./dist/caddy/index.d.ts",
|
|
54
|
+
"default": "./dist/caddy/index.js"
|
|
55
|
+
},
|
|
52
56
|
"./package.json": "./package.json"
|
|
53
57
|
},
|
|
54
58
|
"publishConfig": {
|
|
55
59
|
"access": "public"
|
|
56
60
|
},
|
|
57
61
|
"peerDependencies": {
|
|
62
|
+
"@distilled.cloud/cloudflare": "1.0.0-rc.12",
|
|
58
63
|
"@effect/platform-node": "4.0.0-rc.115",
|
|
59
64
|
"alchemy": "2.0.0-beta.79",
|
|
60
65
|
"cloudflare": ">=4.5.0",
|