@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.1-rc.4",
4
- "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
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 parachute with id ${uuid}\n`,
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", "parachute"]);
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
- "parachute",
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
- parachute: {
181
+ [derived]: {
178
182
  pid: 42000,
179
183
  tunnelUuid: uuid,
180
- tunnelName: "parachute",
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 name "parachute"
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 parachute with id ${uuidA}\n`,
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 they're per-tunnel-derived but the
885
- // derivation now resolves against the tmp `configDir` above, so the
886
- // generated config.yml lands under tmp, not the operator's real
887
- // ~/.parachute/cloudflared/parachute/.
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(["parachute", "second"]);
922
- expect(findTunnelRecord(state, "parachute")?.hostname).toBe("alpha.example.com");
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, "parachute")?.configPath ?? "";
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("/parachute/config.yml")).toBe(true);
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
@@ -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 default tunnel ("parachute") lives at
13
- * `~/.parachute/cloudflared/parachute/{config.yml,cloudflared.log}` a
14
- * location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
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 the new path; the legacy file is left in place but unused.
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 { DEFAULT_TUNNEL_NAME, cloudflaredPathsFor, writeConfig } from "../cloudflare/config.ts";
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. Defaults to `parachute` —
273
- * the canonical single-tunnel name. Override to run multiple tunnels on
274
- * one box (#32).
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
- function resolve(opts: ExposeCloudflareOpts): Resolved {
370
- const tunnelName = opts.tunnelName ?? DEFAULT_TUNNEL_NAME;
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
- const r = resolve(opts);
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(stateBefore, record), r.statePath);
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
- export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Promise<number> {
788
- const r = resolve(opts);
789
- const stateBefore = readCloudflaredState(r.statePath);
790
- const record = findTunnelRecord(stateBefore, r.tunnelName);
791
- if (!record) {
792
- if (stateBefore && Object.keys(stateBefore.tunnels).length > 0) {
793
- const others = listTunnelRecords(stateBefore)
794
- .map((t) => t.tunnelName)
795
- .join(", ");
796
- r.log(
797
- `No Cloudflare exposure recorded for tunnel "${r.tunnelName}". Other tunnels: ${others}.`,
798
- );
799
- } else {
800
- r.log("No Cloudflare exposure recorded. Nothing to tear down.");
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
- const stateAfter = withoutTunnelRecord(stateBefore, r.tunnelName);
828
- if (stateAfter) {
829
- writeCloudflaredState(stateAfter, r.statePath);
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 (!stateAfter) {
976
+ if (!state) {
838
977
  clearExposeState(r.exposeStatePath);
839
978
  }
840
- r.log(` ${record.hostname} is no longer reachable through this machine.`);
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 (default: \`parachute\`).
373
- Use to coexist multiple named tunnels on one box.
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.