@openparachute/hub 0.6.1-rc.4 → 0.6.2
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 +2 -2
- package/src/__tests__/account-home-ui.test.ts +34 -0
- package/src/__tests__/cloudflare-config.test.ts +65 -1
- package/src/__tests__/cloudflare-connector-service.test.ts +441 -0
- package/src/__tests__/expose-cloudflare.test.ts +684 -16
- package/src/account-home-ui.ts +4 -1
- package/src/cli.ts +2 -1
- package/src/cloudflare/config.ts +70 -4
- package/src/cloudflare/connector-service.ts +478 -0
- package/src/cloudflare/state.ts +13 -1
- package/src/commands/expose-cloudflare.ts +308 -43
- package/src/help.ts +7 -2
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { mkdirSync, openSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_TUNNEL_NAME,
|
|
6
|
+
cloudflaredPathsFor,
|
|
7
|
+
deriveTunnelName,
|
|
8
|
+
writeConfig,
|
|
9
|
+
} from "../cloudflare/config.ts";
|
|
10
|
+
import {
|
|
11
|
+
type ConnectorServiceDeps,
|
|
12
|
+
type InstallResult,
|
|
13
|
+
type RemoveResult,
|
|
14
|
+
installConnectorService,
|
|
15
|
+
removeConnectorService,
|
|
16
|
+
} from "../cloudflare/connector-service.ts";
|
|
5
17
|
import {
|
|
6
18
|
DEFAULT_CLOUDFLARED_HOME,
|
|
7
19
|
cloudflaredInstallHint,
|
|
@@ -10,6 +22,7 @@ import {
|
|
|
10
22
|
} from "../cloudflare/detect.ts";
|
|
11
23
|
import {
|
|
12
24
|
CLOUDFLARED_STATE_PATH,
|
|
25
|
+
type CloudflaredState,
|
|
13
26
|
type CloudflaredTunnelRecord,
|
|
14
27
|
clearCloudflaredState,
|
|
15
28
|
findTunnelRecord,
|
|
@@ -249,6 +262,27 @@ export interface ExposeCloudflareOpts {
|
|
|
249
262
|
* `defaultConnectorPids` (a filtered `pgrep -fl cloudflared`).
|
|
250
263
|
*/
|
|
251
264
|
connectorPids?: ConnectorPidsFn;
|
|
265
|
+
/**
|
|
266
|
+
* Install/remove the reboot-persistent connector OS service (launchd on
|
|
267
|
+
* macOS, systemd on Linux). Injectable so tests drive the install/remove
|
|
268
|
+
* without touching real launchctl/systemctl or `~/Library/LaunchAgents`.
|
|
269
|
+
* Defaults: the up-path installs (falls back to a transient `proc.unref()`
|
|
270
|
+
* connector when the tool is absent); the off / legacy-sweep paths remove.
|
|
271
|
+
* Tests inject fakes to assert the generated service file + command sequence.
|
|
272
|
+
*/
|
|
273
|
+
installService?: (args: {
|
|
274
|
+
tunnelName: string;
|
|
275
|
+
configPath: string;
|
|
276
|
+
logPath: string;
|
|
277
|
+
}) => InstallResult;
|
|
278
|
+
removeService?: (args: { tunnelName: string }) => RemoveResult;
|
|
279
|
+
/**
|
|
280
|
+
* Override the side-effect deps the default install/remove implementations
|
|
281
|
+
* use (platform, getuid, fs, run). Only consulted when `installService` /
|
|
282
|
+
* `removeService` aren't injected directly. Lets a test pin `platform` /
|
|
283
|
+
* `getuid` while still exercising the real install/remove logic.
|
|
284
|
+
*/
|
|
285
|
+
connectorServiceDeps?: ConnectorServiceDeps;
|
|
252
286
|
/**
|
|
253
287
|
* Resolve a hostname to its addresses, for the post-route DNS self-diagnosis
|
|
254
288
|
* (hub#487). Returns the resolved IPs (empty when NXDOMAIN / not yet live).
|
|
@@ -269,9 +303,12 @@ export interface ExposeCloudflareOpts {
|
|
|
269
303
|
*/
|
|
270
304
|
exposeStatePath?: string;
|
|
271
305
|
/**
|
|
272
|
-
* Tunnel name targeted by this invocation.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
306
|
+
* Tunnel name targeted by this invocation. The up-path defaults to a
|
|
307
|
+
* per-hostname derived name (`deriveTunnelName(hostname)`) so each machine
|
|
308
|
+
* gets its own tunnel and account-wide tunnels don't collide across boxes
|
|
309
|
+
* (#491). Override to pin a specific name (e.g. multiple tunnels on one
|
|
310
|
+
* box, #32). The off-path resolves the name from `cloudflared-state.json`
|
|
311
|
+
* when omitted (it has no hostname to derive from).
|
|
275
312
|
*/
|
|
276
313
|
tunnelName?: string;
|
|
277
314
|
/**
|
|
@@ -346,6 +383,12 @@ interface Resolved {
|
|
|
346
383
|
alive: AliveFn;
|
|
347
384
|
kill: KillFn;
|
|
348
385
|
connectorPids: ConnectorPidsFn;
|
|
386
|
+
installService: (args: {
|
|
387
|
+
tunnelName: string;
|
|
388
|
+
configPath: string;
|
|
389
|
+
logPath: string;
|
|
390
|
+
}) => InstallResult;
|
|
391
|
+
removeService: (args: { tunnelName: string }) => RemoveResult;
|
|
349
392
|
resolveHost: ResolveHostFn;
|
|
350
393
|
log: (line: string) => void;
|
|
351
394
|
manifestPath: string;
|
|
@@ -366,8 +409,20 @@ interface Resolved {
|
|
|
366
409
|
restartService: (short: string) => Promise<number>;
|
|
367
410
|
}
|
|
368
411
|
|
|
369
|
-
|
|
370
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Resolve options into the fully-defaulted `Resolved` shape.
|
|
414
|
+
*
|
|
415
|
+
* `tunnelNameDefault` is the fallback tunnel name when the caller didn't pass
|
|
416
|
+
* an explicit `opts.tunnelName`. The up-path passes `deriveTunnelName(hostname)`
|
|
417
|
+
* so each machine/hostname gets its OWN dedicated tunnel (#491) — sharing one
|
|
418
|
+
* account-wide tunnel across boxes collides their connectors. An explicit
|
|
419
|
+
* `--tunnel-name` always wins (operators can override). The off-path has no
|
|
420
|
+
* hostname to derive from, so it resolves the name from state before calling
|
|
421
|
+
* in (see `exposeCloudflareOff`) and only relies on this default as a last
|
|
422
|
+
* resort.
|
|
423
|
+
*/
|
|
424
|
+
function resolve(opts: ExposeCloudflareOpts, tunnelNameDefault: string): Resolved {
|
|
425
|
+
const tunnelName = opts.tunnelName ?? tunnelNameDefault;
|
|
371
426
|
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
372
427
|
// Derive per-tunnel config/log paths from the *resolved* configDir, not the
|
|
373
428
|
// real `CONFIG_DIR`. When a test threads a tmp `configDir` but omits explicit
|
|
@@ -387,6 +442,39 @@ function resolve(opts: ExposeCloudflareOpts): Resolved {
|
|
|
387
442
|
// gets the real `pgrep` sweep + DNS diagnosis.
|
|
388
443
|
connectorPids:
|
|
389
444
|
opts.connectorPids ?? (opts.spawner === undefined ? defaultConnectorPids : () => []),
|
|
445
|
+
// Reboot-persistent connector seam. Defaulting policy mirrors
|
|
446
|
+
// `connectorPids`/`resolveHost`: when a test injects a stub `spawner` (and
|
|
447
|
+
// no explicit service seam), default to an inert "fallback" so existing
|
|
448
|
+
// stub-spawner suites keep exercising the transient-spawn path without
|
|
449
|
+
// touching real launchctl/systemctl. Production (no spawner override) gets
|
|
450
|
+
// the real install/remove. An explicit `installService`/`removeService`
|
|
451
|
+
// always wins; `connectorServiceDeps` lets a test pin platform/getuid
|
|
452
|
+
// while running the real install/remove logic.
|
|
453
|
+
installService:
|
|
454
|
+
opts.installService ??
|
|
455
|
+
(opts.spawner === undefined || opts.connectorServiceDeps !== undefined
|
|
456
|
+
? (args) =>
|
|
457
|
+
installConnectorService({
|
|
458
|
+
...args,
|
|
459
|
+
...(opts.connectorServiceDeps !== undefined
|
|
460
|
+
? { deps: opts.connectorServiceDeps }
|
|
461
|
+
: {}),
|
|
462
|
+
})
|
|
463
|
+
: () => ({
|
|
464
|
+
outcome: "fallback",
|
|
465
|
+
messages: [],
|
|
466
|
+
})),
|
|
467
|
+
removeService:
|
|
468
|
+
opts.removeService ??
|
|
469
|
+
(opts.spawner === undefined || opts.connectorServiceDeps !== undefined
|
|
470
|
+
? (args) =>
|
|
471
|
+
removeConnectorService({
|
|
472
|
+
...args,
|
|
473
|
+
...(opts.connectorServiceDeps !== undefined
|
|
474
|
+
? { deps: opts.connectorServiceDeps }
|
|
475
|
+
: {}),
|
|
476
|
+
})
|
|
477
|
+
: () => ({ removed: false, messages: [] })),
|
|
390
478
|
resolveHost:
|
|
391
479
|
opts.resolveHost ??
|
|
392
480
|
(opts.spawner === undefined ? defaultResolveHost : async () => ["104.16.0.1"]),
|
|
@@ -489,7 +577,12 @@ export async function exposeCloudflareUp(
|
|
|
489
577
|
hostname: string,
|
|
490
578
|
opts: ExposeCloudflareOpts = {},
|
|
491
579
|
): Promise<number> {
|
|
492
|
-
|
|
580
|
+
// Default to a per-hostname dedicated tunnel (#491). An explicit
|
|
581
|
+
// `--tunnel-name` still wins (handled inside `resolve`). Deriving from the
|
|
582
|
+
// hostname keeps re-expose idempotent (same hostname → same name → reuse the
|
|
583
|
+
// tunnel created last time) and stops two machines from colliding on the
|
|
584
|
+
// single account-wide `"parachute"` tunnel.
|
|
585
|
+
const r = resolve(opts, deriveTunnelName(hostname));
|
|
493
586
|
|
|
494
587
|
if (!isValidTunnelName(r.tunnelName)) {
|
|
495
588
|
r.log(
|
|
@@ -591,6 +684,9 @@ export async function exposeCloudflareUp(
|
|
|
591
684
|
return reportCloudflaredError(err, r.log);
|
|
592
685
|
}
|
|
593
686
|
r.log(`✓ Created tunnel ${tunnel.id}`);
|
|
687
|
+
r.log(
|
|
688
|
+
" Each machine gets its own dedicated tunnel — you don't need to run `cloudflared tunnel create` separately; expose does it.",
|
|
689
|
+
);
|
|
594
690
|
} else {
|
|
595
691
|
r.log(`✓ Reusing existing tunnel "${r.tunnelName}" (${tunnel.id})`);
|
|
596
692
|
}
|
|
@@ -672,10 +768,88 @@ export async function exposeCloudflareUp(
|
|
|
672
768
|
}
|
|
673
769
|
}
|
|
674
770
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
771
|
+
// Legacy shared-tunnel migration sweep (#491). Aaron's running boxes were
|
|
772
|
+
// exposed under the old single account-wide `"parachute"` tunnel; the bug
|
|
773
|
+
// was that a second box reusing that name collided connectors. Now that the
|
|
774
|
+
// default is per-hostname, a box upgrading and re-exposing will create/route
|
|
775
|
+
// a NEW dedicated tunnel — but the OLD `"parachute"` connector is still
|
|
776
|
+
// running, still registered on the shared tunnel, still able to pick up
|
|
777
|
+
// load-balanced requests for OTHER hosts. Kill it + drop its state record so
|
|
778
|
+
// the box self-heals immediately on this expose instead of at the next
|
|
779
|
+
// reboot. Only fires when (a) we actually migrated AWAY from "parachute"
|
|
780
|
+
// (the new derived name differs) and (b) a live legacy record exists.
|
|
781
|
+
// `routeDns` above already used `--overwrite-dns`, so this hostname's CNAME
|
|
782
|
+
// has been repointed to the new tunnel — the legacy connector can't serve it
|
|
783
|
+
// anymore regardless; this just stops it from serving anyone else's.
|
|
784
|
+
let migratedState = stateBefore;
|
|
785
|
+
if (r.tunnelName !== DEFAULT_TUNNEL_NAME) {
|
|
786
|
+
const legacy = findTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
|
|
787
|
+
if (legacy) {
|
|
788
|
+
// Remove any boot service for the legacy shared tunnel before killing its
|
|
789
|
+
// connector, so a service doesn't restart the connector we're migrating
|
|
790
|
+
// away from. Best-effort + idempotent (no-op when no service file exists,
|
|
791
|
+
// which is the common pre-0.6.2 case).
|
|
792
|
+
const legacyRemoval = r.removeService({ tunnelName: DEFAULT_TUNNEL_NAME });
|
|
793
|
+
for (const line of legacyRemoval.messages) r.log(line);
|
|
794
|
+
if (r.alive(legacy.pid)) {
|
|
795
|
+
try {
|
|
796
|
+
r.kill(legacy.pid, "SIGTERM");
|
|
797
|
+
} catch {
|
|
798
|
+
// Already gone between read and kill — fine; we drop the record below.
|
|
799
|
+
}
|
|
800
|
+
r.log(
|
|
801
|
+
`Stopped legacy shared-tunnel connector (migrated ${hostname} to dedicated tunnel ${r.tunnelName}).`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
// Drop the legacy shared-tunnel record whether or not its connector was
|
|
805
|
+
// still alive. A dead record would otherwise linger across re-exposes
|
|
806
|
+
// until the next `off`; clearing it here keeps state tidy (#491 review).
|
|
807
|
+
migratedState = withoutTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Install the reboot-persistent connector OS service (launchd / systemd) so
|
|
812
|
+
// the connector survives a reboot — replacing the bare `proc.unref()` spawn
|
|
813
|
+
// that died on restart (0.6.2). When the service installs successfully it
|
|
814
|
+
// *becomes* the connector: it spawns + supervises `cloudflared tunnel run`,
|
|
815
|
+
// so we do NOT also leave a duplicate transient connector — exactly one
|
|
816
|
+
// process serves the config we wrote. We then discover the service-spawned
|
|
817
|
+
// connector's pid (by UUID/config match) to record in state so the next
|
|
818
|
+
// up-path's orphan sweep + the off-path's kill target the right process.
|
|
819
|
+
//
|
|
820
|
+
// Graceful fallback: if the service tool is missing / the install fails, we
|
|
821
|
+
// fall back to the prior transient `proc.unref()` spawn and warn it won't
|
|
822
|
+
// survive a reboot. The expose never hard-fails because the service didn't
|
|
823
|
+
// take.
|
|
824
|
+
const installResult = r.installService({
|
|
825
|
+
tunnelName: r.tunnelName,
|
|
826
|
+
configPath: r.configPath,
|
|
827
|
+
logPath: r.logPath,
|
|
828
|
+
});
|
|
829
|
+
for (const line of installResult.messages) r.log(line);
|
|
830
|
+
|
|
831
|
+
let pid: number;
|
|
832
|
+
let serviceManaged = false;
|
|
833
|
+
if (installResult.outcome === "installed") {
|
|
834
|
+
serviceManaged = true;
|
|
835
|
+
// The service (`enable --now` / `bootstrap` with RunAtLoad) already started
|
|
836
|
+
// the connector; discover its pid for the state record so the next up-path's
|
|
837
|
+
// orphan sweep + the off-path's kill target the right process. In practice
|
|
838
|
+
// the connector is up by the time we look here.
|
|
839
|
+
const managedPids = r.connectorPids(tunnel.id, r.configPath).filter((p) => r.alive(p));
|
|
840
|
+
if (managedPids.length > 0) {
|
|
841
|
+
pid = managedPids[0]!;
|
|
842
|
+
} else {
|
|
843
|
+
// Service is enabled (survives reboot) but we couldn't see its connector
|
|
844
|
+
// yet. Spawn a transient one so state carries a live pid + connectivity
|
|
845
|
+
// is immediate; the service takes over on the next reboot regardless.
|
|
846
|
+
pid = r.spawner.spawn(["cloudflared", "tunnel", "--config", r.configPath, "run"], r.logPath);
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
// Fallback: no boot service. Spawn the transient connector (won't survive
|
|
850
|
+
// a reboot — warned below).
|
|
851
|
+
pid = r.spawner.spawn(["cloudflared", "tunnel", "--config", r.configPath, "run"], r.logPath);
|
|
852
|
+
}
|
|
679
853
|
|
|
680
854
|
const record: CloudflaredTunnelRecord = {
|
|
681
855
|
pid,
|
|
@@ -684,8 +858,11 @@ export async function exposeCloudflareUp(
|
|
|
684
858
|
hostname,
|
|
685
859
|
startedAt: r.now().toISOString(),
|
|
686
860
|
configPath: r.configPath,
|
|
861
|
+
// Only serialize the flag when true — keep the state JSON clean for the
|
|
862
|
+
// common (transient-fallback) case; absent reads as unmanaged.
|
|
863
|
+
...(serviceManaged ? { serviceManaged: true } : {}),
|
|
687
864
|
};
|
|
688
|
-
writeCloudflaredState(withTunnelRecord(
|
|
865
|
+
writeCloudflaredState(withTunnelRecord(migratedState, record), r.statePath);
|
|
689
866
|
|
|
690
867
|
// Persist the shared cross-provider expose record. Without this, the
|
|
691
868
|
// Tailscale path was the only one writing expose-state.json — so after a
|
|
@@ -763,12 +940,29 @@ export async function exposeCloudflareUp(
|
|
|
763
940
|
|
|
764
941
|
r.log("");
|
|
765
942
|
r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
|
|
943
|
+
r.log(` Tunnel: ${r.tunnelName} (dedicated to this machine)`);
|
|
766
944
|
r.log(` Open: ${baseUrl}/`);
|
|
767
945
|
r.log(` Admin: ${baseUrl}/admin/`);
|
|
768
946
|
r.log(` Vault: ${vaultUrl}`);
|
|
769
947
|
r.log(` OAuth: ${hubOrigin}`);
|
|
770
948
|
r.log(` Logs: ${r.logPath}`);
|
|
771
949
|
r.log("");
|
|
950
|
+
if (serviceManaged) {
|
|
951
|
+
// The connector is now an OS service (launchd LaunchAgent on macOS, systemd
|
|
952
|
+
// unit on Linux), so it starts on boot — no need to re-run expose after a
|
|
953
|
+
// reboot. `parachute expose public --cloudflare off` stops + removes it.
|
|
954
|
+
r.log("The connector runs on boot (via launchd/systemd) — it survives reboots. Re-running");
|
|
955
|
+
r.log("the same command is still idempotent (same hostname → same dedicated tunnel).");
|
|
956
|
+
} else {
|
|
957
|
+
// Honest reboot caveat for the fallback path: the boot service couldn't be
|
|
958
|
+
// installed (tool missing / unsupported platform / install failed — see the
|
|
959
|
+
// warning printed above), so the connector is a detached background process
|
|
960
|
+
// that does NOT survive a reboot. Re-running the same command brings it back.
|
|
961
|
+
r.log("Note: a boot service couldn't be installed (see above), so the connector runs in the");
|
|
962
|
+
r.log("background but does NOT survive a reboot. After a reboot, re-run:");
|
|
963
|
+
r.log(` parachute expose public --cloudflare --domain ${hostname}`);
|
|
964
|
+
}
|
|
965
|
+
r.log("");
|
|
772
966
|
r.log("Point a claude.ai / ChatGPT connector at:");
|
|
773
967
|
r.log(` ${vaultUrl}`);
|
|
774
968
|
printAuthGuidance(r.log, vaultUrl);
|
|
@@ -784,30 +978,37 @@ export async function exposeCloudflareUp(
|
|
|
784
978
|
return 0;
|
|
785
979
|
}
|
|
786
980
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
981
|
+
/**
|
|
982
|
+
* Tear down ONE tunnel record: SIGTERM its connector, sweep any orphan
|
|
983
|
+
* connectors for it (hub#487), drop its state record, and emit the
|
|
984
|
+
* reuse-hint copy. Pure-ish over `r` + the current state: returns the state
|
|
985
|
+
* with the record removed (or undefined when that empties it) plus an exit
|
|
986
|
+
* code, so the caller commits the disk write once after tearing down one or
|
|
987
|
+
* many tunnels. The connector kill is non-fatal-on-already-gone, fatal only
|
|
988
|
+
* when SIGTERM itself errors on a live pid.
|
|
989
|
+
*/
|
|
990
|
+
function teardownOne(
|
|
991
|
+
r: Resolved,
|
|
992
|
+
state: CloudflaredState | undefined,
|
|
993
|
+
record: CloudflaredTunnelRecord,
|
|
994
|
+
): { state: CloudflaredState | undefined; code: number } {
|
|
995
|
+
// Remove the reboot-persistent connector service FIRST (when one owns this
|
|
996
|
+
// tunnel) so a still-enabled launchd/systemd service doesn't immediately
|
|
997
|
+
// restart the connector we SIGTERM below. `removeConnectorService` is
|
|
998
|
+
// idempotent + best-effort: a missing service file is a no-op (covers
|
|
999
|
+
// transient-fallback tunnels and pre-0.6.2 records, which carry no service).
|
|
1000
|
+
// We always attempt it — even when `serviceManaged` is unset — so a record
|
|
1001
|
+
// written before this field existed, or an out-of-band service file, still
|
|
1002
|
+
// gets swept. The removal stops the service, after which the SIGTERM is final.
|
|
1003
|
+
const removal = r.removeService({ tunnelName: record.tunnelName });
|
|
1004
|
+
for (const line of removal.messages) r.log(line);
|
|
804
1005
|
if (r.alive(record.pid)) {
|
|
805
1006
|
try {
|
|
806
1007
|
r.kill(record.pid, "SIGTERM");
|
|
807
|
-
r.log(`✓ Stopped cloudflared (pid ${record.pid}).`);
|
|
1008
|
+
r.log(`✓ Stopped cloudflared (pid ${record.pid}, tunnel "${record.tunnelName}").`);
|
|
808
1009
|
} catch (err) {
|
|
809
1010
|
r.log(`✗ Failed to stop cloudflared: ${err instanceof Error ? err.message : String(err)}`);
|
|
810
|
-
return 1;
|
|
1011
|
+
return { state, code: 1 };
|
|
811
1012
|
}
|
|
812
1013
|
} else {
|
|
813
1014
|
r.log(`cloudflared (pid ${record.pid}) wasn't running; clearing stale state.`);
|
|
@@ -824,9 +1025,80 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
|
|
|
824
1025
|
// Already gone between probe and kill — fine.
|
|
825
1026
|
}
|
|
826
1027
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1028
|
+
r.log(` ${record.hostname} is no longer reachable through this machine.`);
|
|
1029
|
+
r.log(
|
|
1030
|
+
` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
|
|
1031
|
+
);
|
|
1032
|
+
// Only suggest `--tunnel-name` for a custom name. The auto-derived name
|
|
1033
|
+
// (and the legacy shared "parachute" name) need no flag — re-running with
|
|
1034
|
+
// just --domain re-derives the per-hostname name (and migrates a legacy
|
|
1035
|
+
// record off the shared tunnel), which is exactly what we want.
|
|
1036
|
+
const isAutoName =
|
|
1037
|
+
record.tunnelName === deriveTunnelName(record.hostname) ||
|
|
1038
|
+
record.tunnelName === DEFAULT_TUNNEL_NAME;
|
|
1039
|
+
r.log(
|
|
1040
|
+
` \`parachute expose public --cloudflare --domain ${record.hostname}${isAutoName ? "" : ` --tunnel-name ${record.tunnelName}`}\` reuses it.`,
|
|
1041
|
+
);
|
|
1042
|
+
return { state: withoutTunnelRecord(state, record.tunnelName), code: 0 };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
|
|
1046
|
+
// The off-path has no hostname to derive a name from. When `--tunnel-name`
|
|
1047
|
+
// is set we use it; otherwise we resolve from cloudflared-state.json (below).
|
|
1048
|
+
// `DEFAULT_TUNNEL_NAME` is only the inert `resolve` fallback here — the
|
|
1049
|
+
// state-driven branch never relies on it.
|
|
1050
|
+
const r = resolve(opts, DEFAULT_TUNNEL_NAME);
|
|
1051
|
+
const stateBefore = readCloudflaredState(r.statePath);
|
|
1052
|
+
const records = listTunnelRecords(stateBefore);
|
|
1053
|
+
|
|
1054
|
+
// Decide which records to tear down.
|
|
1055
|
+
// - explicit `--tunnel-name` → exactly that one (or a not-found message).
|
|
1056
|
+
// - no flag, 0 tunnels → nothing to do.
|
|
1057
|
+
// - no flag, exactly 1 → that one.
|
|
1058
|
+
// - no flag, ≥2 → ALL of them. A bare `expose public
|
|
1059
|
+
// --cloudflare off` means "stop all public Cloudflare exposure on this
|
|
1060
|
+
// machine"; tearing down only one would leave the box half-exposed with
|
|
1061
|
+
// no obvious signal which tunnel survived.
|
|
1062
|
+
let targets: CloudflaredTunnelRecord[];
|
|
1063
|
+
if (opts.tunnelName !== undefined) {
|
|
1064
|
+
const record = findTunnelRecord(stateBefore, r.tunnelName);
|
|
1065
|
+
if (!record) {
|
|
1066
|
+
if (records.length > 0) {
|
|
1067
|
+
const others = records.map((t) => t.tunnelName).join(", ");
|
|
1068
|
+
r.log(
|
|
1069
|
+
`No Cloudflare exposure recorded for tunnel "${r.tunnelName}". Other tunnels: ${others}.`,
|
|
1070
|
+
);
|
|
1071
|
+
} else {
|
|
1072
|
+
r.log("No Cloudflare exposure recorded. Nothing to tear down.");
|
|
1073
|
+
}
|
|
1074
|
+
return 0;
|
|
1075
|
+
}
|
|
1076
|
+
targets = [record];
|
|
1077
|
+
} else {
|
|
1078
|
+
if (records.length === 0) {
|
|
1079
|
+
r.log("No Cloudflare exposure recorded. Nothing to tear down.");
|
|
1080
|
+
return 0;
|
|
1081
|
+
}
|
|
1082
|
+
if (records.length > 1) {
|
|
1083
|
+
r.log(
|
|
1084
|
+
`Tearing down all ${records.length} recorded Cloudflare tunnels: ${records
|
|
1085
|
+
.map((t) => t.tunnelName)
|
|
1086
|
+
.join(", ")}.`,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
targets = records;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let state = stateBefore;
|
|
1093
|
+
let failed = false;
|
|
1094
|
+
for (const record of targets) {
|
|
1095
|
+
const result = teardownOne(r, state, record);
|
|
1096
|
+
state = result.state;
|
|
1097
|
+
if (result.code !== 0) failed = true;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (state) {
|
|
1101
|
+
writeCloudflaredState(state, r.statePath);
|
|
830
1102
|
} else {
|
|
831
1103
|
clearCloudflaredState(r.statePath);
|
|
832
1104
|
}
|
|
@@ -834,17 +1106,10 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
|
|
|
834
1106
|
// downstream consumers stop resolving the now-dead public URL (mirrors the
|
|
835
1107
|
// up-path write above + the Tailscale off-path's expose-state teardown). When
|
|
836
1108
|
// other tunnels survive we leave it — a later off for the last one clears it.
|
|
837
|
-
if (!
|
|
1109
|
+
if (!state) {
|
|
838
1110
|
clearExposeState(r.exposeStatePath);
|
|
839
1111
|
}
|
|
840
|
-
|
|
841
|
-
r.log(
|
|
842
|
-
` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
|
|
843
|
-
);
|
|
844
|
-
r.log(
|
|
845
|
-
` \`parachute expose public --cloudflare --domain ${record.hostname}${record.tunnelName === DEFAULT_TUNNEL_NAME ? "" : ` --tunnel-name ${record.tunnelName}`}\` reuses it.`,
|
|
846
|
-
);
|
|
847
|
-
return 0;
|
|
1112
|
+
return failed ? 1 : 0;
|
|
848
1113
|
}
|
|
849
1114
|
|
|
850
1115
|
function reportCloudflaredError(err: unknown, log: (line: string) => void): number {
|
package/src/help.ts
CHANGED
|
@@ -369,8 +369,13 @@ Flags:
|
|
|
369
369
|
--domain <hostname> fully-qualified hostname to route through the tunnel
|
|
370
370
|
(e.g. vault.example.com). The apex must be a zone on
|
|
371
371
|
your Cloudflare account.
|
|
372
|
-
--tunnel-name <name> Cloudflare tunnel name
|
|
373
|
-
|
|
372
|
+
--tunnel-name <name> Cloudflare tunnel name. Defaults to a per-hostname
|
|
373
|
+
name (e.g. vault.example.com → parachute-vault-example-com)
|
|
374
|
+
so each machine gets its OWN dedicated tunnel —
|
|
375
|
+
Cloudflare tunnels are account-wide, and sharing one
|
|
376
|
+
across machines collides their connectors. You don't
|
|
377
|
+
need to create the tunnel yourself; expose does it.
|
|
378
|
+
Override only to pin a specific name.
|
|
374
379
|
--skip-provider-check bypass non-TTY auto-detect, default to Tailscale
|
|
375
380
|
Funnel as before. Intended for CI / scripts whose
|
|
376
381
|
environment is already pre-flighted.
|