@openparachute/hub 0.5.14-rc.8 → 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 +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-ops.test.ts +45 -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__/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 +77 -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-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-revoke-token.ts +107 -21
- 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-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- 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/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
|
}
|
package/src/commands/auth.ts
CHANGED
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
* - `list-users` — show accounts in `users`.
|
|
14
14
|
*
|
|
15
15
|
* Vault-forwarded subcommands (still implemented in `parachute-vault`):
|
|
16
|
-
* - `2fa`
|
|
16
|
+
* - (none currently). `2fa` used to forward here, but the legacy
|
|
17
|
+
* `parachute-vault 2fa` stub writes vault YAML that does NOT gate hub
|
|
18
|
+
* `/login`. As of hub#473 `parachute auth 2fa` is the REAL hub-login TOTP
|
|
19
|
+
* surface: it reads/writes the hub.db `users` TOTP columns and gates
|
|
20
|
+
* `/login`. Subcommands: `status`, `enroll` (CLI text enroll — prints the
|
|
21
|
+
* otpauth:// URI + base32 secret for manual authenticator entry, then
|
|
22
|
+
* prompts for the confirm code), `disenroll`. The browser path lives at
|
|
23
|
+
* `<hub-origin>/account/2fa` (QR + backup codes).
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
import { join } from "node:path";
|
|
@@ -44,6 +51,13 @@ import {
|
|
|
44
51
|
} from "../operator-token.ts";
|
|
45
52
|
import { isNonRequestableScope } from "../scope-explanations.ts";
|
|
46
53
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
54
|
+
import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "../totp.ts";
|
|
55
|
+
import {
|
|
56
|
+
clearEnrollment,
|
|
57
|
+
getTotpState,
|
|
58
|
+
isTotpEnrolled,
|
|
59
|
+
persistEnrollment,
|
|
60
|
+
} from "../two-factor-store.ts";
|
|
47
61
|
import {
|
|
48
62
|
SingleUserModeError,
|
|
49
63
|
UsernameTakenError,
|
|
@@ -71,9 +85,10 @@ export const defaultRunner: Runner = {
|
|
|
71
85
|
},
|
|
72
86
|
};
|
|
73
87
|
|
|
74
|
-
const VAULT_FORWARDED_SUBCOMMANDS = new Set([
|
|
88
|
+
const VAULT_FORWARDED_SUBCOMMANDS = new Set<string>([]);
|
|
75
89
|
const HUB_LOCAL_SUBCOMMANDS = new Set([
|
|
76
90
|
"rotate-key",
|
|
91
|
+
"2fa",
|
|
77
92
|
"set-password",
|
|
78
93
|
"list-users",
|
|
79
94
|
"rotate-operator",
|
|
@@ -92,10 +107,14 @@ Usage:
|
|
|
92
107
|
parachute auth set-password [--username <name>] [--password <pw>] [--allow-multi]
|
|
93
108
|
Create or update the hub user's password
|
|
94
109
|
parachute auth list-users Show registered hub accounts
|
|
95
|
-
parachute auth 2fa status
|
|
96
|
-
parachute auth 2fa enroll
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
parachute auth 2fa [status] Show hub-login 2FA (TOTP) status
|
|
111
|
+
parachute auth 2fa enroll [--username <name>]
|
|
112
|
+
Enroll TOTP for hub login (prints the
|
|
113
|
+
otpauth:// URI + base32 secret for manual
|
|
114
|
+
authenticator entry, then prompts for a
|
|
115
|
+
confirm code; prints backup codes once)
|
|
116
|
+
parachute auth 2fa disenroll [--username <name>]
|
|
117
|
+
Turn off hub-login 2FA for the account
|
|
99
118
|
parachute auth rotate-key Rotate the hub's JWT signing key
|
|
100
119
|
parachute auth rotate-operator [--scope-set <set>]
|
|
101
120
|
Mint a fresh ~/.parachute/operator.token
|
|
@@ -128,10 +147,20 @@ The default username on first run is "owner" — override with --username.
|
|
|
128
147
|
Single-user mode is the default; pass --allow-multi to add additional
|
|
129
148
|
accounts beyond the first.
|
|
130
149
|
|
|
131
|
-
2fa
|
|
132
|
-
|
|
150
|
+
2fa is real hub-login TOTP (hub#473). It reads/writes the hub.db \`users\` TOTP
|
|
151
|
+
columns and gates \`/login\`: once enrolled, signing in requires a 6-digit code
|
|
152
|
+
from your authenticator app (or a single-use backup code) after your password.
|
|
153
|
+
|
|
154
|
+
\`parachute auth 2fa enroll\` is headless-friendly — it prints the \`otpauth://\`
|
|
155
|
+
URI and the base32 secret as text so you can type them into an authenticator
|
|
156
|
+
manually (no QR scan needed on a server), then prompts for the current 6-digit
|
|
157
|
+
code to confirm. On success it prints 10 single-use backup codes ONCE — save
|
|
158
|
+
them. The browser path (\`<hub-origin>/account/2fa\`) shows a scannable QR code.
|
|
133
159
|
|
|
134
|
-
|
|
160
|
+
\`parachute auth 2fa disenroll\` clears the TOTP secret + backup codes.
|
|
161
|
+
|
|
162
|
+
In single-user mode both default to the only hub account; pass --username to
|
|
163
|
+
target a specific account when more than one exists.
|
|
135
164
|
|
|
136
165
|
rotate-key generates a fresh RSA-2048 keypair and retires the previous
|
|
137
166
|
one. The retired key keeps appearing in /.well-known/jwks.json for 24
|
|
@@ -1124,6 +1153,115 @@ async function runRevokeToken(args: readonly string[], deps: AuthDeps): Promise<
|
|
|
1124
1153
|
}
|
|
1125
1154
|
}
|
|
1126
1155
|
|
|
1156
|
+
/**
|
|
1157
|
+
* Real hub-login TOTP 2FA CLI (hub#473). `parachute auth 2fa [status|enroll|
|
|
1158
|
+
* disenroll]`. Reads/writes the hub.db `users` TOTP columns — the same store
|
|
1159
|
+
* the `/login` flow and `/account/2fa` browser surface use, so a CLI enroll
|
|
1160
|
+
* gates the exposed hub login exactly like the browser one.
|
|
1161
|
+
*
|
|
1162
|
+
* Headless-first by design: `enroll` prints the `otpauth://` URI + base32
|
|
1163
|
+
* secret as text so the operator can type them into an authenticator app
|
|
1164
|
+
* manually (no QR scan on a server), then prompts for the current code to
|
|
1165
|
+
* confirm before persisting. Browser enroll (QR) lives at
|
|
1166
|
+
* `<hub-origin>/account/2fa`.
|
|
1167
|
+
*/
|
|
1168
|
+
async function run2fa(args: readonly string[], deps: AuthDeps): Promise<number> {
|
|
1169
|
+
const flag = extractUsernameFlag(args);
|
|
1170
|
+
if (flag.error) {
|
|
1171
|
+
console.error(`parachute auth 2fa: ${flag.error}`);
|
|
1172
|
+
return 1;
|
|
1173
|
+
}
|
|
1174
|
+
const sub = flag.rest[0] ?? "status";
|
|
1175
|
+
if (flag.rest.length > 1) {
|
|
1176
|
+
console.error(`parachute auth 2fa: unexpected argument "${flag.rest[1]}"`);
|
|
1177
|
+
console.error("usage: parachute auth 2fa [status|enroll|disenroll] [--username <name>]");
|
|
1178
|
+
return 1;
|
|
1179
|
+
}
|
|
1180
|
+
if (sub !== "status" && sub !== "enroll" && sub !== "disenroll") {
|
|
1181
|
+
console.error(`parachute auth 2fa: unknown subcommand "${sub}"`);
|
|
1182
|
+
console.error("usage: parachute auth 2fa [status|enroll|disenroll] [--username <name>]");
|
|
1183
|
+
return 1;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
1187
|
+
try {
|
|
1188
|
+
const target = resolveTargetUser(db, flag.username, sub);
|
|
1189
|
+
if ("error" in target) {
|
|
1190
|
+
console.error(`parachute auth 2fa: ${target.error}`);
|
|
1191
|
+
return 1;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (sub === "status") {
|
|
1195
|
+
const state = getTotpState(db, target.id);
|
|
1196
|
+
if (state.secret) {
|
|
1197
|
+
console.log(`Two-factor authentication: ON for "${target.username}".`);
|
|
1198
|
+
if (state.enrolledAt) console.log(` enrolled_at: ${state.enrolledAt}`);
|
|
1199
|
+
console.log(` backup_codes: ${state.backupCodes.length} remaining`);
|
|
1200
|
+
} else {
|
|
1201
|
+
console.log(`Two-factor authentication: OFF for "${target.username}".`);
|
|
1202
|
+
console.log("Run `parachute auth 2fa enroll` to turn it on.");
|
|
1203
|
+
}
|
|
1204
|
+
return 0;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (sub === "disenroll") {
|
|
1208
|
+
if (!isTotpEnrolled(db, target.id)) {
|
|
1209
|
+
console.log(`Two-factor authentication is already off for "${target.username}".`);
|
|
1210
|
+
return 0;
|
|
1211
|
+
}
|
|
1212
|
+
clearEnrollment(db, target.id);
|
|
1213
|
+
console.log(`Turned off two-factor authentication for "${target.username}".`);
|
|
1214
|
+
return 0;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// sub === "enroll"
|
|
1218
|
+
if (isTotpEnrolled(db, target.id)) {
|
|
1219
|
+
console.error(
|
|
1220
|
+
`parachute auth 2fa: two-factor is already enabled for "${target.username}". Run \`parachute auth 2fa disenroll\` first to re-enroll.`,
|
|
1221
|
+
);
|
|
1222
|
+
return 1;
|
|
1223
|
+
}
|
|
1224
|
+
const isInteractive = (deps.isInteractive ?? defaultIsInteractive)();
|
|
1225
|
+
if (!isInteractive) {
|
|
1226
|
+
console.error(
|
|
1227
|
+
"parachute auth 2fa enroll: a TTY is required to confirm the enrollment code (run it interactively)",
|
|
1228
|
+
);
|
|
1229
|
+
return 1;
|
|
1230
|
+
}
|
|
1231
|
+
const readLine = deps.readLine ?? defaultReadLine;
|
|
1232
|
+
|
|
1233
|
+
const { secret } = generateTotpSecret(target.username);
|
|
1234
|
+
const otpauthUrl = otpauthUrlFor(secret, target.username);
|
|
1235
|
+
console.log(`Enrolling two-factor authentication for "${target.username}".`);
|
|
1236
|
+
console.log("");
|
|
1237
|
+
console.log("Add this account to your authenticator app. Either scan the otpauth URL");
|
|
1238
|
+
console.log("(paste it into a QR generator), or enter the secret key manually:");
|
|
1239
|
+
console.log("");
|
|
1240
|
+
console.log(` otpauth URL: ${otpauthUrl}`);
|
|
1241
|
+
console.log(` secret key: ${secret}`);
|
|
1242
|
+
console.log("");
|
|
1243
|
+
const code = (await readLine("Enter the 6-digit code from your app to confirm: ")).trim();
|
|
1244
|
+
if (!verifyTotpCode(secret, code)) {
|
|
1245
|
+
console.error(
|
|
1246
|
+
"parachute auth 2fa enroll: that code didn't match — nothing was saved. Check your device clock and try again.",
|
|
1247
|
+
);
|
|
1248
|
+
return 1;
|
|
1249
|
+
}
|
|
1250
|
+
const result = await persistEnrollment(db, target.id, secret);
|
|
1251
|
+
console.log("");
|
|
1252
|
+
console.log("✓ Two-factor authentication is now ON for hub login.");
|
|
1253
|
+
console.log("");
|
|
1254
|
+
console.log("Save these backup codes — each works once if you lose your authenticator:");
|
|
1255
|
+
console.log("");
|
|
1256
|
+
for (const c of result.backupCodes) console.log(` ${c}`);
|
|
1257
|
+
console.log("");
|
|
1258
|
+
console.log("They are shown only once. Store them somewhere safe.");
|
|
1259
|
+
return 0;
|
|
1260
|
+
} finally {
|
|
1261
|
+
db.close();
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1127
1265
|
function runListUsers(deps: AuthDeps): number {
|
|
1128
1266
|
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
1129
1267
|
try {
|
|
@@ -1182,6 +1320,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
1182
1320
|
return 1;
|
|
1183
1321
|
}
|
|
1184
1322
|
}
|
|
1323
|
+
if (sub === "2fa") {
|
|
1324
|
+
try {
|
|
1325
|
+
return await run2fa(args.slice(1), normalized);
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1328
|
+
console.error(`parachute auth 2fa: ${msg}`);
|
|
1329
|
+
return 1;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1185
1332
|
if (sub === "list-users") {
|
|
1186
1333
|
return runListUsers(normalized);
|
|
1187
1334
|
}
|
|
@@ -1250,23 +1397,17 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
1250
1397
|
}
|
|
1251
1398
|
}
|
|
1252
1399
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1400
|
+
// No subcommands forward to parachute-vault anymore (VAULT_FORWARDED_SUBCOMMANDS
|
|
1401
|
+
// is empty — `2fa` is now hub-local + honest, see #473). Anything that fell
|
|
1402
|
+
// through every hub-local handler above is unknown.
|
|
1403
|
+
if (VAULT_FORWARDED_SUBCOMMANDS.has(sub)) {
|
|
1404
|
+
// Defensive: if a future subcommand is added back to the forward set, route
|
|
1405
|
+
// it. Currently unreachable (the set is empty).
|
|
1259
1406
|
return await runner.run(["parachute-vault", ...args]);
|
|
1260
|
-
} catch (err) {
|
|
1261
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1262
|
-
if (msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found")) {
|
|
1263
|
-
console.error("parachute-vault not found on PATH.");
|
|
1264
|
-
console.error("Install it with: parachute install vault");
|
|
1265
|
-
return 127;
|
|
1266
|
-
}
|
|
1267
|
-
console.error(`failed to run parachute-vault: ${msg}`);
|
|
1268
|
-
return 1;
|
|
1269
1407
|
}
|
|
1408
|
+
console.error(`parachute auth: unknown subcommand "${sub}"`);
|
|
1409
|
+
console.error("run `parachute auth --help` for usage");
|
|
1410
|
+
return 1;
|
|
1270
1411
|
}
|
|
1271
1412
|
|
|
1272
1413
|
// Re-exported so `users.ts` consumers can preserve the named-export.
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public-exposure
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Public-exposure security warning (#186). Once the operator brings up
|
|
3
|
+
* cloudflare or Tailscale Funnel, `/login` is reachable from the public
|
|
4
|
+
* internet on every layer admitting traffic. After #188's `/login` rate-limit
|
|
5
|
+
* floor, the owner password is the wall — and now (hub#473) hub-login TOTP 2FA
|
|
6
|
+
* is the second wall.
|
|
7
|
+
*
|
|
8
|
+
* 2FA at the hub login layer is real as of hub#473: "password +
|
|
9
|
+
* something-you-have." This warning recommends `parachute auth 2fa enroll`
|
|
10
|
+
* (which now gates hub `/login` for real) when the operator hasn't enrolled.
|
|
7
11
|
*
|
|
8
12
|
* Why this is a warning, not a hard gate: hard-gating would surprise operators
|
|
9
13
|
* mid-flow — they ran `parachute expose public` to expose, not to be told
|
|
10
|
-
* "set up 2FA first." A loud, contextual warning + a clear
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Why the source-of-truth is vault's `config.yaml`: 2FA enrollment lives in
|
|
15
|
-
* `parachute-vault` (the hub forwards `parachute auth 2fa enroll` to vault —
|
|
16
|
-
* see `commands/auth.ts` `VAULT_FORWARDED_SUBCOMMANDS`). The hub's `users`
|
|
17
|
-
* table has no TOTP column today; it will gain one when hub-admin login
|
|
18
|
-
* verifies TOTP against vault. Until then, "is 2FA enrolled?" maps cleanly
|
|
19
|
-
* to "does vault's config.yaml carry a non-empty `totp_secret`?", which is
|
|
20
|
-
* exactly what `readVaultAuthStatus().hasTotp` returns.
|
|
14
|
+
* "set up 2FA first." A loud, contextual warning + a clear remediation is the
|
|
15
|
+
* right shape; the operator decides whether to act now or later. The tunnel is
|
|
16
|
+
* up regardless.
|
|
21
17
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `parachute auth 2fa enroll` then surfaces vault's "install vault first"
|
|
26
|
-
* error, which is the right next step regardless.
|
|
18
|
+
* The probe consults `readVaultAuthStatus().hasTotp`, which now reflects the
|
|
19
|
+
* hub.db `users.totp_secret` column (real hub-login 2FA) — true when any user
|
|
20
|
+
* has enrolled. The warning fires only when no second factor is configured.
|
|
27
21
|
*/
|
|
28
22
|
|
|
29
23
|
import { type VaultAuthStatus, readVaultAuthStatus } from "../vault/auth-status.ts";
|
|
@@ -40,17 +34,20 @@ export interface Public2FAWarningOpts {
|
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
/**
|
|
43
|
-
* `true` when
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* module-level doc comment.
|
|
37
|
+
* `true` when a second factor is configured, `false` otherwise. As of hub#473
|
|
38
|
+
* this reflects the hub.db `users.totp_secret` column (real hub-login 2FA),
|
|
39
|
+
* with the legacy vault `config.yaml` `totp_secret` as a fallback for old
|
|
40
|
+
* installs — see {@link readVaultAuthStatus} and the module-level doc comment.
|
|
48
41
|
*/
|
|
49
42
|
export function is2FAEnrolled(
|
|
50
|
-
opts: { vaultHome?: string; status?: VaultAuthStatus } = {},
|
|
43
|
+
opts: { vaultHome?: string; hubDbPath?: string; status?: VaultAuthStatus } = {},
|
|
51
44
|
): boolean {
|
|
52
45
|
const status =
|
|
53
|
-
opts.status ??
|
|
46
|
+
opts.status ??
|
|
47
|
+
readVaultAuthStatus({
|
|
48
|
+
...(opts.vaultHome ? { vaultHome: opts.vaultHome } : {}),
|
|
49
|
+
...(opts.hubDbPath ? { hubDbPath: opts.hubDbPath } : {}),
|
|
50
|
+
});
|
|
54
51
|
return status.hasTotp;
|
|
55
52
|
}
|
|
56
53
|
|
|
@@ -71,12 +68,17 @@ export function printPublic2FAWarning(opts: Public2FAWarningOpts): boolean {
|
|
|
71
68
|
return false;
|
|
72
69
|
}
|
|
73
70
|
log("");
|
|
74
|
-
log("⚠
|
|
75
|
-
log(` (${opts.publicUrl}/login). Anyone who guesses your password
|
|
76
|
-
log("
|
|
71
|
+
log("⚠ /login is now reachable on the public internet");
|
|
72
|
+
log(` (${opts.publicUrl}/login). Anyone who guesses your password is in.`);
|
|
73
|
+
log("");
|
|
74
|
+
log(" Turn on two-factor authentication — it adds a second wall (a one-time");
|
|
75
|
+
log(" code from your authenticator app) on top of your password:");
|
|
77
76
|
log("");
|
|
78
77
|
log(" parachute auth 2fa enroll");
|
|
79
78
|
log("");
|
|
80
|
-
log("
|
|
79
|
+
log(" (Or set it up in the browser at /account/2fa for a scannable QR code.)");
|
|
80
|
+
log(" Either way, also make sure your owner password is a strong one:");
|
|
81
|
+
log("");
|
|
82
|
+
log(" parachute auth set-password");
|
|
81
83
|
return true;
|
|
82
84
|
}
|