@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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 +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/api-revoke-token.ts
CHANGED
|
@@ -1,38 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `POST /api/auth/revoke-token` — HTTP companion to `parachute auth
|
|
3
|
-
* revoke-token <jti>` (hub#221) and the
|
|
4
|
-
*
|
|
3
|
+
* revoke-token <jti>` (hub#221) and the backing endpoint for the admin
|
|
4
|
+
* UI's revoke action. Closes hub#220.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Auth — capability attenuation, SYMMETRIC to mint-token (hub#452): you may
|
|
7
|
+
* revoke exactly what you could have minted. After validating the bearer
|
|
8
|
+
* (signature / issuer / expiry — same as today):
|
|
9
|
+
*
|
|
10
|
+
* 1. If the bearer holds `parachute:host:auth` → it may revoke ANY jti
|
|
11
|
+
* (the original, broadest behavior — preserved unchanged).
|
|
12
|
+
* 2. Otherwise the bearer must clear the entry gate — it must hold at least
|
|
13
|
+
* one minting authority (`parachute:host:auth`, `parachute:host:admin`,
|
|
14
|
+
* or some `vault:<*>:admin`, via `hasMintingAuthority`). A bearer with
|
|
15
|
+
* none (e.g. a read-only token) gets 403 up front — it can revoke
|
|
16
|
+
* nothing, just as it can mint nothing.
|
|
17
|
+
* 3. The per-jti authority check then governs what such a bearer may
|
|
18
|
+
* actually revoke: the target jti is revocable iff EVERY one of its
|
|
19
|
+
* recorded scopes satisfies `canGrant(bearerScopes, scope)` — i.e. the
|
|
20
|
+
* bearer could have minted that exact token. A `vault:work:admin` bearer
|
|
21
|
+
* can revoke a `vault:work:write` or `vault:work:admin` jti, but NOT a
|
|
22
|
+
* `vault:other:*` jti and NOT a `parachute:host:*` jti — the same
|
|
23
|
+
* cross-vault / host-escalation walls mint enforces.
|
|
24
|
+
*
|
|
25
|
+
* Idempotency / no-info-leak: an UNKNOWN jti (no `tokens` row — never minted
|
|
26
|
+
* or already purged) returns the SAME 404 `not_found` the endpoint has always
|
|
27
|
+
* returned, for every caller including host:auth. The per-jti authority check
|
|
28
|
+
* only runs when the row is FOUND. So an attenuated bearer probing a jti it
|
|
29
|
+
* doesn't own cannot distinguish "exists but not yours" from "doesn't exist"
|
|
30
|
+
* by the unknown-jti path — it gets the identical 404 a host:auth bearer
|
|
31
|
+
* would. A jti that EXISTS but is out of the bearer's authority returns 403
|
|
32
|
+
* (and is NOT revoked): the caller already knows the jti string, so "exists
|
|
33
|
+
* but not yours" leaks nothing beyond what it already holds — and returning
|
|
34
|
+
* idempotent-ok there would be a lie (it revoked nothing).
|
|
10
35
|
*
|
|
11
36
|
* Body: `{ jti: string }`.
|
|
12
37
|
*
|
|
13
|
-
* Responses (
|
|
14
|
-
* mint-token and the rest of the hub's bearer-protected admin API):
|
|
38
|
+
* Responses (OAuth 2.0 error-shape vocabulary, matching mint-token):
|
|
15
39
|
*
|
|
16
40
|
* - 200 `{ jti, revoked_at }` — success. Idempotent: re-revoking an
|
|
17
|
-
* already-revoked jti returns the existing `revoked_at` and 200
|
|
18
|
-
* same as the CLI's exit-0-with-existing-timestamp behavior.
|
|
41
|
+
* already-revoked jti returns the existing `revoked_at` and 200.
|
|
19
42
|
* - 400 `invalid_request` — missing/malformed body, missing jti.
|
|
20
43
|
* - 401 `unauthenticated` — missing or invalid bearer.
|
|
21
|
-
* - 403 `insufficient_scope` — bearer
|
|
44
|
+
* - 403 `insufficient_scope` — bearer holds no minting authority (entry
|
|
45
|
+
* gate), or the target jti carries a scope the bearer couldn't have
|
|
46
|
+
* minted (per-jti authority check).
|
|
22
47
|
* - 404 `not_found` — no `tokens` row matches the jti.
|
|
23
48
|
* - 405 `method_not_allowed` — non-POST.
|
|
24
49
|
*
|
|
25
50
|
* Identity field in audit-friendly success: not echoed in the response
|
|
26
51
|
* body (the JSON shape is intentionally minimal — `jti` + `revoked_at`
|
|
27
52
|
* is all a UI consumer needs); operator-side audit lives in hub logs.
|
|
28
|
-
* Mirrors the CLI's design where `identity=` was added for stdout but
|
|
29
|
-
* the wire response stays narrow.
|
|
30
53
|
*/
|
|
31
54
|
import type { Database } from "bun:sqlite";
|
|
32
55
|
import { findTokenRowByJti, revokeTokenByJti, validateAccessToken } from "./jwt-sign.ts";
|
|
56
|
+
import { MINT_HOST_AUTH_SCOPE, canGrant, hasMintingAuthority } from "./scope-attenuation.ts";
|
|
33
57
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Scope that authorises revoking ANY jti unconditionally (rule 1). A bearer
|
|
60
|
+
* without it may still revoke via attenuation (rule 3) if it clears the
|
|
61
|
+
* `hasMintingAuthority` entry gate.
|
|
62
|
+
*/
|
|
63
|
+
export const API_REVOKE_TOKEN_REQUIRED_SCOPE = MINT_HOST_AUTH_SCOPE;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Maximum accepted length of a caller-supplied `jti`. A real jti is a UUID or
|
|
67
|
+
* short opaque token; anything materially longer is malformed input. Capping
|
|
68
|
+
* it keeps the verbatim-echoed value out of structured logs from bloating.
|
|
69
|
+
*/
|
|
70
|
+
export const MAX_JTI_LENGTH = 256;
|
|
36
71
|
|
|
37
72
|
export interface ApiRevokeTokenDeps {
|
|
38
73
|
db: Database;
|
|
@@ -80,12 +115,19 @@ export async function handleApiRevokeToken(
|
|
|
80
115
|
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
81
116
|
}
|
|
82
117
|
|
|
83
|
-
// 3.
|
|
84
|
-
|
|
118
|
+
// 3. Entry gate. A `parachute:host:auth` bearer may revoke anything
|
|
119
|
+
// (rule 1) and skips the per-jti authority check below. Any other
|
|
120
|
+
// bearer must hold SOME minting authority (host:admin or a
|
|
121
|
+
// `vault:<*>:admin`) to attempt a revoke at all — a bearer with none
|
|
122
|
+
// can revoke nothing under attenuation, so we 403 it here rather than
|
|
123
|
+
// looking up the jti. Whether such a bearer may revoke a SPECIFIC jti
|
|
124
|
+
// is decided per-jti in step 5 via `canGrant`.
|
|
125
|
+
const bearerHasHostAuth = bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE);
|
|
126
|
+
if (!bearerHasHostAuth && !hasMintingAuthority(bearerScopes)) {
|
|
85
127
|
return jsonError(
|
|
86
128
|
403,
|
|
87
129
|
"insufficient_scope",
|
|
88
|
-
`bearer token
|
|
130
|
+
`bearer token holds no revoke authority (need ${API_REVOKE_TOKEN_REQUIRED_SCOPE}, parachute:host:admin, or vault:<name>:admin)`,
|
|
89
131
|
);
|
|
90
132
|
}
|
|
91
133
|
|
|
@@ -103,15 +145,59 @@ export async function handleApiRevokeToken(
|
|
|
103
145
|
if (typeof body.jti !== "string" || body.jti.length === 0) {
|
|
104
146
|
return jsonError(400, "invalid_request", "jti is required and must be a non-empty string");
|
|
105
147
|
}
|
|
148
|
+
// Cap the jti length. It's echoed verbatim into `error_description` and
|
|
149
|
+
// structured log lines; a real jti is a UUID/short token (well under 256
|
|
150
|
+
// chars), so a longer value is malformed input — reject it before it can
|
|
151
|
+
// bloat log lines. JSON-encoded responses already neutralize injection;
|
|
152
|
+
// this is a size guard, not an escaping one.
|
|
153
|
+
if (body.jti.length > MAX_JTI_LENGTH) {
|
|
154
|
+
return jsonError(400, "invalid_request", `jti exceeds ${MAX_JTI_LENGTH}-character maximum`);
|
|
155
|
+
}
|
|
106
156
|
const jti = body.jti;
|
|
107
157
|
|
|
108
|
-
// 5. Lookup + revoke. Order: row-existence first
|
|
109
|
-
//
|
|
110
|
-
//
|
|
158
|
+
// 5. Lookup + per-jti authority + revoke. Order: row-existence first
|
|
159
|
+
// (404 if missing — same response for every caller, no leak), then the
|
|
160
|
+
// attenuation authority check (for non-host:auth bearers), then attempt
|
|
161
|
+
// revoke. Idempotent: if already revoked, surface the existing revoked_at
|
|
162
|
+
// — same CLI semantics from hub#221.
|
|
111
163
|
const existing = findTokenRowByJti(deps.db, jti);
|
|
112
164
|
if (!existing) {
|
|
113
165
|
return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
|
|
114
166
|
}
|
|
167
|
+
|
|
168
|
+
// Per-jti authority (rule 3 / symmetric to mint attenuation). A host:auth
|
|
169
|
+
// bearer skips this — it may revoke anything. Any other bearer may revoke
|
|
170
|
+
// this jti only if EVERY one of its recorded scopes is one the bearer could
|
|
171
|
+
// have minted (`canGrant`). One out-of-authority scope (cross-vault, a
|
|
172
|
+
// host:* scope, etc.) blocks the whole revoke with 403 — and the token is
|
|
173
|
+
// left intact. The caller already knows the jti, so "exists but not yours"
|
|
174
|
+
// leaks nothing beyond what it holds; idempotent-ok would falsely imply a
|
|
175
|
+
// revoke happened.
|
|
176
|
+
if (!bearerHasHostAuth) {
|
|
177
|
+
// A scopeless target (recorded `scopes: []`) would otherwise pass the
|
|
178
|
+
// `canGrant` filter vacuously — `[].filter(...)` is empty, so
|
|
179
|
+
// `ungrantable.length === 0`. That's silently permissive: any bearer
|
|
180
|
+
// clearing the entry gate could revoke a zero-scope token. Such tokens
|
|
181
|
+
// shouldn't exist (the CLI/SPA never mint them), but if one does, only a
|
|
182
|
+
// host:auth bearer may revoke it — a non-host:auth bearer has no
|
|
183
|
+
// attenuation authority that "covers" the empty scope set.
|
|
184
|
+
if (existing.scopes.length === 0) {
|
|
185
|
+
return jsonError(
|
|
186
|
+
403,
|
|
187
|
+
"insufficient_scope",
|
|
188
|
+
`bearer token cannot revoke jti ${jti}: target has no recorded scopes (only ${API_REVOKE_TOKEN_REQUIRED_SCOPE} may revoke a scopeless token)`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const ungrantable = existing.scopes.filter((s) => !canGrant(bearerScopes, s));
|
|
192
|
+
if (ungrantable.length > 0) {
|
|
193
|
+
return jsonError(
|
|
194
|
+
403,
|
|
195
|
+
"insufficient_scope",
|
|
196
|
+
`bearer token cannot revoke jti ${jti}: its scope(s) ${ungrantable.join(", ")} are outside the bearer's authority`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
115
201
|
if (existing.revokedAt) {
|
|
116
202
|
return ok({ jti, revoked_at: existing.revokedAt });
|
|
117
203
|
}
|
package/src/api-users.ts
CHANGED
|
@@ -336,7 +336,16 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
|
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* DELETE /api/users/:id — hard-delete + token revocation + session/grant
|
|
341
|
+
* cleanup.
|
|
342
|
+
*
|
|
343
|
+
* Success returns `200 { ok: true, revocation_lag_seconds: 60 }` (was a bare
|
|
344
|
+
* 204 pre-consistency-fix) so the SPA can warn that the deleted user's
|
|
345
|
+
* tokens linger ~60s on resource-server revocation caches — same surface
|
|
346
|
+
* the reset-password path carries. The race-tolerant "row already gone"
|
|
347
|
+
* path stays a bodyless 204 (nothing was revoked here, no lag to report).
|
|
348
|
+
*/
|
|
340
349
|
export async function handleDeleteUser(
|
|
341
350
|
req: Request,
|
|
342
351
|
userId: string,
|
|
@@ -390,11 +399,28 @@ export async function handleDeleteUser(
|
|
|
390
399
|
if (!removed) {
|
|
391
400
|
// Race: row deleted by a concurrent request. Operator's intent
|
|
392
401
|
// (no such user) is already satisfied — same shape as the grant-
|
|
393
|
-
// revoke race in `admin-grants.ts`.
|
|
402
|
+
// revoke race in `admin-grants.ts`. No tokens were revoked by THIS
|
|
403
|
+
// call, so there's no revocation lag to warn about; keep the bodyless
|
|
404
|
+
// 204 for the race path.
|
|
394
405
|
return new Response(null, { status: 204 });
|
|
395
406
|
}
|
|
396
407
|
console.log(`user deleted: id=${userId} username=${target.username}`);
|
|
397
|
-
|
|
408
|
+
// `revocation_lag_seconds`: same consistency fix the reset-password path
|
|
409
|
+
// got (smoke 2026-05-27 finding 3). Deleting a user revokes their tokens
|
|
410
|
+
// in hub's DB immediately, but resource servers (vault, scribe, …) cache
|
|
411
|
+
// the revocation list via scope-guard's `REVOCATION_CACHE_TTL_MS = 60_000`
|
|
412
|
+
// — a deleted user's tokens linger for up to ~60s on those caches. Surface
|
|
413
|
+
// that so the admin isn't surprised when a just-deleted user's client can
|
|
414
|
+
// still read for a minute (relevant in the stolen-device / compromise
|
|
415
|
+
// threat model). 200 + body instead of the old bare 204 so the SPA can
|
|
416
|
+
// render the warning banner.
|
|
417
|
+
return new Response(
|
|
418
|
+
JSON.stringify({ ok: true, revocation_lag_seconds: REVOCATION_LAG_SECONDS }),
|
|
419
|
+
{
|
|
420
|
+
status: 200,
|
|
421
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
422
|
+
},
|
|
423
|
+
);
|
|
398
424
|
}
|
|
399
425
|
|
|
400
426
|
/**
|
package/src/cli.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* Run `parachute --help` or `parachute <subcommand> --help` for usage.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { MissingDependencyError } from "@openparachute/depcheck";
|
|
9
10
|
import pkg from "../package.json" with { type: "json" };
|
|
10
11
|
import { CloudflaredStateError } from "./cloudflare/state.ts";
|
|
11
12
|
import { auth } from "./commands/auth.ts";
|
|
12
13
|
import { exposePublic, exposeTailnet } from "./commands/expose.ts";
|
|
14
|
+
import { init } from "./commands/init.ts";
|
|
13
15
|
import { install } from "./commands/install.ts";
|
|
14
16
|
import { logs, restart, start, stop } from "./commands/lifecycle.ts";
|
|
15
17
|
import { migrate } from "./commands/migrate.ts";
|
|
@@ -18,15 +20,18 @@ import { setup } from "./commands/setup.ts";
|
|
|
18
20
|
import { status } from "./commands/status.ts";
|
|
19
21
|
import { upgrade } from "./commands/upgrade.ts";
|
|
20
22
|
import { dispatchVault } from "./commands/vault.ts";
|
|
23
|
+
import { runSetupWizardCommand } from "./commands/wizard.ts";
|
|
21
24
|
import { ExposeStateError } from "./expose-state.ts";
|
|
22
25
|
import {
|
|
23
26
|
exposeHelp,
|
|
27
|
+
initHelp,
|
|
24
28
|
installHelp,
|
|
25
29
|
logsHelp,
|
|
26
30
|
migrateHelp,
|
|
27
31
|
restartHelp,
|
|
28
32
|
serveHelp,
|
|
29
33
|
setupHelp,
|
|
34
|
+
setupWizardHelp,
|
|
30
35
|
startHelp,
|
|
31
36
|
statusHelp,
|
|
32
37
|
stopHelp,
|
|
@@ -305,6 +310,76 @@ async function main(argv: string[]): Promise<number> {
|
|
|
305
310
|
return await setup(setupOpts);
|
|
306
311
|
}
|
|
307
312
|
|
|
313
|
+
case "setup-wizard": {
|
|
314
|
+
// hub#168 Cut 3 — the in-terminal mirror of /admin/setup. Distinct
|
|
315
|
+
// from `parachute setup` (which is the multi-pick install
|
|
316
|
+
// walk-through, not a wizard-handler frontend). Both surfaces stay
|
|
317
|
+
// — `parachute setup` is the historical "install + configure
|
|
318
|
+
// services" entry; `parachute setup-wizard` drives the same
|
|
319
|
+
// handlers the browser wizard uses.
|
|
320
|
+
if (isHelpFlag(rest[0])) {
|
|
321
|
+
console.log(setupWizardHelp());
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
return await runSetupWizardCommand(rest);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case "init": {
|
|
328
|
+
if (isHelpFlag(rest[0])) {
|
|
329
|
+
console.log(initHelp());
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
const exposeExtract = extractNamedFlag(rest, "--expose");
|
|
333
|
+
if (exposeExtract.error) {
|
|
334
|
+
console.error(`parachute init: ${exposeExtract.error}`);
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
if (
|
|
338
|
+
exposeExtract.value !== undefined &&
|
|
339
|
+
exposeExtract.value !== "none" &&
|
|
340
|
+
exposeExtract.value !== "tailnet" &&
|
|
341
|
+
exposeExtract.value !== "cloudflare"
|
|
342
|
+
) {
|
|
343
|
+
console.error(
|
|
344
|
+
`parachute init: --expose must be one of none|tailnet|cloudflare (got "${exposeExtract.value}")`,
|
|
345
|
+
);
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
const noBrowser = exposeExtract.rest.includes("--no-browser");
|
|
349
|
+
const noExposePrompt = exposeExtract.rest.includes("--no-expose-prompt");
|
|
350
|
+
const cliWizard = exposeExtract.rest.includes("--cli-wizard");
|
|
351
|
+
const browserWizard = exposeExtract.rest.includes("--browser-wizard");
|
|
352
|
+
const known = new Set([
|
|
353
|
+
"--no-browser",
|
|
354
|
+
"--no-expose-prompt",
|
|
355
|
+
"--cli-wizard",
|
|
356
|
+
"--browser-wizard",
|
|
357
|
+
]);
|
|
358
|
+
const unknown = exposeExtract.rest.find((a) => !known.has(a));
|
|
359
|
+
if (unknown !== undefined) {
|
|
360
|
+
console.error(`parachute init: unknown argument "${unknown}"`);
|
|
361
|
+
console.error(
|
|
362
|
+
"usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
|
|
363
|
+
" [--expose none|tailnet|cloudflare]\n" +
|
|
364
|
+
" [--cli-wizard | --browser-wizard]",
|
|
365
|
+
);
|
|
366
|
+
return 1;
|
|
367
|
+
}
|
|
368
|
+
if (cliWizard && browserWizard) {
|
|
369
|
+
console.error("parachute init: --cli-wizard and --browser-wizard are mutually exclusive.");
|
|
370
|
+
return 1;
|
|
371
|
+
}
|
|
372
|
+
const initOpts: Parameters<typeof init>[0] = {};
|
|
373
|
+
if (noBrowser) initOpts.noBrowser = true;
|
|
374
|
+
if (noExposePrompt) initOpts.noExposePrompt = true;
|
|
375
|
+
if (exposeExtract.value) {
|
|
376
|
+
initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
|
|
377
|
+
}
|
|
378
|
+
if (cliWizard) initOpts.wizardChoice = "cli";
|
|
379
|
+
else if (browserWizard) initOpts.wizardChoice = "browser";
|
|
380
|
+
return await init(initOpts);
|
|
381
|
+
}
|
|
382
|
+
|
|
308
383
|
case "install": {
|
|
309
384
|
if (isHelpFlag(rest[0])) {
|
|
310
385
|
console.log(installHelp());
|
|
@@ -398,12 +473,21 @@ async function main(argv: string[]): Promise<number> {
|
|
|
398
473
|
return 1;
|
|
399
474
|
}
|
|
400
475
|
const exposeArgs = flagExtract.rest;
|
|
401
|
-
|
|
476
|
+
let layer = exposeArgs[0];
|
|
402
477
|
const mode = exposeArgs[1];
|
|
403
478
|
if (isHelpFlag(layer)) {
|
|
404
479
|
console.log(exposeHelp());
|
|
405
480
|
return 0;
|
|
406
481
|
}
|
|
482
|
+
// Alias: `parachute expose cloudflare [--domain X] [off]` is shorthand for
|
|
483
|
+
// `parachute expose public --cloudflare …`. Cloudflare is a public-internet
|
|
484
|
+
// provider, so we rewrite the layer to `public` and force the cloudflare
|
|
485
|
+
// flag — the rest of the dispatch (domain prompt, off-path, etc.) is
|
|
486
|
+
// identical to the canonical form.
|
|
487
|
+
if (layer === "cloudflare") {
|
|
488
|
+
layer = "public";
|
|
489
|
+
flagExtract.cloudflare = true;
|
|
490
|
+
}
|
|
407
491
|
if (layer !== "tailnet" && layer !== "public") {
|
|
408
492
|
console.error(`parachute expose: unknown layer "${layer ?? ""}"`);
|
|
409
493
|
console.error("usage: parachute expose tailnet [off]");
|
|
@@ -627,14 +711,17 @@ async function main(argv: string[]): Promise<number> {
|
|
|
627
711
|
return 0;
|
|
628
712
|
}
|
|
629
713
|
const dryRun = rest.includes("--dry-run");
|
|
714
|
+
const list = rest.includes("--list");
|
|
630
715
|
const yes = rest.includes("--yes") || rest.includes("-y");
|
|
631
|
-
const unknown = rest.find(
|
|
716
|
+
const unknown = rest.find(
|
|
717
|
+
(a) => a !== "--dry-run" && a !== "--list" && a !== "--yes" && a !== "-y",
|
|
718
|
+
);
|
|
632
719
|
if (unknown !== undefined) {
|
|
633
720
|
console.error(`parachute migrate: unknown argument "${unknown}"`);
|
|
634
|
-
console.error("usage: parachute migrate [--dry-run] [--yes]");
|
|
721
|
+
console.error("usage: parachute migrate [--list] [--dry-run] [--yes]");
|
|
635
722
|
return 1;
|
|
636
723
|
}
|
|
637
|
-
return await migrate({ dryRun, yes });
|
|
724
|
+
return await migrate({ dryRun, list, yes });
|
|
638
725
|
}
|
|
639
726
|
|
|
640
727
|
case "serve": {
|
|
@@ -672,32 +759,24 @@ async function main(argv: string[]): Promise<number> {
|
|
|
672
759
|
// after `vault` (including --help) is passed through verbatim.
|
|
673
760
|
if (rest.length === 0) return await dispatchVault(["--help"]);
|
|
674
761
|
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
rest[1] === "create" &&
|
|
683
|
-
isTtyInteractive() &&
|
|
684
|
-
!rest.includes("--scope") &&
|
|
685
|
-
!rest.includes("--read") &&
|
|
686
|
-
!rest.includes("--permission") &&
|
|
687
|
-
!isHelpFlag(rest[2]);
|
|
688
|
-
if (wantsGuidedTokenCreate) {
|
|
689
|
-
const { runVaultTokensCreateInteractive } = await import(
|
|
690
|
-
"./commands/vault-tokens-create-interactive.ts"
|
|
691
|
-
);
|
|
692
|
-
return await runVaultTokensCreateInteractive({ args: rest.slice(2) });
|
|
693
|
-
}
|
|
694
|
-
|
|
762
|
+
// Everything under `vault` forwards transparently to `parachute-vault`.
|
|
763
|
+
// `vault tokens create` used to route through a guided interactive
|
|
764
|
+
// wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
|
|
765
|
+
// subcommand — it now exits 1 with migration guidance. Access tokens are
|
|
766
|
+
// hub-issued JWTs; mint them with `parachute auth mint-token` or the
|
|
767
|
+
// admin SPA Connect card. We forward verbatim so the operator sees
|
|
768
|
+
// vault's own migration error rather than a hub-side stub.
|
|
695
769
|
return await dispatchVault(rest);
|
|
696
770
|
}
|
|
697
771
|
|
|
698
772
|
default:
|
|
699
773
|
console.error(`parachute: unknown command "${command}"`);
|
|
700
|
-
console.error("
|
|
774
|
+
console.error("");
|
|
775
|
+
console.error("If this is a fresh install, start here:");
|
|
776
|
+
console.error(" parachute init # get the admin wizard going");
|
|
777
|
+
console.error("");
|
|
778
|
+
console.error("Or see all commands:");
|
|
779
|
+
console.error(" parachute --help");
|
|
701
780
|
return 1;
|
|
702
781
|
}
|
|
703
782
|
}
|
|
@@ -706,6 +785,14 @@ async function run(argv: string[]): Promise<number> {
|
|
|
706
785
|
try {
|
|
707
786
|
return await main(argv);
|
|
708
787
|
} catch (err) {
|
|
788
|
+
if (err instanceof MissingDependencyError) {
|
|
789
|
+
// A required external binary wasn't on PATH (git / tailscale / tail /
|
|
790
|
+
// …). Print the friendly install block to stderr. interactive:true so
|
|
791
|
+
// the operator at a terminal sees the "ask your sysadmin" trailer; the
|
|
792
|
+
// message was already formatted at construction, so we just emit it.
|
|
793
|
+
console.error(err.message);
|
|
794
|
+
return 1;
|
|
795
|
+
}
|
|
709
796
|
if (err instanceof ServicesManifestError) {
|
|
710
797
|
console.error(`services.json is malformed: ${err.message}`);
|
|
711
798
|
console.error("Fix or remove the file, then re-run.");
|
package/src/clients.ts
CHANGED
|
@@ -12,12 +12,24 @@
|
|
|
12
12
|
* plaintext exactly once. The token endpoint enforces client_secret per
|
|
13
13
|
* RFC 6749 §3.2.1 (closes #72).
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* Status column (`pending` | `approved`): every row carries one. New
|
|
16
|
+
* self-registrations default to `pending`; registrations that authenticate
|
|
17
|
+
* with an operator token bearing `hub:admin` (the install-time path for
|
|
18
|
+
* first-party modules) land as `approved`.
|
|
19
|
+
*
|
|
20
|
+
* Single-consent change (2026-05-29): the separate operator "approve this
|
|
21
|
+
* client" gate was retired. The user's OAuth consent IS the authorization —
|
|
22
|
+
* `handleAuthorizeGet` now session-gates a `pending` client: a request
|
|
23
|
+
* carrying a valid session auto-approves the client (status → `approved`,
|
|
24
|
+
* audit-logged) and FALLS THROUGH to the normal consent screen; a session-
|
|
25
|
+
* less request still renders the unauth "App not yet approved" page whose
|
|
26
|
+
* sign-in CTA round-trips back to authorize (after login the user re-enters
|
|
27
|
+
* with a session → auto-approve → consent). The `status` column, the DCR
|
|
28
|
+
* `pending` default, the `/oauth/token` pending rejection, and the
|
|
29
|
+
* `parachute auth approve-client` / SPA approve surfaces all persist but are
|
|
30
|
+
* near-vestigial — kept for defense-in-depth and back-compat. Motivation:
|
|
31
|
+
* Notes/Claude DCR a fresh `client_id` per instance, so a per-client_id
|
|
32
|
+
* approval gate re-prompted the operator constantly.
|
|
21
33
|
*/
|
|
22
34
|
import type { Database } from "bun:sqlite";
|
|
23
35
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
package/src/cloudflare/config.ts
CHANGED
|
@@ -2,8 +2,6 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR } from "../config.ts";
|
|
4
4
|
|
|
5
|
-
export const CLOUDFLARED_DIR = join(CONFIG_DIR, "cloudflared");
|
|
6
|
-
|
|
7
5
|
export const DEFAULT_TUNNEL_NAME = "parachute";
|
|
8
6
|
|
|
9
7
|
/**
|
|
@@ -16,12 +14,20 @@ export const DEFAULT_TUNNEL_NAME = "parachute";
|
|
|
16
14
|
* location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
|
|
17
15
|
* Re-running `parachute expose public --cloudflare` regenerates the file
|
|
18
16
|
* at the new path; the legacy file is left in place but unused.
|
|
17
|
+
*
|
|
18
|
+
* `configDir` overrides the base (`~/.parachute` by default). Tests pass a
|
|
19
|
+
* tmp dir so per-tunnel-derived paths never resolve against the operator's
|
|
20
|
+
* real `CONFIG_DIR` — otherwise running the suite scribbles fixture
|
|
21
|
+
* config.yml + log files into `~/.parachute/cloudflared/<name>/`.
|
|
19
22
|
*/
|
|
20
|
-
export function cloudflaredPathsFor(
|
|
23
|
+
export function cloudflaredPathsFor(
|
|
24
|
+
tunnelName: string,
|
|
25
|
+
configDir: string = CONFIG_DIR,
|
|
26
|
+
): {
|
|
21
27
|
configPath: string;
|
|
22
28
|
logPath: string;
|
|
23
29
|
} {
|
|
24
|
-
const dir = join(
|
|
30
|
+
const dir = join(configDir, "cloudflared", tunnelName);
|
|
25
31
|
return {
|
|
26
32
|
configPath: join(dir, "config.yml"),
|
|
27
33
|
logPath: join(dir, "cloudflared.log"),
|
package/src/cloudflare/detect.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { isBinaryNotFoundError, lookupDep } from "@openparachute/depcheck";
|
|
4
5
|
import type { Runner } from "../tailscale/run.ts";
|
|
5
6
|
|
|
6
7
|
export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
|
|
@@ -10,30 +11,22 @@ export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
|
|
|
10
11
|
* "binary not on PATH" errors — anything else (EACCES from a non-executable
|
|
11
12
|
* file, corrupted binary, etc.) propagates so we don't silently report
|
|
12
13
|
* "not installed" when something more specific is wrong.
|
|
14
|
+
*
|
|
15
|
+
* The not-found matcher is `@openparachute/depcheck`'s `isBinaryNotFoundError`
|
|
16
|
+
* — the single source of truth across the ecosystem (this used to be a local
|
|
17
|
+
* copy that drifted from vault's `git-preflight.ts`). Pass the binary name so
|
|
18
|
+
* a not-found message about an unrelated file isn't mis-attributed.
|
|
13
19
|
*/
|
|
14
20
|
export async function isCloudflaredInstalled(runner: Runner): Promise<boolean> {
|
|
15
21
|
try {
|
|
16
22
|
const { code } = await runner(["cloudflared", "--version"]);
|
|
17
23
|
return code === 0;
|
|
18
24
|
} catch (err) {
|
|
19
|
-
if (isBinaryNotFoundError(err)) return false;
|
|
25
|
+
if (isBinaryNotFoundError(err, "cloudflared")) return false;
|
|
20
26
|
throw err;
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
function isBinaryNotFoundError(err: unknown): boolean {
|
|
25
|
-
if (!err || typeof err !== "object") return false;
|
|
26
|
-
const e = err as { code?: unknown; message?: unknown };
|
|
27
|
-
if (e.code === "ENOENT") return true;
|
|
28
|
-
// Bun.spawn's error shape varies across versions; fall back to message
|
|
29
|
-
// string matching so we catch "Executable not found in $PATH" and
|
|
30
|
-
// "ENOENT" variants without pinning to one runtime detail.
|
|
31
|
-
if (typeof e.message === "string") {
|
|
32
|
-
return /ENOENT|not found|No such file/i.test(e.message);
|
|
33
|
-
}
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
30
|
/**
|
|
38
31
|
* `cloudflared tunnel login` drops a cert at `~/.cloudflared/cert.pem` — its
|
|
39
32
|
* presence is cloudflared's own login marker. Every `cloudflared tunnel
|
|
@@ -45,14 +38,83 @@ export function isCloudflaredLoggedIn(cloudflaredHome: string = DEFAULT_CLOUDFLA
|
|
|
45
38
|
return existsSync(join(cloudflaredHome, "cert.pem"));
|
|
46
39
|
}
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Cloudflare's "Downloads" page (developers.cloudflare.com/cloudflare-one/
|
|
43
|
+
* connections/connect-networks/downloads/) churns markdown anchors; pkg.cloudflare.com
|
|
44
|
+
* paths the older instructions referenced now serve HTML / 404. Aaron hit
|
|
45
|
+
* the failure mode on a fresh Amazon Linux 2023 EC2 install (2026-05-27):
|
|
46
|
+
* `sudo dnf install cloudflared` returned 'No match for argument:
|
|
47
|
+
* cloudflared'. The reliable cross-distro path is grabbing the static
|
|
48
|
+
* binary from Cloudflare's GitHub releases.
|
|
49
|
+
*
|
|
50
|
+
* Canonical install paths:
|
|
51
|
+
*
|
|
52
|
+
* macOS → `brew install cloudflared` (homebrew is the documented path)
|
|
53
|
+
* Linux → architecture-specific binary from GitHub releases
|
|
54
|
+
* other → the binary-download path is still the best generic answer
|
|
55
|
+
*
|
|
56
|
+
* The `arch` parameter is the architecture string in `process.arch`
|
|
57
|
+
* shape (`x64`, `arm64`, `arm`). The static-binary curl recipe + the arch
|
|
58
|
+
* mapping now live in `@openparachute/depcheck`'s `cloudflared` registry
|
|
59
|
+
* entry (`install.linuxBinaryUrl`) — the single source of truth shared with
|
|
60
|
+
* the structured `MissingDependencyError` UX. This function keeps its own
|
|
61
|
+
* prose (the surrounding "works across distros" framing the expose flow
|
|
62
|
+
* prints) but derives the URL + arch support from the registry so the two
|
|
63
|
+
* can't drift. A `undefined` recipe (arch with no published artifact) is the
|
|
64
|
+
* signal to fall through to the generic releases pointer rather than
|
|
65
|
+
* fabricating a 404-bound URL.
|
|
66
|
+
*/
|
|
67
|
+
export function cloudflaredInstallHint(
|
|
68
|
+
platform: NodeJS.Platform = process.platform,
|
|
69
|
+
arch: NodeJS.Architecture = process.arch,
|
|
70
|
+
): string {
|
|
71
|
+
const releasesUrl = "https://github.com/cloudflare/cloudflared/releases/latest";
|
|
51
72
|
if (platform === "darwin") {
|
|
52
|
-
return
|
|
73
|
+
return [
|
|
74
|
+
"Install cloudflared:",
|
|
75
|
+
" brew install cloudflared",
|
|
76
|
+
"",
|
|
77
|
+
"(or download a static binary from",
|
|
78
|
+
` ${releasesUrl})`,
|
|
79
|
+
].join("\n");
|
|
53
80
|
}
|
|
54
81
|
if (platform === "linux") {
|
|
55
|
-
|
|
82
|
+
const downloadUrl = cloudflaredLinuxDownloadUrl(arch);
|
|
83
|
+
if (downloadUrl) {
|
|
84
|
+
return [
|
|
85
|
+
"Install cloudflared (static binary — works across distros):",
|
|
86
|
+
" curl -L -o /usr/local/bin/cloudflared \\",
|
|
87
|
+
` ${downloadUrl}`,
|
|
88
|
+
" sudo chmod +x /usr/local/bin/cloudflared",
|
|
89
|
+
" cloudflared --version",
|
|
90
|
+
"",
|
|
91
|
+
"(distro packages are unreliable across versions; the GitHub release is the canonical path.)",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
return [
|
|
95
|
+
"Install cloudflared from the official binary release:",
|
|
96
|
+
` ${releasesUrl}`,
|
|
97
|
+
`(pick the linux-* artifact matching your architecture; your arch is "${arch}")`,
|
|
98
|
+
].join("\n");
|
|
56
99
|
}
|
|
57
|
-
return
|
|
100
|
+
return ["Install cloudflared from the official binary release:", ` ${releasesUrl}`].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pull the cloudflared-linux-<suffix> download URL for an arch out of the
|
|
105
|
+
* depcheck registry's static-binary recipe. The registry recipe is a
|
|
106
|
+
* multi-line `curl … / chmod … / version` block; we extract the single
|
|
107
|
+
* `https://…/cloudflared-linux-<suffix>` line so this function's own prose
|
|
108
|
+
* wraps the canonical URL. Returns undefined when the arch has no published
|
|
109
|
+
* artifact (registry recipe is undefined) — the caller then uses the generic
|
|
110
|
+
* pointer. Keeps the arch→suffix mapping in exactly one place (the registry).
|
|
111
|
+
*/
|
|
112
|
+
function cloudflaredLinuxDownloadUrl(arch: NodeJS.Architecture): string | undefined {
|
|
113
|
+
const recipe = lookupDep("cloudflared")?.install.linuxBinaryUrl?.(arch);
|
|
114
|
+
if (!recipe) return undefined;
|
|
115
|
+
const urlLine = recipe
|
|
116
|
+
.split("\n")
|
|
117
|
+
.map((l) => l.trim())
|
|
118
|
+
.find((l) => l.startsWith("https://"));
|
|
119
|
+
return urlLine;
|
|
58
120
|
}
|