@openparachute/hub 0.6.1-rc.4 → 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__/cloudflare-config.test.ts +65 -1
- package/src/__tests__/expose-cloudflare.test.ts +412 -16
- 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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.6.1
|
|
4
|
-
"description": "parachute
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { renderConfig, writeConfig } from "../cloudflare/config.ts";
|
|
5
|
+
import { deriveTunnelName, renderConfig, writeConfig } from "../cloudflare/config.ts";
|
|
6
|
+
import { isValidTunnelName } from "../commands/expose-cloudflare.ts";
|
|
6
7
|
|
|
7
8
|
describe("cloudflare config", () => {
|
|
8
9
|
test("renderConfig produces a valid cloudflared YAML with one-hostname ingress + catch-all 404", () => {
|
|
@@ -52,3 +53,66 @@ describe("cloudflare config", () => {
|
|
|
52
53
|
}
|
|
53
54
|
});
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("deriveTunnelName (#491 — per-hostname dedicated tunnels)", () => {
|
|
58
|
+
test("prefixes parachute- and turns dots into hyphens", () => {
|
|
59
|
+
expect(deriveTunnelName("our.parachute.computer")).toBe("parachute-our-parachute-computer");
|
|
60
|
+
expect(deriveTunnelName("vault.example.com")).toBe("parachute-vault-example-com");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("lowercases and strips characters outside [a-z0-9_-]", () => {
|
|
64
|
+
// Uppercase → lowercase; a stray char that an over-permissive hostname
|
|
65
|
+
// validator might let through is dropped so the result stays a valid
|
|
66
|
+
// tunnel name. (Dots are already mapped to hyphens before stripping.)
|
|
67
|
+
expect(deriveTunnelName("Vault.Example.COM")).toBe("parachute-vault-example-com");
|
|
68
|
+
expect(deriveTunnelName("a_b-c.example.com")).toBe("parachute-a_b-c-example-com");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("every derived name satisfies isValidTunnelName", () => {
|
|
72
|
+
for (const host of [
|
|
73
|
+
"our.parachute.computer",
|
|
74
|
+
"vault.example.com",
|
|
75
|
+
"Vault.Example.COM",
|
|
76
|
+
"a_b-c.example.com",
|
|
77
|
+
`${"x".repeat(200)}.example.com`,
|
|
78
|
+
]) {
|
|
79
|
+
const name = deriveTunnelName(host);
|
|
80
|
+
expect(isValidTunnelName(name)).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("truncates + appends a stable 8-hex suffix when the name would exceed 64 chars", () => {
|
|
85
|
+
const longHost = `${"sub.".repeat(20)}example.com`; // way over 64 once prefixed
|
|
86
|
+
const name = deriveTunnelName(longHost);
|
|
87
|
+
expect(name.length).toBeLessThanOrEqual(64);
|
|
88
|
+
expect(name.startsWith("parachute-")).toBe(true);
|
|
89
|
+
// 8-hex stable suffix on the end.
|
|
90
|
+
expect(name).toMatch(/-[0-9a-f]{8}$/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("is deterministic — same hostname always derives the same name (idempotent re-expose)", () => {
|
|
94
|
+
const longHost = `${"sub.".repeat(20)}example.com`;
|
|
95
|
+
expect(deriveTunnelName(longHost)).toBe(deriveTunnelName(longHost));
|
|
96
|
+
expect(deriveTunnelName("our.parachute.computer")).toBe(
|
|
97
|
+
deriveTunnelName("our.parachute.computer"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("two distinct long hostnames whose truncated bodies are identical don't collide", () => {
|
|
102
|
+
// Identical leading labels long enough that the body truncation
|
|
103
|
+
// (parachute- + body-slice + -<8hex>, capped at 64) cuts BEFORE the
|
|
104
|
+
// differing tail — so the truncated bodies are byte-identical and only the
|
|
105
|
+
// full-hostname hash distinguishes them. Verifies the suffix disambiguates.
|
|
106
|
+
const sharedPrefix = "x".repeat(80); // single long label, well past the truncation point
|
|
107
|
+
const a = `${sharedPrefix}.alpha.example.com`;
|
|
108
|
+
const b = `${sharedPrefix}.beta.example.com`;
|
|
109
|
+
const nameA = deriveTunnelName(a);
|
|
110
|
+
const nameB = deriveTunnelName(b);
|
|
111
|
+
expect(nameA.length).toBeLessThanOrEqual(64);
|
|
112
|
+
expect(nameB.length).toBeLessThanOrEqual(64);
|
|
113
|
+
// Bodies before the suffix are identical (truncation cut inside the shared
|
|
114
|
+
// prefix), so the names can only differ in the trailing 8-hex hash.
|
|
115
|
+
expect(nameA.slice(0, -8)).toBe(nameB.slice(0, -8));
|
|
116
|
+
expect(nameA).not.toBe(nameB);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -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
|
});
|
package/src/cli.ts
CHANGED
|
@@ -159,7 +159,8 @@ function extractNamedFlag(
|
|
|
159
159
|
* fall through to today's Tailscale
|
|
160
160
|
* default (CI escape hatch, #29)
|
|
161
161
|
* --domain=<host> hostname for the Cloudflare path
|
|
162
|
-
* --tunnel-name=<name> named tunnel override (#32)
|
|
162
|
+
* --tunnel-name=<name> named tunnel override (#32); defaults to a
|
|
163
|
+
* per-hostname dedicated name (#491)
|
|
163
164
|
*
|
|
164
165
|
* Returns the stripped argv so the layer/action parser sees `[layer, action?]`
|
|
165
166
|
* regardless of flag placement. `--tailnet` + `--cloudflare` together is
|
package/src/cloudflare/config.ts
CHANGED
|
@@ -2,18 +2,84 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR } from "../config.ts";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The legacy shared tunnel name. Pre-#491, every machine defaulted its
|
|
7
|
+
* Cloudflare tunnel to this single constant — but Cloudflare tunnels are
|
|
8
|
+
* account-wide, so a second machine exposing a *different* hostname found and
|
|
9
|
+
* reused the SAME tunnel, both connectors registered on one UUID, and the edge
|
|
10
|
+
* load-balanced requests across them → a request for host B could land on host
|
|
11
|
+
* A's connector (whose config.yml only routes host A) → ~50% cross-host 404s.
|
|
12
|
+
*
|
|
13
|
+
* The default is now a per-hostname derived name (`deriveTunnelName`). This
|
|
14
|
+
* constant's role narrows to "the legacy shared name we migrate away from":
|
|
15
|
+
* - the up-path legacy-sweep kills a stale `"parachute"` connector on the box
|
|
16
|
+
* so running deploys self-heal on the next expose, and
|
|
17
|
+
* - the off-path reuse-hint compares against it (records no longer equal it,
|
|
18
|
+
* so the hint always includes `--tunnel-name`, which is now correct).
|
|
19
|
+
*/
|
|
5
20
|
export const DEFAULT_TUNNEL_NAME = "parachute";
|
|
6
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Derive a dedicated, per-hostname tunnel name from a hostname. Cloudflare
|
|
24
|
+
* tunnels are account-wide, so each machine/hostname needs its OWN tunnel —
|
|
25
|
+
* sharing one name across boxes collides their connectors (#491). The name is
|
|
26
|
+
* deterministic (same hostname → same name) so re-exposing the same hostname
|
|
27
|
+
* is idempotent: it finds and reuses the tunnel it created last time.
|
|
28
|
+
*
|
|
29
|
+
* Sanitization: lowercase, dots → hyphens, drop anything outside `[a-z0-9_-]`,
|
|
30
|
+
* then prefix `parachute-`. Examples:
|
|
31
|
+
* `our.parachute.computer` → `parachute-our-parachute-computer`
|
|
32
|
+
* `vault.example.com` → `parachute-vault-example-com`
|
|
33
|
+
*
|
|
34
|
+
* Length: tunnel names must satisfy `isValidTunnelName` (≤64 chars). When the
|
|
35
|
+
* derived name would exceed 64, truncate the sanitized body and append a short
|
|
36
|
+
* stable suffix (`-<8-hex>`) computed deterministically from the FULL hostname
|
|
37
|
+
* so two long hostnames sharing a 64-char prefix can't collide on the same
|
|
38
|
+
* tunnel. The hash is a non-crypto FNV-1a-style fold — deterministic, no
|
|
39
|
+
* Math.random / Date dependency (those would break idempotent re-expose).
|
|
40
|
+
*/
|
|
41
|
+
const TUNNEL_NAME_PREFIX = "parachute-";
|
|
42
|
+
const MAX_TUNNEL_NAME = 64;
|
|
43
|
+
|
|
44
|
+
function shortStableHash(input: string): string {
|
|
45
|
+
// FNV-1a 32-bit. Deterministic, dependency-free, good enough to disambiguate
|
|
46
|
+
// two hostnames that sanitize to the same truncated prefix. >>> 0 keeps it
|
|
47
|
+
// unsigned so the hex is stable across runtimes.
|
|
48
|
+
let h = 0x811c9dc5;
|
|
49
|
+
for (let i = 0; i < input.length; i++) {
|
|
50
|
+
h ^= input.charCodeAt(i);
|
|
51
|
+
h = Math.imul(h, 0x01000193);
|
|
52
|
+
}
|
|
53
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function deriveTunnelName(hostname: string): string {
|
|
57
|
+
const body = hostname
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/\./g, "-")
|
|
60
|
+
.replace(/[^a-z0-9_-]/g, "");
|
|
61
|
+
const full = `${TUNNEL_NAME_PREFIX}${body}`;
|
|
62
|
+
if (full.length <= MAX_TUNNEL_NAME) return full;
|
|
63
|
+
// Too long — truncate the body and append a stable 8-hex suffix derived from
|
|
64
|
+
// the full hostname. Reserve room for the prefix + "-" + 8 hex chars.
|
|
65
|
+
const suffix = `-${shortStableHash(hostname)}`;
|
|
66
|
+
const room = MAX_TUNNEL_NAME - TUNNEL_NAME_PREFIX.length - suffix.length;
|
|
67
|
+
// Strip any trailing hyphen the truncation left behind (e.g. a slice that
|
|
68
|
+
// lands on a dot-turned-hyphen) so the body doesn't abut the suffix as `--`.
|
|
69
|
+
const truncated = body.slice(0, room).replace(/-+$/, "");
|
|
70
|
+
return `${TUNNEL_NAME_PREFIX}${truncated}${suffix}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
7
73
|
/**
|
|
8
74
|
* Per-tunnel config + log file paths. Each tunnel gets its own subdirectory
|
|
9
75
|
* under `~/.parachute/cloudflared/<tunnelName>/` so multiple tunnels on one
|
|
10
76
|
* box don't trample each other's config.yml or interleave log lines.
|
|
11
77
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
78
|
+
* The per-hostname tunnel (`deriveTunnelName(host)`, e.g.
|
|
79
|
+
* `parachute-our-parachute-computer`) lives at
|
|
80
|
+
* `~/.parachute/cloudflared/<tunnelName>/{config.yml,cloudflared.log}`.
|
|
15
81
|
* Re-running `parachute expose public --cloudflare` regenerates the file
|
|
16
|
-
* at
|
|
82
|
+
* at that path; any legacy `parachute/` file is left in place but unused.
|
|
17
83
|
*
|
|
18
84
|
* `configDir` overrides the base (`~/.parachute` by default). Tests pass a
|
|
19
85
|
* tmp dir so per-tunnel-derived paths never resolve against the operator's
|
|
@@ -1,7 +1,12 @@
|
|
|
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";
|
|
5
10
|
import {
|
|
6
11
|
DEFAULT_CLOUDFLARED_HOME,
|
|
7
12
|
cloudflaredInstallHint,
|
|
@@ -10,6 +15,7 @@ import {
|
|
|
10
15
|
} from "../cloudflare/detect.ts";
|
|
11
16
|
import {
|
|
12
17
|
CLOUDFLARED_STATE_PATH,
|
|
18
|
+
type CloudflaredState,
|
|
13
19
|
type CloudflaredTunnelRecord,
|
|
14
20
|
clearCloudflaredState,
|
|
15
21
|
findTunnelRecord,
|
|
@@ -269,9 +275,12 @@ export interface ExposeCloudflareOpts {
|
|
|
269
275
|
*/
|
|
270
276
|
exposeStatePath?: string;
|
|
271
277
|
/**
|
|
272
|
-
* Tunnel name targeted by this invocation.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
278
|
+
* Tunnel name targeted by this invocation. The up-path defaults to a
|
|
279
|
+
* per-hostname derived name (`deriveTunnelName(hostname)`) so each machine
|
|
280
|
+
* gets its own tunnel and account-wide tunnels don't collide across boxes
|
|
281
|
+
* (#491). Override to pin a specific name (e.g. multiple tunnels on one
|
|
282
|
+
* box, #32). The off-path resolves the name from `cloudflared-state.json`
|
|
283
|
+
* when omitted (it has no hostname to derive from).
|
|
275
284
|
*/
|
|
276
285
|
tunnelName?: string;
|
|
277
286
|
/**
|
|
@@ -366,8 +375,20 @@ interface Resolved {
|
|
|
366
375
|
restartService: (short: string) => Promise<number>;
|
|
367
376
|
}
|
|
368
377
|
|
|
369
|
-
|
|
370
|
-
|
|
378
|
+
/**
|
|
379
|
+
* Resolve options into the fully-defaulted `Resolved` shape.
|
|
380
|
+
*
|
|
381
|
+
* `tunnelNameDefault` is the fallback tunnel name when the caller didn't pass
|
|
382
|
+
* an explicit `opts.tunnelName`. The up-path passes `deriveTunnelName(hostname)`
|
|
383
|
+
* so each machine/hostname gets its OWN dedicated tunnel (#491) — sharing one
|
|
384
|
+
* account-wide tunnel across boxes collides their connectors. An explicit
|
|
385
|
+
* `--tunnel-name` always wins (operators can override). The off-path has no
|
|
386
|
+
* hostname to derive from, so it resolves the name from state before calling
|
|
387
|
+
* in (see `exposeCloudflareOff`) and only relies on this default as a last
|
|
388
|
+
* resort.
|
|
389
|
+
*/
|
|
390
|
+
function resolve(opts: ExposeCloudflareOpts, tunnelNameDefault: string): Resolved {
|
|
391
|
+
const tunnelName = opts.tunnelName ?? tunnelNameDefault;
|
|
371
392
|
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
372
393
|
// Derive per-tunnel config/log paths from the *resolved* configDir, not the
|
|
373
394
|
// real `CONFIG_DIR`. When a test threads a tmp `configDir` but omits explicit
|
|
@@ -489,7 +510,12 @@ export async function exposeCloudflareUp(
|
|
|
489
510
|
hostname: string,
|
|
490
511
|
opts: ExposeCloudflareOpts = {},
|
|
491
512
|
): Promise<number> {
|
|
492
|
-
|
|
513
|
+
// Default to a per-hostname dedicated tunnel (#491). An explicit
|
|
514
|
+
// `--tunnel-name` still wins (handled inside `resolve`). Deriving from the
|
|
515
|
+
// hostname keeps re-expose idempotent (same hostname → same name → reuse the
|
|
516
|
+
// tunnel created last time) and stops two machines from colliding on the
|
|
517
|
+
// single account-wide `"parachute"` tunnel.
|
|
518
|
+
const r = resolve(opts, deriveTunnelName(hostname));
|
|
493
519
|
|
|
494
520
|
if (!isValidTunnelName(r.tunnelName)) {
|
|
495
521
|
r.log(
|
|
@@ -591,6 +617,9 @@ export async function exposeCloudflareUp(
|
|
|
591
617
|
return reportCloudflaredError(err, r.log);
|
|
592
618
|
}
|
|
593
619
|
r.log(`✓ Created tunnel ${tunnel.id}`);
|
|
620
|
+
r.log(
|
|
621
|
+
" Each machine gets its own dedicated tunnel — you don't need to run `cloudflared tunnel create` separately; expose does it.",
|
|
622
|
+
);
|
|
594
623
|
} else {
|
|
595
624
|
r.log(`✓ Reusing existing tunnel "${r.tunnelName}" (${tunnel.id})`);
|
|
596
625
|
}
|
|
@@ -672,6 +701,40 @@ export async function exposeCloudflareUp(
|
|
|
672
701
|
}
|
|
673
702
|
}
|
|
674
703
|
|
|
704
|
+
// Legacy shared-tunnel migration sweep (#491). Aaron's running boxes were
|
|
705
|
+
// exposed under the old single account-wide `"parachute"` tunnel; the bug
|
|
706
|
+
// was that a second box reusing that name collided connectors. Now that the
|
|
707
|
+
// default is per-hostname, a box upgrading and re-exposing will create/route
|
|
708
|
+
// a NEW dedicated tunnel — but the OLD `"parachute"` connector is still
|
|
709
|
+
// running, still registered on the shared tunnel, still able to pick up
|
|
710
|
+
// load-balanced requests for OTHER hosts. Kill it + drop its state record so
|
|
711
|
+
// the box self-heals immediately on this expose instead of at the next
|
|
712
|
+
// reboot. Only fires when (a) we actually migrated AWAY from "parachute"
|
|
713
|
+
// (the new derived name differs) and (b) a live legacy record exists.
|
|
714
|
+
// `routeDns` above already used `--overwrite-dns`, so this hostname's CNAME
|
|
715
|
+
// has been repointed to the new tunnel — the legacy connector can't serve it
|
|
716
|
+
// anymore regardless; this just stops it from serving anyone else's.
|
|
717
|
+
let migratedState = stateBefore;
|
|
718
|
+
if (r.tunnelName !== DEFAULT_TUNNEL_NAME) {
|
|
719
|
+
const legacy = findTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
|
|
720
|
+
if (legacy) {
|
|
721
|
+
if (r.alive(legacy.pid)) {
|
|
722
|
+
try {
|
|
723
|
+
r.kill(legacy.pid, "SIGTERM");
|
|
724
|
+
} catch {
|
|
725
|
+
// Already gone between read and kill — fine; we drop the record below.
|
|
726
|
+
}
|
|
727
|
+
r.log(
|
|
728
|
+
`Stopped legacy shared-tunnel connector (migrated ${hostname} to dedicated tunnel ${r.tunnelName}).`,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
// Drop the legacy shared-tunnel record whether or not its connector was
|
|
732
|
+
// still alive. A dead record would otherwise linger across re-exposes
|
|
733
|
+
// until the next `off`; clearing it here keeps state tidy (#491 review).
|
|
734
|
+
migratedState = withoutTunnelRecord(stateBefore, DEFAULT_TUNNEL_NAME);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
675
738
|
const pid = r.spawner.spawn(
|
|
676
739
|
["cloudflared", "tunnel", "--config", r.configPath, "run"],
|
|
677
740
|
r.logPath,
|
|
@@ -685,7 +748,7 @@ export async function exposeCloudflareUp(
|
|
|
685
748
|
startedAt: r.now().toISOString(),
|
|
686
749
|
configPath: r.configPath,
|
|
687
750
|
};
|
|
688
|
-
writeCloudflaredState(withTunnelRecord(
|
|
751
|
+
writeCloudflaredState(withTunnelRecord(migratedState, record), r.statePath);
|
|
689
752
|
|
|
690
753
|
// Persist the shared cross-provider expose record. Without this, the
|
|
691
754
|
// Tailscale path was the only one writing expose-state.json — so after a
|
|
@@ -763,12 +826,20 @@ export async function exposeCloudflareUp(
|
|
|
763
826
|
|
|
764
827
|
r.log("");
|
|
765
828
|
r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
|
|
829
|
+
r.log(` Tunnel: ${r.tunnelName} (dedicated to this machine)`);
|
|
766
830
|
r.log(` Open: ${baseUrl}/`);
|
|
767
831
|
r.log(` Admin: ${baseUrl}/admin/`);
|
|
768
832
|
r.log(` Vault: ${vaultUrl}`);
|
|
769
833
|
r.log(` OAuth: ${hubOrigin}`);
|
|
770
834
|
r.log(` Logs: ${r.logPath}`);
|
|
771
835
|
r.log("");
|
|
836
|
+
// Honest reboot caveat: the connector is a detached background process, not
|
|
837
|
+
// yet a launchd/systemd service, so it does NOT survive a reboot (durable
|
|
838
|
+
// connector is a tracked follow-up). Re-running the same command brings it
|
|
839
|
+
// back idempotently — same hostname → same dedicated tunnel.
|
|
840
|
+
r.log("Note: the connector runs in the background but does not survive a reboot yet. After a");
|
|
841
|
+
r.log(`reboot, re-run: parachute expose public --cloudflare --domain ${hostname}`);
|
|
842
|
+
r.log("");
|
|
772
843
|
r.log("Point a claude.ai / ChatGPT connector at:");
|
|
773
844
|
r.log(` ${vaultUrl}`);
|
|
774
845
|
printAuthGuidance(r.log, vaultUrl);
|
|
@@ -784,30 +855,27 @@ export async function exposeCloudflareUp(
|
|
|
784
855
|
return 0;
|
|
785
856
|
}
|
|
786
857
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
return 0;
|
|
803
|
-
}
|
|
858
|
+
/**
|
|
859
|
+
* Tear down ONE tunnel record: SIGTERM its connector, sweep any orphan
|
|
860
|
+
* connectors for it (hub#487), drop its state record, and emit the
|
|
861
|
+
* reuse-hint copy. Pure-ish over `r` + the current state: returns the state
|
|
862
|
+
* with the record removed (or undefined when that empties it) plus an exit
|
|
863
|
+
* code, so the caller commits the disk write once after tearing down one or
|
|
864
|
+
* many tunnels. The connector kill is non-fatal-on-already-gone, fatal only
|
|
865
|
+
* when SIGTERM itself errors on a live pid.
|
|
866
|
+
*/
|
|
867
|
+
function teardownOne(
|
|
868
|
+
r: Resolved,
|
|
869
|
+
state: CloudflaredState | undefined,
|
|
870
|
+
record: CloudflaredTunnelRecord,
|
|
871
|
+
): { state: CloudflaredState | undefined; code: number } {
|
|
804
872
|
if (r.alive(record.pid)) {
|
|
805
873
|
try {
|
|
806
874
|
r.kill(record.pid, "SIGTERM");
|
|
807
|
-
r.log(`✓ Stopped cloudflared (pid ${record.pid}).`);
|
|
875
|
+
r.log(`✓ Stopped cloudflared (pid ${record.pid}, tunnel "${record.tunnelName}").`);
|
|
808
876
|
} catch (err) {
|
|
809
877
|
r.log(`✗ Failed to stop cloudflared: ${err instanceof Error ? err.message : String(err)}`);
|
|
810
|
-
return 1;
|
|
878
|
+
return { state, code: 1 };
|
|
811
879
|
}
|
|
812
880
|
} else {
|
|
813
881
|
r.log(`cloudflared (pid ${record.pid}) wasn't running; clearing stale state.`);
|
|
@@ -824,9 +892,80 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
|
|
|
824
892
|
// Already gone between probe and kill — fine.
|
|
825
893
|
}
|
|
826
894
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
895
|
+
r.log(` ${record.hostname} is no longer reachable through this machine.`);
|
|
896
|
+
r.log(
|
|
897
|
+
` Tunnel "${record.tunnelName}" (${record.tunnelUuid}) remains defined in Cloudflare; re-running`,
|
|
898
|
+
);
|
|
899
|
+
// Only suggest `--tunnel-name` for a custom name. The auto-derived name
|
|
900
|
+
// (and the legacy shared "parachute" name) need no flag — re-running with
|
|
901
|
+
// just --domain re-derives the per-hostname name (and migrates a legacy
|
|
902
|
+
// record off the shared tunnel), which is exactly what we want.
|
|
903
|
+
const isAutoName =
|
|
904
|
+
record.tunnelName === deriveTunnelName(record.hostname) ||
|
|
905
|
+
record.tunnelName === DEFAULT_TUNNEL_NAME;
|
|
906
|
+
r.log(
|
|
907
|
+
` \`parachute expose public --cloudflare --domain ${record.hostname}${isAutoName ? "" : ` --tunnel-name ${record.tunnelName}`}\` reuses it.`,
|
|
908
|
+
);
|
|
909
|
+
return { state: withoutTunnelRecord(state, record.tunnelName), code: 0 };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
|
|
913
|
+
// The off-path has no hostname to derive a name from. When `--tunnel-name`
|
|
914
|
+
// is set we use it; otherwise we resolve from cloudflared-state.json (below).
|
|
915
|
+
// `DEFAULT_TUNNEL_NAME` is only the inert `resolve` fallback here — the
|
|
916
|
+
// state-driven branch never relies on it.
|
|
917
|
+
const r = resolve(opts, DEFAULT_TUNNEL_NAME);
|
|
918
|
+
const stateBefore = readCloudflaredState(r.statePath);
|
|
919
|
+
const records = listTunnelRecords(stateBefore);
|
|
920
|
+
|
|
921
|
+
// Decide which records to tear down.
|
|
922
|
+
// - explicit `--tunnel-name` → exactly that one (or a not-found message).
|
|
923
|
+
// - no flag, 0 tunnels → nothing to do.
|
|
924
|
+
// - no flag, exactly 1 → that one.
|
|
925
|
+
// - no flag, ≥2 → ALL of them. A bare `expose public
|
|
926
|
+
// --cloudflare off` means "stop all public Cloudflare exposure on this
|
|
927
|
+
// machine"; tearing down only one would leave the box half-exposed with
|
|
928
|
+
// no obvious signal which tunnel survived.
|
|
929
|
+
let targets: CloudflaredTunnelRecord[];
|
|
930
|
+
if (opts.tunnelName !== undefined) {
|
|
931
|
+
const record = findTunnelRecord(stateBefore, r.tunnelName);
|
|
932
|
+
if (!record) {
|
|
933
|
+
if (records.length > 0) {
|
|
934
|
+
const others = records.map((t) => t.tunnelName).join(", ");
|
|
935
|
+
r.log(
|
|
936
|
+
`No Cloudflare exposure recorded for tunnel "${r.tunnelName}". Other tunnels: ${others}.`,
|
|
937
|
+
);
|
|
938
|
+
} else {
|
|
939
|
+
r.log("No Cloudflare exposure recorded. Nothing to tear down.");
|
|
940
|
+
}
|
|
941
|
+
return 0;
|
|
942
|
+
}
|
|
943
|
+
targets = [record];
|
|
944
|
+
} else {
|
|
945
|
+
if (records.length === 0) {
|
|
946
|
+
r.log("No Cloudflare exposure recorded. Nothing to tear down.");
|
|
947
|
+
return 0;
|
|
948
|
+
}
|
|
949
|
+
if (records.length > 1) {
|
|
950
|
+
r.log(
|
|
951
|
+
`Tearing down all ${records.length} recorded Cloudflare tunnels: ${records
|
|
952
|
+
.map((t) => t.tunnelName)
|
|
953
|
+
.join(", ")}.`,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
targets = records;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
let state = stateBefore;
|
|
960
|
+
let failed = false;
|
|
961
|
+
for (const record of targets) {
|
|
962
|
+
const result = teardownOne(r, state, record);
|
|
963
|
+
state = result.state;
|
|
964
|
+
if (result.code !== 0) failed = true;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (state) {
|
|
968
|
+
writeCloudflaredState(state, r.statePath);
|
|
830
969
|
} else {
|
|
831
970
|
clearCloudflaredState(r.statePath);
|
|
832
971
|
}
|
|
@@ -834,17 +973,10 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
|
|
|
834
973
|
// downstream consumers stop resolving the now-dead public URL (mirrors the
|
|
835
974
|
// up-path write above + the Tailscale off-path's expose-state teardown). When
|
|
836
975
|
// other tunnels survive we leave it — a later off for the last one clears it.
|
|
837
|
-
if (!
|
|
976
|
+
if (!state) {
|
|
838
977
|
clearExposeState(r.exposeStatePath);
|
|
839
978
|
}
|
|
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;
|
|
979
|
+
return failed ? 1 : 0;
|
|
848
980
|
}
|
|
849
981
|
|
|
850
982
|
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.
|