@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/package.json +1 -1
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +433 -0
- package/src/__tests__/hub-origins-env-set.test.ts +74 -0
- package/src/__tests__/hub-server.test.ts +240 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/setup-wizard.test.ts +121 -1
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +409 -0
- package/src/commands/init.ts +63 -0
- package/src/commands/serve.ts +39 -3
- package/src/help.ts +8 -0
- package/src/hub-server.ts +46 -0
- package/src/setup-wizard.ts +20 -0
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
|
}
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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 —
|