@openparachute/hub 0.7.3-rc.8 → 0.7.3

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/src/hub-origin.ts CHANGED
@@ -14,6 +14,70 @@
14
14
 
15
15
  export const HUB_ORIGIN_ENV = "PARACHUTE_HUB_ORIGIN";
16
16
 
17
+ /**
18
+ * The env var carrying the SET of origins the hub legitimately answers on,
19
+ * comma-separated (multi-origin iss-set, onboarding-streamline 2026-06-25).
20
+ *
21
+ * Supervised resource servers (vault, scribe) read this in addition to the
22
+ * single canonical `PARACHUTE_HUB_ORIGIN` and widen their accepted-`iss` check
23
+ * from one string to this set — so a token minted under one URL of a
24
+ * multi-URL box (loopback ∪ `<ip>.sslip.io` ∪ a custom domain behind Caddy)
25
+ * validates when the resource is reached via another URL of the SAME box. The
26
+ * signing key is stable + origin-independent, so only the `iss` string varies.
27
+ *
28
+ * SECURITY INVARIANT: the value MUST be the hub's `buildHubBoundOrigins`
29
+ * output — configured issuer ∪ loopback aliases ∪ expose-state public origin ∪
30
+ * platform origin — published BY THE HUB. It must NEVER contain an unvalidated
31
+ * request `Host` / `X-Forwarded-Host`. Accepting `iss ∈ this-set` is safe
32
+ * ONLY because the resource server's JWKS signature verify runs first and
33
+ * proves the hub minted the token.
34
+ *
35
+ * `PARACHUTE_HUB_ORIGIN` (the single canonical origin) is ALWAYS written
36
+ * alongside this var for back-compat: a resource server on an older
37
+ * scope-guard that doesn't read `PARACHUTE_HUB_ORIGINS` keeps its existing
38
+ * single-origin behavior.
39
+ */
40
+ export const HUB_ORIGINS_ENV = "PARACHUTE_HUB_ORIGINS";
41
+
42
+ /**
43
+ * Serialize an origin SET into the comma-separated `PARACHUTE_HUB_ORIGINS`
44
+ * wire form. Dedupes, drops empties, preserves first-seen order. Returns
45
+ * `undefined` when nothing survives (caller skips setting the env var so a
46
+ * resource server falls back to single-origin behavior). Pair with
47
+ * `buildHubBoundOrigins` to produce `origins`.
48
+ */
49
+ export function serializeHubOrigins(origins: readonly string[]): string | undefined {
50
+ const seen = new Set<string>();
51
+ for (const raw of origins) {
52
+ if (typeof raw !== "string") continue;
53
+ const trimmed = raw.trim().replace(/\/+$/, "");
54
+ if (trimmed.length > 0) seen.add(trimmed);
55
+ }
56
+ if (seen.size === 0) return undefined;
57
+ return Array.from(seen).join(",");
58
+ }
59
+
60
+ /**
61
+ * Parse the comma-separated `PARACHUTE_HUB_ORIGINS` wire form back into an
62
+ * origin array. Tolerant of surrounding whitespace + trailing slashes + empty
63
+ * segments. The resource-server inverse of `serializeHubOrigins`; lives here
64
+ * (rather than only in the consumer adapters) so the hub's own injection tests
65
+ * can round-trip against the canonical parser. Returns `[]` for an
66
+ * absent/empty/garbage value.
67
+ */
68
+ export function parseHubOrigins(raw: string | undefined): string[] {
69
+ if (!raw) return [];
70
+ const out: string[] = [];
71
+ const seen = new Set<string>();
72
+ for (const seg of raw.split(",")) {
73
+ const trimmed = seg.trim().replace(/\/+$/, "");
74
+ if (trimmed.length === 0 || seen.has(trimmed)) continue;
75
+ seen.add(trimmed);
76
+ out.push(trimmed);
77
+ }
78
+ return out;
79
+ }
80
+
17
81
  export interface DeriveHubOriginOpts {
18
82
  /** Explicit user override (e.g., `--hub-origin`). Wins over everything else. */
19
83
  override?: string;
package/src/hub-server.ts CHANGED
@@ -578,6 +578,16 @@ export type RequestLayer = "loopback" | "tailnet" | "public";
578
578
  * the cloak fires. When `peerAddr` is unknown (null/undefined — no Server
579
579
  * threaded, e.g. a unit test calling `layerOf(req)` directly), we fail CLOSED
580
580
  * to `public` rather than open to `loopback`.
581
+ *
582
+ * Caddy/nginx-direct (hub#704): a SAME-BOX reverse proxy dials loopback (peer
583
+ * is 127.0.0.1) but, unlike cloudflared/tailscale, sets NO cf/tailscale header
584
+ * — so a header-only-or-peer-only classifier would call every public request
585
+ * through it "loopback" (most-trusted). The discriminator is the standard
586
+ * reverse-proxy forwarding headers (X-Forwarded-For / X-Forwarded-Host /
587
+ * Forwarded): a loopback peer that carries one is a proxied PUBLIC request →
588
+ * `public`; a header-less loopback peer (direct on-box caller — CLI, probes,
589
+ * the init bootstrap-token loopback probe) stays `loopback`. See the inline
590
+ * comment in the function for the full rationale + spoof analysis.
581
591
  */
582
592
  export function layerOf(req: Request, peerAddr?: string | null): RequestLayer {
583
593
  const h = req.headers;
@@ -590,6 +600,42 @@ export function layerOf(req: Request, peerAddr?: string | null): RequestLayer {
590
600
  // value to compare against, hence the presence-check above.
591
601
  if (h.get("tailscale-funnel-request") === "?1") return "public";
592
602
  if (h.get("tailscale-user-login") !== null) return "tailnet";
603
+ // Caddy/nginx-direct deploy (hub#704): a same-box reverse proxy terminates
604
+ // TLS and `reverse_proxy 127.0.0.1:1939` — so it dials loopback (peer is
605
+ // 127.0.0.1) and, unlike cloudflared/tailscale, stamps NO cf/tailscale
606
+ // header. Without this branch every PUBLIC request through such a proxy
607
+ // would classify "loopback" (the MOST-trusted layer): the GET /admin/setup
608
+ // bootstrap-token JSON probe would hand the token to any public visitor, and
609
+ // the publicExposure:"loopback" 404-cloak would stop hiding loopback-only
610
+ // services/vaults from the network.
611
+ //
612
+ // The discriminator is the standard reverse-proxy forwarding headers. A
613
+ // same-box proxy carrying a PUBLIC request sets X-Forwarded-For /
614
+ // X-Forwarded-Host / Forwarded; a direct on-box caller (the CLI, health
615
+ // probes, the init bootstrap-token loopback probe `curl 127.0.0.1/admin/setup`,
616
+ // the hub's own loopback self-requests) sets none of them — the hub never
617
+ // injects X-Forwarded-* on the INBOUND request it classifies (it only stamps
618
+ // X-Forwarded-Host/Proto on OUTBOUND proxy requests to modules). So a
619
+ // loopback peer that ALSO carries a forwarding header is a proxied public
620
+ // request → "public"; a header-less loopback peer stays "loopback".
621
+ //
622
+ // No spoof vector: a NON-loopback peer is already "public" regardless of
623
+ // headers (the branch below), so adding these headers can only DOWNGRADE a
624
+ // loopback caller (the on-box operator hurting only their own request) —
625
+ // never upgrade a network peer to "loopback".
626
+ //
627
+ // Presence check (`!== null`), NOT a trim: an empty/whitespace forwarding
628
+ // header still means "a proxy is in front" → err to public. Downgrading on
629
+ // ambiguity is the safe direction for a trust classifier; a future ".trim()
630
+ // tidy-up" that let an empty XFF fall back to loopback would re-open the leak.
631
+ if (
632
+ isLoopbackPeer(peerAddr) &&
633
+ (h.get("x-forwarded-for") !== null ||
634
+ h.get("x-forwarded-host") !== null ||
635
+ h.get("forwarded") !== null)
636
+ ) {
637
+ return "public";
638
+ }
593
639
  // No proxy headers — classify by peer address, failing closed when unknown.
594
640
  return isLoopbackPeer(peerAddr) ? "loopback" : "public";
595
641
  }
package/src/jwt-sign.ts CHANGED
@@ -55,9 +55,20 @@ export interface SignAccessTokenOpts {
55
55
  clientId: string;
56
56
  /**
57
57
  * Hub origin — sets the `iss` claim. Required: every consumer (vault,
58
- * scribe, channel) validates `iss` against `PARACHUTE_HUB_ORIGIN`, and a
59
- * missing claim is rejected. Callers derive this via `deriveHubOrigin()`
60
- * or thread it from `OAuthDeps.issuer`.
58
+ * scribe, agent) validates `iss`, and a missing claim is rejected. Callers
59
+ * derive this via `deriveHubOrigin()` or thread it from `OAuthDeps.issuer`
60
+ * (the per-request origin from `resolveIssuer`).
61
+ *
62
+ * Multi-origin iss-set (onboarding-streamline 2026-06-25): consumers no
63
+ * longer pin `iss` to a SINGLE `PARACHUTE_HUB_ORIGIN`. The hub publishes the
64
+ * SET of origins it legitimately answers on via `PARACHUTE_HUB_ORIGINS`
65
+ * (`buildHubBoundOrigins` — issuer ∪ loopback ∪ expose-state ∪ platform),
66
+ * and a scope-guard ≥0.5.0 consumer accepts `iss` ∈ that set. So a token
67
+ * minted here with one URL of a multi-URL box validates when the resource is
68
+ * reached via another URL of the SAME box. Mint stays per-request as-is: the
69
+ * canonical configured origin (the intended Caddy/zero-SSH deploy) is minted
70
+ * on every request AND is a member of the published set, so mint and validate
71
+ * already align — no clamp needed here.
61
72
  */
62
73
  issuer: string;
63
74
  /**
@@ -60,6 +60,7 @@ import {
60
60
  SETUP_EXPOSE_MODES,
61
61
  type SetupExposeMode,
62
62
  deleteSetting,
63
+ getHubOrigin,
63
64
  getSetting,
64
65
  isSetupExposeMode,
65
66
  openFirstClientAutoApproveWindow,
@@ -89,6 +90,7 @@ import {
89
90
  } from "./sessions.ts";
90
91
  import type { Supervisor } from "./supervisor.ts";
91
92
  import { createUser, userCount } from "./users.ts";
93
+ import { sanitizePublicOrigin } from "./vault-hub-origin-env.ts";
92
94
  import { DEFAULT_VAULT_NAME, validateVaultName } from "./vault-name.ts";
93
95
 
94
96
  // --- shared chrome --------------------------------------------------------
@@ -352,6 +354,24 @@ export function deriveWizardState(deps: {
352
354
  setSetting(deps.db, "setup_expose_mode", seeded);
353
355
  }
354
356
  }
357
+ // hub Caddy-direct case: a reverse-proxy box (`parachute init --hub-origin
358
+ // https://<host>`, or `parachute hub set-origin`) persists a real public
359
+ // origin to `hub_settings.hub_origin` (also honored from PARACHUTE_HUB_ORIGIN).
360
+ // That origin already answers "how is this hub reached?" — Caddy/Let's-Encrypt
361
+ // fronts the loopback hub, no `parachute expose` and no Render/Fly env var.
362
+ // Auto-seed `setup_expose_mode = "public"` so the wizard skips the expose step
363
+ // exactly as the Render/Fly + live-tailscale branches do. `sanitizePublicOrigin`
364
+ // (the SAME guard `resolveIssuer`/`exposeIssuerOrigin` use) requires an absolute
365
+ // http(s) URL and REJECTS loopback/0.0.0.0 — so a laptop/loopback/unset origin
366
+ // returns undefined and the expose step still renders (the operator chooses).
367
+ if (getSetting(deps.db, "setup_expose_mode") === undefined) {
368
+ const configured =
369
+ sanitizePublicOrigin(getHubOrigin(deps.db) ?? undefined) ??
370
+ sanitizePublicOrigin(deps.env?.PARACHUTE_HUB_ORIGIN ?? process.env.PARACHUTE_HUB_ORIGIN);
371
+ if (configured !== undefined) {
372
+ setSetting(deps.db, "setup_expose_mode", "public");
373
+ }
374
+ }
355
375
  const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
356
376
  let step: WizardStep;
357
377
  // Note: `"account"` is a visual-only step in the progress header —
@@ -29,7 +29,10 @@
29
29
  import { join } from "node:path";
30
30
  import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
31
31
  import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
32
- import { HUB_ORIGIN_ENV } from "./hub-origin.ts";
32
+ import { readHubPort } from "./hub-control.ts";
33
+ import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV, serializeHubOrigins } from "./hub-origin.ts";
34
+ import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
35
+ import { buildHubBoundOrigins } from "./origin-check.ts";
33
36
 
34
37
  /**
35
38
  * Loopback origins (`http://127.0.0.1:<port>`, `localhost`, `[::1]`) are the
@@ -91,11 +94,80 @@ function vaultEnvPath(configDir: string): string {
91
94
  return join(configDir, "vault", ".env");
92
95
  }
93
96
 
97
+ /**
98
+ * Compose `https://${FLY_APP_NAME}.fly.dev` when FLY_APP_NAME is a plausible
99
+ * Fly app slug (no slashes — Fly slugs don't contain them). Mirrors the
100
+ * private helper in operator-token.ts / hub-server.ts; kept local so the
101
+ * origin-SET assembly here doesn't reach across modules for a 3-line guard.
102
+ */
103
+ function flyDefaultOriginFromEnv(env: NodeJS.ProcessEnv): string | undefined {
104
+ const app = env.FLY_APP_NAME;
105
+ if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
106
+ return `https://${app}.fly.dev`;
107
+ }
108
+
109
+ /**
110
+ * Assemble the comma-separated `PARACHUTE_HUB_ORIGINS` value the hub injects
111
+ * into supervised resource servers (multi-origin iss-set). The SET is the
112
+ * hub's own legitimate origins — the env-injection sibling of the operator
113
+ * token's `buildKnownIssuersForOperatorToken` and the per-request
114
+ * `buildHubBoundOrigins` call in hub-server.ts. Inputs:
115
+ *
116
+ * - `issuer` — the canonical hub origin the child also receives as the
117
+ * single `PARACHUTE_HUB_ORIGIN` (the seed; always included).
118
+ * - loopback aliases — `http://127.0.0.1:<port>` ∪ `http://localhost:<port>`
119
+ * for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`).
120
+ * - the expose-state public origin — `expose-state.json`'s `hubOrigin`.
121
+ * - the platform/env public origin — `RENDER_EXTERNAL_URL` ∪ the composed
122
+ * Fly default (container deploys where the public origin comes from the
123
+ * platform, not expose-state).
124
+ *
125
+ * SECURITY INVARIANT: every input is hub/operator-controlled config or
126
+ * on-disk state — NEVER an unvalidated request `Host` / `X-Forwarded-Host`.
127
+ * The accepted-`iss` widening this enables is safe only because the resource
128
+ * server verifies the JWKS signature first; this set is the belt-and-
129
+ * suspenders allowlist layered on top.
130
+ *
131
+ * Returns `undefined` when the seed issuer is absent AND nothing else resolves
132
+ * (caller skips the env var so the child keeps single-origin behavior).
133
+ */
134
+ export function buildHubOriginsEnvValue(
135
+ configDir: string,
136
+ issuer: string | undefined,
137
+ env: NodeJS.ProcessEnv = process.env,
138
+ exposeStatePath: string = EXPOSE_STATE_PATH,
139
+ ): string | undefined {
140
+ const loopbackPort = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
141
+ let exposeHubOrigin: string | undefined;
142
+ try {
143
+ exposeHubOrigin = readExposeState(exposeStatePath)?.hubOrigin;
144
+ } catch {
145
+ // A malformed expose-state.json must never block a module spawn — the seed
146
+ // issuer + loopback aliases already cover legitimate access.
147
+ exposeHubOrigin = undefined;
148
+ }
149
+ const platformOrigin = env.RENDER_EXTERNAL_URL ?? flyDefaultOriginFromEnv(env);
150
+ const origins = buildHubBoundOrigins({
151
+ // buildHubBoundOrigins requires `issuer`; pass "" when absent (it's dropped
152
+ // by the URL parse) so the loopback aliases still seed the set.
153
+ issuer: issuer ?? "",
154
+ loopbackPort,
155
+ ...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
156
+ ...(platformOrigin !== undefined ? { platformOrigin } : {}),
157
+ });
158
+ return serializeHubOrigins(origins);
159
+ }
160
+
94
161
  /**
95
162
  * Upsert `PARACHUTE_HUB_ORIGIN=<origin>` into `vault/.env` when `origin` is a
96
- * non-loopback public origin. Idempotent skips the write (and the log) when
97
- * the value is already current so repeated `start`s don't churn the file.
98
- * Returns true iff the file was written this call.
163
+ * non-loopback public origin, AND the `PARACHUTE_HUB_ORIGINS` set (the
164
+ * multi-origin iss-set: origin loopback aliases expose-state platform)
165
+ * so the daemon-boot path validates `iss` against every URL the box answers on
166
+ * (a Caddy-fronted box reached via loopback + sslip.io + a custom domain at
167
+ * once). The set is assembled from hub-controlled inputs only (see
168
+ * `buildHubOriginsEnvValue`'s security invariant). Idempotent — skips the
169
+ * write (and the log) when BOTH values are already current so repeated
170
+ * `start`s don't churn the file. Returns true iff the file was written.
99
171
  */
100
172
  export function persistVaultHubOrigin(
101
173
  configDir: string,
@@ -105,26 +177,47 @@ export function persistVaultHubOrigin(
105
177
  if (isLoopbackOrigin(origin)) return false;
106
178
  const path = vaultEnvPath(configDir);
107
179
  const parsed = parseEnvFile(path);
108
- if (parsed.values[HUB_ORIGIN_ENV] === origin) return false;
109
- writeEnvFile(path, upsertEnvLine(parsed.lines, HUB_ORIGIN_ENV, origin));
110
- log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
180
+ const originsValue = buildHubOriginsEnvValue(configDir, origin);
181
+ const originCurrent = parsed.values[HUB_ORIGIN_ENV] === origin;
182
+ const originsCurrent =
183
+ originsValue === undefined || parsed.values[HUB_ORIGINS_ENV] === originsValue;
184
+ if (originCurrent && originsCurrent) return false;
185
+ let lines = parsed.lines;
186
+ if (!originCurrent) lines = upsertEnvLine(lines, HUB_ORIGIN_ENV, origin);
187
+ if (!originsCurrent && originsValue !== undefined) {
188
+ lines = upsertEnvLine(lines, HUB_ORIGINS_ENV, originsValue);
189
+ }
190
+ writeEnvFile(path, lines);
191
+ if (!originCurrent) {
192
+ log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
193
+ }
194
+ if (!originsCurrent && originsValue !== undefined) {
195
+ log(` persisted ${HUB_ORIGINS_ENV}=${originsValue} to ${path} (multi-origin iss-set)`);
196
+ }
111
197
  return true;
112
198
  }
113
199
 
114
200
  /**
115
- * Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` from `vault/.env`. Called
116
- * on `expose … off`: once exposure is torn down, a local-only hub mints tokens
117
- * with a loopback `iss`, so a stale public origin left in `.env` would itself
118
- * cause the mismatch. Removing the line reverts vault to its loopback default
119
- * (`getHubOrigin`), which matches what the local hub now stamps. No-op (returns
120
- * false) when the key isn't present. Returns true iff the file was rewritten.
201
+ * Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` (and `PARACHUTE_HUB_ORIGINS`)
202
+ * from `vault/.env`. Called on `expose … off`: once exposure is torn down, a
203
+ * local-only hub mints tokens with a loopback `iss`, so a stale public origin
204
+ * left in `.env` would itself cause the mismatch. Removing the lines reverts
205
+ * vault to its loopback default (`getHubOrigin`), which matches what the local
206
+ * hub now stamps. No-op (returns false) when neither key is present. Returns
207
+ * true iff the file was rewritten.
121
208
  */
122
209
  export function clearVaultHubOrigin(configDir: string, log: (line: string) => void): boolean {
123
210
  const path = vaultEnvPath(configDir);
124
211
  const parsed = parseEnvFile(path);
125
- if (parsed.values[HUB_ORIGIN_ENV] === undefined) return false;
126
- writeEnvFile(path, removeEnvLine(parsed.lines, HUB_ORIGIN_ENV));
127
- log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
212
+ const hadOrigin = parsed.values[HUB_ORIGIN_ENV] !== undefined;
213
+ const hadOrigins = parsed.values[HUB_ORIGINS_ENV] !== undefined;
214
+ if (!hadOrigin && !hadOrigins) return false;
215
+ let lines = parsed.lines;
216
+ if (hadOrigin) lines = removeEnvLine(lines, HUB_ORIGIN_ENV);
217
+ if (hadOrigins) lines = removeEnvLine(lines, HUB_ORIGINS_ENV);
218
+ writeEnvFile(path, lines);
219
+ if (hadOrigin) log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
220
+ if (hadOrigins) log(` cleared ${HUB_ORIGINS_ENV} from ${path} (exposure torn down)`);
128
221
  return true;
129
222
  }
130
223