@openparachute/hub 0.5.2 → 0.5.9-rc.6

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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Public-exposure 2FA-enrollment warning (#186). Lands as the next layer of
3
+ * defense after #188's `/login` rate-limit floor: once the operator brings
4
+ * up cloudflare or Tailscale Funnel, `/login` is reachable from the public
5
+ * internet on every layer admitting traffic. 2FA is the difference between
6
+ * "password is the only wall" and "password + something-you-have."
7
+ *
8
+ * Why this is a warning, not a hard gate: hard-gating would surprise operators
9
+ * mid-flow — they ran `parachute expose public` to expose, not to be told
10
+ * "set up 2FA first." A loud, contextual warning + a clear one-line
11
+ * remediation is the right shape; the operator decides whether to act now or
12
+ * later. The tunnel is up regardless.
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.
21
+ *
22
+ * If vault isn't installed at all (rare for the cloudflare path — it requires
23
+ * a vault entry — but possible on the tailnet/funnel path): `hasTotp` comes
24
+ * back `false` and the warning still fires. The remediation
25
+ * `parachute auth 2fa enroll` then surfaces vault's "install vault first"
26
+ * error, which is the right next step regardless.
27
+ */
28
+
29
+ import { type VaultAuthStatus, readVaultAuthStatus } from "../vault/auth-status.ts";
30
+
31
+ export interface Public2FAWarningOpts {
32
+ /** Pre-computed status to skip on-disk probe (tests). Production omits. */
33
+ status?: VaultAuthStatus;
34
+ /** Forwarded to {@link readVaultAuthStatus} when `status` is not supplied. */
35
+ vaultHome?: string;
36
+ /** Sink for the warning lines. Defaults to console.log. */
37
+ log?: (line: string) => void;
38
+ /** Public URL the operator just brought up — embedded in the warning. */
39
+ publicUrl: string;
40
+ }
41
+
42
+ /**
43
+ * `true` when `totp_secret` is present and non-empty in vault's config.yaml,
44
+ * `false` otherwise (missing vault, missing config.yaml, empty value).
45
+ *
46
+ * Source-of-truth note: TOTP storage is the vault's, not the hub's. See the
47
+ * module-level doc comment.
48
+ */
49
+ export function is2FAEnrolled(
50
+ opts: { vaultHome?: string; status?: VaultAuthStatus } = {},
51
+ ): boolean {
52
+ const status =
53
+ opts.status ?? readVaultAuthStatus(opts.vaultHome ? { vaultHome: opts.vaultHome } : {});
54
+ return status.hasTotp;
55
+ }
56
+
57
+ /**
58
+ * Print a 2FA-enrollment warning to `log` when not enrolled. No-op when
59
+ * enrolled. Returns `true` if the warning fired, `false` if suppressed —
60
+ * primarily to make integration tests assert the branch without scraping log
61
+ * text.
62
+ */
63
+ export function printPublic2FAWarning(opts: Public2FAWarningOpts): boolean {
64
+ const log = opts.log ?? ((line: string) => console.log(line));
65
+ if (
66
+ is2FAEnrolled({
67
+ ...(opts.vaultHome ? { vaultHome: opts.vaultHome } : {}),
68
+ ...(opts.status ? { status: opts.status } : {}),
69
+ })
70
+ ) {
71
+ return false;
72
+ }
73
+ log("");
74
+ log("⚠ 2FA is not enrolled. /login is now reachable on the public internet");
75
+ log(` (${opts.publicUrl}/login). Anyone who guesses your password`);
76
+ log(" is in. Strongly recommended:");
77
+ log("");
78
+ log(" parachute auth 2fa enroll");
79
+ log("");
80
+ log(" Adds TOTP + backup codes. Takes 30 seconds.");
81
+ return true;
82
+ }
@@ -30,6 +30,8 @@ import { SERVICES_MANIFEST_PATH } from "../config.ts";
30
30
  import { type AliveFn, defaultAlive } from "../process-state.ts";
31
31
  import { readManifest } from "../services-manifest.ts";
32
32
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
33
+ import type { VaultAuthStatus } from "../vault/auth-status.ts";
34
+ import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
33
35
 
34
36
  const AUTH_DOC_URL =
35
37
  "https://github.com/ParachuteComputer/parachute-vault/blob/main/docs/auth-model.md";
