@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { mkdirSync, openSync } from "node:fs";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
import { DEFAULT_TUNNEL_NAME, cloudflaredPathsFor, writeConfig } from "../cloudflare/config.ts";
|
|
@@ -26,12 +27,28 @@ import {
|
|
|
26
27
|
findTunnelByName,
|
|
27
28
|
routeDns,
|
|
28
29
|
} from "../cloudflare/tunnel.ts";
|
|
29
|
-
import { SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
30
|
-
import {
|
|
30
|
+
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
31
|
+
import {
|
|
32
|
+
EXPOSE_STATE_PATH,
|
|
33
|
+
type ExposeState,
|
|
34
|
+
clearExposeState,
|
|
35
|
+
writeExposeState,
|
|
36
|
+
} from "../expose-state.ts";
|
|
37
|
+
import {
|
|
38
|
+
type EnsureHubOpts,
|
|
39
|
+
HUB_DEFAULT_PORT,
|
|
40
|
+
ensureHubRunning,
|
|
41
|
+
readHubPort,
|
|
42
|
+
} from "../hub-control.ts";
|
|
43
|
+
import { deriveHubOrigin } from "../hub-origin.ts";
|
|
44
|
+
import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
|
|
31
45
|
import { readManifest } from "../services-manifest.ts";
|
|
32
46
|
import { type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
47
|
+
import { persistVaultHubOrigin } from "../vault-hub-origin-env.ts";
|
|
33
48
|
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
49
|
+
import { WELL_KNOWN_DIR } from "../well-known.ts";
|
|
34
50
|
import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
|
|
51
|
+
import { restart } from "./lifecycle.ts";
|
|
35
52
|
|
|
36
53
|
const AUTH_DOC_URL =
|
|
37
54
|
"https://github.com/ParachuteComputer/parachute-vault/blob/main/docs/auth-model.md";
|
|
@@ -86,14 +103,171 @@ const defaultKill: KillFn = (pid, signal) => {
|
|
|
86
103
|
process.kill(pid, signal);
|
|
87
104
|
};
|
|
88
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Find the PIDs of every running `cloudflared` connector serving THIS tunnel.
|
|
108
|
+
* "This tunnel" is identified by either the tunnel UUID or the config.yml path
|
|
109
|
+
* appearing on the process command line — both are unique to Parachute's
|
|
110
|
+
* connector for this tunnel, so we never touch an unrelated cloudflared the
|
|
111
|
+
* operator may be running for a different tunnel.
|
|
112
|
+
*
|
|
113
|
+
* The motivating bug (hub#487): each `parachute expose public --cloudflare`
|
|
114
|
+
* "reused the tunnel" but spawned a fresh connector (new pid) without killing
|
|
115
|
+
* the prior ones, and the state file only tracked the most-recent pid. Orphan
|
|
116
|
+
* connectors accumulated — multiple `cloudflared tunnel run` processes all
|
|
117
|
+
* serving stale `config.yml` snapshots, so edge routing became nondeterministic
|
|
118
|
+
* ("silent fails"). Sweeping by UUID/config-path catches the orphans that the
|
|
119
|
+
* single-pid state record misses (prior runs that crashed mid-rewrite, or a
|
|
120
|
+
* connector the operator started by hand for this tunnel).
|
|
121
|
+
*
|
|
122
|
+
* Injectable so tests assert the sweep without a live `pgrep`.
|
|
123
|
+
*/
|
|
124
|
+
export type ConnectorPidsFn = (tunnelUuid: string, configPath: string) => number[];
|
|
125
|
+
|
|
126
|
+
export const defaultConnectorPids: ConnectorPidsFn = (tunnelUuid, configPath) => {
|
|
127
|
+
try {
|
|
128
|
+
// `pgrep -fl cloudflared` lists "<pid> <full command line>" for every
|
|
129
|
+
// process whose command line matches "cloudflared". We then filter to the
|
|
130
|
+
// ones that name THIS tunnel (uuid or config path) so the kill is surgical.
|
|
131
|
+
// macOS + Linux ship pgrep; Windows is out of scope (mirrors hub#287's lsof
|
|
132
|
+
// assumption). Any failure → [] (caller falls back to state-tracked pid).
|
|
133
|
+
const result = spawnSync("pgrep", ["-fl", "cloudflared"], {
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
timeout: 2000,
|
|
136
|
+
});
|
|
137
|
+
if (result.status !== 0 || typeof result.stdout !== "string") return [];
|
|
138
|
+
const selfPid = process.pid;
|
|
139
|
+
const pids: number[] = [];
|
|
140
|
+
for (const line of result.stdout.split("\n")) {
|
|
141
|
+
const trimmed = line.trim();
|
|
142
|
+
if (trimmed.length === 0) continue;
|
|
143
|
+
const match = trimmed.match(/^(\d+)\s+(.*)$/);
|
|
144
|
+
if (!match) continue;
|
|
145
|
+
const pid = Number.parseInt(match[1]!, 10);
|
|
146
|
+
const cmdline = match[2]!;
|
|
147
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === selfPid) continue;
|
|
148
|
+
// Surgical match: only connectors that name this tunnel's UUID or its
|
|
149
|
+
// config path. A bare `cloudflared` (e.g. `--version`, `tunnel list`)
|
|
150
|
+
// or a connector for a *different* tunnel won't match either token.
|
|
151
|
+
if (cmdline.includes(tunnelUuid) || cmdline.includes(configPath)) {
|
|
152
|
+
pids.push(pid);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return pids;
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a hostname to its A/AAAA addresses. Returns [] when the name doesn't
|
|
163
|
+
* resolve (NXDOMAIN, SERVFAIL, no records yet) — the signal the DNS
|
|
164
|
+
* self-diagnosis keys on. Injectable so tests drive each case (unresolved /
|
|
165
|
+
* Cloudflare / non-Cloudflare) deterministically.
|
|
166
|
+
*/
|
|
167
|
+
export type ResolveHostFn = (hostname: string) => Promise<string[]>;
|
|
168
|
+
|
|
169
|
+
export const defaultResolveHost: ResolveHostFn = async (hostname) => {
|
|
170
|
+
try {
|
|
171
|
+
// Bun.dns ships with the runtime; `node:dns/promises` is equally fine but
|
|
172
|
+
// Bun.dns.lookup returns both families in one call. `all: true` gives every
|
|
173
|
+
// record so a partially-propagated name still surfaces an address.
|
|
174
|
+
const records = await Bun.dns.lookup(hostname, { family: 0 });
|
|
175
|
+
return records.map((r) => r.address).filter((a) => typeof a === "string" && a.length > 0);
|
|
176
|
+
} catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Cloudflare's published anycast IPv4 ranges (the proxy edge). A proxied
|
|
183
|
+
* (orange-cloud) record — which is what `cloudflared tunnel route dns` creates
|
|
184
|
+
* — resolves to one of these. If the hostname resolves to something *outside*
|
|
185
|
+
* these ranges, it's almost certainly shadowed: a Pages project, an A record,
|
|
186
|
+
* or a grey-cloud CNAME pointing elsewhere. We keep the list to the v4 ranges
|
|
187
|
+
* (the common case) and treat any IPv6 in Cloudflare's 2606:4700::/32 block as
|
|
188
|
+
* Cloudflare too. Source: https://www.cloudflare.com/ips/ (stable for years).
|
|
189
|
+
*/
|
|
190
|
+
const CLOUDFLARE_V4_RANGES: ReadonlyArray<readonly [string, number]> = [
|
|
191
|
+
["173.245.48.0", 20],
|
|
192
|
+
["103.21.244.0", 22],
|
|
193
|
+
["103.22.200.0", 22],
|
|
194
|
+
["103.31.4.0", 22],
|
|
195
|
+
["141.101.64.0", 18],
|
|
196
|
+
["108.162.192.0", 18],
|
|
197
|
+
["190.93.240.0", 20],
|
|
198
|
+
["188.114.96.0", 20],
|
|
199
|
+
["197.234.240.0", 22],
|
|
200
|
+
["198.41.128.0", 17],
|
|
201
|
+
["162.158.0.0", 15],
|
|
202
|
+
["104.16.0.0", 13],
|
|
203
|
+
["104.24.0.0", 14],
|
|
204
|
+
["172.64.0.0", 13],
|
|
205
|
+
["131.0.72.0", 22],
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
function ipv4ToInt(ip: string): number | undefined {
|
|
209
|
+
const parts = ip.split(".");
|
|
210
|
+
if (parts.length !== 4) return undefined;
|
|
211
|
+
let n = 0;
|
|
212
|
+
for (const part of parts) {
|
|
213
|
+
const octet = Number.parseInt(part, 10);
|
|
214
|
+
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return undefined;
|
|
215
|
+
n = n * 256 + octet;
|
|
216
|
+
}
|
|
217
|
+
return n >>> 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** True if any resolved address belongs to Cloudflare's edge. */
|
|
221
|
+
export function looksLikeCloudflare(addresses: readonly string[]): boolean {
|
|
222
|
+
for (const addr of addresses) {
|
|
223
|
+
// IPv6: Cloudflare's edge lives in 2606:4700::/32.
|
|
224
|
+
if (addr.includes(":")) {
|
|
225
|
+
if (addr.toLowerCase().startsWith("2606:4700")) return true;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const ipInt = ipv4ToInt(addr);
|
|
229
|
+
if (ipInt === undefined) continue;
|
|
230
|
+
for (const [base, bits] of CLOUDFLARE_V4_RANGES) {
|
|
231
|
+
const baseInt = ipv4ToInt(base);
|
|
232
|
+
if (baseInt === undefined) continue;
|
|
233
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
234
|
+
if ((ipInt & mask) === (baseInt & mask)) return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
89
240
|
export interface ExposeCloudflareOpts {
|
|
90
241
|
runner?: Runner;
|
|
91
242
|
spawner?: CloudflaredSpawner;
|
|
92
243
|
alive?: AliveFn;
|
|
93
244
|
kill?: KillFn;
|
|
245
|
+
/**
|
|
246
|
+
* Find every running cloudflared connector PID serving this tunnel (by UUID
|
|
247
|
+
* or config-path match). Used to sweep orphan connectors before spawning a
|
|
248
|
+
* fresh one (hub#487). Tests inject a stub; production uses
|
|
249
|
+
* `defaultConnectorPids` (a filtered `pgrep -fl cloudflared`).
|
|
250
|
+
*/
|
|
251
|
+
connectorPids?: ConnectorPidsFn;
|
|
252
|
+
/**
|
|
253
|
+
* Resolve a hostname to its addresses, for the post-route DNS self-diagnosis
|
|
254
|
+
* (hub#487). Returns the resolved IPs (empty when NXDOMAIN / not yet live).
|
|
255
|
+
* Best-effort and non-fatal — a failure to resolve never blocks the expose.
|
|
256
|
+
* Tests inject a stub; production uses `defaultResolveHost` (Bun DNS).
|
|
257
|
+
*/
|
|
258
|
+
resolveHost?: ResolveHostFn;
|
|
94
259
|
log?: (line: string) => void;
|
|
95
260
|
manifestPath?: string;
|
|
96
261
|
statePath?: string;
|
|
262
|
+
/**
|
|
263
|
+
* Path to `expose-state.json` — the shared cross-provider expose record the
|
|
264
|
+
* Tailscale path also writes (`expose.ts`). Distinct from `statePath`
|
|
265
|
+
* (cloudflared-state.json, the per-tunnel process record). The cloudflare
|
|
266
|
+
* up-path writes this so downstream consumers (`resolveAdminUrl` in init,
|
|
267
|
+
* `resolveHubOrigin` in lifecycle / auth) see the public URL instead of
|
|
268
|
+
* loopback; the off-path clears it. Defaults to `EXPOSE_STATE_PATH`.
|
|
269
|
+
*/
|
|
270
|
+
exposeStatePath?: string;
|
|
97
271
|
/**
|
|
98
272
|
* Tunnel name targeted by this invocation. Defaults to `parachute` —
|
|
99
273
|
* the canonical single-tunnel name. Override to run multiple tunnels on
|
|
@@ -112,6 +286,37 @@ export interface ExposeCloudflareOpts {
|
|
|
112
286
|
logPath?: string;
|
|
113
287
|
/** Override `~/.cloudflared` for tests and `$HOME`-free environments. */
|
|
114
288
|
cloudflaredHome?: string;
|
|
289
|
+
/**
|
|
290
|
+
* Config root for hub PID / port / log files. Defaults to `~/.parachute`.
|
|
291
|
+
* Threaded into `ensureHubRunning` so cloudflared's ingress target stays
|
|
292
|
+
* in sync with where the hub actually bound.
|
|
293
|
+
*/
|
|
294
|
+
configDir?: string;
|
|
295
|
+
/**
|
|
296
|
+
* Override the public hub origin (the `iss` claim baked into the OAuth
|
|
297
|
+
* issuer). Mirrors the Tailscale path — when set, this URL is what the
|
|
298
|
+
* hub advertises rather than the cloudflared hostname.
|
|
299
|
+
*/
|
|
300
|
+
hubOrigin?: string;
|
|
301
|
+
/**
|
|
302
|
+
* Overrides for hub lifecycle — primarily for tests. Tests pass
|
|
303
|
+
* `skipHubLifecycle: true` (above) plus a seeded `hub.port` file so the
|
|
304
|
+
* cloudflare path can resolve a port without actually spawning a hub.
|
|
305
|
+
*/
|
|
306
|
+
hubEnsureOpts?: Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log">;
|
|
307
|
+
/**
|
|
308
|
+
* Directory holding hub.html (passed through to the hub server on first
|
|
309
|
+
* spawn). Defaults to the same `well-known/` resolution the Tailscale
|
|
310
|
+
* path uses.
|
|
311
|
+
*/
|
|
312
|
+
wellKnownDir?: string;
|
|
313
|
+
/**
|
|
314
|
+
* Skip spawning the hub server. Tests flip this on and pre-seed
|
|
315
|
+
* `<configDir>/hub/run/hub.port` so `readHubPort` can resolve the
|
|
316
|
+
* cloudflared target without a live process. Production always leaves
|
|
317
|
+
* this off so the bringup self-heals a missing hub.
|
|
318
|
+
*/
|
|
319
|
+
skipHub?: boolean;
|
|
115
320
|
now?: () => Date;
|
|
116
321
|
/**
|
|
117
322
|
* Override `~/.parachute/vault` for the 2FA-enrollment probe. Tests
|
|
@@ -125,6 +330,14 @@ export interface ExposeCloudflareOpts {
|
|
|
125
330
|
* `<vaultHome>/config.yaml` from disk. (#186)
|
|
126
331
|
*/
|
|
127
332
|
vaultAuthStatus?: VaultAuthStatus;
|
|
333
|
+
/**
|
|
334
|
+
* Restart a hub-dependent service so it re-reads the new public hub origin.
|
|
335
|
+
* Mirrors the Tailscale path's `restartService` seam (`expose.ts`). Defaults
|
|
336
|
+
* to lifecycle `restart`; tests inject a fake to assert the call without
|
|
337
|
+
* spawning a real daemon. Only invoked for vault (the only `iss`-validating
|
|
338
|
+
* service) and only when it's already running.
|
|
339
|
+
*/
|
|
340
|
+
restartService?: (short: string) => Promise<number>;
|
|
128
341
|
}
|
|
129
342
|
|
|
130
343
|
interface Resolved {
|
|
@@ -132,36 +345,75 @@ interface Resolved {
|
|
|
132
345
|
spawner: CloudflaredSpawner;
|
|
133
346
|
alive: AliveFn;
|
|
134
347
|
kill: KillFn;
|
|
348
|
+
connectorPids: ConnectorPidsFn;
|
|
349
|
+
resolveHost: ResolveHostFn;
|
|
135
350
|
log: (line: string) => void;
|
|
136
351
|
manifestPath: string;
|
|
137
352
|
statePath: string;
|
|
353
|
+
exposeStatePath: string;
|
|
138
354
|
tunnelName: string;
|
|
139
355
|
configPath: string;
|
|
140
356
|
logPath: string;
|
|
141
357
|
cloudflaredHome: string;
|
|
358
|
+
configDir: string;
|
|
359
|
+
hubOrigin: string | undefined;
|
|
360
|
+
hubEnsureOpts: Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log">;
|
|
361
|
+
wellKnownDir: string;
|
|
362
|
+
skipHub: boolean;
|
|
142
363
|
now: () => Date;
|
|
143
364
|
vaultHome: string | undefined;
|
|
144
365
|
vaultAuthStatus: VaultAuthStatus | undefined;
|
|
366
|
+
restartService: (short: string) => Promise<number>;
|
|
145
367
|
}
|
|
146
368
|
|
|
147
369
|
function resolve(opts: ExposeCloudflareOpts): Resolved {
|
|
148
370
|
const tunnelName = opts.tunnelName ?? DEFAULT_TUNNEL_NAME;
|
|
149
|
-
const
|
|
371
|
+
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
372
|
+
// Derive per-tunnel config/log paths from the *resolved* configDir, not the
|
|
373
|
+
// real `CONFIG_DIR`. When a test threads a tmp `configDir` but omits explicit
|
|
374
|
+
// `configPath`/`logPath`, this keeps the derived files inside the tmp dir
|
|
375
|
+
// instead of writing fixtures into the operator's real ~/.parachute.
|
|
376
|
+
const paths = cloudflaredPathsFor(tunnelName, configDir);
|
|
150
377
|
return {
|
|
151
378
|
runner: opts.runner ?? defaultRunner,
|
|
152
379
|
spawner: opts.spawner ?? defaultCloudflaredSpawner,
|
|
153
380
|
alive: opts.alive ?? defaultAlive,
|
|
154
381
|
kill: opts.kill ?? defaultKill,
|
|
382
|
+
// Defaulting policy mirrors lifecycle's startReadyMs (hub#487): the real
|
|
383
|
+
// implementations shell out (`pgrep`) / hit the network (DNS). When a test
|
|
384
|
+
// injects a fake `spawner` but no explicit seam, fall back to inert stubs
|
|
385
|
+
// (no orphans found; "resolves at Cloudflare" → no DNS warning) so suites
|
|
386
|
+
// stay deterministic and offline. Production (no spawner override) always
|
|
387
|
+
// gets the real `pgrep` sweep + DNS diagnosis.
|
|
388
|
+
connectorPids:
|
|
389
|
+
opts.connectorPids ?? (opts.spawner === undefined ? defaultConnectorPids : () => []),
|
|
390
|
+
resolveHost:
|
|
391
|
+
opts.resolveHost ??
|
|
392
|
+
(opts.spawner === undefined ? defaultResolveHost : async () => ["104.16.0.1"]),
|
|
155
393
|
log: opts.log ?? ((line) => console.log(line)),
|
|
156
394
|
manifestPath: opts.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
157
395
|
statePath: opts.statePath ?? CLOUDFLARED_STATE_PATH,
|
|
396
|
+
exposeStatePath: opts.exposeStatePath ?? EXPOSE_STATE_PATH,
|
|
158
397
|
tunnelName,
|
|
159
398
|
configPath: opts.configPath ?? paths.configPath,
|
|
160
399
|
logPath: opts.logPath ?? paths.logPath,
|
|
161
400
|
cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
|
|
401
|
+
configDir,
|
|
402
|
+
hubOrigin: opts.hubOrigin,
|
|
403
|
+
hubEnsureOpts: opts.hubEnsureOpts ?? {},
|
|
404
|
+
wellKnownDir: opts.wellKnownDir ?? WELL_KNOWN_DIR,
|
|
405
|
+
skipHub: opts.skipHub ?? false,
|
|
162
406
|
now: opts.now ?? (() => new Date()),
|
|
163
407
|
vaultHome: opts.vaultHome,
|
|
164
408
|
vaultAuthStatus: opts.vaultAuthStatus,
|
|
409
|
+
restartService:
|
|
410
|
+
opts.restartService ??
|
|
411
|
+
((short: string) =>
|
|
412
|
+
restart(short, {
|
|
413
|
+
manifestPath: opts.manifestPath,
|
|
414
|
+
configDir,
|
|
415
|
+
log: opts.log ?? (() => {}),
|
|
416
|
+
})),
|
|
165
417
|
};
|
|
166
418
|
}
|
|
167
419
|
|
|
@@ -174,19 +426,65 @@ function printAuthGuidance(log: (line: string) => void, vaultUrl: string): void
|
|
|
174
426
|
log("Pick the path that matches how you'll reach it:");
|
|
175
427
|
log("");
|
|
176
428
|
log(" Humans (claude.ai / ChatGPT connectors, browser):");
|
|
177
|
-
log(" parachute auth set-password # set
|
|
178
|
-
log(" parachute auth 2fa enroll #
|
|
429
|
+
log(" parachute auth set-password # set a STRONG owner password");
|
|
430
|
+
log(" parachute auth 2fa enroll # add a second factor (recommended)");
|
|
431
|
+
log(" # (or set 2FA up in the browser at /account/2fa for a scannable QR)");
|
|
179
432
|
log(" then point your connector at:");
|
|
180
433
|
log(` ${vaultUrl}`);
|
|
181
434
|
log("");
|
|
182
|
-
log(" Scripts / machines:");
|
|
183
|
-
log(" parachute
|
|
184
|
-
log(" Authorization: Bearer
|
|
435
|
+
log(" Scripts / machines (hub-issued JWT — set the owner password first):");
|
|
436
|
+
log(" parachute auth mint-token --scope vault:<name>:read # or :write");
|
|
437
|
+
log(" Authorization: Bearer <hub-jwt> # attach the printed token to every request");
|
|
438
|
+
log(" (or: Admin → Vaults → Connect mints one and shows the header for you)");
|
|
185
439
|
log("");
|
|
186
|
-
log("
|
|
440
|
+
log("The owner password gates both paths — browser sign-in and minting tokens.");
|
|
441
|
+
log("Full auth reference:");
|
|
187
442
|
log(` ${AUTH_DOC_URL}`);
|
|
188
443
|
}
|
|
189
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Best-effort registrable-zone guess: the last two labels of the hostname
|
|
447
|
+
* (`vault.example.com` → `example.com`, `gitcoin.parachute.computer` →
|
|
448
|
+
* `parachute.computer`). This is a heuristic — multi-label public suffixes
|
|
449
|
+
* (`foo.co.uk`) would guess `co.uk` — but it's only used to phrase the
|
|
450
|
+
* `dig +short <zone> NS` remedy, where being off by a label is a harmless
|
|
451
|
+
* nudge. We don't ship a full public-suffix list for one warning string.
|
|
452
|
+
*/
|
|
453
|
+
function guessZone(hostname: string): string {
|
|
454
|
+
const labels = hostname.split(".").filter((l) => l.length > 0);
|
|
455
|
+
if (labels.length <= 2) return hostname;
|
|
456
|
+
return labels.slice(-2).join(".");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Non-fatal post-route DNS diagnosis. Resolves `hostname` and warns when the
|
|
461
|
+
* result looks wrong — see the call site for the two symptoms this addresses.
|
|
462
|
+
* Never throws (resolveHost swallows its own errors) and never changes the
|
|
463
|
+
* exit code; the worst case is no output.
|
|
464
|
+
*/
|
|
465
|
+
async function diagnoseDns(hostname: string, r: Resolved): Promise<void> {
|
|
466
|
+
const zone = guessZone(hostname);
|
|
467
|
+
const addresses = await r.resolveHost(hostname);
|
|
468
|
+
if (addresses.length === 0) {
|
|
469
|
+
r.log("");
|
|
470
|
+
r.log(`⚠ DNS isn't live yet for ${hostname}.`);
|
|
471
|
+
r.log(` If ${zone} is a new Cloudflare zone, its nameservers may not be switched at your`);
|
|
472
|
+
r.log(" registrar yet. Check with:");
|
|
473
|
+
r.log(` dig +short ${zone} NS # should list *.ns.cloudflare.com`);
|
|
474
|
+
r.log(" Propagation can take minutes to hours. The tunnel itself is up — the URLs below");
|
|
475
|
+
r.log(" will start working once DNS resolves.");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!looksLikeCloudflare(addresses)) {
|
|
479
|
+
r.log("");
|
|
480
|
+
r.log(`⚠ ${hostname} resolves (${addresses.join(", ")}) but not to Cloudflare's edge.`);
|
|
481
|
+
r.log(` It may be shadowed by another DNS record or a Cloudflare Pages project on ${zone}.`);
|
|
482
|
+
r.log(" Ensure it's a proxied (orange-cloud) CNAME to the tunnel — check");
|
|
483
|
+
r.log(` https://dash.cloudflare.com → DNS for ${zone}. A grey-cloud / A record / Pages`);
|
|
484
|
+
r.log(" binding on this hostname will 404 the tunnel at the edge.");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
190
488
|
export async function exposeCloudflareUp(
|
|
191
489
|
hostname: string,
|
|
192
490
|
opts: ExposeCloudflareOpts = {},
|
|
@@ -239,6 +537,46 @@ export async function exposeCloudflareUp(
|
|
|
239
537
|
return 1;
|
|
240
538
|
}
|
|
241
539
|
|
|
540
|
+
// Resolve the public hub origin before spawning the hub server — it gets
|
|
541
|
+
// baked into the OAuth `iss` claim via the `--issuer` flag. For Cloudflare
|
|
542
|
+
// ingress the canonical origin is the user-supplied hostname (mirrors the
|
|
543
|
+
// Tailscale Funnel path which uses the tailnet FQDN). Falling back to the
|
|
544
|
+
// request origin would put `http://127.0.0.1:<port>` in tokens, which any
|
|
545
|
+
// client following RFC 8414 would reject.
|
|
546
|
+
const canonicalOrigin = `https://${hostname}`;
|
|
547
|
+
const hubOrigin =
|
|
548
|
+
deriveHubOrigin({ override: r.hubOrigin, exposeFqdn: hostname }) ?? canonicalOrigin;
|
|
549
|
+
|
|
550
|
+
// Ensure the hub is running and figure out the loopback port cloudflared
|
|
551
|
+
// should target. The hub does all internal routing (discovery, admin,
|
|
552
|
+
// OAuth, well-known, per-vault proxy, generic /<svc>/* dispatch) — same
|
|
553
|
+
// shape the Tailscale Funnel path uses (see `planEntries` in expose.ts).
|
|
554
|
+
// Pre-2026-05-27 the cloudflared config routed straight at vault's port,
|
|
555
|
+
// so a public URL like https://gitcoin.parachute.computer/ returned 404
|
|
556
|
+
// from vault itself instead of the hub's discovery page; admin / OAuth
|
|
557
|
+
// were unreachable. Aaron hit this on a fresh EC2 install.
|
|
558
|
+
let hubPort: number;
|
|
559
|
+
if (r.skipHub) {
|
|
560
|
+
const existing = readHubPort(r.configDir);
|
|
561
|
+
if (existing === undefined) {
|
|
562
|
+
throw new Error("skipHub set but no hub.port on disk — tests must seed one");
|
|
563
|
+
}
|
|
564
|
+
hubPort = existing;
|
|
565
|
+
} else {
|
|
566
|
+
const hub = await ensureHubRunning({
|
|
567
|
+
reservedPorts: manifest.services.map((s) => s.port),
|
|
568
|
+
...r.hubEnsureOpts,
|
|
569
|
+
configDir: r.configDir,
|
|
570
|
+
wellKnownDir: r.wellKnownDir,
|
|
571
|
+
issuer: hubOrigin,
|
|
572
|
+
log: r.log,
|
|
573
|
+
});
|
|
574
|
+
hubPort = hub.port;
|
|
575
|
+
if (hub.started) r.log(`✓ hub started (pid ${hub.pid}, port ${hub.port}).`);
|
|
576
|
+
else r.log(`✓ hub already running (pid ${hub.pid}, port ${hub.port}).`);
|
|
577
|
+
}
|
|
578
|
+
if (hubPort === 0) hubPort = HUB_DEFAULT_PORT;
|
|
579
|
+
|
|
242
580
|
let tunnel: Tunnel | undefined;
|
|
243
581
|
try {
|
|
244
582
|
tunnel = await findTunnelByName(r.runner, r.tunnelName);
|
|
@@ -276,24 +614,59 @@ export async function exposeCloudflareUp(
|
|
|
276
614
|
}
|
|
277
615
|
r.log("✓ DNS routed.");
|
|
278
616
|
|
|
617
|
+
// Post-route DNS self-diagnosis (hub#487). `cloudflared tunnel route dns`
|
|
618
|
+
// can succeed (the CNAME is written in Cloudflare's API) while the hostname
|
|
619
|
+
// is still NOT actually serving the tunnel — two shapes Aaron hit:
|
|
620
|
+
// (a) a "pending" zone whose nameservers aren't switched at the registrar
|
|
621
|
+
// yet, so the record exists in Cloudflare but nothing resolves; and
|
|
622
|
+
// (b) a subdomain shadowed by a Cloudflare Pages project on the same zone,
|
|
623
|
+
// so the edge 404s the tunnel.
|
|
624
|
+
// Both previously printed "✓ DNS routed" + the URLs as if fine. This check
|
|
625
|
+
// is best-effort and strictly NON-FATAL — it only adds a warning; it never
|
|
626
|
+
// changes the exit code or blocks the expose. Fast: one DNS lookup with a
|
|
627
|
+
// built-in timeout in `resolveHost`.
|
|
628
|
+
await diagnoseDns(hostname, r);
|
|
629
|
+
|
|
279
630
|
const credsFile = credentialsPath(tunnel.id, r.cloudflaredHome);
|
|
280
631
|
writeConfig(
|
|
281
632
|
{
|
|
282
633
|
tunnelUuid: tunnel.id,
|
|
283
634
|
credentialsFile: credsFile,
|
|
284
635
|
hostname,
|
|
285
|
-
|
|
636
|
+
// Route into the hub, not vault directly. The hub dispatches
|
|
637
|
+
// discovery / admin / OAuth / per-vault proxy / generic /<svc>/*
|
|
638
|
+
// — same shape Tailscale Funnel uses (single mount → hub catchall).
|
|
639
|
+
// Pre-fix this was `vaultEntry.port`, which served vault's own 404
|
|
640
|
+
// page on every request that wasn't /vault/<name>/… — admin SPA and
|
|
641
|
+
// OAuth surfaces were unreachable from the public URL.
|
|
642
|
+
servicePort: hubPort,
|
|
286
643
|
},
|
|
287
644
|
r.configPath,
|
|
288
645
|
);
|
|
289
646
|
r.log(`✓ Wrote ${r.configPath}`);
|
|
290
647
|
|
|
648
|
+
// Orphan-connector sweep (hub#487). Before spawning a fresh connector, kill
|
|
649
|
+
// EVERY cloudflared connector currently serving this tunnel so exactly one
|
|
650
|
+
// process serves the config.yml we just wrote. Pre-fix, each re-expose
|
|
651
|
+
// spawned a new connector without killing the prior ones (state tracked only
|
|
652
|
+
// the most-recent pid), so orphans accumulated and edge routing became
|
|
653
|
+
// nondeterministic. We union two sources:
|
|
654
|
+
// - the pid recorded in cloudflared-state.json (the prior `parachute`-
|
|
655
|
+
// spawned connector for this tunnel name), and
|
|
656
|
+
// - any pid found by scanning running processes for this tunnel's UUID or
|
|
657
|
+
// config path (catches orphans the state file lost track of — crashed
|
|
658
|
+
// mid-rewrite, or started by hand for this tunnel).
|
|
291
659
|
const stateBefore = readCloudflaredState(r.statePath);
|
|
292
660
|
const prior = findTunnelRecord(stateBefore, r.tunnelName);
|
|
293
|
-
|
|
661
|
+
const toKill = new Set<number>();
|
|
662
|
+
if (prior && r.alive(prior.pid)) toKill.add(prior.pid);
|
|
663
|
+
for (const pid of r.connectorPids(tunnel.id, r.configPath)) {
|
|
664
|
+
if (r.alive(pid)) toKill.add(pid);
|
|
665
|
+
}
|
|
666
|
+
for (const deadPid of toKill) {
|
|
294
667
|
try {
|
|
295
|
-
r.kill(
|
|
296
|
-
r.log(`Stopped prior cloudflared (pid ${
|
|
668
|
+
r.kill(deadPid, "SIGTERM");
|
|
669
|
+
r.log(`Stopped prior cloudflared connector (pid ${deadPid}).`);
|
|
297
670
|
} catch {
|
|
298
671
|
// Process is already gone — safe to ignore; we replace the record below.
|
|
299
672
|
}
|
|
@@ -314,6 +687,67 @@ export async function exposeCloudflareUp(
|
|
|
314
687
|
};
|
|
315
688
|
writeCloudflaredState(withTunnelRecord(stateBefore, record), r.statePath);
|
|
316
689
|
|
|
690
|
+
// Persist the shared cross-provider expose record. Without this, the
|
|
691
|
+
// Tailscale path was the only one writing expose-state.json — so after a
|
|
692
|
+
// Cloudflare bring-up `readExposeState()` returned undefined and downstream
|
|
693
|
+
// consumers fell back to loopback:
|
|
694
|
+
// - init's `resolveAdminUrl` printed http://127.0.0.1:1939/admin/ instead
|
|
695
|
+
// of the public URL.
|
|
696
|
+
// - lifecycle's `resolveHubOrigin` (and the hub#460 vault `.env`
|
|
697
|
+
// PARACHUTE_HUB_ORIGIN persistence) kept the loopback origin, so vault's
|
|
698
|
+
// OAuth `iss` claim didn't match the public host — the "rejected on
|
|
699
|
+
// reconnect" P0 on Cloudflare deploys.
|
|
700
|
+
// Mode is "subdomain": cloudflared routes the whole FQDN at the hub catchall
|
|
701
|
+
// (one ingress → hub), unlike the Tailscale path's "path" routing. The single
|
|
702
|
+
// proxy entry mirrors the hub-catchall shape the Tailscale Funnel path plans.
|
|
703
|
+
const exposeState: ExposeState = {
|
|
704
|
+
version: 1,
|
|
705
|
+
layer: "public",
|
|
706
|
+
mode: "subdomain",
|
|
707
|
+
canonicalFqdn: hostname,
|
|
708
|
+
port: hubPort,
|
|
709
|
+
funnel: false,
|
|
710
|
+
entries: [
|
|
711
|
+
{
|
|
712
|
+
kind: "proxy",
|
|
713
|
+
mount: "/",
|
|
714
|
+
target: `http://localhost:${hubPort}`,
|
|
715
|
+
service: "hub",
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
hubOrigin,
|
|
719
|
+
};
|
|
720
|
+
writeExposeState(exposeState, r.exposeStatePath);
|
|
721
|
+
|
|
722
|
+
// Persist the public hub origin into vault's `.env` and restart vault — the
|
|
723
|
+
// durable half of the OAuth issuer-mismatch fix on Cloudflare deploys.
|
|
724
|
+
//
|
|
725
|
+
// The bug (vault 401s every hub token on a Cloudflare deploy): the Tailscale
|
|
726
|
+
// path gets this for free because it auto-restarts vault, and that restart
|
|
727
|
+
// flows the freshly-written expose-state `hubOrigin` into `vault/.env` via
|
|
728
|
+
// lifecycle's `persistVaultHubOrigin`. The Cloudflare path wrote expose-state
|
|
729
|
+
// but never touched vault's `.env` or restarted it, so the launchd / systemd
|
|
730
|
+
// daemon kept booting vault with NO `PARACHUTE_HUB_ORIGIN` → vault fell back
|
|
731
|
+
// to loopback as its expected issuer → every hub-minted token (whose `iss`
|
|
732
|
+
// is the public origin) failed the `iss` check → 401 → "You're not signed in
|
|
733
|
+
// to the hub." We mirror the Tailscale path here exactly.
|
|
734
|
+
//
|
|
735
|
+
// `persistVaultHubOrigin` writes the durable `.env` (skips loopback itself,
|
|
736
|
+
// so a `--hub-origin http://127.0.0.1` override never bakes a dead issuer in);
|
|
737
|
+
// the restart makes the running vault re-read it immediately rather than
|
|
738
|
+
// waiting for the next reboot.
|
|
739
|
+
persistVaultHubOrigin(r.configDir, hubOrigin, r.log);
|
|
740
|
+
if (processState("vault", r.configDir, r.alive).status === "running") {
|
|
741
|
+
r.log("");
|
|
742
|
+
r.log("Restarting vault to pick up new hub origin…");
|
|
743
|
+
const rcode = await r.restartService("vault");
|
|
744
|
+
if (rcode !== 0) {
|
|
745
|
+
r.log(
|
|
746
|
+
"⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
317
751
|
const baseUrl = `https://${hostname}`;
|
|
318
752
|
// A well-formed vault manifest always lists at least one mount path. If
|
|
319
753
|
// it's empty, something went sideways in `parachute install vault` — warn
|
|
@@ -329,9 +763,11 @@ export async function exposeCloudflareUp(
|
|
|
329
763
|
|
|
330
764
|
r.log("");
|
|
331
765
|
r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
|
|
332
|
-
r.log(`
|
|
333
|
-
r.log(`
|
|
334
|
-
r.log(`
|
|
766
|
+
r.log(` Open: ${baseUrl}/`);
|
|
767
|
+
r.log(` Admin: ${baseUrl}/admin/`);
|
|
768
|
+
r.log(` Vault: ${vaultUrl}`);
|
|
769
|
+
r.log(` OAuth: ${hubOrigin}`);
|
|
770
|
+
r.log(` Logs: ${r.logPath}`);
|
|
335
771
|
r.log("");
|
|
336
772
|
r.log("Point a claude.ai / ChatGPT connector at:");
|
|
337
773
|
r.log(` ${vaultUrl}`);
|
|
@@ -376,12 +812,31 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
|
|
|
376
812
|
} else {
|
|
377
813
|
r.log(`cloudflared (pid ${record.pid}) wasn't running; clearing stale state.`);
|
|
378
814
|
}
|
|
815
|
+
// Sweep any orphan connectors for this tunnel that the state record didn't
|
|
816
|
+
// track (hub#487) so `off` leaves exactly zero connectors serving it. Match
|
|
817
|
+
// by UUID/config-path; skip the record pid we already signalled above.
|
|
818
|
+
for (const orphanPid of r.connectorPids(record.tunnelUuid, record.configPath)) {
|
|
819
|
+
if (orphanPid === record.pid || !r.alive(orphanPid)) continue;
|
|
820
|
+
try {
|
|
821
|
+
r.kill(orphanPid, "SIGTERM");
|
|
822
|
+
r.log(`✓ Stopped orphan cloudflared connector (pid ${orphanPid}).`);
|
|
823
|
+
} catch {
|
|
824
|
+
// Already gone between probe and kill — fine.
|
|
825
|
+
}
|
|
826
|
+
}
|
|
379
827
|
const stateAfter = withoutTunnelRecord(stateBefore, r.tunnelName);
|
|
380
828
|
if (stateAfter) {
|
|
381
829
|
writeCloudflaredState(stateAfter, r.statePath);
|
|
382
830
|
} else {
|
|
383
831
|
clearCloudflaredState(r.statePath);
|
|
384
832
|
}
|
|
833
|
+
// Clear the shared expose-state.json when no Cloudflare tunnels remain, so
|
|
834
|
+
// downstream consumers stop resolving the now-dead public URL (mirrors the
|
|
835
|
+
// up-path write above + the Tailscale off-path's expose-state teardown). When
|
|
836
|
+
// other tunnels survive we leave it — a later off for the last one clears it.
|
|
837
|
+
if (!stateAfter) {
|
|
838
|
+
clearExposeState(r.exposeStatePath);
|
|
839
|
+
}
|
|
385
840
|
r.log(` ${record.hostname} is no longer reachable through this machine.`);
|
|
386
841
|
r.log(
|
|
387
842
|
` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createInterface } from "node:readline/promises";
|
|
14
14
|
import {
|
|
15
15
|
DEFAULT_CLOUDFLARED_HOME,
|
|
16
|
+
cloudflaredInstallHint,
|
|
16
17
|
isCloudflaredInstalled,
|
|
17
18
|
isCloudflaredLoggedIn,
|
|
18
19
|
} from "../cloudflare/detect.ts";
|
|
@@ -273,19 +274,17 @@ async function guideCloudflareSetup(
|
|
|
273
274
|
return false;
|
|
274
275
|
}
|
|
275
276
|
} else {
|
|
277
|
+
// 2026-05-27 refresh: distro-package paths (`apt-get`, `dnf`) are
|
|
278
|
+
// unreliable across versions — Aaron hit `No match for argument:
|
|
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.
|
|
276
284
|
r.log("");
|
|
277
285
|
r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
|
|
278
|
-
r.log("
|
|
279
|
-
r.
|
|
280
|
-
r.log(
|
|
281
|
-
" curl -L https://pkg.cloudflare.com/install.sh | sudo bash && sudo apt-get install -y cloudflared",
|
|
282
|
-
);
|
|
283
|
-
r.log(" RHEL / Fedora:");
|
|
284
|
-
r.log(" sudo dnf install cloudflared");
|
|
285
|
-
r.log(" Tarball / other:");
|
|
286
|
-
r.log(
|
|
287
|
-
" https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
288
|
-
);
|
|
286
|
+
r.log("");
|
|
287
|
+
for (const line of cloudflaredInstallHint(r.platform).split("\n")) r.log(line);
|
|
289
288
|
r.log("");
|
|
290
289
|
r.log("After install, re-run: parachute expose public");
|
|
291
290
|
return false;
|