leak-cli 2026.2.17-beta.1 → 2026.2.17

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.
@@ -0,0 +1,1131 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import readline from "node:readline/promises";
6
+ import { stdin as input, stdout as output } from "node:process";
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import {
10
+ DEFAULT_ACCESS_MODE,
11
+ accessModeRequiresDownloadCode,
12
+ accessModeRequiresPayment,
13
+ isValidAccessMode,
14
+ } from "../src/access_mode.js";
15
+ import { createUi } from "./ui.js";
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+ const LEAK_SCRIPT_PATH = path.resolve(__dirname, "leak.js");
20
+ const PUBLIC_CONFIRM_PHRASE = "I_UNDERSTAND_PUBLIC_EXPOSURE";
21
+
22
+ const outUi = createUi(process.stdout);
23
+ const errUi = createUi(process.stderr);
24
+
25
+ function logInfo(message) {
26
+ console.log(outUi.statusLine("info", message));
27
+ }
28
+
29
+ function logOk(message) {
30
+ console.log(outUi.statusLine("ok", message));
31
+ }
32
+
33
+ function logWarn(message) {
34
+ console.error(errUi.statusLine("warn", message));
35
+ }
36
+
37
+ function logError(message) {
38
+ console.error(errUi.statusLine("error", message));
39
+ }
40
+
41
+ function usageAndExit(code = 1, hint = "") {
42
+ if (hint) logWarn(`Hint: ${hint}`);
43
+ console.log(outUi.heading("Leak Multi-Host Runner"));
44
+ console.log("");
45
+ console.log(outUi.section("Usage"));
46
+ console.log(
47
+ ` leak host --config <path> [--proxy-host <host>] [--proxy-port <port>] [--public] [--public-confirm ${PUBLIC_CONFIRM_PHRASE}] [--dry-run]`,
48
+ );
49
+ console.log("");
50
+ console.log(outUi.section("Config Shape (JSON)"));
51
+ console.log(" {");
52
+ console.log(' "proxy": { "host": "127.0.0.1", "port": 4080 },');
53
+ console.log(" \"defaults\": {");
54
+ console.log(' "window": "1h",');
55
+ console.log(' "network": "eip155:84532",');
56
+ console.log(' "payTo": "0x...",');
57
+ console.log(' "price": "0.01"');
58
+ console.log(" },");
59
+ console.log(' "routes": [');
60
+ console.log(" {");
61
+ console.log(' "slug": "lolboy",');
62
+ console.log(' "prefix": "/leak/lolboy",');
63
+ console.log(' "port": 4101,');
64
+ console.log(' "artifactPath": "./content/lol.mp3",');
65
+ console.log(' "accessMode": "payment-only-no-download-code"');
66
+ console.log(" }");
67
+ console.log(" ]");
68
+ console.log(" }");
69
+ console.log("");
70
+ console.log(outUi.section("Notes"));
71
+ console.log(" - One route maps to one leak worker process.");
72
+ console.log(" - The reverse proxy rewrites /leak/<slug>/... to worker-local /...");
73
+ console.log(" - publicOrigin in config is optional; --public can auto-create quick tunnel.");
74
+ process.exit(code);
75
+ }
76
+
77
+ function parseArgs(argv) {
78
+ const args = { _: [] };
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const a = argv[i];
81
+ if (a === "--help" || a === "-h") usageAndExit(0);
82
+ if (a === "--dry-run") {
83
+ args["dry-run"] = true;
84
+ continue;
85
+ }
86
+ if (a === "--public") {
87
+ args.public = true;
88
+ continue;
89
+ }
90
+ if (a.startsWith("--")) {
91
+ const key = a.slice(2);
92
+ const val = argv[i + 1];
93
+ if (val && !val.startsWith("--")) {
94
+ args[key] = val;
95
+ i++;
96
+ } else {
97
+ args[key] = true;
98
+ }
99
+ continue;
100
+ }
101
+ args._.push(a);
102
+ }
103
+ return args;
104
+ }
105
+
106
+ function isPlainObject(value) {
107
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
108
+ }
109
+
110
+ function parsePositiveInt(value, label) {
111
+ const n = Number(value);
112
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
113
+ throw new Error(`Invalid ${label}: expected a positive integer`);
114
+ }
115
+ return n;
116
+ }
117
+
118
+ function parseNonNegativeInt(value, label) {
119
+ const n = Number(value);
120
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
121
+ throw new Error(`Invalid ${label}: expected a non-negative integer`);
122
+ }
123
+ return n;
124
+ }
125
+
126
+ function trim(value) {
127
+ return String(value ?? "").trim();
128
+ }
129
+
130
+ function toStringMap(value, label) {
131
+ if (!isPlainObject(value)) return {};
132
+ const out = {};
133
+ for (const [k, v] of Object.entries(value)) {
134
+ if (!k || typeof k !== "string") {
135
+ throw new Error(`Invalid ${label}: all keys must be non-empty strings`);
136
+ }
137
+ if (v === null || v === undefined) continue;
138
+ out[k] = String(v);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function toBoolean(value) {
144
+ return value === true;
145
+ }
146
+
147
+ function normalizePrefix(input, label) {
148
+ let prefix = trim(input);
149
+ if (!prefix) throw new Error(`Missing ${label}`);
150
+ if (!prefix.startsWith("/")) prefix = `/${prefix}`;
151
+ prefix = prefix.replace(/\/+$/, "");
152
+ if (!prefix || prefix === "/") {
153
+ throw new Error(`Invalid ${label}: use a non-root path prefix, for example /leak/lolboy`);
154
+ }
155
+ return prefix;
156
+ }
157
+
158
+ function ensureHttpOrigin(value, label) {
159
+ const raw = trim(value);
160
+ if (!raw) return "";
161
+ let parsed;
162
+ try {
163
+ parsed = new URL(raw);
164
+ } catch {
165
+ throw new Error(`Invalid ${label}: must be an absolute http(s) URL`);
166
+ }
167
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
168
+ throw new Error(`Invalid ${label}: protocol must be http or https`);
169
+ }
170
+ return raw.replace(/\/+$/, "");
171
+ }
172
+
173
+ function stringOrDefault(route, defaults, key, fallback = "") {
174
+ const routeValue = route?.[key];
175
+ if (routeValue !== undefined && routeValue !== null && trim(routeValue) !== "") {
176
+ return String(routeValue);
177
+ }
178
+ const defaultValue = defaults?.[key];
179
+ if (
180
+ defaultValue !== undefined &&
181
+ defaultValue !== null &&
182
+ trim(defaultValue) !== ""
183
+ ) {
184
+ return String(defaultValue);
185
+ }
186
+ return fallback;
187
+ }
188
+
189
+ function boolOrDefault(route, defaults, key, fallback = false) {
190
+ if (route?.[key] !== undefined) return toBoolean(route[key]);
191
+ if (defaults?.[key] !== undefined) return toBoolean(defaults[key]);
192
+ return fallback;
193
+ }
194
+
195
+ function arrayToStrings(value, label) {
196
+ if (value === undefined || value === null) return [];
197
+ if (!Array.isArray(value)) throw new Error(`Invalid ${label}: expected an array of strings`);
198
+ return value.map((item, idx) => {
199
+ const str = String(item ?? "").trim();
200
+ if (!str) throw new Error(`Invalid ${label}[${idx}]: value must be a non-empty string`);
201
+ return str;
202
+ });
203
+ }
204
+
205
+ function shellQuote(value) {
206
+ const raw = String(value ?? "");
207
+ if (/^[A-Za-z0-9_./:=+-]+$/.test(raw)) return raw;
208
+ return JSON.stringify(raw);
209
+ }
210
+
211
+ function looksSensitiveEnvKey(key) {
212
+ const normalized = String(key || "").toLowerCase();
213
+ return (
214
+ normalized.includes("secret") ||
215
+ normalized.includes("token") ||
216
+ normalized.includes("private") ||
217
+ normalized.includes("password") ||
218
+ normalized.includes("key")
219
+ );
220
+ }
221
+
222
+ function redactEnvForDisplay(envObj) {
223
+ const redacted = {};
224
+ for (const [k, v] of Object.entries(envObj)) {
225
+ redacted[k] = looksSensitiveEnvKey(k) ? "<redacted>" : String(v);
226
+ }
227
+ return redacted;
228
+ }
229
+
230
+ function appendForwardedFor(existing, remoteAddress) {
231
+ const current = trim(existing || "");
232
+ const remote = trim(remoteAddress || "");
233
+ if (!current) return remote;
234
+ if (!remote) return current;
235
+ return `${current}, ${remote}`;
236
+ }
237
+
238
+ function matchRouteByPrefix(routes, pathname) {
239
+ for (const route of routes) {
240
+ if (pathname === route.prefix) return route;
241
+ if (pathname.startsWith(`${route.prefix}/`)) return route;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ function rewritePathForRoute(pathname, prefix) {
247
+ const suffix = pathname.slice(prefix.length);
248
+ if (!suffix) return "/";
249
+ if (suffix.startsWith("/")) return suffix;
250
+ return `/${suffix}`;
251
+ }
252
+
253
+ function attachPrefixedOutput(stream, outputStream, prefix, onLine = null) {
254
+ let buffer = "";
255
+ stream.on("data", (chunk) => {
256
+ buffer += chunk.toString("utf8");
257
+ const lines = buffer.split(/\r?\n/);
258
+ buffer = lines.pop() ?? "";
259
+ for (const line of lines) {
260
+ outputStream.write(`${prefix}${line}\n`);
261
+ if (typeof onLine === "function") onLine(line);
262
+ }
263
+ });
264
+ stream.on("end", () => {
265
+ if (buffer) {
266
+ outputStream.write(`${prefix}${buffer}\n`);
267
+ if (typeof onLine === "function") onLine(buffer);
268
+ }
269
+ });
270
+ }
271
+
272
+ function cloudflaredPreflight() {
273
+ const probe = spawnSync("cloudflared", ["--version"], { stdio: "ignore" });
274
+ if (!probe.error && probe.status === 0) return { ok: true };
275
+
276
+ const missing = probe.error?.code === "ENOENT";
277
+ return {
278
+ ok: false,
279
+ missing,
280
+ reason: missing
281
+ ? "cloudflared is not installed or not on PATH."
282
+ : `cloudflared check failed (status=${probe.status ?? "n/a"}).`,
283
+ };
284
+ }
285
+
286
+ function printCloudflaredInstallHelp() {
287
+ logError("--public requested, but cloudflared is unavailable.");
288
+ logWarn("cloudflared is required to create a public tunnel URL.");
289
+ console.error("");
290
+ console.error(errUi.section("Install cloudflared"));
291
+ console.error(" macOS (Homebrew): brew install cloudflared");
292
+ console.error(" Windows (winget): winget install --id Cloudflare.cloudflared");
293
+ console.error(" Linux packages/docs: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
294
+ console.error("");
295
+ console.error(errUi.section("Retry"));
296
+ console.error(
297
+ ` leak host --config ./examples/multi-host.example.json --public --public-confirm ${PUBLIC_CONFIRM_PHRASE}`,
298
+ );
299
+ }
300
+
301
+ async function ensurePublicExposureConfirmedForHost(args) {
302
+ if (!args.public) return;
303
+
304
+ const provided =
305
+ typeof args["public-confirm"] === "string" ? args["public-confirm"].trim() : "";
306
+ if (provided) {
307
+ if (provided !== PUBLIC_CONFIRM_PHRASE) {
308
+ throw new Error(`Invalid --public-confirm value. Expected exactly: ${PUBLIC_CONFIRM_PHRASE}`);
309
+ }
310
+ return;
311
+ }
312
+
313
+ if (!input.isTTY || !output.isTTY) {
314
+ throw new Error(`--public requires --public-confirm ${PUBLIC_CONFIRM_PHRASE} in non-interactive mode`);
315
+ }
316
+
317
+ const rl = readline.createInterface({ input, output });
318
+ try {
319
+ logWarn("You are about to expose local content to the public internet.");
320
+ const answer = (
321
+ await rl.question(`[leak-host] Type ${PUBLIC_CONFIRM_PHRASE} to continue: `)
322
+ ).trim();
323
+ if (answer !== PUBLIC_CONFIRM_PHRASE) {
324
+ throw new Error("Public exposure confirmation failed. Aborting.");
325
+ }
326
+ } finally {
327
+ rl.close();
328
+ }
329
+ }
330
+
331
+ function buildRoutePlan(config, cliArgs) {
332
+ const proxyCfg = isPlainObject(config.proxy) ? config.proxy : {};
333
+ const defaults = isPlainObject(config.defaults) ? config.defaults : {};
334
+ const routes = Array.isArray(config.routes) ? config.routes : null;
335
+ if (!routes || routes.length === 0) {
336
+ throw new Error("Missing routes: provide at least one route in config.routes");
337
+ }
338
+
339
+ const proxyHost = trim(cliArgs["proxy-host"] || proxyCfg.host || "127.0.0.1");
340
+ if (!proxyHost) throw new Error("Invalid proxy host");
341
+ const proxyPort = parsePositiveInt(
342
+ cliArgs["proxy-port"] ?? proxyCfg.port ?? 4080,
343
+ "proxy port",
344
+ );
345
+
346
+ const configuredOrigin = ensureHttpOrigin(config.publicOrigin || "", "publicOrigin");
347
+ const localFallbackOrigin = `http://127.0.0.1:${proxyPort}`;
348
+ const publicOriginMode = configuredOrigin
349
+ ? "configured_origin"
350
+ : (cliArgs.public ? "quick_tunnel" : "local_only");
351
+
352
+ const usedPrefixes = new Set();
353
+ const usedPorts = new Set();
354
+ const plannedRoutes = [];
355
+
356
+ for (let i = 0; i < routes.length; i++) {
357
+ const route = routes[i];
358
+ const label = `routes[${i}]`;
359
+ if (!isPlainObject(route)) {
360
+ throw new Error(`Invalid ${label}: expected an object`);
361
+ }
362
+
363
+ const prefix = normalizePrefix(route.prefix, `${label}.prefix`);
364
+ if (usedPrefixes.has(prefix)) {
365
+ throw new Error(`Duplicate route prefix: ${prefix}`);
366
+ }
367
+ usedPrefixes.add(prefix);
368
+
369
+ const port = parsePositiveInt(route.port, `${label}.port`);
370
+ if (usedPorts.has(port)) {
371
+ throw new Error(`Duplicate route port: ${port}`);
372
+ }
373
+ usedPorts.add(port);
374
+
375
+ const artifactPath = stringOrDefault(route, defaults, "artifactPath");
376
+ if (!trim(artifactPath)) {
377
+ throw new Error(`Missing ${label}.artifactPath`);
378
+ }
379
+
380
+ const accessMode = stringOrDefault(route, defaults, "accessMode", DEFAULT_ACCESS_MODE)
381
+ .trim()
382
+ .toLowerCase();
383
+ if (!isValidAccessMode(accessMode)) {
384
+ throw new Error(`Invalid ${label}.accessMode: ${accessMode}`);
385
+ }
386
+ const requiresPayment = accessModeRequiresPayment(accessMode);
387
+ const requiresDownloadCode = accessModeRequiresDownloadCode(accessMode);
388
+
389
+ const windowValue = stringOrDefault(route, defaults, "window");
390
+ if (!trim(windowValue)) {
391
+ throw new Error(`Missing ${label}.window (required for non-interactive launch)`);
392
+ }
393
+
394
+ const payTo = stringOrDefault(route, defaults, "payTo");
395
+ const price = stringOrDefault(route, defaults, "price");
396
+ if (requiresPayment && !trim(payTo)) {
397
+ throw new Error(`Missing ${label}.payTo (required for payment access modes)`);
398
+ }
399
+ if (requiresPayment && !trim(price)) {
400
+ throw new Error(`Missing ${label}.price (required for payment access modes)`);
401
+ }
402
+
403
+ const network = stringOrDefault(route, defaults, "network");
404
+ const facilitatorMode = stringOrDefault(route, defaults, "facilitatorMode");
405
+ const facilitatorUrl = stringOrDefault(route, defaults, "facilitatorUrl");
406
+ const confirmationPolicy = stringOrDefault(route, defaults, "confirmationPolicy");
407
+ const cdpApiKeyId = stringOrDefault(route, defaults, "cdpApiKeyId");
408
+ const cdpApiKeySecret = stringOrDefault(route, defaults, "cdpApiKeySecret");
409
+
410
+ const ogTitle = stringOrDefault(route, defaults, "ogTitle");
411
+ const ogDescription = stringOrDefault(route, defaults, "ogDescription");
412
+ const ogImageUrl = stringOrDefault(route, defaults, "ogImageUrl");
413
+
414
+ const routeEnv = {
415
+ ...toStringMap(defaults.env, "defaults.env"),
416
+ ...toStringMap(route.env, `${label}.env`),
417
+ };
418
+
419
+ const downloadCodeHash = stringOrDefault(route, defaults, "downloadCodeHash");
420
+ if (downloadCodeHash && !routeEnv.DOWNLOAD_CODE_HASH) {
421
+ routeEnv.DOWNLOAD_CODE_HASH = downloadCodeHash;
422
+ }
423
+
424
+ const downloadCode = stringOrDefault(route, defaults, "downloadCode");
425
+ if (
426
+ requiresDownloadCode &&
427
+ !downloadCode &&
428
+ !trim(routeEnv.DOWNLOAD_CODE_HASH || "")
429
+ ) {
430
+ throw new Error(
431
+ `Missing ${label}.downloadCode or ${label}.downloadCodeHash for download-code access mode`,
432
+ );
433
+ }
434
+
435
+ const endedWindowSecondsRaw = route.endedWindowSeconds ?? defaults.endedWindowSeconds;
436
+ let endedWindowSeconds = null;
437
+ if (
438
+ endedWindowSecondsRaw !== undefined &&
439
+ endedWindowSecondsRaw !== null &&
440
+ `${endedWindowSecondsRaw}` !== ""
441
+ ) {
442
+ endedWindowSeconds = parseNonNegativeInt(
443
+ endedWindowSecondsRaw,
444
+ `${label}.endedWindowSeconds`,
445
+ );
446
+ }
447
+
448
+ const confirmed = boolOrDefault(route, defaults, "confirmed", false);
449
+ const allowSensitivePath = boolOrDefault(route, defaults, "allowSensitivePath", false);
450
+ const acknowledgeSensitivePathRisk = boolOrDefault(
451
+ route,
452
+ defaults,
453
+ "acknowledgeSensitivePathRisk",
454
+ false,
455
+ );
456
+ if (allowSensitivePath !== acknowledgeSensitivePathRisk) {
457
+ throw new Error(
458
+ `${label}: allowSensitivePath and acknowledgeSensitivePathRisk must both be true together`,
459
+ );
460
+ }
461
+
462
+ const extraArgs = arrayToStrings(route.extraArgs, `${label}.extraArgs`);
463
+ const slug = trim(route.slug) || prefix.split("/").filter(Boolean).pop() || `route-${i + 1}`;
464
+ const restartOnExit = boolOrDefault(route, defaults, "restartOnExit", false);
465
+
466
+ const leakArgs = [
467
+ "--file",
468
+ artifactPath,
469
+ "--access-mode",
470
+ accessMode,
471
+ "--window",
472
+ windowValue,
473
+ "--port",
474
+ String(port),
475
+ ];
476
+ if (requiresPayment) {
477
+ leakArgs.push("--price", price, "--pay-to", payTo);
478
+ }
479
+ if (network) leakArgs.push("--network", network);
480
+ if (confirmed) leakArgs.push("--confirmed");
481
+ if (!confirmed && confirmationPolicy) {
482
+ leakArgs.push("--confirmation-policy", confirmationPolicy);
483
+ }
484
+ if (requiresDownloadCode && downloadCode) {
485
+ leakArgs.push("--download-code", downloadCode);
486
+ }
487
+ if (facilitatorMode) leakArgs.push("--facilitator-mode", facilitatorMode);
488
+ if (facilitatorUrl) leakArgs.push("--facilitator-url", facilitatorUrl);
489
+ if (cdpApiKeyId) leakArgs.push("--cdp-api-key-id", cdpApiKeyId);
490
+ if (cdpApiKeySecret) leakArgs.push("--cdp-api-key-secret", cdpApiKeySecret);
491
+ if (ogTitle) leakArgs.push("--og-title", ogTitle);
492
+ if (ogDescription) leakArgs.push("--og-description", ogDescription);
493
+ if (ogImageUrl) leakArgs.push("--og-image-url", ogImageUrl);
494
+ if (endedWindowSeconds !== null) {
495
+ leakArgs.push("--ended-window-seconds", String(endedWindowSeconds));
496
+ }
497
+ if (allowSensitivePath && acknowledgeSensitivePathRisk) {
498
+ leakArgs.push("--allow-sensitive-path", "--acknowledge-sensitive-path-risk");
499
+ }
500
+ leakArgs.push(...extraArgs);
501
+
502
+ plannedRoutes.push({
503
+ slug,
504
+ prefix,
505
+ port,
506
+ requiresPayment,
507
+ requiresDownloadCode,
508
+ restartOnExit,
509
+ leakArgs,
510
+ routeEnv,
511
+ publicBaseUrl: "",
512
+ env: null,
513
+ status: "pending",
514
+ restarts: 0,
515
+ child: null,
516
+ pid: null,
517
+ lastExitCode: null,
518
+ lastSignal: null,
519
+ starting: false,
520
+ });
521
+ }
522
+
523
+ plannedRoutes.sort((a, b) => b.prefix.length - a.prefix.length);
524
+ return {
525
+ proxyHost,
526
+ proxyPort,
527
+ configuredOrigin,
528
+ localFallbackOrigin,
529
+ publicOriginMode,
530
+ routes: plannedRoutes,
531
+ };
532
+ }
533
+
534
+ function applyPublicOriginToRoutes(routes, publicOrigin) {
535
+ const normalized = String(publicOrigin).replace(/\/+$/, "");
536
+ for (const route of routes) {
537
+ route.publicBaseUrl = `${normalized}${route.prefix}`;
538
+ route.env = {
539
+ ...process.env,
540
+ ...route.routeEnv,
541
+ PUBLIC_BASE_URL: route.publicBaseUrl,
542
+ };
543
+ }
544
+ }
545
+
546
+ function computeDryRunOriginPreview(plan) {
547
+ if (plan.publicOriginMode === "configured_origin") return plan.configuredOrigin;
548
+ if (plan.publicOriginMode === "local_only") return plan.localFallbackOrigin;
549
+ return "<quick-tunnel-origin-at-runtime>";
550
+ }
551
+
552
+ function printDryRun(plan, configPath) {
553
+ const dryRunOrigin = computeDryRunOriginPreview(plan);
554
+ const modeLabel =
555
+ plan.publicOriginMode === "configured_origin"
556
+ ? "configured origin"
557
+ : (plan.publicOriginMode === "quick_tunnel" ? "quick tunnel" : "local-only fallback");
558
+
559
+ console.log(outUi.section("Multi-host Dry Run"));
560
+ console.log("");
561
+ for (const line of outUi.formatRows([
562
+ { key: "config", value: configPath },
563
+ { key: "proxy_host", value: plan.proxyHost },
564
+ { key: "proxy_port", value: plan.proxyPort },
565
+ { key: "origin_mode", value: modeLabel },
566
+ { key: "public_origin", value: dryRunOrigin },
567
+ ])) {
568
+ console.log(line);
569
+ }
570
+ console.log("");
571
+ for (const route of plan.routes) {
572
+ const previewPublicBaseUrl = `${dryRunOrigin}${route.prefix}`;
573
+ console.log(outUi.section(`Route ${route.slug}`));
574
+ for (const line of outUi.formatRows([
575
+ { key: "prefix", value: route.prefix },
576
+ { key: "public_base_url", value: previewPublicBaseUrl },
577
+ { key: "local_target", value: `http://127.0.0.1:${route.port}` },
578
+ { key: "requires_payment", value: route.requiresPayment ? "yes" : "no" },
579
+ {
580
+ key: "requires_download_code",
581
+ value: route.requiresDownloadCode ? "yes" : "no",
582
+ },
583
+ ])) {
584
+ console.log(line);
585
+ }
586
+
587
+ const command = [process.execPath, LEAK_SCRIPT_PATH, ...route.leakArgs]
588
+ .map(shellQuote)
589
+ .join(" ");
590
+ console.log(` command: ${command}`);
591
+ const envForDisplay = redactEnvForDisplay({
592
+ PUBLIC_BASE_URL: previewPublicBaseUrl,
593
+ DOWNLOAD_CODE_HASH: route.routeEnv.DOWNLOAD_CODE_HASH,
594
+ FACILITATOR_MODE: route.routeEnv.FACILITATOR_MODE,
595
+ FACILITATOR_URL: route.routeEnv.FACILITATOR_URL,
596
+ });
597
+ for (const [k, v] of Object.entries(envForDisplay)) {
598
+ if (v === undefined || v === "undefined") continue;
599
+ console.log(` env.${k}: ${v}`);
600
+ }
601
+ console.log("");
602
+ }
603
+ }
604
+
605
+ async function startQuickTunnelForProxy({ proxyPort, timeoutMs = 30000 }) {
606
+ const urlRegex = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/gi;
607
+ return new Promise((resolve, reject) => {
608
+ let settled = false;
609
+ const proc = spawn(
610
+ "cloudflared",
611
+ ["tunnel", "--url", `http://localhost:${proxyPort}`, "--no-autoupdate"],
612
+ { stdio: ["ignore", "pipe", "pipe"] },
613
+ );
614
+
615
+ const cleanup = () => {
616
+ clearTimeout(timer);
617
+ proc.stdout?.off("data", onData);
618
+ proc.stderr?.off("data", onData);
619
+ proc.off("error", onError);
620
+ proc.off("exit", onEarlyExit);
621
+ };
622
+
623
+ const fail = (message) => {
624
+ if (settled) return;
625
+ settled = true;
626
+ cleanup();
627
+ try {
628
+ proc.kill("SIGTERM");
629
+ } catch {
630
+ // best effort only
631
+ }
632
+ reject(new Error(message));
633
+ };
634
+
635
+ const succeed = (origin) => {
636
+ if (settled) return;
637
+ settled = true;
638
+ cleanup();
639
+ resolve({ origin: String(origin).replace(/\/+$/, ""), proc });
640
+ };
641
+
642
+ const onData = (chunk) => {
643
+ const s = chunk.toString("utf8");
644
+ const m = s.match(urlRegex);
645
+ if (m && m[0]) succeed(m[0]);
646
+ };
647
+
648
+ const onError = (err) => {
649
+ if (err?.code === "ENOENT") {
650
+ fail("cloudflared not found. Install it or run without --public.");
651
+ return;
652
+ }
653
+ fail(`failed to start tunnel: ${err?.message || String(err)}`);
654
+ };
655
+
656
+ const onEarlyExit = (code, signal) => {
657
+ fail(
658
+ signal
659
+ ? `tunnel exited before URL was assigned (signal ${signal})`
660
+ : `tunnel exited before URL was assigned (code ${code})`,
661
+ );
662
+ };
663
+
664
+ const timer = setTimeout(() => {
665
+ fail("Timed out waiting for quick tunnel URL");
666
+ }, timeoutMs);
667
+
668
+ proc.stdout?.on("data", onData);
669
+ proc.stderr?.on("data", onData);
670
+ proc.on("error", onError);
671
+ proc.on("exit", onEarlyExit);
672
+ });
673
+ }
674
+
675
+ function printPublicTunnelSummary(publicOrigin, routes) {
676
+ console.log("");
677
+ console.log(outUi.section("Public Tunnel"));
678
+ for (const line of outUi.formatRows([
679
+ { key: "public_origin", value: outUi.link(publicOrigin) },
680
+ ])) {
681
+ console.log(line);
682
+ }
683
+
684
+ for (const route of routes) {
685
+ const promoUrl = `${route.publicBaseUrl}/`;
686
+ const buyUrl = `${route.publicBaseUrl}/download`;
687
+ for (const line of outUi.formatRows([
688
+ { key: `${route.slug}_promo`, value: outUi.link(promoUrl) },
689
+ { key: `${route.slug}_buy`, value: outUi.link(buyUrl) },
690
+ ])) {
691
+ console.log(line);
692
+ }
693
+ }
694
+ console.log("");
695
+ }
696
+
697
+ function lineSignalsRouteReady(route, line) {
698
+ const text = String(line || "").trim();
699
+ if (!text) return false;
700
+ const readyRe = new RegExp(
701
+ `^download\\s+http:\\/\\/(?:localhost|127\\.0\\.0\\.1):${route.port}\\/download\\b`,
702
+ );
703
+ return readyRe.test(text);
704
+ }
705
+
706
+ async function main() {
707
+ const args = parseArgs(process.argv.slice(2));
708
+ if (args._.length > 0) {
709
+ usageAndExit(1, `Unexpected positional arguments: ${args._.join(" ")}`);
710
+ }
711
+
712
+ const configArg = trim(args.config);
713
+ if (!configArg) usageAndExit(1, "Missing required --config <path>");
714
+ const configPath = path.resolve(process.cwd(), configArg);
715
+
716
+ if (!fs.existsSync(configPath)) {
717
+ logError(`Config file not found: ${configPath}`);
718
+ process.exit(1);
719
+ }
720
+
721
+ let parsedConfig;
722
+ try {
723
+ parsedConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
724
+ } catch (err) {
725
+ logError(`Failed to parse JSON config: ${err?.message || String(err)}`);
726
+ process.exit(1);
727
+ }
728
+
729
+ let plan;
730
+ try {
731
+ plan = buildRoutePlan(parsedConfig, args);
732
+ } catch (err) {
733
+ logError(err?.message || String(err));
734
+ process.exit(1);
735
+ }
736
+
737
+ try {
738
+ await ensurePublicExposureConfirmedForHost(args);
739
+ } catch (err) {
740
+ logError(err?.message || String(err));
741
+ process.exit(1);
742
+ }
743
+
744
+ if (args["dry-run"]) {
745
+ printDryRun(plan, configPath);
746
+ process.exit(0);
747
+ }
748
+
749
+ const runtime = {
750
+ shuttingDown: false,
751
+ serverClosed: false,
752
+ forceExitTimer: null,
753
+ tunnelProc: null,
754
+ publicSummaryEnabled: false,
755
+ publicSummaryPrinted: false,
756
+ publicSummaryOrigin: "",
757
+ publicSummaryTimer: null,
758
+ };
759
+
760
+ function maybeExit() {
761
+ if (!runtime.shuttingDown) return;
762
+ const hasAliveChild = plan.routes.some((route) => route.child !== null);
763
+ const hasTunnel = runtime.tunnelProc !== null;
764
+ if (!hasAliveChild && !hasTunnel && runtime.serverClosed) {
765
+ if (runtime.publicSummaryTimer) clearTimeout(runtime.publicSummaryTimer);
766
+ if (runtime.forceExitTimer) clearTimeout(runtime.forceExitTimer);
767
+ process.exit(0);
768
+ }
769
+ }
770
+
771
+ function maybePrintPublicSummary() {
772
+ if (!runtime.publicSummaryEnabled || runtime.publicSummaryPrinted) return;
773
+ const allReady = plan.routes.every((route) => route.startupReady);
774
+ if (!allReady) return;
775
+ runtime.publicSummaryPrinted = true;
776
+ if (runtime.publicSummaryTimer) {
777
+ clearTimeout(runtime.publicSummaryTimer);
778
+ runtime.publicSummaryTimer = null;
779
+ }
780
+ printPublicTunnelSummary(runtime.publicSummaryOrigin, plan.routes);
781
+ }
782
+
783
+ function schedulePublicSummary(origin) {
784
+ runtime.publicSummaryEnabled = true;
785
+ runtime.publicSummaryPrinted = false;
786
+ runtime.publicSummaryOrigin = origin;
787
+
788
+ if (runtime.publicSummaryTimer) clearTimeout(runtime.publicSummaryTimer);
789
+ runtime.publicSummaryTimer = setTimeout(() => {
790
+ if (runtime.shuttingDown || runtime.publicSummaryPrinted) return;
791
+ runtime.publicSummaryPrinted = true;
792
+ runtime.publicSummaryTimer = null;
793
+ logWarn("Timed out waiting for all route startup banners; printing public URLs anyway.");
794
+ printPublicTunnelSummary(runtime.publicSummaryOrigin, plan.routes);
795
+ }, 12000);
796
+ }
797
+
798
+ function shutdown(signal) {
799
+ if (runtime.shuttingDown) return;
800
+ runtime.shuttingDown = true;
801
+ logInfo(`Received ${signal}. Shutting down proxy, tunnel, and route workers...`);
802
+
803
+ runtime.forceExitTimer = setTimeout(() => {
804
+ logWarn("Force exiting after timeout.");
805
+ process.exit(1);
806
+ }, 8000);
807
+
808
+ proxyServer.close(() => {
809
+ runtime.serverClosed = true;
810
+ maybeExit();
811
+ });
812
+
813
+ if (runtime.tunnelProc && !runtime.tunnelProc.killed) {
814
+ try {
815
+ runtime.tunnelProc.kill("SIGTERM");
816
+ } catch {
817
+ // best effort only
818
+ }
819
+ }
820
+
821
+ for (const route of plan.routes) {
822
+ if (route.child && !route.child.killed) {
823
+ route.child.kill("SIGTERM");
824
+ }
825
+ }
826
+
827
+ setTimeout(() => {
828
+ if (runtime.tunnelProc && !runtime.tunnelProc.killed) {
829
+ try {
830
+ runtime.tunnelProc.kill("SIGKILL");
831
+ } catch {
832
+ // best effort only
833
+ }
834
+ }
835
+
836
+ for (const route of plan.routes) {
837
+ if (route.child && !route.child.killed) {
838
+ route.child.kill("SIGKILL");
839
+ }
840
+ }
841
+ }, 3000);
842
+
843
+ if (runtime.publicSummaryTimer) {
844
+ clearTimeout(runtime.publicSummaryTimer);
845
+ runtime.publicSummaryTimer = null;
846
+ }
847
+ }
848
+
849
+ function startRoute(route) {
850
+ if (runtime.shuttingDown || route.starting) return;
851
+ if (!route.env) {
852
+ throw new Error(`Missing route environment for ${route.slug}; PUBLIC_BASE_URL not initialized`);
853
+ }
854
+
855
+ route.starting = true;
856
+ route.status = "starting";
857
+
858
+ const child = spawn(process.execPath, [LEAK_SCRIPT_PATH, ...route.leakArgs], {
859
+ stdio: ["ignore", "pipe", "pipe"],
860
+ env: route.env,
861
+ });
862
+
863
+ route.child = child;
864
+ route.pid = child.pid ?? null;
865
+ route.lastExitCode = null;
866
+ route.lastSignal = null;
867
+ route.status = "running";
868
+ route.starting = false;
869
+ route.startupReady = false;
870
+
871
+ const onRouteLine = (line) => {
872
+ if (route.startupReady) return;
873
+ if (!lineSignalsRouteReady(route, line)) return;
874
+ route.startupReady = true;
875
+ maybePrintPublicSummary();
876
+ };
877
+ attachPrefixedOutput(child.stdout, process.stdout, `[${route.slug}] `, onRouteLine);
878
+ attachPrefixedOutput(child.stderr, process.stderr, `[${route.slug}] `, onRouteLine);
879
+
880
+ child.on("error", (err) => {
881
+ route.status = "error";
882
+ route.lastSignal = "spawn-error";
883
+ logError(`route=${route.slug} spawn failed: ${err?.message || String(err)}`);
884
+ });
885
+
886
+ child.on("exit", (code, signal) => {
887
+ route.child = null;
888
+ route.pid = null;
889
+ route.lastExitCode = code;
890
+ route.lastSignal = signal || null;
891
+ route.status = runtime.shuttingDown ? "stopped" : "exited";
892
+
893
+ if (runtime.shuttingDown) {
894
+ maybeExit();
895
+ return;
896
+ }
897
+
898
+ logWarn(
899
+ `route=${route.slug} exited (code=${code ?? "null"}, signal=${signal || "none"})`,
900
+ );
901
+
902
+ if (route.restartOnExit) {
903
+ route.restarts += 1;
904
+ logInfo(`route=${route.slug} restarting in 1s (restart count=${route.restarts})`);
905
+ setTimeout(() => {
906
+ if (!runtime.shuttingDown) startRoute(route);
907
+ }, 1000);
908
+ }
909
+ });
910
+ }
911
+
912
+ const proxyServer = http.createServer((req, res) => {
913
+ const method = String(req.method || "GET").toUpperCase();
914
+ const parsedUrl = new URL(
915
+ String(req.url || "/"),
916
+ `http://${req.headers.host || "localhost"}`,
917
+ );
918
+ const pathname = parsedUrl.pathname || "/";
919
+
920
+ if (method === "GET" && pathname === "/health") {
921
+ const routes = plan.routes.map((route) => ({
922
+ slug: route.slug,
923
+ prefix: route.prefix,
924
+ port: route.port,
925
+ status: route.status,
926
+ pid: route.pid,
927
+ restarts: route.restarts,
928
+ public_base_url: route.publicBaseUrl || null,
929
+ last_exit_code: route.lastExitCode,
930
+ last_signal: route.lastSignal,
931
+ }));
932
+ const allRunning = routes.every((route) => route.status === "running");
933
+ const body = {
934
+ ok: allRunning,
935
+ proxy: {
936
+ host: plan.proxyHost,
937
+ port: plan.proxyPort,
938
+ },
939
+ routes,
940
+ };
941
+ res.statusCode = allRunning ? 200 : 503;
942
+ res.setHeader("content-type", "application/json; charset=utf-8");
943
+ res.end(`${JSON.stringify(body, null, 2)}\n`);
944
+ return;
945
+ }
946
+
947
+ if (method === "GET" && pathname === "/") {
948
+ const lines = [
949
+ "leak multi-host reverse proxy",
950
+ "",
951
+ "available route prefixes:",
952
+ ...plan.routes.map((route) => `- ${route.prefix}/ -> http://127.0.0.1:${route.port}/`),
953
+ "",
954
+ "health: /health",
955
+ ];
956
+ res.statusCode = 200;
957
+ res.setHeader("content-type", "text/plain; charset=utf-8");
958
+ res.end(`${lines.join("\n")}\n`);
959
+ return;
960
+ }
961
+
962
+ const route = matchRouteByPrefix(plan.routes, pathname);
963
+ if (!route) {
964
+ res.statusCode = 404;
965
+ res.setHeader("content-type", "application/json; charset=utf-8");
966
+ res.end(
967
+ `${JSON.stringify(
968
+ {
969
+ error: "no matching route prefix",
970
+ path: pathname,
971
+ routes: plan.routes.map((entry) => entry.prefix),
972
+ },
973
+ null,
974
+ 2,
975
+ )}\n`,
976
+ );
977
+ return;
978
+ }
979
+
980
+ if (route.status !== "running") {
981
+ res.statusCode = 503;
982
+ res.setHeader("content-type", "application/json; charset=utf-8");
983
+ res.end(
984
+ `${JSON.stringify(
985
+ {
986
+ error: "route backend unavailable",
987
+ slug: route.slug,
988
+ status: route.status,
989
+ },
990
+ null,
991
+ 2,
992
+ )}\n`,
993
+ );
994
+ return;
995
+ }
996
+
997
+ const upstreamPath = `${rewritePathForRoute(pathname, route.prefix)}${parsedUrl.search || ""}`;
998
+ const forwardedHeaders = {
999
+ ...req.headers,
1000
+ host: `127.0.0.1:${route.port}`,
1001
+ "x-forwarded-for": appendForwardedFor(
1002
+ req.headers["x-forwarded-for"],
1003
+ req.socket?.remoteAddress || "",
1004
+ ),
1005
+ "x-forwarded-host": String(req.headers.host || ""),
1006
+ "x-forwarded-prefix": route.prefix,
1007
+ "x-forwarded-proto": String(req.headers["x-forwarded-proto"] || "http"),
1008
+ };
1009
+
1010
+ const upstreamReq = http.request(
1011
+ {
1012
+ host: "127.0.0.1",
1013
+ port: route.port,
1014
+ method,
1015
+ path: upstreamPath,
1016
+ headers: forwardedHeaders,
1017
+ },
1018
+ (upstreamRes) => {
1019
+ res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
1020
+ upstreamRes.pipe(res);
1021
+ },
1022
+ );
1023
+
1024
+ upstreamReq.on("error", (err) => {
1025
+ res.statusCode = 502;
1026
+ res.setHeader("content-type", "application/json; charset=utf-8");
1027
+ res.end(
1028
+ `${JSON.stringify(
1029
+ {
1030
+ error: "upstream request failed",
1031
+ slug: route.slug,
1032
+ message: err?.message || String(err),
1033
+ },
1034
+ null,
1035
+ 2,
1036
+ )}\n`,
1037
+ );
1038
+ });
1039
+
1040
+ req.on("aborted", () => {
1041
+ upstreamReq.destroy();
1042
+ });
1043
+ req.pipe(upstreamReq);
1044
+ });
1045
+
1046
+ process.on("SIGINT", () => shutdown("SIGINT"));
1047
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1048
+
1049
+ proxyServer.on("error", (err) => {
1050
+ logError(`Proxy server error: ${err?.message || String(err)}`);
1051
+ shutdown("proxy-error");
1052
+ });
1053
+
1054
+ async function initializeRuntime() {
1055
+ logInfo(`Health endpoint: http://${plan.proxyHost}:${plan.proxyPort}/health`);
1056
+
1057
+ let resolvedPublicOrigin = plan.localFallbackOrigin;
1058
+
1059
+ if (plan.publicOriginMode === "configured_origin") {
1060
+ resolvedPublicOrigin = plan.configuredOrigin;
1061
+ if (args.public) {
1062
+ logInfo("Using configured publicOrigin; skipping quick tunnel startup.");
1063
+ } else {
1064
+ logInfo("Using configured publicOrigin from config.");
1065
+ }
1066
+ } else if (plan.publicOriginMode === "quick_tunnel") {
1067
+ const preflight = cloudflaredPreflight();
1068
+ if (!preflight.ok) {
1069
+ printCloudflaredInstallHelp();
1070
+ shutdown("cloudflared_missing");
1071
+ return;
1072
+ }
1073
+
1074
+ logInfo("Starting Cloudflare quick tunnel for shared proxy...");
1075
+
1076
+ let tunnel;
1077
+ try {
1078
+ tunnel = await startQuickTunnelForProxy({ proxyPort: plan.proxyPort });
1079
+ } catch (err) {
1080
+ logError(`Failed to start quick tunnel: ${err?.message || String(err)}`);
1081
+ shutdown("tunnel_setup_failed");
1082
+ return;
1083
+ }
1084
+
1085
+ runtime.tunnelProc = tunnel.proc;
1086
+ resolvedPublicOrigin = tunnel.origin;
1087
+
1088
+ runtime.tunnelProc.on("exit", (code, signal) => {
1089
+ const wasShuttingDown = runtime.shuttingDown;
1090
+ runtime.tunnelProc = null;
1091
+
1092
+ if (wasShuttingDown) {
1093
+ maybeExit();
1094
+ return;
1095
+ }
1096
+
1097
+ logError(
1098
+ signal
1099
+ ? `Public tunnel exited unexpectedly (signal ${signal})`
1100
+ : `Public tunnel exited unexpectedly (code ${code})`,
1101
+ );
1102
+ shutdown("tunnel_fatal");
1103
+ });
1104
+ } else {
1105
+ logInfo("Running in local-only mode (no public tunnel). Use --public to auto-create quick tunnel.");
1106
+ }
1107
+
1108
+ applyPublicOriginToRoutes(plan.routes, resolvedPublicOrigin);
1109
+
1110
+ const shouldPrintPublicSummary =
1111
+ args.public || plan.publicOriginMode === "configured_origin";
1112
+ if (shouldPrintPublicSummary) {
1113
+ schedulePublicSummary(resolvedPublicOrigin);
1114
+ }
1115
+
1116
+ for (const route of plan.routes) startRoute(route);
1117
+ }
1118
+
1119
+ proxyServer.listen(plan.proxyPort, plan.proxyHost, () => {
1120
+ logOk(`Proxy listening on http://${plan.proxyHost}:${plan.proxyPort}`);
1121
+ void initializeRuntime().catch((err) => {
1122
+ logError(err?.message || String(err));
1123
+ shutdown("runtime_init_error");
1124
+ });
1125
+ });
1126
+ }
1127
+
1128
+ main().catch((err) => {
1129
+ logError(err?.message || String(err));
1130
+ process.exit(1);
1131
+ });