@homeflare/alchemy 0.20.0 → 0.21.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 +25 -0
- package/dist/caddy/access-forward-auth.d.ts +56 -0
- package/dist/caddy/access-forward-auth.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +6 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +169 -75
- package/dist/caddy/index.js.map +10 -7
- package/dist/cloudflare/mcp-server-api.d.ts +135 -0
- package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
- package/dist/cloudflare/mcp-server-form.d.ts +116 -0
- package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/file-converge.d.ts +70 -0
- package/dist/launchd/file-converge.d.ts.map +1 -0
- package/dist/launchd/file-identity.d.ts +32 -0
- package/dist/launchd/file-identity.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +2 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +11 -24
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +173 -120
- package/dist/launchd/index.js.map +12 -10
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +17 -2
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +11 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/index.js.map +3 -3
- package/dist/linux/sudo-listing.d.ts +47 -0
- package/dist/linux/sudo-listing.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +1 -1
- package/dist/proxmox/credential-literals.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +7 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +7 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +1 -1
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +2 -2
- package/dist/proxmox/index.js.map +9 -9
- package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
- package/dist/release/archive.d.ts +11 -0
- package/dist/release/archive.d.ts.map +1 -0
- package/dist/release/binary-claim.d.ts +53 -0
- package/dist/release/binary-claim.d.ts.map +1 -0
- package/dist/release/binary-diff.d.ts +38 -0
- package/dist/release/binary-diff.d.ts.map +1 -0
- package/dist/release/binary-form.d.ts +118 -0
- package/dist/release/binary-form.d.ts.map +1 -0
- package/dist/release/binary-lifecycle.d.ts +55 -0
- package/dist/release/binary-lifecycle.d.ts.map +1 -0
- package/dist/release/binary-read.d.ts +11 -0
- package/dist/release/binary-read.d.ts.map +1 -0
- package/dist/release/binary.d.ts +68 -0
- package/dist/release/binary.d.ts.map +1 -0
- package/dist/release/catalog.d.ts +76 -0
- package/dist/release/catalog.d.ts.map +1 -0
- package/dist/release/checksums.d.ts +22 -0
- package/dist/release/checksums.d.ts.map +1 -0
- package/dist/release/declared-pins.d.ts +4 -0
- package/dist/release/declared-pins.d.ts.map +1 -0
- package/dist/release/download.d.ts +44 -0
- package/dist/release/download.d.ts.map +1 -0
- package/dist/release/fake-deploy.d.ts +48 -0
- package/dist/release/fake-deploy.d.ts.map +1 -0
- package/dist/release/fake-engine.d.ts +8 -0
- package/dist/release/fake-engine.d.ts.map +1 -0
- package/dist/release/fake-install.d.ts +39 -0
- package/dist/release/fake-install.d.ts.map +1 -0
- package/dist/release/fake-provider.d.ts +58 -0
- package/dist/release/fake-provider.d.ts.map +1 -0
- package/dist/release/fake-release.d.ts +95 -0
- package/dist/release/fake-release.d.ts.map +1 -0
- package/dist/release/index.d.ts +24 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +1365 -0
- package/dist/release/index.js.map +35 -0
- package/dist/release/providers.d.ts +19 -0
- package/dist/release/providers.d.ts.map +1 -0
- package/dist/release/refused.d.ts +39 -0
- package/dist/release/refused.d.ts.map +1 -0
- package/dist/release/tar.d.ts +14 -0
- package/dist/release/tar.d.ts.map +1 -0
- package/dist/release/victoria.d.ts +36 -0
- package/dist/release/victoria.d.ts.map +1 -0
- package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
- package/docs/release-binary-catalogs.md +113 -0
- package/docs/release-binary-measured.md +163 -0
- package/docs/release-binary-refusals.md +57 -0
- package/docs/release-binary-upstream.md +163 -0
- package/docs/release-binary.md +154 -0
- package/package.json +5 -1
- package/src/caddy/access-forward-auth.test.ts +63 -0
- package/src/caddy/access-forward-auth.ts +120 -0
- package/src/caddy/index.ts +10 -0
- package/src/caddy/providers.test.ts +4 -0
- package/src/cloudflare/mcp-server-api.ts +294 -0
- package/src/cloudflare/mcp-server-form.ts +219 -0
- package/src/index.ts +1 -0
- package/src/launchd/file-converge.ts +233 -0
- package/src/launchd/file-identity.ts +69 -0
- package/src/launchd/host-file-alias.test.ts +65 -0
- package/src/launchd/host-file-form.ts +2 -1
- package/src/launchd/host-file-lifecycle.ts +25 -144
- package/src/launchd/host-file.ts +8 -2
- package/src/launchd/local-runner.test.ts +12 -1
- package/src/launchd/local-runner.ts +2 -0
- package/src/launchd/runner.ts +17 -2
- package/src/launchd/sudo-runner.ts +11 -0
- package/src/linux/sudo-listing.ts +122 -0
- package/src/proxmox/credential-literals.ts +1 -1
- package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
- package/src/proxmox/generated/pbs.ts +7 -0
- package/src/proxmox/generated/pve.ts +7 -0
- package/src/proxmox/index.ts +1 -1
- package/src/proxmox/pbs-notification-target-form.ts +1 -1
- package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
- package/src/proxmox/pbs-notification-target-wire.ts +1 -1
- package/src/proxmox/pbs-notification-target.ts +1 -1
- package/src/release/adopt-parity.test.ts +106 -0
- package/src/release/apply-pins.test.ts +128 -0
- package/src/release/archive.ts +94 -0
- package/src/release/binary-alias.test.ts +109 -0
- package/src/release/binary-claim.ts +80 -0
- package/src/release/binary-claims.test.ts +47 -0
- package/src/release/binary-diff.ts +73 -0
- package/src/release/binary-form.ts +229 -0
- package/src/release/binary-lifecycle.test.ts +90 -0
- package/src/release/binary-lifecycle.ts +202 -0
- package/src/release/binary-read.ts +55 -0
- package/src/release/binary-refusals.test.ts +192 -0
- package/src/release/binary-sudo.test.ts +93 -0
- package/src/release/binary.ts +181 -0
- package/src/release/catalog.test.ts +96 -0
- package/src/release/catalog.ts +175 -0
- package/src/release/checksums.test.ts +35 -0
- package/src/release/checksums.ts +43 -0
- package/src/release/declared-pins.ts +39 -0
- package/src/release/download.test.ts +138 -0
- package/src/release/download.ts +110 -0
- package/src/release/error-channel.test.ts +69 -0
- package/src/release/fake-deploy.ts +87 -0
- package/src/release/fake-engine.ts +50 -0
- package/src/release/fake-install.ts +59 -0
- package/src/release/fake-provider.ts +68 -0
- package/src/release/fake-release.ts +185 -0
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
- package/src/release/index.ts +41 -0
- package/src/release/plan.test.ts +189 -0
- package/src/release/provider-refusals.test.ts +48 -0
- package/src/release/providers.test.ts +174 -0
- package/src/release/providers.ts +24 -0
- package/src/release/recovery-read.test.ts +44 -0
- package/src/release/refused.ts +40 -0
- package/src/release/tar.test.ts +142 -0
- package/src/release/tar.ts +195 -0
- package/src/release/victoria.test.ts +108 -0
- package/src/release/victoria.ts +154 -0
- package/src/{proxmox → secrets}/write-only.ts +6 -0
- /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
- /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Release binaries — `@homeflare/alchemy/release`
|
|
2
|
+
|
|
3
|
+
Status: active
|
|
4
|
+
Verified: 2026-09-22
|
|
5
|
+
|
|
6
|
+
`Release.Binary` (`ReleaseBinary`) installs one binary out of a pinned release
|
|
7
|
+
archive, verified twice: the archive against its pinned SHA-256 before anything
|
|
8
|
+
is unpacked, then the binary against its own before it is written. The pinned
|
|
9
|
+
archive is **props**. Each vendor's pinned versions are **data** kept beside
|
|
10
|
+
the resource, and `VICTORIA_RELEASES` is the first data set. It exists to
|
|
11
|
+
replace the Nix-built Victoria binaries on the Mac host: all five Nix builds
|
|
12
|
+
link a `/nix/store` `libresolv`, and the vendor builds link none.
|
|
13
|
+
|
|
14
|
+
⛔ **It installs; it never starts.** No `launchctl`, no `systemctl`, no restart.
|
|
15
|
+
The daemon is the stack's own `LaunchdJob` (or systemd unit), and it puts the
|
|
16
|
+
binary's `path` in its argv.
|
|
17
|
+
|
|
18
|
+
## Declaring one
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { HostDirectory, linuxProviders } from '@homeflare/alchemy/linux';
|
|
22
|
+
import { LaunchdJob, launchdProviders, localRunner } from '@homeflare/alchemy/launchd';
|
|
23
|
+
import {
|
|
24
|
+
ReleaseBinary, VICTORIA_RELEASES, catalogBinary, catalogDirectory, releaseProviders,
|
|
25
|
+
} from '@homeflare/alchemy/release';
|
|
26
|
+
|
|
27
|
+
const runner = localRunner();
|
|
28
|
+
// providers: Layer.mergeAll(launchdProviders(runner), linuxProviders(runner), releaseProviders(runner))
|
|
29
|
+
|
|
30
|
+
const vmalertOf = { package: 'vmutils', version: '1.151.0', platform: 'darwin-arm64', binary: 'vmalert' };
|
|
31
|
+
const dir = yield* HostDirectory('vmutils-1.151.0', {
|
|
32
|
+
path: catalogDirectory('/opt/example/bin', vmalertOf), // …/vmutils-1.151.0
|
|
33
|
+
mode: 0o755, owner: 0, group: 0,
|
|
34
|
+
});
|
|
35
|
+
const vmalert = yield* ReleaseBinary('vmalert', {
|
|
36
|
+
...catalogBinary(VICTORIA_RELEASES, vmalertOf), // archive, member, sha256, name
|
|
37
|
+
directory: dir.path, // ★ an Output: the engine creates the directory first
|
|
38
|
+
owner: 0, group: 0, mode: 0o555,
|
|
39
|
+
});
|
|
40
|
+
yield* LaunchdJob('vmalert', { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'], … });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **`catalogBinary()` runs in your stack program.** A version, package,
|
|
44
|
+
platform or binary the data set does not pin throws `BinaryRefused` there,
|
|
45
|
+
so `alchemy plan` fails before any resource is asked anything
|
|
46
|
+
(`plan.test.ts`: zero requests, zero host calls).
|
|
47
|
+
- **The directory is yours, one per version.** It must exist.
|
|
48
|
+
`catalogDirectory(root, …)` names it `<root>/<package>-<version>`. A version
|
|
49
|
+
bump is then a new path. Measured through the engine (`plan.test.ts`):
|
|
50
|
+
mkdir new, download, write new, remove old, rmdir old, in that order.
|
|
51
|
+
- ⛔ **A new pin at the same path is refused**, at plan when the path is
|
|
52
|
+
resolved and at apply otherwise, rather than overwriting a binary under the
|
|
53
|
+
daemon that runs it. 🔴 While the directory is still an Output, a re-pin of
|
|
54
|
+
the same bytes is planned as an `update`, never a `replace`: measured through
|
|
55
|
+
the engine, a `replace` onto an unmoved path took the identical file as the
|
|
56
|
+
new generation's, then deleted it with the old one, and reported success.
|
|
57
|
+
- **The job references `vmalert.path`**, not a string it built. That Output
|
|
58
|
+
orders the job after the binary. A version bump moves the path, so the plist
|
|
59
|
+
changes and the job restarts on the new binary before the old one is deleted.
|
|
60
|
+
- **Declare each path once.** vmalert-logs is vmalert with other flags: one
|
|
61
|
+
`ReleaseBinary`, referenced by both jobs.
|
|
62
|
+
- **One logical id per binary, one per versioned directory.** `plan.test.ts`
|
|
63
|
+
declares `ReleaseBinary('vmalert')` and `HostDirectory('vmutils-<version>')`:
|
|
64
|
+
a bump plans the binary as a `replace`, the new directory as a `create` and
|
|
65
|
+
the old one as a `delete`, and the engine removes the old binary before its
|
|
66
|
+
directory.
|
|
67
|
+
- **Nothing estate-specific is in the kit.** Root, owner, group and mode are
|
|
68
|
+
your props. The data sets hold vendor facts only.
|
|
69
|
+
- **One runner for all of it.** Pass the same `HostRunner` to
|
|
70
|
+
`releaseProviders()` as to the jobs. There is no default: a binary goes
|
|
71
|
+
wherever the runner reaches. It provides its own `FetchHttpClient.layer`.
|
|
72
|
+
- **Your own pins work too.** The props are plain data, so a stack may write
|
|
73
|
+
them itself. ⛔ They must be plain values in the stack program: a pin wired
|
|
74
|
+
from another resource's Output (a checksum file read at apply) is refused,
|
|
75
|
+
on a first deploy too (`apply-pins.test.ts`). ⚠️ A stack program that
|
|
76
|
+
fetches the file itself and passes plain strings cannot be told apart from
|
|
77
|
+
a reviewed pin; `catalogBinary()` over a reviewed data set is the path
|
|
78
|
+
that keeps them reviewed.
|
|
79
|
+
|
|
80
|
+
## What reconcile does, in order
|
|
81
|
+
|
|
82
|
+
1. **Validate every prop**, before any host or network call. Every pin must be a
|
|
83
|
+
plain, well-formed value, as the stack program DECLARED it: by now an Output
|
|
84
|
+
has resolved to a plausible string (`declared-pins.ts`).
|
|
85
|
+
2. **Check the directory exists**, is not a symlink, and is not writable by
|
|
86
|
+
group or other (they could rename a file over the binary), before any download.
|
|
87
|
+
3. **Observe the path.** A symlink or directory there is refused. A file this
|
|
88
|
+
resource does not own is refused without `--adopt`, even one holding the
|
|
89
|
+
pinned bytes: at plan the probe calls it `Unowned`, and at apply, when a
|
|
90
|
+
prop was an Output and the probe never ran, reconcile refuses it the same
|
|
91
|
+
way (`binary-claim.ts`). ⛔ A file with other bytes is refused under
|
|
92
|
+
`--adopt` too, at plan and at apply: adopting it would overwrite it in
|
|
93
|
+
place, and the plan would print that as `adopted`. Under `--adopt`, the
|
|
94
|
+
pinned bytes are kept: no download, and a write only to fix mode or owner.
|
|
95
|
+
⚠️ A rename is not an adoption: declare it with `renamedFrom()`. Under
|
|
96
|
+
`--adopt` the new name takes the file, and the old name's delete leaves a
|
|
97
|
+
path another declaration installed in the same deploy (`adopt-parity.test.ts`).
|
|
98
|
+
4. **Download** `https://github.com/<repo>/releases/download/<tag>/<asset>`,
|
|
99
|
+
into memory, never past the pinned size. Two binaries from one archive at
|
|
100
|
+
once share one download.
|
|
101
|
+
5. **Verify the archive** against its pinned SHA-256, before unpacking a byte.
|
|
102
|
+
6. **Unpack only the declared member**, streaming. The tar reader refuses the
|
|
103
|
+
whole archive for any unsafe entry, declared or not.
|
|
104
|
+
7. **Verify the member** against its own pinned SHA-256.
|
|
105
|
+
8. **Write** through `HostRunner.writeFileAtomic`, the one writer every file
|
|
106
|
+
resource uses: a temp file in the same directory, mode and owner set, then
|
|
107
|
+
rename. Under `sudoRunner`, one staged `install -S`, ⚠️ which sets the mode
|
|
108
|
+
just after its rename (measured: `0600` for under half a millisecond).
|
|
109
|
+
9. **Read back** and compare. A create that does not read back, or whose
|
|
110
|
+
read-back fails, is removed.
|
|
111
|
+
|
|
112
|
+
A binary with the right bytes but the wrong mode or owner is re-written from
|
|
113
|
+
the bytes already on disk, re-hashed, without a download.
|
|
114
|
+
|
|
115
|
+
⛔ A directory respelled to the same place (a symlinked parent, or case on
|
|
116
|
+
APFS) is an `update` that keeps the file, never a `replace`: the replace's
|
|
117
|
+
Phase 2 would delete the old path, which is the file (`launchd/file-identity.ts`).
|
|
118
|
+
|
|
119
|
+
## Trust: why the digests are pinned in code
|
|
120
|
+
|
|
121
|
+
★ **Pinned, not fetched at apply.** A checksum file comes from the same
|
|
122
|
+
account, release and channel as the archive, and every release checked so far
|
|
123
|
+
is mutable (`"immutable": false`). Whoever can swap an archive can swap its
|
|
124
|
+
checksum file, and a provider that trusts what it fetches would call the swap
|
|
125
|
+
verified. A pin moves only through a reviewed change, so a swapped asset is a
|
|
126
|
+
refusal. And a plan knows exactly which bytes it will install without the
|
|
127
|
+
network. Each data-set entry records the checksum file's URL, its own SHA-256
|
|
128
|
+
and the date the pins were copied.
|
|
129
|
+
|
|
130
|
+
⚠️ **The trust root.** At apply it is the pins alone: TLS, GitHub's redirect,
|
|
131
|
+
its CDN or a proxy can make a download fail, never make other bytes pass. The
|
|
132
|
+
pins rest on one read of the GitHub release over TLS on the `recorded` date
|
|
133
|
+
(trust on first use), checked against GitHub's own asset `digest`, then on
|
|
134
|
+
review of that commit and the kit release the stack's lockfile resolves.
|
|
135
|
+
VictoriaMetrics publishes no signature, cosign bundle, SLSA provenance or
|
|
136
|
+
GitHub attestation for these archives: a checksum proves integrity, not
|
|
137
|
+
authorship.
|
|
138
|
+
|
|
139
|
+
★ **What CI proves without a network** (`victoria.test.ts`) is consistency,
|
|
140
|
+
not provenance: each committed checksum file in `src/release/fixtures/victoria/`
|
|
141
|
+
hashes to the digest the data set records for it; the strict parser reads
|
|
142
|
+
them; the data set equals what it reads; every asset is in the committed
|
|
143
|
+
listing, and none is an `-enterprise`, `-cluster` or `vlutils` sibling.
|
|
144
|
+
⚠️ That those recorded digests are GitHub's was measured once, not by CI. A
|
|
145
|
+
change that edits a fixture and its pins together passes; review is the gate.
|
|
146
|
+
|
|
147
|
+
## Refusals
|
|
148
|
+
|
|
149
|
+
Every refusal, by where it is refused (the stack program, the plan, the apply,
|
|
150
|
+
the vendor's bytes, the read-back): [release-binary-refusals.md](./release-binary-refusals.md).
|
|
151
|
+
|
|
152
|
+
Data sets and adding a vendor: [release-binary-catalogs.md](./release-binary-catalogs.md).
|
|
153
|
+
Measurements and limits: [release-binary-measured.md](./release-binary-measured.md).
|
|
154
|
+
What alchemy-run/alchemy would need: [release-binary-upstream.md](./release-binary-upstream.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
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",
|
|
@@ -64,6 +64,10 @@
|
|
|
64
64
|
"types": "./dist/linux/index.d.ts",
|
|
65
65
|
"default": "./dist/linux/index.js"
|
|
66
66
|
},
|
|
67
|
+
"./release": {
|
|
68
|
+
"types": "./dist/release/index.d.ts",
|
|
69
|
+
"default": "./dist/release/index.js"
|
|
70
|
+
},
|
|
67
71
|
"./caddy": {
|
|
68
72
|
"types": "./dist/caddy/index.d.ts",
|
|
69
73
|
"default": "./dist/caddy/index.js"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { accessForwardAuth, verifierProblems } from './access-forward-auth.ts';
|
|
3
|
+
|
|
4
|
+
describe('accessForwardAuth', () => {
|
|
5
|
+
test('emits a forward_auth block with the identity headers copied', () => {
|
|
6
|
+
const block = accessForwardAuth({
|
|
7
|
+
verifier: '127.0.0.1:9101',
|
|
8
|
+
body: ['reverse_proxy 127.0.0.1:6080'],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(block).toBe(
|
|
12
|
+
[
|
|
13
|
+
'\treverse_proxy 127.0.0.1:6080',
|
|
14
|
+
'\tforward_auth 127.0.0.1:9101 {',
|
|
15
|
+
'\t\turi /access/verify',
|
|
16
|
+
'\t\tcopy_headers {',
|
|
17
|
+
'\t\t\tX-Access-Email',
|
|
18
|
+
'\t\t\tX-Access-Sub',
|
|
19
|
+
'\t\t}',
|
|
20
|
+
'\t}',
|
|
21
|
+
].join('\n'),
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('copies exactly the headers the verifier sets, by default', () => {
|
|
26
|
+
// ⛔ A header the verifier emits but Caddy does not copy never reaches the upstream;
|
|
27
|
+
// a header Caddy copies but the verifier never sets is the SPOOFING case — the
|
|
28
|
+
// client's own value survives. These two lists must agree, which is why the default
|
|
29
|
+
// is not configurable-by-accident.
|
|
30
|
+
const block = accessForwardAuth({ verifier: '127.0.0.1:9101' });
|
|
31
|
+
expect(block).toContain('X-Access-Email');
|
|
32
|
+
expect(block).toContain('X-Access-Sub');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('refuses a verifier on a routable address', () => {
|
|
36
|
+
// 🔴 A verifier reachable from off-box can be answered around, and the upstream with it.
|
|
37
|
+
expect(() => accessForwardAuth({ verifier: '10.20.10.250:9101' })).toThrow(/loopback/);
|
|
38
|
+
expect(() => accessForwardAuth({ verifier: '0.0.0.0:9101' })).toThrow(/loopback/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('accepts loopback in its usual spellings, and a unix socket', () => {
|
|
42
|
+
for (const verifier of ['127.0.0.1:9101', 'localhost:9101', '[::1]:9101', 'unix//run/v.sock']) {
|
|
43
|
+
expect(verifierProblems(verifier)).toEqual([]);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a custom uri and header names flow through', () => {
|
|
48
|
+
const block = accessForwardAuth({
|
|
49
|
+
verifier: 'unix//run/access.sock',
|
|
50
|
+
uri: '/verify/vnc',
|
|
51
|
+
copyHeaders: ['Remote-Email'],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(block).toContain('forward_auth unix//run/access.sock {');
|
|
55
|
+
expect(block).toContain('uri /verify/vnc');
|
|
56
|
+
expect(block).toContain('Remote-Email');
|
|
57
|
+
expect(block).not.toContain('X-Access-Sub');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('refuses an empty verifier rather than emitting a block that adapts to nothing', () => {
|
|
61
|
+
expect(() => accessForwardAuth({ verifier: ' ' })).toThrow(/empty/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Caddyfile half of "protect this route with Cloudflare Access": a `forward_auth` block
|
|
3
|
+
* pointing at a verifier that speaks the contract in `@homeflare/cloudflare/access-auth`.
|
|
4
|
+
*
|
|
5
|
+
* ★ A BUILDER, NOT A RESOURCE. Nothing here is created, adopted or destroyed — it is Caddyfile
|
|
6
|
+
* TEXT that a caller drops into the `caddyfile` prop of a `CaddyConfig` / `caddyWithFile()`.
|
|
7
|
+
* A resource would own a fragment of a config that `CaddyConfig` already owns whole, and two
|
|
8
|
+
* owners of one file is the drift this package exists to prevent.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ WHY IT IS GENERATED RATHER THAN COPY-PASTED. Every field below is load-bearing, and the
|
|
11
|
+
* failure mode of getting one wrong is a route that looks protected and is not:
|
|
12
|
+
* · a missing `copy_headers` — the upstream gets no identity and either breaks or, worse,
|
|
13
|
+
* falls back to trusting a header the CLIENT set;
|
|
14
|
+
* · the verifier on anything but loopback — the JWT check becomes advisory, because the
|
|
15
|
+
* verifier itself can be reached and answered around;
|
|
16
|
+
* · `uri` pointing at a path the verifier does not route — every request 404s, which is
|
|
17
|
+
* non-2xx, which Caddy correctly treats as a denial, so the symptom is "Access is broken".
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** ⚠️ Kept in step with `granted()` in @homeflare/cloudflare/access-auth. */
|
|
21
|
+
const DEFAULT_COPY_HEADERS = ['X-Access-Email', 'X-Access-Sub'] as const;
|
|
22
|
+
|
|
23
|
+
export interface AccessForwardAuthProps {
|
|
24
|
+
/**
|
|
25
|
+
* Where the verifier listens, e.g. `127.0.0.1:9101`.
|
|
26
|
+
* ⛔ LOOPBACK OR A UNIX SOCKET. See `verifierProblems` — a routable verifier is not a check.
|
|
27
|
+
*/
|
|
28
|
+
readonly verifier: string;
|
|
29
|
+
/** The path the verifier answers on. @default '/access/verify' */
|
|
30
|
+
readonly uri?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Response headers copied from the verifier onto the upstream request.
|
|
33
|
+
* @default ['X-Access-Email', 'X-Access-Sub']
|
|
34
|
+
*/
|
|
35
|
+
readonly copyHeaders?: readonly string[];
|
|
36
|
+
/** Lines placed inside the route BEFORE `forward_auth` — typically the upstream. */
|
|
37
|
+
readonly body?: readonly string[];
|
|
38
|
+
/** Indent applied to every emitted line. @default '\t' */
|
|
39
|
+
readonly indent?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Problems that make a `forward_auth` block unsafe rather than merely wrong.
|
|
44
|
+
*
|
|
45
|
+
* ⛔ THE LOOPBACK RULE IS THE IMPORTANT ONE. If the verifier answers on a routable address, an
|
|
46
|
+
* attacker reaches the UPSTREAM directly and never involves Caddy at all — or reaches the
|
|
47
|
+
* verifier and reads identities out of it. The whole design assumes both the verifier and the
|
|
48
|
+
* upstream are bound to loopback and that Caddy is the only way in.
|
|
49
|
+
*/
|
|
50
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The host part of a Caddy upstream address.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ NOT `split(':')[0]`. That returns `'['` for `[::1]:9101`, so an IPv6 loopback verifier
|
|
56
|
+
* was rejected as routable — caught by the test below, not by reading the code.
|
|
57
|
+
*/
|
|
58
|
+
const hostOf = (verifier: string): string => {
|
|
59
|
+
const address = verifier.replace(/^https?:\/\//, '');
|
|
60
|
+
if (address.startsWith('unix/')) return 'unix';
|
|
61
|
+
|
|
62
|
+
// Bracketed IPv6 keeps its brackets; the port is whatever follows the closing one.
|
|
63
|
+
if (address.startsWith('[')) return address.slice(0, address.indexOf(']') + 1);
|
|
64
|
+
|
|
65
|
+
const colon = address.indexOf(':');
|
|
66
|
+
return colon === -1 ? address : address.slice(0, colon);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const verifierProblems = (verifier: string): string[] => {
|
|
70
|
+
const found: string[] = [];
|
|
71
|
+
const host = hostOf(verifier.trim());
|
|
72
|
+
|
|
73
|
+
if (verifier.trim() === '') found.push('verifier is empty');
|
|
74
|
+
else if (host !== 'unix' && !LOOPBACK.has(host)) {
|
|
75
|
+
found.push(
|
|
76
|
+
`verifier must be loopback or a unix socket, got ${verifier} — a routable verifier can be bypassed`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render the `forward_auth` block for one protected route.
|
|
84
|
+
*
|
|
85
|
+
* ```ts
|
|
86
|
+
* const caddyfile = `vnc.example.com {
|
|
87
|
+
* ${accessForwardAuth({ verifier: '127.0.0.1:9101', body: ['reverse_proxy 127.0.0.1:6080'] })}
|
|
88
|
+
* }
|
|
89
|
+
* `;
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* ⛔ ONE VERIFIER ENDPOINT PER ACCESS APPLICATION. The AUD is fixed in the verifier's own config,
|
|
93
|
+
* never carried in this request — so two applications mean two `uri`s (or two verifiers), not
|
|
94
|
+
* one endpoint told which app to check. An AUD the request can influence is the vulnerability.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ `forward_auth` COPIES THE VERIFIER'S RESPONSE BACK TO THE CLIENT on any non-2xx, which is why
|
|
97
|
+
* the verifier answers a bare 401 with no body: whatever it returns is what the browser sees.
|
|
98
|
+
*/
|
|
99
|
+
export const accessForwardAuth = (props: AccessForwardAuthProps): string => {
|
|
100
|
+
const problems = verifierProblems(props.verifier);
|
|
101
|
+
if (problems.length > 0) throw new Error(`accessForwardAuth: ${problems.join('; ')}`);
|
|
102
|
+
|
|
103
|
+
const indent = props.indent ?? '\t';
|
|
104
|
+
const uri = props.uri ?? '/access/verify';
|
|
105
|
+
const headers = props.copyHeaders ?? DEFAULT_COPY_HEADERS;
|
|
106
|
+
|
|
107
|
+
const lines = [
|
|
108
|
+
...(props.body ?? []),
|
|
109
|
+
`forward_auth ${props.verifier} {`,
|
|
110
|
+
`${indent}uri ${uri}`,
|
|
111
|
+
// ⚠️ `copy_headers` with a block, one field per line: the single-line form is also valid but
|
|
112
|
+
// diffs badly, and this list is exactly where a security review needs to read a change.
|
|
113
|
+
`${indent}copy_headers {`,
|
|
114
|
+
...headers.map((header) => `${indent}${indent}${header}`),
|
|
115
|
+
`${indent}}`,
|
|
116
|
+
'}',
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
return lines.map((line) => `${indent}${line}`).join('\n');
|
|
120
|
+
};
|
package/src/caddy/index.ts
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* ⛔ NOTHING IS ADOPTED SILENTLY: a running config that is not the declared one needs `--adopt`.
|
|
10
10
|
* Guide, order of file and load, adoption, `--resume` and secrets: docs/caddy.md.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* ★ `accessForwardAuth` is Caddyfile TEXT, not a resource: it renders the `forward_auth` block
|
|
14
|
+
* that puts a Cloudflare Access check in front of a route, paired with the verifier in
|
|
15
|
+
* `@homeflare/cloudflare/access-auth`. Guide: docs/access-auth.md.
|
|
16
|
+
*/
|
|
17
|
+
export {
|
|
18
|
+
accessForwardAuth,
|
|
19
|
+
verifierProblems,
|
|
20
|
+
type AccessForwardAuthProps,
|
|
21
|
+
} from './access-forward-auth.ts';
|
|
12
22
|
export type {
|
|
13
23
|
CaddyAdmin,
|
|
14
24
|
CaddyAdminListener,
|
|
@@ -220,10 +220,14 @@ describe('the barrel', () => {
|
|
|
220
220
|
'CaddyConfigProvider',
|
|
221
221
|
'CaddyUnreachableError',
|
|
222
222
|
'DEFAULT_ADMIN_ADDRESS',
|
|
223
|
+
// Caddyfile TEXT for an Access-protected route, not a resource — see
|
|
224
|
+
// access-forward-auth.ts. It pairs with @homeflare/cloudflare/access-auth.
|
|
225
|
+
'accessForwardAuth',
|
|
223
226
|
'caddyAdminLayer',
|
|
224
227
|
'caddyProviders',
|
|
225
228
|
'caddyWithFile',
|
|
226
229
|
'localCaddyAdmin',
|
|
230
|
+
'verifierProblems',
|
|
227
231
|
]);
|
|
228
232
|
});
|
|
229
233
|
});
|