@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
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import type { InstallResult, RemoveResult } from "../cloudflare/connector-service.ts";
|
|
5
6
|
import {
|
|
6
7
|
type CloudflaredTunnelRecord,
|
|
7
8
|
findTunnelRecord,
|
|
@@ -119,12 +120,16 @@ describe("exposeCloudflareUp", () => {
|
|
|
119
120
|
const env = makeEnv();
|
|
120
121
|
try {
|
|
121
122
|
const uuid = "2c1a7c7e-1234-5678-9abc-def012345678";
|
|
123
|
+
// Default tunnel name is now per-hostname (#491): vault.example.com →
|
|
124
|
+
// parachute-vault-example-com. Each machine gets its own dedicated tunnel
|
|
125
|
+
// so account-wide tunnels don't collide across boxes.
|
|
126
|
+
const derived = "parachute-vault-example-com";
|
|
122
127
|
const { runner, calls } = queueRunner([
|
|
123
128
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version preflight
|
|
124
129
|
{ code: 0, stdout: "[]", stderr: "" }, // tunnel list (none yet)
|
|
125
130
|
{
|
|
126
131
|
code: 0,
|
|
127
|
-
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel
|
|
132
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel ${derived} with id ${uuid}\n`,
|
|
128
133
|
stderr: "",
|
|
129
134
|
}, // tunnel create
|
|
130
135
|
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
@@ -158,14 +163,14 @@ describe("exposeCloudflareUp", () => {
|
|
|
158
163
|
]);
|
|
159
164
|
expect(calls[0]!.cmd).toEqual(["cloudflared", "--version"]);
|
|
160
165
|
expect(calls[1]!.cmd).toEqual(["cloudflared", "tunnel", "list", "--output", "json"]);
|
|
161
|
-
expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create",
|
|
166
|
+
expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", derived]);
|
|
162
167
|
expect(calls[3]!.cmd).toEqual([
|
|
163
168
|
"cloudflared",
|
|
164
169
|
"tunnel",
|
|
165
170
|
"route",
|
|
166
171
|
"dns",
|
|
167
172
|
"--overwrite-dns",
|
|
168
|
-
|
|
173
|
+
derived,
|
|
169
174
|
"vault.example.com",
|
|
170
175
|
]);
|
|
171
176
|
expect(seen[0]).toEqual(["cloudflared", "tunnel", "--config", env.configPath, "run"]);
|
|
@@ -174,10 +179,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
174
179
|
expect(state).toEqual({
|
|
175
180
|
version: 2,
|
|
176
181
|
tunnels: {
|
|
177
|
-
|
|
182
|
+
[derived]: {
|
|
178
183
|
pid: 42000,
|
|
179
184
|
tunnelUuid: uuid,
|
|
180
|
-
tunnelName:
|
|
185
|
+
tunnelName: derived,
|
|
181
186
|
hostname: "vault.example.com",
|
|
182
187
|
startedAt: "2026-04-22T12:00:00.000Z",
|
|
183
188
|
configPath: env.configPath,
|
|
@@ -248,6 +253,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
248
253
|
cloudflaredHome: env.cloudflaredHome,
|
|
249
254
|
configDir: env.configDir,
|
|
250
255
|
skipHub: true,
|
|
256
|
+
// Pin the legacy shared name so this test's substance (expose-state
|
|
257
|
+
// write) is isolated from the per-hostname-derivation change (#491);
|
|
258
|
+
// the queued runner output names the "parachute" tunnel.
|
|
259
|
+
tunnelName: "parachute",
|
|
251
260
|
});
|
|
252
261
|
|
|
253
262
|
expect(code).toBe(0);
|
|
@@ -419,6 +428,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
419
428
|
cloudflaredHome: env.cloudflaredHome,
|
|
420
429
|
configDir: env.configDir,
|
|
421
430
|
skipHub: true,
|
|
431
|
+
// Pin the legacy name: the queued `tunnel list` reports a "parachute"
|
|
432
|
+
// tunnel, so reuse only happens when we look it up by that name. The
|
|
433
|
+
// per-hostname default (#491) is exercised in the happy-path test.
|
|
434
|
+
tunnelName: "parachute",
|
|
422
435
|
});
|
|
423
436
|
expect(code).toBe(0);
|
|
424
437
|
// No `tunnel create` — only list + route.
|
|
@@ -604,6 +617,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
604
617
|
cloudflaredHome: env.cloudflaredHome,
|
|
605
618
|
configDir: env.configDir,
|
|
606
619
|
skipHub: true,
|
|
620
|
+
// Pin the legacy name so reuse (queued "parachute" list) drives the
|
|
621
|
+
// route-dns failure under test, not a tunnel-create from the
|
|
622
|
+
// per-hostname default (#491).
|
|
623
|
+
tunnelName: "parachute",
|
|
607
624
|
});
|
|
608
625
|
|
|
609
626
|
expect(code).toBe(1);
|
|
@@ -656,6 +673,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
656
673
|
cloudflaredHome: env.cloudflaredHome,
|
|
657
674
|
configDir: env.configDir,
|
|
658
675
|
skipHub: true,
|
|
676
|
+
// Pin the legacy name so the prior record (keyed "parachute") matches
|
|
677
|
+
// this invocation's tunnel — the orphan-sweep behavior under test is
|
|
678
|
+
// independent of the per-hostname-derivation change (#491).
|
|
679
|
+
tunnelName: "parachute",
|
|
659
680
|
});
|
|
660
681
|
|
|
661
682
|
expect(code).toBe(0);
|
|
@@ -712,6 +733,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
712
733
|
cloudflaredHome: env.cloudflaredHome,
|
|
713
734
|
configDir: env.configDir,
|
|
714
735
|
skipHub: true,
|
|
736
|
+
// Pin the legacy name so the prior record (keyed "parachute") matches —
|
|
737
|
+
// the orphan-sweep behavior under test is independent of #491.
|
|
738
|
+
tunnelName: "parachute",
|
|
715
739
|
});
|
|
716
740
|
|
|
717
741
|
expect(code).toBe(0);
|
|
@@ -757,6 +781,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
757
781
|
cloudflaredHome: env.cloudflaredHome,
|
|
758
782
|
configDir: env.configDir,
|
|
759
783
|
skipHub: true,
|
|
784
|
+
// Pin the legacy name so reuse drives the DNS-diagnosis path under test
|
|
785
|
+
// (queued "parachute" list), not a create from the #491 default.
|
|
786
|
+
tunnelName: "parachute",
|
|
760
787
|
});
|
|
761
788
|
|
|
762
789
|
expect(code).toBe(0); // non-fatal — the expose still completes
|
|
@@ -801,6 +828,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
801
828
|
cloudflaredHome: env.cloudflaredHome,
|
|
802
829
|
configDir: env.configDir,
|
|
803
830
|
skipHub: true,
|
|
831
|
+
// Pin the legacy name so reuse drives the shadowed-DNS path under test
|
|
832
|
+
// (queued "parachute" list), not a create from the #491 default.
|
|
833
|
+
tunnelName: "parachute",
|
|
804
834
|
});
|
|
805
835
|
|
|
806
836
|
expect(code).toBe(0);
|
|
@@ -841,6 +871,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
841
871
|
cloudflaredHome: env.cloudflaredHome,
|
|
842
872
|
configDir: env.configDir,
|
|
843
873
|
skipHub: true,
|
|
874
|
+
// Pin the legacy name so reuse drives the no-warning path under test
|
|
875
|
+
// (queued "parachute" list), not a create from the #491 default.
|
|
876
|
+
tunnelName: "parachute",
|
|
844
877
|
});
|
|
845
878
|
|
|
846
879
|
expect(code).toBe(0);
|
|
@@ -857,13 +890,15 @@ describe("exposeCloudflareUp", () => {
|
|
|
857
890
|
try {
|
|
858
891
|
const uuidA = "aaaa1111-aaaa-1111-aaaa-111111111111";
|
|
859
892
|
const uuidB = "bbbb2222-bbbb-2222-bbbb-222222222222";
|
|
860
|
-
// Up #1 — default
|
|
893
|
+
// Up #1 — per-hostname default (#491): alpha.example.com →
|
|
894
|
+
// parachute-alpha-example-com.
|
|
895
|
+
const derivedA = "parachute-alpha-example-com";
|
|
861
896
|
const r1 = queueRunner([
|
|
862
897
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
863
898
|
{ code: 0, stdout: "[]", stderr: "" },
|
|
864
899
|
{
|
|
865
900
|
code: 0,
|
|
866
|
-
stdout: `Created tunnel
|
|
901
|
+
stdout: `Created tunnel ${derivedA} with id ${uuidA}\n`,
|
|
867
902
|
stderr: "",
|
|
868
903
|
},
|
|
869
904
|
{ code: 0, stdout: "", stderr: "" },
|
|
@@ -881,10 +916,11 @@ describe("exposeCloudflareUp", () => {
|
|
|
881
916
|
cloudflaredHome: env.cloudflaredHome,
|
|
882
917
|
configDir: env.configDir,
|
|
883
918
|
skipHub: true,
|
|
884
|
-
// Omit configPath/logPath so
|
|
885
|
-
//
|
|
886
|
-
// generated config.yml lands under
|
|
887
|
-
//
|
|
919
|
+
// Omit configPath/logPath AND tunnelName so the name is the per-hostname
|
|
920
|
+
// derived default (#491) and the paths are per-tunnel-derived against
|
|
921
|
+
// the tmp `configDir` above — so the generated config.yml lands under
|
|
922
|
+
// tmp/cloudflared/parachute-alpha-example-com/, not the operator's real
|
|
923
|
+
// ~/.parachute.
|
|
888
924
|
});
|
|
889
925
|
expect(code1).toBe(0);
|
|
890
926
|
|
|
@@ -916,19 +952,20 @@ describe("exposeCloudflareUp", () => {
|
|
|
916
952
|
});
|
|
917
953
|
expect(code2).toBe(0);
|
|
918
954
|
|
|
919
|
-
// Both tunnels should be present in state, keyed by tunnel name
|
|
955
|
+
// Both tunnels should be present in state, keyed by tunnel name: the
|
|
956
|
+
// per-hostname derived name for #1, the explicit override for #2.
|
|
920
957
|
const state = readCloudflaredState(env.statePath);
|
|
921
|
-
expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual([
|
|
922
|
-
expect(findTunnelRecord(state,
|
|
958
|
+
expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual([derivedA, "second"]);
|
|
959
|
+
expect(findTunnelRecord(state, derivedA)?.hostname).toBe("alpha.example.com");
|
|
923
960
|
expect(findTunnelRecord(state, "second")?.hostname).toBe("beta.example.com");
|
|
924
961
|
expect(findTunnelRecord(state, "second")?.pid).toBe(50002);
|
|
925
962
|
|
|
926
963
|
// Each tunnel should have written its own config file at the per-tunnel
|
|
927
964
|
// path under `~/.parachute/cloudflared/<tunnelName>/config.yml`.
|
|
928
|
-
const cfgA = findTunnelRecord(state,
|
|
965
|
+
const cfgA = findTunnelRecord(state, derivedA)?.configPath ?? "";
|
|
929
966
|
const cfgB = findTunnelRecord(state, "second")?.configPath ?? "";
|
|
930
967
|
expect(cfgA).not.toBe(cfgB);
|
|
931
|
-
expect(cfgA.endsWith(
|
|
968
|
+
expect(cfgA.endsWith(`/${derivedA}/config.yml`)).toBe(true);
|
|
932
969
|
expect(cfgB.endsWith("/second/config.yml")).toBe(true);
|
|
933
970
|
expect(existsSync(cfgA)).toBe(true);
|
|
934
971
|
expect(existsSync(cfgB)).toBe(true);
|
|
@@ -937,6 +974,267 @@ describe("exposeCloudflareUp", () => {
|
|
|
937
974
|
}
|
|
938
975
|
});
|
|
939
976
|
|
|
977
|
+
describe("#491: per-hostname tunnel naming + legacy migration", () => {
|
|
978
|
+
test("explicit --tunnel-name overrides the per-hostname default", async () => {
|
|
979
|
+
const env = makeEnv();
|
|
980
|
+
try {
|
|
981
|
+
const uuid = "11112222-3333-4444-5555-666677778888";
|
|
982
|
+
const { runner, calls } = queueRunner([
|
|
983
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
984
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
985
|
+
{ code: 0, stdout: `Created tunnel custom-name with id ${uuid}\n`, stderr: "" },
|
|
986
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
987
|
+
]);
|
|
988
|
+
const { spawner } = fakeSpawner(43000);
|
|
989
|
+
|
|
990
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
991
|
+
runner,
|
|
992
|
+
spawner,
|
|
993
|
+
alive: () => false,
|
|
994
|
+
kill: () => {},
|
|
995
|
+
log: () => {},
|
|
996
|
+
manifestPath: env.manifestPath,
|
|
997
|
+
statePath: env.statePath,
|
|
998
|
+
exposeStatePath: env.exposeStatePath,
|
|
999
|
+
configPath: env.configPath,
|
|
1000
|
+
logPath: env.logPath,
|
|
1001
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1002
|
+
configDir: env.configDir,
|
|
1003
|
+
skipHub: true,
|
|
1004
|
+
tunnelName: "custom-name",
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
expect(code).toBe(0);
|
|
1008
|
+
// The explicit name wins — NOT the derived parachute-our-parachute-computer.
|
|
1009
|
+
expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", "custom-name"]);
|
|
1010
|
+
const state = readCloudflaredState(env.statePath);
|
|
1011
|
+
expect(findTunnelRecord(state, "custom-name")?.hostname).toBe("our.parachute.computer");
|
|
1012
|
+
expect(findTunnelRecord(state, "parachute-our-parachute-computer")).toBeUndefined();
|
|
1013
|
+
} finally {
|
|
1014
|
+
env.cleanup();
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test("legacy-sweep: stops a live shared 'parachute' connector when migrating to a derived name", async () => {
|
|
1019
|
+
const env = makeEnv();
|
|
1020
|
+
try {
|
|
1021
|
+
// A box that was exposed under the old shared "parachute" tunnel.
|
|
1022
|
+
const legacy: CloudflaredTunnelRecord = {
|
|
1023
|
+
pid: 70001,
|
|
1024
|
+
tunnelUuid: "legacy-uuid",
|
|
1025
|
+
tunnelName: "parachute",
|
|
1026
|
+
hostname: "our.parachute.computer",
|
|
1027
|
+
startedAt: "2026-05-01T00:00:00.000Z",
|
|
1028
|
+
configPath: "/tmp/legacy/parachute/config.yml",
|
|
1029
|
+
};
|
|
1030
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: legacy } }, env.statePath);
|
|
1031
|
+
|
|
1032
|
+
const uuid = "99990000-1111-2222-3333-444455556666";
|
|
1033
|
+
const { runner } = queueRunner([
|
|
1034
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1035
|
+
{ code: 0, stdout: "[]", stderr: "" }, // new derived tunnel doesn't exist yet
|
|
1036
|
+
{
|
|
1037
|
+
code: 0,
|
|
1038
|
+
stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
|
|
1039
|
+
stderr: "",
|
|
1040
|
+
},
|
|
1041
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns (--overwrite-dns repoints the CNAME)
|
|
1042
|
+
]);
|
|
1043
|
+
const { spawner } = fakeSpawner(70100);
|
|
1044
|
+
const killed: number[] = [];
|
|
1045
|
+
const logs: string[] = [];
|
|
1046
|
+
|
|
1047
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
1048
|
+
runner,
|
|
1049
|
+
spawner,
|
|
1050
|
+
// The legacy connector (70001) is alive; the new spawn is 70100.
|
|
1051
|
+
alive: (pid) => pid === 70001,
|
|
1052
|
+
kill: (pid) => killed.push(pid),
|
|
1053
|
+
connectorPids: () => [],
|
|
1054
|
+
resolveHost: async () => ["104.16.0.1"],
|
|
1055
|
+
log: (l) => logs.push(l),
|
|
1056
|
+
manifestPath: env.manifestPath,
|
|
1057
|
+
statePath: env.statePath,
|
|
1058
|
+
exposeStatePath: env.exposeStatePath,
|
|
1059
|
+
configPath: env.configPath,
|
|
1060
|
+
logPath: env.logPath,
|
|
1061
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1062
|
+
configDir: env.configDir,
|
|
1063
|
+
skipHub: true,
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
expect(code).toBe(0);
|
|
1067
|
+
// The legacy shared connector got SIGTERM'd.
|
|
1068
|
+
expect(killed).toContain(70001);
|
|
1069
|
+
const joined = logs.join("\n");
|
|
1070
|
+
expect(joined).toContain("Stopped legacy shared-tunnel connector");
|
|
1071
|
+
expect(joined).toContain("migrated our.parachute.computer to dedicated tunnel");
|
|
1072
|
+
// The legacy "parachute" record is gone; only the new derived one remains.
|
|
1073
|
+
const state = readCloudflaredState(env.statePath);
|
|
1074
|
+
expect(findTunnelRecord(state, "parachute")).toBeUndefined();
|
|
1075
|
+
expect(findTunnelRecord(state, "parachute-our-parachute-computer")?.pid).toBe(70100);
|
|
1076
|
+
} finally {
|
|
1077
|
+
env.cleanup();
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
test("legacy-sweep: does NOT fire when no legacy 'parachute' record exists", async () => {
|
|
1082
|
+
const env = makeEnv();
|
|
1083
|
+
try {
|
|
1084
|
+
const uuid = "aaaa9999-1111-2222-3333-444455556666";
|
|
1085
|
+
const { runner } = queueRunner([
|
|
1086
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1087
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
1088
|
+
{
|
|
1089
|
+
code: 0,
|
|
1090
|
+
stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
|
|
1091
|
+
stderr: "",
|
|
1092
|
+
},
|
|
1093
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
1094
|
+
]);
|
|
1095
|
+
const { spawner } = fakeSpawner(70200);
|
|
1096
|
+
const logs: string[] = [];
|
|
1097
|
+
|
|
1098
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
1099
|
+
runner,
|
|
1100
|
+
spawner,
|
|
1101
|
+
alive: () => false,
|
|
1102
|
+
kill: () => {},
|
|
1103
|
+
connectorPids: () => [],
|
|
1104
|
+
resolveHost: async () => ["104.16.0.1"],
|
|
1105
|
+
log: (l) => logs.push(l),
|
|
1106
|
+
manifestPath: env.manifestPath,
|
|
1107
|
+
statePath: env.statePath,
|
|
1108
|
+
exposeStatePath: env.exposeStatePath,
|
|
1109
|
+
configPath: env.configPath,
|
|
1110
|
+
logPath: env.logPath,
|
|
1111
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1112
|
+
configDir: env.configDir,
|
|
1113
|
+
skipHub: true,
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
expect(code).toBe(0);
|
|
1117
|
+
expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
|
|
1118
|
+
} finally {
|
|
1119
|
+
env.cleanup();
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
test("legacy-sweep: drops a DEAD legacy 'parachute' record without killing, when migrating", async () => {
|
|
1124
|
+
const env = makeEnv();
|
|
1125
|
+
try {
|
|
1126
|
+
// A leftover shared-tunnel record whose connector is no longer running.
|
|
1127
|
+
const deadLegacy: CloudflaredTunnelRecord = {
|
|
1128
|
+
pid: 72001,
|
|
1129
|
+
tunnelUuid: "dead-legacy-uuid",
|
|
1130
|
+
tunnelName: "parachute",
|
|
1131
|
+
hostname: "our.parachute.computer",
|
|
1132
|
+
startedAt: "2026-05-01T00:00:00.000Z",
|
|
1133
|
+
configPath: "/tmp/legacy/parachute/config.yml",
|
|
1134
|
+
};
|
|
1135
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: deadLegacy } }, env.statePath);
|
|
1136
|
+
|
|
1137
|
+
const uuid = "cccc7777-1111-2222-3333-444455556666";
|
|
1138
|
+
const { runner } = queueRunner([
|
|
1139
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1140
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
1141
|
+
{
|
|
1142
|
+
code: 0,
|
|
1143
|
+
stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
|
|
1144
|
+
stderr: "",
|
|
1145
|
+
},
|
|
1146
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
1147
|
+
]);
|
|
1148
|
+
const { spawner } = fakeSpawner(72100);
|
|
1149
|
+
const killed: number[] = [];
|
|
1150
|
+
const logs: string[] = [];
|
|
1151
|
+
|
|
1152
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
1153
|
+
runner,
|
|
1154
|
+
spawner,
|
|
1155
|
+
alive: () => false, // nothing alive — including the dead legacy pid
|
|
1156
|
+
kill: (pid) => killed.push(pid),
|
|
1157
|
+
connectorPids: () => [],
|
|
1158
|
+
resolveHost: async () => ["104.16.0.1"],
|
|
1159
|
+
log: (l) => logs.push(l),
|
|
1160
|
+
manifestPath: env.manifestPath,
|
|
1161
|
+
statePath: env.statePath,
|
|
1162
|
+
exposeStatePath: env.exposeStatePath,
|
|
1163
|
+
configPath: env.configPath,
|
|
1164
|
+
logPath: env.logPath,
|
|
1165
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1166
|
+
configDir: env.configDir,
|
|
1167
|
+
skipHub: true,
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
expect(code).toBe(0);
|
|
1171
|
+
// Connector wasn't alive → nothing killed, no sweep log.
|
|
1172
|
+
expect(killed).not.toContain(72001);
|
|
1173
|
+
expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
|
|
1174
|
+
// …but the stale dead record is cleared, leaving only the new derived one.
|
|
1175
|
+
const state = readCloudflaredState(env.statePath);
|
|
1176
|
+
expect(findTunnelRecord(state, "parachute")).toBeUndefined();
|
|
1177
|
+
expect(findTunnelRecord(state, "parachute-our-parachute-computer")?.pid).toBe(72100);
|
|
1178
|
+
} finally {
|
|
1179
|
+
env.cleanup();
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("legacy-sweep: does NOT fire when the derived name IS 'parachute' (no migration)", async () => {
|
|
1184
|
+
// A live "parachute" record AND an invocation that resolves to the
|
|
1185
|
+
// "parachute" name (here via explicit --tunnel-name parachute) must not
|
|
1186
|
+
// self-sweep — the connector we'd kill is the very one we're about to
|
|
1187
|
+
// reuse. Reuse-flow: queued list reports the parachute tunnel.
|
|
1188
|
+
const env = makeEnv();
|
|
1189
|
+
try {
|
|
1190
|
+
const uuid = "bbbb8888-1111-2222-3333-444455556666";
|
|
1191
|
+
const legacy: CloudflaredTunnelRecord = {
|
|
1192
|
+
pid: 71001,
|
|
1193
|
+
tunnelUuid: uuid,
|
|
1194
|
+
tunnelName: "parachute",
|
|
1195
|
+
hostname: "our.parachute.computer",
|
|
1196
|
+
startedAt: "2026-05-01T00:00:00.000Z",
|
|
1197
|
+
configPath: env.configPath,
|
|
1198
|
+
};
|
|
1199
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: legacy } }, env.statePath);
|
|
1200
|
+
|
|
1201
|
+
const { runner } = queueRunner([
|
|
1202
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1203
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
1204
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
1205
|
+
]);
|
|
1206
|
+
const { spawner } = fakeSpawner(71100);
|
|
1207
|
+
const logs: string[] = [];
|
|
1208
|
+
|
|
1209
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
1210
|
+
runner,
|
|
1211
|
+
spawner,
|
|
1212
|
+
alive: () => true,
|
|
1213
|
+
kill: () => {},
|
|
1214
|
+
connectorPids: () => [],
|
|
1215
|
+
resolveHost: async () => ["104.16.0.1"],
|
|
1216
|
+
log: (l) => logs.push(l),
|
|
1217
|
+
manifestPath: env.manifestPath,
|
|
1218
|
+
statePath: env.statePath,
|
|
1219
|
+
exposeStatePath: env.exposeStatePath,
|
|
1220
|
+
configPath: env.configPath,
|
|
1221
|
+
logPath: env.logPath,
|
|
1222
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1223
|
+
configDir: env.configDir,
|
|
1224
|
+
skipHub: true,
|
|
1225
|
+
tunnelName: "parachute",
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
expect(code).toBe(0);
|
|
1229
|
+
// No legacy-migration log line: we resolved TO "parachute", so there's
|
|
1230
|
+
// nothing to migrate away from.
|
|
1231
|
+
expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
|
|
1232
|
+
} finally {
|
|
1233
|
+
env.cleanup();
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
|
|
940
1238
|
// 2FA-enrollment warning (#186). The cloudflare path is always public —
|
|
941
1239
|
// every successful bringup makes /admin/login reachable on the open
|
|
942
1240
|
// internet, where 2FA is the primary defense beyond #188's rate-limit floor.
|
|
@@ -1348,4 +1646,374 @@ describe("exposeCloudflareOff", () => {
|
|
|
1348
1646
|
env.cleanup();
|
|
1349
1647
|
}
|
|
1350
1648
|
});
|
|
1649
|
+
|
|
1650
|
+
describe("#491: state-driven off (no --tunnel-name)", () => {
|
|
1651
|
+
test("0 tunnels → 'Nothing to tear down' (exit 0)", async () => {
|
|
1652
|
+
const env = makeEnv();
|
|
1653
|
+
try {
|
|
1654
|
+
const logs: string[] = [];
|
|
1655
|
+
const code = await exposeCloudflareOff({
|
|
1656
|
+
statePath: env.statePath,
|
|
1657
|
+
exposeStatePath: env.exposeStatePath,
|
|
1658
|
+
log: (l) => logs.push(l),
|
|
1659
|
+
});
|
|
1660
|
+
expect(code).toBe(0);
|
|
1661
|
+
expect(logs.join("\n")).toContain("Nothing to tear down");
|
|
1662
|
+
} finally {
|
|
1663
|
+
env.cleanup();
|
|
1664
|
+
}
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
test("exactly 1 tunnel → tears it down by reading state (even a derived non-'parachute' name)", async () => {
|
|
1668
|
+
const env = makeEnv();
|
|
1669
|
+
try {
|
|
1670
|
+
const record: CloudflaredTunnelRecord = {
|
|
1671
|
+
pid: 80001,
|
|
1672
|
+
tunnelUuid: "derived-uuid",
|
|
1673
|
+
tunnelName: "parachute-our-parachute-computer",
|
|
1674
|
+
hostname: "our.parachute.computer",
|
|
1675
|
+
startedAt: "2026-05-20T10:00:00.000Z",
|
|
1676
|
+
configPath: "/tmp/derived/config.yml",
|
|
1677
|
+
};
|
|
1678
|
+
writeCloudflaredState(
|
|
1679
|
+
{ version: 2, tunnels: { "parachute-our-parachute-computer": record } },
|
|
1680
|
+
env.statePath,
|
|
1681
|
+
);
|
|
1682
|
+
|
|
1683
|
+
const killed: number[] = [];
|
|
1684
|
+
const code = await exposeCloudflareOff({
|
|
1685
|
+
statePath: env.statePath,
|
|
1686
|
+
exposeStatePath: env.exposeStatePath,
|
|
1687
|
+
alive: () => true,
|
|
1688
|
+
kill: (pid) => killed.push(pid),
|
|
1689
|
+
log: () => {},
|
|
1690
|
+
// No tunnelName — resolved from state.
|
|
1691
|
+
});
|
|
1692
|
+
expect(code).toBe(0);
|
|
1693
|
+
expect(killed).toEqual([80001]);
|
|
1694
|
+
expect(existsSync(env.statePath)).toBe(false);
|
|
1695
|
+
} finally {
|
|
1696
|
+
env.cleanup();
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
test("≥2 tunnels → tears down ALL of them and lists each", async () => {
|
|
1701
|
+
const env = makeEnv();
|
|
1702
|
+
try {
|
|
1703
|
+
const recordA: CloudflaredTunnelRecord = {
|
|
1704
|
+
pid: 81001,
|
|
1705
|
+
tunnelUuid: "aaaa-uuid",
|
|
1706
|
+
tunnelName: "parachute-alpha-example-com",
|
|
1707
|
+
hostname: "alpha.example.com",
|
|
1708
|
+
startedAt: "2026-05-20T10:00:00.000Z",
|
|
1709
|
+
configPath: "/tmp/alpha/config.yml",
|
|
1710
|
+
};
|
|
1711
|
+
const recordB: CloudflaredTunnelRecord = {
|
|
1712
|
+
pid: 81002,
|
|
1713
|
+
tunnelUuid: "bbbb-uuid",
|
|
1714
|
+
tunnelName: "parachute-beta-example-com",
|
|
1715
|
+
hostname: "beta.example.com",
|
|
1716
|
+
startedAt: "2026-05-20T11:00:00.000Z",
|
|
1717
|
+
configPath: "/tmp/beta/config.yml",
|
|
1718
|
+
};
|
|
1719
|
+
writeCloudflaredState(
|
|
1720
|
+
withTunnelRecord(withTunnelRecord(undefined, recordA), recordB),
|
|
1721
|
+
env.statePath,
|
|
1722
|
+
);
|
|
1723
|
+
|
|
1724
|
+
const killed: number[] = [];
|
|
1725
|
+
const logs: string[] = [];
|
|
1726
|
+
const code = await exposeCloudflareOff({
|
|
1727
|
+
statePath: env.statePath,
|
|
1728
|
+
exposeStatePath: env.exposeStatePath,
|
|
1729
|
+
alive: () => true,
|
|
1730
|
+
kill: (pid) => killed.push(pid),
|
|
1731
|
+
log: (l) => logs.push(l),
|
|
1732
|
+
// No tunnelName — bare `off` means "stop all public Cloudflare exposure".
|
|
1733
|
+
});
|
|
1734
|
+
expect(code).toBe(0);
|
|
1735
|
+
// Both connectors stopped.
|
|
1736
|
+
expect(killed.sort()).toEqual([81001, 81002]);
|
|
1737
|
+
// State fully cleared (no tunnels remain).
|
|
1738
|
+
expect(existsSync(env.statePath)).toBe(false);
|
|
1739
|
+
const joined = logs.join("\n");
|
|
1740
|
+
expect(joined).toContain("Tearing down all 2 recorded Cloudflare tunnels");
|
|
1741
|
+
expect(joined).toContain("parachute-alpha-example-com");
|
|
1742
|
+
expect(joined).toContain("parachute-beta-example-com");
|
|
1743
|
+
} finally {
|
|
1744
|
+
env.cleanup();
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
describe("reboot-persistent connector service wiring", () => {
|
|
1751
|
+
// The default tunnel name for vault.example.com (#491 per-hostname derivation).
|
|
1752
|
+
const DERIVED = "parachute-vault-example-com";
|
|
1753
|
+
|
|
1754
|
+
function upRunner(uuid: string, derived = DERIVED) {
|
|
1755
|
+
return queueRunner([
|
|
1756
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
|
|
1757
|
+
{ code: 0, stdout: "[]", stderr: "" }, // tunnel list
|
|
1758
|
+
{
|
|
1759
|
+
code: 0,
|
|
1760
|
+
stdout: `Created tunnel ${derived} with id ${uuid}\n`,
|
|
1761
|
+
stderr: "",
|
|
1762
|
+
}, // tunnel create
|
|
1763
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
1764
|
+
]);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
test("up installs the connector service (no duplicate transient spawn) + records serviceManaged", async () => {
|
|
1768
|
+
const env = makeEnv();
|
|
1769
|
+
try {
|
|
1770
|
+
const uuid = "aaaaaaaa-0000-0000-0000-000000000001";
|
|
1771
|
+
const { runner } = upRunner(uuid);
|
|
1772
|
+
const { spawner, seen } = fakeSpawner(91000);
|
|
1773
|
+
const installCalls: { tunnelName: string; configPath: string }[] = [];
|
|
1774
|
+
const installService = (args: {
|
|
1775
|
+
tunnelName: string;
|
|
1776
|
+
configPath: string;
|
|
1777
|
+
logPath: string;
|
|
1778
|
+
}): InstallResult => {
|
|
1779
|
+
installCalls.push({ tunnelName: args.tunnelName, configPath: args.configPath });
|
|
1780
|
+
return {
|
|
1781
|
+
outcome: "installed",
|
|
1782
|
+
kind: "launchd",
|
|
1783
|
+
servicePath: "/home/op/Library/LaunchAgents/x.plist",
|
|
1784
|
+
messages: ["Installed launchd LaunchAgent — starts on boot."],
|
|
1785
|
+
};
|
|
1786
|
+
};
|
|
1787
|
+
const logs: string[] = [];
|
|
1788
|
+
|
|
1789
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
1790
|
+
runner,
|
|
1791
|
+
spawner,
|
|
1792
|
+
// The service-spawned connector (93333) is alive; no prior record exists
|
|
1793
|
+
// so the orphan sweep finds nothing else to kill.
|
|
1794
|
+
alive: (pid) => pid === 93333,
|
|
1795
|
+
kill: () => {},
|
|
1796
|
+
// Service manages the connector; report a live pid so the up-path
|
|
1797
|
+
// records it WITHOUT spawning a transient one.
|
|
1798
|
+
connectorPids: () => [93333],
|
|
1799
|
+
installService,
|
|
1800
|
+
log: (l) => logs.push(l),
|
|
1801
|
+
manifestPath: env.manifestPath,
|
|
1802
|
+
statePath: env.statePath,
|
|
1803
|
+
exposeStatePath: env.exposeStatePath,
|
|
1804
|
+
configPath: env.configPath,
|
|
1805
|
+
logPath: env.logPath,
|
|
1806
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1807
|
+
configDir: env.configDir,
|
|
1808
|
+
skipHub: true,
|
|
1809
|
+
now: () => new Date("2026-05-31T00:00:00Z"),
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
expect(code).toBe(0);
|
|
1813
|
+
// Service install was attempted with the derived tunnel name + the
|
|
1814
|
+
// config path we wrote.
|
|
1815
|
+
expect(installCalls).toHaveLength(1);
|
|
1816
|
+
expect(installCalls[0]!.tunnelName).toBe(DERIVED);
|
|
1817
|
+
expect(installCalls[0]!.configPath).toBe(env.configPath);
|
|
1818
|
+
// Exactly one connector: the service owns it, so NO transient spawn ran
|
|
1819
|
+
// (connectorPids surfaced the service-spawned pid).
|
|
1820
|
+
expect(seen).toHaveLength(0);
|
|
1821
|
+
|
|
1822
|
+
const state = readCloudflaredState(env.statePath);
|
|
1823
|
+
const rec = findTunnelRecord(state, DERIVED);
|
|
1824
|
+
expect(rec?.serviceManaged).toBe(true);
|
|
1825
|
+
// Recorded pid is the service-spawned connector's pid (from connectorPids).
|
|
1826
|
+
expect(rec?.pid).toBe(93333);
|
|
1827
|
+
|
|
1828
|
+
const joined = logs.join("\n");
|
|
1829
|
+
expect(joined).toContain("Installed launchd LaunchAgent");
|
|
1830
|
+
// Success copy: runs on boot, no "re-run after a reboot" nag.
|
|
1831
|
+
expect(joined).toContain("runs on boot");
|
|
1832
|
+
expect(joined).not.toContain("does NOT survive a reboot");
|
|
1833
|
+
} finally {
|
|
1834
|
+
env.cleanup();
|
|
1835
|
+
}
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
test("service-managed but connector not yet visible → transient spawn keeps a live pid", async () => {
|
|
1839
|
+
const env = makeEnv();
|
|
1840
|
+
try {
|
|
1841
|
+
const uuid = "aaaaaaaa-0000-0000-0000-000000000002";
|
|
1842
|
+
const { runner } = upRunner(uuid);
|
|
1843
|
+
const { spawner, seen } = fakeSpawner(91500);
|
|
1844
|
+
const installService = (): InstallResult => ({
|
|
1845
|
+
outcome: "installed",
|
|
1846
|
+
kind: "systemd-user",
|
|
1847
|
+
servicePath: "/home/op/.config/systemd/user/x.service",
|
|
1848
|
+
messages: [],
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
1852
|
+
runner,
|
|
1853
|
+
spawner,
|
|
1854
|
+
alive: () => false,
|
|
1855
|
+
kill: () => {},
|
|
1856
|
+
// Service is enabled but its connector isn't visible to pgrep yet.
|
|
1857
|
+
connectorPids: () => [],
|
|
1858
|
+
installService,
|
|
1859
|
+
log: () => {},
|
|
1860
|
+
manifestPath: env.manifestPath,
|
|
1861
|
+
statePath: env.statePath,
|
|
1862
|
+
exposeStatePath: env.exposeStatePath,
|
|
1863
|
+
configPath: env.configPath,
|
|
1864
|
+
logPath: env.logPath,
|
|
1865
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1866
|
+
configDir: env.configDir,
|
|
1867
|
+
skipHub: true,
|
|
1868
|
+
now: () => new Date("2026-05-31T00:00:00Z"),
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
expect(code).toBe(0);
|
|
1872
|
+
// Belt-and-suspenders: a transient connector was spawned so the state
|
|
1873
|
+
// record carries a live pid + connectivity is immediate. Still marked
|
|
1874
|
+
// serviceManaged (the service takes over on reboot).
|
|
1875
|
+
expect(seen).toHaveLength(1);
|
|
1876
|
+
const rec = findTunnelRecord(readCloudflaredState(env.statePath), DERIVED);
|
|
1877
|
+
expect(rec?.pid).toBe(91500);
|
|
1878
|
+
expect(rec?.serviceManaged).toBe(true);
|
|
1879
|
+
} finally {
|
|
1880
|
+
env.cleanup();
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
test("graceful fallback: when install fails, transient spawn + honest reboot caveat", async () => {
|
|
1885
|
+
const env = makeEnv();
|
|
1886
|
+
try {
|
|
1887
|
+
const uuid = "aaaaaaaa-0000-0000-0000-000000000003";
|
|
1888
|
+
const { runner } = upRunner(uuid);
|
|
1889
|
+
const { spawner, seen } = fakeSpawner(92000);
|
|
1890
|
+
const installService = (): InstallResult => ({
|
|
1891
|
+
outcome: "fallback",
|
|
1892
|
+
messages: ["launchctl not found; using a transient connector (won't survive a reboot)."],
|
|
1893
|
+
});
|
|
1894
|
+
const logs: string[] = [];
|
|
1895
|
+
|
|
1896
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
1897
|
+
runner,
|
|
1898
|
+
spawner,
|
|
1899
|
+
alive: () => false,
|
|
1900
|
+
kill: () => {},
|
|
1901
|
+
connectorPids: () => [],
|
|
1902
|
+
installService,
|
|
1903
|
+
log: (l) => logs.push(l),
|
|
1904
|
+
manifestPath: env.manifestPath,
|
|
1905
|
+
statePath: env.statePath,
|
|
1906
|
+
exposeStatePath: env.exposeStatePath,
|
|
1907
|
+
configPath: env.configPath,
|
|
1908
|
+
logPath: env.logPath,
|
|
1909
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1910
|
+
configDir: env.configDir,
|
|
1911
|
+
skipHub: true,
|
|
1912
|
+
now: () => new Date("2026-05-31T00:00:00Z"),
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
expect(code).toBe(0);
|
|
1916
|
+
// Transient connector spawned (the fallback).
|
|
1917
|
+
expect(seen).toHaveLength(1);
|
|
1918
|
+
expect(seen[0]).toEqual(["cloudflared", "tunnel", "--config", env.configPath, "run"]);
|
|
1919
|
+
const rec = findTunnelRecord(readCloudflaredState(env.statePath), DERIVED);
|
|
1920
|
+
expect(rec?.serviceManaged).toBeUndefined();
|
|
1921
|
+
const joined = logs.join("\n");
|
|
1922
|
+
// The install fallback warning surfaced + the honest reboot caveat.
|
|
1923
|
+
expect(joined).toContain("won't survive a reboot");
|
|
1924
|
+
expect(joined).toContain("does NOT survive a reboot");
|
|
1925
|
+
expect(joined).toContain("parachute expose public --cloudflare --domain vault.example.com");
|
|
1926
|
+
} finally {
|
|
1927
|
+
env.cleanup();
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
test("re-up is idempotent: install runs again, no duplicate state record", async () => {
|
|
1932
|
+
const env = makeEnv();
|
|
1933
|
+
try {
|
|
1934
|
+
const installCount = { n: 0 };
|
|
1935
|
+
const installService = (): InstallResult => {
|
|
1936
|
+
installCount.n++;
|
|
1937
|
+
return { outcome: "installed", kind: "launchd", servicePath: "/x.plist", messages: [] };
|
|
1938
|
+
};
|
|
1939
|
+
const runOnce = async (uuid: string) => {
|
|
1940
|
+
const { runner } = upRunner(uuid);
|
|
1941
|
+
const { spawner } = fakeSpawner(90000 + installCount.n);
|
|
1942
|
+
return exposeCloudflareUp("vault.example.com", {
|
|
1943
|
+
runner,
|
|
1944
|
+
spawner,
|
|
1945
|
+
alive: () => false,
|
|
1946
|
+
kill: () => {},
|
|
1947
|
+
connectorPids: () => [94000 + installCount.n],
|
|
1948
|
+
installService,
|
|
1949
|
+
log: () => {},
|
|
1950
|
+
manifestPath: env.manifestPath,
|
|
1951
|
+
statePath: env.statePath,
|
|
1952
|
+
exposeStatePath: env.exposeStatePath,
|
|
1953
|
+
configPath: env.configPath,
|
|
1954
|
+
logPath: env.logPath,
|
|
1955
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1956
|
+
configDir: env.configDir,
|
|
1957
|
+
skipHub: true,
|
|
1958
|
+
now: () => new Date("2026-05-31T00:00:00Z"),
|
|
1959
|
+
});
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
expect(await runOnce("aaaaaaaa-0000-0000-0000-000000000004")).toBe(0);
|
|
1963
|
+
expect(await runOnce("aaaaaaaa-0000-0000-0000-000000000004")).toBe(0);
|
|
1964
|
+
// Install attempted on each up (idempotent — the module overwrites + reloads).
|
|
1965
|
+
expect(installCount.n).toBe(2);
|
|
1966
|
+
// Exactly one tunnel record, keyed by name (re-up replaces, not appends).
|
|
1967
|
+
const state = readCloudflaredState(env.statePath);
|
|
1968
|
+
expect(Object.keys(state?.tunnels ?? {})).toEqual([DERIVED]);
|
|
1969
|
+
} finally {
|
|
1970
|
+
env.cleanup();
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
test("off removes the connector service for the torn-down tunnel", async () => {
|
|
1975
|
+
const env = makeEnv();
|
|
1976
|
+
try {
|
|
1977
|
+
// Seed a service-managed tunnel record.
|
|
1978
|
+
const record: CloudflaredTunnelRecord = {
|
|
1979
|
+
pid: 95000,
|
|
1980
|
+
tunnelUuid: "bbbbbbbb-0000-0000-0000-000000000001",
|
|
1981
|
+
tunnelName: DERIVED,
|
|
1982
|
+
hostname: "vault.example.com",
|
|
1983
|
+
startedAt: "2026-05-31T00:00:00.000Z",
|
|
1984
|
+
configPath: env.configPath,
|
|
1985
|
+
serviceManaged: true,
|
|
1986
|
+
};
|
|
1987
|
+
writeCloudflaredState(withTunnelRecord(undefined, record), env.statePath);
|
|
1988
|
+
|
|
1989
|
+
const removeCalls: string[] = [];
|
|
1990
|
+
const removeService = (args: { tunnelName: string }): RemoveResult => {
|
|
1991
|
+
removeCalls.push(args.tunnelName);
|
|
1992
|
+
return { removed: true, messages: [`Removed launchd LaunchAgent for ${args.tunnelName}.`] };
|
|
1993
|
+
};
|
|
1994
|
+
const killed: number[] = [];
|
|
1995
|
+
const logs: string[] = [];
|
|
1996
|
+
|
|
1997
|
+
const code = await exposeCloudflareOff({
|
|
1998
|
+
statePath: env.statePath,
|
|
1999
|
+
exposeStatePath: env.exposeStatePath,
|
|
2000
|
+
alive: () => true,
|
|
2001
|
+
kill: (pid) => killed.push(pid),
|
|
2002
|
+
connectorPids: () => [],
|
|
2003
|
+
removeService,
|
|
2004
|
+
log: (l) => logs.push(l),
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
expect(code).toBe(0);
|
|
2008
|
+
// The boot service was removed for the torn-down tunnel...
|
|
2009
|
+
expect(removeCalls).toEqual([DERIVED]);
|
|
2010
|
+
// ...AND the connector pid was SIGTERM'd (removal stops the service so it
|
|
2011
|
+
// won't restart it).
|
|
2012
|
+
expect(killed).toContain(95000);
|
|
2013
|
+
expect(existsSync(env.statePath)).toBe(false);
|
|
2014
|
+
expect(logs.join("\n")).toContain("Removed launchd LaunchAgent");
|
|
2015
|
+
} finally {
|
|
2016
|
+
env.cleanup();
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
1351
2019
|
});
|