@openparachute/hub 0.3.0-rc.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/LICENSE +661 -0
- package/README.md +284 -0
- package/package.json +31 -0
- package/src/__tests__/auth.test.ts +101 -0
- package/src/__tests__/auto-wire.test.ts +283 -0
- package/src/__tests__/cli.test.ts +192 -0
- package/src/__tests__/cloudflare-config.test.ts +54 -0
- package/src/__tests__/cloudflare-detect.test.ts +68 -0
- package/src/__tests__/cloudflare-state.test.ts +92 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
- package/src/__tests__/config.test.ts +18 -0
- package/src/__tests__/env-file.test.ts +125 -0
- package/src/__tests__/expose-auth-preflight.test.ts +201 -0
- package/src/__tests__/expose-cloudflare.test.ts +484 -0
- package/src/__tests__/expose-interactive.test.ts +703 -0
- package/src/__tests__/expose-last-provider.test.ts +113 -0
- package/src/__tests__/expose-off-auto.test.ts +269 -0
- package/src/__tests__/expose-state.test.ts +101 -0
- package/src/__tests__/expose.test.ts +1581 -0
- package/src/__tests__/hub-control.test.ts +346 -0
- package/src/__tests__/hub-server.test.ts +157 -0
- package/src/__tests__/hub.test.ts +116 -0
- package/src/__tests__/install.test.ts +1145 -0
- package/src/__tests__/lifecycle.test.ts +608 -0
- package/src/__tests__/migrate.test.ts +422 -0
- package/src/__tests__/notes-serve.test.ts +135 -0
- package/src/__tests__/port-assign.test.ts +178 -0
- package/src/__tests__/process-state.test.ts +140 -0
- package/src/__tests__/scribe-config.test.ts +193 -0
- package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
- package/src/__tests__/services-manifest.test.ts +177 -0
- package/src/__tests__/status.test.ts +347 -0
- package/src/__tests__/tailscale-commands.test.ts +111 -0
- package/src/__tests__/tailscale-detect.test.ts +64 -0
- package/src/__tests__/vault-auth-status.test.ts +164 -0
- package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
- package/src/__tests__/well-known.test.ts +214 -0
- package/src/auto-wire.ts +184 -0
- package/src/cli.ts +482 -0
- package/src/cloudflare/config.ts +58 -0
- package/src/cloudflare/detect.ts +58 -0
- package/src/cloudflare/state.ts +96 -0
- package/src/cloudflare/tunnel.ts +135 -0
- package/src/commands/auth.ts +69 -0
- package/src/commands/expose-auth-preflight.ts +217 -0
- package/src/commands/expose-cloudflare.ts +329 -0
- package/src/commands/expose-interactive.ts +428 -0
- package/src/commands/expose-off-auto.ts +199 -0
- package/src/commands/expose.ts +522 -0
- package/src/commands/install.ts +422 -0
- package/src/commands/lifecycle.ts +324 -0
- package/src/commands/migrate.ts +253 -0
- package/src/commands/scribe-provider-interactive.ts +269 -0
- package/src/commands/status.ts +238 -0
- package/src/commands/vault-tokens-create-interactive.ts +137 -0
- package/src/commands/vault.ts +17 -0
- package/src/config.ts +16 -0
- package/src/env-file.ts +76 -0
- package/src/expose-last-provider.ts +71 -0
- package/src/expose-state.ts +125 -0
- package/src/help.ts +279 -0
- package/src/hub-control.ts +254 -0
- package/src/hub-origin.ts +44 -0
- package/src/hub-server.ts +113 -0
- package/src/hub.ts +674 -0
- package/src/notes-serve.ts +135 -0
- package/src/port-assign.ts +125 -0
- package/src/process-state.ts +111 -0
- package/src/scribe-config.ts +149 -0
- package/src/service-spec.ts +296 -0
- package/src/services-manifest.ts +171 -0
- package/src/tailscale/commands.ts +41 -0
- package/src/tailscale/detect.ts +107 -0
- package/src/tailscale/run.ts +28 -0
- package/src/vault/auth-status.ts +179 -0
- package/src/well-known.ts +127 -0
|
@@ -0,0 +1,1581 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { exposePublic, exposeTailnet } from "../commands/expose.ts";
|
|
6
|
+
import { readExposeState, writeExposeState } from "../expose-state.ts";
|
|
7
|
+
import type { EnsureHubOpts, HubSpawner, StopHubOpts } from "../hub-control.ts";
|
|
8
|
+
import { writePid } from "../process-state.ts";
|
|
9
|
+
import { upsertService } from "../services-manifest.ts";
|
|
10
|
+
import type { Runner } from "../tailscale/run.ts";
|
|
11
|
+
|
|
12
|
+
interface Harness {
|
|
13
|
+
dir: string;
|
|
14
|
+
manifestPath: string;
|
|
15
|
+
statePath: string;
|
|
16
|
+
wellKnownPath: string;
|
|
17
|
+
hubPath: string;
|
|
18
|
+
wellKnownDir: string;
|
|
19
|
+
configDir: string;
|
|
20
|
+
cleanup: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeHarness(): Harness {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-expose-"));
|
|
25
|
+
return {
|
|
26
|
+
dir,
|
|
27
|
+
manifestPath: join(dir, "services.json"),
|
|
28
|
+
statePath: join(dir, "expose-state.json"),
|
|
29
|
+
wellKnownPath: join(dir, "well-known", "parachute.json"),
|
|
30
|
+
hubPath: join(dir, "well-known", "hub.html"),
|
|
31
|
+
wellKnownDir: join(dir, "well-known"),
|
|
32
|
+
configDir: dir,
|
|
33
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeRunner(): { runner: Runner; calls: string[][] } {
|
|
38
|
+
const calls: string[][] = [];
|
|
39
|
+
const runner: Runner = async (cmd) => {
|
|
40
|
+
calls.push([...cmd]);
|
|
41
|
+
if (cmd[0] === "tailscale" && cmd[1] === "version") {
|
|
42
|
+
return { code: 0, stdout: "1.96.4\n", stderr: "" };
|
|
43
|
+
}
|
|
44
|
+
if (cmd[0] === "tailscale" && cmd[1] === "status" && cmd[2] === "--json") {
|
|
45
|
+
return {
|
|
46
|
+
code: 0,
|
|
47
|
+
stdout: JSON.stringify({ Self: { DNSName: "parachute.taildf9ce2.ts.net." } }),
|
|
48
|
+
stderr: "",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
52
|
+
};
|
|
53
|
+
return { runner, calls };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeHubSpawner(pid: number): { spawner: HubSpawner; calls: string[][] } {
|
|
57
|
+
const calls: string[][] = [];
|
|
58
|
+
const spawner: HubSpawner = {
|
|
59
|
+
spawn(cmd) {
|
|
60
|
+
calls.push([...cmd]);
|
|
61
|
+
return pid;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
return { spawner, calls };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Default hub overrides for expose tests — no real subprocess, no sleep. */
|
|
68
|
+
function hubEnsureOpts(
|
|
69
|
+
spawner: HubSpawner,
|
|
70
|
+
): Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log"> {
|
|
71
|
+
return {
|
|
72
|
+
spawner,
|
|
73
|
+
alive: () => true,
|
|
74
|
+
probe: async () => true,
|
|
75
|
+
readyWaitMs: 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hubStopOpts(): Omit<StopHubOpts, "configDir" | "log"> {
|
|
80
|
+
return {
|
|
81
|
+
kill: () => {},
|
|
82
|
+
alive: () => false,
|
|
83
|
+
sleep: async () => {},
|
|
84
|
+
now: () => 0,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function seedServices(path: string): void {
|
|
89
|
+
upsertService(
|
|
90
|
+
{
|
|
91
|
+
name: "parachute-vault",
|
|
92
|
+
port: 1940,
|
|
93
|
+
paths: ["/vault/default"],
|
|
94
|
+
health: "/vault/default/health",
|
|
95
|
+
version: "0.2.4",
|
|
96
|
+
},
|
|
97
|
+
path,
|
|
98
|
+
);
|
|
99
|
+
upsertService(
|
|
100
|
+
{
|
|
101
|
+
name: "parachute-notes",
|
|
102
|
+
port: 5173,
|
|
103
|
+
paths: ["/notes"],
|
|
104
|
+
health: "/notes/health",
|
|
105
|
+
version: "0.0.1",
|
|
106
|
+
},
|
|
107
|
+
path,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Default probe for tests: every service is up (nobody wants dead-service warnings polluting unrelated assertions). */
|
|
112
|
+
const allServicesUp = async () => true;
|
|
113
|
+
|
|
114
|
+
describe("expose tailnet up", () => {
|
|
115
|
+
test("mounts hub proxy at /, one proxy per service, plus well-known proxy", async () => {
|
|
116
|
+
const h = makeHarness();
|
|
117
|
+
try {
|
|
118
|
+
seedServices(h.manifestPath);
|
|
119
|
+
const { runner, calls } = makeRunner();
|
|
120
|
+
const { spawner } = makeHubSpawner(1111);
|
|
121
|
+
const logs: string[] = [];
|
|
122
|
+
const code = await exposeTailnet("up", {
|
|
123
|
+
runner,
|
|
124
|
+
manifestPath: h.manifestPath,
|
|
125
|
+
statePath: h.statePath,
|
|
126
|
+
wellKnownPath: h.wellKnownPath,
|
|
127
|
+
hubPath: h.hubPath,
|
|
128
|
+
wellKnownDir: h.wellKnownDir,
|
|
129
|
+
configDir: h.configDir,
|
|
130
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
131
|
+
servicePortProbe: allServicesUp,
|
|
132
|
+
log: (l) => logs.push(l),
|
|
133
|
+
});
|
|
134
|
+
expect(code).toBe(0);
|
|
135
|
+
|
|
136
|
+
const serveCalls = calls.filter(
|
|
137
|
+
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
138
|
+
);
|
|
139
|
+
// 4 baseline (hub + wk + vault + notes) + 4 OAuth proxies (vault present).
|
|
140
|
+
expect(serveCalls).toHaveLength(8);
|
|
141
|
+
// Tailnet mode never uses funnel — neither the old flag nor the new subcommand.
|
|
142
|
+
expect(serveCalls.every((c) => !c.includes("--funnel"))).toBe(true);
|
|
143
|
+
expect(calls.every((c) => c[1] !== "funnel")).toBe(true);
|
|
144
|
+
|
|
145
|
+
const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path="))).sort();
|
|
146
|
+
expect(mounts).toEqual([
|
|
147
|
+
"--set-path=/",
|
|
148
|
+
"--set-path=/.well-known/oauth-authorization-server",
|
|
149
|
+
"--set-path=/.well-known/parachute.json",
|
|
150
|
+
"--set-path=/notes",
|
|
151
|
+
"--set-path=/oauth/authorize",
|
|
152
|
+
"--set-path=/oauth/register",
|
|
153
|
+
"--set-path=/oauth/token",
|
|
154
|
+
"--set-path=/vault/default",
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
// Hub + well-known now point at localhost HTTP, not a file path.
|
|
158
|
+
// Target path mirrors mount exactly so tailscale's strip-then-forward
|
|
159
|
+
// is a no-op; otherwise SPAs at /<mount>/ redirect-loop.
|
|
160
|
+
const hubCall = serveCalls.find((c) => c.includes("--set-path=/"));
|
|
161
|
+
expect(hubCall?.[hubCall.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
|
|
162
|
+
|
|
163
|
+
const wkCall = serveCalls.find((c) => c.includes("--set-path=/.well-known/parachute.json"));
|
|
164
|
+
expect(wkCall?.[wkCall.length - 1]).toMatch(
|
|
165
|
+
/^http:\/\/127\.0\.0\.1:\d+\/\.well-known\/parachute\.json$/,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Service targets also include their mount path to prevent tailscale
|
|
169
|
+
// from stripping the prefix before forwarding to a base-aware backend.
|
|
170
|
+
const notesCall = serveCalls.find((c) => c.includes("--set-path=/notes"));
|
|
171
|
+
expect(notesCall?.[notesCall.length - 1]).toBe("http://127.0.0.1:5173/notes");
|
|
172
|
+
const vaultCall = serveCalls.find((c) => c.includes("--set-path=/vault/default"));
|
|
173
|
+
expect(vaultCall?.[vaultCall.length - 1]).toBe("http://127.0.0.1:1940/vault/default");
|
|
174
|
+
|
|
175
|
+
expect(existsSync(h.wellKnownPath)).toBe(true);
|
|
176
|
+
expect(existsSync(h.hubPath)).toBe(true);
|
|
177
|
+
const wk = JSON.parse(await Bun.file(h.wellKnownPath).text());
|
|
178
|
+
expect(wk.vaults).toHaveLength(1);
|
|
179
|
+
|
|
180
|
+
const state = readExposeState(h.statePath);
|
|
181
|
+
expect(state?.layer).toBe("tailnet");
|
|
182
|
+
expect(state?.mode).toBe("path");
|
|
183
|
+
expect(state?.entries).toHaveLength(8);
|
|
184
|
+
// All entries are proxy now — no file-backed tailscale serve.
|
|
185
|
+
expect(state?.entries.every((e) => e.kind === "proxy")).toBe(true);
|
|
186
|
+
} finally {
|
|
187
|
+
h.cleanup();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("spawns hub server with --port + --well-known-dir", async () => {
|
|
192
|
+
const h = makeHarness();
|
|
193
|
+
try {
|
|
194
|
+
seedServices(h.manifestPath);
|
|
195
|
+
const { runner } = makeRunner();
|
|
196
|
+
const { spawner, calls: hubCalls } = makeHubSpawner(7777);
|
|
197
|
+
const code = await exposeTailnet("up", {
|
|
198
|
+
runner,
|
|
199
|
+
manifestPath: h.manifestPath,
|
|
200
|
+
statePath: h.statePath,
|
|
201
|
+
wellKnownPath: h.wellKnownPath,
|
|
202
|
+
hubPath: h.hubPath,
|
|
203
|
+
wellKnownDir: h.wellKnownDir,
|
|
204
|
+
configDir: h.configDir,
|
|
205
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
206
|
+
servicePortProbe: allServicesUp,
|
|
207
|
+
log: () => {},
|
|
208
|
+
});
|
|
209
|
+
expect(code).toBe(0);
|
|
210
|
+
expect(hubCalls).toHaveLength(1);
|
|
211
|
+
const cmd = hubCalls[0] ?? [];
|
|
212
|
+
expect(cmd[0]).toBe("bun");
|
|
213
|
+
expect(cmd).toContain("--port");
|
|
214
|
+
expect(cmd).toContain("--well-known-dir");
|
|
215
|
+
expect(cmd).toContain(h.wellKnownDir);
|
|
216
|
+
} finally {
|
|
217
|
+
h.cleanup();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("trailing-slash mount preserves trailing slash in target URL", async () => {
|
|
222
|
+
// Aaron hit ERR_TOO_MANY_REDIRECTS on /notes/ because tailscale strips
|
|
223
|
+
// the prefix, Vite (base=/notes) redirects back to /notes/, tailscale
|
|
224
|
+
// strips again, loop. Pinning target = mount byte-for-byte breaks that.
|
|
225
|
+
const h = makeHarness();
|
|
226
|
+
try {
|
|
227
|
+
upsertService(
|
|
228
|
+
{
|
|
229
|
+
name: "parachute-notes",
|
|
230
|
+
port: 5173,
|
|
231
|
+
paths: ["/notes/"],
|
|
232
|
+
health: "/notes/health",
|
|
233
|
+
version: "0.0.1",
|
|
234
|
+
},
|
|
235
|
+
h.manifestPath,
|
|
236
|
+
);
|
|
237
|
+
const { runner, calls } = makeRunner();
|
|
238
|
+
const { spawner } = makeHubSpawner(1111);
|
|
239
|
+
const code = await exposeTailnet("up", {
|
|
240
|
+
runner,
|
|
241
|
+
manifestPath: h.manifestPath,
|
|
242
|
+
statePath: h.statePath,
|
|
243
|
+
wellKnownPath: h.wellKnownPath,
|
|
244
|
+
hubPath: h.hubPath,
|
|
245
|
+
wellKnownDir: h.wellKnownDir,
|
|
246
|
+
configDir: h.configDir,
|
|
247
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
248
|
+
servicePortProbe: allServicesUp,
|
|
249
|
+
log: () => {},
|
|
250
|
+
});
|
|
251
|
+
expect(code).toBe(0);
|
|
252
|
+
const serveCalls = calls.filter(
|
|
253
|
+
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
254
|
+
);
|
|
255
|
+
const notesCall = serveCalls.find((c) => c.includes("--set-path=/notes/"));
|
|
256
|
+
expect(notesCall).toBeDefined();
|
|
257
|
+
expect(notesCall?.[notesCall.length - 1]).toBe("http://127.0.0.1:5173/notes/");
|
|
258
|
+
} finally {
|
|
259
|
+
h.cleanup();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("legacy paths:[/] entry is remapped to /<shortname> with warning", async () => {
|
|
264
|
+
const h = makeHarness();
|
|
265
|
+
try {
|
|
266
|
+
upsertService(
|
|
267
|
+
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.2.4" },
|
|
268
|
+
h.manifestPath,
|
|
269
|
+
);
|
|
270
|
+
const { runner, calls } = makeRunner();
|
|
271
|
+
const { spawner } = makeHubSpawner(1111);
|
|
272
|
+
const logs: string[] = [];
|
|
273
|
+
const code = await exposeTailnet("up", {
|
|
274
|
+
runner,
|
|
275
|
+
manifestPath: h.manifestPath,
|
|
276
|
+
statePath: h.statePath,
|
|
277
|
+
wellKnownPath: h.wellKnownPath,
|
|
278
|
+
hubPath: h.hubPath,
|
|
279
|
+
wellKnownDir: h.wellKnownDir,
|
|
280
|
+
configDir: h.configDir,
|
|
281
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
282
|
+
servicePortProbe: allServicesUp,
|
|
283
|
+
log: (l) => logs.push(l),
|
|
284
|
+
});
|
|
285
|
+
expect(code).toBe(0);
|
|
286
|
+
|
|
287
|
+
const serveCalls = calls.filter(
|
|
288
|
+
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
289
|
+
);
|
|
290
|
+
const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path="))).sort();
|
|
291
|
+
expect(mounts).toContain("--set-path=/vault");
|
|
292
|
+
expect(mounts).toContain("--set-path=/");
|
|
293
|
+
expect(mounts.filter((m) => m === "--set-path=/")).toHaveLength(1);
|
|
294
|
+
|
|
295
|
+
expect(logs.join("\n")).toMatch(/parachute-vault claims "\/"; hub page lives there/);
|
|
296
|
+
} finally {
|
|
297
|
+
h.cleanup();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("empty manifest exits 1 with hint", async () => {
|
|
302
|
+
const h = makeHarness();
|
|
303
|
+
try {
|
|
304
|
+
const { runner } = makeRunner();
|
|
305
|
+
const { spawner } = makeHubSpawner(1111);
|
|
306
|
+
const logs: string[] = [];
|
|
307
|
+
const code = await exposeTailnet("up", {
|
|
308
|
+
runner,
|
|
309
|
+
manifestPath: h.manifestPath,
|
|
310
|
+
statePath: h.statePath,
|
|
311
|
+
wellKnownPath: h.wellKnownPath,
|
|
312
|
+
hubPath: h.hubPath,
|
|
313
|
+
wellKnownDir: h.wellKnownDir,
|
|
314
|
+
configDir: h.configDir,
|
|
315
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
316
|
+
servicePortProbe: allServicesUp,
|
|
317
|
+
log: (l) => logs.push(l),
|
|
318
|
+
});
|
|
319
|
+
expect(code).toBe(1);
|
|
320
|
+
expect(logs.join("\n")).toMatch(/No services installed/);
|
|
321
|
+
} finally {
|
|
322
|
+
h.cleanup();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("missing tailscale exits 1 with install hint", async () => {
|
|
327
|
+
const h = makeHarness();
|
|
328
|
+
try {
|
|
329
|
+
seedServices(h.manifestPath);
|
|
330
|
+
const runner: Runner = async () => {
|
|
331
|
+
throw new Error("spawn tailscale ENOENT");
|
|
332
|
+
};
|
|
333
|
+
const { spawner } = makeHubSpawner(1111);
|
|
334
|
+
const logs: string[] = [];
|
|
335
|
+
const code = await exposeTailnet("up", {
|
|
336
|
+
runner,
|
|
337
|
+
manifestPath: h.manifestPath,
|
|
338
|
+
statePath: h.statePath,
|
|
339
|
+
wellKnownPath: h.wellKnownPath,
|
|
340
|
+
hubPath: h.hubPath,
|
|
341
|
+
wellKnownDir: h.wellKnownDir,
|
|
342
|
+
configDir: h.configDir,
|
|
343
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
344
|
+
servicePortProbe: allServicesUp,
|
|
345
|
+
log: (l) => logs.push(l),
|
|
346
|
+
});
|
|
347
|
+
expect(code).toBe(1);
|
|
348
|
+
expect(logs.join("\n")).toMatch(/tailscale is not installed/);
|
|
349
|
+
} finally {
|
|
350
|
+
h.cleanup();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("idempotent re-run: tears down prior state first", async () => {
|
|
355
|
+
const h = makeHarness();
|
|
356
|
+
try {
|
|
357
|
+
seedServices(h.manifestPath);
|
|
358
|
+
writeExposeState(
|
|
359
|
+
{
|
|
360
|
+
version: 1,
|
|
361
|
+
layer: "tailnet",
|
|
362
|
+
mode: "path",
|
|
363
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
364
|
+
port: 443,
|
|
365
|
+
funnel: false,
|
|
366
|
+
entries: [
|
|
367
|
+
{
|
|
368
|
+
kind: "proxy",
|
|
369
|
+
mount: "/old-service",
|
|
370
|
+
target: "http://127.0.0.1:9999",
|
|
371
|
+
service: "parachute-old",
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
h.statePath,
|
|
376
|
+
);
|
|
377
|
+
const { runner, calls } = makeRunner();
|
|
378
|
+
const { spawner } = makeHubSpawner(1111);
|
|
379
|
+
const code = await exposeTailnet("up", {
|
|
380
|
+
runner,
|
|
381
|
+
manifestPath: h.manifestPath,
|
|
382
|
+
statePath: h.statePath,
|
|
383
|
+
wellKnownPath: h.wellKnownPath,
|
|
384
|
+
hubPath: h.hubPath,
|
|
385
|
+
wellKnownDir: h.wellKnownDir,
|
|
386
|
+
configDir: h.configDir,
|
|
387
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
388
|
+
servicePortProbe: allServicesUp,
|
|
389
|
+
log: () => {},
|
|
390
|
+
});
|
|
391
|
+
expect(code).toBe(0);
|
|
392
|
+
const offs = calls.filter((c) => c[c.length - 1] === "off");
|
|
393
|
+
expect(offs).toHaveLength(1);
|
|
394
|
+
expect(offs[0]).toContain("--set-path=/old-service");
|
|
395
|
+
} finally {
|
|
396
|
+
h.cleanup();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("warns (but still exposes) when a service port isn't responding", async () => {
|
|
401
|
+
// Aaron hit this: vault was quietly stopped, `parachute expose tailnet`
|
|
402
|
+
// happily proxied /vault/default to a dead port, every request 502'd.
|
|
403
|
+
// Now we probe and warn — but don't fail, so users can stand up layers
|
|
404
|
+
// before starting services if they want.
|
|
405
|
+
const h = makeHarness();
|
|
406
|
+
try {
|
|
407
|
+
seedServices(h.manifestPath);
|
|
408
|
+
const { runner, calls } = makeRunner();
|
|
409
|
+
const { spawner } = makeHubSpawner(1111);
|
|
410
|
+
const logs: string[] = [];
|
|
411
|
+
const code = await exposeTailnet("up", {
|
|
412
|
+
runner,
|
|
413
|
+
manifestPath: h.manifestPath,
|
|
414
|
+
statePath: h.statePath,
|
|
415
|
+
wellKnownPath: h.wellKnownPath,
|
|
416
|
+
hubPath: h.hubPath,
|
|
417
|
+
wellKnownDir: h.wellKnownDir,
|
|
418
|
+
configDir: h.configDir,
|
|
419
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
420
|
+
// vault is up; notes is down.
|
|
421
|
+
servicePortProbe: async (port) => port === 1940,
|
|
422
|
+
log: (l) => logs.push(l),
|
|
423
|
+
});
|
|
424
|
+
expect(code).toBe(0);
|
|
425
|
+
const joined = logs.join("\n");
|
|
426
|
+
expect(joined).toMatch(/parachute-notes \(port 5173\) is not responding/);
|
|
427
|
+
expect(joined).toMatch(/parachute start notes/);
|
|
428
|
+
expect(joined).not.toMatch(/parachute-vault.*not responding/);
|
|
429
|
+
// Bringup still happened — 4 service entries + 4 OAuth proxies got staged.
|
|
430
|
+
const serveCalls = calls.filter(
|
|
431
|
+
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
432
|
+
);
|
|
433
|
+
expect(serveCalls).toHaveLength(8);
|
|
434
|
+
} finally {
|
|
435
|
+
h.cleanup();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("bringup failure propagates exit code", async () => {
|
|
440
|
+
const h = makeHarness();
|
|
441
|
+
try {
|
|
442
|
+
seedServices(h.manifestPath);
|
|
443
|
+
const runner: Runner = async (cmd) => {
|
|
444
|
+
if (cmd[1] === "version") return { code: 0, stdout: "", stderr: "" };
|
|
445
|
+
if (cmd[1] === "status") {
|
|
446
|
+
return {
|
|
447
|
+
code: 0,
|
|
448
|
+
stdout: JSON.stringify({ Self: { DNSName: "parachute.taildf9ce2.ts.net." } }),
|
|
449
|
+
stderr: "",
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
if (cmd[1] === "serve" && cmd.includes("--bg")) {
|
|
453
|
+
return { code: 2, stdout: "", stderr: "port 443 already in use" };
|
|
454
|
+
}
|
|
455
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
456
|
+
};
|
|
457
|
+
const { spawner } = makeHubSpawner(1111);
|
|
458
|
+
const logs: string[] = [];
|
|
459
|
+
const code = await exposeTailnet("up", {
|
|
460
|
+
runner,
|
|
461
|
+
manifestPath: h.manifestPath,
|
|
462
|
+
statePath: h.statePath,
|
|
463
|
+
wellKnownPath: h.wellKnownPath,
|
|
464
|
+
hubPath: h.hubPath,
|
|
465
|
+
wellKnownDir: h.wellKnownDir,
|
|
466
|
+
configDir: h.configDir,
|
|
467
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
468
|
+
servicePortProbe: allServicesUp,
|
|
469
|
+
log: (l) => logs.push(l),
|
|
470
|
+
});
|
|
471
|
+
expect(code).toBe(2);
|
|
472
|
+
expect(logs.join("\n")).toMatch(/Bringup failed/);
|
|
473
|
+
} finally {
|
|
474
|
+
h.cleanup();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("emits 4 OAuth proxies targeting vault when vault is installed", async () => {
|
|
479
|
+
const h = makeHarness();
|
|
480
|
+
try {
|
|
481
|
+
seedServices(h.manifestPath);
|
|
482
|
+
const { runner, calls } = makeRunner();
|
|
483
|
+
const { spawner } = makeHubSpawner(1111);
|
|
484
|
+
const code = await exposeTailnet("up", {
|
|
485
|
+
runner,
|
|
486
|
+
manifestPath: h.manifestPath,
|
|
487
|
+
statePath: h.statePath,
|
|
488
|
+
wellKnownPath: h.wellKnownPath,
|
|
489
|
+
hubPath: h.hubPath,
|
|
490
|
+
wellKnownDir: h.wellKnownDir,
|
|
491
|
+
configDir: h.configDir,
|
|
492
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
493
|
+
servicePortProbe: allServicesUp,
|
|
494
|
+
log: () => {},
|
|
495
|
+
});
|
|
496
|
+
expect(code).toBe(0);
|
|
497
|
+
|
|
498
|
+
const serveCalls = calls.filter(
|
|
499
|
+
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
500
|
+
);
|
|
501
|
+
const oauthTargets = new Map<string, string>();
|
|
502
|
+
for (const c of serveCalls) {
|
|
503
|
+
const mount = c.find((a) => a.startsWith("--set-path="))?.slice("--set-path=".length);
|
|
504
|
+
if (
|
|
505
|
+
mount &&
|
|
506
|
+
(mount.startsWith("/oauth/") || mount === "/.well-known/oauth-authorization-server")
|
|
507
|
+
) {
|
|
508
|
+
oauthTargets.set(mount, c[c.length - 1] ?? "");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
expect(oauthTargets.get("/.well-known/oauth-authorization-server")).toBe(
|
|
512
|
+
"http://127.0.0.1:1940/vault/default/.well-known/oauth-authorization-server",
|
|
513
|
+
);
|
|
514
|
+
expect(oauthTargets.get("/oauth/authorize")).toBe(
|
|
515
|
+
"http://127.0.0.1:1940/vault/default/oauth/authorize",
|
|
516
|
+
);
|
|
517
|
+
expect(oauthTargets.get("/oauth/token")).toBe(
|
|
518
|
+
"http://127.0.0.1:1940/vault/default/oauth/token",
|
|
519
|
+
);
|
|
520
|
+
expect(oauthTargets.get("/oauth/register")).toBe(
|
|
521
|
+
"http://127.0.0.1:1940/vault/default/oauth/register",
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const state = readExposeState(h.statePath);
|
|
525
|
+
expect(state?.hubOrigin).toBe("https://parachute.taildf9ce2.ts.net");
|
|
526
|
+
} finally {
|
|
527
|
+
h.cleanup();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("skips OAuth proxies when no vault is installed", async () => {
|
|
532
|
+
const h = makeHarness();
|
|
533
|
+
try {
|
|
534
|
+
upsertService(
|
|
535
|
+
{
|
|
536
|
+
name: "parachute-notes",
|
|
537
|
+
port: 5173,
|
|
538
|
+
paths: ["/notes"],
|
|
539
|
+
health: "/notes/health",
|
|
540
|
+
version: "0.0.1",
|
|
541
|
+
},
|
|
542
|
+
h.manifestPath,
|
|
543
|
+
);
|
|
544
|
+
const { runner, calls } = makeRunner();
|
|
545
|
+
const { spawner } = makeHubSpawner(1111);
|
|
546
|
+
const code = await exposeTailnet("up", {
|
|
547
|
+
runner,
|
|
548
|
+
manifestPath: h.manifestPath,
|
|
549
|
+
statePath: h.statePath,
|
|
550
|
+
wellKnownPath: h.wellKnownPath,
|
|
551
|
+
hubPath: h.hubPath,
|
|
552
|
+
wellKnownDir: h.wellKnownDir,
|
|
553
|
+
configDir: h.configDir,
|
|
554
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
555
|
+
servicePortProbe: allServicesUp,
|
|
556
|
+
log: () => {},
|
|
557
|
+
});
|
|
558
|
+
expect(code).toBe(0);
|
|
559
|
+
|
|
560
|
+
const serveCalls = calls.filter(
|
|
561
|
+
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
562
|
+
);
|
|
563
|
+
// No vault → no OAuth proxies. Hub + well-known + notes = 3.
|
|
564
|
+
expect(serveCalls).toHaveLength(3);
|
|
565
|
+
const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
566
|
+
expect(mounts.every((m) => m !== undefined && !m.includes("/oauth/"))).toBe(true);
|
|
567
|
+
} finally {
|
|
568
|
+
h.cleanup();
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("--hub-origin override wins over derived origin and lands in state", async () => {
|
|
573
|
+
const h = makeHarness();
|
|
574
|
+
try {
|
|
575
|
+
seedServices(h.manifestPath);
|
|
576
|
+
const { runner } = makeRunner();
|
|
577
|
+
const { spawner } = makeHubSpawner(1111);
|
|
578
|
+
const code = await exposeTailnet("up", {
|
|
579
|
+
runner,
|
|
580
|
+
manifestPath: h.manifestPath,
|
|
581
|
+
statePath: h.statePath,
|
|
582
|
+
wellKnownPath: h.wellKnownPath,
|
|
583
|
+
hubPath: h.hubPath,
|
|
584
|
+
wellKnownDir: h.wellKnownDir,
|
|
585
|
+
configDir: h.configDir,
|
|
586
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
587
|
+
servicePortProbe: allServicesUp,
|
|
588
|
+
hubOrigin: "https://hub.example.com/",
|
|
589
|
+
log: () => {},
|
|
590
|
+
});
|
|
591
|
+
expect(code).toBe(0);
|
|
592
|
+
const state = readExposeState(h.statePath);
|
|
593
|
+
// Trailing slash stripped by deriveHubOrigin.
|
|
594
|
+
expect(state?.hubOrigin).toBe("https://hub.example.com");
|
|
595
|
+
} finally {
|
|
596
|
+
h.cleanup();
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe("expose tailnet off", () => {
|
|
602
|
+
test("no-op when no prior state", async () => {
|
|
603
|
+
const h = makeHarness();
|
|
604
|
+
try {
|
|
605
|
+
const { runner, calls } = makeRunner();
|
|
606
|
+
const logs: string[] = [];
|
|
607
|
+
const code = await exposeTailnet("off", {
|
|
608
|
+
runner,
|
|
609
|
+
statePath: h.statePath,
|
|
610
|
+
wellKnownPath: h.wellKnownPath,
|
|
611
|
+
hubPath: h.hubPath,
|
|
612
|
+
wellKnownDir: h.wellKnownDir,
|
|
613
|
+
configDir: h.configDir,
|
|
614
|
+
hubStopOpts: hubStopOpts(),
|
|
615
|
+
log: (l) => logs.push(l),
|
|
616
|
+
});
|
|
617
|
+
expect(code).toBe(0);
|
|
618
|
+
expect(calls).toHaveLength(0);
|
|
619
|
+
expect(logs.join("\n")).toMatch(/Nothing to tear down/);
|
|
620
|
+
} finally {
|
|
621
|
+
h.cleanup();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("tears down every tracked entry, stops hub, and clears state", async () => {
|
|
626
|
+
const h = makeHarness();
|
|
627
|
+
try {
|
|
628
|
+
writeExposeState(
|
|
629
|
+
{
|
|
630
|
+
version: 1,
|
|
631
|
+
layer: "tailnet",
|
|
632
|
+
mode: "path",
|
|
633
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
634
|
+
port: 443,
|
|
635
|
+
funnel: false,
|
|
636
|
+
entries: [
|
|
637
|
+
{
|
|
638
|
+
kind: "proxy",
|
|
639
|
+
mount: "/",
|
|
640
|
+
target: "http://127.0.0.1:1939",
|
|
641
|
+
service: "hub",
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
kind: "proxy",
|
|
645
|
+
mount: "/.well-known/parachute.json",
|
|
646
|
+
target: "http://127.0.0.1:1939/.well-known/parachute.json",
|
|
647
|
+
service: "well-known",
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
},
|
|
651
|
+
h.statePath,
|
|
652
|
+
);
|
|
653
|
+
await Bun.write(h.wellKnownPath, "{}\n");
|
|
654
|
+
await Bun.write(h.hubPath, "<html/>\n");
|
|
655
|
+
writePid("hub", 4242, h.configDir);
|
|
656
|
+
const { runner, calls } = makeRunner();
|
|
657
|
+
const signals: NodeJS.Signals[] = [];
|
|
658
|
+
let aliveNow = true;
|
|
659
|
+
const code = await exposeTailnet("off", {
|
|
660
|
+
runner,
|
|
661
|
+
statePath: h.statePath,
|
|
662
|
+
wellKnownPath: h.wellKnownPath,
|
|
663
|
+
hubPath: h.hubPath,
|
|
664
|
+
wellKnownDir: h.wellKnownDir,
|
|
665
|
+
configDir: h.configDir,
|
|
666
|
+
hubStopOpts: {
|
|
667
|
+
kill: (_pid, sig) => {
|
|
668
|
+
signals.push(sig as NodeJS.Signals);
|
|
669
|
+
aliveNow = false;
|
|
670
|
+
},
|
|
671
|
+
alive: () => aliveNow,
|
|
672
|
+
sleep: async () => {},
|
|
673
|
+
now: () => 0,
|
|
674
|
+
},
|
|
675
|
+
log: () => {},
|
|
676
|
+
});
|
|
677
|
+
expect(code).toBe(0);
|
|
678
|
+
expect(calls.every((c) => c[c.length - 1] === "off")).toBe(true);
|
|
679
|
+
expect(calls).toHaveLength(2);
|
|
680
|
+
expect(existsSync(h.statePath)).toBe(false);
|
|
681
|
+
expect(existsSync(h.wellKnownPath)).toBe(false);
|
|
682
|
+
expect(existsSync(h.hubPath)).toBe(false);
|
|
683
|
+
// Hub was running and got stopped.
|
|
684
|
+
expect(signals).toContain("SIGTERM");
|
|
685
|
+
} finally {
|
|
686
|
+
h.cleanup();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("leaves state in place on teardown failure", async () => {
|
|
691
|
+
const h = makeHarness();
|
|
692
|
+
try {
|
|
693
|
+
writeExposeState(
|
|
694
|
+
{
|
|
695
|
+
version: 1,
|
|
696
|
+
layer: "tailnet",
|
|
697
|
+
mode: "path",
|
|
698
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
699
|
+
port: 443,
|
|
700
|
+
funnel: false,
|
|
701
|
+
entries: [
|
|
702
|
+
{
|
|
703
|
+
kind: "proxy",
|
|
704
|
+
mount: "/",
|
|
705
|
+
target: "http://127.0.0.1:1940",
|
|
706
|
+
service: "parachute-vault",
|
|
707
|
+
},
|
|
708
|
+
],
|
|
709
|
+
},
|
|
710
|
+
h.statePath,
|
|
711
|
+
);
|
|
712
|
+
const runner: Runner = async () => ({ code: 5, stdout: "", stderr: "tailscale blew up" });
|
|
713
|
+
const logs: string[] = [];
|
|
714
|
+
const code = await exposeTailnet("off", {
|
|
715
|
+
runner,
|
|
716
|
+
statePath: h.statePath,
|
|
717
|
+
wellKnownPath: h.wellKnownPath,
|
|
718
|
+
hubPath: h.hubPath,
|
|
719
|
+
wellKnownDir: h.wellKnownDir,
|
|
720
|
+
configDir: h.configDir,
|
|
721
|
+
hubStopOpts: hubStopOpts(),
|
|
722
|
+
log: (l) => logs.push(l),
|
|
723
|
+
});
|
|
724
|
+
expect(code).toBe(5);
|
|
725
|
+
expect(existsSync(h.statePath)).toBe(true);
|
|
726
|
+
} finally {
|
|
727
|
+
h.cleanup();
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("tailnet off does not tear down public exposure or stop the hub", async () => {
|
|
732
|
+
const h = makeHarness();
|
|
733
|
+
try {
|
|
734
|
+
writeExposeState(
|
|
735
|
+
{
|
|
736
|
+
version: 1,
|
|
737
|
+
layer: "public",
|
|
738
|
+
mode: "path",
|
|
739
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
740
|
+
port: 443,
|
|
741
|
+
funnel: true,
|
|
742
|
+
entries: [
|
|
743
|
+
{
|
|
744
|
+
kind: "proxy",
|
|
745
|
+
mount: "/",
|
|
746
|
+
target: "http://127.0.0.1:1939",
|
|
747
|
+
service: "hub",
|
|
748
|
+
},
|
|
749
|
+
],
|
|
750
|
+
},
|
|
751
|
+
h.statePath,
|
|
752
|
+
);
|
|
753
|
+
writePid("hub", 4242, h.configDir);
|
|
754
|
+
const { runner, calls } = makeRunner();
|
|
755
|
+
let killCalled = false;
|
|
756
|
+
const logs: string[] = [];
|
|
757
|
+
const code = await exposeTailnet("off", {
|
|
758
|
+
runner,
|
|
759
|
+
statePath: h.statePath,
|
|
760
|
+
wellKnownPath: h.wellKnownPath,
|
|
761
|
+
hubPath: h.hubPath,
|
|
762
|
+
wellKnownDir: h.wellKnownDir,
|
|
763
|
+
configDir: h.configDir,
|
|
764
|
+
hubStopOpts: {
|
|
765
|
+
kill: () => {
|
|
766
|
+
killCalled = true;
|
|
767
|
+
},
|
|
768
|
+
alive: () => false,
|
|
769
|
+
sleep: async () => {},
|
|
770
|
+
now: () => 0,
|
|
771
|
+
},
|
|
772
|
+
log: (l) => logs.push(l),
|
|
773
|
+
});
|
|
774
|
+
expect(code).toBe(0);
|
|
775
|
+
expect(calls).toHaveLength(0);
|
|
776
|
+
expect(existsSync(h.statePath)).toBe(true);
|
|
777
|
+
expect(killCalled).toBe(false);
|
|
778
|
+
expect(logs.join("\n")).toMatch(/Current exposure is Public/);
|
|
779
|
+
} finally {
|
|
780
|
+
h.cleanup();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
describe("expose public up", () => {
|
|
786
|
+
test("routes every bringup through `tailscale funnel` and records layer=public", async () => {
|
|
787
|
+
const h = makeHarness();
|
|
788
|
+
try {
|
|
789
|
+
seedServices(h.manifestPath);
|
|
790
|
+
const { runner, calls } = makeRunner();
|
|
791
|
+
const { spawner } = makeHubSpawner(1111);
|
|
792
|
+
const logs: string[] = [];
|
|
793
|
+
const code = await exposePublic("up", {
|
|
794
|
+
runner,
|
|
795
|
+
manifestPath: h.manifestPath,
|
|
796
|
+
statePath: h.statePath,
|
|
797
|
+
wellKnownPath: h.wellKnownPath,
|
|
798
|
+
hubPath: h.hubPath,
|
|
799
|
+
wellKnownDir: h.wellKnownDir,
|
|
800
|
+
configDir: h.configDir,
|
|
801
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
802
|
+
servicePortProbe: allServicesUp,
|
|
803
|
+
log: (l) => logs.push(l),
|
|
804
|
+
});
|
|
805
|
+
expect(code).toBe(0);
|
|
806
|
+
|
|
807
|
+
// Modern tailscale (1.82+) rejects `serve --funnel`; public mode must use
|
|
808
|
+
// the `funnel` subcommand instead.
|
|
809
|
+
const funnelCalls = calls.filter(
|
|
810
|
+
(c) => c[0] === "tailscale" && c[1] === "funnel" && c.includes("--bg"),
|
|
811
|
+
);
|
|
812
|
+
// 4 baseline mounts + 4 OAuth proxies (vault seeded).
|
|
813
|
+
expect(funnelCalls).toHaveLength(8);
|
|
814
|
+
// Never emit the legacy `serve --funnel` shape.
|
|
815
|
+
expect(calls.every((c) => !c.includes("--funnel"))).toBe(true);
|
|
816
|
+
expect(calls.every((c) => !(c[1] === "serve" && c.includes("--bg")))).toBe(true);
|
|
817
|
+
|
|
818
|
+
const state = readExposeState(h.statePath);
|
|
819
|
+
expect(state?.layer).toBe("public");
|
|
820
|
+
expect(state?.funnel).toBe(true);
|
|
821
|
+
expect(state?.entries).toHaveLength(8);
|
|
822
|
+
|
|
823
|
+
expect(logs.join("\n")).toMatch(/Public exposure active/);
|
|
824
|
+
} finally {
|
|
825
|
+
h.cleanup();
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("switching from public to tailnet tears prior state down via `tailscale funnel … off`", async () => {
|
|
830
|
+
const h = makeHarness();
|
|
831
|
+
try {
|
|
832
|
+
seedServices(h.manifestPath);
|
|
833
|
+
writeExposeState(
|
|
834
|
+
{
|
|
835
|
+
version: 1,
|
|
836
|
+
layer: "public",
|
|
837
|
+
mode: "path",
|
|
838
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
839
|
+
port: 443,
|
|
840
|
+
funnel: true,
|
|
841
|
+
entries: [
|
|
842
|
+
{
|
|
843
|
+
kind: "proxy",
|
|
844
|
+
mount: "/vault/default",
|
|
845
|
+
target: "http://127.0.0.1:1940/vault/default",
|
|
846
|
+
service: "parachute-vault",
|
|
847
|
+
},
|
|
848
|
+
],
|
|
849
|
+
},
|
|
850
|
+
h.statePath,
|
|
851
|
+
);
|
|
852
|
+
const { runner, calls } = makeRunner();
|
|
853
|
+
const { spawner } = makeHubSpawner(1111);
|
|
854
|
+
const code = await exposeTailnet("up", {
|
|
855
|
+
runner,
|
|
856
|
+
manifestPath: h.manifestPath,
|
|
857
|
+
statePath: h.statePath,
|
|
858
|
+
wellKnownPath: h.wellKnownPath,
|
|
859
|
+
hubPath: h.hubPath,
|
|
860
|
+
wellKnownDir: h.wellKnownDir,
|
|
861
|
+
configDir: h.configDir,
|
|
862
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
863
|
+
servicePortProbe: allServicesUp,
|
|
864
|
+
log: () => {},
|
|
865
|
+
});
|
|
866
|
+
expect(code).toBe(0);
|
|
867
|
+
// Prior was public → teardown must use `tailscale funnel … off`,
|
|
868
|
+
// not `tailscale serve … off` (which wouldn't drop the funnel entry on 1.82+).
|
|
869
|
+
const offs = calls.filter((c) => c[c.length - 1] === "off");
|
|
870
|
+
expect(offs).toHaveLength(1);
|
|
871
|
+
expect(offs[0]?.[1]).toBe("funnel");
|
|
872
|
+
} finally {
|
|
873
|
+
h.cleanup();
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("switching from tailnet to public tears down prior state first", async () => {
|
|
878
|
+
const h = makeHarness();
|
|
879
|
+
try {
|
|
880
|
+
seedServices(h.manifestPath);
|
|
881
|
+
writeExposeState(
|
|
882
|
+
{
|
|
883
|
+
version: 1,
|
|
884
|
+
layer: "tailnet",
|
|
885
|
+
mode: "path",
|
|
886
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
887
|
+
port: 443,
|
|
888
|
+
funnel: false,
|
|
889
|
+
entries: [
|
|
890
|
+
{
|
|
891
|
+
kind: "proxy",
|
|
892
|
+
mount: "/",
|
|
893
|
+
target: "http://127.0.0.1:1940",
|
|
894
|
+
service: "parachute-vault",
|
|
895
|
+
},
|
|
896
|
+
],
|
|
897
|
+
},
|
|
898
|
+
h.statePath,
|
|
899
|
+
);
|
|
900
|
+
const { runner, calls } = makeRunner();
|
|
901
|
+
const { spawner } = makeHubSpawner(1111);
|
|
902
|
+
const code = await exposePublic("up", {
|
|
903
|
+
runner,
|
|
904
|
+
manifestPath: h.manifestPath,
|
|
905
|
+
statePath: h.statePath,
|
|
906
|
+
wellKnownPath: h.wellKnownPath,
|
|
907
|
+
hubPath: h.hubPath,
|
|
908
|
+
wellKnownDir: h.wellKnownDir,
|
|
909
|
+
configDir: h.configDir,
|
|
910
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
911
|
+
servicePortProbe: allServicesUp,
|
|
912
|
+
log: () => {},
|
|
913
|
+
});
|
|
914
|
+
expect(code).toBe(0);
|
|
915
|
+
const offs = calls.filter((c) => c[c.length - 1] === "off");
|
|
916
|
+
expect(offs).toHaveLength(1);
|
|
917
|
+
const state = readExposeState(h.statePath);
|
|
918
|
+
expect(state?.layer).toBe("public");
|
|
919
|
+
} finally {
|
|
920
|
+
h.cleanup();
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
describe("expose public off", () => {
|
|
926
|
+
test("tears down public exposure via `tailscale funnel … off` and clears state", async () => {
|
|
927
|
+
const h = makeHarness();
|
|
928
|
+
try {
|
|
929
|
+
writeExposeState(
|
|
930
|
+
{
|
|
931
|
+
version: 1,
|
|
932
|
+
layer: "public",
|
|
933
|
+
mode: "path",
|
|
934
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
935
|
+
port: 443,
|
|
936
|
+
funnel: true,
|
|
937
|
+
entries: [
|
|
938
|
+
{
|
|
939
|
+
kind: "proxy",
|
|
940
|
+
mount: "/",
|
|
941
|
+
target: "http://127.0.0.1:1939",
|
|
942
|
+
service: "hub",
|
|
943
|
+
},
|
|
944
|
+
],
|
|
945
|
+
},
|
|
946
|
+
h.statePath,
|
|
947
|
+
);
|
|
948
|
+
const { runner, calls } = makeRunner();
|
|
949
|
+
const code = await exposePublic("off", {
|
|
950
|
+
runner,
|
|
951
|
+
statePath: h.statePath,
|
|
952
|
+
wellKnownPath: h.wellKnownPath,
|
|
953
|
+
hubPath: h.hubPath,
|
|
954
|
+
wellKnownDir: h.wellKnownDir,
|
|
955
|
+
configDir: h.configDir,
|
|
956
|
+
hubStopOpts: hubStopOpts(),
|
|
957
|
+
log: () => {},
|
|
958
|
+
});
|
|
959
|
+
expect(code).toBe(0);
|
|
960
|
+
// Public teardown must use the funnel subcommand, matching bringup.
|
|
961
|
+
const offCalls = calls.filter((c) => c[c.length - 1] === "off");
|
|
962
|
+
expect(offCalls.length).toBeGreaterThan(0);
|
|
963
|
+
expect(offCalls.every((c) => c[1] === "funnel")).toBe(true);
|
|
964
|
+
expect(existsSync(h.statePath)).toBe(false);
|
|
965
|
+
} finally {
|
|
966
|
+
h.cleanup();
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
test("public off does not tear down tailnet exposure", async () => {
|
|
971
|
+
const h = makeHarness();
|
|
972
|
+
try {
|
|
973
|
+
writeExposeState(
|
|
974
|
+
{
|
|
975
|
+
version: 1,
|
|
976
|
+
layer: "tailnet",
|
|
977
|
+
mode: "path",
|
|
978
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
979
|
+
port: 443,
|
|
980
|
+
funnel: false,
|
|
981
|
+
entries: [
|
|
982
|
+
{
|
|
983
|
+
kind: "proxy",
|
|
984
|
+
mount: "/",
|
|
985
|
+
target: "http://127.0.0.1:1940",
|
|
986
|
+
service: "parachute-vault",
|
|
987
|
+
},
|
|
988
|
+
],
|
|
989
|
+
},
|
|
990
|
+
h.statePath,
|
|
991
|
+
);
|
|
992
|
+
const { runner, calls } = makeRunner();
|
|
993
|
+
const logs: string[] = [];
|
|
994
|
+
const code = await exposePublic("off", {
|
|
995
|
+
runner,
|
|
996
|
+
statePath: h.statePath,
|
|
997
|
+
wellKnownPath: h.wellKnownPath,
|
|
998
|
+
hubPath: h.hubPath,
|
|
999
|
+
wellKnownDir: h.wellKnownDir,
|
|
1000
|
+
configDir: h.configDir,
|
|
1001
|
+
hubStopOpts: hubStopOpts(),
|
|
1002
|
+
log: (l) => logs.push(l),
|
|
1003
|
+
});
|
|
1004
|
+
expect(code).toBe(0);
|
|
1005
|
+
expect(calls).toHaveLength(0);
|
|
1006
|
+
expect(existsSync(h.statePath)).toBe(true);
|
|
1007
|
+
expect(logs.join("\n")).toMatch(/Current exposure is Tailnet/);
|
|
1008
|
+
} finally {
|
|
1009
|
+
h.cleanup();
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
describe("expose publicExposure filter", () => {
|
|
1015
|
+
// Launch-blocker: services without auth should never be mounted on
|
|
1016
|
+
// tailnet/funnel. The filter reads `publicExposure` from each entry (or
|
|
1017
|
+
// derives a safe default from the service spec) and withholds non-"allowed"
|
|
1018
|
+
// services from the tailscale serve plan.
|
|
1019
|
+
test("explicit loopback keeps the service off the serve plan", async () => {
|
|
1020
|
+
const h = makeHarness();
|
|
1021
|
+
try {
|
|
1022
|
+
// Vault is mounted as usual; scribe declares loopback and is withheld.
|
|
1023
|
+
upsertService(
|
|
1024
|
+
{
|
|
1025
|
+
name: "parachute-vault",
|
|
1026
|
+
port: 1940,
|
|
1027
|
+
paths: ["/vault/default"],
|
|
1028
|
+
health: "/vault/default/health",
|
|
1029
|
+
version: "0.2.4",
|
|
1030
|
+
publicExposure: "allowed",
|
|
1031
|
+
},
|
|
1032
|
+
h.manifestPath,
|
|
1033
|
+
);
|
|
1034
|
+
upsertService(
|
|
1035
|
+
{
|
|
1036
|
+
name: "parachute-scribe",
|
|
1037
|
+
port: 1943,
|
|
1038
|
+
paths: ["/scribe"],
|
|
1039
|
+
health: "/scribe/health",
|
|
1040
|
+
version: "0.1.0",
|
|
1041
|
+
publicExposure: "loopback",
|
|
1042
|
+
},
|
|
1043
|
+
h.manifestPath,
|
|
1044
|
+
);
|
|
1045
|
+
const { runner, calls } = makeRunner();
|
|
1046
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1047
|
+
const logs: string[] = [];
|
|
1048
|
+
const code = await exposeTailnet("up", {
|
|
1049
|
+
runner,
|
|
1050
|
+
manifestPath: h.manifestPath,
|
|
1051
|
+
statePath: h.statePath,
|
|
1052
|
+
wellKnownPath: h.wellKnownPath,
|
|
1053
|
+
hubPath: h.hubPath,
|
|
1054
|
+
wellKnownDir: h.wellKnownDir,
|
|
1055
|
+
configDir: h.configDir,
|
|
1056
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1057
|
+
servicePortProbe: allServicesUp,
|
|
1058
|
+
log: (l) => logs.push(l),
|
|
1059
|
+
});
|
|
1060
|
+
expect(code).toBe(0);
|
|
1061
|
+
|
|
1062
|
+
const serveCalls = calls.filter(
|
|
1063
|
+
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
1064
|
+
);
|
|
1065
|
+
const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1066
|
+
// Vault + its 4 OAuth proxies + hub + well-known — but no /scribe.
|
|
1067
|
+
expect(mounts).toContain("--set-path=/vault/default");
|
|
1068
|
+
expect(mounts).not.toContain("--set-path=/scribe");
|
|
1069
|
+
|
|
1070
|
+
// Operator-visible notice explaining the withhold.
|
|
1071
|
+
expect(logs.join("\n")).toMatch(
|
|
1072
|
+
/parachute-scribe is loopback-only — loopback-only by service declaration/,
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
// State file reflects the reduced plan so teardown doesn't trip on
|
|
1076
|
+
// entries that were never brought up.
|
|
1077
|
+
const state = readExposeState(h.statePath);
|
|
1078
|
+
expect(state?.entries.some((e) => e.mount === "/scribe")).toBe(false);
|
|
1079
|
+
} finally {
|
|
1080
|
+
h.cleanup();
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test("explicit auth-required behaves like loopback at launch", async () => {
|
|
1085
|
+
// auth-required is the future-looking declaration for a service that
|
|
1086
|
+
// wants auth but hasn't confirmed it's configured. Today the CLI treats
|
|
1087
|
+
// it identically to loopback — still no funnel/tailnet exposure.
|
|
1088
|
+
const h = makeHarness();
|
|
1089
|
+
try {
|
|
1090
|
+
seedServices(h.manifestPath); // vault + notes, both allowed by default
|
|
1091
|
+
upsertService(
|
|
1092
|
+
{
|
|
1093
|
+
name: "parachute-channel",
|
|
1094
|
+
port: 1941,
|
|
1095
|
+
paths: ["/channel"],
|
|
1096
|
+
health: "/channel/health",
|
|
1097
|
+
version: "0.1.0",
|
|
1098
|
+
publicExposure: "auth-required",
|
|
1099
|
+
},
|
|
1100
|
+
h.manifestPath,
|
|
1101
|
+
);
|
|
1102
|
+
const { runner, calls } = makeRunner();
|
|
1103
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1104
|
+
const logs: string[] = [];
|
|
1105
|
+
const code = await exposeTailnet("up", {
|
|
1106
|
+
runner,
|
|
1107
|
+
manifestPath: h.manifestPath,
|
|
1108
|
+
statePath: h.statePath,
|
|
1109
|
+
wellKnownPath: h.wellKnownPath,
|
|
1110
|
+
hubPath: h.hubPath,
|
|
1111
|
+
wellKnownDir: h.wellKnownDir,
|
|
1112
|
+
configDir: h.configDir,
|
|
1113
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1114
|
+
servicePortProbe: allServicesUp,
|
|
1115
|
+
log: (l) => logs.push(l),
|
|
1116
|
+
});
|
|
1117
|
+
expect(code).toBe(0);
|
|
1118
|
+
const mounts = calls
|
|
1119
|
+
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1120
|
+
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1121
|
+
expect(mounts).not.toContain("--set-path=/channel");
|
|
1122
|
+
expect(logs.join("\n")).toMatch(/parachute-channel is loopback-only — auth-required/);
|
|
1123
|
+
} finally {
|
|
1124
|
+
h.cleanup();
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
test("missing publicExposure + spec kind=api, hasAuth=false → loopback default (scribe)", async () => {
|
|
1129
|
+
// Scribe today has no auth gate; its ServiceSpec says so (kind: "api",
|
|
1130
|
+
// hasAuth: false). With publicExposure absent we should still withhold.
|
|
1131
|
+
// This is the safe-by-default case for services that haven't yet been
|
|
1132
|
+
// updated to declare their exposure.
|
|
1133
|
+
const h = makeHarness();
|
|
1134
|
+
try {
|
|
1135
|
+
seedServices(h.manifestPath);
|
|
1136
|
+
upsertService(
|
|
1137
|
+
{
|
|
1138
|
+
name: "parachute-scribe",
|
|
1139
|
+
port: 1943,
|
|
1140
|
+
paths: ["/scribe"],
|
|
1141
|
+
health: "/scribe/health",
|
|
1142
|
+
version: "0.1.0",
|
|
1143
|
+
// publicExposure intentionally absent — exercises spec-derived default
|
|
1144
|
+
},
|
|
1145
|
+
h.manifestPath,
|
|
1146
|
+
);
|
|
1147
|
+
const { runner, calls } = makeRunner();
|
|
1148
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1149
|
+
const logs: string[] = [];
|
|
1150
|
+
const code = await exposeTailnet("up", {
|
|
1151
|
+
runner,
|
|
1152
|
+
manifestPath: h.manifestPath,
|
|
1153
|
+
statePath: h.statePath,
|
|
1154
|
+
wellKnownPath: h.wellKnownPath,
|
|
1155
|
+
hubPath: h.hubPath,
|
|
1156
|
+
wellKnownDir: h.wellKnownDir,
|
|
1157
|
+
configDir: h.configDir,
|
|
1158
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1159
|
+
servicePortProbe: allServicesUp,
|
|
1160
|
+
log: (l) => logs.push(l),
|
|
1161
|
+
});
|
|
1162
|
+
expect(code).toBe(0);
|
|
1163
|
+
const mounts = calls
|
|
1164
|
+
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1165
|
+
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1166
|
+
expect(mounts).not.toContain("--set-path=/scribe");
|
|
1167
|
+
// Reason text points operators at the missing auth gate.
|
|
1168
|
+
expect(logs.join("\n")).toMatch(
|
|
1169
|
+
/parachute-scribe is loopback-only — auth-required: service has no auth gate/,
|
|
1170
|
+
);
|
|
1171
|
+
} finally {
|
|
1172
|
+
h.cleanup();
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
test("missing publicExposure on a known auth'd api service (vault) still exposes", async () => {
|
|
1177
|
+
// vault's ServiceSpec has hasAuth: true, so the absence of publicExposure
|
|
1178
|
+
// should not hide it — the back-compat path for every vault entry written
|
|
1179
|
+
// before this field existed.
|
|
1180
|
+
const h = makeHarness();
|
|
1181
|
+
try {
|
|
1182
|
+
upsertService(
|
|
1183
|
+
{
|
|
1184
|
+
name: "parachute-vault",
|
|
1185
|
+
port: 1940,
|
|
1186
|
+
paths: ["/vault/default"],
|
|
1187
|
+
health: "/vault/default/health",
|
|
1188
|
+
version: "0.2.4",
|
|
1189
|
+
},
|
|
1190
|
+
h.manifestPath,
|
|
1191
|
+
);
|
|
1192
|
+
const { runner, calls } = makeRunner();
|
|
1193
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1194
|
+
const code = await exposeTailnet("up", {
|
|
1195
|
+
runner,
|
|
1196
|
+
manifestPath: h.manifestPath,
|
|
1197
|
+
statePath: h.statePath,
|
|
1198
|
+
wellKnownPath: h.wellKnownPath,
|
|
1199
|
+
hubPath: h.hubPath,
|
|
1200
|
+
wellKnownDir: h.wellKnownDir,
|
|
1201
|
+
configDir: h.configDir,
|
|
1202
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1203
|
+
servicePortProbe: allServicesUp,
|
|
1204
|
+
log: () => {},
|
|
1205
|
+
});
|
|
1206
|
+
expect(code).toBe(0);
|
|
1207
|
+
const mounts = calls
|
|
1208
|
+
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1209
|
+
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1210
|
+
expect(mounts).toContain("--set-path=/vault/default");
|
|
1211
|
+
} finally {
|
|
1212
|
+
h.cleanup();
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
test("unknown third-party service without publicExposure defaults to allowed", async () => {
|
|
1217
|
+
// A service not in SERVICE_SPECS has no kind/hasAuth signal. We err on
|
|
1218
|
+
// the side of preserving current behavior (back-compat) so operators'
|
|
1219
|
+
// existing exposures don't silently stop working on upgrade. If the
|
|
1220
|
+
// third-party wants to opt out, they can write publicExposure: "loopback"
|
|
1221
|
+
// into their services.json entry.
|
|
1222
|
+
const h = makeHarness();
|
|
1223
|
+
try {
|
|
1224
|
+
upsertService(
|
|
1225
|
+
{
|
|
1226
|
+
name: "parachute-rando",
|
|
1227
|
+
port: 1947,
|
|
1228
|
+
paths: ["/rando"],
|
|
1229
|
+
health: "/rando/health",
|
|
1230
|
+
version: "0.0.1",
|
|
1231
|
+
},
|
|
1232
|
+
h.manifestPath,
|
|
1233
|
+
);
|
|
1234
|
+
const { runner, calls } = makeRunner();
|
|
1235
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1236
|
+
const code = await exposeTailnet("up", {
|
|
1237
|
+
runner,
|
|
1238
|
+
manifestPath: h.manifestPath,
|
|
1239
|
+
statePath: h.statePath,
|
|
1240
|
+
wellKnownPath: h.wellKnownPath,
|
|
1241
|
+
hubPath: h.hubPath,
|
|
1242
|
+
wellKnownDir: h.wellKnownDir,
|
|
1243
|
+
configDir: h.configDir,
|
|
1244
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1245
|
+
servicePortProbe: allServicesUp,
|
|
1246
|
+
log: () => {},
|
|
1247
|
+
});
|
|
1248
|
+
expect(code).toBe(0);
|
|
1249
|
+
const mounts = calls
|
|
1250
|
+
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1251
|
+
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1252
|
+
expect(mounts).toContain("--set-path=/rando");
|
|
1253
|
+
} finally {
|
|
1254
|
+
h.cleanup();
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
describe("expose auto-restart of hub-dependent services", () => {
|
|
1260
|
+
// Launch-day bug (2026-04-23): `expose public` updated hubOrigin in
|
|
1261
|
+
// expose-state.json, but a vault already running kept its stale
|
|
1262
|
+
// PARACHUTE_HUB_ORIGIN in memory, so the OAuth issuer didn't match what
|
|
1263
|
+
// clients saw and claude.ai MCP failed to reach the server. The CLI used
|
|
1264
|
+
// to print a "Restart vault to pick up…" hint that got lost in the wall
|
|
1265
|
+
// of expose output. Auto-restart the service instead.
|
|
1266
|
+
test("restarts vault when vault is running", async () => {
|
|
1267
|
+
const h = makeHarness();
|
|
1268
|
+
try {
|
|
1269
|
+
seedServices(h.manifestPath);
|
|
1270
|
+
writePid("vault", 4242, h.configDir);
|
|
1271
|
+
const { runner } = makeRunner();
|
|
1272
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1273
|
+
const restarted: string[] = [];
|
|
1274
|
+
const code = await exposePublic("up", {
|
|
1275
|
+
runner,
|
|
1276
|
+
manifestPath: h.manifestPath,
|
|
1277
|
+
statePath: h.statePath,
|
|
1278
|
+
wellKnownPath: h.wellKnownPath,
|
|
1279
|
+
hubPath: h.hubPath,
|
|
1280
|
+
wellKnownDir: h.wellKnownDir,
|
|
1281
|
+
configDir: h.configDir,
|
|
1282
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1283
|
+
servicePortProbe: allServicesUp,
|
|
1284
|
+
alive: () => true,
|
|
1285
|
+
restartService: async (short) => {
|
|
1286
|
+
restarted.push(short);
|
|
1287
|
+
return 0;
|
|
1288
|
+
},
|
|
1289
|
+
log: () => {},
|
|
1290
|
+
});
|
|
1291
|
+
expect(code).toBe(0);
|
|
1292
|
+
expect(restarted).toEqual(["vault"]);
|
|
1293
|
+
} finally {
|
|
1294
|
+
h.cleanup();
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("skips restart when vault is not running", async () => {
|
|
1299
|
+
const h = makeHarness();
|
|
1300
|
+
try {
|
|
1301
|
+
seedServices(h.manifestPath);
|
|
1302
|
+
// No writePid → vault has no pidfile → processState returns "unknown".
|
|
1303
|
+
const { runner } = makeRunner();
|
|
1304
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1305
|
+
const restarted: string[] = [];
|
|
1306
|
+
const code = await exposePublic("up", {
|
|
1307
|
+
runner,
|
|
1308
|
+
manifestPath: h.manifestPath,
|
|
1309
|
+
statePath: h.statePath,
|
|
1310
|
+
wellKnownPath: h.wellKnownPath,
|
|
1311
|
+
hubPath: h.hubPath,
|
|
1312
|
+
wellKnownDir: h.wellKnownDir,
|
|
1313
|
+
configDir: h.configDir,
|
|
1314
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1315
|
+
servicePortProbe: allServicesUp,
|
|
1316
|
+
alive: () => true,
|
|
1317
|
+
restartService: async (short) => {
|
|
1318
|
+
restarted.push(short);
|
|
1319
|
+
return 0;
|
|
1320
|
+
},
|
|
1321
|
+
log: () => {},
|
|
1322
|
+
});
|
|
1323
|
+
expect(code).toBe(0);
|
|
1324
|
+
expect(restarted).toEqual([]);
|
|
1325
|
+
} finally {
|
|
1326
|
+
h.cleanup();
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
test("skips restart when pidfile is stale (process dead)", async () => {
|
|
1331
|
+
const h = makeHarness();
|
|
1332
|
+
try {
|
|
1333
|
+
seedServices(h.manifestPath);
|
|
1334
|
+
writePid("vault", 4242, h.configDir);
|
|
1335
|
+
const { runner } = makeRunner();
|
|
1336
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1337
|
+
const restarted: string[] = [];
|
|
1338
|
+
const code = await exposePublic("up", {
|
|
1339
|
+
runner,
|
|
1340
|
+
manifestPath: h.manifestPath,
|
|
1341
|
+
statePath: h.statePath,
|
|
1342
|
+
wellKnownPath: h.wellKnownPath,
|
|
1343
|
+
hubPath: h.hubPath,
|
|
1344
|
+
wellKnownDir: h.wellKnownDir,
|
|
1345
|
+
configDir: h.configDir,
|
|
1346
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1347
|
+
servicePortProbe: allServicesUp,
|
|
1348
|
+
// Simulate pid-file-present-but-process-dead. processState returns
|
|
1349
|
+
// "stopped", not "running", so we should skip.
|
|
1350
|
+
alive: () => false,
|
|
1351
|
+
restartService: async (short) => {
|
|
1352
|
+
restarted.push(short);
|
|
1353
|
+
return 0;
|
|
1354
|
+
},
|
|
1355
|
+
log: () => {},
|
|
1356
|
+
});
|
|
1357
|
+
expect(code).toBe(0);
|
|
1358
|
+
expect(restarted).toEqual([]);
|
|
1359
|
+
} finally {
|
|
1360
|
+
h.cleanup();
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
test("restart failure logs warning but expose still succeeds", async () => {
|
|
1365
|
+
const h = makeHarness();
|
|
1366
|
+
try {
|
|
1367
|
+
seedServices(h.manifestPath);
|
|
1368
|
+
writePid("vault", 4242, h.configDir);
|
|
1369
|
+
const { runner } = makeRunner();
|
|
1370
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1371
|
+
const logs: string[] = [];
|
|
1372
|
+
const code = await exposePublic("up", {
|
|
1373
|
+
runner,
|
|
1374
|
+
manifestPath: h.manifestPath,
|
|
1375
|
+
statePath: h.statePath,
|
|
1376
|
+
wellKnownPath: h.wellKnownPath,
|
|
1377
|
+
hubPath: h.hubPath,
|
|
1378
|
+
wellKnownDir: h.wellKnownDir,
|
|
1379
|
+
configDir: h.configDir,
|
|
1380
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1381
|
+
servicePortProbe: allServicesUp,
|
|
1382
|
+
alive: () => true,
|
|
1383
|
+
restartService: async () => 1,
|
|
1384
|
+
log: (l) => logs.push(l),
|
|
1385
|
+
});
|
|
1386
|
+
expect(code).toBe(0);
|
|
1387
|
+
expect(logs.join("\n")).toMatch(/vault restart failed/);
|
|
1388
|
+
expect(logs.join("\n")).toMatch(/parachute restart vault/);
|
|
1389
|
+
} finally {
|
|
1390
|
+
h.cleanup();
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
describe("expose teardown tolerance for already-gone entries", () => {
|
|
1396
|
+
// Launch-day bug (2026-04-23): Aaron ran `tailscale funnel reset` while
|
|
1397
|
+
// debugging, then re-ran `parachute expose public`. expose-state.json still
|
|
1398
|
+
// recorded 8 entries; the CLI tried to tear them down; tailscale returned
|
|
1399
|
+
// `error: failed to remove web serve: handler does not exist` for each; the
|
|
1400
|
+
// CLI aborted and never got to bringup. Tailscale's `off` is idempotent
|
|
1401
|
+
// from the user's perspective — if the handler is already gone, that's the
|
|
1402
|
+
// outcome we wanted.
|
|
1403
|
+
function makePublicPriorState(statePath: string, entryCount: number): void {
|
|
1404
|
+
const entries = Array.from({ length: entryCount }, (_, i) => ({
|
|
1405
|
+
kind: "proxy" as const,
|
|
1406
|
+
mount: `/svc${i}`,
|
|
1407
|
+
target: `http://127.0.0.1:${2000 + i}`,
|
|
1408
|
+
service: `parachute-svc${i}`,
|
|
1409
|
+
}));
|
|
1410
|
+
writeExposeState(
|
|
1411
|
+
{
|
|
1412
|
+
version: 1,
|
|
1413
|
+
layer: "public",
|
|
1414
|
+
mode: "path",
|
|
1415
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
1416
|
+
port: 443,
|
|
1417
|
+
funnel: true,
|
|
1418
|
+
entries,
|
|
1419
|
+
},
|
|
1420
|
+
statePath,
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
test("exposeOff treats 'handler does not exist' as success and clears state", async () => {
|
|
1425
|
+
const h = makeHarness();
|
|
1426
|
+
try {
|
|
1427
|
+
makePublicPriorState(h.statePath, 3);
|
|
1428
|
+
const runner: Runner = async (cmd) => {
|
|
1429
|
+
if (cmd[cmd.length - 1] === "off") {
|
|
1430
|
+
return {
|
|
1431
|
+
code: 1,
|
|
1432
|
+
stdout: "",
|
|
1433
|
+
stderr: "error: failed to remove web serve: handler does not exist",
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
1437
|
+
};
|
|
1438
|
+
const logs: string[] = [];
|
|
1439
|
+
const code = await exposePublic("off", {
|
|
1440
|
+
runner,
|
|
1441
|
+
statePath: h.statePath,
|
|
1442
|
+
wellKnownPath: h.wellKnownPath,
|
|
1443
|
+
hubPath: h.hubPath,
|
|
1444
|
+
wellKnownDir: h.wellKnownDir,
|
|
1445
|
+
configDir: h.configDir,
|
|
1446
|
+
hubStopOpts: hubStopOpts(),
|
|
1447
|
+
log: (l) => logs.push(l),
|
|
1448
|
+
});
|
|
1449
|
+
expect(code).toBe(0);
|
|
1450
|
+
expect(existsSync(h.statePath)).toBe(false);
|
|
1451
|
+
const joined = logs.join("\n");
|
|
1452
|
+
expect(joined).toMatch(/already gone/);
|
|
1453
|
+
expect(joined).toMatch(/✓ Public \(Funnel\) exposure removed/);
|
|
1454
|
+
} finally {
|
|
1455
|
+
h.cleanup();
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
test("exposeOff handles a mix of clean and already-gone entries", async () => {
|
|
1460
|
+
const h = makeHarness();
|
|
1461
|
+
try {
|
|
1462
|
+
makePublicPriorState(h.statePath, 3);
|
|
1463
|
+
let i = 0;
|
|
1464
|
+
const runner: Runner = async (cmd) => {
|
|
1465
|
+
if (cmd[cmd.length - 1] === "off") {
|
|
1466
|
+
const idx = i++;
|
|
1467
|
+
if (idx === 1) {
|
|
1468
|
+
return {
|
|
1469
|
+
code: 1,
|
|
1470
|
+
stdout: "",
|
|
1471
|
+
stderr: "error: failed to remove web serve: listener does not exist",
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
1475
|
+
}
|
|
1476
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
1477
|
+
};
|
|
1478
|
+
const code = await exposePublic("off", {
|
|
1479
|
+
runner,
|
|
1480
|
+
statePath: h.statePath,
|
|
1481
|
+
wellKnownPath: h.wellKnownPath,
|
|
1482
|
+
hubPath: h.hubPath,
|
|
1483
|
+
wellKnownDir: h.wellKnownDir,
|
|
1484
|
+
configDir: h.configDir,
|
|
1485
|
+
hubStopOpts: hubStopOpts(),
|
|
1486
|
+
log: () => {},
|
|
1487
|
+
});
|
|
1488
|
+
expect(code).toBe(0);
|
|
1489
|
+
expect(existsSync(h.statePath)).toBe(false);
|
|
1490
|
+
} finally {
|
|
1491
|
+
h.cleanup();
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
test("exposeOff still aborts on a real (non-already-gone) error", async () => {
|
|
1496
|
+
const h = makeHarness();
|
|
1497
|
+
try {
|
|
1498
|
+
makePublicPriorState(h.statePath, 2);
|
|
1499
|
+
const runner: Runner = async (cmd) => {
|
|
1500
|
+
if (cmd[cmd.length - 1] === "off") {
|
|
1501
|
+
return {
|
|
1502
|
+
code: 1,
|
|
1503
|
+
stdout: "",
|
|
1504
|
+
stderr: "failed to connect to tailscaled: is it running?",
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
1508
|
+
};
|
|
1509
|
+
const logs: string[] = [];
|
|
1510
|
+
const code = await exposePublic("off", {
|
|
1511
|
+
runner,
|
|
1512
|
+
statePath: h.statePath,
|
|
1513
|
+
wellKnownPath: h.wellKnownPath,
|
|
1514
|
+
hubPath: h.hubPath,
|
|
1515
|
+
wellKnownDir: h.wellKnownDir,
|
|
1516
|
+
configDir: h.configDir,
|
|
1517
|
+
hubStopOpts: hubStopOpts(),
|
|
1518
|
+
log: (l) => logs.push(l),
|
|
1519
|
+
});
|
|
1520
|
+
expect(code).toBe(1);
|
|
1521
|
+
expect(existsSync(h.statePath)).toBe(true);
|
|
1522
|
+
expect(logs.join("\n")).toMatch(/Teardown failed/);
|
|
1523
|
+
} finally {
|
|
1524
|
+
h.cleanup();
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
test("exposeUp tolerates already-gone prior entries and proceeds to bringup", async () => {
|
|
1529
|
+
// Aaron's exact repro: prior expose-state lingered after an external
|
|
1530
|
+
// `tailscale funnel reset`, re-running `parachute expose public` aborted
|
|
1531
|
+
// because every teardown said "handler does not exist".
|
|
1532
|
+
const h = makeHarness();
|
|
1533
|
+
try {
|
|
1534
|
+
seedServices(h.manifestPath);
|
|
1535
|
+
makePublicPriorState(h.statePath, 2);
|
|
1536
|
+
const { spawner } = makeHubSpawner(1111);
|
|
1537
|
+
const bringupCalls: string[][] = [];
|
|
1538
|
+
const runner: Runner = async (cmd) => {
|
|
1539
|
+
if (cmd[0] === "tailscale" && cmd[1] === "version") {
|
|
1540
|
+
return { code: 0, stdout: "1.96.5\n", stderr: "" };
|
|
1541
|
+
}
|
|
1542
|
+
if (cmd[0] === "tailscale" && cmd[1] === "status" && cmd[2] === "--json") {
|
|
1543
|
+
return {
|
|
1544
|
+
code: 0,
|
|
1545
|
+
stdout: JSON.stringify({ Self: { DNSName: "parachute.taildf9ce2.ts.net." } }),
|
|
1546
|
+
stderr: "",
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
if (cmd[cmd.length - 1] === "off") {
|
|
1550
|
+
return {
|
|
1551
|
+
code: 1,
|
|
1552
|
+
stdout: "",
|
|
1553
|
+
stderr: "error: failed to remove web serve: handler does not exist",
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
if (cmd.includes("--bg")) {
|
|
1557
|
+
bringupCalls.push([...cmd]);
|
|
1558
|
+
}
|
|
1559
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
1560
|
+
};
|
|
1561
|
+
const code = await exposePublic("up", {
|
|
1562
|
+
runner,
|
|
1563
|
+
manifestPath: h.manifestPath,
|
|
1564
|
+
statePath: h.statePath,
|
|
1565
|
+
wellKnownPath: h.wellKnownPath,
|
|
1566
|
+
hubPath: h.hubPath,
|
|
1567
|
+
wellKnownDir: h.wellKnownDir,
|
|
1568
|
+
configDir: h.configDir,
|
|
1569
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1570
|
+
servicePortProbe: allServicesUp,
|
|
1571
|
+
log: () => {},
|
|
1572
|
+
});
|
|
1573
|
+
expect(code).toBe(0);
|
|
1574
|
+
expect(bringupCalls.length).toBeGreaterThan(0);
|
|
1575
|
+
const state = readExposeState(h.statePath);
|
|
1576
|
+
expect(state?.layer).toBe("public");
|
|
1577
|
+
} finally {
|
|
1578
|
+
h.cleanup();
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
});
|