@openparachute/hub 0.5.13-rc.23 → 0.5.13-rc.34
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 +7 -4
- package/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +38 -0
- package/src/__tests__/hub-origin-resolution.test.ts +50 -0
- package/src/__tests__/hub-server.test.ts +182 -3
- package/src/__tests__/lifecycle.test.ts +12 -1
- package/src/__tests__/serve-boot.test.ts +21 -0
- package/src/__tests__/serve.test.ts +9 -0
- package/src/__tests__/setup-wizard.test.ts +10 -3
- package/src/__tests__/spawn-env-propagation.test.ts +78 -0
- package/src/admin-vaults.ts +6 -1
- package/src/api-modules-ops.ts +21 -2
- package/src/commands/auth.ts +7 -1
- package/src/commands/expose-auth-preflight.ts +6 -1
- package/src/commands/expose-cloudflare.ts +6 -1
- package/src/commands/expose-interactive.ts +7 -1
- package/src/commands/install.ts +7 -1
- package/src/commands/lifecycle.ts +17 -2
- package/src/commands/serve-boot.ts +8 -4
- package/src/commands/serve.ts +18 -8
- package/src/commands/upgrade.ts +5 -0
- package/src/commands/vault-tokens-create-interactive.ts +7 -1
- package/src/commands/vault.ts +3 -0
- package/src/hub-control.ts +6 -1
- package/src/hub-server.ts +43 -1
- package/src/supervisor.ts +4 -0
- package/src/tailscale/run.ts +7 -1
package/README.md
CHANGED
|
@@ -24,11 +24,14 @@ One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions
|
|
|
24
24
|
|
|
25
25
|
**Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
|
|
26
26
|
|
|
27
|
-
After deploy:
|
|
27
|
+
After deploy completes:
|
|
28
28
|
|
|
29
|
-
1. Open
|
|
30
|
-
2.
|
|
31
|
-
3.
|
|
29
|
+
1. Open Render Logs → search for `parachute-bootstrap-` to find your one-time admin setup token (printed in a prominent banner on first boot).
|
|
30
|
+
2. Visit your Render service URL's `/admin/setup` → paste the token → create your admin account.
|
|
31
|
+
3. Set custom domain (optional) → set `PARACHUTE_HUB_ORIGIN` env to match.
|
|
32
|
+
4. Install modules via the admin SPA at `/admin/modules` (or via the wizard).
|
|
33
|
+
|
|
34
|
+
Operators who want env-var-driven seeding (CI, scripted deploys) can still set `PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD` manually in the Render dashboard — hub honors them when present.
|
|
32
35
|
|
|
33
36
|
Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
|
|
34
37
|
|
package/package.json
CHANGED
|
@@ -1005,6 +1005,44 @@ describe("well-known regen after module ops", () => {
|
|
|
1005
1005
|
expect(doc.vaults.some((v) => v.name === "default")).toBe(true);
|
|
1006
1006
|
});
|
|
1007
1007
|
|
|
1008
|
+
test("runInstall sets PORT in child env from services.json entry (hub#356)", async () => {
|
|
1009
|
+
// Container deploys (Render / etc.) set PORT in hub's env via the
|
|
1010
|
+
// Dockerfile / platform. Without explicit override at spawn time, every
|
|
1011
|
+
// supervised child inherits hub's PORT via `env: process.env` and tries
|
|
1012
|
+
// to bind hub's own port — EADDRINUSE → crashloop → supervisor gives up.
|
|
1013
|
+
// Regression guard: the spawn captures the child's services.json port.
|
|
1014
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
1015
|
+
const { run } = alwaysOkRun();
|
|
1016
|
+
const wkPath = join(h.dir, "well-known.json");
|
|
1017
|
+
const install = fakeInstall("@openparachute/vault", {
|
|
1018
|
+
name: "vault",
|
|
1019
|
+
manifestName: "parachute-vault",
|
|
1020
|
+
port: 1940,
|
|
1021
|
+
paths: ["/vault/default"],
|
|
1022
|
+
health: "/vault/default/health",
|
|
1023
|
+
});
|
|
1024
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1025
|
+
const deps = {
|
|
1026
|
+
db: h.db,
|
|
1027
|
+
issuer: ISSUER,
|
|
1028
|
+
manifestPath: h.manifestPath,
|
|
1029
|
+
configDir: h.dir,
|
|
1030
|
+
supervisor,
|
|
1031
|
+
run,
|
|
1032
|
+
findGlobalInstall: install.findGlobalInstall,
|
|
1033
|
+
readModuleManifest: install.readModuleManifest,
|
|
1034
|
+
wellKnownPath: wkPath,
|
|
1035
|
+
};
|
|
1036
|
+
await handleInstall(
|
|
1037
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
1038
|
+
"vault",
|
|
1039
|
+
deps,
|
|
1040
|
+
);
|
|
1041
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1042
|
+
expect(spawns.length).toBe(1);
|
|
1043
|
+
expect(spawns[0]?.env?.PORT).toBe("1940");
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1008
1046
|
test("runInstall failure: bun add fails -> no well-known regen (no partial state)", async () => {
|
|
1009
1047
|
const { supervisor } = makeIdleSupervisor();
|
|
1010
1048
|
const wkPath = join(h.dir, "well-known.json");
|
|
@@ -117,6 +117,56 @@ describe("resolveIssuer — precedence chain", () => {
|
|
|
117
117
|
// Pass 3 — back to request origin.
|
|
118
118
|
expect(resolveIssuer(req(baseUrl), db, undefined)).toBe("http://127.0.0.1:1939");
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
test("X-Forwarded-Proto: https upgrades the request-origin fallback", () => {
|
|
122
|
+
// Render / Tailscale Funnel / cloudflared terminate TLS at the edge
|
|
123
|
+
// and forward plain HTTP. Without honoring the header, hub publishes
|
|
124
|
+
// `http://...` in OAuth discovery — mixed-content blocked when the
|
|
125
|
+
// page loaded over https://. See hub#355 (the notes app's
|
|
126
|
+
// /oauth/register call surfaced this).
|
|
127
|
+
const r = new Request("http://parachute-hub.onrender.com/.well-known/oauth-authorization-server", {
|
|
128
|
+
method: "GET",
|
|
129
|
+
headers: { "X-Forwarded-Proto": "https" },
|
|
130
|
+
});
|
|
131
|
+
expect(resolveIssuer(r, db, undefined)).toBe("https://parachute-hub.onrender.com");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("X-Forwarded-Proto with comma-separated values takes the first", () => {
|
|
135
|
+
// Multi-hop proxies append; the leftmost is the original client → edge
|
|
136
|
+
// hop. RFC-style parsing (consistent with isHttpsRequest).
|
|
137
|
+
const r = new Request("http://hub.internal/oauth/token", {
|
|
138
|
+
method: "GET",
|
|
139
|
+
headers: { "X-Forwarded-Proto": "https, http" },
|
|
140
|
+
});
|
|
141
|
+
expect(resolveIssuer(r, db, undefined)).toBe("https://hub.internal");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("missing X-Forwarded-Proto leaves the URL scheme as-is (localhost dev)", () => {
|
|
145
|
+
// No reverse proxy → no header → keep http for the local-dev shape.
|
|
146
|
+
// Operators on plain HTTP localhost depend on this.
|
|
147
|
+
const r = new Request("http://127.0.0.1:1939/oauth/token", { method: "GET" });
|
|
148
|
+
expect(resolveIssuer(r, db, undefined)).toBe("http://127.0.0.1:1939");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("X-Forwarded-Proto is IGNORED when hub_settings or env wins", () => {
|
|
152
|
+
// Precedence guard: X-Forwarded-Proto should only affect the
|
|
153
|
+
// request-origin fallback branch. Explicit operator config
|
|
154
|
+
// (settings row, env var) always wins as-is, including its scheme.
|
|
155
|
+
// Without this guard, a future refactor could accidentally let the
|
|
156
|
+
// header override an operator's deliberate choice.
|
|
157
|
+
const r = new Request("http://hub.internal/oauth/token", {
|
|
158
|
+
method: "GET",
|
|
159
|
+
headers: { "X-Forwarded-Proto": "https" },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Env layer wins, even though the header says https — the env value
|
|
163
|
+
// is returned verbatim (preserving whatever scheme the operator set).
|
|
164
|
+
expect(resolveIssuer(r, db, "http://configured.example")).toBe("http://configured.example");
|
|
165
|
+
|
|
166
|
+
// Settings layer wins above env, also verbatim.
|
|
167
|
+
setHubOrigin(db, "http://settings.example");
|
|
168
|
+
expect(resolveIssuer(r, db, "https://env.example")).toBe("http://settings.example");
|
|
169
|
+
});
|
|
120
170
|
});
|
|
121
171
|
|
|
122
172
|
describe("resolveIssuerSource — attribution for SPA", () => {
|
|
@@ -80,13 +80,24 @@ describe("hubFetch routing", () => {
|
|
|
80
80
|
// hub#259 rc.6: requires an admin row to bypass the fresh-hub
|
|
81
81
|
// funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
|
|
82
82
|
// test continues to exercise the signed-out-but-setup-done branch.
|
|
83
|
+
//
|
|
84
|
+
// Hub also funnels to /admin/setup when no vault is installed
|
|
85
|
+
// (env-seed case). Pass an explicit manifestPath + seed a vault row
|
|
86
|
+
// so the funnel sees an installed vault and lets the discovery page
|
|
87
|
+
// render. Without this, the manifestPath default falls back to
|
|
88
|
+
// `~/.parachute/services.json` — which works on a dev box with an
|
|
89
|
+
// installed vault but fails in CI with a 302 wizard redirect.
|
|
83
90
|
const h = makeHarness();
|
|
84
91
|
try {
|
|
92
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
85
93
|
const db = openHubDb(hubDbPath(h.dir));
|
|
86
94
|
try {
|
|
87
95
|
const { createUser } = await import("../users.ts");
|
|
88
96
|
await createUser(db, "owner", "pw");
|
|
89
|
-
const res = await hubFetch(h.dir, {
|
|
97
|
+
const res = await hubFetch(h.dir, {
|
|
98
|
+
getDb: () => db,
|
|
99
|
+
manifestPath: h.manifestPath,
|
|
100
|
+
})(req("/"));
|
|
90
101
|
expect(res.status).toBe(200);
|
|
91
102
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
92
103
|
const body = await res.text();
|
|
@@ -102,8 +113,12 @@ describe("hubFetch routing", () => {
|
|
|
102
113
|
});
|
|
103
114
|
|
|
104
115
|
test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
|
|
116
|
+
// Same wizard-funnel bypass as the signed-out test above — seed a
|
|
117
|
+
// vault row and pass an explicit manifestPath so CI doesn't fall back
|
|
118
|
+
// to ~/.parachute/services.json.
|
|
105
119
|
const h = makeHarness();
|
|
106
120
|
try {
|
|
121
|
+
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
107
122
|
const db = openHubDb(hubDbPath(h.dir));
|
|
108
123
|
try {
|
|
109
124
|
const { createUser } = await import("../users.ts");
|
|
@@ -113,7 +128,10 @@ describe("hubFetch routing", () => {
|
|
|
113
128
|
const user = await createUser(db, "aaron", "pw");
|
|
114
129
|
const session = createSession(db, { userId: user.id });
|
|
115
130
|
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
116
|
-
const res = await hubFetch(h.dir, {
|
|
131
|
+
const res = await hubFetch(h.dir, {
|
|
132
|
+
getDb: () => db,
|
|
133
|
+
manifestPath: h.manifestPath,
|
|
134
|
+
})(req("/", { headers: { cookie } }));
|
|
117
135
|
expect(res.status).toBe(200);
|
|
118
136
|
const body = await res.text();
|
|
119
137
|
expect(body).toContain("Signed in as");
|
|
@@ -1390,7 +1408,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1390
1408
|
fetch: async (req) => {
|
|
1391
1409
|
const u = new URL(req.url);
|
|
1392
1410
|
// Echo enough metadata for tests to verify path + method + body
|
|
1393
|
-
// arrive intact end-to-end.
|
|
1411
|
+
// arrive intact end-to-end. Also echo X-Forwarded-* headers so
|
|
1412
|
+
// tests can assert hub forwards proxy hints (hub#358).
|
|
1394
1413
|
const body = req.body ? await req.text() : "";
|
|
1395
1414
|
return new Response(
|
|
1396
1415
|
JSON.stringify({
|
|
@@ -1399,6 +1418,8 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1399
1418
|
pathname: u.pathname,
|
|
1400
1419
|
search: u.search,
|
|
1401
1420
|
body,
|
|
1421
|
+
forwardedHost: req.headers.get("x-forwarded-host"),
|
|
1422
|
+
forwardedProto: req.headers.get("x-forwarded-proto"),
|
|
1402
1423
|
}),
|
|
1403
1424
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1404
1425
|
);
|
|
@@ -1409,6 +1430,164 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1409
1430
|
return { port: server.port as number, stop: () => server.stop(true) };
|
|
1410
1431
|
}
|
|
1411
1432
|
|
|
1433
|
+
test("forwards X-Forwarded-Host + X-Forwarded-Proto to upstream (hub#358)", async () => {
|
|
1434
|
+
// Container deploys: supervised modules (vault, scribe, app) reverse-
|
|
1435
|
+
// proxy through hub. They need the public origin to construct OAuth
|
|
1436
|
+
// discovery metadata and redirect URIs. Without forwarded headers,
|
|
1437
|
+
// they fall back to their internal loopback URL — breaking OAuth
|
|
1438
|
+
// flows for clients that landed on a hub-proxied URL.
|
|
1439
|
+
const h = makeHarness();
|
|
1440
|
+
const upstream = startUpstream("hdr-test");
|
|
1441
|
+
try {
|
|
1442
|
+
writeManifest(
|
|
1443
|
+
{
|
|
1444
|
+
services: [
|
|
1445
|
+
{
|
|
1446
|
+
name: "parachute-vault",
|
|
1447
|
+
port: upstream.port,
|
|
1448
|
+
paths: ["/vault/default"],
|
|
1449
|
+
health: "/vault/default/health",
|
|
1450
|
+
version: "0.4.0",
|
|
1451
|
+
},
|
|
1452
|
+
],
|
|
1453
|
+
},
|
|
1454
|
+
h.manifestPath,
|
|
1455
|
+
);
|
|
1456
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1457
|
+
// Simulate Render-shape: page came in over HTTPS at parachute-hub.onrender.com
|
|
1458
|
+
// Render terminates TLS and sets X-Forwarded-Proto: https; the Host
|
|
1459
|
+
// header is preserved as the public hostname.
|
|
1460
|
+
const incoming = new Request("http://parachute-hub.onrender.com/vault/default/.well-known/oauth-authorization-server", {
|
|
1461
|
+
headers: {
|
|
1462
|
+
host: "parachute-hub.onrender.com",
|
|
1463
|
+
"x-forwarded-proto": "https",
|
|
1464
|
+
},
|
|
1465
|
+
});
|
|
1466
|
+
const res = await fetcher(incoming);
|
|
1467
|
+
expect(res.status).toBe(200);
|
|
1468
|
+
const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
|
|
1469
|
+
// Hub captured the public Host and forwarded it as X-Forwarded-Host.
|
|
1470
|
+
expect(body.forwardedHost).toBe("parachute-hub.onrender.com");
|
|
1471
|
+
// X-Forwarded-Proto preserved (edge already set it).
|
|
1472
|
+
expect(body.forwardedProto).toBe("https");
|
|
1473
|
+
} finally {
|
|
1474
|
+
upstream.stop();
|
|
1475
|
+
h.cleanup();
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
test("synthesizes X-Forwarded-Proto when edge didn't set it (direct HTTPS to hub)", async () => {
|
|
1480
|
+
// Non-Render shape: hub bound directly to https (e.g. local TLS or a
|
|
1481
|
+
// proxy that doesn't set X-Forwarded-Proto). isHttpsRequest sees the
|
|
1482
|
+
// URL's https scheme; proxyRequest synthesizes X-Forwarded-Proto.
|
|
1483
|
+
const h = makeHarness();
|
|
1484
|
+
const upstream = startUpstream("hdr-test");
|
|
1485
|
+
try {
|
|
1486
|
+
writeManifest(
|
|
1487
|
+
{
|
|
1488
|
+
services: [
|
|
1489
|
+
{
|
|
1490
|
+
name: "parachute-vault",
|
|
1491
|
+
port: upstream.port,
|
|
1492
|
+
paths: ["/vault/default"],
|
|
1493
|
+
health: "/vault/default/health",
|
|
1494
|
+
version: "0.4.0",
|
|
1495
|
+
},
|
|
1496
|
+
],
|
|
1497
|
+
},
|
|
1498
|
+
h.manifestPath,
|
|
1499
|
+
);
|
|
1500
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1501
|
+
const incoming = new Request("https://hub.example.com/vault/default/health", {
|
|
1502
|
+
headers: { host: "hub.example.com" },
|
|
1503
|
+
});
|
|
1504
|
+
const res = await fetcher(incoming);
|
|
1505
|
+
const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
|
|
1506
|
+
expect(body.forwardedHost).toBe("hub.example.com");
|
|
1507
|
+
expect(body.forwardedProto).toBe("https");
|
|
1508
|
+
} finally {
|
|
1509
|
+
upstream.stop();
|
|
1510
|
+
h.cleanup();
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
test("synthesizes X-Forwarded-Proto=http when neither edge nor URL signal HTTPS", async () => {
|
|
1515
|
+
// Plain-HTTP path: no edge, no https URL scheme. isHttpsRequest returns
|
|
1516
|
+
// false; proxyRequest synthesizes "http". This is local-dev shape.
|
|
1517
|
+
const h = makeHarness();
|
|
1518
|
+
const upstream = startUpstream("hdr-test");
|
|
1519
|
+
try {
|
|
1520
|
+
writeManifest(
|
|
1521
|
+
{
|
|
1522
|
+
services: [
|
|
1523
|
+
{
|
|
1524
|
+
name: "parachute-vault",
|
|
1525
|
+
port: upstream.port,
|
|
1526
|
+
paths: ["/vault/default"],
|
|
1527
|
+
health: "/vault/default/health",
|
|
1528
|
+
version: "0.4.0",
|
|
1529
|
+
},
|
|
1530
|
+
],
|
|
1531
|
+
},
|
|
1532
|
+
h.manifestPath,
|
|
1533
|
+
);
|
|
1534
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1535
|
+
const incoming = new Request("http://127.0.0.1:1939/vault/default/health", {
|
|
1536
|
+
headers: { host: "127.0.0.1:1939" },
|
|
1537
|
+
});
|
|
1538
|
+
const res = await fetcher(incoming);
|
|
1539
|
+
const body = (await res.json()) as { forwardedProto: string };
|
|
1540
|
+
expect(body.forwardedProto).toBe("http");
|
|
1541
|
+
} finally {
|
|
1542
|
+
upstream.stop();
|
|
1543
|
+
h.cleanup();
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
test("preserves X-Forwarded-Host when edge already set it (nested-proxy chain)", async () => {
|
|
1548
|
+
// Multi-hop chain: client → edge proxy → hub → upstream. Edge captures
|
|
1549
|
+
// the original host as X-Forwarded-Host before its own host-rewrite;
|
|
1550
|
+
// hub MUST preserve that, not overwrite with its own incoming Host
|
|
1551
|
+
// (which may be the edge's hostname). "Don't clobber" semantics.
|
|
1552
|
+
const h = makeHarness();
|
|
1553
|
+
const upstream = startUpstream("hdr-test");
|
|
1554
|
+
try {
|
|
1555
|
+
writeManifest(
|
|
1556
|
+
{
|
|
1557
|
+
services: [
|
|
1558
|
+
{
|
|
1559
|
+
name: "parachute-vault",
|
|
1560
|
+
port: upstream.port,
|
|
1561
|
+
paths: ["/vault/default"],
|
|
1562
|
+
health: "/vault/default/health",
|
|
1563
|
+
version: "0.4.0",
|
|
1564
|
+
},
|
|
1565
|
+
],
|
|
1566
|
+
},
|
|
1567
|
+
h.manifestPath,
|
|
1568
|
+
);
|
|
1569
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1570
|
+
// Edge already labeled the canonical hop: original-client.example
|
|
1571
|
+
// (this is what an HTTPS termination proxy in front of hub would set).
|
|
1572
|
+
const incoming = new Request("http://edge.local/vault/default/health", {
|
|
1573
|
+
headers: {
|
|
1574
|
+
host: "edge.local",
|
|
1575
|
+
"x-forwarded-host": "original-client.example",
|
|
1576
|
+
"x-forwarded-proto": "https",
|
|
1577
|
+
},
|
|
1578
|
+
});
|
|
1579
|
+
const res = await fetcher(incoming);
|
|
1580
|
+
const body = (await res.json()) as { forwardedHost: string; forwardedProto: string };
|
|
1581
|
+
// Edge's X-Forwarded-Host preserved verbatim, NOT clobbered by the
|
|
1582
|
+
// edge.local Host that hub itself saw.
|
|
1583
|
+
expect(body.forwardedHost).toBe("original-client.example");
|
|
1584
|
+
expect(body.forwardedProto).toBe("https");
|
|
1585
|
+
} finally {
|
|
1586
|
+
upstream.stop();
|
|
1587
|
+
h.cleanup();
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1412
1591
|
test("proxies a /vault/<name>/* request to the matching upstream", async () => {
|
|
1413
1592
|
const h = makeHarness();
|
|
1414
1593
|
const upstream = startUpstream("default-vault");
|
|
@@ -342,7 +342,10 @@ describe("parachute start", () => {
|
|
|
342
342
|
log: () => {},
|
|
343
343
|
});
|
|
344
344
|
expect(code).toBe(0);
|
|
345
|
+
// PORT is always set by `parachute start` (hub#356) from the
|
|
346
|
+
// services.json entry. PARACHUTE_HUB_ORIGIN comes from expose-state.
|
|
345
347
|
expect(spawner.calls[0]?.env).toEqual({
|
|
348
|
+
PORT: "1940",
|
|
346
349
|
PARACHUTE_HUB_ORIGIN: "https://parachute.taildf9ce2.ts.net",
|
|
347
350
|
});
|
|
348
351
|
} finally {
|
|
@@ -364,6 +367,7 @@ describe("parachute start", () => {
|
|
|
364
367
|
});
|
|
365
368
|
expect(code).toBe(0);
|
|
366
369
|
expect(spawner.calls[0]?.env).toEqual({
|
|
370
|
+
PORT: "1940",
|
|
367
371
|
PARACHUTE_HUB_ORIGIN: "http://127.0.0.1:1939",
|
|
368
372
|
});
|
|
369
373
|
} finally {
|
|
@@ -398,6 +402,7 @@ describe("parachute start", () => {
|
|
|
398
402
|
});
|
|
399
403
|
expect(code).toBe(0);
|
|
400
404
|
expect(spawner.calls[0]?.env).toEqual({
|
|
405
|
+
PORT: "1940",
|
|
401
406
|
PARACHUTE_HUB_ORIGIN: "https://override.example.com",
|
|
402
407
|
});
|
|
403
408
|
} finally {
|
|
@@ -417,7 +422,11 @@ describe("parachute start", () => {
|
|
|
417
422
|
log: () => {},
|
|
418
423
|
});
|
|
419
424
|
expect(code).toBe(0);
|
|
420
|
-
|
|
425
|
+
// PORT is always set (hub#356) — even with no override, no exposure,
|
|
426
|
+
// and no hub.port file, the spawn env carries the canonical PORT
|
|
427
|
+
// from services.json. Test renamed from "omits env" to reflect
|
|
428
|
+
// the new minimum-env shape.
|
|
429
|
+
expect(spawner.calls[0]?.env).toEqual({ PORT: "1940" });
|
|
421
430
|
} finally {
|
|
422
431
|
h.cleanup();
|
|
423
432
|
}
|
|
@@ -453,6 +462,7 @@ describe("parachute start", () => {
|
|
|
453
462
|
});
|
|
454
463
|
expect(code).toBe(0);
|
|
455
464
|
expect(spawner.calls[0]?.env).toEqual({
|
|
465
|
+
PORT: "1943",
|
|
456
466
|
GROQ_API_KEY: "gsk_real_value",
|
|
457
467
|
QUOTED: "quoted_val",
|
|
458
468
|
});
|
|
@@ -483,6 +493,7 @@ describe("parachute start", () => {
|
|
|
483
493
|
});
|
|
484
494
|
expect(code).toBe(0);
|
|
485
495
|
expect(spawner.calls[0]?.env).toEqual({
|
|
496
|
+
PORT: "1940",
|
|
486
497
|
SCRIBE_AUTH_TOKEN: "secret",
|
|
487
498
|
PARACHUTE_HUB_ORIGIN: "https://live.example.com",
|
|
488
499
|
});
|
|
@@ -121,6 +121,27 @@ describe("bootSupervisedModules", () => {
|
|
|
121
121
|
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test("sets PORT in child env from services.json entry (hub#357)", async () => {
|
|
125
|
+
// Container deploys (Render etc.) set PORT in hub's process.env via
|
|
126
|
+
// Dockerfile / platform injection. The supervisor's defaultSpawnFn
|
|
127
|
+
// passes `env: process.env` so children inherit hub's PORT and try
|
|
128
|
+
// to bind hub's own port → EADDRINUSE crashloop. This boot path
|
|
129
|
+
// (called on hub startup to re-spawn supervised modules from
|
|
130
|
+
// services.json) was missed by hub#356 which only fixed the
|
|
131
|
+
// install-time + lifecycle paths. Third spawn site, same fix shape.
|
|
132
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
133
|
+
const recorder = makeRecorder();
|
|
134
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
135
|
+
|
|
136
|
+
await bootSupervisedModules(sup, {
|
|
137
|
+
manifestPath: h.manifestPath,
|
|
138
|
+
configDir: h.dir,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// VAULT_ENTRY's port = 1940 (vault's canonical).
|
|
142
|
+
expect(recorder.calls[0]?.env?.PORT).toBe("1940");
|
|
143
|
+
});
|
|
144
|
+
|
|
124
145
|
test("merges per-module .env file into child env", async () => {
|
|
125
146
|
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
126
147
|
// Write a per-module .env at <configDir>/<short>/.env — what the
|
|
@@ -184,6 +184,15 @@ describe("formatBootstrapTokenBanner", () => {
|
|
|
184
184
|
expect(line.startsWith("[wizard]")).toBe(true);
|
|
185
185
|
}
|
|
186
186
|
});
|
|
187
|
+
|
|
188
|
+
test("uses ═ delimiters and an ALL-CAPS heading so operators spot the block in log viewers", () => {
|
|
189
|
+
const banner = formatBootstrapTokenBanner("parachute-bootstrap-visual-token");
|
|
190
|
+
// The ═ box-drawing char is the visual cue an operator scrolling
|
|
191
|
+
// Render's log tab keys off; this assertion locks the new shape so
|
|
192
|
+
// a stylistic regression doesn't silently demote the banner.
|
|
193
|
+
expect(banner).toContain("═");
|
|
194
|
+
expect(banner).toContain("PARACHUTE BOOTSTRAP TOKEN");
|
|
195
|
+
});
|
|
187
196
|
});
|
|
188
197
|
|
|
189
198
|
// --- bootstrap-token generation under needs-setup (Issue 1 wiring) -------
|
|
@@ -2143,6 +2143,10 @@ describe("typed vault name (hub#267)", () => {
|
|
|
2143
2143
|
const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
|
|
2144
2144
|
expect(vaultSpawn).toBeDefined();
|
|
2145
2145
|
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBe("smoke-1940");
|
|
2146
|
+
// PORT also injected (hub#356) — supervisor always sets it from the
|
|
2147
|
+
// services.json entry regardless of whether the typed-name path
|
|
2148
|
+
// contributed any additional env vars.
|
|
2149
|
+
expect(vaultSpawn?.env?.PORT).toBe("1940");
|
|
2146
2150
|
} finally {
|
|
2147
2151
|
db.close();
|
|
2148
2152
|
}
|
|
@@ -2254,10 +2258,13 @@ describe("typed vault name (hub#267)", () => {
|
|
|
2254
2258
|
await new Promise((r) => setTimeout(r, 50));
|
|
2255
2259
|
const vaultSpawn = spawnRequests.find((s) => s.short === "vault");
|
|
2256
2260
|
expect(vaultSpawn).toBeDefined();
|
|
2257
|
-
// No
|
|
2261
|
+
// No PARACHUTE_VAULT_NAME override on the default-name path (vault's
|
|
2258
2262
|
// resolveFirstBootVaultName already defaults to "default" when the
|
|
2259
|
-
// env var is absent
|
|
2260
|
-
|
|
2263
|
+
// env var is absent). PORT is set by the supervisor (hub#356) for
|
|
2264
|
+
// every supervised child regardless — assert the empty-name path
|
|
2265
|
+
// doesn't add PARACHUTE_VAULT_NAME.
|
|
2266
|
+
expect(vaultSpawn?.env?.PARACHUTE_VAULT_NAME).toBeUndefined();
|
|
2267
|
+
expect(vaultSpawn?.env?.PORT).toBe("1940");
|
|
2261
2268
|
} finally {
|
|
2262
2269
|
db.close();
|
|
2263
2270
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for hub#349: Bun.spawn defaults to an EMPTY env, which meant
|
|
3
|
+
* subprocess `bun add -g` didn't see TMPDIR, BUN_INSTALL, or any other env vars
|
|
4
|
+
* set by the Dockerfile / Render env. All `defaultRun`-style helpers were
|
|
5
|
+
* updated to pass `env: process.env`.
|
|
6
|
+
*
|
|
7
|
+
* This test asserts that property end-to-end: spawn a real child via `bun -e`
|
|
8
|
+
* and have it print one parent-set env var. Pre-fix, the child would not see
|
|
9
|
+
* the var; post-fix, it does.
|
|
10
|
+
*
|
|
11
|
+
* We exercise the production path by importing one representative helper.
|
|
12
|
+
* The full set of seven explicit + several inherited fix sites all use the
|
|
13
|
+
* same `env: process.env` pattern; testing one is sufficient to lock the
|
|
14
|
+
* pattern in place — the others are mechanical applications of it.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
describe("Bun.spawn env propagation (hub#349)", () => {
|
|
19
|
+
test("child process sees parent env when defaultRun-style helper is used", async () => {
|
|
20
|
+
// Unique marker so we can't false-positive against leftover env from
|
|
21
|
+
// another test or the harness itself.
|
|
22
|
+
const markerKey = "PARACHUTE_HUB_SPAWN_ENV_TEST_MARKER";
|
|
23
|
+
const markerValue = `marker-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
24
|
+
|
|
25
|
+
const originalValue = process.env[markerKey];
|
|
26
|
+
process.env[markerKey] = markerValue;
|
|
27
|
+
try {
|
|
28
|
+
// Spawn a child the same way every defaultRun helper does:
|
|
29
|
+
// `env: process.env`. The child prints its view of the marker var.
|
|
30
|
+
const proc = Bun.spawn(
|
|
31
|
+
["bun", "-e", `process.stdout.write(process.env.${markerKey} ?? "MISSING")`],
|
|
32
|
+
{
|
|
33
|
+
stdout: "pipe",
|
|
34
|
+
stderr: "pipe",
|
|
35
|
+
env: process.env,
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
const stdout = await new Response(proc.stdout).text();
|
|
39
|
+
const exitCode = await proc.exited;
|
|
40
|
+
|
|
41
|
+
expect(exitCode).toBe(0);
|
|
42
|
+
expect(stdout).toBe(markerValue);
|
|
43
|
+
} finally {
|
|
44
|
+
if (originalValue === undefined) delete process.env[markerKey];
|
|
45
|
+
else process.env[markerKey] = originalValue;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("child process does NOT see parent env when env is omitted (negative control)", async () => {
|
|
50
|
+
// The bug we're guarding against: without `env: process.env`, Bun.spawn
|
|
51
|
+
// hands the child an empty env. This test pins the failure mode so a
|
|
52
|
+
// future regression (someone removing `env: process.env`) is caught here,
|
|
53
|
+
// not in production on Render.
|
|
54
|
+
const markerKey = "PARACHUTE_HUB_SPAWN_ENV_TEST_MARKER_NEG";
|
|
55
|
+
const markerValue = `marker-${Date.now()}`;
|
|
56
|
+
|
|
57
|
+
const originalValue = process.env[markerKey];
|
|
58
|
+
process.env[markerKey] = markerValue;
|
|
59
|
+
try {
|
|
60
|
+
const proc = Bun.spawn(
|
|
61
|
+
["bun", "-e", `process.stdout.write(process.env.${markerKey} ?? "MISSING")`],
|
|
62
|
+
{
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "pipe",
|
|
65
|
+
// intentionally NO env: process.env
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
const stdout = await new Response(proc.stdout).text();
|
|
69
|
+
const exitCode = await proc.exited;
|
|
70
|
+
|
|
71
|
+
expect(exitCode).toBe(0);
|
|
72
|
+
expect(stdout).toBe("MISSING");
|
|
73
|
+
} finally {
|
|
74
|
+
if (originalValue === undefined) delete process.env[markerKey];
|
|
75
|
+
else process.env[markerKey] = originalValue;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/admin-vaults.ts
CHANGED
|
@@ -206,7 +206,12 @@ function buildEntry(
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
async function defaultRunCommand(cmd: readonly string[]): Promise<RunResult> {
|
|
209
|
-
|
|
209
|
+
// Inherit env so the child sees PATH, HOME, BUN_INSTALL, etc. Bun.spawn
|
|
210
|
+
// defaults to empty env — see api-modules-ops.ts:defaultRun for the rationale.
|
|
211
|
+
const proc = Bun.spawn([...cmd], {
|
|
212
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
213
|
+
env: process.env,
|
|
214
|
+
});
|
|
210
215
|
// Drain both pipes in parallel — leaving stderr unread can deadlock long
|
|
211
216
|
// installs once the OS pipe buffer fills (#97). Captured stderr is folded
|
|
212
217
|
// into the orchestration error message on non-zero exit.
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -322,7 +322,14 @@ async function resolveSpawnSpec(
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
function defaultRun(cmd: readonly string[]): Promise<number> {
|
|
325
|
-
|
|
325
|
+
// Inherit env so child `bun add` sees TMPDIR, BUN_INSTALL, PARACHUTE_*,
|
|
326
|
+
// etc. set by the Dockerfile / Render env. Bun.spawn defaults to empty
|
|
327
|
+
// env — without this, bun-add fails with cross-mount rename errors on
|
|
328
|
+
// Render (where TMPDIR points at the persistent disk). See hub#349.
|
|
329
|
+
const proc = Bun.spawn([...cmd], {
|
|
330
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
331
|
+
env: process.env,
|
|
332
|
+
});
|
|
326
333
|
return proc.exited;
|
|
327
334
|
}
|
|
328
335
|
|
|
@@ -394,11 +401,23 @@ async function spawnSupervised(
|
|
|
394
401
|
if (!entry) return undefined;
|
|
395
402
|
const cmd = spec.startCmd?.(entry);
|
|
396
403
|
if (!cmd || cmd.length === 0) return undefined;
|
|
404
|
+
// PORT override (hub#356): in container deploys, hub binds its own port
|
|
405
|
+
// via the PORT env var (Render sets PORT=$PORT, Dockerfile defaults to
|
|
406
|
+
// 1939). Bun.spawn's `env: process.env` propagates that PORT to every
|
|
407
|
+
// supervised child — so vault (which reads `process.env.PORT` in
|
|
408
|
+
// server.ts:230) tries to bind hub's port and crashes EADDRINUSE.
|
|
409
|
+
// Explicitly override with the child's services.json port so children
|
|
410
|
+
// honor their canonical port assignment regardless of hub's PORT.
|
|
411
|
+
// `deps.spawnEnv` still wins (test seam + first-boot vault-name pass-through).
|
|
412
|
+
const childEnv: Record<string, string> = {
|
|
413
|
+
PORT: String(entry.port),
|
|
414
|
+
...(deps.spawnEnv ?? {}),
|
|
415
|
+
};
|
|
397
416
|
const req: SpawnRequest = {
|
|
398
417
|
short,
|
|
399
418
|
cmd,
|
|
400
419
|
...(entry.installDir ? { cwd: entry.installDir } : {}),
|
|
401
|
-
|
|
420
|
+
env: childEnv,
|
|
402
421
|
};
|
|
403
422
|
return deps.supervisor.start(req);
|
|
404
423
|
}
|
package/src/commands/auth.ts
CHANGED
|
@@ -60,7 +60,13 @@ export interface Runner {
|
|
|
60
60
|
|
|
61
61
|
export const defaultRunner: Runner = {
|
|
62
62
|
async run(cmd) {
|
|
63
|
-
|
|
63
|
+
// Inherit env so the child (e.g. parachute-vault subprocess) sees PATH,
|
|
64
|
+
// HOME, PARACHUTE_HOME, etc. Bun.spawn defaults to empty env — see
|
|
65
|
+
// api-modules-ops.ts:defaultRun.
|
|
66
|
+
const proc = Bun.spawn([...cmd], {
|
|
67
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
68
|
+
env: process.env,
|
|
69
|
+
});
|
|
64
70
|
return await proc.exited;
|
|
65
71
|
},
|
|
66
72
|
};
|
|
@@ -26,7 +26,12 @@ import { type VaultAuthStatus, readVaultAuthStatus } from "../vault/auth-status.
|
|
|
26
26
|
export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
|
|
27
27
|
|
|
28
28
|
const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
|
|
29
|
-
|
|
29
|
+
// Inherit env so subprocesses see PATH (to find `tailscale`), HOME, etc.
|
|
30
|
+
// Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
|
|
31
|
+
const proc = Bun.spawn([...cmd], {
|
|
32
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
33
|
+
env: process.env,
|
|
34
|
+
});
|
|
30
35
|
return await proc.exited;
|
|
31
36
|
};
|
|
32
37
|
|
|
@@ -69,7 +69,12 @@ export const defaultCloudflaredSpawner: CloudflaredSpawner = {
|
|
|
69
69
|
spawn(cmd, logFile) {
|
|
70
70
|
mkdirSync(dirname(logFile), { recursive: true });
|
|
71
71
|
const fd = openSync(logFile, "a");
|
|
72
|
-
|
|
72
|
+
// Inherit env so cloudflared sees HOME (where it reads ~/.cloudflared/),
|
|
73
|
+
// PATH, etc. Bun.spawn defaults to empty env — see api-modules-ops.ts.
|
|
74
|
+
const proc = Bun.spawn([...cmd], {
|
|
75
|
+
stdio: ["ignore", fd, fd],
|
|
76
|
+
env: process.env,
|
|
77
|
+
});
|
|
73
78
|
proc.unref();
|
|
74
79
|
return proc.pid;
|
|
75
80
|
},
|
|
@@ -46,7 +46,13 @@ import { type ExposeOpts, exposePublic } from "./expose.ts";
|
|
|
46
46
|
export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
|
|
47
47
|
|
|
48
48
|
const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
|
|
49
|
-
|
|
49
|
+
// Inherit env so interactive subprocesses (e.g. `brew install cloudflared`,
|
|
50
|
+
// `cloudflared tunnel login`) see PATH, HOME, etc. Bun.spawn defaults to
|
|
51
|
+
// empty env — see api-modules-ops.ts:defaultRun.
|
|
52
|
+
const proc = Bun.spawn([...cmd], {
|
|
53
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
54
|
+
env: process.env,
|
|
55
|
+
});
|
|
50
56
|
return await proc.exited;
|
|
51
57
|
};
|
|
52
58
|
|
package/src/commands/install.ts
CHANGED
|
@@ -250,7 +250,13 @@ export interface InstallOpts {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
async function defaultRunner(cmd: readonly string[]): Promise<number> {
|
|
253
|
-
|
|
253
|
+
// Inherit env (TMPDIR, BUN_INSTALL, PATH, HOME, PARACHUTE_*, etc.) — see
|
|
254
|
+
// api-modules-ops.ts:defaultRun for the rationale. Same Bun.spawn-defaults-
|
|
255
|
+
// to-empty-env bug; same one-line fix. See hub#349.
|
|
256
|
+
const proc = Bun.spawn([...cmd], {
|
|
257
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
258
|
+
env: process.env,
|
|
259
|
+
});
|
|
254
260
|
return await proc.exited;
|
|
255
261
|
}
|
|
256
262
|
|
|
@@ -67,6 +67,10 @@ export const defaultSpawner: Spawner = {
|
|
|
67
67
|
// wrapped startCmds like `pnpm exec tsx server.ts` leave the tsx
|
|
68
68
|
// grandchild bound to the port after stop → restart hits EADDRINUSE.
|
|
69
69
|
detached: true,
|
|
70
|
+
// Inherit env so child sees PATH, HOME, PARACHUTE_HOME, etc.
|
|
71
|
+
// Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
|
|
72
|
+
// Per-call `opts.env` overrides merge on top below.
|
|
73
|
+
env: process.env,
|
|
70
74
|
};
|
|
71
75
|
if (opts?.env) spawnOpts.env = { ...process.env, ...opts.env };
|
|
72
76
|
if (opts?.cwd) spawnOpts.cwd = opts.cwd;
|
|
@@ -432,7 +436,13 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
|
|
|
432
436
|
// wrapper for launchd / systemd) — this is idempotent there. Hub-origin
|
|
433
437
|
// override wins on collision; that's the live-exposure source of truth.
|
|
434
438
|
const fileEnv = readEnvFileValues(join(r.configDir, short, ".env"));
|
|
435
|
-
|
|
439
|
+
// PORT override (hub#356): same shape as `spawnSupervised` in
|
|
440
|
+
// api-modules-ops.ts. Without this, operators running `parachute start
|
|
441
|
+
// vault` inside a container that has PORT in env (Render / Fly / etc.)
|
|
442
|
+
// hit EADDRINUSE on hub's port. Local dev typically doesn't set PORT, so
|
|
443
|
+
// this is a no-op there. fileEnv wins on collision so per-service .env
|
|
444
|
+
// can still override if an operator deliberately set PORT in there.
|
|
445
|
+
const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
|
|
436
446
|
if (r.hubOrigin) env[HUB_ORIGIN_ENV] = r.hubOrigin;
|
|
437
447
|
const spawnerOpts: { env?: Record<string, string>; cwd?: string } = {};
|
|
438
448
|
if (Object.keys(env).length > 0) spawnerOpts.env = env;
|
|
@@ -647,7 +657,12 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
647
657
|
if (follow) {
|
|
648
658
|
const spawner = opts.tailSpawner ?? {
|
|
649
659
|
spawn(cmd) {
|
|
650
|
-
|
|
660
|
+
// Inherit env so `tail` sees PATH, etc. Bun.spawn defaults to empty
|
|
661
|
+
// env — see api-modules-ops.ts:defaultRun.
|
|
662
|
+
const proc = Bun.spawn([...cmd], {
|
|
663
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
664
|
+
env: process.env,
|
|
665
|
+
});
|
|
651
666
|
return proc.pid;
|
|
652
667
|
},
|
|
653
668
|
};
|
|
@@ -89,11 +89,15 @@ export async function bootSupervisedModules(
|
|
|
89
89
|
continue;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
92
|
+
// PORT override (hub#357 — third spawn site missed by hub#356).
|
|
93
|
+
// Without this, modules that read process.env.PORT (vault, scribe)
|
|
94
|
+
// inherit hub's PORT from Bun.spawn's env: process.env default and
|
|
95
|
+
// crash EADDRINUSE on hub's port. Container deploy was still broken
|
|
96
|
+
// after #356 because this BOOT path runs on hub startup before the
|
|
97
|
+
// supervisor's other spawn paths see any traffic. fileEnv wins on
|
|
98
|
+
// collision so per-service .env can still override.
|
|
95
99
|
const fileEnv = readEnvFileValues(join(opts.configDir, short, ".env"));
|
|
96
|
-
const env: Record<string, string> = { ...fileEnv };
|
|
100
|
+
const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
|
|
97
101
|
if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
|
|
98
102
|
|
|
99
103
|
const req: {
|
package/src/commands/serve.ts
CHANGED
|
@@ -149,16 +149,26 @@ export async function seedInitialAdminIfNeeded(
|
|
|
149
149
|
* the token can't proceed; an attacker who reads it before the
|
|
150
150
|
* operator wins the race).
|
|
151
151
|
*/
|
|
152
|
-
export function formatBootstrapTokenBanner(token: string): string {
|
|
152
|
+
export function formatBootstrapTokenBanner(token: string, hubUrl?: string): string {
|
|
153
|
+
const rule = "═".repeat(64);
|
|
154
|
+
// Substitute the actual hub URL when known (PARACHUTE_HUB_ORIGIN). Operators
|
|
155
|
+
// staring at the banner in Render Logs shouldn't have to figure out their
|
|
156
|
+
// own URL — show the literal placeholder only when the issuer isn't set.
|
|
157
|
+
const url = hubUrl && hubUrl.length > 0 ? hubUrl.replace(/\/+$/, "") : "<hub-url>";
|
|
153
158
|
return [
|
|
154
|
-
"[wizard]
|
|
155
|
-
|
|
156
|
-
"[wizard]
|
|
159
|
+
"[wizard]",
|
|
160
|
+
`[wizard] ${rule}`,
|
|
161
|
+
"[wizard] PARACHUTE BOOTSTRAP TOKEN",
|
|
162
|
+
`[wizard] ${rule}`,
|
|
157
163
|
"[wizard]",
|
|
158
164
|
`[wizard] ${token}`,
|
|
159
165
|
"[wizard]",
|
|
160
|
-
|
|
161
|
-
"[wizard] admin
|
|
166
|
+
`[wizard] → Visit ${url}/admin/setup and paste this token to create`,
|
|
167
|
+
"[wizard] your admin account.",
|
|
168
|
+
"[wizard] → Expires when admin is created OR when hub restarts.",
|
|
169
|
+
"[wizard]",
|
|
170
|
+
`[wizard] ${rule}`,
|
|
171
|
+
"[wizard]",
|
|
162
172
|
].join("\n");
|
|
163
173
|
}
|
|
164
174
|
|
|
@@ -199,14 +209,14 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
199
209
|
|
|
200
210
|
if (adminBootstrap === "needs-setup") {
|
|
201
211
|
log(
|
|
202
|
-
"parachute serve: no admin account configured.
|
|
212
|
+
"parachute serve: no admin account configured. Visit /admin/setup once the hub is reachable, or seed via PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD env vars for scripted deploys.",
|
|
203
213
|
);
|
|
204
214
|
// Mint a bootstrap token + log it. The wizard's account POST will
|
|
205
215
|
// require this token, so an attacker who beats the operator to the
|
|
206
216
|
// freshly-provisioned URL still can't claim the admin row without
|
|
207
217
|
// shell access to the platform's startup logs.
|
|
208
218
|
const token = generateBootstrapToken();
|
|
209
|
-
log(formatBootstrapTokenBanner(token));
|
|
219
|
+
log(formatBootstrapTokenBanner(token, issuer));
|
|
210
220
|
}
|
|
211
221
|
|
|
212
222
|
const supervisor = opts.supervisor ?? new Supervisor();
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -74,17 +74,22 @@ export interface UpgradeRunner {
|
|
|
74
74
|
|
|
75
75
|
export const defaultRunner: UpgradeRunner = {
|
|
76
76
|
async run(cmd, opts) {
|
|
77
|
+
// Inherit env so `bun add -g` etc. see TMPDIR, BUN_INSTALL, PATH, HOME.
|
|
78
|
+
// Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
|
|
77
79
|
const proc = Bun.spawn([...cmd], {
|
|
78
80
|
cwd: opts?.cwd,
|
|
79
81
|
stdio: ["inherit", "inherit", "inherit"],
|
|
82
|
+
env: process.env,
|
|
80
83
|
});
|
|
81
84
|
return await proc.exited;
|
|
82
85
|
},
|
|
83
86
|
async capture(cmd, opts) {
|
|
87
|
+
// Inherit env — same rationale as `run` above.
|
|
84
88
|
const proc = Bun.spawn([...cmd], {
|
|
85
89
|
cwd: opts?.cwd,
|
|
86
90
|
stdout: "pipe",
|
|
87
91
|
stderr: "pipe",
|
|
92
|
+
env: process.env,
|
|
88
93
|
});
|
|
89
94
|
const [stdout, stderr] = await Promise.all([
|
|
90
95
|
new Response(proc.stdout).text(),
|
|
@@ -30,7 +30,13 @@ import { createInterface } from "node:readline/promises";
|
|
|
30
30
|
export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
|
|
31
31
|
|
|
32
32
|
const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
|
|
33
|
-
|
|
33
|
+
// Inherit env so the child (parachute-vault subprocess) sees PATH, HOME,
|
|
34
|
+
// PARACHUTE_HOME, etc. Bun.spawn defaults to empty env — see
|
|
35
|
+
// api-modules-ops.ts:defaultRun.
|
|
36
|
+
const proc = Bun.spawn([...cmd], {
|
|
37
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
38
|
+
env: process.env,
|
|
39
|
+
});
|
|
34
40
|
return await proc.exited;
|
|
35
41
|
};
|
|
36
42
|
|
package/src/commands/vault.ts
CHANGED
|
@@ -2,6 +2,9 @@ export async function dispatchVault(args: readonly string[]): Promise<number> {
|
|
|
2
2
|
try {
|
|
3
3
|
const proc = Bun.spawn(["parachute-vault", ...args], {
|
|
4
4
|
stdio: ["inherit", "inherit", "inherit"],
|
|
5
|
+
// Inherit env so parachute-vault sees PATH, HOME, PARACHUTE_HOME, etc.
|
|
6
|
+
// Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
|
|
7
|
+
env: process.env,
|
|
5
8
|
});
|
|
6
9
|
return await proc.exited;
|
|
7
10
|
} catch (err) {
|
package/src/hub-control.ts
CHANGED
|
@@ -74,7 +74,12 @@ export interface HubSpawner {
|
|
|
74
74
|
export const defaultHubSpawner: HubSpawner = {
|
|
75
75
|
spawn(cmd, logFile) {
|
|
76
76
|
const fd = openSync(logFile, "a");
|
|
77
|
-
|
|
77
|
+
// Inherit env so the hub child process sees PATH, HOME, PARACHUTE_HOME,
|
|
78
|
+
// etc. Bun.spawn defaults to empty env — see api-modules-ops.ts.
|
|
79
|
+
const proc = Bun.spawn([...cmd], {
|
|
80
|
+
stdio: ["ignore", fd, fd],
|
|
81
|
+
env: process.env,
|
|
82
|
+
});
|
|
78
83
|
proc.unref();
|
|
79
84
|
return proc.pid;
|
|
80
85
|
},
|
package/src/hub-server.ts
CHANGED
|
@@ -167,6 +167,7 @@ import {
|
|
|
167
167
|
} from "./oauth-handlers.ts";
|
|
168
168
|
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
169
169
|
import { clearPid, writePid } from "./process-state.ts";
|
|
170
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
170
171
|
import {
|
|
171
172
|
FIRST_PARTY_FALLBACKS,
|
|
172
173
|
KNOWN_MODULES,
|
|
@@ -417,9 +418,28 @@ async function proxyRequest(
|
|
|
417
418
|
const path = targetPath ?? url.pathname;
|
|
418
419
|
const upstream = `http://127.0.0.1:${port}${path}${url.search}`;
|
|
419
420
|
const headers = new Headers(req.headers);
|
|
421
|
+
// Capture the public hostname BEFORE deleting Host so we can forward it
|
|
422
|
+
// as X-Forwarded-Host (hub#358). Without this, supervised modules like
|
|
423
|
+
// vault see no X-Forwarded-Host header and fall back to their internal
|
|
424
|
+
// loopback URL when constructing OAuth metadata — publishing
|
|
425
|
+
// `http://127.0.0.1:1940/...` as the issuer instead of the public origin.
|
|
426
|
+
const publicHost = req.headers.get("host");
|
|
420
427
|
// Host comes from the requester (tailnet FQDN); the loopback target wants
|
|
421
428
|
// its own. Bun's fetch fills it in when omitted.
|
|
422
429
|
headers.delete("host");
|
|
430
|
+
// Forward the public origin so downstream services build their public-
|
|
431
|
+
// facing URLs (OAuth metadata, redirect URIs) against the same host the
|
|
432
|
+
// client used. We DON'T overwrite X-Forwarded-Host if already set —
|
|
433
|
+
// some platforms (Render) set it at the edge.
|
|
434
|
+
if (publicHost && !headers.has("x-forwarded-host")) {
|
|
435
|
+
headers.set("x-forwarded-host", publicHost);
|
|
436
|
+
}
|
|
437
|
+
// Same for protocol — if the edge set X-Forwarded-Proto we preserve it,
|
|
438
|
+
// otherwise we set it from isHttpsRequest's signal (direct HTTPS to hub
|
|
439
|
+
// is unusual on Render, but covers local TLS-terminating proxies too).
|
|
440
|
+
if (!headers.has("x-forwarded-proto")) {
|
|
441
|
+
headers.set("x-forwarded-proto", isHttpsRequest(req) ? "https" : "http");
|
|
442
|
+
}
|
|
423
443
|
|
|
424
444
|
const init: RequestInit & { duplex?: "half" } = {
|
|
425
445
|
method: req.method,
|
|
@@ -944,7 +964,29 @@ export function resolveIssuer(
|
|
|
944
964
|
if (stored) return stored;
|
|
945
965
|
}
|
|
946
966
|
if (configuredIssuer) return configuredIssuer;
|
|
947
|
-
|
|
967
|
+
// Reverse-proxy aware: Render / Tailscale Funnel / cloudflared terminate
|
|
968
|
+
// TLS at the edge and forward plain HTTP to hub. Without X-Forwarded-Proto
|
|
969
|
+
// honoring, `req.url.origin` is `http://...` and hub publishes mixed-content
|
|
970
|
+
// URLs in OAuth discovery (`registration_endpoint`, `authorization_endpoint`,
|
|
971
|
+
// etc.) — browsers block them when the page itself loaded over https://.
|
|
972
|
+
// The `isHttpsRequest` helper is the canonical place where this trust
|
|
973
|
+
// is established (also used for the Secure cookie attribute).
|
|
974
|
+
//
|
|
975
|
+
// We do NOT honor X-Forwarded-Host *for hub's own issuer derivation*.
|
|
976
|
+
// (Note: hub DOES forward X-Forwarded-Host to upstream supervised
|
|
977
|
+
// modules in `proxyRequest` — that's a separate concern, see #358.
|
|
978
|
+
// Here we're deriving hub's own canonical origin from the incoming
|
|
979
|
+
// request, not what to tell downstream services.) Render, Tailscale
|
|
980
|
+
// Funnel, and cloudflared all preserve the Host header end-to-end,
|
|
981
|
+
// so `req.url`'s host already reflects the public hostname.
|
|
982
|
+
// Operators on a proxy that rewrites Host (some nginx / Caddy
|
|
983
|
+
// configs) should set hub_origin via the admin SPA — that path
|
|
984
|
+
// bypasses this fallback entirely.
|
|
985
|
+
const url = new URL(req.url);
|
|
986
|
+
if (isHttpsRequest(req)) {
|
|
987
|
+
url.protocol = "https:";
|
|
988
|
+
}
|
|
989
|
+
return url.origin;
|
|
948
990
|
}
|
|
949
991
|
|
|
950
992
|
/**
|
package/src/supervisor.ts
CHANGED
|
@@ -403,6 +403,10 @@ async function pumpLines(
|
|
|
403
403
|
const defaultSpawnFn: SpawnFn = (req) => {
|
|
404
404
|
const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
|
|
405
405
|
stdio: ["ignore", "pipe", "pipe"],
|
|
406
|
+
// Inherit env so supervised module sees PATH, HOME, PARACHUTE_HOME, etc.
|
|
407
|
+
// Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
|
|
408
|
+
// Per-call `req.env` overrides merge on top below.
|
|
409
|
+
env: process.env,
|
|
406
410
|
};
|
|
407
411
|
if (req.cwd) spawnOpts.cwd = req.cwd;
|
|
408
412
|
if (req.env) spawnOpts.env = { ...process.env, ...req.env };
|
package/src/tailscale/run.ts
CHANGED
|
@@ -7,7 +7,13 @@ export interface CommandResult {
|
|
|
7
7
|
export type Runner = (cmd: readonly string[]) => Promise<CommandResult>;
|
|
8
8
|
|
|
9
9
|
export async function defaultRunner(cmd: readonly string[]): Promise<CommandResult> {
|
|
10
|
-
|
|
10
|
+
// Inherit env so `tailscale` sees PATH (and HOME for state dir). Bun.spawn
|
|
11
|
+
// defaults to empty env — see api-modules-ops.ts:defaultRun for rationale.
|
|
12
|
+
const proc = Bun.spawn([...cmd], {
|
|
13
|
+
stdout: "pipe",
|
|
14
|
+
stderr: "pipe",
|
|
15
|
+
env: process.env,
|
|
16
|
+
});
|
|
11
17
|
const [stdout, stderr, code] = await Promise.all([
|
|
12
18
|
new Response(proc.stdout).text(),
|
|
13
19
|
new Response(proc.stderr).text(),
|