@openparachute/hub 0.5.14-rc.9 → 0.6.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 +23 -0
- 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 +30 -21
- package/src/__tests__/api-modules-ops.test.ts +45 -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__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- 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__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +41 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- 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 +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -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-modules-ops.ts +49 -11
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- 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 +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -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/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-explanations.ts +46 -18
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- 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 +71 -19
- 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-account.ts
CHANGED
|
@@ -55,12 +55,15 @@ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
|
55
55
|
import { changePasswordRateLimiter } from "./rate-limit.ts";
|
|
56
56
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
57
57
|
import { findActiveSession } from "./sessions.ts";
|
|
58
|
+
import { isTotpEnrolled } from "./two-factor-store.ts";
|
|
58
59
|
import {
|
|
59
60
|
PASSWORD_MAX_LEN,
|
|
60
61
|
UserNotFoundError,
|
|
62
|
+
type VaultVerb,
|
|
61
63
|
getUserById,
|
|
62
64
|
isFirstAdmin,
|
|
63
65
|
validatePassword,
|
|
66
|
+
vaultVerbsForUserVault,
|
|
64
67
|
verifyPassword,
|
|
65
68
|
} from "./users.ts";
|
|
66
69
|
|
|
@@ -489,6 +492,15 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
|
|
|
489
492
|
const adminFlag = isFirstAdmin(deps.db, user.id);
|
|
490
493
|
const csrf = ensureCsrfToken(req);
|
|
491
494
|
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
495
|
+
// Per-vault mintable verbs for the "mint an access token" affordance on each
|
|
496
|
+
// tile. Reads the assignment role (today always write → ["read", "write"])
|
|
497
|
+
// so the UI only ever offers a verb the POST handler would accept. Empty for
|
|
498
|
+
// the admin / no-vault branches (no assigned vaults to iterate).
|
|
499
|
+
const mintableVerbs: Record<string, VaultVerb[]> = {};
|
|
500
|
+
for (const v of user.assignedVaults) {
|
|
501
|
+
const verbs = vaultVerbsForUserVault(deps.db, user.id, v);
|
|
502
|
+
if (verbs && verbs.length > 0) mintableVerbs[v] = verbs;
|
|
503
|
+
}
|
|
492
504
|
return htmlResponse(
|
|
493
505
|
renderAccountHome({
|
|
494
506
|
username: user.username,
|
|
@@ -497,6 +509,8 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
|
|
|
497
509
|
hubOrigin: deps.hubOrigin,
|
|
498
510
|
isFirstAdmin: adminFlag,
|
|
499
511
|
csrfToken: csrf.token,
|
|
512
|
+
twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
|
|
513
|
+
mintableVerbs,
|
|
500
514
|
}),
|
|
501
515
|
200,
|
|
502
516
|
extra,
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import type { Database } from "bun:sqlite";
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
import { dirname } from "node:path";
|
|
38
|
+
import { MissingDependencyError, type MissingDependencyWire } from "@openparachute/depcheck";
|
|
38
39
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
39
40
|
import { isLinked as defaultIsLinked } from "./bun-link.ts";
|
|
40
41
|
import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
|
|
@@ -49,11 +50,7 @@ import {
|
|
|
49
50
|
getSpec,
|
|
50
51
|
synthesizeManifestForKnownModule,
|
|
51
52
|
} from "./service-spec.ts";
|
|
52
|
-
import {
|
|
53
|
-
findService,
|
|
54
|
-
readManifestLenient,
|
|
55
|
-
removeService,
|
|
56
|
-
} from "./services-manifest.ts";
|
|
53
|
+
import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
|
|
57
54
|
import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
|
|
58
55
|
import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
|
|
59
56
|
|
|
@@ -81,6 +78,15 @@ export interface Operation {
|
|
|
81
78
|
log: string[];
|
|
82
79
|
/** Error message when status is `failed`. Mirrored from the underlying throw. */
|
|
83
80
|
error?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Structured error detail when the failure is a known typed error — today
|
|
83
|
+
* only `MissingDependencyError.toWire()` (a missing external binary like
|
|
84
|
+
* `bun` / `git` during install). The operations-polling SPA switches on
|
|
85
|
+
* `error_detail.error_type === "missing_dependency"` to render a dedicated
|
|
86
|
+
* install card; the plain `error` string is the fallback for everything
|
|
87
|
+
* else. Wire shape matches `@openparachute/depcheck`'s `MissingDependencyWire`.
|
|
88
|
+
*/
|
|
89
|
+
error_detail?: MissingDependencyWire;
|
|
84
90
|
startedAt: string;
|
|
85
91
|
finishedAt?: string;
|
|
86
92
|
}
|
|
@@ -89,7 +95,11 @@ export interface OperationsRegistry {
|
|
|
89
95
|
create(kind: OperationKind, short: string): Operation;
|
|
90
96
|
get(id: string): Operation | undefined;
|
|
91
97
|
/** Append a log line + (optionally) advance status. */
|
|
92
|
-
update(
|
|
98
|
+
update(
|
|
99
|
+
id: string,
|
|
100
|
+
patch: Partial<Pick<Operation, "status" | "error" | "error_detail">>,
|
|
101
|
+
logLine?: string,
|
|
102
|
+
): void;
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
/**
|
|
@@ -122,11 +132,16 @@ class InMemoryOperationsRegistry implements OperationsRegistry {
|
|
|
122
132
|
return this.ops.get(id);
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
update(
|
|
135
|
+
update(
|
|
136
|
+
id: string,
|
|
137
|
+
patch: Partial<Pick<Operation, "status" | "error" | "error_detail">>,
|
|
138
|
+
logLine?: string,
|
|
139
|
+
): void {
|
|
126
140
|
const op = this.ops.get(id);
|
|
127
141
|
if (!op) return;
|
|
128
142
|
if (patch.status) op.status = patch.status;
|
|
129
143
|
if (patch.error !== undefined) op.error = patch.error;
|
|
144
|
+
if (patch.error_detail !== undefined) op.error_detail = patch.error_detail;
|
|
130
145
|
if (logLine) op.log.push(logLine);
|
|
131
146
|
if (patch.status === "succeeded" || patch.status === "failed") {
|
|
132
147
|
op.finishedAt = this.clock().toISOString();
|
|
@@ -520,13 +535,37 @@ export async function handleInstall(
|
|
|
520
535
|
// immediately + the work runs in the background. Errors get logged
|
|
521
536
|
// to the operation; nothing throws back to the request handler.
|
|
522
537
|
void runInstall(op.id, short, spec, deps, bodyChannel).catch((err) => {
|
|
523
|
-
|
|
524
|
-
registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
|
|
538
|
+
failOperation(registry, op.id, "install", err);
|
|
525
539
|
});
|
|
526
540
|
|
|
527
541
|
return acceptedOp(op.id);
|
|
528
542
|
}
|
|
529
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Mark an async op failed, attaching the structured `error_detail` wire when
|
|
546
|
+
* the underlying throw is a `MissingDependencyError` (a missing external
|
|
547
|
+
* binary like `bun` / `git` during install). The operations-polling SPA reads
|
|
548
|
+
* `error_detail` to render the dedicated install card; the plain `error`
|
|
549
|
+
* string is the fallback for every other failure.
|
|
550
|
+
*/
|
|
551
|
+
function failOperation(
|
|
552
|
+
registry: OperationsRegistry,
|
|
553
|
+
opId: string,
|
|
554
|
+
verb: string,
|
|
555
|
+
err: unknown,
|
|
556
|
+
): void {
|
|
557
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
558
|
+
if (err instanceof MissingDependencyError) {
|
|
559
|
+
registry.update(
|
|
560
|
+
opId,
|
|
561
|
+
{ status: "failed", error: msg, error_detail: err.toWire() },
|
|
562
|
+
`${verb} failed: ${err.binary} not installed`,
|
|
563
|
+
);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
registry.update(opId, { status: "failed", error: msg }, `${verb} failed: ${msg}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
530
569
|
/**
|
|
531
570
|
* Internal install runner. Exported so non-API callers (the first-boot
|
|
532
571
|
* wizard at `/admin/setup`, hub#259) can drive the same install →
|
|
@@ -722,8 +761,7 @@ export async function handleUpgrade(
|
|
|
722
761
|
const spec = specFor(short);
|
|
723
762
|
|
|
724
763
|
void runUpgrade(op.id, short, spec, deps).catch((err) => {
|
|
725
|
-
|
|
726
|
-
registry.update(op.id, { status: "failed", error: msg }, `upgrade failed: ${msg}`);
|
|
764
|
+
failOperation(registry, op.id, "upgrade", err);
|
|
727
765
|
});
|
|
728
766
|
return acceptedOp(op.id);
|
|
729
767
|
}
|
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,6 +6,7 @@
|
|
|
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";
|
|
@@ -472,12 +473,21 @@ async function main(argv: string[]): Promise<number> {
|
|
|
472
473
|
return 1;
|
|
473
474
|
}
|
|
474
475
|
const exposeArgs = flagExtract.rest;
|
|
475
|
-
|
|
476
|
+
let layer = exposeArgs[0];
|
|
476
477
|
const mode = exposeArgs[1];
|
|
477
478
|
if (isHelpFlag(layer)) {
|
|
478
479
|
console.log(exposeHelp());
|
|
479
480
|
return 0;
|
|
480
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
|
+
}
|
|
481
491
|
if (layer !== "tailnet" && layer !== "public") {
|
|
482
492
|
console.error(`parachute expose: unknown layer "${layer ?? ""}"`);
|
|
483
493
|
console.error("usage: parachute expose tailnet [off]");
|
|
@@ -749,26 +759,13 @@ async function main(argv: string[]): Promise<number> {
|
|
|
749
759
|
// after `vault` (including --help) is passed through verbatim.
|
|
750
760
|
if (rest.length === 0) return await dispatchVault(["--help"]);
|
|
751
761
|
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
rest[1] === "create" &&
|
|
760
|
-
isTtyInteractive() &&
|
|
761
|
-
!rest.includes("--scope") &&
|
|
762
|
-
!rest.includes("--read") &&
|
|
763
|
-
!rest.includes("--permission") &&
|
|
764
|
-
!isHelpFlag(rest[2]);
|
|
765
|
-
if (wantsGuidedTokenCreate) {
|
|
766
|
-
const { runVaultTokensCreateInteractive } = await import(
|
|
767
|
-
"./commands/vault-tokens-create-interactive.ts"
|
|
768
|
-
);
|
|
769
|
-
return await runVaultTokensCreateInteractive({ args: rest.slice(2) });
|
|
770
|
-
}
|
|
771
|
-
|
|
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.
|
|
772
769
|
return await dispatchVault(rest);
|
|
773
770
|
}
|
|
774
771
|
|
|
@@ -788,6 +785,14 @@ async function run(argv: string[]): Promise<number> {
|
|
|
788
785
|
try {
|
|
789
786
|
return await main(argv);
|
|
790
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
|
+
}
|
|
791
796
|
if (err instanceof ServicesManifestError) {
|
|
792
797
|
console.error(`services.json is malformed: ${err.message}`);
|
|
793
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
|
|
@@ -61,30 +54,37 @@ export function isCloudflaredLoggedIn(cloudflaredHome: string = DEFAULT_CLOUDFLA
|
|
|
61
54
|
* other → the binary-download path is still the best generic answer
|
|
62
55
|
*
|
|
63
56
|
* The `arch` parameter is the architecture string in `process.arch`
|
|
64
|
-
* shape (`x64`, `arm64`, `arm`).
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
|
67
66
|
*/
|
|
68
67
|
export function cloudflaredInstallHint(
|
|
69
68
|
platform: NodeJS.Platform = process.platform,
|
|
70
69
|
arch: NodeJS.Architecture = process.arch,
|
|
71
70
|
): string {
|
|
71
|
+
const releasesUrl = "https://github.com/cloudflare/cloudflared/releases/latest";
|
|
72
72
|
if (platform === "darwin") {
|
|
73
73
|
return [
|
|
74
74
|
"Install cloudflared:",
|
|
75
75
|
" brew install cloudflared",
|
|
76
76
|
"",
|
|
77
77
|
"(or download a static binary from",
|
|
78
|
-
|
|
78
|
+
` ${releasesUrl})`,
|
|
79
79
|
].join("\n");
|
|
80
80
|
}
|
|
81
81
|
if (platform === "linux") {
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
82
|
+
const downloadUrl = cloudflaredLinuxDownloadUrl(arch);
|
|
83
|
+
if (downloadUrl) {
|
|
84
84
|
return [
|
|
85
85
|
"Install cloudflared (static binary — works across distros):",
|
|
86
|
-
|
|
87
|
-
`
|
|
86
|
+
" curl -L -o /usr/local/bin/cloudflared \\",
|
|
87
|
+
` ${downloadUrl}`,
|
|
88
88
|
" sudo chmod +x /usr/local/bin/cloudflared",
|
|
89
89
|
" cloudflared --version",
|
|
90
90
|
"",
|
|
@@ -93,33 +93,28 @@ export function cloudflaredInstallHint(
|
|
|
93
93
|
}
|
|
94
94
|
return [
|
|
95
95
|
"Install cloudflared from the official binary release:",
|
|
96
|
-
|
|
96
|
+
` ${releasesUrl}`,
|
|
97
97
|
`(pick the linux-* artifact matching your architecture; your arch is "${arch}")`,
|
|
98
98
|
].join("\n");
|
|
99
99
|
}
|
|
100
|
-
return [
|
|
101
|
-
"Install cloudflared from the official binary release:",
|
|
102
|
-
" https://github.com/cloudflare/cloudflared/releases/latest",
|
|
103
|
-
].join("\n");
|
|
100
|
+
return ["Install cloudflared from the official binary release:", ` ${releasesUrl}`].join("\n");
|
|
104
101
|
}
|
|
105
102
|
|
|
106
103
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
111
|
*/
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
case "ia32":
|
|
121
|
-
return "386";
|
|
122
|
-
default:
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
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;
|
|
125
120
|
}
|