@openparachute/hub 0.6.3 → 0.6.4-rc.10
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 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -51,8 +51,9 @@ import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
|
|
|
51
51
|
import { deriveHubOrigin } from "../hub-origin.ts";
|
|
52
52
|
import { HUB_UNIT_DEFAULT_PORT } from "../hub-unit.ts";
|
|
53
53
|
import { type AliveFn, defaultAlive } from "../process-state.ts";
|
|
54
|
-
import {
|
|
54
|
+
import { readManifestLenient } from "../services-manifest.ts";
|
|
55
55
|
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
56
|
+
import { clearVaultHubOrigin } from "../vault-hub-origin-env.ts";
|
|
56
57
|
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
57
58
|
import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
|
|
58
59
|
import {
|
|
@@ -91,6 +92,22 @@ export function isValidHostname(h: string): boolean {
|
|
|
91
92
|
return h.split(".").every((label) => labelRe.test(label));
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Best-effort "is this module registered in services.json?" check, used only
|
|
97
|
+
* for the courtesy note in the Cloudflare expose path (hub#564). Uses the
|
|
98
|
+
* LENIENT manifest reader on purpose: the strict `readManifest` can reject an
|
|
99
|
+
* older manifest shape (the #564 diagnostic — a manifest written by 0.6.3-era
|
|
100
|
+
* registration that the strict reader bounced), and a courtesy note must never
|
|
101
|
+
* throw. Any read error is swallowed and treated as "not installed".
|
|
102
|
+
*/
|
|
103
|
+
function serviceInstalled(manifestPath: string, name: string): boolean {
|
|
104
|
+
try {
|
|
105
|
+
return readManifestLenient(manifestPath).services.some((s) => s.name === name);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
export interface CloudflaredSpawner {
|
|
95
112
|
spawn(cmd: readonly string[], logFile: string): number;
|
|
96
113
|
}
|
|
@@ -606,12 +623,18 @@ export async function exposeCloudflareUp(
|
|
|
606
623
|
return 1;
|
|
607
624
|
}
|
|
608
625
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
626
|
+
// No vault gate here (hub#564). cloudflared's ingress targets the HUB port
|
|
627
|
+
// (see `servicePort: hubPort` in `writeConfig` below + the long comment
|
|
628
|
+
// there) — the hub does ALL routing (discovery, admin, OAuth, well-known,
|
|
629
|
+
// per-vault proxy, generic /<svc>/* dispatch). Vault doesn't have to be
|
|
630
|
+
// installed for the tunnel to route anything. The old gate
|
|
631
|
+
// (`parachute-vault is not installed; nothing to route` → return 1) was a
|
|
632
|
+
// vestige of the pre-2026-05-27 vault-centric ingress, and it dead-ended
|
|
633
|
+
// fresh-server init: post-#168 init exposes (Step 2) BEFORE it installs the
|
|
634
|
+
// vault module (Step 2.5), so the gate always tripped on a fresh box and
|
|
635
|
+
// aborted the whole init. Courtesy note only — never block.
|
|
636
|
+
if (!serviceInstalled(r.manifestPath, "parachute-vault")) {
|
|
637
|
+
r.log("vault not installed yet — the admin wizard will set it up. Routing the hub anyway.");
|
|
615
638
|
}
|
|
616
639
|
|
|
617
640
|
// Resolve the public hub origin before spawning the hub server — it gets
|
|
@@ -897,45 +920,58 @@ export async function exposeCloudflareUp(
|
|
|
897
920
|
// and makes the running vault re-read it immediately rather than waiting for
|
|
898
921
|
// the next reboot.
|
|
899
922
|
//
|
|
923
|
+
// hub#564: vault may not be installed yet (init exposes BEFORE installing the
|
|
924
|
+
// vault module). Resolve the entry once, here, and gate the vault-restart +
|
|
925
|
+
// the Vault-URL footer on it — restarting a not-installed vault would just
|
|
926
|
+
// fail and print a spurious warning. When present, a well-formed manifest
|
|
927
|
+
// always lists at least one mount path.
|
|
928
|
+
const vaultEntry = (() => {
|
|
929
|
+
try {
|
|
930
|
+
return readManifestLenient(r.manifestPath).services.find((s) => s.name === "parachute-vault");
|
|
931
|
+
} catch {
|
|
932
|
+
return undefined;
|
|
933
|
+
}
|
|
934
|
+
})();
|
|
935
|
+
|
|
900
936
|
// §4.3c: drive the restart through the running Supervisor
|
|
901
937
|
// (`driveModuleOp("vault", "restart")`), which re-injects the hub's current
|
|
902
938
|
// origin; `restartHubDependentViaSupervisor` also persists the durable `.env`
|
|
903
939
|
// + self-heals the operator-token issuer. Phase 5b retired the detached
|
|
904
|
-
// `lifecycle.restart` arm.
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
940
|
+
// `lifecycle.restart` arm. Skipped entirely when vault isn't installed.
|
|
941
|
+
if (vaultEntry) {
|
|
942
|
+
r.log("");
|
|
943
|
+
r.log("Restarting vault to pick up new hub origin…");
|
|
944
|
+
const rcode = await restartHubDependentViaSupervisor({
|
|
945
|
+
short: "vault",
|
|
946
|
+
hubOrigin,
|
|
947
|
+
configDir: r.configDir,
|
|
948
|
+
sup: r.sup,
|
|
949
|
+
log: r.log,
|
|
950
|
+
});
|
|
951
|
+
if (rcode !== 0) {
|
|
952
|
+
r.log(
|
|
953
|
+
"⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
|
|
954
|
+
);
|
|
955
|
+
}
|
|
918
956
|
}
|
|
919
957
|
|
|
920
958
|
const baseUrl = `https://${hostname}`;
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
959
|
+
let vaultUrl: string | undefined;
|
|
960
|
+
if (vaultEntry) {
|
|
961
|
+
if (!vaultEntry.paths[0]) {
|
|
962
|
+
r.log(
|
|
963
|
+
`⚠ vault entry in services.json has no paths[]; defaulting to "/vault/default". Check the manifest.`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
vaultUrl = `${baseUrl}${vaultEntry.paths[0] ?? "/vault/default"}`;
|
|
929
967
|
}
|
|
930
|
-
const vaultMount = vaultEntry.paths[0] ?? "/vault/default";
|
|
931
|
-
const vaultUrl = `${baseUrl}${vaultMount}`;
|
|
932
968
|
|
|
933
969
|
r.log("");
|
|
934
970
|
r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
|
|
935
971
|
r.log(` Tunnel: ${r.tunnelName} (dedicated to this machine)`);
|
|
936
972
|
r.log(` Open: ${baseUrl}/`);
|
|
937
973
|
r.log(` Admin: ${baseUrl}/admin/`);
|
|
938
|
-
r.log(` Vault: ${vaultUrl}`);
|
|
974
|
+
if (vaultUrl) r.log(` Vault: ${vaultUrl}`);
|
|
939
975
|
r.log(` OAuth: ${hubOrigin}`);
|
|
940
976
|
r.log(` Logs: ${r.logPath}`);
|
|
941
977
|
r.log("");
|
|
@@ -954,10 +990,14 @@ export async function exposeCloudflareUp(
|
|
|
954
990
|
r.log("background but does NOT survive a reboot. After a reboot, re-run:");
|
|
955
991
|
r.log(` parachute expose public --cloudflare --domain ${hostname}`);
|
|
956
992
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
993
|
+
// The connector guidance points at a vault MCP URL — only meaningful once a
|
|
994
|
+
// vault is installed (hub#564: it may not be yet, when init exposes first).
|
|
995
|
+
if (vaultUrl) {
|
|
996
|
+
r.log("");
|
|
997
|
+
r.log("Point a claude.ai / ChatGPT connector at:");
|
|
998
|
+
r.log(` ${vaultUrl}`);
|
|
999
|
+
printAuthGuidance(r.log, vaultUrl);
|
|
1000
|
+
}
|
|
961
1001
|
// 2FA-enrollment warning when /admin/login is now reachable on the public
|
|
962
1002
|
// internet but the operator hasn't enrolled TOTP. Cloudflare exposure is
|
|
963
1003
|
// always public; tailnet/funnel mirrors this in `expose.ts`. See #186.
|
|
@@ -1098,8 +1138,35 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
|
|
|
1098
1138
|
// downstream consumers stop resolving the now-dead public URL (mirrors the
|
|
1099
1139
|
// up-path write above + the Tailscale off-path's expose-state teardown). When
|
|
1100
1140
|
// other tunnels survive we leave it — a later off for the last one clears it.
|
|
1141
|
+
//
|
|
1142
|
+
// TODO(multi-tunnel) #588: with TWO CF tunnels up, tearing down the
|
|
1143
|
+
// last-written-up one (whose hostname is what's in vault's `.env`) while the
|
|
1144
|
+
// other survives leaves `.env` carrying the dead tunnel's origin while the
|
|
1145
|
+
// surviving tunnel serves a different one → stale-iss on the next vault
|
|
1146
|
+
// restart. Retention is still the only SAFE choice here: a single
|
|
1147
|
+
// `PARACHUTE_HUB_ORIGIN` field can't represent "which surviving tunnel wins,"
|
|
1148
|
+
// and clearing it would break the survivor's iss check. Properly fixing it
|
|
1149
|
+
// needs re-resolving the effective origin from the survivor (or multi-origin
|
|
1150
|
+
// issuer acceptance vault-side) — larger than the #503 single-tunnel fix, and
|
|
1151
|
+
// multi-CF-tunnel-on-one-box is rare. See #588.
|
|
1101
1152
|
if (!state) {
|
|
1102
1153
|
clearExposeState(r.exposeStatePath);
|
|
1154
|
+
// Drop the persisted PARACHUTE_HUB_ORIGIN from vault's `.env` (#503). With
|
|
1155
|
+
// the last Cloudflare tunnel gone, the hub is loopback-only and mints
|
|
1156
|
+
// loopback-`iss` tokens; a stale public origin left in `vault/.env` would
|
|
1157
|
+
// pin a public expected issuer and 401 every request on the next vault
|
|
1158
|
+
// daemon restart ("not signed in to the hub" — the inverse of the bug
|
|
1159
|
+
// selfHealVaultHubOrigin closed). This mirrors exactly what the Tailscale
|
|
1160
|
+
// off-path does (`exposeOff` in expose.ts) — the Cloudflare path had been
|
|
1161
|
+
// the asymmetric gap. expose-state's own `hubOrigin` is cleared above via
|
|
1162
|
+
// clearExposeState, so hub's per-request `resolveIssuer`/`exposeIssuerOrigin`
|
|
1163
|
+
// (which read expose-state) also stop minting the public iss after teardown.
|
|
1164
|
+
// No restart needed for the gap this closes — the next vault restart picks
|
|
1165
|
+
// up the cleared `.env` — but tell the operator so an already-running vault
|
|
1166
|
+
// doesn't keep validating against the now-dead public origin.
|
|
1167
|
+
if (clearVaultHubOrigin(r.configDir, r.log)) {
|
|
1168
|
+
r.log(" Restart vault to apply the loopback issuer now: `parachute restart vault`.");
|
|
1169
|
+
}
|
|
1103
1170
|
}
|
|
1104
1171
|
return failed ? 1 : 0;
|
|
1105
1172
|
}
|
|
@@ -14,9 +14,16 @@ import { createInterface } from "node:readline/promises";
|
|
|
14
14
|
import {
|
|
15
15
|
DEFAULT_CLOUDFLARED_HOME,
|
|
16
16
|
cloudflaredInstallHint,
|
|
17
|
+
cloudflaredLinuxDownloadUrl,
|
|
17
18
|
isCloudflaredInstalled,
|
|
18
19
|
isCloudflaredLoggedIn,
|
|
19
20
|
} from "../cloudflare/detect.ts";
|
|
21
|
+
import {
|
|
22
|
+
CLOUDFLARED_STATE_PATH,
|
|
23
|
+
clearPendingHostname,
|
|
24
|
+
readPendingHostname,
|
|
25
|
+
writePendingHostname,
|
|
26
|
+
} from "../cloudflare/state.ts";
|
|
20
27
|
import {
|
|
21
28
|
EXPOSE_LAST_PROVIDER_PATH,
|
|
22
29
|
type ExposeProvider,
|
|
@@ -73,6 +80,19 @@ export interface ExposeInteractiveOpts {
|
|
|
73
80
|
prompt?: (question: string) => Promise<string>;
|
|
74
81
|
cloudflaredHome?: string;
|
|
75
82
|
platform?: NodeJS.Platform;
|
|
83
|
+
/** Test seam: `process.arch` — drives the Linux cloudflared download URL. */
|
|
84
|
+
arch?: NodeJS.Architecture;
|
|
85
|
+
/**
|
|
86
|
+
* Test seam: `process.getuid` — root (uid 0) can write
|
|
87
|
+
* /usr/local/bin/cloudflared directly; non-root needs `sudo`. Defaults to
|
|
88
|
+
* `process.getuid` (undefined on platforms without it → treated non-root).
|
|
89
|
+
*/
|
|
90
|
+
getuid?: () => number;
|
|
91
|
+
/**
|
|
92
|
+
* Path to cloudflared-state.json (hub#567 pending-hostname persistence).
|
|
93
|
+
* Defaults to the canonical `CLOUDFLARED_STATE_PATH`.
|
|
94
|
+
*/
|
|
95
|
+
statePath?: string;
|
|
76
96
|
lastProviderPath?: string;
|
|
77
97
|
now?: () => Date;
|
|
78
98
|
log?: (line: string) => void;
|
|
@@ -110,6 +130,9 @@ interface Resolved {
|
|
|
110
130
|
prompt: (question: string) => Promise<string>;
|
|
111
131
|
cloudflaredHome: string;
|
|
112
132
|
platform: NodeJS.Platform;
|
|
133
|
+
arch: NodeJS.Architecture;
|
|
134
|
+
getuid: () => number;
|
|
135
|
+
statePath: string;
|
|
113
136
|
lastProviderPath: string;
|
|
114
137
|
now: () => Date;
|
|
115
138
|
log: (line: string) => void;
|
|
@@ -129,6 +152,10 @@ function resolve(opts: ExposeInteractiveOpts): Resolved {
|
|
|
129
152
|
prompt: opts.prompt ?? defaultPrompt,
|
|
130
153
|
cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
|
|
131
154
|
platform: opts.platform ?? process.platform,
|
|
155
|
+
arch: opts.arch ?? process.arch,
|
|
156
|
+
// `process.getuid` is absent on Windows; treat missing as non-root (uid 1).
|
|
157
|
+
getuid: opts.getuid ?? (() => process.getuid?.() ?? 1),
|
|
158
|
+
statePath: opts.statePath ?? CLOUDFLARED_STATE_PATH,
|
|
132
159
|
lastProviderPath: opts.lastProviderPath ?? EXPOSE_LAST_PROVIDER_PATH,
|
|
133
160
|
now: opts.now ?? (() => new Date()),
|
|
134
161
|
log: opts.log ?? ((line) => console.log(line)),
|
|
@@ -185,10 +212,25 @@ async function promptHostname(r: Resolved): Promise<string | undefined> {
|
|
|
185
212
|
r.log("");
|
|
186
213
|
r.log("Cloudflare needs a hostname under a domain you've added to your Cloudflare account.");
|
|
187
214
|
r.log('Example: vault.example.com (apex "example.com" must be a Cloudflare zone)');
|
|
215
|
+
// hub#567: pre-fill with a hostname the operator typed on a prior (failed)
|
|
216
|
+
// run so a retry is "press Enter", not "redo the whole interview".
|
|
217
|
+
const pending = readPendingHostname(r.statePath);
|
|
218
|
+
const promptText = pending
|
|
219
|
+
? `Hostname [${pending}] (or blank to quit): `
|
|
220
|
+
: "Hostname (or blank to quit): ";
|
|
188
221
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
189
|
-
const raw = (await r.prompt(
|
|
222
|
+
const raw = (await r.prompt(promptText)).trim();
|
|
223
|
+
// Enter on a pre-filled prompt accepts the stashed hostname.
|
|
224
|
+
if (raw === "" && pending) {
|
|
225
|
+
return pending;
|
|
226
|
+
}
|
|
190
227
|
if (raw === "") return undefined;
|
|
191
|
-
if (isValidHostname(raw))
|
|
228
|
+
if (isValidHostname(raw)) {
|
|
229
|
+
// Stash it the moment it validates so a downstream failure (cloudflared
|
|
230
|
+
// login, tunnel/DNS error) doesn't discard it. Cleared on success.
|
|
231
|
+
writePendingHostname(raw, r.statePath);
|
|
232
|
+
return raw;
|
|
233
|
+
}
|
|
192
234
|
r.log(`"${raw}" doesn't look like a hostname. Expected something like vault.example.com.`);
|
|
193
235
|
}
|
|
194
236
|
r.log("Too many invalid entries; aborting.");
|
|
@@ -238,11 +280,99 @@ function printTailscaleSetupGuidance(r: Resolved, readiness: ProviderAvailabilit
|
|
|
238
280
|
r.log("Once those are done, re-run: parachute expose public");
|
|
239
281
|
}
|
|
240
282
|
|
|
283
|
+
/**
|
|
284
|
+
* hub#566: offer to install cloudflared on Linux in place (instead of printing
|
|
285
|
+
* the command and bailing). The install is a single static-binary download
|
|
286
|
+
* (curl + chmod) we already know how to do.
|
|
287
|
+
*
|
|
288
|
+
* - Confirm with `Install cloudflared now? [Y/n]` (Enter accepts).
|
|
289
|
+
* - Run the curl into /usr/local/bin/cloudflared + chmod +x. Root writes
|
|
290
|
+
* directly; non-root wraps each step in `sudo -n` (non-interactive — only
|
|
291
|
+
* succeeds when sudo creds are already cached / passwordless). We use `-n`
|
|
292
|
+
* deliberately: init often runs detached/unattended on a fresh server (SSH
|
|
293
|
+
* + tmux, a cloud-init script), where a blocking interactive sudo password
|
|
294
|
+
* prompt would hang the whole flow. `-n` fails fast instead, and we fall
|
|
295
|
+
* back to printing the manual command.
|
|
296
|
+
* - Verify with `cloudflared --version`.
|
|
297
|
+
*
|
|
298
|
+
* Returns true only when cloudflared is on PATH afterward. On decline, missing
|
|
299
|
+
* download URL (unknown arch), or any install/verify failure, prints the
|
|
300
|
+
* canonical manual instructions + the `--cloudflare` re-run hint and returns
|
|
301
|
+
* false. Per hub#565 the caller's `false` does NOT abort init.
|
|
302
|
+
*/
|
|
303
|
+
async function offerLinuxCloudflaredInstall(r: Resolved): Promise<boolean> {
|
|
304
|
+
r.log("");
|
|
305
|
+
r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
|
|
306
|
+
const downloadUrl = cloudflaredLinuxDownloadUrl(r.arch);
|
|
307
|
+
|
|
308
|
+
const printManualAndBail = () => {
|
|
309
|
+
r.log("");
|
|
310
|
+
for (const line of cloudflaredInstallHint("linux", r.arch).split("\n")) r.log(line);
|
|
311
|
+
r.log("");
|
|
312
|
+
r.log("After install, re-run: parachute expose public --cloudflare");
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// No published artifact for this arch → can't auto-install; print + bail.
|
|
316
|
+
if (!downloadUrl) {
|
|
317
|
+
printManualAndBail();
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const answer = (await r.prompt("Install cloudflared now? [Y/n] ")).trim().toLowerCase();
|
|
322
|
+
if (answer === "n" || answer === "no") {
|
|
323
|
+
r.log("Skipped auto-install.");
|
|
324
|
+
printManualAndBail();
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const isRoot = r.getuid() === 0;
|
|
329
|
+
const dest = "/usr/local/bin/cloudflared";
|
|
330
|
+
// Root writes directly; non-root prefixes each privileged step with `sudo -n`
|
|
331
|
+
// (non-interactive). `-n` never prompts for a password: it exits non-zero
|
|
332
|
+
// when creds aren't cached, so a detached/unattended init (SSH + tmux, a
|
|
333
|
+
// cloud-init script) fails fast and falls back to the printed instructions
|
|
334
|
+
// rather than hanging on a password prompt nobody's there to answer.
|
|
335
|
+
const sudo = isRoot ? [] : ["sudo", "-n"];
|
|
336
|
+
const curlCmd = [...sudo, "curl", "-L", "-o", dest, downloadUrl];
|
|
337
|
+
const chmodCmd = [...sudo, "chmod", "+x", dest];
|
|
338
|
+
|
|
339
|
+
r.log("");
|
|
340
|
+
r.log(`Downloading cloudflared → ${dest} …`);
|
|
341
|
+
const curlCode = await r.interactiveRunner(curlCmd);
|
|
342
|
+
if (curlCode !== 0) {
|
|
343
|
+
r.log(`Download failed (exit ${curlCode}).`);
|
|
344
|
+
if (!isRoot) {
|
|
345
|
+
r.log(
|
|
346
|
+
"(`sudo -n` needs cached credentials; run `sudo -v` first, or use the commands below.)",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
printManualAndBail();
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
const chmodCode = await r.interactiveRunner(chmodCmd);
|
|
353
|
+
if (chmodCode !== 0) {
|
|
354
|
+
r.log(`chmod failed (exit ${chmodCode}).`);
|
|
355
|
+
printManualAndBail();
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!(await isCloudflaredInstalled(r.runner))) {
|
|
360
|
+
r.log("Install ran but `cloudflared` still isn't on PATH.");
|
|
361
|
+
r.log(
|
|
362
|
+
"Open a fresh shell (so PATH picks up the new binary), then re-run: parachute expose public --cloudflare",
|
|
363
|
+
);
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
r.log("✓ cloudflared installed.");
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
241
370
|
/**
|
|
242
371
|
* Walks the user through installing and logging in cloudflared. On macOS we
|
|
243
|
-
* auto-install via brew (with confirmation); on Linux we
|
|
244
|
-
*
|
|
245
|
-
*
|
|
372
|
+
* auto-install via brew (with confirmation); on Linux we auto-install the
|
|
373
|
+
* static binary (hub#566) with confirmation; everywhere else we print
|
|
374
|
+
* manual-install pointers and bail. Returns true only when cloudflared is
|
|
375
|
+
* both present and logged in afterwards.
|
|
246
376
|
*/
|
|
247
377
|
async function guideCloudflareSetup(
|
|
248
378
|
r: Resolved,
|
|
@@ -259,7 +389,11 @@ async function guideCloudflareSetup(
|
|
|
259
389
|
.trim()
|
|
260
390
|
.toLowerCase();
|
|
261
391
|
if (answer === "n" || answer === "no") {
|
|
262
|
-
|
|
392
|
+
// hub#566: re-run with `--cloudflare` (bare `expose public` defaults
|
|
393
|
+
// to Tailscale Funnel, the wrong provider for someone who chose CF).
|
|
394
|
+
r.log(
|
|
395
|
+
"Skipped auto-install. Install manually, then re-run: parachute expose public --cloudflare",
|
|
396
|
+
);
|
|
263
397
|
return false;
|
|
264
398
|
}
|
|
265
399
|
const code = await r.interactiveRunner(["brew", "install", "cloudflared"]);
|
|
@@ -273,20 +407,26 @@ async function guideCloudflareSetup(
|
|
|
273
407
|
r.log("Open a fresh shell (so PATH picks up the new binary) and re-run.");
|
|
274
408
|
return false;
|
|
275
409
|
}
|
|
410
|
+
} else if (r.platform === "linux") {
|
|
411
|
+
// hub#566: on Linux the install is a single static-binary download we
|
|
412
|
+
// already know how to do — offer to run it in place instead of dumping
|
|
413
|
+
// the operator back to a shell. Auto-install requires root (write to
|
|
414
|
+
// /usr/local/bin) or a working passwordless `sudo -n`. If we can't, or
|
|
415
|
+
// the operator declines, fall back to printing the instructions (and
|
|
416
|
+
// per hub#565 init continues regardless of the `false` return here).
|
|
417
|
+
installed = await offerLinuxCloudflaredInstall(r);
|
|
418
|
+
if (!installed) return false;
|
|
276
419
|
} else {
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// cloudflared` on Amazon Linux 2023 — and the
|
|
280
|
-
// pkg.cloudflare.com / developers.cloudflare.com paths the old hint
|
|
281
|
-
// pointed at now serve HTML/404. Defer to `cloudflaredInstallHint`,
|
|
282
|
-
// which writes the canonical GitHub-release static-binary path
|
|
283
|
-
// matching the host's architecture.
|
|
420
|
+
// Non-darwin/linux (e.g. Windows / misc): no auto-install path. Print
|
|
421
|
+
// the canonical pointer and bail (init continues per hub#565).
|
|
284
422
|
r.log("");
|
|
285
423
|
r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
|
|
286
424
|
r.log("");
|
|
287
|
-
for (const line of cloudflaredInstallHint(r.platform).split("\n")) r.log(line);
|
|
425
|
+
for (const line of cloudflaredInstallHint(r.platform, r.arch).split("\n")) r.log(line);
|
|
288
426
|
r.log("");
|
|
289
|
-
|
|
427
|
+
// hub#566: the bare `parachute expose public` defaults to Tailscale
|
|
428
|
+
// Funnel — an operator who chose Cloudflare must re-run with the flag.
|
|
429
|
+
r.log("After install, re-run: parachute expose public --cloudflare");
|
|
290
430
|
return false;
|
|
291
431
|
}
|
|
292
432
|
}
|
|
@@ -310,7 +450,7 @@ async function guideCloudflareSetup(
|
|
|
310
450
|
loggedIn = isCloudflaredLoggedIn(r.cloudflaredHome);
|
|
311
451
|
if (!loggedIn) {
|
|
312
452
|
r.log("Login ran but cert.pem didn't appear in ~/.cloudflared.");
|
|
313
|
-
r.log("Check the browser flow completed, then re-run: parachute expose public");
|
|
453
|
+
r.log("Check the browser flow completed, then re-run: parachute expose public --cloudflare");
|
|
314
454
|
return false;
|
|
315
455
|
}
|
|
316
456
|
}
|
|
@@ -391,7 +531,13 @@ export async function exposePublicInteractive(opts: ExposeInteractiveOpts = {}):
|
|
|
391
531
|
}
|
|
392
532
|
writeLastProvider("cloudflare", { path: r.lastProviderPath, now: r.now });
|
|
393
533
|
const code = await r.exposeCloudflareUpImpl(hostname, r.cloudflareOpts);
|
|
394
|
-
if (code === 0)
|
|
534
|
+
if (code === 0) {
|
|
535
|
+
// hub#567: routing succeeded — the tunnel record now carries the live
|
|
536
|
+
// hostname, so drop the pending one (a retry shouldn't pre-fill a
|
|
537
|
+
// hostname that's already exposed).
|
|
538
|
+
clearPendingHostname(r.statePath);
|
|
539
|
+
await runPreflightSafely(r);
|
|
540
|
+
}
|
|
395
541
|
return code;
|
|
396
542
|
}
|
|
397
543
|
|
|
@@ -16,15 +16,18 @@
|
|
|
16
16
|
* `expose-cloudflare.ts` (cloudflared) use so the two paths can't drift.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
19
20
|
import { readHubPort } from "../hub-control.ts";
|
|
20
21
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
21
22
|
import {
|
|
22
23
|
type EnsureHubUnitOpts,
|
|
23
24
|
type EnsureHubUnitResult,
|
|
25
|
+
type EnsureHubVersionMatchesResult,
|
|
24
26
|
HUB_UNIT_DEFAULT_PORT,
|
|
25
27
|
type HubUnitDeps,
|
|
26
28
|
defaultHubUnitDeps,
|
|
27
29
|
ensureHubUnit as ensureHubUnitImpl,
|
|
30
|
+
ensureHubVersionMatches as ensureHubVersionMatchesImpl,
|
|
28
31
|
} from "../hub-unit.ts";
|
|
29
32
|
import {
|
|
30
33
|
type DriveModuleOpDeps,
|
|
@@ -54,6 +57,17 @@ export interface ExposeSupervisorOpts {
|
|
|
54
57
|
hubUnitDeps?: HubUnitDeps;
|
|
55
58
|
/** Ensure the hub unit is up before / during expose (§3.2 / §4.3a). */
|
|
56
59
|
ensureHubUnit?: (opts: EnsureHubUnitOpts) => Promise<EnsureHubUnitResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Version-check-and-restart at the expose adoption point (#590). After the
|
|
62
|
+
* hub unit is confirmed up, compare the RUNNING hub's `/health` version to the
|
|
63
|
+
* installed package version; restart the managed unit on mismatch so an expose
|
|
64
|
+
* never wires a tunnel to a stale zombie. Production wires
|
|
65
|
+
* `ensureHubVersionMatches`; tests inject a stub.
|
|
66
|
+
*/
|
|
67
|
+
ensureHubVersion?: (ctx: {
|
|
68
|
+
port: number;
|
|
69
|
+
log: (line: string) => void;
|
|
70
|
+
}) => Promise<EnsureHubVersionMatchesResult>;
|
|
57
71
|
/** Drive a per-module op against the running hub (reads operator.token). */
|
|
58
72
|
driveModuleOp?: (short: string, op: ModuleOp, deps: DriveModuleOpDeps) => Promise<ModuleOpResult>;
|
|
59
73
|
/**
|
|
@@ -83,6 +97,10 @@ export interface ExposeSupervisorOpts {
|
|
|
83
97
|
export interface ResolvedExposeSupervisor {
|
|
84
98
|
hubUnitDeps: HubUnitDeps;
|
|
85
99
|
ensureHubUnit: (opts: EnsureHubUnitOpts) => Promise<EnsureHubUnitResult>;
|
|
100
|
+
ensureHubVersion: (ctx: {
|
|
101
|
+
port: number;
|
|
102
|
+
log: (line: string) => void;
|
|
103
|
+
}) => Promise<EnsureHubVersionMatchesResult>;
|
|
86
104
|
driveModuleOp: (short: string, op: ModuleOp, deps: DriveModuleOpDeps) => Promise<ModuleOpResult>;
|
|
87
105
|
openDb: (configDir: string) => import("bun:sqlite").Database;
|
|
88
106
|
selfHealOperatorTokenIssuer: (
|
|
@@ -105,6 +123,15 @@ export function resolveExposeSupervisor(
|
|
|
105
123
|
return {
|
|
106
124
|
hubUnitDeps,
|
|
107
125
|
ensureHubUnit: opts?.ensureHubUnit ?? ensureHubUnitImpl,
|
|
126
|
+
ensureHubVersion:
|
|
127
|
+
opts?.ensureHubVersion ??
|
|
128
|
+
((ctx) =>
|
|
129
|
+
ensureHubVersionMatchesImpl({
|
|
130
|
+
installedVersion: pkg.version,
|
|
131
|
+
port: ctx.port,
|
|
132
|
+
deps: hubUnitDeps,
|
|
133
|
+
log: ctx.log,
|
|
134
|
+
})),
|
|
108
135
|
driveModuleOp: opts?.driveModuleOp ?? driveModuleOpImpl,
|
|
109
136
|
openDb: opts?.openDb ?? ((configDir) => openHubDb(hubDbPath(configDir))),
|
|
110
137
|
selfHealOperatorTokenIssuer:
|
|
@@ -145,6 +172,24 @@ export async function ensureHubUnitForExpose(
|
|
|
145
172
|
): Promise<{ ok: boolean; port: number }> {
|
|
146
173
|
const ensured = await sup.ensureHubUnit({ port, deps: sup.hubUnitDeps, log });
|
|
147
174
|
if (ensured.outcome === "already-up" || ensured.outcome === "started") {
|
|
175
|
+
// #590: the hub is up — but is it the version we installed? A zombie that
|
|
176
|
+
// merely answers /health must not become the target of a fresh tunnel.
|
|
177
|
+
// Compare + restart-on-mismatch (once). A non-unit-managed mismatch is NOT
|
|
178
|
+
// killed: surface it + fail the expose so the operator resolves it; a
|
|
179
|
+
// still-mismatched-after-restart (bun-linked branch) warns + continues.
|
|
180
|
+
try {
|
|
181
|
+
const versionResult = await sup.ensureHubVersion({ port: ensured.port, log });
|
|
182
|
+
for (const m of versionResult.messages) log(m);
|
|
183
|
+
if (
|
|
184
|
+
versionResult.outcome === "not-unit-managed" ||
|
|
185
|
+
versionResult.outcome === "restart-failed"
|
|
186
|
+
) {
|
|
187
|
+
return { ok: false, port: ensured.port };
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// A version-check failure must never block expose — degrade to a note.
|
|
191
|
+
log(`note: hub version check skipped (${err instanceof Error ? err.message : String(err)})`);
|
|
192
|
+
}
|
|
148
193
|
return { ok: true, port: ensured.port };
|
|
149
194
|
}
|
|
150
195
|
for (const m of ensured.messages) log(m);
|