@openparachute/hub 0.6.1 → 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 +1 -1
- package/src/__tests__/account-home-ui.test.ts +34 -0
- package/src/__tests__/cloudflare-connector-service.test.ts +441 -0
- package/src/__tests__/expose-cloudflare.test.ts +272 -0
- package/src/account-home-ui.ts +4 -1
- package/src/cloudflare/connector-service.ts +478 -0
- package/src/cloudflare/state.ts +13 -1
- package/src/commands/expose-cloudflare.ts +143 -10
|
@@ -7,6 +7,13 @@ import {
|
|
|
7
7
|
deriveTunnelName,
|
|
8
8
|
writeConfig,
|
|
9
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";
|
|
10
17
|
import {
|
|
11
18
|
DEFAULT_CLOUDFLARED_HOME,
|
|
12
19
|
cloudflaredInstallHint,
|
|
@@ -255,6 +262,27 @@ export interface ExposeCloudflareOpts {
|
|
|
255
262
|
* `defaultConnectorPids` (a filtered `pgrep -fl cloudflared`).
|
|
256
263
|
*/
|
|
257
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;
|
|
258
286
|
/**
|
|
259
287
|
* Resolve a hostname to its addresses, for the post-route DNS self-diagnosis
|
|
260
288
|
* (hub#487). Returns the resolved IPs (empty when NXDOMAIN / not yet live).
|
|
@@ -355,6 +383,12 @@ interface Resolved {
|
|
|
355
383
|
alive: AliveFn;
|
|
356
384
|
kill: KillFn;
|
|
357
385
|
connectorPids: ConnectorPidsFn;
|
|
386
|
+
installService: (args: {
|
|
387
|
+
tunnelName: string;
|
|
388
|
+
configPath: string;
|
|
389
|
+
logPath: string;
|
|
390
|
+
}) => InstallResult;
|
|
391
|
+
removeService: (args: { tunnelName: string }) => RemoveResult;
|
|
358
392
|
resolveHost: ResolveHostFn;
|
|
359
393
|
log: (line: string) => void;
|
|
360
394
|
manifestPath: string;
|
|
@@ -408,6 +442,39 @@ function resolve(opts: ExposeCloudflareOpts, tunnelNameDefault: string): Resolve
|
|
|
408
442
|
// gets the real `pgrep` sweep + DNS diagnosis.
|
|
409
443
|
connectorPids:
|
|
410
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: [] })),
|
|
411
478
|
resolveHost:
|
|
412
479
|
opts.resolveHost ??
|
|
413
480
|
(opts.spawner === undefined ? defaultResolveHost : async () => ["104.16.0.1"]),
|
|
@@ -718,6 +785,12 @@ export async function exposeCloudflareUp(
|
|
|
718
785
|
if (r.tunnelName !== DEFAULT_TUNNEL_NAME) {
|
|
719
786
|
const legacy = findTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
|
|
720
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);
|
|
721
794
|
if (r.alive(legacy.pid)) {
|
|
722
795
|
try {
|
|
723
796
|
r.kill(legacy.pid, "SIGTERM");
|
|
@@ -735,10 +808,48 @@ export async function exposeCloudflareUp(
|
|
|
735
808
|
}
|
|
736
809
|
}
|
|
737
810
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
+
}
|
|
742
853
|
|
|
743
854
|
const record: CloudflaredTunnelRecord = {
|
|
744
855
|
pid,
|
|
@@ -747,6 +858,9 @@ export async function exposeCloudflareUp(
|
|
|
747
858
|
hostname,
|
|
748
859
|
startedAt: r.now().toISOString(),
|
|
749
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 } : {}),
|
|
750
864
|
};
|
|
751
865
|
writeCloudflaredState(withTunnelRecord(migratedState, record), r.statePath);
|
|
752
866
|
|
|
@@ -833,12 +947,21 @@ export async function exposeCloudflareUp(
|
|
|
833
947
|
r.log(` OAuth: ${hubOrigin}`);
|
|
834
948
|
r.log(` Logs: ${r.logPath}`);
|
|
835
949
|
r.log("");
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
+
}
|
|
842
965
|
r.log("");
|
|
843
966
|
r.log("Point a claude.ai / ChatGPT connector at:");
|
|
844
967
|
r.log(` ${vaultUrl}`);
|
|
@@ -869,6 +992,16 @@ function teardownOne(
|
|
|
869
992
|
state: CloudflaredState | undefined,
|
|
870
993
|
record: CloudflaredTunnelRecord,
|
|
871
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);
|
|
872
1005
|
if (r.alive(record.pid)) {
|
|
873
1006
|
try {
|
|
874
1007
|
r.kill(record.pid, "SIGTERM");
|