@openparachute/hub 0.5.1 → 0.5.7
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 +1 -1
- package/src/__tests__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +1227 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +367 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +266 -32
- package/src/module-manifest.ts +19 -0
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +66 -13
- package/src/services-manifest.ts +83 -3
- package/src/sessions.ts +19 -0
|
@@ -112,7 +112,11 @@ function seedServices(path: string): void {
|
|
|
112
112
|
const allServicesUp = async () => true;
|
|
113
113
|
|
|
114
114
|
describe("expose tailnet up", () => {
|
|
115
|
-
test("
|
|
115
|
+
test("emits exactly one catchall mount: / → http://127.0.0.1:<hubPort>/", async () => {
|
|
116
|
+
// Single-rule symmetry with cloudflare ingress (#178). Hub does all
|
|
117
|
+
// internal dispatch (UI, OAuth, well-known, vault SPA + per-vault proxy,
|
|
118
|
+
// generic /<svc>/* services dispatch) so the tailscale plan stays at
|
|
119
|
+
// one mount regardless of how many services are installed.
|
|
116
120
|
const h = makeHarness();
|
|
117
121
|
try {
|
|
118
122
|
seedServices(h.manifestPath);
|
|
@@ -136,47 +140,20 @@ describe("expose tailnet up", () => {
|
|
|
136
140
|
const serveCalls = calls.filter(
|
|
137
141
|
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
138
142
|
);
|
|
139
|
-
//
|
|
140
|
-
expect(serveCalls).toHaveLength(
|
|
143
|
+
// Exactly one bringup: `/ → http://127.0.0.1:<hubPort>/`.
|
|
144
|
+
expect(serveCalls).toHaveLength(1);
|
|
141
145
|
// Tailnet mode never uses funnel — neither the old flag nor the new subcommand.
|
|
142
146
|
expect(serveCalls.every((c) => !c.includes("--funnel"))).toBe(true);
|
|
143
147
|
expect(calls.every((c) => c[1] !== "funnel")).toBe(true);
|
|
144
148
|
|
|
145
|
-
const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")))
|
|
146
|
-
|
|
147
|
-
// the hub then picks the specific vault instance per request from
|
|
148
|
-
// services.json. Notes (and other non-vault services) keep their
|
|
149
|
-
// direct mount.
|
|
150
|
-
expect(mounts).toEqual([
|
|
151
|
-
"--set-path=/",
|
|
152
|
-
"--set-path=/.well-known/oauth-authorization-server",
|
|
153
|
-
"--set-path=/.well-known/parachute.json",
|
|
154
|
-
"--set-path=/notes",
|
|
155
|
-
"--set-path=/oauth/authorize",
|
|
156
|
-
"--set-path=/oauth/register",
|
|
157
|
-
"--set-path=/oauth/token",
|
|
158
|
-
"--set-path=/vault/",
|
|
159
|
-
]);
|
|
160
|
-
|
|
161
|
-
// Hub + well-known now point at localhost HTTP, not a file path.
|
|
162
|
-
// Target path mirrors mount exactly so tailscale's strip-then-forward
|
|
163
|
-
// is a no-op; otherwise SPAs at /<mount>/ redirect-loop.
|
|
164
|
-
const hubCall = serveCalls.find((c) => c.includes("--set-path=/"));
|
|
165
|
-
expect(hubCall?.[hubCall.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
|
|
166
|
-
|
|
167
|
-
const wkCall = serveCalls.find((c) => c.includes("--set-path=/.well-known/parachute.json"));
|
|
168
|
-
expect(wkCall?.[wkCall.length - 1]).toMatch(
|
|
169
|
-
/^http:\/\/127\.0\.0\.1:\d+\/\.well-known\/parachute\.json$/,
|
|
170
|
-
);
|
|
149
|
+
const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
150
|
+
expect(mounts).toEqual(["--set-path=/"]);
|
|
171
151
|
|
|
172
|
-
//
|
|
173
|
-
// strip-then-forward is a no-op
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// the right vault backend on each request) — port is dynamic per test.
|
|
178
|
-
const vaultCall = serveCalls.find((c) => c.includes("--set-path=/vault/"));
|
|
179
|
-
expect(vaultCall?.[vaultCall.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/vault\/$/);
|
|
152
|
+
// Hub catchall target is the hub loopback root with trailing slash so
|
|
153
|
+
// tailscale's strip-then-forward is a no-op (mount and target match
|
|
154
|
+
// byte-for-byte).
|
|
155
|
+
const hubCall = serveCalls[0] ?? [];
|
|
156
|
+
expect(hubCall[hubCall.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
|
|
180
157
|
|
|
181
158
|
expect(existsSync(h.wellKnownPath)).toBe(true);
|
|
182
159
|
expect(existsSync(h.hubPath)).toBe(true);
|
|
@@ -186,9 +163,9 @@ describe("expose tailnet up", () => {
|
|
|
186
163
|
const state = readExposeState(h.statePath);
|
|
187
164
|
expect(state?.layer).toBe("tailnet");
|
|
188
165
|
expect(state?.mode).toBe("path");
|
|
189
|
-
expect(state?.entries).toHaveLength(
|
|
190
|
-
|
|
191
|
-
expect(state?.entries.
|
|
166
|
+
expect(state?.entries).toHaveLength(1);
|
|
167
|
+
expect(state?.entries[0]?.mount).toBe("/");
|
|
168
|
+
expect(state?.entries[0]?.kind).toBe("proxy");
|
|
192
169
|
} finally {
|
|
193
170
|
h.cleanup();
|
|
194
171
|
}
|
|
@@ -224,10 +201,11 @@ describe("expose tailnet up", () => {
|
|
|
224
201
|
}
|
|
225
202
|
});
|
|
226
203
|
|
|
227
|
-
test("
|
|
228
|
-
// Aaron hit ERR_TOO_MANY_REDIRECTS on /notes/ because
|
|
229
|
-
// the prefix
|
|
230
|
-
//
|
|
204
|
+
test("hub catchall target ends in `/` so tailscale strip-then-forward is a no-op", async () => {
|
|
205
|
+
// Aaron hit ERR_TOO_MANY_REDIRECTS on /notes/ pre-collapse because
|
|
206
|
+
// tailscale strips the prefix and Vite (base=/notes) redirects back to
|
|
207
|
+
// /notes/. Mount and target byte-equal breaks that loop. Now that there's
|
|
208
|
+
// one catchall, the same rule applies to the hub root: `/ → http://…/`.
|
|
231
209
|
const h = makeHarness();
|
|
232
210
|
try {
|
|
233
211
|
upsertService(
|
|
@@ -258,15 +236,20 @@ describe("expose tailnet up", () => {
|
|
|
258
236
|
const serveCalls = calls.filter(
|
|
259
237
|
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
260
238
|
);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
expect(
|
|
239
|
+
expect(serveCalls).toHaveLength(1);
|
|
240
|
+
const call = serveCalls[0] ?? [];
|
|
241
|
+
expect(call).toContain("--set-path=/");
|
|
242
|
+
expect(call[call.length - 1]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/);
|
|
264
243
|
} finally {
|
|
265
244
|
h.cleanup();
|
|
266
245
|
}
|
|
267
246
|
});
|
|
268
247
|
|
|
269
|
-
test("legacy paths:[/] entry
|
|
248
|
+
test("legacy paths:[/] entry warns operator (no rewrite — hub dispatches per request)", async () => {
|
|
249
|
+
// Pre-collapse this remapped to /<shortname>; now the hub does dispatch
|
|
250
|
+
// per services.json, so a paths:["/"] entry would still collide with the
|
|
251
|
+
// hub UI but the failure surface is hub-side, not tailscale-plan-side.
|
|
252
|
+
// Keep the warn so operators know to re-install.
|
|
270
253
|
const h = makeHarness();
|
|
271
254
|
try {
|
|
272
255
|
upsertService(
|
|
@@ -290,16 +273,12 @@ describe("expose tailnet up", () => {
|
|
|
290
273
|
});
|
|
291
274
|
expect(code).toBe(0);
|
|
292
275
|
|
|
276
|
+
// Plan is still exactly one catchall regardless of the legacy entry.
|
|
293
277
|
const serveCalls = calls.filter(
|
|
294
278
|
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
295
279
|
);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// `/vault/` mount → hub. The remap warning still fires; the mount shape
|
|
299
|
-
// just doesn't reflect the original `/<shortname>` since #144.
|
|
300
|
-
expect(mounts).toContain("--set-path=/vault/");
|
|
301
|
-
expect(mounts).toContain("--set-path=/");
|
|
302
|
-
expect(mounts.filter((m) => m === "--set-path=/")).toHaveLength(1);
|
|
280
|
+
expect(serveCalls).toHaveLength(1);
|
|
281
|
+
expect(serveCalls[0]).toContain("--set-path=/");
|
|
303
282
|
|
|
304
283
|
expect(logs.join("\n")).toMatch(/parachute-vault claims "\/"; hub page lives there/);
|
|
305
284
|
} finally {
|
|
@@ -435,11 +414,12 @@ describe("expose tailnet up", () => {
|
|
|
435
414
|
expect(joined).toMatch(/parachute-notes \(port 5173\) is not responding/);
|
|
436
415
|
expect(joined).toMatch(/parachute start notes/);
|
|
437
416
|
expect(joined).not.toMatch(/parachute-vault.*not responding/);
|
|
438
|
-
// Bringup still happened —
|
|
417
|
+
// Bringup still happened — single hub catchall regardless of which
|
|
418
|
+
// services responded to the probe.
|
|
439
419
|
const serveCalls = calls.filter(
|
|
440
420
|
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
441
421
|
);
|
|
442
|
-
expect(serveCalls).toHaveLength(
|
|
422
|
+
expect(serveCalls).toHaveLength(1);
|
|
443
423
|
} finally {
|
|
444
424
|
h.cleanup();
|
|
445
425
|
}
|
|
@@ -484,7 +464,10 @@ describe("expose tailnet up", () => {
|
|
|
484
464
|
}
|
|
485
465
|
});
|
|
486
466
|
|
|
487
|
-
test("
|
|
467
|
+
test("hub catchall serves OAuth + well-known internally — no separate mount per endpoint", async () => {
|
|
468
|
+
// OAuth (hub IS the IdP) and well-known (parachute.json + JWKS +
|
|
469
|
+
// oauth-authorization-server metadata) are dispatched by the hub from
|
|
470
|
+
// the single catchall. State + bringup carry exactly one entry.
|
|
488
471
|
const h = makeHarness();
|
|
489
472
|
try {
|
|
490
473
|
seedServices(h.manifestPath);
|
|
@@ -507,26 +490,10 @@ describe("expose tailnet up", () => {
|
|
|
507
490
|
const serveCalls = calls.filter(
|
|
508
491
|
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
509
492
|
);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
mount &&
|
|
515
|
-
(mount.startsWith("/oauth/") || mount === "/.well-known/oauth-authorization-server")
|
|
516
|
-
) {
|
|
517
|
-
oauthTargets.set(mount, c[c.length - 1] ?? "");
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
expect(oauthTargets.get("/.well-known/oauth-authorization-server")).toMatch(
|
|
521
|
-
/^http:\/\/127\.0\.0\.1:\d+\/\.well-known\/oauth-authorization-server$/,
|
|
522
|
-
);
|
|
523
|
-
expect(oauthTargets.get("/oauth/authorize")).toMatch(
|
|
524
|
-
/^http:\/\/127\.0\.0\.1:\d+\/oauth\/authorize$/,
|
|
525
|
-
);
|
|
526
|
-
expect(oauthTargets.get("/oauth/token")).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/token$/);
|
|
527
|
-
expect(oauthTargets.get("/oauth/register")).toMatch(
|
|
528
|
-
/^http:\/\/127\.0\.0\.1:\d+\/oauth\/register$/,
|
|
529
|
-
);
|
|
493
|
+
// Single catchall — no per-endpoint OAuth or well-known mounts.
|
|
494
|
+
expect(serveCalls).toHaveLength(1);
|
|
495
|
+
const mounts = serveCalls.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
496
|
+
expect(mounts).toEqual(["--set-path=/"]);
|
|
530
497
|
|
|
531
498
|
const state = readExposeState(h.statePath);
|
|
532
499
|
expect(state?.hubOrigin).toBe("https://parachute.taildf9ce2.ts.net");
|
|
@@ -535,7 +502,10 @@ describe("expose tailnet up", () => {
|
|
|
535
502
|
}
|
|
536
503
|
});
|
|
537
504
|
|
|
538
|
-
test("
|
|
505
|
+
test("plan is one catchall regardless of which services are installed", async () => {
|
|
506
|
+
// Pre-collapse this varied: with vault installed we got more mounts than
|
|
507
|
+
// without. Now the count is constant — the hub dispatches from
|
|
508
|
+
// services.json per request, so the tailscale plan doesn't enumerate.
|
|
539
509
|
const h = makeHarness();
|
|
540
510
|
try {
|
|
541
511
|
upsertService(
|
|
@@ -567,15 +537,7 @@ describe("expose tailnet up", () => {
|
|
|
567
537
|
const serveCalls = calls.filter(
|
|
568
538
|
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
569
539
|
);
|
|
570
|
-
|
|
571
|
-
// regardless of which services are installed.
|
|
572
|
-
expect(serveCalls).toHaveLength(7);
|
|
573
|
-
const oauthMounts = serveCalls
|
|
574
|
-
.map((c) => c.find((a) => a.startsWith("--set-path=")))
|
|
575
|
-
.filter(
|
|
576
|
-
(m): m is string => !!m && (m.includes("/oauth/") || m.endsWith("authorization-server")),
|
|
577
|
-
);
|
|
578
|
-
expect(oauthMounts).toHaveLength(4);
|
|
540
|
+
expect(serveCalls).toHaveLength(1);
|
|
579
541
|
} finally {
|
|
580
542
|
h.cleanup();
|
|
581
543
|
}
|
|
@@ -608,6 +570,41 @@ describe("expose tailnet up", () => {
|
|
|
608
570
|
h.cleanup();
|
|
609
571
|
}
|
|
610
572
|
});
|
|
573
|
+
|
|
574
|
+
// 2FA warning is public-layer only (#186). Tailnet ingress is
|
|
575
|
+
// tailscale-authed at the proxy, so /admin/login isn't on the open
|
|
576
|
+
// internet — the warning is moot here even when 2FA is unset.
|
|
577
|
+
test("tailnet bringup does NOT fire the 2FA warning even when not enrolled", async () => {
|
|
578
|
+
const h = makeHarness();
|
|
579
|
+
try {
|
|
580
|
+
seedServices(h.manifestPath);
|
|
581
|
+
const { runner } = makeRunner();
|
|
582
|
+
const { spawner } = makeHubSpawner(1111);
|
|
583
|
+
const logs: string[] = [];
|
|
584
|
+
const code = await exposeTailnet("up", {
|
|
585
|
+
runner,
|
|
586
|
+
manifestPath: h.manifestPath,
|
|
587
|
+
statePath: h.statePath,
|
|
588
|
+
wellKnownPath: h.wellKnownPath,
|
|
589
|
+
hubPath: h.hubPath,
|
|
590
|
+
wellKnownDir: h.wellKnownDir,
|
|
591
|
+
configDir: h.configDir,
|
|
592
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
593
|
+
servicePortProbe: allServicesUp,
|
|
594
|
+
log: (l) => logs.push(l),
|
|
595
|
+
vaultAuthStatus: {
|
|
596
|
+
hasOwnerPassword: false,
|
|
597
|
+
hasTotp: false,
|
|
598
|
+
tokenCount: 0,
|
|
599
|
+
vaultNames: [],
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
expect(code).toBe(0);
|
|
603
|
+
expect(logs.join("\n")).not.toContain("2FA is not enrolled");
|
|
604
|
+
} finally {
|
|
605
|
+
h.cleanup();
|
|
606
|
+
}
|
|
607
|
+
});
|
|
611
608
|
});
|
|
612
609
|
|
|
613
610
|
describe("expose tailnet off", () => {
|
|
@@ -821,8 +818,9 @@ describe("expose public up", () => {
|
|
|
821
818
|
const funnelCalls = calls.filter(
|
|
822
819
|
(c) => c[0] === "tailscale" && c[1] === "funnel" && c.includes("--bg"),
|
|
823
820
|
);
|
|
824
|
-
//
|
|
825
|
-
|
|
821
|
+
// Single hub catchall — public mode shape now matches tailnet (#178
|
|
822
|
+
// landed the cloudflare-side single-rule; this closes tailnet).
|
|
823
|
+
expect(funnelCalls).toHaveLength(1);
|
|
826
824
|
// Never emit the legacy `serve --funnel` shape.
|
|
827
825
|
expect(calls.every((c) => !c.includes("--funnel"))).toBe(true);
|
|
828
826
|
expect(calls.every((c) => !(c[1] === "serve" && c.includes("--bg")))).toBe(true);
|
|
@@ -830,7 +828,7 @@ describe("expose public up", () => {
|
|
|
830
828
|
const state = readExposeState(h.statePath);
|
|
831
829
|
expect(state?.layer).toBe("public");
|
|
832
830
|
expect(state?.funnel).toBe(true);
|
|
833
|
-
expect(state?.entries).toHaveLength(
|
|
831
|
+
expect(state?.entries).toHaveLength(1);
|
|
834
832
|
|
|
835
833
|
expect(logs.join("\n")).toMatch(/Public exposure active/);
|
|
836
834
|
} finally {
|
|
@@ -932,6 +930,77 @@ describe("expose public up", () => {
|
|
|
932
930
|
h.cleanup();
|
|
933
931
|
}
|
|
934
932
|
});
|
|
933
|
+
|
|
934
|
+
// 2FA-enrollment warning (#186). Funnel exposure makes /admin/login
|
|
935
|
+
// reachable on the open internet just like cloudflare; the warning lives in
|
|
936
|
+
// a shared helper (`expose-2fa-warning.ts`) wired into both paths.
|
|
937
|
+
test("2FA not enrolled → warning fires on the public layer", async () => {
|
|
938
|
+
const h = makeHarness();
|
|
939
|
+
try {
|
|
940
|
+
seedServices(h.manifestPath);
|
|
941
|
+
const { runner } = makeRunner();
|
|
942
|
+
const { spawner } = makeHubSpawner(1111);
|
|
943
|
+
const logs: string[] = [];
|
|
944
|
+
const code = await exposePublic("up", {
|
|
945
|
+
runner,
|
|
946
|
+
manifestPath: h.manifestPath,
|
|
947
|
+
statePath: h.statePath,
|
|
948
|
+
wellKnownPath: h.wellKnownPath,
|
|
949
|
+
hubPath: h.hubPath,
|
|
950
|
+
wellKnownDir: h.wellKnownDir,
|
|
951
|
+
configDir: h.configDir,
|
|
952
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
953
|
+
servicePortProbe: allServicesUp,
|
|
954
|
+
log: (l) => logs.push(l),
|
|
955
|
+
vaultAuthStatus: {
|
|
956
|
+
hasOwnerPassword: true,
|
|
957
|
+
hasTotp: false,
|
|
958
|
+
tokenCount: 0,
|
|
959
|
+
vaultNames: ["default"],
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
expect(code).toBe(0);
|
|
963
|
+
const joined = logs.join("\n");
|
|
964
|
+
expect(joined).toContain("2FA is not enrolled");
|
|
965
|
+
expect(joined).toContain("parachute auth 2fa enroll");
|
|
966
|
+
// /admin/login pointer uses the canonical https://<fqdn> origin.
|
|
967
|
+
expect(joined).toContain("https://parachute.taildf9ce2.ts.net/admin/login");
|
|
968
|
+
} finally {
|
|
969
|
+
h.cleanup();
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test("2FA enrolled → warning suppressed on the public layer", async () => {
|
|
974
|
+
const h = makeHarness();
|
|
975
|
+
try {
|
|
976
|
+
seedServices(h.manifestPath);
|
|
977
|
+
const { runner } = makeRunner();
|
|
978
|
+
const { spawner } = makeHubSpawner(1111);
|
|
979
|
+
const logs: string[] = [];
|
|
980
|
+
const code = await exposePublic("up", {
|
|
981
|
+
runner,
|
|
982
|
+
manifestPath: h.manifestPath,
|
|
983
|
+
statePath: h.statePath,
|
|
984
|
+
wellKnownPath: h.wellKnownPath,
|
|
985
|
+
hubPath: h.hubPath,
|
|
986
|
+
wellKnownDir: h.wellKnownDir,
|
|
987
|
+
configDir: h.configDir,
|
|
988
|
+
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
989
|
+
servicePortProbe: allServicesUp,
|
|
990
|
+
log: (l) => logs.push(l),
|
|
991
|
+
vaultAuthStatus: {
|
|
992
|
+
hasOwnerPassword: true,
|
|
993
|
+
hasTotp: true,
|
|
994
|
+
tokenCount: 1,
|
|
995
|
+
vaultNames: ["default"],
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
expect(code).toBe(0);
|
|
999
|
+
expect(logs.join("\n")).not.toContain("2FA is not enrolled");
|
|
1000
|
+
} finally {
|
|
1001
|
+
h.cleanup();
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
935
1004
|
});
|
|
936
1005
|
|
|
937
1006
|
describe("expose public off", () => {
|
|
@@ -1023,15 +1092,21 @@ describe("expose public off", () => {
|
|
|
1023
1092
|
});
|
|
1024
1093
|
});
|
|
1025
1094
|
|
|
1026
|
-
describe("expose
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
|
|
1095
|
+
describe("expose plan is layer-agnostic — gating moved to hub", () => {
|
|
1096
|
+
// Pre-collapse the tailscale plan partitioned services by publicExposure
|
|
1097
|
+
// and withheld loopback/auth-required entries. Now the plan is always one
|
|
1098
|
+
// catchall to the hub, which gates per request via `effectivePublicExposure`
|
|
1099
|
+
// + `layerOf` (see hub-server.ts). These tests confirm the plan stays
|
|
1100
|
+
// single-rule regardless of services' exposure declarations; per-request
|
|
1101
|
+
// gating is exercised in hub-server.test.ts.
|
|
1102
|
+
|
|
1103
|
+
test("plan stays one catchall when a loopback-only service is installed", async () => {
|
|
1104
|
+
// Pre-collapse, scribe (publicExposure: "loopback") was withheld from the
|
|
1105
|
+
// plan with an operator-visible warning. Now scribe is on the plan via
|
|
1106
|
+
// the hub catchall — the hub returns 404 to non-loopback callers per
|
|
1107
|
+
// hub-server's `proxyToService` layer-gate. Plan stays one mount.
|
|
1032
1108
|
const h = makeHarness();
|
|
1033
1109
|
try {
|
|
1034
|
-
// Vault is mounted as usual; scribe declares loopback and is withheld.
|
|
1035
1110
|
upsertService(
|
|
1036
1111
|
{
|
|
1037
1112
|
name: "parachute-vault",
|
|
@@ -1056,7 +1131,6 @@ describe("expose publicExposure filter", () => {
|
|
|
1056
1131
|
);
|
|
1057
1132
|
const { runner, calls } = makeRunner();
|
|
1058
1133
|
const { spawner } = makeHubSpawner(1111);
|
|
1059
|
-
const logs: string[] = [];
|
|
1060
1134
|
const code = await exposeTailnet("up", {
|
|
1061
1135
|
runner,
|
|
1062
1136
|
manifestPath: h.manifestPath,
|
|
@@ -1067,41 +1141,29 @@ describe("expose publicExposure filter", () => {
|
|
|
1067
1141
|
configDir: h.configDir,
|
|
1068
1142
|
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1069
1143
|
servicePortProbe: allServicesUp,
|
|
1070
|
-
log: (
|
|
1144
|
+
log: () => {},
|
|
1071
1145
|
});
|
|
1072
1146
|
expect(code).toBe(0);
|
|
1073
|
-
|
|
1074
1147
|
const serveCalls = calls.filter(
|
|
1075
1148
|
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
1076
1149
|
);
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
// its 4 OAuth proxies, hub, and well-known are still individually
|
|
1080
|
-
// mounted. /scribe is loopback-only and absent.
|
|
1081
|
-
expect(mounts).toContain("--set-path=/vault/");
|
|
1082
|
-
expect(mounts).not.toContain("--set-path=/scribe");
|
|
1083
|
-
|
|
1084
|
-
// Operator-visible notice explaining the withhold.
|
|
1085
|
-
expect(logs.join("\n")).toMatch(
|
|
1086
|
-
/parachute-scribe is loopback-only — loopback-only by service declaration/,
|
|
1087
|
-
);
|
|
1150
|
+
expect(serveCalls).toHaveLength(1);
|
|
1151
|
+
expect(serveCalls[0]).toContain("--set-path=/");
|
|
1088
1152
|
|
|
1089
|
-
// State
|
|
1090
|
-
// entries that were never brought up.
|
|
1153
|
+
// State carries one entry — hub catchall. No /scribe or /vault/* in state.
|
|
1091
1154
|
const state = readExposeState(h.statePath);
|
|
1092
|
-
expect(state?.entries
|
|
1155
|
+
expect(state?.entries).toHaveLength(1);
|
|
1156
|
+
expect(state?.entries[0]?.mount).toBe("/");
|
|
1093
1157
|
} finally {
|
|
1094
1158
|
h.cleanup();
|
|
1095
1159
|
}
|
|
1096
1160
|
});
|
|
1097
1161
|
|
|
1098
|
-
test("
|
|
1099
|
-
// auth-required
|
|
1100
|
-
// wants auth but hasn't confirmed it's configured. Today the CLI treats
|
|
1101
|
-
// it identically to loopback — still no funnel/tailnet exposure.
|
|
1162
|
+
test("plan stays one catchall regardless of mix of publicExposure values", async () => {
|
|
1163
|
+
// Mix: allowed, loopback, auth-required, and absent. Plan is one mount.
|
|
1102
1164
|
const h = makeHarness();
|
|
1103
1165
|
try {
|
|
1104
|
-
seedServices(h.manifestPath); // vault + notes
|
|
1166
|
+
seedServices(h.manifestPath); // vault + notes
|
|
1105
1167
|
upsertService(
|
|
1106
1168
|
{
|
|
1107
1169
|
name: "parachute-channel",
|
|
@@ -1113,40 +1175,6 @@ describe("expose publicExposure filter", () => {
|
|
|
1113
1175
|
},
|
|
1114
1176
|
h.manifestPath,
|
|
1115
1177
|
);
|
|
1116
|
-
const { runner, calls } = makeRunner();
|
|
1117
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1118
|
-
const logs: string[] = [];
|
|
1119
|
-
const code = await exposeTailnet("up", {
|
|
1120
|
-
runner,
|
|
1121
|
-
manifestPath: h.manifestPath,
|
|
1122
|
-
statePath: h.statePath,
|
|
1123
|
-
wellKnownPath: h.wellKnownPath,
|
|
1124
|
-
hubPath: h.hubPath,
|
|
1125
|
-
wellKnownDir: h.wellKnownDir,
|
|
1126
|
-
configDir: h.configDir,
|
|
1127
|
-
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1128
|
-
servicePortProbe: allServicesUp,
|
|
1129
|
-
log: (l) => logs.push(l),
|
|
1130
|
-
});
|
|
1131
|
-
expect(code).toBe(0);
|
|
1132
|
-
const mounts = calls
|
|
1133
|
-
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1134
|
-
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1135
|
-
expect(mounts).not.toContain("--set-path=/channel");
|
|
1136
|
-
expect(logs.join("\n")).toMatch(/parachute-channel is loopback-only — auth-required/);
|
|
1137
|
-
} finally {
|
|
1138
|
-
h.cleanup();
|
|
1139
|
-
}
|
|
1140
|
-
});
|
|
1141
|
-
|
|
1142
|
-
test("missing publicExposure + spec kind=api, hasAuth=false → loopback default (scribe)", async () => {
|
|
1143
|
-
// Scribe today has no auth gate; its ServiceSpec says so (kind: "api",
|
|
1144
|
-
// hasAuth: false). With publicExposure absent we should still withhold.
|
|
1145
|
-
// This is the safe-by-default case for services that haven't yet been
|
|
1146
|
-
// updated to declare their exposure.
|
|
1147
|
-
const h = makeHarness();
|
|
1148
|
-
try {
|
|
1149
|
-
seedServices(h.manifestPath);
|
|
1150
1178
|
upsertService(
|
|
1151
1179
|
{
|
|
1152
1180
|
name: "parachute-scribe",
|
|
@@ -1160,51 +1188,6 @@ describe("expose publicExposure filter", () => {
|
|
|
1160
1188
|
);
|
|
1161
1189
|
const { runner, calls } = makeRunner();
|
|
1162
1190
|
const { spawner } = makeHubSpawner(1111);
|
|
1163
|
-
const logs: string[] = [];
|
|
1164
|
-
const code = await exposeTailnet("up", {
|
|
1165
|
-
runner,
|
|
1166
|
-
manifestPath: h.manifestPath,
|
|
1167
|
-
statePath: h.statePath,
|
|
1168
|
-
wellKnownPath: h.wellKnownPath,
|
|
1169
|
-
hubPath: h.hubPath,
|
|
1170
|
-
wellKnownDir: h.wellKnownDir,
|
|
1171
|
-
configDir: h.configDir,
|
|
1172
|
-
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1173
|
-
servicePortProbe: allServicesUp,
|
|
1174
|
-
log: (l) => logs.push(l),
|
|
1175
|
-
});
|
|
1176
|
-
expect(code).toBe(0);
|
|
1177
|
-
const mounts = calls
|
|
1178
|
-
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1179
|
-
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1180
|
-
expect(mounts).not.toContain("--set-path=/scribe");
|
|
1181
|
-
// Reason text points operators at the missing auth gate.
|
|
1182
|
-
expect(logs.join("\n")).toMatch(
|
|
1183
|
-
/parachute-scribe is loopback-only — auth-required: service has no auth gate/,
|
|
1184
|
-
);
|
|
1185
|
-
} finally {
|
|
1186
|
-
h.cleanup();
|
|
1187
|
-
}
|
|
1188
|
-
});
|
|
1189
|
-
|
|
1190
|
-
test("missing publicExposure on a known auth'd api service (vault) still exposes", async () => {
|
|
1191
|
-
// vault's ServiceSpec has hasAuth: true, so the absence of publicExposure
|
|
1192
|
-
// should not hide it — the back-compat path for every vault entry written
|
|
1193
|
-
// before this field existed.
|
|
1194
|
-
const h = makeHarness();
|
|
1195
|
-
try {
|
|
1196
|
-
upsertService(
|
|
1197
|
-
{
|
|
1198
|
-
name: "parachute-vault",
|
|
1199
|
-
port: 1940,
|
|
1200
|
-
paths: ["/vault/default"],
|
|
1201
|
-
health: "/vault/default/health",
|
|
1202
|
-
version: "0.2.4",
|
|
1203
|
-
},
|
|
1204
|
-
h.manifestPath,
|
|
1205
|
-
);
|
|
1206
|
-
const { runner, calls } = makeRunner();
|
|
1207
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1208
1191
|
const code = await exposeTailnet("up", {
|
|
1209
1192
|
runner,
|
|
1210
1193
|
manifestPath: h.manifestPath,
|
|
@@ -1218,53 +1201,10 @@ describe("expose publicExposure filter", () => {
|
|
|
1218
1201
|
log: () => {},
|
|
1219
1202
|
});
|
|
1220
1203
|
expect(code).toBe(0);
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1224
|
-
// Vault → consolidated `/vault/` mount (#144).
|
|
1225
|
-
expect(mounts).toContain("--set-path=/vault/");
|
|
1226
|
-
} finally {
|
|
1227
|
-
h.cleanup();
|
|
1228
|
-
}
|
|
1229
|
-
});
|
|
1230
|
-
|
|
1231
|
-
test("unknown third-party service without publicExposure defaults to allowed", async () => {
|
|
1232
|
-
// A service not in SERVICE_SPECS has no kind/hasAuth signal. We err on
|
|
1233
|
-
// the side of preserving current behavior (back-compat) so operators'
|
|
1234
|
-
// existing exposures don't silently stop working on upgrade. If the
|
|
1235
|
-
// third-party wants to opt out, they can write publicExposure: "loopback"
|
|
1236
|
-
// into their services.json entry.
|
|
1237
|
-
const h = makeHarness();
|
|
1238
|
-
try {
|
|
1239
|
-
upsertService(
|
|
1240
|
-
{
|
|
1241
|
-
name: "parachute-rando",
|
|
1242
|
-
port: 1947,
|
|
1243
|
-
paths: ["/rando"],
|
|
1244
|
-
health: "/rando/health",
|
|
1245
|
-
version: "0.0.1",
|
|
1246
|
-
},
|
|
1247
|
-
h.manifestPath,
|
|
1204
|
+
const serveCalls = calls.filter(
|
|
1205
|
+
(c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"),
|
|
1248
1206
|
);
|
|
1249
|
-
|
|
1250
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1251
|
-
const code = await exposeTailnet("up", {
|
|
1252
|
-
runner,
|
|
1253
|
-
manifestPath: h.manifestPath,
|
|
1254
|
-
statePath: h.statePath,
|
|
1255
|
-
wellKnownPath: h.wellKnownPath,
|
|
1256
|
-
hubPath: h.hubPath,
|
|
1257
|
-
wellKnownDir: h.wellKnownDir,
|
|
1258
|
-
configDir: h.configDir,
|
|
1259
|
-
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1260
|
-
servicePortProbe: allServicesUp,
|
|
1261
|
-
log: () => {},
|
|
1262
|
-
});
|
|
1263
|
-
expect(code).toBe(0);
|
|
1264
|
-
const mounts = calls
|
|
1265
|
-
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1266
|
-
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1267
|
-
expect(mounts).toContain("--set-path=/rando");
|
|
1207
|
+
expect(serveCalls).toHaveLength(1);
|
|
1268
1208
|
} finally {
|
|
1269
1209
|
h.cleanup();
|
|
1270
1210
|
}
|
|
@@ -1595,12 +1535,15 @@ describe("expose teardown tolerance for already-gone entries", () => {
|
|
|
1595
1535
|
});
|
|
1596
1536
|
});
|
|
1597
1537
|
|
|
1598
|
-
describe("expose vault
|
|
1599
|
-
// Pre-#144: tailscale
|
|
1600
|
-
//
|
|
1601
|
-
//
|
|
1538
|
+
describe("expose: vault routing fully internal to hub", () => {
|
|
1539
|
+
// Pre-#144: one tailscale mount per vault path. #144 collapsed those to a
|
|
1540
|
+
// single `/vault/ → hub` mount. This PR collapses one step further: vault
|
|
1541
|
+
// routing is just a slice of the hub catchall now. Hub's `proxyToVault`
|
|
1542
|
+
// (in hub-server.ts) still dispatches per services.json on each request,
|
|
1543
|
+
// so `parachute vault create <name>` is reachable without re-expose; the
|
|
1544
|
+
// tailscale plan just no longer carries a vault-specific entry.
|
|
1602
1545
|
|
|
1603
|
-
test("single vault, single path →
|
|
1546
|
+
test("single vault, single path → still one catchall, no /vault/* tailscale rule", async () => {
|
|
1604
1547
|
const h = makeHarness();
|
|
1605
1548
|
try {
|
|
1606
1549
|
upsertService(
|
|
@@ -1631,59 +1574,16 @@ describe("expose vault mount consolidation (#144)", () => {
|
|
|
1631
1574
|
const mounts = calls
|
|
1632
1575
|
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1633
1576
|
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1634
|
-
expect(mounts).
|
|
1635
|
-
expect(mounts).not.toContain("--set-path=/vault/default");
|
|
1636
|
-
} finally {
|
|
1637
|
-
h.cleanup();
|
|
1638
|
-
}
|
|
1639
|
-
});
|
|
1640
|
-
|
|
1641
|
-
test("multi-path single ServiceEntry still emits exactly one /vault/ mount", async () => {
|
|
1642
|
-
// The current vault manifest shape: one ServiceEntry whose `paths` lists
|
|
1643
|
-
// every instance. The plan must collapse them all to the hub-routed
|
|
1644
|
-
// `/vault/` instead of one mount per instance.
|
|
1645
|
-
const h = makeHarness();
|
|
1646
|
-
try {
|
|
1647
|
-
upsertService(
|
|
1648
|
-
{
|
|
1649
|
-
name: "parachute-vault",
|
|
1650
|
-
port: 1940,
|
|
1651
|
-
paths: ["/vault/default", "/vault/techne"],
|
|
1652
|
-
health: "/vault/default/health",
|
|
1653
|
-
version: "0.4.0",
|
|
1654
|
-
},
|
|
1655
|
-
h.manifestPath,
|
|
1656
|
-
);
|
|
1657
|
-
const { runner, calls } = makeRunner();
|
|
1658
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1659
|
-
const code = await exposeTailnet("up", {
|
|
1660
|
-
runner,
|
|
1661
|
-
manifestPath: h.manifestPath,
|
|
1662
|
-
statePath: h.statePath,
|
|
1663
|
-
wellKnownPath: h.wellKnownPath,
|
|
1664
|
-
hubPath: h.hubPath,
|
|
1665
|
-
wellKnownDir: h.wellKnownDir,
|
|
1666
|
-
configDir: h.configDir,
|
|
1667
|
-
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1668
|
-
servicePortProbe: allServicesUp,
|
|
1669
|
-
log: () => {},
|
|
1670
|
-
});
|
|
1671
|
-
expect(code).toBe(0);
|
|
1672
|
-
const mounts = calls
|
|
1673
|
-
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1674
|
-
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1675
|
-
const vaultMounts = mounts.filter((m) => m?.startsWith("--set-path=/vault"));
|
|
1676
|
-
expect(vaultMounts).toEqual(["--set-path=/vault/"]);
|
|
1577
|
+
expect(mounts).toEqual(["--set-path=/"]);
|
|
1677
1578
|
} finally {
|
|
1678
1579
|
h.cleanup();
|
|
1679
1580
|
}
|
|
1680
1581
|
});
|
|
1681
1582
|
|
|
1682
|
-
test("multiple separate vault ServiceEntries still
|
|
1683
|
-
// Pathological but representable:
|
|
1684
|
-
//
|
|
1685
|
-
//
|
|
1686
|
-
// request.
|
|
1583
|
+
test("multiple separate vault ServiceEntries → still one catchall", async () => {
|
|
1584
|
+
// Pathological but representable: a second parachute-vault-archive
|
|
1585
|
+
// alongside the bare parachute-vault. Both reachable via the hub
|
|
1586
|
+
// catchall; hub picks the backend per request.
|
|
1687
1587
|
const h = makeHarness();
|
|
1688
1588
|
try {
|
|
1689
1589
|
upsertService(
|
|
@@ -1724,48 +1624,7 @@ describe("expose vault mount consolidation (#144)", () => {
|
|
|
1724
1624
|
const mounts = calls
|
|
1725
1625
|
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1726
1626
|
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1727
|
-
|
|
1728
|
-
expect(vaultMounts).toEqual(["--set-path=/vault/"]);
|
|
1729
|
-
} finally {
|
|
1730
|
-
h.cleanup();
|
|
1731
|
-
}
|
|
1732
|
-
});
|
|
1733
|
-
|
|
1734
|
-
test("no vault installed → no /vault/ mount in the plan", async () => {
|
|
1735
|
-
const h = makeHarness();
|
|
1736
|
-
try {
|
|
1737
|
-
upsertService(
|
|
1738
|
-
{
|
|
1739
|
-
name: "parachute-notes",
|
|
1740
|
-
port: 5173,
|
|
1741
|
-
paths: ["/notes"],
|
|
1742
|
-
health: "/notes/health",
|
|
1743
|
-
version: "0.0.1",
|
|
1744
|
-
},
|
|
1745
|
-
h.manifestPath,
|
|
1746
|
-
);
|
|
1747
|
-
const { runner, calls } = makeRunner();
|
|
1748
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1749
|
-
const code = await exposeTailnet("up", {
|
|
1750
|
-
runner,
|
|
1751
|
-
manifestPath: h.manifestPath,
|
|
1752
|
-
statePath: h.statePath,
|
|
1753
|
-
wellKnownPath: h.wellKnownPath,
|
|
1754
|
-
hubPath: h.hubPath,
|
|
1755
|
-
wellKnownDir: h.wellKnownDir,
|
|
1756
|
-
configDir: h.configDir,
|
|
1757
|
-
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1758
|
-
servicePortProbe: allServicesUp,
|
|
1759
|
-
log: () => {},
|
|
1760
|
-
});
|
|
1761
|
-
expect(code).toBe(0);
|
|
1762
|
-
const mounts = calls
|
|
1763
|
-
.filter((c) => c[0] === "tailscale" && c[1] === "serve" && c.includes("--bg"))
|
|
1764
|
-
.map((c) => c.find((a) => a.startsWith("--set-path=")));
|
|
1765
|
-
expect(mounts).not.toContain("--set-path=/vault/");
|
|
1766
|
-
// /notes is still individually mounted — non-vault services keep
|
|
1767
|
-
// their direct route.
|
|
1768
|
-
expect(mounts).toContain("--set-path=/notes");
|
|
1627
|
+
expect(mounts).toEqual(["--set-path=/"]);
|
|
1769
1628
|
} finally {
|
|
1770
1629
|
h.cleanup();
|
|
1771
1630
|
}
|