@openparachute/hub 0.5.13 → 0.5.14-rc.10
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 +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- 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 +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- 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 +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- 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 +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- 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 +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -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-Dzrbe6EP.js +0 -61
package/src/bun-link.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bun-link detection — shared helper used by both the CLI install path
|
|
3
|
+
* (`commands/install.ts`) and the API/wizard install path (`api-modules-ops.ts`).
|
|
4
|
+
*
|
|
5
|
+
* "Linked" means a global symlink shape under `~/.bun/install/global/node_modules/<pkg>`
|
|
6
|
+
* created by `bun link` (from a local checkout). When the package is already linked,
|
|
7
|
+
* `bun add -g <pkg>` is at best a wasted npm round-trip (~3s) and at worst a hard
|
|
8
|
+
* failure when the global bun.lock has unrelated noise — neither outcome is desirable
|
|
9
|
+
* given the linked checkout already provides the binary on PATH.
|
|
10
|
+
*
|
|
11
|
+
* Both install paths gate the `bun add -g` call on `isLinked(pkg) === false`.
|
|
12
|
+
* Centralizing the detection here keeps the CLI and wizard in lockstep — diverging
|
|
13
|
+
* (as the wizard did pre-hub#433) is the bug class this module exists to prevent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { lstatSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The set of bun global-prefix locations to probe for a `<pkg>` symlink.
|
|
22
|
+
* Honors `BUN_INSTALL` (the canonical override) before falling back to the
|
|
23
|
+
* default `~/.bun` layout. Order matters — env-set prefix wins on a custom
|
|
24
|
+
* bun layout (containers, CI).
|
|
25
|
+
*/
|
|
26
|
+
export function bunGlobalPrefixes(): string[] {
|
|
27
|
+
const prefixes: string[] = [];
|
|
28
|
+
const fromEnv = process.env.BUN_INSTALL;
|
|
29
|
+
if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
|
|
30
|
+
prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
|
|
31
|
+
return prefixes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True iff `<pkg>` resolves to a symlink under any bun global prefix —
|
|
36
|
+
* i.e. the package was installed via `bun link` from a local checkout
|
|
37
|
+
* rather than `bun add -g` from npm. Used to short-circuit `bun add -g`
|
|
38
|
+
* in both the CLI and the wizard install paths.
|
|
39
|
+
*
|
|
40
|
+
* Scoped packages (`@openparachute/vault`) are split on `/` so the probe
|
|
41
|
+
* lands at `<prefix>/@openparachute/vault`. Non-symlink resolutions
|
|
42
|
+
* (real dir from `bun add -g`) return false — we only want to skip the
|
|
43
|
+
* `bun add -g` when the symlink-shape is in place.
|
|
44
|
+
*/
|
|
45
|
+
export function isLinked(pkg: string): boolean {
|
|
46
|
+
for (const prefix of bunGlobalPrefixes()) {
|
|
47
|
+
const path = join(prefix, ...pkg.split("/"));
|
|
48
|
+
try {
|
|
49
|
+
if (lstatSync(path).isSymbolicLink()) return true;
|
|
50
|
+
} catch {
|
|
51
|
+
// Not present at this prefix; try the next.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
package/src/chrome-strip.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* (above that threshold the response is almost certainly not an HTML shell
|
|
14
14
|
* anyway — SPA index.html files are < 16 KB in this ecosystem).
|
|
15
15
|
*
|
|
16
|
-
* Opt-out: hub-side path-prefix deny list. The Notes PWA at `/
|
|
16
|
+
* Opt-out: hub-side path-prefix deny list. The Notes PWA at `/surface/notes/*`
|
|
17
17
|
* is the canonical opt-out — it owns its own chrome (see design-system §7
|
|
18
18
|
* "Where NOT to inject" + AUDIT §4: "Notes is the proof this can work: own
|
|
19
19
|
* application, looks distinctively Notes, reads as Parachute because the
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* Why path-based and not module-declared:
|
|
23
23
|
* - Notes is a `uis[]` sub-unit of parachute-app, not its own module —
|
|
24
24
|
* adding `chrome: "off"` to parachute-app's module.json would suppress
|
|
25
|
-
* chrome on `/
|
|
25
|
+
* chrome on `/surface/admin/*` too (wrong: that surface SHOULD get chrome).
|
|
26
26
|
* - The per-uis well-known fan-out (workstream C/4) is in flight but the
|
|
27
27
|
* hub side doesn't yet thread per-uis metadata into proxy dispatch.
|
|
28
28
|
* - HTML meta-tag peeking adds parsing overhead on every response.
|
|
@@ -46,10 +46,10 @@ import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
|
|
|
46
46
|
* prefix" or "pathname startsWith prefix" — the same shape as
|
|
47
47
|
* `findServiceUpstream`'s mount comparison.
|
|
48
48
|
*
|
|
49
|
-
* `/
|
|
49
|
+
* `/surface/notes/` covers the Notes PWA bundled by parachute-app. Notes is a
|
|
50
50
|
* destination, not chrome; it owns its own header (see design-system.md §7).
|
|
51
51
|
*/
|
|
52
|
-
export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/
|
|
52
|
+
export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/surface/notes/"];
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* Buffer size cap. Responses larger than this are passed through unchanged.
|
|
@@ -208,8 +208,8 @@ function renderSignedOutCluster(nextPath: string): string {
|
|
|
208
208
|
* when any opt-out prefix matches (`pathname === prefix` or
|
|
209
209
|
* `pathname startsWith prefix`).
|
|
210
210
|
*
|
|
211
|
-
* Match shape mirrors `findServiceUpstream` so an opt-out for `"/
|
|
212
|
-
* suppresses chrome for `/
|
|
211
|
+
* Match shape mirrors `findServiceUpstream` so an opt-out for `"/surface/notes/"`
|
|
212
|
+
* suppresses chrome for `/surface/notes`, `/surface/notes/`, and every sub-path.
|
|
213
213
|
*/
|
|
214
214
|
export function shouldInjectChrome(
|
|
215
215
|
pathname: string,
|
package/src/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import pkg from "../package.json" with { type: "json" };
|
|
|
10
10
|
import { CloudflaredStateError } from "./cloudflare/state.ts";
|
|
11
11
|
import { auth } from "./commands/auth.ts";
|
|
12
12
|
import { exposePublic, exposeTailnet } from "./commands/expose.ts";
|
|
13
|
+
import { init } from "./commands/init.ts";
|
|
13
14
|
import { install } from "./commands/install.ts";
|
|
14
15
|
import { logs, restart, start, stop } from "./commands/lifecycle.ts";
|
|
15
16
|
import { migrate } from "./commands/migrate.ts";
|
|
@@ -18,15 +19,18 @@ import { setup } from "./commands/setup.ts";
|
|
|
18
19
|
import { status } from "./commands/status.ts";
|
|
19
20
|
import { upgrade } from "./commands/upgrade.ts";
|
|
20
21
|
import { dispatchVault } from "./commands/vault.ts";
|
|
22
|
+
import { runSetupWizardCommand } from "./commands/wizard.ts";
|
|
21
23
|
import { ExposeStateError } from "./expose-state.ts";
|
|
22
24
|
import {
|
|
23
25
|
exposeHelp,
|
|
26
|
+
initHelp,
|
|
24
27
|
installHelp,
|
|
25
28
|
logsHelp,
|
|
26
29
|
migrateHelp,
|
|
27
30
|
restartHelp,
|
|
28
31
|
serveHelp,
|
|
29
32
|
setupHelp,
|
|
33
|
+
setupWizardHelp,
|
|
30
34
|
startHelp,
|
|
31
35
|
statusHelp,
|
|
32
36
|
stopHelp,
|
|
@@ -305,6 +309,76 @@ async function main(argv: string[]): Promise<number> {
|
|
|
305
309
|
return await setup(setupOpts);
|
|
306
310
|
}
|
|
307
311
|
|
|
312
|
+
case "setup-wizard": {
|
|
313
|
+
// hub#168 Cut 3 — the in-terminal mirror of /admin/setup. Distinct
|
|
314
|
+
// from `parachute setup` (which is the multi-pick install
|
|
315
|
+
// walk-through, not a wizard-handler frontend). Both surfaces stay
|
|
316
|
+
// — `parachute setup` is the historical "install + configure
|
|
317
|
+
// services" entry; `parachute setup-wizard` drives the same
|
|
318
|
+
// handlers the browser wizard uses.
|
|
319
|
+
if (isHelpFlag(rest[0])) {
|
|
320
|
+
console.log(setupWizardHelp());
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
return await runSetupWizardCommand(rest);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
case "init": {
|
|
327
|
+
if (isHelpFlag(rest[0])) {
|
|
328
|
+
console.log(initHelp());
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
const exposeExtract = extractNamedFlag(rest, "--expose");
|
|
332
|
+
if (exposeExtract.error) {
|
|
333
|
+
console.error(`parachute init: ${exposeExtract.error}`);
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
if (
|
|
337
|
+
exposeExtract.value !== undefined &&
|
|
338
|
+
exposeExtract.value !== "none" &&
|
|
339
|
+
exposeExtract.value !== "tailnet" &&
|
|
340
|
+
exposeExtract.value !== "cloudflare"
|
|
341
|
+
) {
|
|
342
|
+
console.error(
|
|
343
|
+
`parachute init: --expose must be one of none|tailnet|cloudflare (got "${exposeExtract.value}")`,
|
|
344
|
+
);
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
const noBrowser = exposeExtract.rest.includes("--no-browser");
|
|
348
|
+
const noExposePrompt = exposeExtract.rest.includes("--no-expose-prompt");
|
|
349
|
+
const cliWizard = exposeExtract.rest.includes("--cli-wizard");
|
|
350
|
+
const browserWizard = exposeExtract.rest.includes("--browser-wizard");
|
|
351
|
+
const known = new Set([
|
|
352
|
+
"--no-browser",
|
|
353
|
+
"--no-expose-prompt",
|
|
354
|
+
"--cli-wizard",
|
|
355
|
+
"--browser-wizard",
|
|
356
|
+
]);
|
|
357
|
+
const unknown = exposeExtract.rest.find((a) => !known.has(a));
|
|
358
|
+
if (unknown !== undefined) {
|
|
359
|
+
console.error(`parachute init: unknown argument "${unknown}"`);
|
|
360
|
+
console.error(
|
|
361
|
+
"usage: parachute init [--no-browser] [--no-expose-prompt]\n" +
|
|
362
|
+
" [--expose none|tailnet|cloudflare]\n" +
|
|
363
|
+
" [--cli-wizard | --browser-wizard]",
|
|
364
|
+
);
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
if (cliWizard && browserWizard) {
|
|
368
|
+
console.error("parachute init: --cli-wizard and --browser-wizard are mutually exclusive.");
|
|
369
|
+
return 1;
|
|
370
|
+
}
|
|
371
|
+
const initOpts: Parameters<typeof init>[0] = {};
|
|
372
|
+
if (noBrowser) initOpts.noBrowser = true;
|
|
373
|
+
if (noExposePrompt) initOpts.noExposePrompt = true;
|
|
374
|
+
if (exposeExtract.value) {
|
|
375
|
+
initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
|
|
376
|
+
}
|
|
377
|
+
if (cliWizard) initOpts.wizardChoice = "cli";
|
|
378
|
+
else if (browserWizard) initOpts.wizardChoice = "browser";
|
|
379
|
+
return await init(initOpts);
|
|
380
|
+
}
|
|
381
|
+
|
|
308
382
|
case "install": {
|
|
309
383
|
if (isHelpFlag(rest[0])) {
|
|
310
384
|
console.log(installHelp());
|
|
@@ -627,14 +701,17 @@ async function main(argv: string[]): Promise<number> {
|
|
|
627
701
|
return 0;
|
|
628
702
|
}
|
|
629
703
|
const dryRun = rest.includes("--dry-run");
|
|
704
|
+
const list = rest.includes("--list");
|
|
630
705
|
const yes = rest.includes("--yes") || rest.includes("-y");
|
|
631
|
-
const unknown = rest.find(
|
|
706
|
+
const unknown = rest.find(
|
|
707
|
+
(a) => a !== "--dry-run" && a !== "--list" && a !== "--yes" && a !== "-y",
|
|
708
|
+
);
|
|
632
709
|
if (unknown !== undefined) {
|
|
633
710
|
console.error(`parachute migrate: unknown argument "${unknown}"`);
|
|
634
|
-
console.error("usage: parachute migrate [--dry-run] [--yes]");
|
|
711
|
+
console.error("usage: parachute migrate [--list] [--dry-run] [--yes]");
|
|
635
712
|
return 1;
|
|
636
713
|
}
|
|
637
|
-
return await migrate({ dryRun, yes });
|
|
714
|
+
return await migrate({ dryRun, list, yes });
|
|
638
715
|
}
|
|
639
716
|
|
|
640
717
|
case "serve": {
|
|
@@ -672,32 +749,24 @@ async function main(argv: string[]): Promise<number> {
|
|
|
672
749
|
// after `vault` (including --help) is passed through verbatim.
|
|
673
750
|
if (rest.length === 0) return await dispatchVault(["--help"]);
|
|
674
751
|
|
|
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
|
-
|
|
752
|
+
// Everything under `vault` forwards transparently to `parachute-vault`.
|
|
753
|
+
// `vault tokens create` used to route through a guided interactive
|
|
754
|
+
// wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
|
|
755
|
+
// subcommand — it now exits 1 with migration guidance. Access tokens are
|
|
756
|
+
// hub-issued JWTs; mint them with `parachute auth mint-token` or the
|
|
757
|
+
// admin SPA Connect card. We forward verbatim so the operator sees
|
|
758
|
+
// vault's own migration error rather than a hub-side stub.
|
|
695
759
|
return await dispatchVault(rest);
|
|
696
760
|
}
|
|
697
761
|
|
|
698
762
|
default:
|
|
699
763
|
console.error(`parachute: unknown command "${command}"`);
|
|
700
|
-
console.error("
|
|
764
|
+
console.error("");
|
|
765
|
+
console.error("If this is a fresh install, start here:");
|
|
766
|
+
console.error(" parachute init # get the admin wizard going");
|
|
767
|
+
console.error("");
|
|
768
|
+
console.error("Or see all commands:");
|
|
769
|
+
console.error(" parachute --help");
|
|
701
770
|
return 1;
|
|
702
771
|
}
|
|
703
772
|
}
|
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
|
@@ -45,14 +45,81 @@ export function isCloudflaredLoggedIn(cloudflaredHome: string = DEFAULT_CLOUDFLA
|
|
|
45
45
|
return existsSync(join(cloudflaredHome, "cert.pem"));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Cloudflare's "Downloads" page (developers.cloudflare.com/cloudflare-one/
|
|
50
|
+
* connections/connect-networks/downloads/) churns markdown anchors; pkg.cloudflare.com
|
|
51
|
+
* paths the older instructions referenced now serve HTML / 404. Aaron hit
|
|
52
|
+
* the failure mode on a fresh Amazon Linux 2023 EC2 install (2026-05-27):
|
|
53
|
+
* `sudo dnf install cloudflared` returned 'No match for argument:
|
|
54
|
+
* cloudflared'. The reliable cross-distro path is grabbing the static
|
|
55
|
+
* binary from Cloudflare's GitHub releases.
|
|
56
|
+
*
|
|
57
|
+
* Canonical install paths:
|
|
58
|
+
*
|
|
59
|
+
* macOS → `brew install cloudflared` (homebrew is the documented path)
|
|
60
|
+
* Linux → architecture-specific binary from GitHub releases
|
|
61
|
+
* other → the binary-download path is still the best generic answer
|
|
62
|
+
*
|
|
63
|
+
* The `arch` parameter is the architecture string in `process.arch`
|
|
64
|
+
* shape (`x64`, `arm64`, `arm`). Mapped to the suffix cloudflared uses
|
|
65
|
+
* in its release artifacts (`amd64`, `arm64`, `arm`). Unknown arches
|
|
66
|
+
* fall through to a generic pointer at the releases page.
|
|
67
|
+
*/
|
|
68
|
+
export function cloudflaredInstallHint(
|
|
69
|
+
platform: NodeJS.Platform = process.platform,
|
|
70
|
+
arch: NodeJS.Architecture = process.arch,
|
|
71
|
+
): string {
|
|
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
|
+
" https://github.com/cloudflare/cloudflared/releases/latest)",
|
|
79
|
+
].join("\n");
|
|
53
80
|
}
|
|
54
81
|
if (platform === "linux") {
|
|
55
|
-
|
|
82
|
+
const suffix = linuxArtifactSuffix(arch);
|
|
83
|
+
if (suffix) {
|
|
84
|
+
return [
|
|
85
|
+
"Install cloudflared (static binary — works across distros):",
|
|
86
|
+
` curl -L -o /usr/local/bin/cloudflared \\`,
|
|
87
|
+
` https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${suffix}`,
|
|
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
|
+
" https://github.com/cloudflare/cloudflared/releases/latest",
|
|
97
|
+
`(pick the linux-* artifact matching your architecture; your arch is "${arch}")`,
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
return [
|
|
101
|
+
"Install cloudflared from the official binary release:",
|
|
102
|
+
" https://github.com/cloudflare/cloudflared/releases/latest",
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Map a Node `process.arch` to the suffix Cloudflare uses for its
|
|
108
|
+
* cloudflared-linux-* release artifacts. Returns undefined for arches
|
|
109
|
+
* that don't have a published artifact (we surface a generic pointer
|
|
110
|
+
* in that case instead of fabricating a download URL that 404s).
|
|
111
|
+
*/
|
|
112
|
+
function linuxArtifactSuffix(arch: NodeJS.Architecture): string | undefined {
|
|
113
|
+
switch (arch) {
|
|
114
|
+
case "x64":
|
|
115
|
+
return "amd64";
|
|
116
|
+
case "arm64":
|
|
117
|
+
return "arm64";
|
|
118
|
+
case "arm":
|
|
119
|
+
return "arm";
|
|
120
|
+
case "ia32":
|
|
121
|
+
return "386";
|
|
122
|
+
default:
|
|
123
|
+
return undefined;
|
|
56
124
|
}
|
|
57
|
-
return `Install cloudflared: ${url}`;
|
|
58
125
|
}
|
|
@@ -2,20 +2,28 @@
|
|
|
2
2
|
* Post-exposure auth nudge. Runs after `parachute expose public` successfully
|
|
3
3
|
* brings a tunnel up (TTY only). The tunnel is already live; this is purely
|
|
4
4
|
* advisory — we never error the exposure flow regardless of what the user
|
|
5
|
-
* chooses. The goal is to catch the "fresh vault, just went public, no
|
|
6
|
-
*
|
|
5
|
+
* chooses. The goal is to catch the "fresh vault, just went public, no auth
|
|
6
|
+
* configured" trap before someone else finds it first.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* The load-bearing signal is the **owner password**. Post-pvt_*-DROP (vault
|
|
9
|
+
* #412 / hub#466), the vault `tokens` table holds only vestigial pvt_* rows;
|
|
10
|
+
* a non-zero count no longer means "API auth is configured." Access is now
|
|
11
|
+
* hub-issued JWTs, minted against the operator's identity — and minting that
|
|
12
|
+
* identity requires the owner password (browser OAuth) or the operator token
|
|
13
|
+
* that `set-password` seeds. So "has an owner password" is the single gate
|
|
14
|
+
* that tells us whether *any* authenticated access is reachable. We branch
|
|
15
|
+
* purely on password + 2FA; we no longer count vault-DB rows for the auth
|
|
16
|
+
* decision.
|
|
9
17
|
*
|
|
10
|
-
*
|
|
18
|
+
* Three states we branch on, based on {@link VaultAuthStatus}:
|
|
19
|
+
*
|
|
20
|
+
* - no owner password: loud warning — the exposure is wide open. Offer to
|
|
21
|
+
* set a password (+ 2FA), and point at the hub-JWT mint path for clients.
|
|
11
22
|
* - password, no 2FA: shorter "recommend 2FA" nudge.
|
|
12
|
-
* -
|
|
13
|
-
* - `tokenCount === null`: couldn't read the DB; advisory only, no prompts
|
|
14
|
-
* that depend on token state.
|
|
15
|
-
* - all set: one-line "looks good" (the quiet path).
|
|
23
|
+
* - password + 2FA: one-line "looks good" (the quiet path).
|
|
16
24
|
*
|
|
17
25
|
* Defaults are always "skip" — Enter declines every prompt. User can always
|
|
18
|
-
* run `parachute auth
|
|
26
|
+
* run `parachute auth set-password` / `parachute auth mint-token …` later.
|
|
19
27
|
*/
|
|
20
28
|
|
|
21
29
|
import { createInterface } from "node:readline/promises";
|
|
@@ -106,10 +114,25 @@ async function offerTotp(r: Resolved): Promise<void> {
|
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Programmatic / headless clients don't use a password — they carry a
|
|
119
|
+
* hub-issued JWT. We don't auto-mint one here (it needs a scope, and the
|
|
120
|
+
* operator should choose read vs write per client), so this is guidance,
|
|
121
|
+
* not a prompt. Mint paths, in order of how most operators reach them:
|
|
122
|
+
*
|
|
123
|
+
* - Admin SPA → Vaults → "Connect" card (mints + shows the header command).
|
|
124
|
+
* - `parachute auth mint-token --scope vault:<name>:<verb>` (pipeable JWT).
|
|
125
|
+
*
|
|
126
|
+
* The old affordance ran `parachute vault tokens create`, which exits 1
|
|
127
|
+
* post-DROP (vault no longer mints pvt_* tokens) — we never offer it.
|
|
128
|
+
*/
|
|
129
|
+
function printTokenGuidance(r: Resolved): void {
|
|
130
|
+
const name = r.status.vaultNames[0] ?? "<name>";
|
|
131
|
+
r.log("");
|
|
132
|
+
r.log("For programmatic / headless clients (scripts, CI), mint a hub token:");
|
|
133
|
+
r.log(" • Admin → Vaults → Connect (mints a scope-narrow token + copy-paste header)");
|
|
134
|
+
r.log(` • parachute auth mint-token --scope vault:${name}:read # or :write`);
|
|
135
|
+
r.log(" → attach the printed JWT as Authorization: Bearer <hub-jwt>");
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
function printDivider(r: Resolved): void {
|
|
@@ -118,24 +141,27 @@ function printDivider(r: Resolved): void {
|
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
/**
|
|
121
|
-
* `
|
|
122
|
-
*
|
|
144
|
+
* `no owner password`: the exposure is wide open — without a password,
|
|
145
|
+
* nobody can sign in and no hub JWT can be minted, so there's no auth gate
|
|
146
|
+
* at all. The loudest warning we draw.
|
|
123
147
|
*/
|
|
124
148
|
async function handleWideOpen(r: Resolved): Promise<void> {
|
|
125
149
|
printDivider(r);
|
|
126
|
-
r.log("⚠ No owner password
|
|
150
|
+
r.log("⚠ No owner password is configured.");
|
|
127
151
|
r.log(" The tunnel is reachable from the public internet RIGHT NOW.");
|
|
128
152
|
r.log(" Anyone with the URL can make requests until you set auth up.");
|
|
129
153
|
r.log("");
|
|
130
|
-
r.log("Recommended: set an owner password
|
|
131
|
-
r.log("and
|
|
154
|
+
r.log("Recommended: set an owner password — it's the gate for both browser");
|
|
155
|
+
r.log("sign-in (OAuth) and minting hub tokens for programmatic clients.");
|
|
132
156
|
r.log("");
|
|
133
157
|
await offerOwnerPassword(r);
|
|
134
158
|
// Offer 2FA regardless of the password step outcome: we can't observe it
|
|
135
159
|
// from outside the subprocess, and vault itself will reject a 2fa enroll
|
|
136
160
|
// if there's no password yet, surfacing the real error to the user.
|
|
137
161
|
await offerTotp(r);
|
|
138
|
-
|
|
162
|
+
// Programmatic-client guidance is informational (no auto-mint) — print it
|
|
163
|
+
// so the operator knows the headless path exists, not the dead pvt_* one.
|
|
164
|
+
printTokenGuidance(r);
|
|
139
165
|
printDivider(r);
|
|
140
166
|
}
|
|
141
167
|
|
|
@@ -151,52 +177,24 @@ async function handlePasswordNoTotp(r: Resolved): Promise<void> {
|
|
|
151
177
|
}
|
|
152
178
|
|
|
153
179
|
/**
|
|
154
|
-
* `
|
|
155
|
-
*
|
|
156
|
-
* the water. Offer to fix.
|
|
157
|
-
*/
|
|
158
|
-
async function handleTokensNoPassword(r: Resolved): Promise<void> {
|
|
159
|
-
r.log("");
|
|
160
|
-
r.log("ℹ API tokens exist, but no owner password is set.");
|
|
161
|
-
r.log(" Browser sign-in (OAuth) won't work until you add one.");
|
|
162
|
-
await offerOwnerPassword(r);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* `tokenCount === null`: SQLite probe failed (DB missing, locked, schema
|
|
167
|
-
* drift, whatever). Don't guess; don't prompt on token state. Nudge 2FA
|
|
168
|
-
* if we know the password is set, otherwise stay quiet.
|
|
169
|
-
*/
|
|
170
|
-
async function handleUnknownTokens(r: Resolved): Promise<void> {
|
|
171
|
-
r.log("");
|
|
172
|
-
r.log("ℹ Couldn't read vault token state (vault may be locked or offline).");
|
|
173
|
-
r.log(" Run `parachute vault tokens list` to check token config yourself.");
|
|
174
|
-
if (r.status.hasOwnerPassword && !r.status.hasTotp) {
|
|
175
|
-
r.log("");
|
|
176
|
-
r.log(" (While you're here: owner password is set, 2FA is not.)");
|
|
177
|
-
await offerTotp(r);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* `all set`: password + 2FA + at least one token. Keep it tight.
|
|
180
|
+
* `all set`: owner password + 2FA. Keep it tight. (We don't assert on
|
|
181
|
+
* tokens — a hub JWT is minted on demand, not a standing prerequisite.)
|
|
183
182
|
*/
|
|
184
183
|
function handleAllGood(r: Resolved): void {
|
|
185
184
|
r.log("");
|
|
186
|
-
r.log("✓ Auth config looks good (password + 2FA
|
|
185
|
+
r.log("✓ Auth config looks good (owner password + 2FA).");
|
|
187
186
|
}
|
|
188
187
|
|
|
189
188
|
/**
|
|
190
189
|
* Pick the branch. Pure function of the status — keeps test coverage trivial.
|
|
190
|
+
*
|
|
191
|
+
* Owner-password-centric since the pvt_* DROP (hub#466): `tokenCount` is no
|
|
192
|
+
* longer consulted — those rows are vestigial and minting access now flows
|
|
193
|
+
* through the owner password, not a standing vault token. Three states.
|
|
191
194
|
*/
|
|
192
|
-
function classify(
|
|
193
|
-
s
|
|
194
|
-
)
|
|
195
|
-
if (s.tokenCount === null) return "unknown-tokens";
|
|
196
|
-
const hasTokens = s.tokenCount > 0;
|
|
197
|
-
if (!s.hasOwnerPassword && !hasTokens) return "wide-open";
|
|
198
|
-
if (!s.hasOwnerPassword && hasTokens) return "tokens-no-password";
|
|
199
|
-
if (s.hasOwnerPassword && !s.hasTotp) return "password-no-totp";
|
|
195
|
+
function classify(s: VaultAuthStatus): "wide-open" | "password-no-totp" | "all-good" {
|
|
196
|
+
if (!s.hasOwnerPassword) return "wide-open";
|
|
197
|
+
if (!s.hasTotp) return "password-no-totp";
|
|
200
198
|
return "all-good";
|
|
201
199
|
}
|
|
202
200
|
|
|
@@ -209,12 +207,6 @@ export async function runAuthPreflight(opts: AuthPreflightOpts = {}): Promise<vo
|
|
|
209
207
|
case "password-no-totp":
|
|
210
208
|
await handlePasswordNoTotp(r);
|
|
211
209
|
return;
|
|
212
|
-
case "tokens-no-password":
|
|
213
|
-
await handleTokensNoPassword(r);
|
|
214
|
-
return;
|
|
215
|
-
case "unknown-tokens":
|
|
216
|
-
await handleUnknownTokens(r);
|
|
217
|
-
return;
|
|
218
210
|
case "all-good":
|
|
219
211
|
handleAllGood(r);
|
|
220
212
|
return;
|