@openparachute/hub 0.5.13 → 0.5.14-rc.10
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/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -40,40 +40,78 @@ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
describe("runAuthPreflight — wide open (no password
|
|
44
|
-
test("warns loudly
|
|
45
|
-
const h = makeHarness(["y", "n"
|
|
43
|
+
describe("runAuthPreflight — wide open (no owner password)", () => {
|
|
44
|
+
test("warns loudly, offers password + 2FA, and prints hub-JWT token guidance", async () => {
|
|
45
|
+
const h = makeHarness(["y", "n"]); // password yes, 2fa no
|
|
46
46
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
47
47
|
const joined = h.logs.join("\n");
|
|
48
|
-
expect(joined).toContain("No owner password
|
|
48
|
+
expect(joined).toContain("No owner password");
|
|
49
49
|
expect(joined).toContain("public internet");
|
|
50
|
+
// Programmatic-client guidance points at the hub mint path, not pvt_*.
|
|
51
|
+
expect(joined).toContain("parachute auth mint-token --scope vault:default:read");
|
|
52
|
+
expect(joined).toContain("Bearer <hub-jwt>");
|
|
53
|
+
// Only password + 2FA are interactive offers; token guidance is printed,
|
|
54
|
+
// not prompted (no auto-mint).
|
|
50
55
|
expect(h.commands).toHaveLength(1);
|
|
51
56
|
expect(h.commands[0]).toEqual(["parachute", "auth", "set-password"]);
|
|
52
57
|
});
|
|
53
58
|
|
|
59
|
+
test("token guidance uses the first discovered vault name", async () => {
|
|
60
|
+
const h = makeHarness(["n", "n"]);
|
|
61
|
+
await runAuthPreflight({ status: status({ vaultNames: ["work"] }), ...wire(h) });
|
|
62
|
+
expect(h.logs.join("\n")).toContain("--scope vault:work:read");
|
|
63
|
+
});
|
|
64
|
+
|
|
54
65
|
test("user declines every prompt → no subprocesses run", async () => {
|
|
55
|
-
const h = makeHarness(["", ""
|
|
66
|
+
const h = makeHarness(["", ""]); // all Enter = skip
|
|
56
67
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
57
68
|
expect(h.commands).toHaveLength(0);
|
|
58
|
-
//
|
|
59
|
-
expect(h.prompts).toHaveLength(
|
|
69
|
+
// Prompted on password + 2FA (token guidance is not a prompt).
|
|
70
|
+
expect(h.prompts).toHaveLength(2);
|
|
60
71
|
});
|
|
61
72
|
|
|
62
|
-
test("user accepts
|
|
63
|
-
const h = makeHarness(["y", "y"
|
|
73
|
+
test("user accepts password + 2FA → both commands invoked in order", async () => {
|
|
74
|
+
const h = makeHarness(["y", "y"]);
|
|
64
75
|
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
65
76
|
expect(h.commands.map((c) => c.join(" "))).toEqual([
|
|
66
77
|
"parachute auth set-password",
|
|
67
78
|
"parachute auth 2fa enroll",
|
|
68
|
-
"parachute vault tokens create",
|
|
69
79
|
]);
|
|
70
80
|
});
|
|
81
|
+
|
|
82
|
+
test("null tokenCount with no owner password still classifies wide-open", async () => {
|
|
83
|
+
// The `tokenCount: null` (unreadable vault DB) path is vestigial post-DROP
|
|
84
|
+
// — `classify()` gates on `hasOwnerPassword` alone. A box with no owner
|
|
85
|
+
// password AND an unreadable token DB must still take the loud wide-open
|
|
86
|
+
// branch, not silently fall through to a quieter state.
|
|
87
|
+
const h = makeHarness(["n", "n"]);
|
|
88
|
+
await runAuthPreflight({
|
|
89
|
+
status: status({ hasOwnerPassword: false, tokenCount: null }),
|
|
90
|
+
...wire(h),
|
|
91
|
+
});
|
|
92
|
+
const joined = h.logs.join("\n");
|
|
93
|
+
expect(joined).toContain("No owner password");
|
|
94
|
+
expect(joined).toContain("public internet");
|
|
95
|
+
// Wide-open offers password + 2FA (two prompts); not the all-good path.
|
|
96
|
+
expect(h.prompts).toHaveLength(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("never offers the removed `vault tokens create` command", async () => {
|
|
100
|
+
const h = makeHarness(["y", "y"]);
|
|
101
|
+
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
102
|
+
const allCommands = h.commands.map((c) => c.join(" ")).join("\n");
|
|
103
|
+
expect(allCommands).not.toContain("vault tokens create");
|
|
104
|
+
// And no log line steers the operator at the dead command as guidance.
|
|
105
|
+
const guidance = h.logs.filter((l) => !l.includes("old affordance")).join("\n");
|
|
106
|
+
expect(guidance).not.toContain("parachute vault tokens create");
|
|
107
|
+
});
|
|
71
108
|
});
|
|
72
109
|
|
|
73
110
|
describe("runAuthPreflight — password set, no 2FA", () => {
|
|
74
|
-
test("short nudge, offers 2FA only", async () => {
|
|
111
|
+
test("short nudge, offers 2FA only — ignores vestigial tokenCount", async () => {
|
|
75
112
|
const h = makeHarness(["y"]);
|
|
76
113
|
await runAuthPreflight({
|
|
114
|
+
// tokenCount is non-zero (vestigial pvt_* rows) but no longer consulted.
|
|
77
115
|
status: status({ hasOwnerPassword: true, tokenCount: 3 }),
|
|
78
116
|
...wire(h),
|
|
79
117
|
});
|
|
@@ -92,40 +130,8 @@ describe("runAuthPreflight — password set, no 2FA", () => {
|
|
|
92
130
|
});
|
|
93
131
|
expect(h.commands).toHaveLength(0);
|
|
94
132
|
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe("runAuthPreflight — tokens exist, no password", () => {
|
|
98
|
-
test("notes that OAuth is not set up, offers password", async () => {
|
|
99
|
-
const h = makeHarness(["y"]);
|
|
100
|
-
await runAuthPreflight({
|
|
101
|
-
status: status({ hasOwnerPassword: false, tokenCount: 2 }),
|
|
102
|
-
...wire(h),
|
|
103
|
-
});
|
|
104
|
-
const joined = h.logs.join("\n");
|
|
105
|
-
expect(joined).toContain("API tokens exist");
|
|
106
|
-
expect(joined).toContain("no owner password");
|
|
107
|
-
expect(h.prompts).toHaveLength(1);
|
|
108
|
-
expect(h.commands).toEqual([["parachute", "auth", "set-password"]]);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe("runAuthPreflight — unknown token count (SQLite failed)", () => {
|
|
113
|
-
test("advises running `tokens list`, no token-dependent prompts", async () => {
|
|
114
|
-
const h = makeHarness([]);
|
|
115
|
-
await runAuthPreflight({
|
|
116
|
-
status: status({ hasOwnerPassword: false, hasTotp: false, tokenCount: null }),
|
|
117
|
-
...wire(h),
|
|
118
|
-
});
|
|
119
|
-
const joined = h.logs.join("\n");
|
|
120
|
-
expect(joined).toContain("Couldn't read vault token state");
|
|
121
|
-
expect(joined).toContain("parachute vault tokens list");
|
|
122
|
-
// No prompts because we don't offer password/token flow when token
|
|
123
|
-
// state is unknown (it'd be ambiguous whether we're dealing with the
|
|
124
|
-
// wide-open or the tokens-only case).
|
|
125
|
-
expect(h.prompts).toHaveLength(0);
|
|
126
|
-
});
|
|
127
133
|
|
|
128
|
-
test("
|
|
134
|
+
test("null tokenCount (DB unreadable) is irrelevant — password gates the branch", async () => {
|
|
129
135
|
const h = makeHarness([""]); // decline 2FA
|
|
130
136
|
await runAuthPreflight({
|
|
131
137
|
status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: null }),
|
|
@@ -137,22 +143,24 @@ describe("runAuthPreflight — unknown token count (SQLite failed)", () => {
|
|
|
137
143
|
});
|
|
138
144
|
|
|
139
145
|
describe("runAuthPreflight — all good", () => {
|
|
140
|
-
test("single positive line, no prompts", async () => {
|
|
146
|
+
test("single positive line, no prompts (tokens not required)", async () => {
|
|
141
147
|
const h = makeHarness([]);
|
|
142
148
|
await runAuthPreflight({
|
|
143
|
-
|
|
149
|
+
// tokenCount: 0 — a hub JWT is minted on demand, not a standing need.
|
|
150
|
+
status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 0 }),
|
|
144
151
|
...wire(h),
|
|
145
152
|
});
|
|
146
153
|
const joined = h.logs.join("\n");
|
|
147
154
|
expect(joined).toContain("looks good");
|
|
155
|
+
expect(joined).toContain("owner password + 2FA");
|
|
148
156
|
expect(h.prompts).toHaveLength(0);
|
|
149
157
|
expect(h.commands).toHaveLength(0);
|
|
150
158
|
});
|
|
151
159
|
});
|
|
152
160
|
|
|
153
161
|
describe("runAuthPreflight — subprocess failure handling", () => {
|
|
154
|
-
test("non-zero exit from auth command doesn't abort the rest of the preflight", async () => {
|
|
155
|
-
const h = makeHarness(["y", "y"
|
|
162
|
+
test("non-zero exit from an auth command doesn't abort the rest of the preflight", async () => {
|
|
163
|
+
const h = makeHarness(["y", "y"]);
|
|
156
164
|
// Override the interactive runner to return non-zero on the first call.
|
|
157
165
|
let first = true;
|
|
158
166
|
const interactiveRunner = async (cmd: readonly string[]) => {
|
|
@@ -172,8 +180,8 @@ describe("runAuthPreflight — subprocess failure handling", () => {
|
|
|
172
180
|
},
|
|
173
181
|
interactiveRunner,
|
|
174
182
|
});
|
|
175
|
-
//
|
|
176
|
-
expect(h.commands.map((c) => c[0])).toEqual(["parachute", "parachute"
|
|
183
|
+
// Both commands still attempted, neither aborted the flow.
|
|
184
|
+
expect(h.commands.map((c) => c[0])).toEqual(["parachute", "parachute"]);
|
|
177
185
|
const joined = h.logs.join("\n");
|
|
178
186
|
expect(joined).toContain("exited 7");
|
|
179
187
|
});
|
|
@@ -14,8 +14,13 @@ import {
|
|
|
14
14
|
exposeCloudflareOff,
|
|
15
15
|
exposeCloudflareUp,
|
|
16
16
|
} from "../commands/expose-cloudflare.ts";
|
|
17
|
+
import { writeHubPort } from "../hub-control.ts";
|
|
17
18
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
18
19
|
|
|
20
|
+
// Default seeded hub port used by tests with `skipHub: true`. The cloudflared
|
|
21
|
+
// path reads `<configDir>/hub/run/hub.port` instead of spawning a real hub.
|
|
22
|
+
const TEST_HUB_PORT = 1939;
|
|
23
|
+
|
|
19
24
|
interface TestEnv {
|
|
20
25
|
configDir: string;
|
|
21
26
|
manifestPath: string;
|
|
@@ -41,6 +46,11 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
|
|
|
41
46
|
require("node:fs").mkdirSync(configDir, { recursive: true });
|
|
42
47
|
require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
|
|
43
48
|
|
|
49
|
+
// Seed the hub port so `skipHub: true` invocations can resolve a port
|
|
50
|
+
// without spawning the actual hub process. Matches the seam pattern used
|
|
51
|
+
// by expose.test.ts (which threads `hubEnsureOpts` for the same purpose).
|
|
52
|
+
writeHubPort(TEST_HUB_PORT, configDir);
|
|
53
|
+
|
|
44
54
|
if (loggedIn) {
|
|
45
55
|
writeFileSync(join(cloudflaredHome, "cert.pem"), "---");
|
|
46
56
|
}
|
|
@@ -128,6 +138,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
128
138
|
configPath: env.configPath,
|
|
129
139
|
logPath: env.logPath,
|
|
130
140
|
cloudflaredHome: env.cloudflaredHome,
|
|
141
|
+
configDir: env.configDir,
|
|
142
|
+
skipHub: true,
|
|
131
143
|
now: () => new Date("2026-04-22T12:00:00Z"),
|
|
132
144
|
});
|
|
133
145
|
|
|
@@ -170,12 +182,22 @@ describe("exposeCloudflareUp", () => {
|
|
|
170
182
|
const yaml = readFileSync(env.configPath, "utf8");
|
|
171
183
|
expect(yaml).toContain(`tunnel: ${uuid}`);
|
|
172
184
|
expect(yaml).toContain("- hostname: vault.example.com");
|
|
173
|
-
|
|
185
|
+
// Routes through the hub (not directly at vault). The hub dispatches
|
|
186
|
+
// discovery / admin / OAuth / per-vault proxy / generic /<svc>/* —
|
|
187
|
+
// same shape Tailscale Funnel uses. Pre-2026-05-27 this was
|
|
188
|
+
// http://localhost:1940 (vault's port), which served vault's own 404
|
|
189
|
+
// page on every request that wasn't /vault/<name>/...
|
|
190
|
+
expect(yaml).toContain(`service: http://localhost:${TEST_HUB_PORT}`);
|
|
174
191
|
|
|
175
192
|
// Security copy surfaces both paths plus a pointer to the auth doc.
|
|
176
193
|
const joined = logs.join("\n");
|
|
177
194
|
expect(joined).toContain("parachute auth set-password");
|
|
178
|
-
|
|
195
|
+
// Scripts/machines path points at the hub-JWT mint (vault#412 / hub#466
|
|
196
|
+
// DROPped `vault tokens create`), not the removed pvt_* command.
|
|
197
|
+
expect(joined).toContain("parachute auth mint-token --scope vault:");
|
|
198
|
+
expect(joined).toContain("Bearer <hub-jwt>");
|
|
199
|
+
expect(joined).not.toContain("vault tokens create");
|
|
200
|
+
expect(joined).not.toContain("pvt_");
|
|
179
201
|
expect(joined).toContain("auth-model.md");
|
|
180
202
|
} finally {
|
|
181
203
|
env.cleanup();
|
|
@@ -209,6 +231,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
209
231
|
configPath: env.configPath,
|
|
210
232
|
logPath: env.logPath,
|
|
211
233
|
cloudflaredHome: env.cloudflaredHome,
|
|
234
|
+
configDir: env.configDir,
|
|
235
|
+
skipHub: true,
|
|
212
236
|
});
|
|
213
237
|
expect(code).toBe(0);
|
|
214
238
|
// No `tunnel create` — only list + route.
|
|
@@ -236,6 +260,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
236
260
|
configPath: env.configPath,
|
|
237
261
|
logPath: env.logPath,
|
|
238
262
|
cloudflaredHome: env.cloudflaredHome,
|
|
263
|
+
configDir: env.configDir,
|
|
264
|
+
skipHub: true,
|
|
239
265
|
});
|
|
240
266
|
|
|
241
267
|
expect(code).toBe(1);
|
|
@@ -262,6 +288,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
262
288
|
configPath: env.configPath,
|
|
263
289
|
logPath: env.logPath,
|
|
264
290
|
cloudflaredHome: env.cloudflaredHome,
|
|
291
|
+
configDir: env.configDir,
|
|
292
|
+
skipHub: true,
|
|
265
293
|
tunnelName: "bad name with spaces",
|
|
266
294
|
});
|
|
267
295
|
|
|
@@ -291,6 +319,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
291
319
|
configPath: env.configPath,
|
|
292
320
|
logPath: env.logPath,
|
|
293
321
|
cloudflaredHome: env.cloudflaredHome,
|
|
322
|
+
configDir: env.configDir,
|
|
323
|
+
skipHub: true,
|
|
294
324
|
});
|
|
295
325
|
|
|
296
326
|
expect(code).toBe(1);
|
|
@@ -317,6 +347,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
317
347
|
configPath: env.configPath,
|
|
318
348
|
logPath: env.logPath,
|
|
319
349
|
cloudflaredHome: env.cloudflaredHome,
|
|
350
|
+
configDir: env.configDir,
|
|
351
|
+
skipHub: true,
|
|
320
352
|
});
|
|
321
353
|
|
|
322
354
|
expect(code).toBe(1);
|
|
@@ -342,6 +374,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
342
374
|
configPath: env.configPath,
|
|
343
375
|
logPath: env.logPath,
|
|
344
376
|
cloudflaredHome: env.cloudflaredHome,
|
|
377
|
+
configDir: env.configDir,
|
|
378
|
+
skipHub: true,
|
|
345
379
|
});
|
|
346
380
|
|
|
347
381
|
expect(code).toBe(1);
|
|
@@ -376,6 +410,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
376
410
|
configPath: env.configPath,
|
|
377
411
|
logPath: env.logPath,
|
|
378
412
|
cloudflaredHome: env.cloudflaredHome,
|
|
413
|
+
configDir: env.configDir,
|
|
414
|
+
skipHub: true,
|
|
379
415
|
});
|
|
380
416
|
|
|
381
417
|
expect(code).toBe(1);
|
|
@@ -425,6 +461,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
425
461
|
configPath: env.configPath,
|
|
426
462
|
logPath: env.logPath,
|
|
427
463
|
cloudflaredHome: env.cloudflaredHome,
|
|
464
|
+
configDir: env.configDir,
|
|
465
|
+
skipHub: true,
|
|
428
466
|
});
|
|
429
467
|
|
|
430
468
|
expect(code).toBe(0);
|
|
@@ -462,7 +500,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
462
500
|
manifestPath: env.manifestPath,
|
|
463
501
|
statePath: env.statePath,
|
|
464
502
|
cloudflaredHome: env.cloudflaredHome,
|
|
465
|
-
|
|
503
|
+
configDir: env.configDir,
|
|
504
|
+
skipHub: true,
|
|
505
|
+
// Omit configPath/logPath so they're per-tunnel-derived — but the
|
|
506
|
+
// derivation now resolves against the tmp `configDir` above, so the
|
|
507
|
+
// generated config.yml lands under tmp, not the operator's real
|
|
508
|
+
// ~/.parachute/cloudflared/parachute/.
|
|
466
509
|
});
|
|
467
510
|
expect(code1).toBe(0);
|
|
468
511
|
|
|
@@ -487,6 +530,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
487
530
|
manifestPath: env.manifestPath,
|
|
488
531
|
statePath: env.statePath,
|
|
489
532
|
cloudflaredHome: env.cloudflaredHome,
|
|
533
|
+
configDir: env.configDir,
|
|
534
|
+
skipHub: true,
|
|
490
535
|
tunnelName: "second",
|
|
491
536
|
});
|
|
492
537
|
expect(code2).toBe(0);
|
|
@@ -544,6 +589,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
544
589
|
configPath: env.configPath,
|
|
545
590
|
logPath: env.logPath,
|
|
546
591
|
cloudflaredHome: env.cloudflaredHome,
|
|
592
|
+
configDir: env.configDir,
|
|
593
|
+
skipHub: true,
|
|
547
594
|
// No password, no 2FA — fully wide open. The warning should still
|
|
548
595
|
// fire; password-recovery copy already lives in `printAuthGuidance`.
|
|
549
596
|
vaultAuthStatus: {
|
|
@@ -592,6 +639,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
592
639
|
configPath: env.configPath,
|
|
593
640
|
logPath: env.logPath,
|
|
594
641
|
cloudflaredHome: env.cloudflaredHome,
|
|
642
|
+
configDir: env.configDir,
|
|
643
|
+
skipHub: true,
|
|
595
644
|
vaultAuthStatus: {
|
|
596
645
|
hasOwnerPassword: true,
|
|
597
646
|
hasTotp: true,
|
|
@@ -612,6 +661,68 @@ describe("exposeCloudflareUp", () => {
|
|
|
612
661
|
}
|
|
613
662
|
});
|
|
614
663
|
});
|
|
664
|
+
|
|
665
|
+
describe("routes through hub, not vault", () => {
|
|
666
|
+
test("config.yml targets the hub port; success log mentions Admin + OAuth URLs", async () => {
|
|
667
|
+
// Regression guard for the 2026-05-27 cut. Aaron ran `parachute expose
|
|
668
|
+
// public` on a fresh EC2 box, configured Cloudflare with a custom
|
|
669
|
+
// domain, and hit it — and got vault's 404 page rather than the hub's
|
|
670
|
+
// discovery / admin. The pre-fix cloudflared config routed straight at
|
|
671
|
+
// vault's port; the fix routes at the hub, mirroring the Tailscale
|
|
672
|
+
// Funnel shape (single mount → hub catchall; hub dispatches per-request).
|
|
673
|
+
const env = makeEnv();
|
|
674
|
+
try {
|
|
675
|
+
// Re-seed hub port to a non-default value so the assertion is
|
|
676
|
+
// unambiguous about *which* port got into the yaml.
|
|
677
|
+
writeHubPort(1949, env.configDir);
|
|
678
|
+
|
|
679
|
+
const uuid = "ffff0000-0000-0000-0000-00000000beef";
|
|
680
|
+
const { runner } = queueRunner([
|
|
681
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
682
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
683
|
+
{
|
|
684
|
+
code: 0,
|
|
685
|
+
stdout: `Created tunnel parachute with id ${uuid}\n`,
|
|
686
|
+
stderr: "",
|
|
687
|
+
},
|
|
688
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
689
|
+
]);
|
|
690
|
+
const { spawner } = fakeSpawner(60001);
|
|
691
|
+
const logs: string[] = [];
|
|
692
|
+
|
|
693
|
+
const code = await exposeCloudflareUp("gitcoin.parachute.computer", {
|
|
694
|
+
runner,
|
|
695
|
+
spawner,
|
|
696
|
+
alive: () => false,
|
|
697
|
+
kill: () => {},
|
|
698
|
+
log: (l) => logs.push(l),
|
|
699
|
+
manifestPath: env.manifestPath,
|
|
700
|
+
statePath: env.statePath,
|
|
701
|
+
configPath: env.configPath,
|
|
702
|
+
logPath: env.logPath,
|
|
703
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
704
|
+
configDir: env.configDir,
|
|
705
|
+
skipHub: true,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
expect(code).toBe(0);
|
|
709
|
+
const yaml = readFileSync(env.configPath, "utf8");
|
|
710
|
+
// Routes through the hub on its loopback port.
|
|
711
|
+
expect(yaml).toContain("service: http://localhost:1949");
|
|
712
|
+
// Does NOT route directly at vault's port (1940 per makeEnv default).
|
|
713
|
+
expect(yaml).not.toContain("service: http://localhost:1940");
|
|
714
|
+
|
|
715
|
+
const joined = logs.join("\n");
|
|
716
|
+
// Discoverable surfaces: open / admin / vault / OAuth all surfaced.
|
|
717
|
+
expect(joined).toContain("https://gitcoin.parachute.computer/");
|
|
718
|
+
expect(joined).toContain("Admin: https://gitcoin.parachute.computer/admin/");
|
|
719
|
+
expect(joined).toContain("Vault: https://gitcoin.parachute.computer/vault/default");
|
|
720
|
+
expect(joined).toContain("OAuth: https://gitcoin.parachute.computer");
|
|
721
|
+
} finally {
|
|
722
|
+
env.cleanup();
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
});
|
|
615
726
|
});
|
|
616
727
|
|
|
617
728
|
describe("exposeCloudflareOff", () => {
|
|
@@ -469,10 +469,16 @@ describe("exposePublicInteractive — neither ready", () => {
|
|
|
469
469
|
expect(interactiveCalled).toBe(false);
|
|
470
470
|
expect(cloudflareCalled).toBe(false);
|
|
471
471
|
const joined = logs.join("\n");
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
)
|
|
472
|
+
// Post 2026-05-27 cloudflared-URL refresh: the install hint moved
|
|
473
|
+
// off apt-get / dnf / developers.cloudflare.com (all unreliable —
|
|
474
|
+
// Aaron hit `No match for argument: cloudflared` on AL2023 and
|
|
475
|
+
// 404s from the docs URL on the same box) onto the static binary
|
|
476
|
+
// from GitHub releases.
|
|
477
|
+
expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
|
|
478
|
+
expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
|
|
479
|
+
expect(joined).not.toContain("developers.cloudflare.com");
|
|
480
|
+
expect(joined).not.toContain("pkg.cloudflare.com");
|
|
481
|
+
expect(joined).not.toContain("sudo dnf install cloudflared");
|
|
476
482
|
} finally {
|
|
477
483
|
env.cleanup();
|
|
478
484
|
}
|
|
@@ -126,7 +126,11 @@ describe("exposePublicAutoPick — neither ready", () => {
|
|
|
126
126
|
expect(code).toBe(1);
|
|
127
127
|
const joined = logs.join("\n");
|
|
128
128
|
expect(joined).toContain("tailscale.com/download");
|
|
129
|
-
|
|
129
|
+
// Post 2026-05-27 cloudflared-URL refresh: the install hint now points
|
|
130
|
+
// at GitHub releases (developers.cloudflare.com / pkg.cloudflare.com
|
|
131
|
+
// both returned HTML/404 on Aaron's fresh AL2023 EC2 box).
|
|
132
|
+
expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
|
|
133
|
+
expect(joined).not.toContain("developers.cloudflare.com");
|
|
130
134
|
expect(joined).toContain("--skip-provider-check");
|
|
131
135
|
});
|
|
132
136
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { exposePublic, exposeTailnet } from "../commands/expose.ts";
|
|
6
|
+
import { readEnvFileValues } from "../env-file.ts";
|
|
6
7
|
import { readExposeState, writeExposeState } from "../expose-state.ts";
|
|
7
8
|
import type { EnsureHubOpts, HubSpawner, StopHubOpts } from "../hub-control.ts";
|
|
8
9
|
import { writePid } from "../process-state.ts";
|
|
@@ -696,6 +697,53 @@ describe("expose tailnet off", () => {
|
|
|
696
697
|
}
|
|
697
698
|
});
|
|
698
699
|
|
|
700
|
+
test("clears the persisted PARACHUTE_HUB_ORIGIN from vault/.env on teardown", async () => {
|
|
701
|
+
// OAuth issuer-mismatch fix: `expose up` persisted the public origin into
|
|
702
|
+
// vault/.env so the daemon validates `iss` against it. With exposure gone,
|
|
703
|
+
// a local-only hub mints loopback-`iss` tokens, so a stale public origin
|
|
704
|
+
// left in `.env` would itself cause the mismatch on the next daemon boot.
|
|
705
|
+
// Tearing it down reverts vault to its loopback default.
|
|
706
|
+
const h = makeHarness();
|
|
707
|
+
try {
|
|
708
|
+
writeExposeState(
|
|
709
|
+
{
|
|
710
|
+
version: 1,
|
|
711
|
+
layer: "tailnet",
|
|
712
|
+
mode: "path",
|
|
713
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
714
|
+
port: 443,
|
|
715
|
+
funnel: false,
|
|
716
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://127.0.0.1:1939", service: "hub" }],
|
|
717
|
+
hubOrigin: "https://parachute.taildf9ce2.ts.net",
|
|
718
|
+
},
|
|
719
|
+
h.statePath,
|
|
720
|
+
);
|
|
721
|
+
mkdirSync(join(h.configDir, "vault"), { recursive: true });
|
|
722
|
+
writeFileSync(
|
|
723
|
+
join(h.configDir, "vault", ".env"),
|
|
724
|
+
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://parachute.taildf9ce2.ts.net\n",
|
|
725
|
+
);
|
|
726
|
+
const { runner } = makeRunner();
|
|
727
|
+
const code = await exposeTailnet("off", {
|
|
728
|
+
runner,
|
|
729
|
+
statePath: h.statePath,
|
|
730
|
+
wellKnownPath: h.wellKnownPath,
|
|
731
|
+
hubPath: h.hubPath,
|
|
732
|
+
wellKnownDir: h.wellKnownDir,
|
|
733
|
+
configDir: h.configDir,
|
|
734
|
+
skipHub: true,
|
|
735
|
+
log: () => {},
|
|
736
|
+
});
|
|
737
|
+
expect(code).toBe(0);
|
|
738
|
+
const values = readEnvFileValues(join(h.configDir, "vault", ".env"));
|
|
739
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
740
|
+
// Sibling keys preserved.
|
|
741
|
+
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
742
|
+
} finally {
|
|
743
|
+
h.cleanup();
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
699
747
|
test("leaves state in place on teardown failure", async () => {
|
|
700
748
|
const h = makeHarness();
|
|
701
749
|
try {
|