@openparachute/hub 0.7.3-rc.9 → 0.7.4-rc.1

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.
@@ -41,6 +41,7 @@ import { type ExposeState, readExposeState } from "../expose-state.ts";
41
41
  import { type EnsureHubOpts, HUB_DEFAULT_PORT, HUB_SVC, readHubPort } from "../hub-control.ts";
42
42
  import { hubDbPath, openHubDb } from "../hub-db.ts";
43
43
  import { deriveHubOrigin } from "../hub-origin.ts";
44
+ import { setHubOrigin } from "../hub-settings.ts";
44
45
  import {
45
46
  type EnsureHubVersionMatchesResult,
46
47
  ensureHubUnit,
@@ -153,14 +154,31 @@ export interface InitOpts {
153
154
  * stub to record the call without shelling out to `cloudflared`.
154
155
  */
155
156
  exposeCloudflareImpl?: () => Promise<number>;
157
+ /**
158
+ * Install channel for the vault module (hub#694 bug 2). `"rc"` makes init's
159
+ * vault install resolve `@openparachute/vault@rc` instead of `@latest`, so an
160
+ * rc-channel box (hub installed from `@rc`) doesn't DOWNGRADE vault below the
161
+ * hub on `parachute init`. Default `"latest"` (npm default; back-compat for
162
+ * every existing operator). Precedence in the CLI: `--channel <v>` flag >
163
+ * `PARACHUTE_CHANNEL` / `PARACHUTE_INSTALL_CHANNEL` env > `"latest"`. Threaded
164
+ * verbatim into `install()`'s existing `channel` plumbing
165
+ * (`resolveInstallChannel`).
166
+ */
167
+ channel?: "latest" | "rc";
156
168
  /**
157
169
  * Test seam: shim for the vault-module install step (hub#168 Cut 1).
158
170
  * Production calls `install("vault", { noCreate: true, noStart: true, …})`
159
171
  * to put `@openparachute/vault` on PATH without creating a first-vault
160
172
  * instance — the wizard's vault step decides Create/Import/Skip. Tests
161
- * pass a stub to record the call without shelling out.
173
+ * pass a stub to record the call without shelling out. The `channel` arg
174
+ * (hub#694) forwards into `install()`'s `channel` option so an rc box installs
175
+ * vault from `@rc`.
162
176
  */
163
- installVaultModuleImpl?: (configDir: string, manifestPath: string) => Promise<number>;
177
+ installVaultModuleImpl?: (
178
+ configDir: string,
179
+ manifestPath: string,
180
+ channel?: "latest" | "rc",
181
+ ) => Promise<number>;
164
182
  /**
165
183
  * Override the wizard-choice prompt (hub#168 Cut 4). When set, the
166
184
  * "Continue setup in the browser or CLI?" question is answered without
@@ -184,6 +202,27 @@ export interface InitOpts {
184
202
  * already known so there's no question to ask).
185
203
  */
186
204
  noWizardPrompt?: boolean;
205
+ /**
206
+ * Canonical public hub origin (the OAuth issuer / `iss` claim). Persisted to
207
+ * `hub_settings.hub_origin` BEFORE the hub unit starts + modules spawn, so
208
+ * supervised vault/scribe come up with the public origin already in their
209
+ * `PARACHUTE_HUB_ORIGINS` set in ONE pass — no restart needed. The headline
210
+ * use case is the zero-SSH Caddy-direct path: a cloud-init script passes
211
+ * `--hub-origin https://<droplet-ip>.sslip.io` so a reverse-proxied box
212
+ * records its public origin without an admin browser session. Validated +
213
+ * canonicalized by the CLI before it reaches here; persisted via the
214
+ * `setHubOriginImpl` seam. Loopback / non-http(s) values never reach this far
215
+ * (the CLI rejects them via `validateHubOrigin`). Absent → no-op (the laptop /
216
+ * loopback path is unchanged).
217
+ */
218
+ hubOrigin?: string;
219
+ /**
220
+ * Test seam: persist the canonical hub origin to `hub_settings.hub_origin`.
221
+ * Production opens `hub.db` (running migrations) and calls `setHubOrigin`.
222
+ * Tests pass a stub to assert the write happens BEFORE `ensureHub` without
223
+ * touching the operator's live DB. Receives the resolved configDir + origin.
224
+ */
225
+ setHubOriginImpl?: (configDir: string, origin: string) => void;
187
226
  /**
188
227
  * Test seam: probe the running hub for its first-claim bootstrap token
189
228
  * (hub#576). Production hits `GET http://127.0.0.1:<port>/admin/setup` with
@@ -391,6 +430,25 @@ async function defaultEnsureHubViaUnit(opts: EnsureHubOpts): Promise<{
391
430
  throw new Error(result.messages.join("\n") || `hub unit bringup failed (${result.outcome}).`);
392
431
  }
393
432
 
433
+ /**
434
+ * Default impl for the `--hub-origin` persistence step. Opens `hub.db` (running
435
+ * migrations on a fresh box) and writes `hub_settings.hub_origin`. Runs BEFORE
436
+ * the hub unit starts so the boot-time issuer resolution + child-spawn env
437
+ * (`PARACHUTE_HUB_ORIGINS`) pick up the public origin in one pass. The DB is an
438
+ * on-disk SQLite file shared with the (not-yet-started) serve process, so a
439
+ * write here is visible to the unit when it boots. The CLI has already
440
+ * validated + canonicalized the URL via `validateHubOrigin`, so this trusts the
441
+ * input (mirrors `setHubOrigin`'s typed-callsite contract).
442
+ */
443
+ function defaultSetHubOrigin(configDir: string, origin: string): void {
444
+ const db = openHubDb(hubDbPath(configDir));
445
+ try {
446
+ setHubOrigin(db, origin);
447
+ } finally {
448
+ db.close();
449
+ }
450
+ }
451
+
394
452
  /**
395
453
  * Resolve the issuer to mint the operator token under. At init time the hub is
396
454
  * reachable on loopback (just installed); prefer the live expose-state origin
@@ -468,8 +526,18 @@ async function defaultGuaranteeOperatorToken(ctx: {
468
526
  * shim that re-emits each line under an `[install vault] ` prefix so the
469
527
  * init log stays grep-able. Idempotent — `install` short-circuits the
470
528
  * bun-add when vault is already linked / installed.
529
+ *
530
+ * The `channel` arg (hub#694) forwards into `install()`'s `channel` option so
531
+ * an rc-channel box installs `@openparachute/vault@rc` instead of `@latest` —
532
+ * otherwise init silently DOWNGRADES vault below the rc-tracking hub. Undefined
533
+ * → install's own resolution (`--tag` > `channel` > `PARACHUTE_INSTALL_CHANNEL`
534
+ * env > `"latest"`) applies, preserving today's behavior.
471
535
  */
472
- async function defaultInstallVaultModule(configDir: string, manifestPath: string): Promise<number> {
536
+ async function defaultInstallVaultModule(
537
+ configDir: string,
538
+ manifestPath: string,
539
+ channel?: "latest" | "rc",
540
+ ): Promise<number> {
473
541
  const installOpts: InstallOpts = {
474
542
  configDir,
475
543
  manifestPath,
@@ -477,6 +545,7 @@ async function defaultInstallVaultModule(configDir: string, manifestPath: string
477
545
  noStart: true,
478
546
  log: (line) => console.log(`[install vault] ${line}`),
479
547
  };
548
+ if (channel) installOpts.channel = channel;
480
549
  return await defaultInstall("vault", installOpts);
481
550
  }
482
551
 
@@ -596,6 +665,32 @@ async function promptExposeChoice(
596
665
  return defaultChoice;
597
666
  }
598
667
 
668
+ /**
669
+ * Resolve the install channel for init's vault module step (hub#694 bug 2).
670
+ *
671
+ * Precedence (highest → lowest):
672
+ * 1. explicit `--channel rc|latest` (parsed in cli.ts → `opts.channel`)
673
+ * 2. `PARACHUTE_CHANNEL` env (the DigitalOcean cloud-init script's var)
674
+ * 3. `PARACHUTE_INSTALL_CHANNEL` env (the install layer's own platform cascade)
675
+ * 4. `undefined` → `install()` falls back to its own "latest" default
676
+ *
677
+ * Returns `"latest"` / `"rc"` when one is resolved, or `undefined` to defer to
678
+ * `install()`'s resolution. A non-`rc`/`latest` env value is ignored (returns
679
+ * undefined) so a typo degrades to "latest" rather than crashing init — the
680
+ * same forgiving posture `resolveInstallChannel` takes for the install command.
681
+ */
682
+ export function resolveInitChannel(
683
+ explicit: "latest" | "rc" | undefined,
684
+ env: NodeJS.ProcessEnv,
685
+ ): "latest" | "rc" | undefined {
686
+ if (explicit === "rc" || explicit === "latest") return explicit;
687
+ for (const key of ["PARACHUTE_CHANNEL", "PARACHUTE_INSTALL_CHANNEL"]) {
688
+ const v = env[key];
689
+ if (v === "rc" || v === "latest") return v;
690
+ }
691
+ return undefined;
692
+ }
693
+
599
694
  export async function init(opts: InitOpts = {}): Promise<number> {
600
695
  const configDir = opts.configDir ?? CONFIG_DIR;
601
696
  const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
@@ -627,12 +722,86 @@ export async function init(opts: InitOpts = {}): Promise<number> {
627
722
  const exposeTailnetImpl = opts.exposeTailnetImpl ?? defaultExposeTailnet;
628
723
  const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
629
724
  const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
725
+ // hub#694 bug 2: resolve the channel for the vault module install. Precedence:
726
+ // explicit `--channel <v>` (opts.channel) > `PARACHUTE_CHANNEL` /
727
+ // `PARACHUTE_INSTALL_CHANNEL` env > undefined (install's own "latest"
728
+ // fallback). The env fallback is what makes the DigitalOcean cloud-init
729
+ // script's `PARACHUTE_CHANNEL=rc` cascade into init's vault install with zero
730
+ // extra flags — init never received a `--channel` from that script, but it
731
+ // reads the env the script already exports. A garbage env value falls through
732
+ // to undefined → install resolves "latest" (matching resolveInstallChannel's
733
+ // own garbage-handling), so an operator typo can't break init.
734
+ const installChannel: "latest" | "rc" | undefined = resolveInitChannel(opts.channel, env);
630
735
  const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
631
736
  const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
737
+ const setHubOriginImpl = opts.setHubOriginImpl ?? defaultSetHubOrigin;
632
738
 
633
739
  log("Parachute init — getting your hub set up.");
634
740
  log("");
635
741
 
742
+ // Step 0: persist `--hub-origin` BEFORE the hub unit starts (below). The
743
+ // boot-time issuer resolution + child-spawn env (`PARACHUTE_HUB_ORIGINS`)
744
+ // both read `hub_settings.hub_origin`, so writing it now means vault/scribe
745
+ // come up with the public origin already in their accepted-`iss` set in ONE
746
+ // pass — no restart needed (the Caddy-direct zero-SSH path). A failure here
747
+ // is non-fatal: warn + continue (the operator can re-run `parachute hub
748
+ // set-origin` later), since init's contract is hub up → wizard regardless.
749
+ if (opts.hubOrigin) {
750
+ try {
751
+ setHubOriginImpl(configDir, opts.hubOrigin);
752
+ log(`✓ Canonical hub origin set to ${opts.hubOrigin} (OAuth issuer).`);
753
+ log("");
754
+ } catch (err) {
755
+ const detail = err instanceof Error ? err.message : String(err);
756
+ log(
757
+ `⚠ Couldn't persist the hub origin (${detail}); set it later with \`parachute hub set-origin <url>\`.`,
758
+ );
759
+ log("");
760
+ }
761
+ }
762
+
763
+ // Step 0.5 (hub#694 bug 1 — the spawn race): install + seed the vault module
764
+ // into services.json BEFORE the hub unit starts (Step 1 below). The hub unit's
765
+ // `serve` runs the in-process Supervisor, which scans services.json EXACTLY
766
+ // ONCE at boot (`bootSupervisedModules`) and never rescans. If we seed vault
767
+ // AFTER the unit boots (the old Step 2.5 ordering), that single scan reads a
768
+ // services.json with no vault row → vault is registered-but-never-spawned, so
769
+ // `/vault/*` 502s until a manual `parachute restart` re-triggers a per-module
770
+ // start. On a slow box (1GB droplet) the scan reliably wins that race. Seeding
771
+ // first means the boot scan finds vault and spawns it on the first pass — no
772
+ // restart needed.
773
+ //
774
+ // `install("vault", { noCreate: true, noStart: true, … })` only does the
775
+ // on-disk work — `bun add -g` (idempotent; short-circuits when vault is
776
+ // bun-linked / already installed) + an `upsertService` seed write. It does NOT
777
+ // need a running hub: the start path, the stale-unit sweep, and the
778
+ // supervised-hub guidance probe are all gated off under `noCreate`/`noStart`,
779
+ // so running it before the unit exists is safe. The wizard's vault step still
780
+ // owns Create / Import / Skip (noCreate defers first-vault creation); the
781
+ // supervisor (not install.ts) owns spawning (noStart).
782
+ //
783
+ // Idempotent: if a vault row already exists (re-run, or a prior install), this
784
+ // short-circuits past the bun-add and the row is left intact. We don't block
785
+ // init on a non-zero exit — the wizard can retry from /admin/setup.
786
+ const findVaultEntry = (): boolean => {
787
+ try {
788
+ return findService("parachute-vault", manifestPath) !== undefined;
789
+ } catch {
790
+ return false;
791
+ }
792
+ };
793
+ const vaultAlreadyInstalled = findVaultEntry();
794
+ if (!vaultAlreadyInstalled) {
795
+ log("Installing the vault module so the wizard can offer create / import / skip…");
796
+ const installCode = await installVaultModuleImpl(configDir, manifestPath, installChannel);
797
+ if (installCode !== 0) {
798
+ log(
799
+ `⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
800
+ );
801
+ }
802
+ log("");
803
+ }
804
+
636
805
  // Step 1: hub running?
637
806
  // NB: under the Phase 3a unit-managed hub there is no pidfile, so
638
807
  // `processState(HUB_SVC)` reports not-running on EVERY init re-run even when
@@ -768,41 +937,14 @@ export async function init(opts: InitOpts = {}): Promise<number> {
768
937
  }
769
938
  }
770
939
 
771
- // Step 2.5: always install the vault module (hub#168 Cut 1). Aaron's
772
- // 2026-05-28 directive: "it should always install the vault module"
773
- // even though "creating a vault should be optional." We split the
774
- // module install (always) from the first-vault create (deferred to
775
- // the wizard) by passing `noCreate: true` to installbun add -g
776
- // runs, services.json gets seeded, but `parachute-vault init` (which
777
- // would auto-create a `default` vault) is skipped. The wizard's
778
- // vault step then either Creates / Imports / Skips.
779
- //
780
- // Idempotent: install short-circuits the bun-add when vault is
781
- // already linked (`bun link`) or already globally installed. If the
782
- // operator already has a vault row, this is a no-op past the
783
- // already-installed log line. We don't block init on this step;
784
- // a non-zero exit code is logged but treated as a warning, since the
785
- // wizard can re-attempt the install itself from /admin/setup.
786
- const findVaultEntry = (): boolean => {
787
- try {
788
- return findService("parachute-vault", manifestPath) !== undefined;
789
- } catch {
790
- return false;
791
- }
792
- };
793
- const vaultAlreadyInstalled = findVaultEntry();
794
- if (!vaultAlreadyInstalled) {
795
- log("");
796
- log("Installing the vault module so the wizard can offer create / import / skip…");
797
- const installCode = await installVaultModuleImpl(configDir, manifestPath);
798
- if (installCode !== 0) {
799
- log(
800
- `⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
801
- );
802
- }
803
- }
940
+ // (The vault module install + seed now runs at Step 0.5, BEFORE the hub unit
941
+ // starts see hub#694 bug 1. It used to live here, after exposure; moving it
942
+ // ahead of Step 1's hub bringup is what lets the supervisor's one-time boot
943
+ // scan find + spawn vault instead of registering it after the scan already
944
+ // ran. The "always install the vault module" directiveAaron 2026-05-28,
945
+ // hub#168 Cut 1 and the noCreate/noStart split are unchanged.)
804
946
 
805
- // Step 3: vault configured? (After the module install above, this may
947
+ // Step 3: vault configured? (After the Step 0.5 module install above, this may
806
948
  // have flipped from false to true on a fresh box. The wizard reads
807
949
  // services.json on every request, so the "configured" answer here is
808
950
  // best-effort — it only shapes the next-step log message below.)
@@ -37,13 +37,14 @@ import { readExposeState } from "../expose-state.ts";
37
37
  import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../hub-db-liveness.ts";
38
38
  import { hubDbPath, openHubDb } from "../hub-db.ts";
39
39
  import { hubFetch } from "../hub-server.ts";
40
+ import { getHubOrigin } from "../hub-settings.ts";
40
41
  import { writeHubFile } from "../hub.ts";
41
- import { createWsBridgeHandlers } from "../ws-bridge.ts";
42
42
  import { enrichedPath } from "../spawn-path.ts";
43
43
  import { Supervisor } from "../supervisor.ts";
44
44
  import { createUser, userCount } from "../users.ts";
45
45
  import { sanitizePublicOrigin } from "../vault-hub-origin-env.ts";
46
46
  import { WELL_KNOWN_DIR } from "../well-known.ts";
47
+ import { createWsBridgeHandlers } from "../ws-bridge.ts";
47
48
  import { bootSupervisedModules } from "./serve-boot.ts";
48
49
 
49
50
  /**
@@ -217,12 +218,30 @@ function parsePort(raw: string | undefined): number | undefined {
217
218
  * remaining gap (rebuild the live spawn-env on `supervisor.restart` so the
218
219
  * first exposure propagates to already-running children without a manual
219
220
  * restart) is the deferred #532 follow-up; not implemented in this PR.
221
+ *
222
+ * DB ORIGIN, highest precedence (onboarding-streamline 2026-06-25, Caddy-direct
223
+ * zero-SSH path): the operator-set `hub_settings.hub_origin` is the tier-1 layer
224
+ * in the per-request `resolveIssuer` (hub-server.ts). The boot path MUST honor
225
+ * it too — otherwise a Caddy-fronted box whose ONLY canonical-origin source is
226
+ * the DB row (no `PARACHUTE_HUB_ORIGIN` env, no expose-state, no platform var)
227
+ * boots `serve` with no issuer, injects only loopback aliases into supervised
228
+ * children's `PARACHUTE_HUB_ORIGINS`, and vault/scribe then reject every token
229
+ * the hub mints under the public origin (which per-request `resolveIssuer` DOES
230
+ * stamp from the DB). Threading the DB origin in as the top input keeps the
231
+ * boot-time seed in lockstep with `resolveIssuer`'s tier-1. `serve()` reads the
232
+ * row off the opened hub DB and passes it as `dbHubOrigin`; tests pass it
233
+ * directly. It is sanitized like any other public-origin input (a stray
234
+ * loopback / non-http(s) DB value never pins the issuer).
220
235
  */
221
236
  export function resolveStartupIssuer(
222
- opts: { issuer?: string },
237
+ opts: { issuer?: string; dbHubOrigin?: string },
223
238
  env: NodeJS.ProcessEnv,
224
239
  readExpose: () => string | undefined = defaultReadExposeHubOrigin,
225
240
  ): string | undefined {
241
+ // DB row first — mirrors `resolveIssuer`'s tier-1 (hub_settings.hub_origin).
242
+ // Sanitized so a stray loopback / non-http(s) value never pins the issuer.
243
+ const dbOrigin = sanitizePublicOrigin(opts.dbHubOrigin);
244
+ if (dbOrigin) return dbOrigin;
226
245
  const flyOrigin = flyDefaultOriginFromEnv(env);
227
246
  const explicit = (
228
247
  opts.issuer ??
@@ -435,7 +454,6 @@ export async function serve(opts: ServeOpts = {}): Promise<{
435
454
 
436
455
  const envPort = parsePort(env.PORT);
437
456
  const port = opts.port ?? envPort ?? DEFAULT_PORT;
438
- const issuer = resolveStartupIssuer(opts, env);
439
457
  // Containers default to 0.0.0.0 so the platform's HTTP forwarder can
440
458
  // reach us; the `--hostname` flag / PARACHUTE_BIND_HOST is the escape
441
459
  // hatch for setups that want loopback-only inside a sidecar.
@@ -461,6 +479,24 @@ export async function serve(opts: ServeOpts = {}): Promise<{
461
479
  const dbPath = hubDbPath();
462
480
  // Self-heal-or-die DB holder (#594) + proactive ghost-fd watchdog (#610/#619).
463
481
  const { dbHolder, livenessTimer } = armServeDbWatchdog(dbPath, { log });
482
+ // Resolve the boot-time issuer AFTER the DB is open so the operator-set
483
+ // `hub_settings.hub_origin` (tier-1 in `resolveIssuer`) seeds the value the
484
+ // hub mints with AND the `PARACHUTE_HUB_ORIGINS` set injected into supervised
485
+ // children. This is what makes a Caddy-fronted, zero-SSH box (whose only
486
+ // canonical-origin source is the DB row) inject the public origin into
487
+ // vault/scribe so they accept hub-minted tokens. Pass it as `dbHubOrigin`;
488
+ // a malformed read can't crash startup — `getHubOrigin` is a plain SELECT
489
+ // and `resolveStartupIssuer` sanitizes the value.
490
+ let dbHubOrigin: string | undefined;
491
+ try {
492
+ dbHubOrigin = getHubOrigin(dbHolder.get()) ?? undefined;
493
+ } catch {
494
+ dbHubOrigin = undefined;
495
+ }
496
+ const issuer = resolveStartupIssuer(
497
+ { ...opts, ...(dbHubOrigin !== undefined ? { dbHubOrigin } : {}) },
498
+ env,
499
+ );
464
500
  const adminBootstrap = await seedInitialAdminIfNeeded(dbHolder.get(), env, log);
465
501
 
466
502
  if (adminBootstrap === "needs-setup") {
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,8 @@ export function initHelp(): string {
130
132
  Usage:
131
133
  parachute init [--no-browser] [--no-expose-prompt]
132
134
  [--expose none|tailnet|cloudflare]
135
+ [--channel rc|latest]
136
+ [--hub-origin <url>]
133
137
  [--cli-wizard | --browser-wizard]
134
138
 
135
139
  What it does:
@@ -165,6 +169,15 @@ Flags:
165
169
  none — stay loopback-only
166
170
  tailnet — set up Tailscale serve (private to your tailnet)
167
171
  cloudflare — set up Cloudflare Tunnel (your own domain)
172
+ --channel <rc|latest> npm dist-tag for the vault module install (default: latest).
173
+ Use \`rc\` on an rc-channel box so init doesn't downgrade
174
+ vault below the hub. Also honors PARACHUTE_CHANNEL /
175
+ PARACHUTE_INSTALL_CHANNEL env when the flag is absent.
176
+ --hub-origin <url> set the canonical public origin (OAuth issuer) BEFORE
177
+ the hub + modules start, so vault/scribe come up
178
+ accepting it in one pass. For reverse-proxy /
179
+ Caddy-direct boxes that bind loopback but are reached
180
+ over a public HTTPS URL (e.g. https://<ip>.sslip.io).
168
181
  --cli-wizard skip the "browser or CLI?" prompt and walk the wizard
169
182
  in this terminal (hub#168 Cut 4)
170
183
  --browser-wizard skip the prompt and open the browser wizard directly
@@ -177,6 +190,7 @@ Examples:
177
190
  parachute init --expose tailnet # CI/scripted: chain straight into Tailscale
178
191
  parachute init --no-browser # don't shell out to open / xdg-open
179
192
  parachute init --cli-wizard # walk the wizard in this terminal (hub#168)
193
+ parachute init --channel rc # rc box: install the vault module from @rc
180
194
  `;
181
195
  }
182
196
 
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 —