@@ -106,6 +108,18 @@ export interface ExposeCloudflareOpts {
106
108
  /** Override `~/.cloudflared` for tests and `$HOME`-free environments. */
107
109
  cloudflaredHome?: string;
108
110
  now?: () => Date;
111
+ /**
112
+ * Override `~/.parachute/vault` for the 2FA-enrollment probe. Tests
113
+ * point at a tmp dir; production omits and the probe defaults to the
114
+ * resolved vault home. (#186)
115
+ */
116
+ vaultHome?: string;
117
+ /**
118
+ * Pre-computed vault auth status, primarily for tests. When set,
119
+ * `printPublic2FAWarning` consults this instead of reading
120
+ * `<vaultHome>/config.yaml` from disk. (#186)
121
+ */
122
+ vaultAuthStatus?: VaultAuthStatus;
109
123
  }
110
124
 
111
125
  interface Resolved {
@@ -121,6 +135,8 @@ interface Resolved {
121
135
  logPath: string;
122
136
  cloudflaredHome: string;
123
137
  now: () => Date;
138
+ vaultHome: string | undefined;
139
+ vaultAuthStatus: VaultAuthStatus | undefined;
124
140
  }
125
141
 
126
142
  function resolve(opts: ExposeCloudflareOpts): Resolved {
@@ -139,6 +155,8 @@ function resolve(opts: ExposeCloudflareOpts): Resolved {
139
155
  logPath: opts.logPath ?? paths.logPath,
140
156
  cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
141
157
  now: opts.now ?? (() => new Date()),
158
+ vaultHome: opts.vaultHome,
159
+ vaultAuthStatus: opts.vaultAuthStatus,
142
160
  };
143
161
  }
144
162
 
@@ -313,6 +331,15 @@ export async function exposeCloudflareUp(
313
331
  r.log("Point a claude.ai / ChatGPT connector at:");
314
332
  r.log(` ${vaultUrl}`);
315
333
  printAuthGuidance(r.log, vaultUrl);
334
+ // 2FA-enrollment warning when /admin/login is now reachable on the public
335
+ // internet but the operator hasn't enrolled TOTP. Cloudflare exposure is
336
+ // always public; tailnet/funnel mirrors this in `expose.ts`. See #186.
337
+ printPublic2FAWarning({
338
+ log: r.log,
339
+ publicUrl: baseUrl,
340
+ ...(r.vaultHome !== undefined ? { vaultHome: r.vaultHome } : {}),
341
+ ...(r.vaultAuthStatus !== undefined ? { status: r.vaultAuthStatus } : {}),
342
+ });
316
343
  return 0;
317
344
  }
318
345
 
@@ -30,8 +30,8 @@
30
30
 
31
31
  import { DEFAULT_CLOUDFLARED_HOME } from "../cloudflare/detect.ts";
32
32
  import {
33
- type ProviderAvailability,
34
33
  type DetectProvidersOpts,
34
+ type ProviderAvailability,
35
35
  detectProviders,
36
36
  isCloudflareReady,
37
37
  isTailnetReady,
@@ -143,9 +143,7 @@ function reportCloudflareNeedsDomain(r: Resolved): number {
143
143
  r.log("Re-run with the hostname:");
144
144
  r.log(" parachute expose public --cloudflare --domain vault.example.com");
145
145
  r.log("");
146
- r.log(
147
- "The hostname's apex domain must already be a zone on your Cloudflare account.",
148
- );
146
+ r.log("The hostname's apex domain must already be a zone on your Cloudflare account.");
149
147
  return 1;
150
148
  }
151
149
 
@@ -154,9 +152,7 @@ function reportCloudflareNeedsDomain(r: Resolved): number {
154
152
  * (`--tailnet` / `--cloudflare`) was supplied AND we're not in a TTY (the TTY
155
153
  * path runs `expose-interactive.ts` instead).
156
154
  */
157
- export async function exposePublicAutoPick(
158
- opts: ExposePublicAutoOpts = {},
159
- ): Promise<number> {
155
+ export async function exposePublicAutoPick(opts: ExposePublicAutoOpts = {}): Promise<number> {
160
156
  const r = resolve(opts);
161
157
  const availability = await r.detectProvidersImpl({ cloudflaredHome: r.cloudflaredHome });
162
158
  const tsReady = isTailnetReady(availability);
@@ -17,39 +17,47 @@ import {
17
17
  stopHub,
18
18
  } from "../hub-control.ts";
19
19
  import { deriveHubOrigin } from "../hub-origin.ts";
20
- import { HUB_MOUNT, HUB_PATH, writeHubFile } from "../hub.ts";
20
+ import { HUB_PATH, writeHubFile } from "../hub.ts";
21
21
  import { type AliveFn, processState } from "../process-state.ts";
22
- import { effectivePublicExposure, shortNameForManifest } from "../service-spec.ts";
22
+ import { shortNameForManifest } from "../service-spec.ts";
23
23
  import { type ServiceEntry, readManifest } from "../services-manifest.ts";
24
24
  import { type ServeEntry, bringupCommand, teardownCommand } from "../tailscale/commands.ts";
25
25
  import { getFqdn, isTailscaleInstalled } from "../tailscale/detect.ts";
26
26
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
27
+ import type { VaultAuthStatus } from "../vault/auth-status.ts";
27
28
  import {
28
29
  WELL_KNOWN_DIR,
29
30
  WELL_KNOWN_MOUNT,
30
31
  WELL_KNOWN_PATH,
31
32
  buildWellKnown,
32
- isVaultEntry,
33
33
  shortName,
34
34
  writeWellKnownFile,
35
35
  } from "../well-known.ts";
36
+ import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
36
37
  import { restart } from "./lifecycle.ts";
37
38
 
38
39
  /**
39
40
  * Two exposure layers share a single tailscale serve config on this node.
40
41
  * Public layer adds `--funnel` to each handler; everything else is identical.
41
42
  *
42
- * Funnel constraint: Tailscale allows at most three public HTTPS ports per
43
- * node (443, 8443, 10000). Path-routing packs every service onto a single
44
- * port that's why we default to one `--https=443` and mount services under
45
- * `/vault`, `/notes`, etc. rather than giving each service its own port or
46
- * subdomain. Subdomain-per-service requires the Tailscale Services feature
47
- * (virtual-IP advertisement) and is deferred.
43
+ * Single-rule shape: tailnet bringup emits exactly one `tailscale serve`
44
+ * mount `/ → http://127.0.0.1:<hubPort>/`. The hub does all internal
45
+ * routing per request: hub UI, OAuth, well-known, vault SPA + per-vault
46
+ * proxy, and generic services.json-driven `/<svc>/*` dispatch. Layer
47
+ * detection (loopback / tailnet / public) and `publicExposure` enforcement
48
+ * also live in the hub (`layerOf` + `effectivePublicExposure`), so this
49
+ * plan layer no longer partitions services up-front. Cloudflare ingress
50
+ * shipped the same shape on 0.5.2 in #178; this closes the symmetry.
48
51
  *
49
- * Hub + well-known entries are HTTP proxies to an internal Bun.serve (see
50
- * `hub-control.ts`). They used to be `--set-path=<mount> <file>` entries but
51
- * macOS `tailscaled` runs sandboxed and can't read arbitrary files; proxy
52
- * mode is the only reliable shape.
52
+ * Funnel constraint, mostly historical now: Tailscale allows at most three
53
+ * public HTTPS ports per node (443, 8443, 10000). With one rule there is
54
+ * one port symbolic but the constraint is what motivated path-routing
55
+ * over subdomain-per-service in the first place.
56
+ *
57
+ * Hub mount is an HTTP proxy to the internal Bun.serve (see `hub-control.ts`).
58
+ * Used to be `--set-path=<mount> <file>` entries but macOS `tailscaled` runs
59
+ * sandboxed and can't read arbitrary files; proxy mode is the only reliable
60
+ * shape.
53
61
  */
54
62
 
55
63
  export interface ExposeOpts {
@@ -92,6 +100,18 @@ export interface ExposeOpts {
92
100
  * spawning real child processes.
93
101
  */
94
102
  restartService?: (short: string) => Promise<number>;
103
+ /**
104
+ * Override `~/.parachute/vault` for the 2FA-enrollment probe on the public
105
+ * (Funnel) layer. Tests point at a tmp dir; production omits and the probe
106
+ * defaults to the resolved vault home. (#186)
107
+ */
108
+ vaultHome?: string;
109
+ /**
110
+ * Pre-computed vault auth status, primarily for tests. When set,
111
+ * `printPublic2FAWarning` consults this instead of reading
112
+ * `<vaultHome>/config.yaml` from disk. (#186)
113
+ */
114
+ vaultAuthStatus?: VaultAuthStatus;
95
115
  }
96
116
 
97
117
  /**
@@ -104,166 +124,49 @@ export interface ExposeOpts {
104
124
  const HUB_DEPENDENT_SHORTS = ["vault"] as const;
105
125
 
106
126
  /**
107
- * OAuth paths the hub serves natively. The mount path is what clients see;
108
- * the target is the hub's loopback origin (where `hub-server.ts` is
109
- * listening). tailscale strips the mount before forwarding, so the target
110
- * must include the same path so the hub-server router sees the full URL.
111
- *
112
- * Pre-cli#58 (PR (c)) these were proxied to vault's `/vault/<name>/oauth/*`
113
- * handlers; after PR (c) the hub IS the OAuth IdP and vault validates
114
- * hub-issued JWTs (vault#169).
127
+ * Single tailscale serve mount: `/ → http://127.0.0.1:<hubPort>/`. The hub
128
+ * dispatches everything internally (hub page, /admin, /api, /hub SPA, /oauth,
129
+ * /.well-known, /vault SPA + proxy, /vaults POST, generic /<svc>/*), so the
130
+ * tailscale plan stays at this single rule regardless of how many services
131
+ * are installed. `publicExposure: "loopback"` enforcement happens inside the
132
+ * hub via `layerOf` see `proxyToService` / `proxyToVault` in hub-server.ts.
115
133
  */
116
- const OAUTH_PATHS = [
117
- "/.well-known/oauth-authorization-server",
118
- "/oauth/authorize",
119
- "/oauth/token",
120
- "/oauth/register",
121
- ] as const;
134
+ const HUB_CATCHALL_MOUNT = "/";
122
135
 
123
136
  /**
124
- * Single tailscale serve mount that catches every `/vault/<name>/...` request
125
- * and routes it through the hub. The hub then reads services.json on each
126
- * request to pick the right vault backend (#144). Consolidating to one mount
127
- * means `parachute vault create techne` is reachable on the tailnet without
128
- * re-running `parachute expose` only the hub-internal lookup needs to know
129
- * about the new path.
130
- *
131
- * Trailing slash distinguishes the mount from `/vaults` (the create-vault
132
- * POST endpoint, an exact-match on hub).
137
+ * Warn (but don't rewrite) for legacy `paths: ["/"]` entries. Pre-#144 these
138
+ * were remapped to `/<shortname>` so they didn't collide with the hub page
139
+ * at `/`. Now that the entire tailnet is one catchall to the hub, the hub
140
+ * dispatches by services.json `paths[]` per request a `paths: ["/"]` entry
141
+ * still wouldn't route correctly, but the failure is hub-side rather than a
142
+ * tailscale plan collision. Emit the warning so operators know to re-install.
133
143
  */
134
- const VAULT_MOUNT = "/vault/";
135
-
136
- /**
137
- * Remap legacy `paths: ["/"]` entries to `/<shortname>` so they don't collide
138
- * with the hub page at `/`. Emits a warning per remapped service. This is the
139
- * transitional path for services installed before the vault PR that writes
140
- * `paths: ["/vault/<default>"]` — once `parachute install` is re-run those
141
- * entries update themselves and this branch goes dormant.
142
- */
143
- function remapLegacyRoot(
144
- services: readonly ServiceEntry[],
145
- log: (line: string) => void,
146
- ): ServiceEntry[] {
147
- return services.map((s) => {
148
- const first = s.paths[0];
149
- if (first !== "/") return s;
144
+ function warnLegacyRoot(services: readonly ServiceEntry[], log: (line: string) => void): void {
145
+ for (const s of services) {
146
+ if (s.paths[0] !== "/") continue;
150
147
  const sn = shortName(s.name);
151
- const remapped = `/${sn}`;
152
148
  log(
153
- `note: ${s.name} claims "/"; hub page lives there — exposing at "${remapped}" instead. Re-run \`parachute install ${sn}\` to update services.json.`,
149
+ `note: ${s.name} claims "/"; hub page lives there — re-run \`parachute install ${sn}\` to update services.json.`,
154
150
  );
155
- return { ...s, paths: [remapped, ...s.paths.slice(1)] };
156
- });
157
- }
158
-
159
- /**
160
- * Partition services into ones that will be mounted on the layer versus ones
161
- * that stay loopback-only. "allowed" services go on the serve plan; every
162
- * other effective exposure state (explicit loopback, explicit auth-required,
163
- * spec-default auth-required) is withheld. Hidden services still appear in
164
- * services.json so on-box callers reach them at http://127.0.0.1:<port>.
165
- */
166
- interface ExposurePartition {
167
- exposed: ServiceEntry[];
168
- hidden: Array<{ entry: ServiceEntry; reason: string }>;
169
- }
170
-
171
- function partitionByExposure(services: readonly ServiceEntry[]): ExposurePartition {
172
- const exposed: ServiceEntry[] = [];
173
- const hidden: Array<{ entry: ServiceEntry; reason: string }> = [];
174
- for (const s of services) {
175
- const eff = effectivePublicExposure(s);
176
- if (eff === "allowed") {
177
- exposed.push(s);
178
- continue;
179
- }
180
- // Explicit declaration tells the user exactly what the service asked for;
181
- // a spec-derived default points at the usual cause (no auth configured).
182
- let reason: string;
183
- if (s.publicExposure === "loopback") {
184
- reason = "loopback-only by service declaration";
185
- } else if (s.publicExposure === "auth-required") {
186
- reason = "auth-required: service reports auth is not yet configured";
187
- } else {
188
- reason = "auth-required: service has no auth gate — set the service's auth token to expose";
189
- }
190
- hidden.push({ entry: s, reason });
191
151
  }
192
- return { exposed, hidden };
193
152
  }
194
153
 
195
154
  /**
196
- * Compose the tailscale serve target URL for a service rooted at `mount`.
197
- *
198
- * `tailscale serve --set-path=<mount> <target>` strips `<mount>` from the
199
- * incoming request path before forwarding. So if the backend expects
200
- * requests to keep arriving at `<mount>/...` (every SPA with a configured
201
- * base path, plus vault's `/vault/<name>/` API root) the target URL must
202
- * include the same mount path — otherwise the backend sees requests at `/`,
203
- * emits a redirect back to its real base, tailscale strips again, and the
204
- * client loops on `ERR_TOO_MANY_REDIRECTS`.
205
- *
206
- * The rule of thumb is: mount and target path must match byte-for-byte
207
- * (including trailing slash state), so tailscale's strip-then-forward is a
208
- * no-op and the backend sees the full path it expects.
155
+ * Build the tailscale plan: one rule, `/ http://127.0.0.1:<hubPort>/`.
156
+ * Hub does internal dispatch (UI, OAuth, well-known, vault SPA + per-vault
157
+ * proxy, generic /<svc>/* services.json dispatch) and per-request layer
158
+ * gating for `publicExposure: "loopback"` services. See `layerOf` in
159
+ * `hub-server.ts` for the access-control matrix.
209
160
  */
210
- function serviceProxyTarget(port: number, mount: string): string {
211
- return `http://127.0.0.1:${port}${mount}`;
212
- }
213
-
214
- function planEntries(services: readonly ServiceEntry[], hubPort: number): ServeEntry[] {
215
- const entries: ServeEntry[] = [];
216
- entries.push({
217
- kind: "proxy",
218
- mount: HUB_MOUNT,
219
- target: serviceProxyTarget(hubPort, HUB_MOUNT),
220
- service: "hub",
221
- });
222
- let anyVault = false;
223
- for (const s of services) {
224
- if (isVaultEntry(s)) {
225
- // Vault paths route through the single `/vault/` → hub mount below so
226
- // `parachute vault create <name>` is reachable on the tailnet without
227
- // a re-expose. Hub does the per-request services.json lookup (#144).
228
- anyVault = true;
229
- continue;
230
- }
231
- const mount = s.paths[0] ?? `/${shortName(s.name)}`;
232
- entries.push({
161
+ function planEntries(hubPort: number): ServeEntry[] {
162
+ return [
163
+ {
233
164
  kind: "proxy",
234
- mount,
235
- target: serviceProxyTarget(s.port, mount),
236
- service: s.name,
237
- });
238
- }
239
- if (anyVault) {
240
- entries.push({
241
- kind: "proxy",
242
- mount: VAULT_MOUNT,
243
- target: serviceProxyTarget(hubPort, VAULT_MOUNT),
244
- service: "vault",
245
- });
246
- }
247
- entries.push({
248
- kind: "proxy",
249
- mount: WELL_KNOWN_MOUNT,
250
- target: serviceProxyTarget(hubPort, WELL_KNOWN_MOUNT),
251
- service: "well-known",
252
- });
253
-
254
- // The hub is the OAuth IdP — mount the four endpoints at the canonical
255
- // origin and proxy them to the hub's loopback. tailscale strips the mount
256
- // before forwarding, so the target keeps the same path (matches the
257
- // `serviceProxyTarget` rule of thumb in the doc above).
258
- for (const oauthPath of OAUTH_PATHS) {
259
- entries.push({
260
- kind: "proxy",
261
- mount: oauthPath,
262
- target: serviceProxyTarget(hubPort, oauthPath),
263
- service: "hub:oauth",
264
- });
265
- }
266
- return entries;
165
+ mount: HUB_CATCHALL_MOUNT,
166
+ target: `http://127.0.0.1:${hubPort}/`,
167
+ service: "hub",
168
+ },
169
+ ];
267
170
  }
268
171
 
269
172
  async function runEach(
@@ -366,12 +269,13 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
366
269
  }
367
270
  }
368
271
 
369
- const allServices = remapLegacyRoot(manifest.services, log);
370
- // Split out loopback/auth-required services before planning the serve routes.
371
- // Hidden services keep their /127.0.0.1:<port> accessibility for on-box
372
- // callers (e.g., vault's transcription-worker dialing scribe); they just
373
- // don't land on tailnet/funnel.
374
- const { exposed: services, hidden } = partitionByExposure(allServices);
272
+ // Plan no longer partitions services — every service goes through the
273
+ // single hub catchall, and hub gates per request (`publicExposure` +
274
+ // `layerOf` in hub-server.ts). Just surface the legacy `paths: ["/"]`
275
+ // warning so operators know to re-install. `warnLegacyRoot` is
276
+ // side-effect-only (warning to `log`); use `manifest.services` directly
277
+ // downstream.
278
+ warnLegacyRoot(manifest.services, log);
375
279
 
376
280
  /**
377
281
  * Probe each service port before wiring tailscale up. A service that's
@@ -381,7 +285,7 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
381
285
  */
382
286
  const portProbe = opts.servicePortProbe ?? (async (p: number) => !(await defaultPortProbe(p)));
383
287
  const probeResults = await Promise.all(
384
- services.map(async (s) => ({ svc: s, up: await portProbe(s.port) })),
288
+ manifest.services.map(async (s) => ({ svc: s, up: await portProbe(s.port) })),
385
289
  );
386
290
  for (const { svc, up } of probeResults) {
387
291
  if (up) continue;
@@ -394,7 +298,7 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
394
298
  // Kept for manual debugging / inspection only — the hub server now builds
395
299
  // /.well-known/parachute.json dynamically from services.json at request time
396
300
  // (#135), so this on-disk copy is no longer load-bearing for any consumer.
397
- const wellKnownDoc = buildWellKnown({ services, canonicalOrigin });
301
+ const wellKnownDoc = buildWellKnown({ services: manifest.services, canonicalOrigin });
398
302
  writeWellKnownFile(wellKnownDoc, wellKnownFilePath);
399
303
  log(`Wrote ${wellKnownFilePath}`);
400
304
  writeHubFile(hubFilePath);
@@ -416,7 +320,7 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
416
320
  hubPort = existing;
417
321
  } else {
418
322
  const hub = await ensureHubRunning({
419
- reservedPorts: services.map((s) => s.port),
323
+ reservedPorts: manifest.services.map((s) => s.port),
420
324
  ...(opts.hubEnsureOpts ?? {}),
421
325
  configDir,
422
326
  wellKnownDir,
@@ -428,15 +332,12 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
428
332
  else log(`✓ hub already running (pid ${hub.pid}, port ${hub.port}).`);
429
333
  }
430
334
 
431
- const entries = planEntries(services, hubPort);
335
+ const entries = planEntries(hubPort);
432
336
  log(`Exposing under ${canonicalOrigin} (${layerLabel(layer)}, path-routing, port ${port}):`);
433
337
  for (const e of entries) {
434
338
  const suffix = e.kind === "proxy" ? `→ ${e.target} (${e.service})` : `→ ${e.target}`;
435
339
  log(` ${e.mount.padEnd(30, " ")} ${suffix}`);
436
340
  }
437
- for (const { entry: hiddenSvc, reason } of hidden) {
438
- log(` (${hiddenSvc.name} is loopback-only — ${reason})`);
439
- }
440
341
 
441
342
  const cmds = entries.map((e) => bringupCommand(e, { port, funnel }));
442
343
  const code = await runEach(runner, cmds, log);
@@ -470,6 +371,20 @@ export async function exposeUp(layer: ExposeLayer, opts: ExposeOpts = {}): Promi
470
371
  log(` Discovery: ${canonicalOrigin}${WELL_KNOWN_MOUNT}`);
471
372
  log(` OAuth issuer: ${hubOrigin}`);
472
373
 
374
+ // 2FA-enrollment warning, public-layer only. /admin/login became reachable
375
+ // from every layer when 0.5.3-rc.1 collapsed the access-control matrix into
376
+ // the hub; on Funnel that means the open internet, where 2FA is the
377
+ // defense beyond #188's rate-limit floor. Tailnet exposure stays
378
+ // tailscale-authed at the ingress so the warning is moot there. See #186.
379
+ if (layer === "public") {
380
+ printPublic2FAWarning({
381
+ log,
382
+ publicUrl: canonicalOrigin,
383
+ ...(opts.vaultHome !== undefined ? { vaultHome: opts.vaultHome } : {}),
384
+ ...(opts.vaultAuthStatus !== undefined ? { status: opts.vaultAuthStatus } : {}),
385
+ });
386
+ }
387
+
473
388
  // Auto-restart services that cache the hub origin. Aaron hit this on launch
474
389
  // day: after `expose public` first-run, vault kept its stale (loopback)
475
390
  // PARACHUTE_HUB_ORIGIN, the OAuth issuer didn't match what clients saw, and
@@ -545,29 +545,27 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
545
545
  }
546
546
  }
547
547
 
548
- // CLI-as-port-authority (#53): pick the service's port now and persist it
549
- // via `~/.parachute/<svc>/.env`. lifecycle.start merges that .env into the
550
- // spawn env (PR #50), so the next daemon boot binds the port we picked.
551
- // Idempotent an existing PORT in .env wins, so re-installs and
552
- // user-edited ports survive across upgrades. Compiled-in service-side
553
- // fallbacks (vault 1940 etc.) stay; this just adds a CLI-managed
554
- // override.
548
+ // Hub-as-port-authority (#53): pick the service's port now and reflect it
549
+ // in services.json. Pre-hub#206 the install path also wrote `PORT=<port>`
550
+ // into the service's `.env`; post-#206 (option A) services.json is the
551
+ // single source of truth services follow the 4-tier resolvePort ladder
552
+ // (services.json service config bare PORT env → compiled-in default,
553
+ // per parachute-scribe#41 / parachute-agent#146 / parachute-agent#148 /
554
+ // parachute-patterns#45), so the duplicate `.env` PORT was at best dead
555
+ // weight and at worst a source of drift on re-install. Existing `.env`
556
+ // PORT lines on operator machines stay where they are — harmless — and
557
+ // future installs no longer touch them.
555
558
  const preInitEntry = findService(entryName, manifestPath);
556
559
  const probe = opts.portProbe ?? defaultPortProbe;
557
560
  const occupied = await collectOccupiedPorts(manifestPath, entryName, preInitEntry?.port, probe);
558
- const envPath = join(configDir, short, ".env");
559
561
  const canonicalPort = spec.seedEntry?.().port ?? preInitEntry?.port;
560
562
  const portResult = assignServicePort({
561
- envPath,
562
563
  canonical: canonicalPort,
563
564
  occupied,
564
565
  });
565
566
  if (portResult.warning) {
566
567
  log(`⚠ ${portResult.warning}`);
567
568
  }
568
- if (portResult.written) {
569
- log(`Wrote PORT=${portResult.port} to ${envPath}.`);
570
- }
571
569
 
572
570
  // Find-or-seed the manifest entry. Re-read after the seed write so a silent
573
571
  // upsert failure (filesystem permission, races against an external writer)
@@ -600,7 +598,7 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
600
598
  } else if (entry && entry.port !== portResult.port) {
601
599
  // init wrote an entry on the canonical port but the CLI assigned a
602
600
  // different one (collision). Reflect the CLI's choice so the hub and
603
- // status views stay consistent with the .env we just wrote.
601
+ // status views stay consistent with the canonical-port assignment.
604
602
  upsertService({ ...entry, port: portResult.port }, manifestPath);
605
603
  entry = findService(entryName, manifestPath);
606
604
  log(