@openparachute/hub 0.6.1-rc.3 → 0.6.1
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 +6 -4
- package/src/__tests__/account-vault-token.test.ts +8 -5
- package/src/__tests__/cloudflare-config.test.ts +65 -1
- package/src/__tests__/expose-cloudflare.test.ts +412 -16
- package/src/__tests__/oauth-handlers.test.ts +64 -55
- package/src/__tests__/users.test.ts +9 -5
- package/src/account-home-ui.ts +6 -1
- package/src/account-vault-token.ts +15 -14
- package/src/cli.ts +2 -1
- package/src/cloudflare/config.ts +70 -4
- package/src/commands/expose-cloudflare.ts +171 -39
- package/src/help.ts +7 -2
- package/src/oauth-handlers.ts +8 -6
- package/src/scope-explanations.ts +7 -4
- package/src/users.ts +22 -15
|
@@ -119,12 +119,16 @@ describe("exposeCloudflareUp", () => {
|
|
|
119
119
|
const env = makeEnv();
|
|
120
120
|
try {
|
|
121
121
|
const uuid = "2c1a7c7e-1234-5678-9abc-def012345678";
|
|
122
|
+
// Default tunnel name is now per-hostname (#491): vault.example.com →
|
|
123
|
+
// parachute-vault-example-com. Each machine gets its own dedicated tunnel
|
|
124
|
+
// so account-wide tunnels don't collide across boxes.
|
|
125
|
+
const derived = "parachute-vault-example-com";
|
|
122
126
|
const { runner, calls } = queueRunner([
|
|
123
127
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version preflight
|
|
124
128
|
{ code: 0, stdout: "[]", stderr: "" }, // tunnel list (none yet)
|
|
125
129
|
{
|
|
126
130
|
code: 0,
|
|
127
|
-
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel
|
|
131
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel ${derived} with id ${uuid}\n`,
|
|
128
132
|
stderr: "",
|
|
129
133
|
}, // tunnel create
|
|
130
134
|
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
@@ -158,14 +162,14 @@ describe("exposeCloudflareUp", () => {
|
|
|
158
162
|
]);
|
|
159
163
|
expect(calls[0]!.cmd).toEqual(["cloudflared", "--version"]);
|
|
160
164
|
expect(calls[1]!.cmd).toEqual(["cloudflared", "tunnel", "list", "--output", "json"]);
|
|
161
|
-
expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create",
|
|
165
|
+
expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", derived]);
|
|
162
166
|
expect(calls[3]!.cmd).toEqual([
|
|
163
167
|
"cloudflared",
|
|
164
168
|
"tunnel",
|
|
165
169
|
"route",
|
|
166
170
|
"dns",
|
|
167
171
|
"--overwrite-dns",
|
|
168
|
-
|
|
172
|
+
derived,
|
|
169
173
|
"vault.example.com",
|
|
170
174
|
]);
|
|
171
175
|
expect(seen[0]).toEqual(["cloudflared", "tunnel", "--config", env.configPath, "run"]);
|
|
@@ -174,10 +178,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
174
178
|
expect(state).toEqual({
|
|
175
179
|
version: 2,
|
|
176
180
|
tunnels: {
|
|
177
|
-
|
|
181
|
+
[derived]: {
|
|
178
182
|
pid: 42000,
|
|
179
183
|
tunnelUuid: uuid,
|
|
180
|
-
tunnelName:
|
|
184
|
+
tunnelName: derived,
|
|
181
185
|
hostname: "vault.example.com",
|
|
182
186
|
startedAt: "2026-04-22T12:00:00.000Z",
|
|
183
187
|
configPath: env.configPath,
|
|
@@ -248,6 +252,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
248
252
|
cloudflaredHome: env.cloudflaredHome,
|
|
249
253
|
configDir: env.configDir,
|
|
250
254
|
skipHub: true,
|
|
255
|
+
// Pin the legacy shared name so this test's substance (expose-state
|
|
256
|
+
// write) is isolated from the per-hostname-derivation change (#491);
|
|
257
|
+
// the queued runner output names the "parachute" tunnel.
|
|
258
|
+
tunnelName: "parachute",
|
|
251
259
|
});
|
|
252
260
|
|
|
253
261
|
expect(code).toBe(0);
|
|
@@ -419,6 +427,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
419
427
|
cloudflaredHome: env.cloudflaredHome,
|
|
420
428
|
configDir: env.configDir,
|
|
421
429
|
skipHub: true,
|
|
430
|
+
// Pin the legacy name: the queued `tunnel list` reports a "parachute"
|
|
431
|
+
// tunnel, so reuse only happens when we look it up by that name. The
|
|
432
|
+
// per-hostname default (#491) is exercised in the happy-path test.
|
|
433
|
+
tunnelName: "parachute",
|
|
422
434
|
});
|
|
423
435
|
expect(code).toBe(0);
|
|
424
436
|
// No `tunnel create` — only list + route.
|
|
@@ -604,6 +616,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
604
616
|
cloudflaredHome: env.cloudflaredHome,
|
|
605
617
|
configDir: env.configDir,
|
|
606
618
|
skipHub: true,
|
|
619
|
+
// Pin the legacy name so reuse (queued "parachute" list) drives the
|
|
620
|
+
// route-dns failure under test, not a tunnel-create from the
|
|
621
|
+
// per-hostname default (#491).
|
|
622
|
+
tunnelName: "parachute",
|
|
607
623
|
});
|
|
608
624
|
|
|
609
625
|
expect(code).toBe(1);
|
|
@@ -656,6 +672,10 @@ describe("exposeCloudflareUp", () => {
|
|
|
656
672
|
cloudflaredHome: env.cloudflaredHome,
|
|
657
673
|
configDir: env.configDir,
|
|
658
674
|
skipHub: true,
|
|
675
|
+
// Pin the legacy name so the prior record (keyed "parachute") matches
|
|
676
|
+
// this invocation's tunnel — the orphan-sweep behavior under test is
|
|
677
|
+
// independent of the per-hostname-derivation change (#491).
|
|
678
|
+
tunnelName: "parachute",
|
|
659
679
|
});
|
|
660
680
|
|
|
661
681
|
expect(code).toBe(0);
|
|
@@ -712,6 +732,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
712
732
|
cloudflaredHome: env.cloudflaredHome,
|
|
713
733
|
configDir: env.configDir,
|
|
714
734
|
skipHub: true,
|
|
735
|
+
// Pin the legacy name so the prior record (keyed "parachute") matches —
|
|
736
|
+
// the orphan-sweep behavior under test is independent of #491.
|
|
737
|
+
tunnelName: "parachute",
|
|
715
738
|
});
|
|
716
739
|
|
|
717
740
|
expect(code).toBe(0);
|
|
@@ -757,6 +780,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
757
780
|
cloudflaredHome: env.cloudflaredHome,
|
|
758
781
|
configDir: env.configDir,
|
|
759
782
|
skipHub: true,
|
|
783
|
+
// Pin the legacy name so reuse drives the DNS-diagnosis path under test
|
|
784
|
+
// (queued "parachute" list), not a create from the #491 default.
|
|
785
|
+
tunnelName: "parachute",
|
|
760
786
|
});
|
|
761
787
|
|
|
762
788
|
expect(code).toBe(0); // non-fatal — the expose still completes
|
|
@@ -801,6 +827,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
801
827
|
cloudflaredHome: env.cloudflaredHome,
|
|
802
828
|
configDir: env.configDir,
|
|
803
829
|
skipHub: true,
|
|
830
|
+
// Pin the legacy name so reuse drives the shadowed-DNS path under test
|
|
831
|
+
// (queued "parachute" list), not a create from the #491 default.
|
|
832
|
+
tunnelName: "parachute",
|
|
804
833
|
});
|
|
805
834
|
|
|
806
835
|
expect(code).toBe(0);
|
|
@@ -841,6 +870,9 @@ describe("exposeCloudflareUp", () => {
|
|
|
841
870
|
cloudflaredHome: env.cloudflaredHome,
|
|
842
871
|
configDir: env.configDir,
|
|
843
872
|
skipHub: true,
|
|
873
|
+
// Pin the legacy name so reuse drives the no-warning path under test
|
|
874
|
+
// (queued "parachute" list), not a create from the #491 default.
|
|
875
|
+
tunnelName: "parachute",
|
|
844
876
|
});
|
|
845
877
|
|
|
846
878
|
expect(code).toBe(0);
|
|
@@ -857,13 +889,15 @@ describe("exposeCloudflareUp", () => {
|
|
|
857
889
|
try {
|
|
858
890
|
const uuidA = "aaaa1111-aaaa-1111-aaaa-111111111111";
|
|
859
891
|
const uuidB = "bbbb2222-bbbb-2222-bbbb-222222222222";
|
|
860
|
-
// Up #1 — default
|
|
892
|
+
// Up #1 — per-hostname default (#491): alpha.example.com →
|
|
893
|
+
// parachute-alpha-example-com.
|
|
894
|
+
const derivedA = "parachute-alpha-example-com";
|
|
861
895
|
const r1 = queueRunner([
|
|
862
896
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
863
897
|
{ code: 0, stdout: "[]", stderr: "" },
|
|
864
898
|
{
|
|
865
899
|
code: 0,
|
|
866
|
-
stdout: `Created tunnel
|
|
900
|
+
stdout: `Created tunnel ${derivedA} with id ${uuidA}\n`,
|
|
867
901
|
stderr: "",
|
|
868
902
|
},
|
|
869
903
|
{ code: 0, stdout: "", stderr: "" },
|
|
@@ -881,10 +915,11 @@ describe("exposeCloudflareUp", () => {
|
|
|
881
915
|
cloudflaredHome: env.cloudflaredHome,
|
|
882
916
|
configDir: env.configDir,
|
|
883
917
|
skipHub: true,
|
|
884
|
-
// Omit configPath/logPath so
|
|
885
|
-
//
|
|
886
|
-
// generated config.yml lands under
|
|
887
|
-
//
|
|
918
|
+
// Omit configPath/logPath AND tunnelName so the name is the per-hostname
|
|
919
|
+
// derived default (#491) and the paths are per-tunnel-derived against
|
|
920
|
+
// the tmp `configDir` above — so the generated config.yml lands under
|
|
921
|
+
// tmp/cloudflared/parachute-alpha-example-com/, not the operator's real
|
|
922
|
+
// ~/.parachute.
|
|
888
923
|
});
|
|
889
924
|
expect(code1).toBe(0);
|
|
890
925
|
|
|
@@ -916,19 +951,20 @@ describe("exposeCloudflareUp", () => {
|
|
|
916
951
|
});
|
|
917
952
|
expect(code2).toBe(0);
|
|
918
953
|
|
|
919
|
-
// Both tunnels should be present in state, keyed by tunnel name
|
|
954
|
+
// Both tunnels should be present in state, keyed by tunnel name: the
|
|
955
|
+
// per-hostname derived name for #1, the explicit override for #2.
|
|
920
956
|
const state = readCloudflaredState(env.statePath);
|
|
921
|
-
expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual([
|
|
922
|
-
expect(findTunnelRecord(state,
|
|
957
|
+
expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual([derivedA, "second"]);
|
|
958
|
+
expect(findTunnelRecord(state, derivedA)?.hostname).toBe("alpha.example.com");
|
|
923
959
|
expect(findTunnelRecord(state, "second")?.hostname).toBe("beta.example.com");
|
|
924
960
|
expect(findTunnelRecord(state, "second")?.pid).toBe(50002);
|
|
925
961
|
|
|
926
962
|
// Each tunnel should have written its own config file at the per-tunnel
|
|
927
963
|
// path under `~/.parachute/cloudflared/<tunnelName>/config.yml`.
|
|
928
|
-
const cfgA = findTunnelRecord(state,
|
|
964
|
+
const cfgA = findTunnelRecord(state, derivedA)?.configPath ?? "";
|
|
929
965
|
const cfgB = findTunnelRecord(state, "second")?.configPath ?? "";
|
|
930
966
|
expect(cfgA).not.toBe(cfgB);
|
|
931
|
-
expect(cfgA.endsWith(
|
|
967
|
+
expect(cfgA.endsWith(`/${derivedA}/config.yml`)).toBe(true);
|
|
932
968
|
expect(cfgB.endsWith("/second/config.yml")).toBe(true);
|
|
933
969
|
expect(existsSync(cfgA)).toBe(true);
|
|
934
970
|
expect(existsSync(cfgB)).toBe(true);
|
|
@@ -937,6 +973,267 @@ describe("exposeCloudflareUp", () => {
|
|
|
937
973
|
}
|
|
938
974
|
});
|
|
939
975
|
|
|
976
|
+
describe("#491: per-hostname tunnel naming + legacy migration", () => {
|
|
977
|
+
test("explicit --tunnel-name overrides the per-hostname default", async () => {
|
|
978
|
+
const env = makeEnv();
|
|
979
|
+
try {
|
|
980
|
+
const uuid = "11112222-3333-4444-5555-666677778888";
|
|
981
|
+
const { runner, calls } = queueRunner([
|
|
982
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
983
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
984
|
+
{ code: 0, stdout: `Created tunnel custom-name with id ${uuid}\n`, stderr: "" },
|
|
985
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
986
|
+
]);
|
|
987
|
+
const { spawner } = fakeSpawner(43000);
|
|
988
|
+
|
|
989
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
990
|
+
runner,
|
|
991
|
+
spawner,
|
|
992
|
+
alive: () => false,
|
|
993
|
+
kill: () => {},
|
|
994
|
+
log: () => {},
|
|
995
|
+
manifestPath: env.manifestPath,
|
|
996
|
+
statePath: env.statePath,
|
|
997
|
+
exposeStatePath: env.exposeStatePath,
|
|
998
|
+
configPath: env.configPath,
|
|
999
|
+
logPath: env.logPath,
|
|
1000
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1001
|
+
configDir: env.configDir,
|
|
1002
|
+
skipHub: true,
|
|
1003
|
+
tunnelName: "custom-name",
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
expect(code).toBe(0);
|
|
1007
|
+
// The explicit name wins — NOT the derived parachute-our-parachute-computer.
|
|
1008
|
+
expect(calls[2]!.cmd).toEqual(["cloudflared", "tunnel", "create", "custom-name"]);
|
|
1009
|
+
const state = readCloudflaredState(env.statePath);
|
|
1010
|
+
expect(findTunnelRecord(state, "custom-name")?.hostname).toBe("our.parachute.computer");
|
|
1011
|
+
expect(findTunnelRecord(state, "parachute-our-parachute-computer")).toBeUndefined();
|
|
1012
|
+
} finally {
|
|
1013
|
+
env.cleanup();
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
test("legacy-sweep: stops a live shared 'parachute' connector when migrating to a derived name", async () => {
|
|
1018
|
+
const env = makeEnv();
|
|
1019
|
+
try {
|
|
1020
|
+
// A box that was exposed under the old shared "parachute" tunnel.
|
|
1021
|
+
const legacy: CloudflaredTunnelRecord = {
|
|
1022
|
+
pid: 70001,
|
|
1023
|
+
tunnelUuid: "legacy-uuid",
|
|
1024
|
+
tunnelName: "parachute",
|
|
1025
|
+
hostname: "our.parachute.computer",
|
|
1026
|
+
startedAt: "2026-05-01T00:00:00.000Z",
|
|
1027
|
+
configPath: "/tmp/legacy/parachute/config.yml",
|
|
1028
|
+
};
|
|
1029
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: legacy } }, env.statePath);
|
|
1030
|
+
|
|
1031
|
+
const uuid = "99990000-1111-2222-3333-444455556666";
|
|
1032
|
+
const { runner } = queueRunner([
|
|
1033
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1034
|
+
{ code: 0, stdout: "[]", stderr: "" }, // new derived tunnel doesn't exist yet
|
|
1035
|
+
{
|
|
1036
|
+
code: 0,
|
|
1037
|
+
stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
|
|
1038
|
+
stderr: "",
|
|
1039
|
+
},
|
|
1040
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns (--overwrite-dns repoints the CNAME)
|
|
1041
|
+
]);
|
|
1042
|
+
const { spawner } = fakeSpawner(70100);
|
|
1043
|
+
const killed: number[] = [];
|
|
1044
|
+
const logs: string[] = [];
|
|
1045
|
+
|
|
1046
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
1047
|
+
runner,
|
|
1048
|
+
spawner,
|
|
1049
|
+
// The legacy connector (70001) is alive; the new spawn is 70100.
|
|
1050
|
+
alive: (pid) => pid === 70001,
|
|
1051
|
+
kill: (pid) => killed.push(pid),
|
|
1052
|
+
connectorPids: () => [],
|
|
1053
|
+
resolveHost: async () => ["104.16.0.1"],
|
|
1054
|
+
log: (l) => logs.push(l),
|
|
1055
|
+
manifestPath: env.manifestPath,
|
|
1056
|
+
statePath: env.statePath,
|
|
1057
|
+
exposeStatePath: env.exposeStatePath,
|
|
1058
|
+
configPath: env.configPath,
|
|
1059
|
+
logPath: env.logPath,
|
|
1060
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1061
|
+
configDir: env.configDir,
|
|
1062
|
+
skipHub: true,
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
expect(code).toBe(0);
|
|
1066
|
+
// The legacy shared connector got SIGTERM'd.
|
|
1067
|
+
expect(killed).toContain(70001);
|
|
1068
|
+
const joined = logs.join("\n");
|
|
1069
|
+
expect(joined).toContain("Stopped legacy shared-tunnel connector");
|
|
1070
|
+
expect(joined).toContain("migrated our.parachute.computer to dedicated tunnel");
|
|
1071
|
+
// The legacy "parachute" record is gone; only the new derived one remains.
|
|
1072
|
+
const state = readCloudflaredState(env.statePath);
|
|
1073
|
+
expect(findTunnelRecord(state, "parachute")).toBeUndefined();
|
|
1074
|
+
expect(findTunnelRecord(state, "parachute-our-parachute-computer")?.pid).toBe(70100);
|
|
1075
|
+
} finally {
|
|
1076
|
+
env.cleanup();
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("legacy-sweep: does NOT fire when no legacy 'parachute' record exists", async () => {
|
|
1081
|
+
const env = makeEnv();
|
|
1082
|
+
try {
|
|
1083
|
+
const uuid = "aaaa9999-1111-2222-3333-444455556666";
|
|
1084
|
+
const { runner } = queueRunner([
|
|
1085
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1086
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
1087
|
+
{
|
|
1088
|
+
code: 0,
|
|
1089
|
+
stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
|
|
1090
|
+
stderr: "",
|
|
1091
|
+
},
|
|
1092
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
1093
|
+
]);
|
|
1094
|
+
const { spawner } = fakeSpawner(70200);
|
|
1095
|
+
const logs: string[] = [];
|
|
1096
|
+
|
|
1097
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
1098
|
+
runner,
|
|
1099
|
+
spawner,
|
|
1100
|
+
alive: () => false,
|
|
1101
|
+
kill: () => {},
|
|
1102
|
+
connectorPids: () => [],
|
|
1103
|
+
resolveHost: async () => ["104.16.0.1"],
|
|
1104
|
+
log: (l) => logs.push(l),
|
|
1105
|
+
manifestPath: env.manifestPath,
|
|
1106
|
+
statePath: env.statePath,
|
|
1107
|
+
exposeStatePath: env.exposeStatePath,
|
|
1108
|
+
configPath: env.configPath,
|
|
1109
|
+
logPath: env.logPath,
|
|
1110
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1111
|
+
configDir: env.configDir,
|
|
1112
|
+
skipHub: true,
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
expect(code).toBe(0);
|
|
1116
|
+
expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
|
|
1117
|
+
} finally {
|
|
1118
|
+
env.cleanup();
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
test("legacy-sweep: drops a DEAD legacy 'parachute' record without killing, when migrating", async () => {
|
|
1123
|
+
const env = makeEnv();
|
|
1124
|
+
try {
|
|
1125
|
+
// A leftover shared-tunnel record whose connector is no longer running.
|
|
1126
|
+
const deadLegacy: CloudflaredTunnelRecord = {
|
|
1127
|
+
pid: 72001,
|
|
1128
|
+
tunnelUuid: "dead-legacy-uuid",
|
|
1129
|
+
tunnelName: "parachute",
|
|
1130
|
+
hostname: "our.parachute.computer",
|
|
1131
|
+
startedAt: "2026-05-01T00:00:00.000Z",
|
|
1132
|
+
configPath: "/tmp/legacy/parachute/config.yml",
|
|
1133
|
+
};
|
|
1134
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: deadLegacy } }, env.statePath);
|
|
1135
|
+
|
|
1136
|
+
const uuid = "cccc7777-1111-2222-3333-444455556666";
|
|
1137
|
+
const { runner } = queueRunner([
|
|
1138
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1139
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
1140
|
+
{
|
|
1141
|
+
code: 0,
|
|
1142
|
+
stdout: `Created tunnel parachute-our-parachute-computer with id ${uuid}\n`,
|
|
1143
|
+
stderr: "",
|
|
1144
|
+
},
|
|
1145
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
1146
|
+
]);
|
|
1147
|
+
const { spawner } = fakeSpawner(72100);
|
|
1148
|
+
const killed: number[] = [];
|
|
1149
|
+
const logs: string[] = [];
|
|
1150
|
+
|
|
1151
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
1152
|
+
runner,
|
|
1153
|
+
spawner,
|
|
1154
|
+
alive: () => false, // nothing alive — including the dead legacy pid
|
|
1155
|
+
kill: (pid) => killed.push(pid),
|
|
1156
|
+
connectorPids: () => [],
|
|
1157
|
+
resolveHost: async () => ["104.16.0.1"],
|
|
1158
|
+
log: (l) => logs.push(l),
|
|
1159
|
+
manifestPath: env.manifestPath,
|
|
1160
|
+
statePath: env.statePath,
|
|
1161
|
+
exposeStatePath: env.exposeStatePath,
|
|
1162
|
+
configPath: env.configPath,
|
|
1163
|
+
logPath: env.logPath,
|
|
1164
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1165
|
+
configDir: env.configDir,
|
|
1166
|
+
skipHub: true,
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
expect(code).toBe(0);
|
|
1170
|
+
// Connector wasn't alive → nothing killed, no sweep log.
|
|
1171
|
+
expect(killed).not.toContain(72001);
|
|
1172
|
+
expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
|
|
1173
|
+
// …but the stale dead record is cleared, leaving only the new derived one.
|
|
1174
|
+
const state = readCloudflaredState(env.statePath);
|
|
1175
|
+
expect(findTunnelRecord(state, "parachute")).toBeUndefined();
|
|
1176
|
+
expect(findTunnelRecord(state, "parachute-our-parachute-computer")?.pid).toBe(72100);
|
|
1177
|
+
} finally {
|
|
1178
|
+
env.cleanup();
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
test("legacy-sweep: does NOT fire when the derived name IS 'parachute' (no migration)", async () => {
|
|
1183
|
+
// A live "parachute" record AND an invocation that resolves to the
|
|
1184
|
+
// "parachute" name (here via explicit --tunnel-name parachute) must not
|
|
1185
|
+
// self-sweep — the connector we'd kill is the very one we're about to
|
|
1186
|
+
// reuse. Reuse-flow: queued list reports the parachute tunnel.
|
|
1187
|
+
const env = makeEnv();
|
|
1188
|
+
try {
|
|
1189
|
+
const uuid = "bbbb8888-1111-2222-3333-444455556666";
|
|
1190
|
+
const legacy: CloudflaredTunnelRecord = {
|
|
1191
|
+
pid: 71001,
|
|
1192
|
+
tunnelUuid: uuid,
|
|
1193
|
+
tunnelName: "parachute",
|
|
1194
|
+
hostname: "our.parachute.computer",
|
|
1195
|
+
startedAt: "2026-05-01T00:00:00.000Z",
|
|
1196
|
+
configPath: env.configPath,
|
|
1197
|
+
};
|
|
1198
|
+
writeCloudflaredState({ version: 2, tunnels: { parachute: legacy } }, env.statePath);
|
|
1199
|
+
|
|
1200
|
+
const { runner } = queueRunner([
|
|
1201
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1202
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
1203
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
1204
|
+
]);
|
|
1205
|
+
const { spawner } = fakeSpawner(71100);
|
|
1206
|
+
const logs: string[] = [];
|
|
1207
|
+
|
|
1208
|
+
const code = await exposeCloudflareUp("our.parachute.computer", {
|
|
1209
|
+
runner,
|
|
1210
|
+
spawner,
|
|
1211
|
+
alive: () => true,
|
|
1212
|
+
kill: () => {},
|
|
1213
|
+
connectorPids: () => [],
|
|
1214
|
+
resolveHost: async () => ["104.16.0.1"],
|
|
1215
|
+
log: (l) => logs.push(l),
|
|
1216
|
+
manifestPath: env.manifestPath,
|
|
1217
|
+
statePath: env.statePath,
|
|
1218
|
+
exposeStatePath: env.exposeStatePath,
|
|
1219
|
+
configPath: env.configPath,
|
|
1220
|
+
logPath: env.logPath,
|
|
1221
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1222
|
+
configDir: env.configDir,
|
|
1223
|
+
skipHub: true,
|
|
1224
|
+
tunnelName: "parachute",
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
expect(code).toBe(0);
|
|
1228
|
+
// No legacy-migration log line: we resolved TO "parachute", so there's
|
|
1229
|
+
// nothing to migrate away from.
|
|
1230
|
+
expect(logs.join("\n")).not.toContain("Stopped legacy shared-tunnel connector");
|
|
1231
|
+
} finally {
|
|
1232
|
+
env.cleanup();
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
|
|
940
1237
|
// 2FA-enrollment warning (#186). The cloudflare path is always public —
|
|
941
1238
|
// every successful bringup makes /admin/login reachable on the open
|
|
942
1239
|
// internet, where 2FA is the primary defense beyond #188's rate-limit floor.
|
|
@@ -1348,4 +1645,103 @@ describe("exposeCloudflareOff", () => {
|
|
|
1348
1645
|
env.cleanup();
|
|
1349
1646
|
}
|
|
1350
1647
|
});
|
|
1648
|
+
|
|
1649
|
+
describe("#491: state-driven off (no --tunnel-name)", () => {
|
|
1650
|
+
test("0 tunnels → 'Nothing to tear down' (exit 0)", async () => {
|
|
1651
|
+
const env = makeEnv();
|
|
1652
|
+
try {
|
|
1653
|
+
const logs: string[] = [];
|
|
1654
|
+
const code = await exposeCloudflareOff({
|
|
1655
|
+
statePath: env.statePath,
|
|
1656
|
+
exposeStatePath: env.exposeStatePath,
|
|
1657
|
+
log: (l) => logs.push(l),
|
|
1658
|
+
});
|
|
1659
|
+
expect(code).toBe(0);
|
|
1660
|
+
expect(logs.join("\n")).toContain("Nothing to tear down");
|
|
1661
|
+
} finally {
|
|
1662
|
+
env.cleanup();
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
test("exactly 1 tunnel → tears it down by reading state (even a derived non-'parachute' name)", async () => {
|
|
1667
|
+
const env = makeEnv();
|
|
1668
|
+
try {
|
|
1669
|
+
const record: CloudflaredTunnelRecord = {
|
|
1670
|
+
pid: 80001,
|
|
1671
|
+
tunnelUuid: "derived-uuid",
|
|
1672
|
+
tunnelName: "parachute-our-parachute-computer",
|
|
1673
|
+
hostname: "our.parachute.computer",
|
|
1674
|
+
startedAt: "2026-05-20T10:00:00.000Z",
|
|
1675
|
+
configPath: "/tmp/derived/config.yml",
|
|
1676
|
+
};
|
|
1677
|
+
writeCloudflaredState(
|
|
1678
|
+
{ version: 2, tunnels: { "parachute-our-parachute-computer": record } },
|
|
1679
|
+
env.statePath,
|
|
1680
|
+
);
|
|
1681
|
+
|
|
1682
|
+
const killed: number[] = [];
|
|
1683
|
+
const code = await exposeCloudflareOff({
|
|
1684
|
+
statePath: env.statePath,
|
|
1685
|
+
exposeStatePath: env.exposeStatePath,
|
|
1686
|
+
alive: () => true,
|
|
1687
|
+
kill: (pid) => killed.push(pid),
|
|
1688
|
+
log: () => {},
|
|
1689
|
+
// No tunnelName — resolved from state.
|
|
1690
|
+
});
|
|
1691
|
+
expect(code).toBe(0);
|
|
1692
|
+
expect(killed).toEqual([80001]);
|
|
1693
|
+
expect(existsSync(env.statePath)).toBe(false);
|
|
1694
|
+
} finally {
|
|
1695
|
+
env.cleanup();
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
test("≥2 tunnels → tears down ALL of them and lists each", async () => {
|
|
1700
|
+
const env = makeEnv();
|
|
1701
|
+
try {
|
|
1702
|
+
const recordA: CloudflaredTunnelRecord = {
|
|
1703
|
+
pid: 81001,
|
|
1704
|
+
tunnelUuid: "aaaa-uuid",
|
|
1705
|
+
tunnelName: "parachute-alpha-example-com",
|
|
1706
|
+
hostname: "alpha.example.com",
|
|
1707
|
+
startedAt: "2026-05-20T10:00:00.000Z",
|
|
1708
|
+
configPath: "/tmp/alpha/config.yml",
|
|
1709
|
+
};
|
|
1710
|
+
const recordB: CloudflaredTunnelRecord = {
|
|
1711
|
+
pid: 81002,
|
|
1712
|
+
tunnelUuid: "bbbb-uuid",
|
|
1713
|
+
tunnelName: "parachute-beta-example-com",
|
|
1714
|
+
hostname: "beta.example.com",
|
|
1715
|
+
startedAt: "2026-05-20T11:00:00.000Z",
|
|
1716
|
+
configPath: "/tmp/beta/config.yml",
|
|
1717
|
+
};
|
|
1718
|
+
writeCloudflaredState(
|
|
1719
|
+
withTunnelRecord(withTunnelRecord(undefined, recordA), recordB),
|
|
1720
|
+
env.statePath,
|
|
1721
|
+
);
|
|
1722
|
+
|
|
1723
|
+
const killed: number[] = [];
|
|
1724
|
+
const logs: string[] = [];
|
|
1725
|
+
const code = await exposeCloudflareOff({
|
|
1726
|
+
statePath: env.statePath,
|
|
1727
|
+
exposeStatePath: env.exposeStatePath,
|
|
1728
|
+
alive: () => true,
|
|
1729
|
+
kill: (pid) => killed.push(pid),
|
|
1730
|
+
log: (l) => logs.push(l),
|
|
1731
|
+
// No tunnelName — bare `off` means "stop all public Cloudflare exposure".
|
|
1732
|
+
});
|
|
1733
|
+
expect(code).toBe(0);
|
|
1734
|
+
// Both connectors stopped.
|
|
1735
|
+
expect(killed.sort()).toEqual([81001, 81002]);
|
|
1736
|
+
// State fully cleared (no tunnels remain).
|
|
1737
|
+
expect(existsSync(env.statePath)).toBe(false);
|
|
1738
|
+
const joined = logs.join("\n");
|
|
1739
|
+
expect(joined).toContain("Tearing down all 2 recorded Cloudflare tunnels");
|
|
1740
|
+
expect(joined).toContain("parachute-alpha-example-com");
|
|
1741
|
+
expect(joined).toContain("parachute-beta-example-com");
|
|
1742
|
+
} finally {
|
|
1743
|
+
env.cleanup();
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
});
|
|
1351
1747
|
});
|