@openparachute/hub 0.7.3-rc.9 → 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/help.ts CHANGED
@@ -28,6 +28,8 @@ Usage:
28
28
  parachute migrate --to-supervised move a legacy detached install to the managed hub
29
29
  parachute migrate [--dry-run] archive legacy files at ecosystem root
30
30
  parachute auth <cmd> identity (set password, manage 2FA)
31
+ parachute hub set-origin <url> set the canonical public hub origin (OAuth issuer)
32
+ — for reverse-proxy / Caddy-direct boxes
31
33
  parachute vault <args...> vault-specific ops (tokens, 2fa, config, init,
32
34
  etc.) — forwards to parachute-vault.
33
35
  For lifecycle, use \`parachute start|stop|restart|logs vault\`.
@@ -130,6 +132,7 @@ export function initHelp(): string {
130
132
  Usage:
131
133
  parachute init [--no-browser] [--no-expose-prompt]
132
134
  [--expose none|tailnet|cloudflare]
135
+ [--hub-origin <url>]
133
136
  [--cli-wizard | --browser-wizard]
134
137
 
135
138
  What it does:
@@ -165,6 +168,11 @@ Flags:
165
168
  none — stay loopback-only
166
169
  tailnet — set up Tailscale serve (private to your tailnet)
167
170
  cloudflare — set up Cloudflare Tunnel (your own domain)
171
+ --hub-origin <url> set the canonical public origin (OAuth issuer) BEFORE
172
+ the hub + modules start, so vault/scribe come up
173
+ accepting it in one pass. For reverse-proxy /
174
+ Caddy-direct boxes that bind loopback but are reached
175
+ over a public HTTPS URL (e.g. https://<ip>.sslip.io).
168
176
  --cli-wizard skip the "browser or CLI?" prompt and walk the wizard
169
177
  in this terminal (hub#168 Cut 4)
170
178
  --browser-wizard skip the prompt and open the browser wizard directly
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
  }
@@ -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 —