@hamedb89/localghost 0.1.10 → 0.1.13
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 +524 -34
- package/dist/cli.js +995 -93
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +343 -93
- package/dist/index.js +2051 -929
- package/dist/index.js.map +1 -1
- package/dist/{tunnel-DzfLXZ8O.d.ts → tunnel-BA52DD9e.d.ts} +53 -1
- package/dist/vite.d.ts +2 -1
- package/dist/vite.js +126 -17
- package/dist/vite.js.map +1 -1
- package/docs/flows.md +28 -25
- package/docs/ghost-tunnel.md +206 -20
- package/docs/github.md +4 -4
- package/docs/localghost.1.md +43 -7
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -129,6 +129,24 @@ function unregisterLocalghostSetup(options, path = getLocalghostActivityPath())
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// src/brand.ts
|
|
133
|
+
function renderLocalghostBanner() {
|
|
134
|
+
return [
|
|
135
|
+
" .-.",
|
|
136
|
+
" (o o) LOCALGHOST",
|
|
137
|
+
" | O \\ friendly local domains",
|
|
138
|
+
" \\ \\",
|
|
139
|
+
" `~~~'"
|
|
140
|
+
].join("\n");
|
|
141
|
+
}
|
|
142
|
+
function renderCompactLocalghostBanner() {
|
|
143
|
+
return [
|
|
144
|
+
" .--.",
|
|
145
|
+
" ( oo ) localghost",
|
|
146
|
+
" \\__/ friendly local domains"
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
132
150
|
// src/config.ts
|
|
133
151
|
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
|
|
134
152
|
import { basename, join as join2, resolve } from "path";
|
|
@@ -251,1089 +269,1931 @@ function sanitizeProjectName(value) {
|
|
|
251
269
|
return projectName || "app";
|
|
252
270
|
}
|
|
253
271
|
|
|
254
|
-
// src/
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return readFileSync3(path, "utf8");
|
|
263
|
-
}
|
|
264
|
-
function writeTextFile(path, value) {
|
|
265
|
-
mkdirSync2(dirname2(path), { recursive: true });
|
|
266
|
-
writeFileSync2(path, value, "utf8");
|
|
267
|
-
return path;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// src/caddy.ts
|
|
271
|
-
function groupByPort(entries) {
|
|
272
|
-
const groups = /* @__PURE__ */ new Map();
|
|
273
|
-
for (const entry of entries) {
|
|
274
|
-
const group = groups.get(entry.port) ?? [];
|
|
275
|
-
group.push(entry);
|
|
276
|
-
groups.set(entry.port, group);
|
|
277
|
-
}
|
|
278
|
-
return groups;
|
|
279
|
-
}
|
|
280
|
-
function getCaddyfilePath(cwd = process.cwd()) {
|
|
281
|
-
return join3(cwd, "ops/local/Caddyfile");
|
|
282
|
-
}
|
|
283
|
-
function renderCaddyfile(entries, options = {}) {
|
|
284
|
-
const groups = groupByPort(entries);
|
|
285
|
-
const https = options.https === true;
|
|
286
|
-
const blocks = [...groups.entries()].sort(([leftPort], [rightPort]) => leftPort - rightPort).map(([port, group]) => {
|
|
287
|
-
const hosts = group.map((entry) => https ? entry.host : `http://${entry.host}`).sort().join(", ");
|
|
288
|
-
return `${hosts} {
|
|
289
|
-
reverse_proxy 127.0.0.1:${port}
|
|
290
|
-
}`;
|
|
291
|
-
});
|
|
292
|
-
const globalOptions = https ? `{
|
|
293
|
-
local_certs
|
|
272
|
+
// src/ghost-file.ts
|
|
273
|
+
var LOCALGHOST_GHOST_TUNNEL_FILE = ".ghosttunnel";
|
|
274
|
+
function toGhostTunnelOptions(options = {}) {
|
|
275
|
+
const resolved = typeof options === "string" ? { cwd: options } : options;
|
|
276
|
+
return {
|
|
277
|
+
...resolved,
|
|
278
|
+
fileName: resolved.fileName ?? LOCALGHOST_GHOST_TUNNEL_FILE
|
|
279
|
+
};
|
|
294
280
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return `${globalOptions}${blocks.join("\n\n")}
|
|
298
|
-
`;
|
|
281
|
+
function normalizeHost(value) {
|
|
282
|
+
return value.trim().toLowerCase().replace(/\.$/, "");
|
|
299
283
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
writeTextFile(path, renderCaddyfile(entries, options));
|
|
303
|
-
return path;
|
|
284
|
+
function resolveGhostTunnelPath(options = {}) {
|
|
285
|
+
return resolveDevHostsPath(toGhostTunnelOptions(options));
|
|
304
286
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
cwd: dirname3(path),
|
|
308
|
-
stdio: "inherit"
|
|
309
|
-
});
|
|
287
|
+
function getGhostTunnelPath(options = {}) {
|
|
288
|
+
return resolveGhostTunnelPath(options).path;
|
|
310
289
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
cwd: dirname3(path),
|
|
314
|
-
stdio: "inherit"
|
|
315
|
-
});
|
|
290
|
+
function readGhostTunnelEntries(options = {}) {
|
|
291
|
+
return readDevHosts(toGhostTunnelOptions(options));
|
|
316
292
|
}
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
});
|
|
293
|
+
function listGhostTunnelEntries(options = {}) {
|
|
294
|
+
const resolved = resolveGhostTunnelPath(options);
|
|
295
|
+
if (!resolved.exists) return [];
|
|
296
|
+
return readGhostTunnelEntries(options);
|
|
322
297
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
stdio: "inherit"
|
|
327
|
-
});
|
|
298
|
+
function findGhostTunnelEntry(host, options = {}) {
|
|
299
|
+
const normalizedHost = normalizeHost(host);
|
|
300
|
+
return listGhostTunnelEntries(options).find((entry) => entry.host === normalizedHost);
|
|
328
301
|
}
|
|
329
302
|
|
|
330
|
-
// src/
|
|
331
|
-
import {
|
|
332
|
-
import { join as join4 } from "path";
|
|
333
|
-
import { pathToFileURL } from "url";
|
|
334
|
-
|
|
335
|
-
// src/port.ts
|
|
336
|
-
import { createServer } from "net";
|
|
337
|
-
async function isPortAvailable(port, host = "127.0.0.1") {
|
|
338
|
-
return new Promise((resolve2) => {
|
|
339
|
-
const server = createServer();
|
|
340
|
-
server.once("error", () => {
|
|
341
|
-
resolve2(false);
|
|
342
|
-
});
|
|
343
|
-
server.once("listening", () => {
|
|
344
|
-
server.close(() => resolve2(true));
|
|
345
|
-
});
|
|
346
|
-
server.listen(port, host);
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
async function findAvailablePort(startPort, options = {}) {
|
|
350
|
-
const host = options.host ?? "127.0.0.1";
|
|
351
|
-
const maxAttempts = options.maxAttempts ?? 50;
|
|
352
|
-
for (let offset = 0; offset < maxAttempts; offset += 1) {
|
|
353
|
-
const port = startPort + offset;
|
|
354
|
-
if (await isPortAvailable(port, host)) {
|
|
355
|
-
return port;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
|
|
359
|
-
}
|
|
303
|
+
// src/ghost-agent.ts
|
|
304
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
360
305
|
|
|
361
|
-
// src/
|
|
306
|
+
// src/relay.ts
|
|
307
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
362
308
|
import { domainToASCII } from "url";
|
|
363
|
-
var
|
|
364
|
-
var
|
|
365
|
-
var
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
309
|
+
var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
|
|
310
|
+
var DEFAULT_RELAY_BLOCKED_PORTS = [22, 2375, 2376, 5432, 6379, 9200, 9229, 27017];
|
|
311
|
+
var DEFAULT_RELAY_LIMITS = {
|
|
312
|
+
requestBodyBytes: 5 * 1024 * 1024,
|
|
313
|
+
responseBytes: 25 * 1024 * 1024,
|
|
314
|
+
timeoutMs: 3e4,
|
|
315
|
+
maxConcurrentRequests: 20,
|
|
316
|
+
perRouteRequestsPerMinute: 120,
|
|
317
|
+
perIpRequestsPerMinute: 60
|
|
318
|
+
};
|
|
319
|
+
var DEFAULT_RELAY_TARGET_POLICY = {
|
|
320
|
+
allowedHosts: [...DEFAULT_RELAY_ALLOWED_TARGET_HOSTS],
|
|
321
|
+
blockedPorts: [...DEFAULT_RELAY_BLOCKED_PORTS],
|
|
322
|
+
allowPrivateNetworkTargets: false
|
|
323
|
+
};
|
|
324
|
+
var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
325
|
+
"connection",
|
|
326
|
+
"keep-alive",
|
|
327
|
+
"proxy-authenticate",
|
|
328
|
+
"proxy-authorization",
|
|
329
|
+
"te",
|
|
330
|
+
"trailer",
|
|
331
|
+
"transfer-encoding",
|
|
332
|
+
"upgrade"
|
|
333
|
+
]);
|
|
334
|
+
var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie"]);
|
|
335
|
+
var TOKEN_QUERY_PATTERN = /(token|secret|key|password|session|jwt|auth)/i;
|
|
336
|
+
var HOST_PATTERN2 = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
|
|
337
|
+
var IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
338
|
+
function base64UrlEncode(value) {
|
|
339
|
+
return Buffer.from(value).toString("base64url");
|
|
369
340
|
}
|
|
370
|
-
function
|
|
371
|
-
return
|
|
341
|
+
function base64UrlDecode(value) {
|
|
342
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
372
343
|
}
|
|
373
|
-
function
|
|
374
|
-
|
|
375
|
-
if (trimmed.startsWith("[") || trimmed.includes("/")) return "";
|
|
376
|
-
const portSeparator = trimmed.lastIndexOf(":");
|
|
377
|
-
if (portSeparator === -1) return trimmed;
|
|
378
|
-
const port = trimmed.slice(portSeparator + 1);
|
|
379
|
-
return /^\d+$/.test(port) ? trimmed.slice(0, portSeparator) : trimmed;
|
|
344
|
+
function signPayload(payload, secret) {
|
|
345
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
380
346
|
}
|
|
381
|
-
function
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
if (ascii.startsWith(".") || ascii.endsWith(".")) return null;
|
|
386
|
-
if (ascii.includes("*")) return null;
|
|
387
|
-
if (!ascii.split(".").every(isValidHostLabel)) return null;
|
|
388
|
-
return ascii;
|
|
347
|
+
function secureEqual(left, right) {
|
|
348
|
+
const leftBuffer = Buffer.from(left);
|
|
349
|
+
const rightBuffer = Buffer.from(right);
|
|
350
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
389
351
|
}
|
|
390
|
-
function
|
|
391
|
-
|
|
352
|
+
function normalizeHost2(host) {
|
|
353
|
+
const trimmed = host.trim().toLowerCase().replace(/\.$/, "");
|
|
354
|
+
if (!trimmed || trimmed.includes("*") || trimmed.includes("/") || trimmed.includes(":")) return null;
|
|
355
|
+
const ascii = domainToASCII(trimmed);
|
|
356
|
+
if (!ascii || ascii.includes("..")) return null;
|
|
357
|
+
return HOST_PATTERN2.test(ascii) ? ascii : null;
|
|
392
358
|
}
|
|
393
|
-
function
|
|
394
|
-
|
|
359
|
+
function normalizeTargetHost(host) {
|
|
360
|
+
const trimmed = host.trim().toLowerCase();
|
|
361
|
+
if (trimmed === "::1" || trimmed === "[::1]") return "::1";
|
|
362
|
+
if (trimmed.includes("/") || trimmed.includes("*")) return null;
|
|
363
|
+
if (IPV4_PATTERN.test(trimmed)) return isValidIpv4(trimmed) ? trimmed : null;
|
|
364
|
+
return normalizeHost2(trimmed);
|
|
395
365
|
}
|
|
396
|
-
function
|
|
397
|
-
return
|
|
366
|
+
function isValidIpv4(value) {
|
|
367
|
+
return value.split(".").every((part) => {
|
|
368
|
+
const octet = Number(part);
|
|
369
|
+
return Number.isInteger(octet) && octet >= 0 && octet <= 255 && String(octet) === part;
|
|
370
|
+
});
|
|
398
371
|
}
|
|
399
|
-
function
|
|
400
|
-
if (!
|
|
401
|
-
|
|
402
|
-
|
|
372
|
+
function isPrivateIpv4(value) {
|
|
373
|
+
if (!isValidIpv4(value)) return false;
|
|
374
|
+
const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
|
|
375
|
+
return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
|
|
403
376
|
}
|
|
404
|
-
function
|
|
405
|
-
|
|
406
|
-
const normalized = values.map((value) => value.trim()).filter(Boolean).map((value) => {
|
|
407
|
-
const domain = normalizeDomain(value);
|
|
408
|
-
if (!domain) throw new Error(`Invalid ghost tunnel domain: ${value}`);
|
|
409
|
-
return domain;
|
|
410
|
-
});
|
|
411
|
-
return [...new Set(normalized)];
|
|
377
|
+
function isLocalTargetHost(host) {
|
|
378
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
412
379
|
}
|
|
413
|
-
function
|
|
414
|
-
return
|
|
380
|
+
function mergeTargetPolicy(policy) {
|
|
381
|
+
return {
|
|
382
|
+
allowedHosts: policy?.allowedHosts ?? DEFAULT_RELAY_TARGET_POLICY.allowedHosts,
|
|
383
|
+
blockedPorts: policy?.blockedPorts ?? DEFAULT_RELAY_TARGET_POLICY.blockedPorts,
|
|
384
|
+
allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
|
|
385
|
+
};
|
|
415
386
|
}
|
|
416
|
-
function
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
387
|
+
function mergeLimits(limits) {
|
|
388
|
+
const merged = {
|
|
389
|
+
...DEFAULT_RELAY_LIMITS,
|
|
390
|
+
...limits ?? {}
|
|
391
|
+
};
|
|
392
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
393
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
394
|
+
throw new Error(`Invalid relay limit ${key}: ${value}`);
|
|
395
|
+
}
|
|
423
396
|
}
|
|
424
|
-
|
|
425
|
-
|
|
397
|
+
return merged;
|
|
398
|
+
}
|
|
399
|
+
function assertExactRelayHost(host) {
|
|
400
|
+
const normalized = normalizeHost2(host);
|
|
401
|
+
if (!normalized) {
|
|
402
|
+
throw new Error(`Relay route claims must use an exact hostname: ${host}`);
|
|
426
403
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
404
|
+
return normalized;
|
|
405
|
+
}
|
|
406
|
+
function assertRelayLocalTarget(target, policyInput) {
|
|
407
|
+
if (!target || typeof target !== "object") {
|
|
408
|
+
throw new Error("Relay target must be an explicit local target object.");
|
|
431
409
|
}
|
|
432
|
-
|
|
433
|
-
|
|
410
|
+
const policy = mergeTargetPolicy(policyInput);
|
|
411
|
+
const host = normalizeTargetHost(target.host);
|
|
412
|
+
if (!host) {
|
|
413
|
+
throw new Error(`Invalid relay target host: ${target.host}`);
|
|
434
414
|
}
|
|
435
|
-
if (!
|
|
436
|
-
throw new Error(`Invalid
|
|
415
|
+
if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
|
|
416
|
+
throw new Error(`Invalid relay target port: ${target.port}`);
|
|
417
|
+
}
|
|
418
|
+
const protocol = target.protocol ?? "http";
|
|
419
|
+
if (protocol !== "http" && protocol !== "https") {
|
|
420
|
+
throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
|
|
421
|
+
}
|
|
422
|
+
if (policy.blockedPorts.includes(target.port)) {
|
|
423
|
+
throw new Error(`Relay target port is blocked: ${target.port}`);
|
|
424
|
+
}
|
|
425
|
+
const allowedHosts = new Set(policy.allowedHosts.map((allowedHost) => normalizeTargetHost(allowedHost)).filter((value) => Boolean(value)));
|
|
426
|
+
if (!allowedHosts.has(host)) {
|
|
427
|
+
throw new Error(`Relay target host is not explicitly allowed: ${host}`);
|
|
428
|
+
}
|
|
429
|
+
if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
|
|
430
|
+
throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
|
|
437
431
|
}
|
|
438
432
|
return {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
433
|
+
protocol,
|
|
434
|
+
host,
|
|
435
|
+
port: target.port
|
|
442
436
|
};
|
|
443
437
|
}
|
|
444
|
-
function
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
438
|
+
function authenticateRelayAgentToken(input) {
|
|
439
|
+
const expected = `Bearer ${input.agentToken}`;
|
|
440
|
+
return typeof input.authorizationHeader === "string" && secureEqual(input.authorizationHeader, expected);
|
|
441
|
+
}
|
|
442
|
+
function signRelayRouteClaim(claim, signingSecret) {
|
|
443
|
+
const payload = {
|
|
444
|
+
...claim,
|
|
445
|
+
host: assertExactRelayHost(claim.host)
|
|
446
|
+
};
|
|
447
|
+
if (!payload.scope) throw new Error("Relay route claim requires a scope.");
|
|
448
|
+
if (!payload.agentId) throw new Error("Relay route claim requires an agentId.");
|
|
449
|
+
if (Number.isNaN(Date.parse(payload.expiresAt))) throw new Error("Relay route claim requires a valid expiresAt.");
|
|
450
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
451
|
+
const signature = signPayload(encodedPayload, signingSecret);
|
|
452
|
+
return {
|
|
453
|
+
payload,
|
|
454
|
+
token: `${encodedPayload}.${signature}`
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function verifyRelayRouteClaim(token, signingSecret, options) {
|
|
458
|
+
const [encodedPayload, signature] = token.split(".");
|
|
459
|
+
if (!encodedPayload || !signature || token.split(".").length !== 2) {
|
|
460
|
+
throw new Error("Invalid relay route claim token.");
|
|
448
461
|
}
|
|
449
|
-
|
|
450
|
-
|
|
462
|
+
const expectedSignature = signPayload(encodedPayload, signingSecret);
|
|
463
|
+
if (!secureEqual(signature, expectedSignature)) {
|
|
464
|
+
throw new Error("Invalid relay route claim signature.");
|
|
451
465
|
}
|
|
452
|
-
|
|
466
|
+
const parsed = JSON.parse(base64UrlDecode(encodedPayload));
|
|
467
|
+
const host = assertExactRelayHost(parsed.host);
|
|
468
|
+
if (parsed.scope !== options.expectedScope) {
|
|
469
|
+
throw new Error("Relay route claim scope mismatch.");
|
|
470
|
+
}
|
|
471
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
472
|
+
if (Date.parse(parsed.expiresAt) <= now.getTime()) {
|
|
473
|
+
throw new Error("Relay route claim has expired.");
|
|
474
|
+
}
|
|
475
|
+
if (!parsed.agentId) {
|
|
476
|
+
throw new Error("Relay route claim requires an agentId.");
|
|
477
|
+
}
|
|
478
|
+
return { ...parsed, host };
|
|
453
479
|
}
|
|
454
|
-
function
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
480
|
+
function createRelayRouteRegistration(input) {
|
|
481
|
+
if (!authenticateRelayAgentToken({
|
|
482
|
+
agentToken: input.agentToken,
|
|
483
|
+
...typeof input.authorizationHeader !== "undefined" ? { authorizationHeader: input.authorizationHeader } : {}
|
|
484
|
+
})) {
|
|
485
|
+
throw new Error("Relay route registration requires an authenticated local agent.");
|
|
486
|
+
}
|
|
487
|
+
const claim = verifyRelayRouteClaim(input.claimToken, input.signingSecret, {
|
|
488
|
+
expectedScope: input.expectedScope,
|
|
489
|
+
...input.now ? { now: input.now } : {}
|
|
461
490
|
});
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
491
|
+
const target = assertRelayLocalTarget(input.target, input.targetPolicy);
|
|
492
|
+
const access = input.publicMode === true ? "public" : input.access ?? "private";
|
|
493
|
+
const passwordProtected = input.passwordProtected ?? false;
|
|
494
|
+
const authRequired = input.authRequired ?? false;
|
|
495
|
+
if (access === "public" && input.publicMode !== true) {
|
|
496
|
+
throw new Error("Relay public mode must be explicitly enabled.");
|
|
465
497
|
}
|
|
466
|
-
|
|
498
|
+
if (access === "private" && !passwordProtected && !authRequired) {
|
|
499
|
+
throw new Error("Private relay previews require password or auth.");
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
host: claim.host,
|
|
503
|
+
scope: claim.scope,
|
|
504
|
+
agentId: claim.agentId,
|
|
505
|
+
expiresAt: claim.expiresAt,
|
|
506
|
+
target,
|
|
507
|
+
access,
|
|
508
|
+
passwordProtected,
|
|
509
|
+
authRequired,
|
|
510
|
+
limits: mergeLimits(input.limits)
|
|
511
|
+
};
|
|
467
512
|
}
|
|
468
|
-
function
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
513
|
+
function isRelayRouteActive(route, options) {
|
|
514
|
+
if (!options.agentConnected) return false;
|
|
515
|
+
return Date.parse(route.expiresAt) > (options.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
516
|
+
}
|
|
517
|
+
function stripRelayForwardHeaders(headers) {
|
|
518
|
+
const stripped = {};
|
|
519
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
520
|
+
if (typeof value === "undefined") continue;
|
|
521
|
+
const lowerName = name.toLowerCase();
|
|
522
|
+
if (HOP_BY_HOP_HEADERS.has(lowerName)) continue;
|
|
523
|
+
if (lowerName.startsWith("x-localghost-")) continue;
|
|
524
|
+
stripped[name] = value;
|
|
525
|
+
}
|
|
526
|
+
return stripped;
|
|
527
|
+
}
|
|
528
|
+
function redactRelayHeaders(headers) {
|
|
529
|
+
const redacted = {};
|
|
530
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
531
|
+
if (typeof value === "undefined") continue;
|
|
532
|
+
redacted[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? "[redacted]" : value;
|
|
533
|
+
}
|
|
534
|
+
return redacted;
|
|
535
|
+
}
|
|
536
|
+
function redactRelayLogUrl(input) {
|
|
537
|
+
const url = new URL(input, "http://localghost.invalid");
|
|
538
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
539
|
+
if (TOKEN_QUERY_PATTERN.test(key)) {
|
|
540
|
+
url.searchParams.set(key, "[redacted]");
|
|
476
541
|
}
|
|
477
|
-
}
|
|
542
|
+
}
|
|
543
|
+
return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
|
|
478
544
|
}
|
|
479
|
-
function
|
|
545
|
+
function renderRelayOfflineResponse() {
|
|
480
546
|
return {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
values: {
|
|
486
|
-
...defaults?.values ?? {},
|
|
487
|
-
...preview?.values ?? {}
|
|
547
|
+
status: 503,
|
|
548
|
+
headers: {
|
|
549
|
+
"content-type": "text/html; charset=utf-8",
|
|
550
|
+
"cache-control": "no-store"
|
|
488
551
|
},
|
|
489
|
-
|
|
490
|
-
|
|
552
|
+
body: [
|
|
553
|
+
"<!doctype html>",
|
|
554
|
+
"<html>",
|
|
555
|
+
'<head><meta charset="utf-8"><title>Preview offline</title></head>',
|
|
556
|
+
"<body><h1>Preview offline</h1><p>The local agent is not connected. Try again later.</p></body>",
|
|
557
|
+
"</html>"
|
|
558
|
+
].join("")
|
|
491
559
|
};
|
|
492
560
|
}
|
|
493
|
-
|
|
561
|
+
|
|
562
|
+
// src/ghost-tunnel-store.ts
|
|
563
|
+
import { randomUUID } from "crypto";
|
|
564
|
+
var DEFAULT_GHOST_TUNNEL_RESPONSE_TTL_SECONDS = 60;
|
|
565
|
+
function base64Encode(value) {
|
|
566
|
+
return value.toString("base64");
|
|
567
|
+
}
|
|
568
|
+
function encodeGhostTunnelBody(value) {
|
|
569
|
+
return base64Encode(Buffer.isBuffer(value) ? value : Buffer.from(value));
|
|
570
|
+
}
|
|
571
|
+
function decodeGhostTunnelBody(value) {
|
|
572
|
+
return value ? Buffer.from(value, "base64") : void 0;
|
|
573
|
+
}
|
|
574
|
+
function createGhostTunnelQueuedRequest(input) {
|
|
575
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
576
|
+
const bodyBase64 = typeof input.body === "undefined" ? void 0 : encodeGhostTunnelBody(input.body);
|
|
494
577
|
return {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
578
|
+
id: randomUUID(),
|
|
579
|
+
host: input.host,
|
|
580
|
+
method: input.method.toUpperCase(),
|
|
581
|
+
path: input.path,
|
|
582
|
+
headers: input.headers ?? {},
|
|
583
|
+
createdAt: now.toISOString(),
|
|
584
|
+
expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString(),
|
|
585
|
+
...bodyBase64 ? { bodyBase64 } : {}
|
|
499
586
|
};
|
|
500
587
|
}
|
|
501
|
-
function
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (!input.path) return url;
|
|
511
|
-
return `${url}${input.path.replace(/^\/+/, "")}`;
|
|
588
|
+
function createGhostTunnelRouteHeartbeat(input) {
|
|
589
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
590
|
+
return {
|
|
591
|
+
host: input.host,
|
|
592
|
+
agentId: input.agentId,
|
|
593
|
+
target: input.target,
|
|
594
|
+
updatedAt: now.toISOString(),
|
|
595
|
+
expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString()
|
|
596
|
+
};
|
|
512
597
|
}
|
|
513
|
-
function
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
|
|
517
|
-
return [...new Set(urls)];
|
|
598
|
+
function isExpired(expiresAt, now = /* @__PURE__ */ new Date()) {
|
|
599
|
+
const timestamp = Date.parse(expiresAt);
|
|
600
|
+
return Number.isNaN(timestamp) || timestamp <= now.getTime();
|
|
518
601
|
}
|
|
519
|
-
function
|
|
520
|
-
|
|
521
|
-
const input = getPreviewDefaults(config.preview, defaults);
|
|
522
|
-
if (!input.domain || !input.route || !input.project || !input.owner) return void 0;
|
|
523
|
-
return constructGhostTunnelUrl({
|
|
524
|
-
domain: input.domain,
|
|
525
|
-
route: input.route,
|
|
526
|
-
project: input.project,
|
|
527
|
-
owner: input.owner,
|
|
528
|
-
values: input.values,
|
|
529
|
-
...input.path ? { path: input.path } : {},
|
|
530
|
-
...input.protocol ? { protocol: input.protocol } : {},
|
|
531
|
-
ghostTunnel: config
|
|
532
|
-
});
|
|
602
|
+
function serializeJson(value) {
|
|
603
|
+
return JSON.stringify(value);
|
|
533
604
|
}
|
|
534
|
-
function
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const spreadWidth = spreadIndex >= 0 ? parts.length - config.tags.length + 1 : 1;
|
|
541
|
-
let partIndex = 0;
|
|
542
|
-
for (const [tagIndex, tag] of config.tags.entries()) {
|
|
543
|
-
const value = tagIndex === spreadIndex ? parts.slice(partIndex, partIndex + spreadWidth).join(config.separator) : parts[partIndex];
|
|
544
|
-
if (!value || !isValidHostLabel(value)) return null;
|
|
545
|
-
if (tagIndex !== spreadIndex && value.includes(config.separator)) return null;
|
|
546
|
-
namespace[tag] = value;
|
|
547
|
-
partIndex += tagIndex === spreadIndex ? spreadWidth : 1;
|
|
605
|
+
function parseJson(value) {
|
|
606
|
+
if (typeof value !== "string") return null;
|
|
607
|
+
try {
|
|
608
|
+
return JSON.parse(value);
|
|
609
|
+
} catch {
|
|
610
|
+
return null;
|
|
548
611
|
}
|
|
549
|
-
return namespace;
|
|
550
612
|
}
|
|
551
|
-
function
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
requireHttps: true,
|
|
561
|
-
requireAuth: true
|
|
562
|
-
};
|
|
613
|
+
function keyPart(value) {
|
|
614
|
+
return value.toLowerCase().replace(/[^a-z0-9._:-]/g, "_");
|
|
615
|
+
}
|
|
616
|
+
var MemoryGhostTunnelStore = class {
|
|
617
|
+
routes = /* @__PURE__ */ new Map();
|
|
618
|
+
queues = /* @__PURE__ */ new Map();
|
|
619
|
+
responses = /* @__PURE__ */ new Map();
|
|
620
|
+
async heartbeatRoute(route) {
|
|
621
|
+
this.routes.set(route.host, route);
|
|
563
622
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
enabled,
|
|
571
|
-
mode: parseGhostTunnelMode(config.mode),
|
|
572
|
-
domains,
|
|
573
|
-
subdomain,
|
|
574
|
-
namespace: resolveNamespaceConfig(config.namespace),
|
|
575
|
-
...config.preview ? { preview: config.preview } : {},
|
|
576
|
-
displayUrls: [],
|
|
577
|
-
requireHttps: config.requireHttps ?? true,
|
|
578
|
-
requireAuth: config.requireAuth ?? true
|
|
579
|
-
};
|
|
580
|
-
if (!enabled) {
|
|
581
|
-
return resolved;
|
|
623
|
+
async getRoute(host) {
|
|
624
|
+
const route = this.routes.get(host);
|
|
625
|
+
if (!route) return null;
|
|
626
|
+
if (!isExpired(route.expiresAt)) return route;
|
|
627
|
+
this.routes.delete(host);
|
|
628
|
+
return null;
|
|
582
629
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
630
|
+
async enqueueRequest(request) {
|
|
631
|
+
const queue = this.queues.get(request.host) ?? [];
|
|
632
|
+
queue.push(request);
|
|
633
|
+
this.queues.set(request.host, queue);
|
|
634
|
+
}
|
|
635
|
+
async claimRequest(host) {
|
|
636
|
+
const queue = this.queues.get(host) ?? [];
|
|
637
|
+
while (queue.length > 0) {
|
|
638
|
+
const request = queue.shift();
|
|
639
|
+
if (request && !isExpired(request.expiresAt)) {
|
|
640
|
+
return request;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
597
644
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
603
|
-
function constructGhostTunnelHost(input) {
|
|
604
|
-
const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
|
|
605
|
-
if (!config.enabled) {
|
|
606
|
-
throw new Error("Ghost tunnel is not enabled.");
|
|
645
|
+
async writeResponse(response, ttlSeconds) {
|
|
646
|
+
this.responses.set(response.id, {
|
|
647
|
+
value: response,
|
|
648
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1e3).toISOString()
|
|
649
|
+
});
|
|
607
650
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const slug = createNamespaceSlug(config.namespace, namespaceValues);
|
|
615
|
-
return `${slug}.${getGhostTunnelEntryHost(input.domain, config)}`;
|
|
616
|
-
}
|
|
617
|
-
function constructGhostTunnelUrl(input) {
|
|
618
|
-
const protocol = input.protocol ?? "https";
|
|
619
|
-
const host = constructGhostTunnelHost(input);
|
|
620
|
-
const url = new URL(`${protocol}://${host}/`);
|
|
621
|
-
if (input.path) {
|
|
622
|
-
url.pathname = `/${input.path.replace(/^\/+/, "")}`;
|
|
651
|
+
async readResponse(requestId) {
|
|
652
|
+
const response = this.responses.get(requestId);
|
|
653
|
+
if (!response) return null;
|
|
654
|
+
if (!isExpired(response.expiresAt)) return response.value;
|
|
655
|
+
this.responses.delete(requestId);
|
|
656
|
+
return null;
|
|
623
657
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
658
|
+
async cleanup(requestId) {
|
|
659
|
+
this.responses.delete(requestId);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
function createMemoryGhostTunnelStore() {
|
|
663
|
+
return new MemoryGhostTunnelStore();
|
|
664
|
+
}
|
|
665
|
+
var RedisGhostTunnelStore = class {
|
|
666
|
+
url;
|
|
667
|
+
token;
|
|
668
|
+
namespace;
|
|
669
|
+
fetchImpl;
|
|
670
|
+
constructor(options) {
|
|
671
|
+
this.url = options.url.replace(/\/+$/, "");
|
|
672
|
+
this.token = options.token;
|
|
673
|
+
this.namespace = options.namespace ?? "localghost";
|
|
674
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
675
|
+
}
|
|
676
|
+
key(kind, id) {
|
|
677
|
+
return `${this.namespace}:ghost-tunnel:${kind}:${keyPart(id)}`;
|
|
678
|
+
}
|
|
679
|
+
async command(command, ...args) {
|
|
680
|
+
const response = await this.fetchImpl(this.url, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: {
|
|
683
|
+
authorization: `Bearer ${this.token}`,
|
|
684
|
+
"content-type": "application/json"
|
|
685
|
+
},
|
|
686
|
+
body: JSON.stringify([command, ...args])
|
|
687
|
+
});
|
|
688
|
+
if (!response.ok) {
|
|
689
|
+
throw new Error(`Redis Ghost Tunnel command failed: ${response.status} ${response.statusText}`);
|
|
690
|
+
}
|
|
691
|
+
const payload = await response.json();
|
|
692
|
+
if (payload.error) {
|
|
693
|
+
throw new Error(`Redis Ghost Tunnel command failed: ${payload.error}`);
|
|
694
|
+
}
|
|
695
|
+
return typeof payload.result === "undefined" ? null : payload.result;
|
|
696
|
+
}
|
|
697
|
+
async heartbeatRoute(route, ttlSeconds) {
|
|
698
|
+
await this.command("SET", this.key("route", route.host), serializeJson(route), "EX", ttlSeconds);
|
|
699
|
+
}
|
|
700
|
+
async getRoute(host) {
|
|
701
|
+
const route = parseJson(await this.command("GET", this.key("route", host)));
|
|
702
|
+
return route && !isExpired(route.expiresAt) ? route : null;
|
|
703
|
+
}
|
|
704
|
+
async enqueueRequest(request, ttlSeconds) {
|
|
705
|
+
const queueKey = this.key("queue", request.host);
|
|
706
|
+
await this.command("RPUSH", queueKey, serializeJson(request));
|
|
707
|
+
await this.command("EXPIRE", queueKey, ttlSeconds);
|
|
708
|
+
}
|
|
709
|
+
async claimRequest(host) {
|
|
710
|
+
const queueKey = this.key("queue", host);
|
|
711
|
+
while (true) {
|
|
712
|
+
const request = parseJson(await this.command("LPOP", queueKey));
|
|
713
|
+
if (!request) return null;
|
|
714
|
+
if (!isExpired(request.expiresAt)) return request;
|
|
631
715
|
}
|
|
632
716
|
}
|
|
633
|
-
|
|
717
|
+
async writeResponse(response, ttlSeconds) {
|
|
718
|
+
await this.command("SET", this.key("response", response.id), serializeJson(response), "EX", ttlSeconds);
|
|
719
|
+
}
|
|
720
|
+
async readResponse(requestId) {
|
|
721
|
+
return parseJson(await this.command("GET", this.key("response", requestId)));
|
|
722
|
+
}
|
|
723
|
+
async cleanup(requestId) {
|
|
724
|
+
await this.command("DEL", this.key("response", requestId));
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
function createRedisGhostTunnelStore(options) {
|
|
728
|
+
return new RedisGhostTunnelStore(options);
|
|
729
|
+
}
|
|
730
|
+
function resolveRedisGhostTunnelEnv(env = process.env) {
|
|
731
|
+
const candidates = [
|
|
732
|
+
env.LOCALGHOST_REDIS_REST_URL && env.LOCALGHOST_REDIS_REST_TOKEN ? { url: env.LOCALGHOST_REDIS_REST_URL, token: env.LOCALGHOST_REDIS_REST_TOKEN, source: "localghost" } : null,
|
|
733
|
+
env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN ? { url: env.UPSTASH_REDIS_REST_URL, token: env.UPSTASH_REDIS_REST_TOKEN, source: "upstash" } : null,
|
|
734
|
+
env.KV_REST_API_URL && env.KV_REST_API_TOKEN ? { url: env.KV_REST_API_URL, token: env.KV_REST_API_TOKEN, source: "vercel-kv" } : null,
|
|
735
|
+
env.REDIS_REST_API_URL && env.REDIS_REST_API_TOKEN ? { url: env.REDIS_REST_API_URL, token: env.REDIS_REST_API_TOKEN, source: "redis" } : null
|
|
736
|
+
];
|
|
737
|
+
const match = candidates.find((candidate) => Boolean(candidate));
|
|
738
|
+
if (!match) {
|
|
739
|
+
throw new Error("Ghost Tunnel Redis transport requires REST env vars: UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN, KV_REST_API_URL/KV_REST_API_TOKEN, or LOCALGHOST_REDIS_REST_URL/LOCALGHOST_REDIS_REST_TOKEN.");
|
|
740
|
+
}
|
|
741
|
+
return match;
|
|
742
|
+
}
|
|
743
|
+
function createRedisGhostTunnelStoreFromEnv(input = {}) {
|
|
744
|
+
const resolved = resolveRedisGhostTunnelEnv(input.env);
|
|
745
|
+
return createRedisGhostTunnelStore({
|
|
746
|
+
url: resolved.url,
|
|
747
|
+
token: resolved.token,
|
|
748
|
+
...input.namespace ? { namespace: input.namespace } : {},
|
|
749
|
+
...input.fetch ? { fetch: input.fetch } : {}
|
|
750
|
+
});
|
|
634
751
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
752
|
+
|
|
753
|
+
// src/ghost-agent.ts
|
|
754
|
+
function isStopped(signal, localSignal) {
|
|
755
|
+
return localSignal.aborted || signal?.aborted === true;
|
|
756
|
+
}
|
|
757
|
+
function wait(ms, signal, localSignal) {
|
|
758
|
+
if (isStopped(signal, localSignal)) return Promise.resolve();
|
|
759
|
+
return new Promise((resolve3) => {
|
|
760
|
+
const timeout = setTimeout(resolve3, ms);
|
|
761
|
+
const stop = () => {
|
|
762
|
+
clearTimeout(timeout);
|
|
763
|
+
resolve3();
|
|
764
|
+
};
|
|
765
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
766
|
+
localSignal.addEventListener("abort", stop, { once: true });
|
|
767
|
+
});
|
|
640
768
|
}
|
|
641
|
-
function
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
769
|
+
function toHeaderRecord(headers) {
|
|
770
|
+
const result = {};
|
|
771
|
+
headers.forEach((value, name) => {
|
|
772
|
+
result[name] = value;
|
|
773
|
+
});
|
|
774
|
+
return result;
|
|
645
775
|
}
|
|
646
|
-
function
|
|
647
|
-
|
|
648
|
-
if (!config.enabled) return [];
|
|
649
|
-
if (config.displayUrls.length > 0) return config.displayUrls;
|
|
650
|
-
const displayUrl = getGhostTunnelDisplayUrl(config, defaults);
|
|
651
|
-
return displayUrl ? [displayUrl] : [];
|
|
776
|
+
function hasRequestBody(method) {
|
|
777
|
+
return method !== "GET" && method !== "HEAD";
|
|
652
778
|
}
|
|
653
|
-
function
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
779
|
+
async function serveGhostTunnelLocalRequest(input) {
|
|
780
|
+
const fetchImpl = input.fetch ?? fetch;
|
|
781
|
+
const localUrl = new URL(`${input.target.protocol}://${input.target.host}:${input.target.port}/`);
|
|
782
|
+
const requestPath = new URL(input.request.path, "http://localghost.invalid");
|
|
783
|
+
localUrl.pathname = requestPath.pathname;
|
|
784
|
+
localUrl.search = requestPath.search;
|
|
785
|
+
try {
|
|
786
|
+
const body = hasRequestBody(input.request.method) ? decodeGhostTunnelBody(input.request.bodyBase64) : void 0;
|
|
787
|
+
const response = await fetchImpl(localUrl, {
|
|
788
|
+
method: input.request.method,
|
|
789
|
+
headers: {
|
|
790
|
+
...stripRelayForwardHeaders(input.request.headers),
|
|
791
|
+
"x-forwarded-host": input.request.host,
|
|
792
|
+
"x-localghost-tunnel": "1"
|
|
793
|
+
},
|
|
794
|
+
...body ? { body } : {}
|
|
795
|
+
});
|
|
796
|
+
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
797
|
+
if (responseBody.byteLength > input.maxResponseBodyBytes) {
|
|
798
|
+
throw new Error(`Ghost Tunnel response exceeded ${input.maxResponseBodyBytes} bytes.`);
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
id: input.request.id,
|
|
802
|
+
status: response.status,
|
|
803
|
+
headers: toHeaderRecord(response.headers),
|
|
804
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
805
|
+
...responseBody.byteLength > 0 ? { bodyBase64: encodeGhostTunnelBody(responseBody) } : {}
|
|
806
|
+
};
|
|
807
|
+
} catch (error) {
|
|
808
|
+
return {
|
|
809
|
+
id: input.request.id,
|
|
810
|
+
status: 502,
|
|
811
|
+
headers: {
|
|
812
|
+
"content-type": "text/plain; charset=utf-8",
|
|
813
|
+
"cache-control": "no-store"
|
|
814
|
+
},
|
|
815
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
816
|
+
error: error instanceof Error ? error.message : String(error),
|
|
817
|
+
bodyBase64: encodeGhostTunnelBody("Ghost Tunnel local target failed.")
|
|
818
|
+
};
|
|
819
|
+
}
|
|
657
820
|
}
|
|
658
|
-
function
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
821
|
+
async function heartbeatRoutes(input) {
|
|
822
|
+
for (const entry of input.entries) {
|
|
823
|
+
const target = assertRelayLocalTarget({ host: input.targetHost, port: entry.port });
|
|
824
|
+
await input.store.heartbeatRoute(createGhostTunnelRouteHeartbeat({
|
|
825
|
+
host: entry.host,
|
|
826
|
+
agentId: input.agentId,
|
|
827
|
+
target,
|
|
828
|
+
ttlSeconds: input.routeTtlSeconds
|
|
829
|
+
}), input.routeTtlSeconds);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async function claimAndServe(input) {
|
|
833
|
+
const request = await input.store.claimRequest(input.entry.host);
|
|
834
|
+
if (!request) return false;
|
|
835
|
+
const target = assertRelayLocalTarget({ host: input.targetHost, port: input.entry.port });
|
|
836
|
+
const response = await serveGhostTunnelLocalRequest({
|
|
837
|
+
request,
|
|
838
|
+
target,
|
|
839
|
+
maxResponseBodyBytes: input.maxResponseBodyBytes,
|
|
840
|
+
...input.fetch ? { fetch: input.fetch } : {}
|
|
841
|
+
});
|
|
842
|
+
await input.store.writeResponse(response, input.requestTtlSeconds);
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
function startGhostTunnelAgent(options) {
|
|
846
|
+
const controller = new AbortController();
|
|
847
|
+
const localSignal = controller.signal;
|
|
848
|
+
const signal = options.signal;
|
|
849
|
+
const agentId = options.agentId ?? `localghost-${randomUUID2()}`;
|
|
850
|
+
const targetHost = options.targetHost ?? "127.0.0.1";
|
|
851
|
+
const routeTtlSeconds = options.routeTtlSeconds ?? 30;
|
|
852
|
+
const requestTtlSeconds = options.requestTtlSeconds ?? 60;
|
|
853
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
854
|
+
const maxResponseBodyBytes = options.maxResponseBodyBytes ?? 5 * 1024 * 1024;
|
|
855
|
+
const done = (async () => {
|
|
856
|
+
if (options.entries.length === 0) {
|
|
857
|
+
throw new Error("Ghost Tunnel agent requires at least one .ghosttunnel entry.");
|
|
858
|
+
}
|
|
859
|
+
options.log?.(`localghost tunnel agent ${agentId}`);
|
|
860
|
+
for (const entry of options.entries) {
|
|
861
|
+
options.log?.(` ${entry.host} -> ${targetHost}:${entry.port}`);
|
|
862
|
+
}
|
|
863
|
+
let lastHeartbeat = 0;
|
|
864
|
+
while (!isStopped(signal, localSignal)) {
|
|
865
|
+
const now = Date.now();
|
|
866
|
+
if (now - lastHeartbeat >= Math.max(1e3, Math.floor(routeTtlSeconds * 1e3 / 3))) {
|
|
867
|
+
await heartbeatRoutes({
|
|
868
|
+
entries: options.entries,
|
|
869
|
+
store: options.store,
|
|
870
|
+
agentId,
|
|
871
|
+
targetHost,
|
|
872
|
+
routeTtlSeconds
|
|
873
|
+
});
|
|
874
|
+
lastHeartbeat = now;
|
|
875
|
+
}
|
|
876
|
+
let served = false;
|
|
877
|
+
for (const entry of options.entries) {
|
|
878
|
+
served = await claimAndServe({
|
|
879
|
+
entry,
|
|
880
|
+
store: options.store,
|
|
881
|
+
targetHost,
|
|
882
|
+
requestTtlSeconds,
|
|
883
|
+
maxResponseBodyBytes,
|
|
884
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
885
|
+
}) || served;
|
|
886
|
+
}
|
|
887
|
+
if (!served) {
|
|
888
|
+
await wait(pollIntervalMs, signal, localSignal);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
})();
|
|
671
892
|
return {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
domain: normalizedDomain
|
|
893
|
+
agentId,
|
|
894
|
+
stop() {
|
|
895
|
+
controller.abort();
|
|
896
|
+
},
|
|
897
|
+
done
|
|
678
898
|
};
|
|
679
899
|
}
|
|
680
|
-
function assertSecureGhostTunnelRequest(input) {
|
|
681
|
-
const config = toGhostTunnelConfig(input.ghostTunnel);
|
|
682
|
-
if (!config.enabled) {
|
|
683
|
-
throw new Error("Ghost tunnel is not enabled.");
|
|
684
|
-
}
|
|
685
|
-
if (config.requireHttps && input.protocol !== "https") {
|
|
686
|
-
throw new Error("Ghost tunnel requests must use HTTPS.");
|
|
687
|
-
}
|
|
688
|
-
if (config.requireAuth && input.authenticated !== true) {
|
|
689
|
-
throw new Error("Ghost tunnel requests must be authenticated.");
|
|
690
|
-
}
|
|
691
|
-
const route = parseGhostTunnelHost(input.host, input.domain, config);
|
|
692
|
-
if (!route) {
|
|
693
|
-
throw new Error(`Host is not a valid ghost tunnel host for ${input.domain}.`);
|
|
694
|
-
}
|
|
695
|
-
return route;
|
|
696
|
-
}
|
|
697
900
|
|
|
698
|
-
// src/
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
901
|
+
// src/ghost-transport.ts
|
|
902
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
903
|
+
import { isIP } from "net";
|
|
904
|
+
|
|
905
|
+
// src/tunnel.ts
|
|
906
|
+
import { domainToASCII as domainToASCII2 } from "url";
|
|
907
|
+
var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
|
|
908
|
+
var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
|
|
909
|
+
var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
|
|
910
|
+
var DEFAULT_GHOST_TUNNEL_MODE = "manual";
|
|
911
|
+
var DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY = "same-project";
|
|
912
|
+
var DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND = "none";
|
|
913
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER = "vercel-redis";
|
|
914
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV = "auto";
|
|
915
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE = "localghost";
|
|
916
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS = 25e3;
|
|
917
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS = 250;
|
|
918
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS = 30;
|
|
919
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS = 60;
|
|
920
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
|
|
921
|
+
var DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
|
|
922
|
+
function isResolvedGhostTunnelConfig(value) {
|
|
923
|
+
return typeof value === "object" && value !== null && "enabled" in value;
|
|
708
924
|
}
|
|
709
|
-
function
|
|
710
|
-
return
|
|
925
|
+
function toGhostTunnelConfig(options) {
|
|
926
|
+
return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
|
|
711
927
|
}
|
|
712
|
-
function
|
|
713
|
-
const
|
|
714
|
-
if (
|
|
715
|
-
|
|
928
|
+
function stripHostPort(value) {
|
|
929
|
+
const trimmed = value.trim().toLowerCase();
|
|
930
|
+
if (trimmed.startsWith("[") || trimmed.includes("/")) return "";
|
|
931
|
+
const portSeparator = trimmed.lastIndexOf(":");
|
|
932
|
+
if (portSeparator === -1) return trimmed;
|
|
933
|
+
const port = trimmed.slice(portSeparator + 1);
|
|
934
|
+
return /^\d+$/.test(port) ? trimmed.slice(0, portSeparator) : trimmed;
|
|
716
935
|
}
|
|
717
|
-
function
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
936
|
+
function normalizeDomain(value) {
|
|
937
|
+
const host = stripHostPort(value.replace(/^\*\./, ""));
|
|
938
|
+
const ascii = domainToASCII2(host);
|
|
939
|
+
if (!ascii || ascii.length > 253 || ascii.includes("..")) return null;
|
|
940
|
+
if (ascii.startsWith(".") || ascii.endsWith(".")) return null;
|
|
941
|
+
if (ascii.includes("*")) return null;
|
|
942
|
+
if (!ascii.split(".").every(isValidHostLabel)) return null;
|
|
943
|
+
return ascii;
|
|
721
944
|
}
|
|
722
|
-
function
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
945
|
+
function isValidHostLabel(value) {
|
|
946
|
+
return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
|
947
|
+
}
|
|
948
|
+
function isValidNamespaceTag(value) {
|
|
949
|
+
return /^[a-z][a-z0-9_]*$/i.test(value);
|
|
950
|
+
}
|
|
951
|
+
function isNamespaceTagList(options) {
|
|
952
|
+
return Array.isArray(options);
|
|
953
|
+
}
|
|
954
|
+
function assertValidSubdomain(value) {
|
|
955
|
+
if (!isValidHostLabel(value)) {
|
|
956
|
+
throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
|
|
728
957
|
}
|
|
729
958
|
}
|
|
730
|
-
function
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
959
|
+
function normalizeDomains(domains) {
|
|
960
|
+
const values = typeof domains === "string" ? [domains] : [...domains ?? []];
|
|
961
|
+
const normalized = values.map((value) => value.trim()).filter(Boolean).map((value) => {
|
|
962
|
+
const domain = normalizeDomain(value);
|
|
963
|
+
if (!domain) throw new Error(`Invalid ghost tunnel domain: ${value}`);
|
|
964
|
+
return domain;
|
|
965
|
+
});
|
|
966
|
+
return [...new Set(normalized)];
|
|
734
967
|
}
|
|
735
|
-
function
|
|
736
|
-
return
|
|
968
|
+
function parseGhostTunnelMode(value) {
|
|
969
|
+
return value ?? DEFAULT_GHOST_TUNNEL_MODE;
|
|
737
970
|
}
|
|
738
|
-
function
|
|
739
|
-
|
|
971
|
+
function parseGhostTunnelAdapterStrategy(value) {
|
|
972
|
+
if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY;
|
|
973
|
+
if (value === "same-project" || value === "separate-relay") return value;
|
|
974
|
+
throw new Error(`Unsupported ghost tunnel adapter strategy: ${String(value)}`);
|
|
740
975
|
}
|
|
741
|
-
function
|
|
742
|
-
return
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
...options.configFiles ? { configFiles: options.configFiles } : {},
|
|
746
|
-
...options.configPattern ? { configPattern: options.configPattern } : {}
|
|
747
|
-
};
|
|
976
|
+
function parseGhostTunnelTransportKind(value) {
|
|
977
|
+
if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TRANSPORT_KIND;
|
|
978
|
+
if (value === "none" || value === "ip" || value === "tunnel") return value;
|
|
979
|
+
throw new Error(`Unsupported ghost tunnel transport: ${String(value)}`);
|
|
748
980
|
}
|
|
749
|
-
function
|
|
750
|
-
if (
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
981
|
+
function parsePositiveInteger(value, fallback, name) {
|
|
982
|
+
if (typeof value === "undefined") return fallback;
|
|
983
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
984
|
+
throw new Error(`Invalid ghost tunnel ${name}: ${value}`);
|
|
985
|
+
}
|
|
986
|
+
return value;
|
|
754
987
|
}
|
|
755
|
-
function
|
|
756
|
-
|
|
988
|
+
function parseTunnelStoreProvider(value) {
|
|
989
|
+
if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_PROVIDER;
|
|
990
|
+
if (value === "vercel-redis" || value === "redis") return value;
|
|
991
|
+
throw new Error(`Unsupported ghost tunnel tunnel store provider: ${String(value)}`);
|
|
757
992
|
}
|
|
758
|
-
function
|
|
759
|
-
|
|
993
|
+
function parseTunnelStoreEnv(value) {
|
|
994
|
+
if (typeof value === "undefined") return DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_ENV;
|
|
995
|
+
if (value === "auto") return value;
|
|
996
|
+
throw new Error(`Unsupported ghost tunnel tunnel store env: ${String(value)}`);
|
|
760
997
|
}
|
|
761
|
-
function
|
|
762
|
-
|
|
998
|
+
function parseTunnelStoreNamespace(value) {
|
|
999
|
+
const namespace = value ?? DEFAULT_GHOST_TUNNEL_TUNNEL_STORE_NAMESPACE;
|
|
1000
|
+
if (!/^[a-z][a-z0-9:_-]{0,63}$/i.test(namespace)) {
|
|
1001
|
+
throw new Error(`Invalid ghost tunnel tunnel store namespace: ${namespace}`);
|
|
1002
|
+
}
|
|
1003
|
+
return namespace;
|
|
763
1004
|
}
|
|
764
|
-
function
|
|
765
|
-
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (alias && !seen.has(alias)) {
|
|
770
|
-
aliases.push({ host: alias, port: entry.port, target: `127.0.0.1:${entry.port}` });
|
|
771
|
-
seen.add(alias);
|
|
772
|
-
}
|
|
1005
|
+
function resolveGhostTunnelAdapter(input) {
|
|
1006
|
+
if (!input) return void 0;
|
|
1007
|
+
const provider = typeof input === "string" ? input : input.provider;
|
|
1008
|
+
if (provider !== "vercel") {
|
|
1009
|
+
throw new Error(`Unsupported ghost tunnel adapter provider: ${String(provider)}`);
|
|
773
1010
|
}
|
|
774
|
-
return
|
|
1011
|
+
return {
|
|
1012
|
+
provider,
|
|
1013
|
+
strategy: typeof input === "string" ? DEFAULT_GHOST_TUNNEL_ADAPTER_STRATEGY : parseGhostTunnelAdapterStrategy(input.strategy)
|
|
1014
|
+
};
|
|
775
1015
|
}
|
|
776
|
-
function
|
|
777
|
-
|
|
1016
|
+
function getLegacyGhostTunnelTransport(input) {
|
|
1017
|
+
if (!input || typeof input === "string" || !("transport" in input)) return void 0;
|
|
1018
|
+
return input.transport;
|
|
778
1019
|
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
if (
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1020
|
+
function resolveGhostTunnelTransport(input) {
|
|
1021
|
+
if (!input) {
|
|
1022
|
+
return { kind: "none" };
|
|
1023
|
+
}
|
|
1024
|
+
const kind = typeof input === "string" ? parseGhostTunnelTransportKind(input) : parseGhostTunnelTransportKind(input.kind);
|
|
1025
|
+
if (kind === "ip") {
|
|
1026
|
+
return {
|
|
1027
|
+
kind,
|
|
1028
|
+
allowPrivateNetworkAddress: typeof input === "string" ? false : input.kind === "ip" ? input.allowPrivateNetworkAddress ?? false : false
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
if (kind === "tunnel") {
|
|
1032
|
+
const config = typeof input === "string" || input.kind !== "tunnel" ? void 0 : input;
|
|
1033
|
+
const store = config?.store ?? {};
|
|
1034
|
+
return {
|
|
1035
|
+
kind,
|
|
1036
|
+
store: {
|
|
1037
|
+
provider: parseTunnelStoreProvider(store.provider),
|
|
1038
|
+
env: parseTunnelStoreEnv(store.env),
|
|
1039
|
+
namespace: parseTunnelStoreNamespace(store.namespace)
|
|
1040
|
+
},
|
|
1041
|
+
waitMs: parsePositiveInteger(config?.waitMs, DEFAULT_GHOST_TUNNEL_TUNNEL_WAIT_MS, "tunnel waitMs"),
|
|
1042
|
+
pollIntervalMs: parsePositiveInteger(config?.pollIntervalMs, DEFAULT_GHOST_TUNNEL_TUNNEL_POLL_INTERVAL_MS, "tunnel pollIntervalMs"),
|
|
1043
|
+
routeTtlSeconds: parsePositiveInteger(config?.routeTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_ROUTE_TTL_SECONDS, "tunnel routeTtlSeconds"),
|
|
1044
|
+
requestTtlSeconds: parsePositiveInteger(config?.requestTtlSeconds, DEFAULT_GHOST_TUNNEL_TUNNEL_REQUEST_TTL_SECONDS, "tunnel requestTtlSeconds"),
|
|
1045
|
+
maxRequestBodyBytes: parsePositiveInteger(config?.maxRequestBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_REQUEST_BODY_BYTES, "tunnel maxRequestBodyBytes"),
|
|
1046
|
+
maxResponseBodyBytes: parsePositiveInteger(config?.maxResponseBodyBytes, DEFAULT_GHOST_TUNNEL_TUNNEL_MAX_RESPONSE_BODY_BYTES, "tunnel maxResponseBodyBytes")
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
return { kind: "none" };
|
|
788
1050
|
}
|
|
789
|
-
function
|
|
790
|
-
|
|
1051
|
+
function resolveNamespaceConfig(options) {
|
|
1052
|
+
const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
|
|
1053
|
+
let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
|
|
1054
|
+
let spreadTag = tags.includes("project") ? "project" : void 0;
|
|
1055
|
+
if (options && !isNamespaceTagList(options)) {
|
|
1056
|
+
separator = options.separator ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
|
|
1057
|
+
spreadTag = options.spreadTag === false ? false : options.spreadTag ?? spreadTag;
|
|
1058
|
+
}
|
|
1059
|
+
if (tags.length === 0) {
|
|
1060
|
+
throw new Error("Ghost tunnel namespace must include at least one tag.");
|
|
1061
|
+
}
|
|
1062
|
+
for (const tag of tags) {
|
|
1063
|
+
if (!isValidNamespaceTag(tag)) {
|
|
1064
|
+
throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (spreadTag && !tags.includes(spreadTag)) {
|
|
1068
|
+
throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
|
|
1069
|
+
}
|
|
1070
|
+
if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
|
|
1071
|
+
throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
|
|
1072
|
+
}
|
|
1073
|
+
return {
|
|
1074
|
+
tags,
|
|
1075
|
+
separator,
|
|
1076
|
+
...spreadTag ? { spreadTag } : {}
|
|
1077
|
+
};
|
|
791
1078
|
}
|
|
792
|
-
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1079
|
+
function normalizeNamespaceValue(tag, value, separator, options = {}) {
|
|
1080
|
+
const normalized = normalizeDomain(value);
|
|
1081
|
+
if (!normalized || normalized.includes(".")) {
|
|
1082
|
+
throw new Error(`Invalid ghost tunnel namespace value for ${tag}: ${value}`);
|
|
1083
|
+
}
|
|
1084
|
+
if (!options.allowSeparator && normalized.includes(separator)) {
|
|
1085
|
+
throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
|
|
1086
|
+
}
|
|
1087
|
+
return normalized;
|
|
1088
|
+
}
|
|
1089
|
+
function createNamespaceSlug(config, values) {
|
|
1090
|
+
const parts = config.tags.map((tag) => {
|
|
1091
|
+
const value = values[tag];
|
|
1092
|
+
if (!value) {
|
|
1093
|
+
throw new Error(`Missing ghost tunnel namespace value: ${tag}`);
|
|
1094
|
+
}
|
|
1095
|
+
return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
|
|
797
1096
|
});
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
1097
|
+
const slug = parts.join(config.separator);
|
|
1098
|
+
if (!isValidHostLabel(slug)) {
|
|
1099
|
+
throw new Error(`Ghost tunnel namespace is too long for a DNS label: ${slug}`);
|
|
1100
|
+
}
|
|
1101
|
+
return slug;
|
|
1102
|
+
}
|
|
1103
|
+
function createNamespaceDisplaySlug(config, values = {}) {
|
|
1104
|
+
return config.tags.map((tag) => {
|
|
1105
|
+
const value = values[tag];
|
|
1106
|
+
if (!value) return `<${tag}>`;
|
|
1107
|
+
try {
|
|
1108
|
+
return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
|
|
1109
|
+
} catch {
|
|
1110
|
+
return `<${tag}>`;
|
|
1111
|
+
}
|
|
1112
|
+
}).join(config.separator);
|
|
1113
|
+
}
|
|
1114
|
+
function getPreviewDefaults(preview, defaults) {
|
|
1115
|
+
return {
|
|
1116
|
+
domain: preview?.domain ?? defaults?.domain,
|
|
1117
|
+
route: preview?.route ?? defaults?.route,
|
|
1118
|
+
project: preview?.project ?? defaults?.project,
|
|
1119
|
+
owner: preview?.owner ?? defaults?.owner,
|
|
1120
|
+
values: {
|
|
1121
|
+
...defaults?.values ?? {},
|
|
1122
|
+
...preview?.values ?? {}
|
|
1123
|
+
},
|
|
1124
|
+
path: preview?.path,
|
|
1125
|
+
protocol: preview?.protocol
|
|
801
1126
|
};
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1127
|
+
}
|
|
1128
|
+
function getDisplayValues(input) {
|
|
1129
|
+
return {
|
|
1130
|
+
...input.route ? { route: input.route } : {},
|
|
1131
|
+
...input.project ? { project: input.project } : {},
|
|
1132
|
+
...input.owner ? { owner: input.owner } : {},
|
|
1133
|
+
...input.values
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
function getDisplayDefaults(defaults) {
|
|
1137
|
+
return defaults;
|
|
1138
|
+
}
|
|
1139
|
+
function createDisplayUrl(config, defaults, domain) {
|
|
1140
|
+
const input = getPreviewDefaults(config.preview, getDisplayDefaults(defaults));
|
|
1141
|
+
const protocol = input.protocol ?? "https";
|
|
1142
|
+
const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input));
|
|
1143
|
+
const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input.domain ? getGhostTunnelEntryHost(input.domain, config) : `${config.subdomain}.*`;
|
|
1144
|
+
const url = `${protocol}://${slug}.${entryHost}/`;
|
|
1145
|
+
if (!input.path) return url;
|
|
1146
|
+
return `${url}${input.path.replace(/^\/+/, "")}`;
|
|
1147
|
+
}
|
|
1148
|
+
function createDisplayUrls(config, defaults) {
|
|
1149
|
+
const displayDefaults = getDisplayDefaults(defaults);
|
|
1150
|
+
const domains = config.domains.length > 0 ? config.domains : displayDefaults?.domain ? [displayDefaults.domain] : [];
|
|
1151
|
+
const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
|
|
1152
|
+
return [...new Set(urls)];
|
|
1153
|
+
}
|
|
1154
|
+
function maybeConstructPreviewUrl(config, defaults) {
|
|
1155
|
+
if (!config.preview) return void 0;
|
|
1156
|
+
const input = getPreviewDefaults(config.preview, defaults);
|
|
1157
|
+
if (!input.domain || !input.route || !input.project || !input.owner) return void 0;
|
|
1158
|
+
return constructGhostTunnelUrl({
|
|
1159
|
+
domain: input.domain,
|
|
1160
|
+
route: input.route,
|
|
1161
|
+
project: input.project,
|
|
1162
|
+
owner: input.owner,
|
|
1163
|
+
values: input.values,
|
|
1164
|
+
...input.path ? { path: input.path } : {},
|
|
1165
|
+
...input.protocol ? { protocol: input.protocol } : {},
|
|
1166
|
+
ghostTunnel: config
|
|
819
1167
|
});
|
|
1168
|
+
}
|
|
1169
|
+
function parseNamespaceSlug(slug, config) {
|
|
1170
|
+
const parts = slug.split(config.separator);
|
|
1171
|
+
if (parts.length < config.tags.length) return null;
|
|
1172
|
+
if (parts.length !== config.tags.length && !config.spreadTag) return null;
|
|
1173
|
+
const namespace = {};
|
|
1174
|
+
const spreadIndex = config.spreadTag ? config.tags.indexOf(config.spreadTag) : -1;
|
|
1175
|
+
const spreadWidth = spreadIndex >= 0 ? parts.length - config.tags.length + 1 : 1;
|
|
1176
|
+
let partIndex = 0;
|
|
1177
|
+
for (const [tagIndex, tag] of config.tags.entries()) {
|
|
1178
|
+
const value = tagIndex === spreadIndex ? parts.slice(partIndex, partIndex + spreadWidth).join(config.separator) : parts[partIndex];
|
|
1179
|
+
if (!value || !isValidHostLabel(value)) return null;
|
|
1180
|
+
if (tagIndex !== spreadIndex && value.includes(config.separator)) return null;
|
|
1181
|
+
namespace[tag] = value;
|
|
1182
|
+
partIndex += tagIndex === spreadIndex ? spreadWidth : 1;
|
|
1183
|
+
}
|
|
1184
|
+
return namespace;
|
|
1185
|
+
}
|
|
1186
|
+
function resolveGhostTunnelConfig(options, defaults) {
|
|
1187
|
+
if (options === false || typeof options === "undefined") {
|
|
1188
|
+
return {
|
|
1189
|
+
enabled: false,
|
|
1190
|
+
mode: DEFAULT_GHOST_TUNNEL_MODE,
|
|
1191
|
+
domains: [],
|
|
1192
|
+
subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
|
|
1193
|
+
namespace: resolveNamespaceConfig(void 0),
|
|
1194
|
+
displayUrls: [],
|
|
1195
|
+
requireHttps: true,
|
|
1196
|
+
requireAuth: true,
|
|
1197
|
+
transport: resolveGhostTunnelTransport(void 0)
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
const config = typeof options === "string" ? { mode: options } : options;
|
|
1201
|
+
const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
|
|
1202
|
+
assertValidSubdomain(subdomain);
|
|
1203
|
+
const domains = normalizeDomains(config.domains);
|
|
1204
|
+
const enabled = config.enabled ?? true;
|
|
1205
|
+
const adapter = resolveGhostTunnelAdapter(config.adapter);
|
|
1206
|
+
const transport = resolveGhostTunnelTransport(config.transport ?? getLegacyGhostTunnelTransport(config.adapter));
|
|
1207
|
+
const resolved = {
|
|
1208
|
+
enabled,
|
|
1209
|
+
mode: parseGhostTunnelMode(config.mode),
|
|
1210
|
+
domains,
|
|
1211
|
+
subdomain,
|
|
1212
|
+
namespace: resolveNamespaceConfig(config.namespace),
|
|
1213
|
+
...config.preview ? { preview: config.preview } : {},
|
|
1214
|
+
displayUrls: [],
|
|
1215
|
+
requireHttps: config.requireHttps ?? true,
|
|
1216
|
+
requireAuth: config.requireAuth ?? true,
|
|
1217
|
+
transport,
|
|
1218
|
+
...adapter ? { adapter } : {}
|
|
1219
|
+
};
|
|
1220
|
+
if (!enabled) {
|
|
1221
|
+
return resolved;
|
|
1222
|
+
}
|
|
1223
|
+
const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
|
|
1224
|
+
const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
|
|
1225
|
+
return {
|
|
1226
|
+
...resolved,
|
|
1227
|
+
displayUrls,
|
|
1228
|
+
...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
|
|
1229
|
+
...previewUrl ? { previewUrl } : {}
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function getGhostTunnelEntryHost(domain, options = {}) {
|
|
1233
|
+
const config = toGhostTunnelConfig(options);
|
|
1234
|
+
const normalizedDomain = normalizeDomain(domain);
|
|
1235
|
+
if (!normalizedDomain) {
|
|
1236
|
+
throw new Error(`Invalid ghost tunnel domain: ${domain}`);
|
|
1237
|
+
}
|
|
1238
|
+
return `${config.subdomain}.${normalizedDomain}`;
|
|
1239
|
+
}
|
|
1240
|
+
function getGhostTunnelWildcardHost(domain, options = {}) {
|
|
1241
|
+
return `*.${getGhostTunnelEntryHost(domain, options)}`;
|
|
1242
|
+
}
|
|
1243
|
+
function constructGhostTunnelHost(input) {
|
|
1244
|
+
const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
|
|
1245
|
+
if (!config.enabled) {
|
|
1246
|
+
throw new Error("Ghost tunnel is not enabled.");
|
|
1247
|
+
}
|
|
1248
|
+
const namespaceValues = {
|
|
1249
|
+
route: input.route,
|
|
1250
|
+
project: input.project,
|
|
1251
|
+
owner: input.owner,
|
|
1252
|
+
...input.values ?? {}
|
|
1253
|
+
};
|
|
1254
|
+
const slug = createNamespaceSlug(config.namespace, namespaceValues);
|
|
1255
|
+
return `${slug}.${getGhostTunnelEntryHost(input.domain, config)}`;
|
|
1256
|
+
}
|
|
1257
|
+
function constructGhostTunnelUrl(input) {
|
|
1258
|
+
const protocol = input.protocol ?? "https";
|
|
1259
|
+
const host = constructGhostTunnelHost(input);
|
|
1260
|
+
const url = new URL(`${protocol}://${host}/`);
|
|
1261
|
+
if (input.path) {
|
|
1262
|
+
url.pathname = `/${input.path.replace(/^\/+/, "")}`;
|
|
1263
|
+
}
|
|
1264
|
+
if (input.searchParams instanceof URLSearchParams) {
|
|
1265
|
+
url.search = input.searchParams.toString();
|
|
1266
|
+
} else if (input.searchParams) {
|
|
1267
|
+
for (const [key, value] of Object.entries(input.searchParams)) {
|
|
1268
|
+
if (typeof value !== "undefined" && value !== null) {
|
|
1269
|
+
url.searchParams.set(key, String(value));
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return url.toString();
|
|
1274
|
+
}
|
|
1275
|
+
var constructGhostTunnelURL = constructGhostTunnelUrl;
|
|
1276
|
+
function getGhostTunnelDefaultDisplayUrl(options = {}, defaults) {
|
|
1277
|
+
const config = toGhostTunnelConfig(options);
|
|
1278
|
+
if (!config.enabled) return null;
|
|
1279
|
+
return createDisplayUrl(config, defaults);
|
|
1280
|
+
}
|
|
1281
|
+
function getGhostTunnelDisplayUrl(options, defaults) {
|
|
1282
|
+
const config = toGhostTunnelConfig(options);
|
|
1283
|
+
if (!config.enabled) return null;
|
|
1284
|
+
return config.displayUrl ?? config.previewUrl ?? getGhostTunnelDefaultDisplayUrl(config, defaults);
|
|
1285
|
+
}
|
|
1286
|
+
function getGhostTunnelDisplayUrls(options, defaults) {
|
|
1287
|
+
const config = toGhostTunnelConfig(options);
|
|
1288
|
+
if (!config.enabled) return [];
|
|
1289
|
+
if (config.displayUrls.length > 0) return config.displayUrls;
|
|
1290
|
+
const displayUrl = getGhostTunnelDisplayUrl(config, defaults);
|
|
1291
|
+
return displayUrl ? [displayUrl] : [];
|
|
1292
|
+
}
|
|
1293
|
+
function getGhostTunnelPreviewUrl(options) {
|
|
1294
|
+
const config = toGhostTunnelConfig(options);
|
|
1295
|
+
if (!config.enabled) return null;
|
|
1296
|
+
return config.previewUrl ?? maybeConstructPreviewUrl(config) ?? null;
|
|
1297
|
+
}
|
|
1298
|
+
function parseGhostTunnelHost(host, domain, options = {}) {
|
|
1299
|
+
const config = toGhostTunnelConfig(options);
|
|
1300
|
+
if (!config.enabled) return null;
|
|
1301
|
+
const normalizedHost = normalizeDomain(host);
|
|
1302
|
+
const normalizedDomain = normalizeDomain(domain);
|
|
1303
|
+
if (!normalizedHost || !normalizedDomain) return null;
|
|
1304
|
+
const entryHost = getGhostTunnelEntryHost(normalizedDomain, config);
|
|
1305
|
+
const suffix = `.${entryHost}`;
|
|
1306
|
+
if (!normalizedHost.endsWith(suffix)) return null;
|
|
1307
|
+
const slug = normalizedHost.slice(0, -suffix.length);
|
|
1308
|
+
if (!isValidHostLabel(slug)) return null;
|
|
1309
|
+
const namespace = parseNamespaceSlug(slug, config.namespace);
|
|
1310
|
+
if (!namespace) return null;
|
|
820
1311
|
return {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
entries,
|
|
828
|
-
hosts,
|
|
829
|
-
requestedPort,
|
|
830
|
-
port,
|
|
831
|
-
dynamicPort,
|
|
832
|
-
bindHost,
|
|
833
|
-
primaryHost,
|
|
834
|
-
https: merged.https ?? envHttps() ?? false,
|
|
835
|
-
wwwAlias,
|
|
836
|
-
ghostTunnel,
|
|
837
|
-
...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
|
|
1312
|
+
host: normalizedHost,
|
|
1313
|
+
slug,
|
|
1314
|
+
namespace,
|
|
1315
|
+
entryHost,
|
|
1316
|
+
wildcardHost: `*.${entryHost}`,
|
|
1317
|
+
domain: normalizedDomain
|
|
838
1318
|
};
|
|
839
1319
|
}
|
|
1320
|
+
function assertSecureGhostTunnelRequest(input) {
|
|
1321
|
+
const config = toGhostTunnelConfig(input.ghostTunnel);
|
|
1322
|
+
if (!config.enabled) {
|
|
1323
|
+
throw new Error("Ghost tunnel is not enabled.");
|
|
1324
|
+
}
|
|
1325
|
+
if (config.requireHttps && input.protocol !== "https") {
|
|
1326
|
+
throw new Error("Ghost tunnel requests must use HTTPS.");
|
|
1327
|
+
}
|
|
1328
|
+
if (config.requireAuth && input.authenticated !== true) {
|
|
1329
|
+
throw new Error("Ghost tunnel requests must be authenticated.");
|
|
1330
|
+
}
|
|
1331
|
+
const route = parseGhostTunnelHost(input.host, input.domain, config);
|
|
1332
|
+
if (!route) {
|
|
1333
|
+
throw new Error(`Host is not a valid ghost tunnel host for ${input.domain}.`);
|
|
1334
|
+
}
|
|
1335
|
+
return route;
|
|
1336
|
+
}
|
|
840
1337
|
|
|
841
|
-
// src/
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1338
|
+
// src/ghost-transport.ts
|
|
1339
|
+
var DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM = "__localghost";
|
|
1340
|
+
var DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS = 10 * 60;
|
|
1341
|
+
function base64UrlEncode2(value) {
|
|
1342
|
+
return Buffer.from(value).toString("base64url");
|
|
1343
|
+
}
|
|
1344
|
+
function base64UrlDecode2(value) {
|
|
1345
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
1346
|
+
}
|
|
1347
|
+
function signPayload2(payload, secret) {
|
|
1348
|
+
return createHmac2("sha256", secret).update(payload).digest("base64url");
|
|
1349
|
+
}
|
|
1350
|
+
function secureEqual2(left, right) {
|
|
1351
|
+
const leftBuffer = Buffer.from(left);
|
|
1352
|
+
const rightBuffer = Buffer.from(right);
|
|
1353
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual2(leftBuffer, rightBuffer);
|
|
1354
|
+
}
|
|
1355
|
+
function isValidIpv42(value) {
|
|
1356
|
+
return isIP(value) === 4;
|
|
1357
|
+
}
|
|
1358
|
+
function isPrivateIpv42(value) {
|
|
1359
|
+
if (!isValidIpv42(value)) return false;
|
|
1360
|
+
const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
|
|
1361
|
+
return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
|
|
1362
|
+
}
|
|
1363
|
+
function assertGhostTunnelIpAddress(address, allowPrivateNetworkAddress = false) {
|
|
1364
|
+
const normalized = address.trim();
|
|
1365
|
+
if (!isValidIpv42(normalized)) {
|
|
1366
|
+
throw new Error(`Ghost tunnel IP transport requires a valid IPv4 address: ${address}`);
|
|
857
1367
|
}
|
|
1368
|
+
if (!allowPrivateNetworkAddress && isPrivateIpv42(normalized)) {
|
|
1369
|
+
throw new Error(`Ghost tunnel IP transport requires explicit private-network opt-in: ${normalized}`);
|
|
1370
|
+
}
|
|
1371
|
+
return normalized;
|
|
858
1372
|
}
|
|
859
|
-
|
|
860
|
-
const
|
|
1373
|
+
function assertRelayProtocol(value) {
|
|
1374
|
+
const protocol = value ?? "http";
|
|
1375
|
+
if (protocol !== "http" && protocol !== "https") {
|
|
1376
|
+
throw new Error(`Invalid ghost tunnel IP transport protocol: ${String(value)}`);
|
|
1377
|
+
}
|
|
1378
|
+
return protocol;
|
|
1379
|
+
}
|
|
1380
|
+
function resolveTransportConfig(input) {
|
|
1381
|
+
return resolveGhostTunnelConfig({
|
|
1382
|
+
enabled: true,
|
|
1383
|
+
...typeof input !== "undefined" ? { transport: input } : {}
|
|
1384
|
+
}).transport;
|
|
1385
|
+
}
|
|
1386
|
+
function resolveExpiresAt(input, now = /* @__PURE__ */ new Date()) {
|
|
1387
|
+
if (input.expiresAt) {
|
|
1388
|
+
if (Number.isNaN(Date.parse(input.expiresAt))) {
|
|
1389
|
+
throw new Error("Ghost tunnel IP transport requires a valid expiresAt value.");
|
|
1390
|
+
}
|
|
1391
|
+
return input.expiresAt;
|
|
1392
|
+
}
|
|
1393
|
+
const ttlSeconds = input.ttlSeconds ?? DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS;
|
|
1394
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
|
|
1395
|
+
throw new Error(`Ghost tunnel IP transport ttlSeconds must be a positive integer: ${ttlSeconds}`);
|
|
1396
|
+
}
|
|
1397
|
+
return new Date(now.getTime() + ttlSeconds * 1e3).toISOString();
|
|
1398
|
+
}
|
|
1399
|
+
function getBaseGhostTunnelUrl(input) {
|
|
1400
|
+
if ("url" in input) {
|
|
1401
|
+
return new URL(input.url);
|
|
1402
|
+
}
|
|
1403
|
+
return new URL(constructGhostTunnelUrl(input));
|
|
1404
|
+
}
|
|
1405
|
+
function signGhostTunnelIpTransportClaim(claim, signingSecret, options = {}) {
|
|
1406
|
+
const payload = {
|
|
1407
|
+
kind: "ip",
|
|
1408
|
+
host: assertExactRelayHost(claim.host),
|
|
1409
|
+
address: assertGhostTunnelIpAddress(claim.address, options.allowPrivateNetworkAddress),
|
|
1410
|
+
protocol: assertRelayProtocol(claim.protocol),
|
|
1411
|
+
expiresAt: resolveExpiresAt({ expiresAt: claim.expiresAt })
|
|
1412
|
+
};
|
|
1413
|
+
const encodedPayload = base64UrlEncode2(JSON.stringify(payload));
|
|
1414
|
+
const signature = signPayload2(encodedPayload, signingSecret);
|
|
861
1415
|
return {
|
|
862
|
-
|
|
863
|
-
|
|
1416
|
+
payload,
|
|
1417
|
+
token: `${encodedPayload}.${signature}`
|
|
864
1418
|
};
|
|
865
1419
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
if (env.LOCALGHOST_ENV === "production") return "LOCALGHOST_ENV=production";
|
|
871
|
-
if (env.NODE_ENV === "production") return "NODE_ENV=production";
|
|
872
|
-
if (env.VERCEL_ENV === "production") return "VERCEL_ENV=production";
|
|
873
|
-
if (env.NETLIFY === "true" && env.CONTEXT === "production") return "NETLIFY=true and CONTEXT=production";
|
|
874
|
-
if (env.CF_PAGES_BRANCH && env.CF_PAGES_BRANCH === env.CF_PAGES_PRODUCTION_BRANCH) {
|
|
875
|
-
return "CF_PAGES_BRANCH matches CF_PAGES_PRODUCTION_BRANCH";
|
|
1420
|
+
function verifyGhostTunnelIpTransportClaim(token, signingSecret, options) {
|
|
1421
|
+
const [encodedPayload, signature] = token.split(".");
|
|
1422
|
+
if (!encodedPayload || !signature || token.split(".").length !== 2) {
|
|
1423
|
+
throw new Error("Invalid ghost tunnel IP transport token.");
|
|
876
1424
|
}
|
|
877
|
-
|
|
1425
|
+
const expectedSignature = signPayload2(encodedPayload, signingSecret);
|
|
1426
|
+
if (!secureEqual2(signature, expectedSignature)) {
|
|
1427
|
+
throw new Error("Invalid ghost tunnel IP transport signature.");
|
|
1428
|
+
}
|
|
1429
|
+
const parsed = JSON.parse(base64UrlDecode2(encodedPayload));
|
|
1430
|
+
const host = assertExactRelayHost(parsed.host);
|
|
1431
|
+
const expectedHost = assertExactRelayHost(options.host);
|
|
1432
|
+
if (host !== expectedHost) {
|
|
1433
|
+
throw new Error("Ghost tunnel IP transport host mismatch.");
|
|
1434
|
+
}
|
|
1435
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1436
|
+
if (Number.isNaN(Date.parse(parsed.expiresAt)) || Date.parse(parsed.expiresAt) <= now.getTime()) {
|
|
1437
|
+
throw new Error("Ghost tunnel IP transport token has expired.");
|
|
1438
|
+
}
|
|
1439
|
+
return {
|
|
1440
|
+
kind: "ip",
|
|
1441
|
+
host,
|
|
1442
|
+
address: assertGhostTunnelIpAddress(parsed.address, options.allowPrivateNetworkAddress),
|
|
1443
|
+
protocol: assertRelayProtocol(parsed.protocol),
|
|
1444
|
+
expiresAt: parsed.expiresAt
|
|
1445
|
+
};
|
|
878
1446
|
}
|
|
879
|
-
function
|
|
880
|
-
|
|
1447
|
+
function constructGhostTunnelIpUrl(input) {
|
|
1448
|
+
const baseUrl = getBaseGhostTunnelUrl(input);
|
|
1449
|
+
const host = assertExactRelayHost(baseUrl.host);
|
|
1450
|
+
const token = signGhostTunnelIpTransportClaim({
|
|
1451
|
+
kind: "ip",
|
|
1452
|
+
host,
|
|
1453
|
+
address: input.address,
|
|
1454
|
+
protocol: input.targetProtocol ?? "http",
|
|
1455
|
+
expiresAt: resolveExpiresAt(input)
|
|
1456
|
+
}, input.signingSecret, {
|
|
1457
|
+
...typeof input.allowPrivateNetworkAddress === "boolean" ? { allowPrivateNetworkAddress: input.allowPrivateNetworkAddress } : {}
|
|
1458
|
+
}).token;
|
|
1459
|
+
const queryParam = input.queryParam ?? DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM;
|
|
1460
|
+
baseUrl.searchParams.set(queryParam, token);
|
|
1461
|
+
return baseUrl.toString();
|
|
1462
|
+
}
|
|
1463
|
+
function resolveGhostTunnelIpRedirect(input) {
|
|
1464
|
+
const transport = resolveTransportConfig(input.transport);
|
|
1465
|
+
if (transport.kind !== "ip") {
|
|
1466
|
+
throw new Error(`Ghost tunnel transport is not configured for IP redirect: ${transport.kind}`);
|
|
1467
|
+
}
|
|
1468
|
+
const queryParam = input.queryParam ?? DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM;
|
|
1469
|
+
const requestUrl = new URL(input.requestUrl);
|
|
1470
|
+
const token = requestUrl.searchParams.get(queryParam);
|
|
1471
|
+
if (!token) {
|
|
1472
|
+
throw new Error(`Ghost tunnel IP transport token is missing. Add ${queryParam}=... to the URL.`);
|
|
1473
|
+
}
|
|
1474
|
+
const claim = verifyGhostTunnelIpTransportClaim(token, input.signingSecret, {
|
|
1475
|
+
host: input.host,
|
|
1476
|
+
allowPrivateNetworkAddress: transport.allowPrivateNetworkAddress,
|
|
1477
|
+
...input.now ? { now: input.now } : {}
|
|
1478
|
+
});
|
|
1479
|
+
requestUrl.searchParams.delete(queryParam);
|
|
1480
|
+
const target = assertRelayLocalTarget({
|
|
1481
|
+
protocol: claim.protocol,
|
|
1482
|
+
host: claim.address,
|
|
1483
|
+
port: input.entryPort
|
|
1484
|
+
}, {
|
|
1485
|
+
allowedHosts: [claim.address],
|
|
1486
|
+
allowPrivateNetworkTargets: transport.allowPrivateNetworkAddress
|
|
1487
|
+
});
|
|
1488
|
+
const redirectUrl = new URL(`${target.protocol}://${target.host}:${target.port}/`);
|
|
1489
|
+
redirectUrl.pathname = requestUrl.pathname;
|
|
1490
|
+
redirectUrl.search = requestUrl.searchParams.toString();
|
|
1491
|
+
redirectUrl.hash = requestUrl.hash;
|
|
1492
|
+
return {
|
|
1493
|
+
claim,
|
|
1494
|
+
queryParam,
|
|
1495
|
+
target,
|
|
1496
|
+
url: redirectUrl.toString()
|
|
1497
|
+
};
|
|
881
1498
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1499
|
+
|
|
1500
|
+
// src/caddy.ts
|
|
1501
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
1502
|
+
import { execa } from "execa";
|
|
1503
|
+
|
|
1504
|
+
// src/fs.ts
|
|
1505
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
1506
|
+
import { dirname as dirname2 } from "path";
|
|
1507
|
+
function readTextFile(path) {
|
|
1508
|
+
return readFileSync3(path, "utf8");
|
|
886
1509
|
}
|
|
887
|
-
function
|
|
888
|
-
|
|
1510
|
+
function writeTextFile(path, value) {
|
|
1511
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
1512
|
+
writeFileSync2(path, value, "utf8");
|
|
1513
|
+
return path;
|
|
889
1514
|
}
|
|
890
1515
|
|
|
891
|
-
// src/
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
import { join as join5 } from "path";
|
|
895
|
-
import { execa as execa3 } from "execa";
|
|
896
|
-
function escapeRegExp(value) {
|
|
897
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1516
|
+
// src/caddy.ts
|
|
1517
|
+
function shouldShowCaddyLogs() {
|
|
1518
|
+
return ["1", "true", "yes", "on"].includes((process.env.LOCALGHOST_CADDY_VERBOSE ?? "").toLowerCase());
|
|
898
1519
|
}
|
|
899
|
-
function
|
|
900
|
-
|
|
901
|
-
const start = `# localghost:start ${sanitizedProjectName}`;
|
|
902
|
-
const end = `# localghost:end ${sanitizedProjectName}`;
|
|
903
|
-
return new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "m");
|
|
1520
|
+
function caddyStdio() {
|
|
1521
|
+
return shouldShowCaddyLogs() ? "inherit" : "pipe";
|
|
904
1522
|
}
|
|
905
|
-
function
|
|
906
|
-
|
|
1523
|
+
function groupByPort(entries) {
|
|
1524
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1525
|
+
for (const entry of entries) {
|
|
1526
|
+
const group = groups.get(entry.port) ?? [];
|
|
1527
|
+
group.push(entry);
|
|
1528
|
+
groups.set(entry.port, group);
|
|
1529
|
+
}
|
|
1530
|
+
return groups;
|
|
907
1531
|
}
|
|
908
|
-
function
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
""
|
|
916
|
-
|
|
1532
|
+
function getCaddyfilePath(cwd = process.cwd()) {
|
|
1533
|
+
return join3(cwd, "ops/local/Caddyfile");
|
|
1534
|
+
}
|
|
1535
|
+
function renderCaddyfile(entries, options = {}) {
|
|
1536
|
+
const groups = groupByPort(entries);
|
|
1537
|
+
const https = options.https === true;
|
|
1538
|
+
const blocks = [...groups.entries()].sort(([leftPort], [rightPort]) => leftPort - rightPort).map(([port, group]) => {
|
|
1539
|
+
const hosts = group.map((entry) => https ? entry.host : `http://${entry.host}`).sort().join(", ");
|
|
1540
|
+
return `${hosts} {
|
|
1541
|
+
reverse_proxy 127.0.0.1:${port}
|
|
1542
|
+
}`;
|
|
1543
|
+
});
|
|
1544
|
+
const globalOptions = https ? `{
|
|
1545
|
+
local_certs
|
|
917
1546
|
}
|
|
918
|
-
function upsertManagedBlock(existing, projectName, block) {
|
|
919
|
-
const pattern = getManagedBlockPattern(projectName);
|
|
920
|
-
if (pattern.test(existing)) {
|
|
921
|
-
return existing.replace(pattern, block);
|
|
922
|
-
}
|
|
923
|
-
return `${existing.trimEnd()}
|
|
924
1547
|
|
|
925
|
-
|
|
1548
|
+
` : "";
|
|
1549
|
+
return `${globalOptions}${blocks.join("\n\n")}
|
|
1550
|
+
`;
|
|
926
1551
|
}
|
|
927
|
-
function
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
932
|
-
return existing.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
1552
|
+
async function writeCaddyfile(entries, cwd = process.cwd(), options = {}) {
|
|
1553
|
+
const path = getCaddyfilePath(cwd);
|
|
1554
|
+
writeTextFile(path, renderCaddyfile(entries, options));
|
|
1555
|
+
return path;
|
|
933
1556
|
}
|
|
934
|
-
async function
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
|
|
940
|
-
}
|
|
941
|
-
await execa3("sudo", ["cp", tempPath, hostsPath], { stdio: "inherit" });
|
|
942
|
-
return tempPath;
|
|
1557
|
+
async function validateCaddyfile(path) {
|
|
1558
|
+
await execa("caddy", ["validate", "--config", path], {
|
|
1559
|
+
cwd: dirname3(path),
|
|
1560
|
+
stdio: caddyStdio()
|
|
1561
|
+
});
|
|
943
1562
|
}
|
|
944
|
-
async function
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1563
|
+
async function runCaddy(path) {
|
|
1564
|
+
await execa("caddy", ["run", "--config", path], {
|
|
1565
|
+
cwd: dirname3(path),
|
|
1566
|
+
stdio: caddyStdio()
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
function startCaddy(path) {
|
|
1570
|
+
return execa("caddy", ["run", "--config", path], {
|
|
1571
|
+
cwd: dirname3(path),
|
|
1572
|
+
stdio: caddyStdio()
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
async function trustCaddy(path) {
|
|
1576
|
+
await execa("caddy", ["trust", "--config", path], {
|
|
1577
|
+
cwd: dirname3(path),
|
|
1578
|
+
stdio: "inherit"
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// src/context.ts
|
|
1583
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
|
|
1584
|
+
import { join as join4 } from "path";
|
|
1585
|
+
import { pathToFileURL } from "url";
|
|
1586
|
+
|
|
1587
|
+
// src/port.ts
|
|
1588
|
+
import { createServer } from "net";
|
|
1589
|
+
async function isPortAvailable(port, host = "127.0.0.1") {
|
|
1590
|
+
return new Promise((resolve3) => {
|
|
1591
|
+
const server = createServer();
|
|
1592
|
+
server.once("error", () => {
|
|
1593
|
+
resolve3(false);
|
|
1594
|
+
});
|
|
1595
|
+
server.once("listening", () => {
|
|
1596
|
+
server.close(() => resolve3(true));
|
|
1597
|
+
});
|
|
1598
|
+
server.listen(port, host);
|
|
1599
|
+
});
|
|
955
1600
|
}
|
|
956
|
-
async function
|
|
957
|
-
const
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1601
|
+
async function findAvailablePort(startPort, options = {}) {
|
|
1602
|
+
const host = options.host ?? "127.0.0.1";
|
|
1603
|
+
const maxAttempts = options.maxAttempts ?? 50;
|
|
1604
|
+
for (let offset = 0; offset < maxAttempts; offset += 1) {
|
|
1605
|
+
const port = startPort + offset;
|
|
1606
|
+
if (await isPortAvailable(port, host)) {
|
|
1607
|
+
return port;
|
|
1608
|
+
}
|
|
963
1609
|
}
|
|
964
|
-
|
|
965
|
-
return { changed: true, removed: true, hostsPath, tempPath };
|
|
1610
|
+
throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
|
|
966
1611
|
}
|
|
967
1612
|
|
|
968
|
-
// src/
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1613
|
+
// src/context.ts
|
|
1614
|
+
var LOCALGHOST_PROJECT_CONFIG_FILES = [
|
|
1615
|
+
"localghost.config.mjs",
|
|
1616
|
+
"localghost.config.js",
|
|
1617
|
+
"localghost.config.cjs"
|
|
1618
|
+
];
|
|
1619
|
+
function parsePort(value) {
|
|
1620
|
+
if (!value) return void 0;
|
|
1621
|
+
const port = Number.parseInt(value, 10);
|
|
1622
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
|
|
975
1623
|
}
|
|
976
|
-
function
|
|
977
|
-
|
|
978
|
-
if (packageManager === "pnpm") return `pnpm ${script}`;
|
|
979
|
-
return `npm run ${script}`;
|
|
1624
|
+
function envPort() {
|
|
1625
|
+
return parsePort(process.env.LOCALGHOST_PORT) ?? parsePort(process.env.VITE_PORT);
|
|
980
1626
|
}
|
|
981
|
-
function
|
|
982
|
-
|
|
983
|
-
if (
|
|
984
|
-
return
|
|
1627
|
+
function envDynamicPort() {
|
|
1628
|
+
const value = process.env.LOCALGHOST_DYNAMIC_PORT;
|
|
1629
|
+
if (!value) return void 0;
|
|
1630
|
+
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
985
1631
|
}
|
|
986
|
-
function
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
`${options.host} ${options.port}`,
|
|
991
|
-
`www.${options.host} ${options.port}`,
|
|
992
|
-
`${options.apiHost} ${options.apiPort}`,
|
|
993
|
-
""
|
|
994
|
-
].join("\n");
|
|
1632
|
+
function envHttps() {
|
|
1633
|
+
const value = process.env.LOCALGHOST_HTTPS;
|
|
1634
|
+
if (!value) return void 0;
|
|
1635
|
+
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
995
1636
|
}
|
|
996
|
-
function
|
|
1637
|
+
function getPackageName(cwd) {
|
|
997
1638
|
try {
|
|
998
|
-
|
|
1639
|
+
const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf8"));
|
|
1640
|
+
return typeof pkg.name === "string" ? pkg.name : void 0;
|
|
999
1641
|
} catch {
|
|
1000
|
-
return
|
|
1642
|
+
return void 0;
|
|
1001
1643
|
}
|
|
1002
1644
|
}
|
|
1003
|
-
function
|
|
1004
|
-
|
|
1005
|
-
|
|
1645
|
+
function getPackageOwner(cwd) {
|
|
1646
|
+
const packageName = getPackageName(cwd);
|
|
1647
|
+
if (!packageName?.startsWith("@")) return void 0;
|
|
1648
|
+
return packageName.slice(1).split("/")[0];
|
|
1006
1649
|
}
|
|
1007
|
-
function
|
|
1008
|
-
return
|
|
1650
|
+
function getLocalOwner(cwd) {
|
|
1651
|
+
return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
|
|
1009
1652
|
}
|
|
1010
|
-
function
|
|
1011
|
-
|
|
1012
|
-
if (!pkg) return false;
|
|
1013
|
-
const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
|
|
1014
|
-
const configFlag = getConfigFlag(configFile);
|
|
1015
|
-
const nextScripts = {
|
|
1016
|
-
...scripts,
|
|
1017
|
-
"localghost:setup": scripts["localghost:setup"] ?? `localghost setup${configFlag}`,
|
|
1018
|
-
"localghost:proxy": scripts["localghost:proxy"] ?? `localghost dev${configFlag}`,
|
|
1019
|
-
"localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
|
|
1020
|
-
"localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
|
|
1021
|
-
"localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
|
|
1022
|
-
"localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
|
|
1023
|
-
"localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
|
|
1024
|
-
"localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
|
|
1025
|
-
"localghost:routes": scripts["localghost:routes"] ?? `localghost routes${configFlag}`,
|
|
1026
|
-
"localghost:status": scripts["localghost:status"] ?? "localghost status",
|
|
1027
|
-
"localghost:reset": scripts["localghost:reset"] ?? "localghost reset",
|
|
1028
|
-
"localghost:teardown": scripts["localghost:teardown"] ?? "localghost teardown",
|
|
1029
|
-
"localghost:doctor": scripts["localghost:doctor"] ?? "localghost doctor",
|
|
1030
|
-
"localghost:update": scripts["localghost:update"] ?? "localghost update",
|
|
1031
|
-
"caddy:setup": scripts["caddy:setup"] ?? `localghost setup${configFlag}`,
|
|
1032
|
-
"caddy:dev": scripts["caddy:dev"] ?? `localghost dev${configFlag}`
|
|
1033
|
-
};
|
|
1034
|
-
const changed = JSON.stringify(scripts) !== JSON.stringify(nextScripts);
|
|
1035
|
-
if (!changed) return false;
|
|
1036
|
-
pkg.scripts = nextScripts;
|
|
1037
|
-
writeFileSync4(packageJsonPath, `${JSON.stringify(pkg, null, 2)}
|
|
1038
|
-
`, "utf8");
|
|
1039
|
-
return true;
|
|
1653
|
+
function getRouteName(primaryHost, fallback) {
|
|
1654
|
+
return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
|
|
1040
1655
|
}
|
|
1041
|
-
function
|
|
1042
|
-
const cwd = options.cwd ?? process.cwd();
|
|
1043
|
-
const projectName = sanitizeProjectName(getProjectName(cwd).split("/").pop() ?? "app");
|
|
1044
|
-
const host = options.host ?? `${projectName}.localhost`;
|
|
1045
|
-
const port = options.port ?? 5173;
|
|
1046
|
-
const apiHost = options.apiHost ?? `api.${host}`;
|
|
1047
|
-
const apiPort = options.apiPort ?? 8787;
|
|
1048
|
-
const packageManager = options.packageManager ?? detectPackageManager(cwd);
|
|
1049
|
-
const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
|
|
1050
|
-
const configPath = join6(cwd, configFile);
|
|
1051
|
-
const configExists = existsSync4(configPath);
|
|
1052
|
-
if (configExists && !options.force) {
|
|
1053
|
-
return {
|
|
1054
|
-
configPath,
|
|
1055
|
-
configCreated: false,
|
|
1056
|
-
packageJsonChanged: false,
|
|
1057
|
-
packageManager,
|
|
1058
|
-
nextSteps: [
|
|
1059
|
-
packageRunCommand(packageManager, "localghost:doctor"),
|
|
1060
|
-
packageRunCommand(packageManager, "localghost:setup"),
|
|
1061
|
-
packageRunCommand(packageManager, "localghost:ready"),
|
|
1062
|
-
packageRunCommand(packageManager, "localghost:proxy")
|
|
1063
|
-
]
|
|
1064
|
-
};
|
|
1065
|
-
}
|
|
1066
|
-
writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
|
|
1067
|
-
const packageJsonPath = join6(cwd, "package.json");
|
|
1068
|
-
const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
|
|
1656
|
+
function readOptionsFromContext(options) {
|
|
1069
1657
|
return {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
...
|
|
1073
|
-
|
|
1074
|
-
packageManager,
|
|
1075
|
-
nextSteps: [
|
|
1076
|
-
packageRunCommand(packageManager, "localghost:doctor"),
|
|
1077
|
-
packageRunCommand(packageManager, "localghost:setup"),
|
|
1078
|
-
packageRunCommand(packageManager, "localghost:ready"),
|
|
1079
|
-
packageRunCommand(packageManager, "localghost:proxy")
|
|
1080
|
-
]
|
|
1658
|
+
cwd: options.cwd ?? process.cwd(),
|
|
1659
|
+
...options.fileName ? { fileName: options.fileName } : {},
|
|
1660
|
+
...options.configFiles ? { configFiles: options.configFiles } : {},
|
|
1661
|
+
...options.configPattern ? { configPattern: options.configPattern } : {}
|
|
1081
1662
|
};
|
|
1082
1663
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
var DEFAULT_RELAY_BLOCKED_PORTS = [22, 2375, 2376, 5432, 6379, 9200, 9229, 27017];
|
|
1089
|
-
var DEFAULT_RELAY_LIMITS = {
|
|
1090
|
-
requestBodyBytes: 5 * 1024 * 1024,
|
|
1091
|
-
responseBytes: 25 * 1024 * 1024,
|
|
1092
|
-
timeoutMs: 3e4,
|
|
1093
|
-
maxConcurrentRequests: 20,
|
|
1094
|
-
perRouteRequestsPerMinute: 120,
|
|
1095
|
-
perIpRequestsPerMinute: 60
|
|
1096
|
-
};
|
|
1097
|
-
var DEFAULT_RELAY_TARGET_POLICY = {
|
|
1098
|
-
allowedHosts: [...DEFAULT_RELAY_ALLOWED_TARGET_HOSTS],
|
|
1099
|
-
blockedPorts: [...DEFAULT_RELAY_BLOCKED_PORTS],
|
|
1100
|
-
allowPrivateNetworkTargets: false
|
|
1101
|
-
};
|
|
1102
|
-
var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
1103
|
-
"connection",
|
|
1104
|
-
"keep-alive",
|
|
1105
|
-
"proxy-authenticate",
|
|
1106
|
-
"proxy-authorization",
|
|
1107
|
-
"te",
|
|
1108
|
-
"trailer",
|
|
1109
|
-
"transfer-encoding",
|
|
1110
|
-
"upgrade"
|
|
1111
|
-
]);
|
|
1112
|
-
var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "set-cookie"]);
|
|
1113
|
-
var TOKEN_QUERY_PATTERN = /(token|secret|key|password|session|jwt|auth)/i;
|
|
1114
|
-
var HOST_PATTERN2 = /^(?=.{1,253}$)(?!-)[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
|
|
1115
|
-
var IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
1116
|
-
function base64UrlEncode(value) {
|
|
1117
|
-
return Buffer.from(value).toString("base64url");
|
|
1664
|
+
function withRuntimePort(entries, requestedPort, port) {
|
|
1665
|
+
if (requestedPort === port) return entries;
|
|
1666
|
+
const hasRequestedPort = entries.some((entry) => entry.port === requestedPort);
|
|
1667
|
+
if (!hasRequestedPort) return entries;
|
|
1668
|
+
return entries.map((entry) => entry.port === requestedPort ? { ...entry, port, target: `127.0.0.1:${port}` } : entry);
|
|
1118
1669
|
}
|
|
1119
|
-
function
|
|
1120
|
-
return
|
|
1670
|
+
function uniqueHosts(entries) {
|
|
1671
|
+
return [...new Set(entries.map((entry) => entry.host))];
|
|
1121
1672
|
}
|
|
1122
|
-
function
|
|
1123
|
-
return
|
|
1673
|
+
function isAliasableHost(host) {
|
|
1674
|
+
return host.includes(".") && !host.startsWith("www.") && !host.includes(":");
|
|
1124
1675
|
}
|
|
1125
|
-
function
|
|
1126
|
-
|
|
1127
|
-
const rightBuffer = Buffer.from(right);
|
|
1128
|
-
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
1676
|
+
function getDefaultWwwAlias(host) {
|
|
1677
|
+
return isAliasableHost(host) ? `www.${host}` : null;
|
|
1129
1678
|
}
|
|
1130
|
-
function
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1679
|
+
function addDefaultWwwAliases(entries) {
|
|
1680
|
+
const seen = new Set(entries.map((entry) => entry.host));
|
|
1681
|
+
const aliases = [];
|
|
1682
|
+
for (const entry of entries) {
|
|
1683
|
+
const alias = getDefaultWwwAlias(entry.host);
|
|
1684
|
+
if (alias && !seen.has(alias)) {
|
|
1685
|
+
aliases.push({ host: alias, port: entry.port, target: `127.0.0.1:${entry.port}` });
|
|
1686
|
+
seen.add(alias);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return [...entries, ...aliases];
|
|
1136
1690
|
}
|
|
1137
|
-
function
|
|
1138
|
-
|
|
1139
|
-
if (trimmed === "::1" || trimmed === "[::1]") return "::1";
|
|
1140
|
-
if (trimmed.includes("/") || trimmed.includes("*")) return null;
|
|
1141
|
-
if (IPV4_PATTERN.test(trimmed)) return isValidIpv4(trimmed) ? trimmed : null;
|
|
1142
|
-
return normalizeHost(trimmed);
|
|
1691
|
+
function defined(input) {
|
|
1692
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => typeof value !== "undefined"));
|
|
1143
1693
|
}
|
|
1144
|
-
function
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1694
|
+
async function readLocalghostProjectConfig(options = {}) {
|
|
1695
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1696
|
+
if (options.configFile === false) return { config: {} };
|
|
1697
|
+
const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
|
|
1698
|
+
const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
|
|
1699
|
+
if (!path) return { config: {} };
|
|
1700
|
+
const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
|
|
1701
|
+
const config = imported.default ?? imported;
|
|
1702
|
+
return { config, path };
|
|
1703
|
+
}
|
|
1704
|
+
function defineLocalghostConfig(config) {
|
|
1705
|
+
return config;
|
|
1706
|
+
}
|
|
1707
|
+
async function resolveLocalghostContext(options = {}) {
|
|
1708
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1709
|
+
const projectConfig = await readLocalghostProjectConfig({
|
|
1710
|
+
cwd,
|
|
1711
|
+
...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
|
|
1712
|
+
});
|
|
1713
|
+
const merged = {
|
|
1714
|
+
...projectConfig.config,
|
|
1715
|
+
...defined(options)
|
|
1716
|
+
};
|
|
1717
|
+
const readOptions = readOptionsFromContext({ ...merged, cwd });
|
|
1718
|
+
const resolvedPath = resolveDevHostsPath(readOptions);
|
|
1719
|
+
const configEntries = readDevHosts(readOptions);
|
|
1720
|
+
const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
|
|
1721
|
+
const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
|
|
1722
|
+
const autoRepair = merged.autoRepair ?? true;
|
|
1723
|
+
const bindHost = merged.bindHost ?? "127.0.0.1";
|
|
1724
|
+
const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
|
|
1725
|
+
const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
|
|
1726
|
+
const wwwAlias = merged.wwwAlias ?? true;
|
|
1727
|
+
const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
|
|
1728
|
+
const hosts = uniqueHosts(entries);
|
|
1729
|
+
const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
|
|
1730
|
+
const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
|
|
1731
|
+
const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
|
|
1732
|
+
route: getRouteName(primaryHost, projectName),
|
|
1733
|
+
project: projectName,
|
|
1734
|
+
owner: getLocalOwner(cwd)
|
|
1148
1735
|
});
|
|
1736
|
+
return {
|
|
1737
|
+
cwd,
|
|
1738
|
+
projectName,
|
|
1739
|
+
readOptions,
|
|
1740
|
+
configPath: resolvedPath.path,
|
|
1741
|
+
configFileName: resolvedPath.fileName,
|
|
1742
|
+
configEntries,
|
|
1743
|
+
entries,
|
|
1744
|
+
hosts,
|
|
1745
|
+
requestedPort,
|
|
1746
|
+
port,
|
|
1747
|
+
dynamicPort,
|
|
1748
|
+
autoRepair,
|
|
1749
|
+
bindHost,
|
|
1750
|
+
primaryHost,
|
|
1751
|
+
https: merged.https ?? envHttps() ?? false,
|
|
1752
|
+
wwwAlias,
|
|
1753
|
+
ghostTunnel,
|
|
1754
|
+
...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
|
|
1755
|
+
};
|
|
1149
1756
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
return first === 10 || first === 127 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 169 && second === 254;
|
|
1154
|
-
}
|
|
1155
|
-
function isLocalTargetHost(host) {
|
|
1156
|
-
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
1157
|
-
}
|
|
1158
|
-
function mergeTargetPolicy(policy) {
|
|
1757
|
+
|
|
1758
|
+
// src/ghost-request.ts
|
|
1759
|
+
function getGhostTunnelReadOptions(input) {
|
|
1159
1760
|
return {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
|
|
1761
|
+
...input.cwd ? { cwd: input.cwd } : {},
|
|
1762
|
+
...input.ghostTunnelFile ? { fileName: input.ghostTunnelFile } : {}
|
|
1163
1763
|
};
|
|
1164
1764
|
}
|
|
1165
|
-
function
|
|
1166
|
-
const
|
|
1167
|
-
...
|
|
1168
|
-
...
|
|
1765
|
+
async function resolveGhostTunnelRequest(input) {
|
|
1766
|
+
const projectConfig = await readLocalghostProjectConfig({
|
|
1767
|
+
...input.cwd ? { cwd: input.cwd } : {},
|
|
1768
|
+
...typeof input.localghostConfig !== "undefined" ? { configFile: input.localghostConfig } : {}
|
|
1769
|
+
});
|
|
1770
|
+
const ghostTunnel = resolveGhostTunnelConfig(projectConfig.config.ghostTunnel, {
|
|
1771
|
+
domain: input.domain
|
|
1772
|
+
});
|
|
1773
|
+
const route = assertSecureGhostTunnelRequest({
|
|
1774
|
+
host: input.host,
|
|
1775
|
+
domain: input.domain,
|
|
1776
|
+
protocol: input.protocol,
|
|
1777
|
+
ghostTunnel,
|
|
1778
|
+
...typeof input.authenticated === "boolean" ? { authenticated: input.authenticated } : {}
|
|
1779
|
+
});
|
|
1780
|
+
const ghostTunnelPath = resolveGhostTunnelPath(getGhostTunnelReadOptions(input));
|
|
1781
|
+
const entry = findGhostTunnelEntry(route.host, getGhostTunnelReadOptions(input));
|
|
1782
|
+
const target = entry ? assertRelayLocalTarget({ host: "127.0.0.1", port: entry.port }) : void 0;
|
|
1783
|
+
return {
|
|
1784
|
+
route,
|
|
1785
|
+
ghostTunnel,
|
|
1786
|
+
...entry ? { entry } : {},
|
|
1787
|
+
...target ? { target } : {},
|
|
1788
|
+
...projectConfig.path ? { projectConfigPath: projectConfig.path } : {},
|
|
1789
|
+
...ghostTunnelPath.exists ? { ghostTunnelPath: ghostTunnelPath.path } : {}
|
|
1169
1790
|
};
|
|
1170
|
-
for (const [key, value] of Object.entries(merged)) {
|
|
1171
|
-
if (!Number.isInteger(value) || value < 1) {
|
|
1172
|
-
throw new Error(`Invalid relay limit ${key}: ${value}`);
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
return merged;
|
|
1176
1791
|
}
|
|
1177
|
-
function
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1792
|
+
function renderGhostTunnelRouteMissingResponse(resolved) {
|
|
1793
|
+
return {
|
|
1794
|
+
status: 404,
|
|
1795
|
+
headers: {
|
|
1796
|
+
"content-type": "text/html; charset=utf-8",
|
|
1797
|
+
"cache-control": "no-store",
|
|
1798
|
+
"x-localghost-relay": "missing",
|
|
1799
|
+
"x-localghost-route": resolved.route.slug
|
|
1800
|
+
},
|
|
1801
|
+
body: [
|
|
1802
|
+
"<!doctype html>",
|
|
1803
|
+
"<html>",
|
|
1804
|
+
'<head><meta charset="utf-8"><title>Ghost Tunnel route not configured</title></head>',
|
|
1805
|
+
"<body>",
|
|
1806
|
+
"<h1>Ghost Tunnel route not configured</h1>",
|
|
1807
|
+
`<p>The wildcard host <code>${resolved.route.host}</code> reached the deployed Ghost Tunnel handler, but no exact <code>.ghosttunnel</code> entry matched it.</p>`,
|
|
1808
|
+
"</body>",
|
|
1809
|
+
"</html>"
|
|
1810
|
+
].join("")
|
|
1811
|
+
};
|
|
1183
1812
|
}
|
|
1184
|
-
function
|
|
1185
|
-
|
|
1186
|
-
throw new Error("Relay target must be an explicit local target object.");
|
|
1187
|
-
}
|
|
1188
|
-
const policy = mergeTargetPolicy(policyInput);
|
|
1189
|
-
const host = normalizeTargetHost(target.host);
|
|
1190
|
-
if (!host) {
|
|
1191
|
-
throw new Error(`Invalid relay target host: ${target.host}`);
|
|
1192
|
-
}
|
|
1193
|
-
if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
|
|
1194
|
-
throw new Error(`Invalid relay target port: ${target.port}`);
|
|
1195
|
-
}
|
|
1196
|
-
const protocol = target.protocol ?? "http";
|
|
1197
|
-
if (protocol !== "http" && protocol !== "https") {
|
|
1198
|
-
throw new Error(`Invalid relay target protocol: ${String(protocol)}`);
|
|
1199
|
-
}
|
|
1200
|
-
if (policy.blockedPorts.includes(target.port)) {
|
|
1201
|
-
throw new Error(`Relay target port is blocked: ${target.port}`);
|
|
1202
|
-
}
|
|
1203
|
-
const allowedHosts = new Set(policy.allowedHosts.map((allowedHost) => normalizeTargetHost(allowedHost)).filter((value) => Boolean(value)));
|
|
1204
|
-
if (!allowedHosts.has(host)) {
|
|
1205
|
-
throw new Error(`Relay target host is not explicitly allowed: ${host}`);
|
|
1206
|
-
}
|
|
1207
|
-
if (!policy.allowPrivateNetworkTargets && !isLocalTargetHost(host) && (host === "::1" || isPrivateIpv4(host))) {
|
|
1208
|
-
throw new Error(`Private-network relay target requires explicit opt-in: ${host}`);
|
|
1209
|
-
}
|
|
1813
|
+
function renderGhostTunnelRelayOfflineResponse(resolved) {
|
|
1814
|
+
const response = renderRelayOfflineResponse();
|
|
1210
1815
|
return {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1816
|
+
...response,
|
|
1817
|
+
headers: {
|
|
1818
|
+
...response.headers,
|
|
1819
|
+
"x-localghost-relay": "offline",
|
|
1820
|
+
"x-localghost-route": resolved.route.slug,
|
|
1821
|
+
"x-localghost-entry": resolved.entry ? "configured" : "missing"
|
|
1822
|
+
},
|
|
1823
|
+
body: [
|
|
1824
|
+
"<!doctype html>",
|
|
1825
|
+
"<html>",
|
|
1826
|
+
'<head><meta charset="utf-8"><title>Ghost Tunnel offline</title></head>',
|
|
1827
|
+
"<body>",
|
|
1828
|
+
"<h1>Ghost Tunnel offline</h1>",
|
|
1829
|
+
`<p>The wildcard host <code>${resolved.route.host}</code> reached the deployed Ghost Tunnel handler.</p>`,
|
|
1830
|
+
resolved.entry ? "<p>The route is configured locally, but no active local relay connection is available yet.</p>" : "<p>No exact <code>.ghosttunnel</code> entry matched this host.</p>",
|
|
1831
|
+
"</body>",
|
|
1832
|
+
"</html>"
|
|
1833
|
+
].join("")
|
|
1214
1834
|
};
|
|
1215
1835
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1836
|
+
|
|
1837
|
+
// src/doctor.ts
|
|
1838
|
+
import { execa as execa2 } from "execa";
|
|
1839
|
+
async function checkCaddy() {
|
|
1840
|
+
try {
|
|
1841
|
+
const result = await execa2("caddy", ["version"], { reject: false });
|
|
1842
|
+
const version = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
1843
|
+
return {
|
|
1844
|
+
found: result.exitCode === 0,
|
|
1845
|
+
...version ? { version } : {},
|
|
1846
|
+
installHint: "brew install caddy"
|
|
1847
|
+
};
|
|
1848
|
+
} catch {
|
|
1849
|
+
return {
|
|
1850
|
+
found: false,
|
|
1851
|
+
installHint: "brew install caddy"
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1219
1854
|
}
|
|
1220
|
-
function
|
|
1221
|
-
const
|
|
1222
|
-
...claim,
|
|
1223
|
-
host: assertExactRelayHost(claim.host)
|
|
1224
|
-
};
|
|
1225
|
-
if (!payload.scope) throw new Error("Relay route claim requires a scope.");
|
|
1226
|
-
if (!payload.agentId) throw new Error("Relay route claim requires an agentId.");
|
|
1227
|
-
if (Number.isNaN(Date.parse(payload.expiresAt))) throw new Error("Relay route claim requires a valid expiresAt.");
|
|
1228
|
-
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
1229
|
-
const signature = signPayload(encodedPayload, signingSecret);
|
|
1855
|
+
async function runDoctor() {
|
|
1856
|
+
const caddy = await checkCaddy();
|
|
1230
1857
|
return {
|
|
1231
|
-
|
|
1232
|
-
|
|
1858
|
+
ok: caddy.found,
|
|
1859
|
+
caddy
|
|
1233
1860
|
};
|
|
1234
1861
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1862
|
+
|
|
1863
|
+
// src/command.ts
|
|
1864
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
|
|
1865
|
+
import { isAbsolute, join as join5, relative, resolve as resolve2 } from "path";
|
|
1866
|
+
function readPackageJson(cwd) {
|
|
1867
|
+
const path = join5(cwd, "package.json");
|
|
1868
|
+
if (!existsSync4(path)) {
|
|
1869
|
+
throw new Error(`No package.json found in ${cwd}. Pass an explicit command with \`localghost run -- <command>\`.`);
|
|
1239
1870
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1871
|
+
try {
|
|
1872
|
+
return JSON.parse(readFileSync5(path, "utf8"));
|
|
1873
|
+
} catch {
|
|
1874
|
+
throw new Error(`Could not parse ${path}.`);
|
|
1243
1875
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
if (
|
|
1247
|
-
|
|
1876
|
+
}
|
|
1877
|
+
function detectDevPackageManager(cwd, packageManager) {
|
|
1878
|
+
if (typeof packageManager === "string") {
|
|
1879
|
+
const name = packageManager.split("@")[0];
|
|
1880
|
+
if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") return name;
|
|
1248
1881
|
}
|
|
1249
|
-
|
|
1250
|
-
if (
|
|
1251
|
-
|
|
1882
|
+
if (existsSync4(join5(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
1883
|
+
if (existsSync4(join5(cwd, "yarn.lock"))) return "yarn";
|
|
1884
|
+
if (existsSync4(join5(cwd, "bun.lock")) || existsSync4(join5(cwd, "bun.lockb"))) return "bun";
|
|
1885
|
+
return "npm";
|
|
1886
|
+
}
|
|
1887
|
+
function scriptCommand(packageManager, script) {
|
|
1888
|
+
if (packageManager === "yarn") return ["yarn", script];
|
|
1889
|
+
return [packageManager, "run", script];
|
|
1890
|
+
}
|
|
1891
|
+
function invokesLocalghost(script) {
|
|
1892
|
+
return /(^|[\s;&|])(?:npm\s+exec\s+|pnpm\s+exec\s+|bunx\s+|npx\s+)?localghost(?:\s|$)/.test(script);
|
|
1893
|
+
}
|
|
1894
|
+
function detectDevCommand(options = {}) {
|
|
1895
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1896
|
+
if (options.command) {
|
|
1897
|
+
if (options.command.length === 0 || options.command.some((part) => typeof part !== "string" || part.length === 0)) {
|
|
1898
|
+
throw new Error("localghost.config.mjs command must be a non-empty array of strings.");
|
|
1899
|
+
}
|
|
1900
|
+
if (invokesLocalghost(options.command.join(" "))) {
|
|
1901
|
+
throw new Error("localghost.config.mjs command cannot invoke Localghost recursively.");
|
|
1902
|
+
}
|
|
1903
|
+
return { command: [...options.command], source: "config" };
|
|
1252
1904
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1905
|
+
const pkg = readPackageJson(cwd);
|
|
1906
|
+
const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
|
|
1907
|
+
const packageManager = detectDevPackageManager(cwd, pkg.packageManager);
|
|
1908
|
+
for (const script of ["dev:raw", "dev"]) {
|
|
1909
|
+
const value = scripts[script];
|
|
1910
|
+
if (typeof value !== "string" || invokesLocalghost(value)) continue;
|
|
1911
|
+
return {
|
|
1912
|
+
command: scriptCommand(packageManager, script),
|
|
1913
|
+
source: "script",
|
|
1914
|
+
packageManager,
|
|
1915
|
+
script
|
|
1916
|
+
};
|
|
1255
1917
|
}
|
|
1256
|
-
|
|
1918
|
+
throw new Error([
|
|
1919
|
+
`Could not detect a safe development command in ${join5(cwd, "package.json")}.`,
|
|
1920
|
+
"Add a non-recursive dev or dev:raw script, configure command in localghost.config.mjs,",
|
|
1921
|
+
"or pass an explicit command with `localghost run -- <command>`."
|
|
1922
|
+
].join(" "));
|
|
1923
|
+
}
|
|
1924
|
+
function formatDetectedDevCommand(detected) {
|
|
1925
|
+
const command = detected.command.map((part) => /^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : JSON.stringify(part)).join(" ");
|
|
1926
|
+
const source = detected.source === "config" ? "localghost.config.mjs" : `package.json#scripts.${detected.script}`;
|
|
1927
|
+
return `${command} (${source})`;
|
|
1928
|
+
}
|
|
1929
|
+
function assertServicePath(root, serviceCwd, name) {
|
|
1930
|
+
const cwd = resolve2(root, serviceCwd);
|
|
1931
|
+
const relativeCwd = relative(root, cwd);
|
|
1932
|
+
if (isAbsolute(relativeCwd) || relativeCwd === ".." || relativeCwd.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
|
|
1933
|
+
throw new Error(`Service ${name} cwd must stay inside the project root.`);
|
|
1934
|
+
}
|
|
1935
|
+
return { cwd, relativeCwd: relativeCwd || "." };
|
|
1936
|
+
}
|
|
1937
|
+
function detectDevServices(options) {
|
|
1938
|
+
const root = options.cwd ?? process.cwd();
|
|
1939
|
+
if (options.services.length === 0) throw new Error("services must contain at least one service.");
|
|
1940
|
+
const names = /* @__PURE__ */ new Set();
|
|
1941
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
1942
|
+
return options.services.map((service, index) => {
|
|
1943
|
+
if (!service || typeof service !== "object") throw new Error(`Service at index ${index} must be an object.`);
|
|
1944
|
+
if (!service.name || names.has(service.name)) throw new Error(`Service name must be unique: ${service.name || `<index ${index}>`}.`);
|
|
1945
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(service.name)) throw new Error(`Invalid service name: ${service.name}.`);
|
|
1946
|
+
if (!service.host || hosts.has(service.host)) throw new Error(`Service host must be unique: ${service.host || `<index ${index}>`}.`);
|
|
1947
|
+
if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65535) {
|
|
1948
|
+
throw new Error(`Invalid port for service ${service.name}: ${service.port}.`);
|
|
1949
|
+
}
|
|
1950
|
+
names.add(service.name);
|
|
1951
|
+
hosts.add(service.host);
|
|
1952
|
+
const path = assertServicePath(root, service.cwd, service.name);
|
|
1953
|
+
const detected = detectDevCommand({
|
|
1954
|
+
cwd: path.cwd,
|
|
1955
|
+
...service.command ? { command: service.command } : {}
|
|
1956
|
+
});
|
|
1957
|
+
return {
|
|
1958
|
+
name: service.name,
|
|
1959
|
+
...path,
|
|
1960
|
+
host: service.host,
|
|
1961
|
+
requestedPort: service.port,
|
|
1962
|
+
command: detected.command,
|
|
1963
|
+
commandSource: detected.source
|
|
1964
|
+
};
|
|
1965
|
+
});
|
|
1257
1966
|
}
|
|
1258
|
-
function
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
...
|
|
1262
|
-
|
|
1263
|
-
|
|
1967
|
+
function formatDetectedDevServices(services) {
|
|
1968
|
+
return [
|
|
1969
|
+
`Localghost detected ${services.length} services:`,
|
|
1970
|
+
...services.map((service) => `${service.name}: ${service.command.map((part) => /^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : JSON.stringify(part)).join(" ")} (${service.relativeCwd}, ${service.host} -> ${service.requestedPort})`)
|
|
1971
|
+
].join("\n");
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// src/env.ts
|
|
1975
|
+
var PRODUCTION_ENV_KEYS = ["NODE_ENV", "VERCEL_ENV", "NETLIFY", "CF_PAGES_BRANCH", "LOCALGHOST_ENV"];
|
|
1976
|
+
function getProductionReason(env = process.env) {
|
|
1977
|
+
if (env.LOCALGHOST_ENV === "production") return "LOCALGHOST_ENV=production";
|
|
1978
|
+
if (env.NODE_ENV === "production") return "NODE_ENV=production";
|
|
1979
|
+
if (env.VERCEL_ENV === "production") return "VERCEL_ENV=production";
|
|
1980
|
+
if (env.NETLIFY === "true" && env.CONTEXT === "production") return "NETLIFY=true and CONTEXT=production";
|
|
1981
|
+
if (env.CF_PAGES_BRANCH && env.CF_PAGES_BRANCH === env.CF_PAGES_PRODUCTION_BRANCH) {
|
|
1982
|
+
return "CF_PAGES_BRANCH matches CF_PAGES_PRODUCTION_BRANCH";
|
|
1264
1983
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1984
|
+
return null;
|
|
1985
|
+
}
|
|
1986
|
+
function isProductionLike(env = process.env) {
|
|
1987
|
+
return getProductionReason(env) !== null;
|
|
1988
|
+
}
|
|
1989
|
+
function assertLocalDevelopment(command, env = process.env) {
|
|
1990
|
+
const reason = getProductionReason(env);
|
|
1991
|
+
if (!reason) return;
|
|
1992
|
+
throw new Error(`Localghost only runs in local development. Refusing \`${command}\` because ${reason}.`);
|
|
1993
|
+
}
|
|
1994
|
+
function getProductionEnvKeys() {
|
|
1995
|
+
return PRODUCTION_ENV_KEYS;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// src/hosts-file.ts
|
|
1999
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
2000
|
+
import { tmpdir } from "os";
|
|
2001
|
+
import { join as join6 } from "path";
|
|
2002
|
+
import { execa as execa3 } from "execa";
|
|
2003
|
+
function escapeRegExp(value) {
|
|
2004
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2005
|
+
}
|
|
2006
|
+
function getManagedBlockPattern(projectName) {
|
|
2007
|
+
const sanitizedProjectName = sanitizeProjectName(projectName);
|
|
2008
|
+
const start = `# localghost:start ${sanitizedProjectName}`;
|
|
2009
|
+
const end = `# localghost:end ${sanitizedProjectName}`;
|
|
2010
|
+
return new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "m");
|
|
2011
|
+
}
|
|
2012
|
+
function getSystemHostsPath(env = process.env) {
|
|
2013
|
+
if (env.LOCALGHOST_HOSTS_PATH) return env.LOCALGHOST_HOSTS_PATH;
|
|
2014
|
+
return process.platform === "win32" ? "C:\\Windows\\System32\\drivers\\etc\\hosts" : "/etc/hosts";
|
|
2015
|
+
}
|
|
2016
|
+
function renderHostsBlock(projectName, entries) {
|
|
2017
|
+
const sanitizedProjectName = sanitizeProjectName(projectName);
|
|
2018
|
+
const hosts = [...new Set(entries.map((entry) => entry.host))].sort();
|
|
2019
|
+
return [
|
|
2020
|
+
`# localghost:start ${sanitizedProjectName}`,
|
|
2021
|
+
...hosts.map((host) => `127.0.0.1 ${host}`),
|
|
2022
|
+
`# localghost:end ${sanitizedProjectName}`,
|
|
2023
|
+
""
|
|
2024
|
+
].join("\n");
|
|
2025
|
+
}
|
|
2026
|
+
function upsertManagedBlock(existing, projectName, block) {
|
|
2027
|
+
const pattern = getManagedBlockPattern(projectName);
|
|
2028
|
+
if (pattern.test(existing)) {
|
|
2029
|
+
return existing.replace(pattern, block);
|
|
1275
2030
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
2031
|
+
return `${existing.trimEnd()}
|
|
2032
|
+
|
|
2033
|
+
${block}`;
|
|
2034
|
+
}
|
|
2035
|
+
function removeManagedBlock(existing, projectName) {
|
|
2036
|
+
const pattern = getManagedBlockPattern(projectName);
|
|
2037
|
+
if (!pattern.test(existing)) {
|
|
2038
|
+
return existing;
|
|
1278
2039
|
}
|
|
1279
|
-
return {
|
|
1280
|
-
host: claim.host,
|
|
1281
|
-
scope: claim.scope,
|
|
1282
|
-
agentId: claim.agentId,
|
|
1283
|
-
expiresAt: claim.expiresAt,
|
|
1284
|
-
target,
|
|
1285
|
-
access,
|
|
1286
|
-
passwordProtected,
|
|
1287
|
-
authRequired,
|
|
1288
|
-
limits: mergeLimits(input.limits)
|
|
1289
|
-
};
|
|
2040
|
+
return existing.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
1290
2041
|
}
|
|
1291
|
-
function
|
|
1292
|
-
|
|
1293
|
-
|
|
2042
|
+
async function writeSystemHostsFile(hostsPath, next, projectName) {
|
|
2043
|
+
const sanitizedProjectName = sanitizeProjectName(projectName);
|
|
2044
|
+
const tempPath = join6(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
|
|
2045
|
+
writeFileSync3(tempPath, next, "utf8");
|
|
2046
|
+
if (process.env.LOCALGHOST_HOSTS_PATH) {
|
|
2047
|
+
writeFileSync3(hostsPath, next, "utf8");
|
|
2048
|
+
return tempPath;
|
|
2049
|
+
}
|
|
2050
|
+
if (process.platform === "win32") {
|
|
2051
|
+
throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
|
|
2052
|
+
}
|
|
2053
|
+
await execa3("sudo", ["cp", tempPath, hostsPath], { stdio: "inherit" });
|
|
2054
|
+
return tempPath;
|
|
1294
2055
|
}
|
|
1295
|
-
function
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
2056
|
+
async function updateSystemHosts(projectName, entries) {
|
|
2057
|
+
const sanitizedProjectName = sanitizeProjectName(projectName);
|
|
2058
|
+
const hostsPath = getSystemHostsPath();
|
|
2059
|
+
const existing = readTextFile(hostsPath);
|
|
2060
|
+
const block = renderHostsBlock(sanitizedProjectName, entries);
|
|
2061
|
+
const next = upsertManagedBlock(existing, sanitizedProjectName, block);
|
|
2062
|
+
if (next === existing) {
|
|
2063
|
+
return { changed: false, hostsPath };
|
|
1303
2064
|
}
|
|
1304
|
-
|
|
2065
|
+
const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
|
|
2066
|
+
return { changed: true, hostsPath, tempPath };
|
|
1305
2067
|
}
|
|
1306
|
-
function
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
2068
|
+
async function removeSystemHosts(projectName) {
|
|
2069
|
+
const sanitizedProjectName = sanitizeProjectName(projectName);
|
|
2070
|
+
const hostsPath = getSystemHostsPath();
|
|
2071
|
+
const existing = readTextFile(hostsPath);
|
|
2072
|
+
const next = removeManagedBlock(existing, sanitizedProjectName);
|
|
2073
|
+
if (next === existing) {
|
|
2074
|
+
return { changed: false, removed: false, hostsPath };
|
|
1311
2075
|
}
|
|
1312
|
-
|
|
2076
|
+
const tempPath = await writeSystemHostsFile(hostsPath, next, sanitizedProjectName);
|
|
2077
|
+
return { changed: true, removed: true, hostsPath, tempPath };
|
|
1313
2078
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
2079
|
+
|
|
2080
|
+
// src/init.ts
|
|
2081
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
2082
|
+
import { join as join7 } from "path";
|
|
2083
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
2084
|
+
if (existsSync5(join7(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
2085
|
+
if (existsSync5(join7(cwd, "yarn.lock"))) return "yarn";
|
|
2086
|
+
if (existsSync5(join7(cwd, "bun.lock")) || existsSync5(join7(cwd, "bun.lockb"))) return "bun";
|
|
2087
|
+
return "npm";
|
|
2088
|
+
}
|
|
2089
|
+
function packageRunCommand(packageManager, script) {
|
|
2090
|
+
if (packageManager === "yarn") return `yarn ${script}`;
|
|
2091
|
+
if (packageManager === "pnpm") return `pnpm ${script}`;
|
|
2092
|
+
if (packageManager === "bun") return `bun run ${script}`;
|
|
2093
|
+
return `npm run ${script}`;
|
|
2094
|
+
}
|
|
2095
|
+
function packageAddCommand(packageManager, packageName = "@hamedb89/localghost") {
|
|
2096
|
+
if (packageManager === "yarn") return `yarn add -D ${packageName}`;
|
|
2097
|
+
if (packageManager === "pnpm") return `pnpm add -D ${packageName}`;
|
|
2098
|
+
if (packageManager === "bun") return `bun add -d ${packageName}`;
|
|
2099
|
+
return `npm install -D ${packageName}`;
|
|
2100
|
+
}
|
|
2101
|
+
function renderConfig(options) {
|
|
2102
|
+
return [
|
|
2103
|
+
"# Buh. Friendly names for local services.",
|
|
2104
|
+
"# Format: <host> <port>",
|
|
2105
|
+
`${options.host} ${options.port}`,
|
|
2106
|
+
`www.${options.host} ${options.port}`,
|
|
2107
|
+
`${options.apiHost} ${options.apiPort}`,
|
|
2108
|
+
""
|
|
2109
|
+
].join("\n");
|
|
2110
|
+
}
|
|
2111
|
+
function readPackageJson2(path) {
|
|
2112
|
+
try {
|
|
2113
|
+
return JSON.parse(readFileSync6(path, "utf8"));
|
|
2114
|
+
} catch {
|
|
2115
|
+
return null;
|
|
1320
2116
|
}
|
|
1321
|
-
return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
|
|
1322
2117
|
}
|
|
1323
|
-
function
|
|
2118
|
+
function shellQuote(value) {
|
|
2119
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
|
|
2120
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
2121
|
+
}
|
|
2122
|
+
function getConfigFlag(configFile) {
|
|
2123
|
+
return configFile === LOCALGHOST_CONFIG_FILE ? "" : ` --config ${shellQuote(configFile)}`;
|
|
2124
|
+
}
|
|
2125
|
+
function updatePackageScripts(packageJsonPath, configFile) {
|
|
2126
|
+
const pkg = readPackageJson2(packageJsonPath);
|
|
2127
|
+
if (!pkg) return false;
|
|
2128
|
+
const scripts = typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
|
|
2129
|
+
const configFlag = getConfigFlag(configFile);
|
|
2130
|
+
const nextScripts = {
|
|
2131
|
+
...scripts,
|
|
2132
|
+
"localghost:setup": scripts["localghost:setup"] ?? `localghost setup${configFlag}`,
|
|
2133
|
+
"localghost:proxy": scripts["localghost:proxy"] ?? `localghost dev${configFlag}`,
|
|
2134
|
+
"localghost:proxy:https": scripts["localghost:proxy:https"] ?? `localghost dev${configFlag} --https`,
|
|
2135
|
+
"localghost:run": scripts["localghost:run"] ?? `localghost run${configFlag} --`,
|
|
2136
|
+
"localghost:ready": scripts["localghost:ready"] ?? `localghost status${configFlag} --ready`,
|
|
2137
|
+
"localghost:repair": scripts["localghost:repair"] ?? `localghost repair${configFlag}`,
|
|
2138
|
+
"localghost:trust": scripts["localghost:trust"] ?? `localghost trust${configFlag}`,
|
|
2139
|
+
"localghost:ps": scripts["localghost:ps"] ?? "localghost ps",
|
|
2140
|
+
"localghost:print": scripts["localghost:print"] ?? `localghost print${configFlag}`,
|
|
2141
|
+
"localghost:routes": scripts["localghost:routes"] ?? `localghost routes${configFlag}`,
|
|
2142
|
+
"localghost:status": scripts["localghost:status"] ?? "localghost status",
|
|
2143
|
+
"localghost:reset": scripts["localghost:reset"] ?? "localghost reset",
|
|
2144
|
+
"localghost:teardown": scripts["localghost:teardown"] ?? "localghost teardown",
|
|
2145
|
+
"localghost:doctor": scripts["localghost:doctor"] ?? "localghost doctor",
|
|
2146
|
+
"localghost:update": scripts["localghost:update"] ?? "localghost update",
|
|
2147
|
+
"caddy:setup": scripts["caddy:setup"] ?? `localghost setup${configFlag}`,
|
|
2148
|
+
"caddy:dev": scripts["caddy:dev"] ?? `localghost dev${configFlag}`
|
|
2149
|
+
};
|
|
2150
|
+
const changed = JSON.stringify(scripts) !== JSON.stringify(nextScripts);
|
|
2151
|
+
if (!changed) return false;
|
|
2152
|
+
pkg.scripts = nextScripts;
|
|
2153
|
+
writeFileSync4(packageJsonPath, `${JSON.stringify(pkg, null, 2)}
|
|
2154
|
+
`, "utf8");
|
|
2155
|
+
return true;
|
|
2156
|
+
}
|
|
2157
|
+
function initLocalghost(options = {}) {
|
|
2158
|
+
const cwd = options.cwd ?? process.cwd();
|
|
2159
|
+
const projectName = sanitizeProjectName(getProjectName(cwd).split("/").pop() ?? "app");
|
|
2160
|
+
const host = options.host ?? `${projectName}.localhost`;
|
|
2161
|
+
const port = options.port ?? 5173;
|
|
2162
|
+
const apiHost = options.apiHost ?? `api.${host}`;
|
|
2163
|
+
const apiPort = options.apiPort ?? 8787;
|
|
2164
|
+
const packageManager = options.packageManager ?? detectPackageManager(cwd);
|
|
2165
|
+
const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
|
|
2166
|
+
const configPath = join7(cwd, configFile);
|
|
2167
|
+
const configExists = existsSync5(configPath);
|
|
2168
|
+
if (configExists && !options.force) {
|
|
2169
|
+
return {
|
|
2170
|
+
configPath,
|
|
2171
|
+
configCreated: false,
|
|
2172
|
+
packageJsonChanged: false,
|
|
2173
|
+
packageManager,
|
|
2174
|
+
nextSteps: [
|
|
2175
|
+
packageRunCommand(packageManager, "localghost:doctor"),
|
|
2176
|
+
packageRunCommand(packageManager, "localghost:setup"),
|
|
2177
|
+
packageRunCommand(packageManager, "localghost:ready"),
|
|
2178
|
+
packageRunCommand(packageManager, "localghost:proxy")
|
|
2179
|
+
]
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
|
|
2183
|
+
const packageJsonPath = join7(cwd, "package.json");
|
|
2184
|
+
const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
|
|
1324
2185
|
return {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
"
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
].join("")
|
|
2186
|
+
configPath,
|
|
2187
|
+
configCreated: true,
|
|
2188
|
+
...existsSync5(packageJsonPath) ? { packageJsonPath } : {},
|
|
2189
|
+
packageJsonChanged,
|
|
2190
|
+
packageManager,
|
|
2191
|
+
nextSteps: [
|
|
2192
|
+
packageRunCommand(packageManager, "localghost:doctor"),
|
|
2193
|
+
packageRunCommand(packageManager, "localghost:setup"),
|
|
2194
|
+
packageRunCommand(packageManager, "localghost:ready"),
|
|
2195
|
+
packageRunCommand(packageManager, "localghost:proxy")
|
|
2196
|
+
]
|
|
1337
2197
|
};
|
|
1338
2198
|
}
|
|
1339
2199
|
|
|
@@ -1394,21 +2254,22 @@ function formatGhostTunnel(config, options = {}) {
|
|
|
1394
2254
|
if (options.verbose) {
|
|
1395
2255
|
lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
|
|
1396
2256
|
lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
|
|
1397
|
-
lines.push(`
|
|
2257
|
+
lines.push(` protocol: ${config.requireHttps ? "https required" : "http allowed"}`);
|
|
2258
|
+
lines.push(` transport: ${config.transport.kind}`);
|
|
1398
2259
|
}
|
|
1399
2260
|
return lines.join("\n");
|
|
1400
2261
|
}
|
|
1401
2262
|
|
|
1402
2263
|
// src/state.ts
|
|
1403
|
-
import { existsSync as
|
|
1404
|
-
import { join as
|
|
2264
|
+
import { existsSync as existsSync6 } from "fs";
|
|
2265
|
+
import { join as join8 } from "path";
|
|
1405
2266
|
var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
|
|
1406
2267
|
function getLocalghostStatePath(cwd = process.cwd()) {
|
|
1407
|
-
return
|
|
2268
|
+
return join8(cwd, LOCALGHOST_STATE_FILE);
|
|
1408
2269
|
}
|
|
1409
2270
|
function readLocalghostState(cwd = process.cwd()) {
|
|
1410
2271
|
const path = getLocalghostStatePath(cwd);
|
|
1411
|
-
if (!
|
|
2272
|
+
if (!existsSync6(path)) return null;
|
|
1412
2273
|
return JSON.parse(readTextFile(path));
|
|
1413
2274
|
}
|
|
1414
2275
|
function writeLocalghostState(cwd, state) {
|
|
@@ -1423,12 +2284,239 @@ function patchLocalghostState(cwd, patch) {
|
|
|
1423
2284
|
return writeLocalghostState(cwd, { ...current, ...patch });
|
|
1424
2285
|
}
|
|
1425
2286
|
|
|
2287
|
+
// src/vercel.ts
|
|
2288
|
+
function getHeaderValue(value) {
|
|
2289
|
+
if (Array.isArray(value)) return value[0] ?? "";
|
|
2290
|
+
return value ?? "";
|
|
2291
|
+
}
|
|
2292
|
+
function getTrustedProtocol(request) {
|
|
2293
|
+
const forwarded = getHeaderValue(request.headers["x-forwarded-proto"]).split(",")[0]?.trim().toLowerCase();
|
|
2294
|
+
return forwarded === "http" ? "http" : "https";
|
|
2295
|
+
}
|
|
2296
|
+
function getTrustedRequestUrl(request, host, protocol) {
|
|
2297
|
+
return new URL(request.url ?? "/", `${protocol}://${host}`).toString();
|
|
2298
|
+
}
|
|
2299
|
+
function getTunnelRequestPath(request) {
|
|
2300
|
+
const url = new URL(request.url ?? "/", "http://localghost.invalid");
|
|
2301
|
+
return `${url.pathname}${url.search}`;
|
|
2302
|
+
}
|
|
2303
|
+
function normalizeRequestHeaders(headers) {
|
|
2304
|
+
const stripped = stripRelayForwardHeaders(headers);
|
|
2305
|
+
return Object.fromEntries(Object.entries(stripped).map(([name, value]) => [
|
|
2306
|
+
name,
|
|
2307
|
+
Array.isArray(value) ? value.join(", ") : value
|
|
2308
|
+
]));
|
|
2309
|
+
}
|
|
2310
|
+
function getTunnelStore(options, namespace) {
|
|
2311
|
+
return options.tunnelStore ?? createRedisGhostTunnelStoreFromEnv({
|
|
2312
|
+
...options.tunnelEnv ? { env: options.tunnelEnv } : {},
|
|
2313
|
+
namespace
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
async function readRequestBody(request, maxBytes) {
|
|
2317
|
+
if (!request[Symbol.asyncIterator]) return void 0;
|
|
2318
|
+
const chunks = [];
|
|
2319
|
+
let size = 0;
|
|
2320
|
+
for await (const chunk of request) {
|
|
2321
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2322
|
+
size += buffer.byteLength;
|
|
2323
|
+
if (size > maxBytes) {
|
|
2324
|
+
throw new Error(`Ghost Tunnel request exceeded ${maxBytes} bytes.`);
|
|
2325
|
+
}
|
|
2326
|
+
chunks.push(buffer);
|
|
2327
|
+
}
|
|
2328
|
+
return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
|
|
2329
|
+
}
|
|
2330
|
+
function writeResponse(response, payload) {
|
|
2331
|
+
response.statusCode = payload.status;
|
|
2332
|
+
for (const [name, value] of Object.entries(payload.headers)) {
|
|
2333
|
+
response.setHeader(name, value);
|
|
2334
|
+
}
|
|
2335
|
+
response.end(payload.body);
|
|
2336
|
+
}
|
|
2337
|
+
function renderGhostTunnelIpRedirectResponse(url) {
|
|
2338
|
+
return {
|
|
2339
|
+
status: 307,
|
|
2340
|
+
headers: {
|
|
2341
|
+
location: url,
|
|
2342
|
+
"cache-control": "no-store",
|
|
2343
|
+
"content-type": "text/html; charset=utf-8",
|
|
2344
|
+
"x-localghost-relay": "ip"
|
|
2345
|
+
},
|
|
2346
|
+
body: [
|
|
2347
|
+
"<!doctype html>",
|
|
2348
|
+
"<html>",
|
|
2349
|
+
'<head><meta charset="utf-8"><title>Redirecting to local preview</title></head>',
|
|
2350
|
+
"<body>",
|
|
2351
|
+
`<p>Redirecting to <a href="${url}">${url}</a>.</p>`,
|
|
2352
|
+
"</body>",
|
|
2353
|
+
"</html>"
|
|
2354
|
+
].join("")
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
function renderGhostTunnelTransportRejectedResponse(message, status = 400) {
|
|
2358
|
+
return {
|
|
2359
|
+
status,
|
|
2360
|
+
headers: {
|
|
2361
|
+
"content-type": "text/html; charset=utf-8",
|
|
2362
|
+
"cache-control": "no-store",
|
|
2363
|
+
"x-localghost-relay": "rejected"
|
|
2364
|
+
},
|
|
2365
|
+
body: [
|
|
2366
|
+
"<!doctype html>",
|
|
2367
|
+
"<html>",
|
|
2368
|
+
'<head><meta charset="utf-8"><title>Ghost Tunnel transport rejected</title></head>',
|
|
2369
|
+
"<body>",
|
|
2370
|
+
"<h1>Ghost Tunnel transport rejected</h1>",
|
|
2371
|
+
`<p>${message}</p>`,
|
|
2372
|
+
"</body>",
|
|
2373
|
+
"</html>"
|
|
2374
|
+
].join("")
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
function renderGhostTunnelTunnelTimeoutResponse() {
|
|
2378
|
+
return {
|
|
2379
|
+
status: 504,
|
|
2380
|
+
headers: {
|
|
2381
|
+
"content-type": "text/html; charset=utf-8",
|
|
2382
|
+
"cache-control": "no-store",
|
|
2383
|
+
"x-localghost-relay": "timeout"
|
|
2384
|
+
},
|
|
2385
|
+
body: [
|
|
2386
|
+
"<!doctype html>",
|
|
2387
|
+
"<html>",
|
|
2388
|
+
'<head><meta charset="utf-8"><title>Ghost Tunnel timed out</title></head>',
|
|
2389
|
+
"<body>",
|
|
2390
|
+
"<h1>Ghost Tunnel timed out</h1>",
|
|
2391
|
+
"<p>The deployed handler did not receive a local response before the request window closed.</p>",
|
|
2392
|
+
"</body>",
|
|
2393
|
+
"</html>"
|
|
2394
|
+
].join("")
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
function renderGhostTunnelQueuedResponse(response) {
|
|
2398
|
+
const body = decodeGhostTunnelBody(response.bodyBase64)?.toString() ?? "";
|
|
2399
|
+
return {
|
|
2400
|
+
status: response.status,
|
|
2401
|
+
headers: {
|
|
2402
|
+
...response.headers,
|
|
2403
|
+
"x-localghost-relay": response.error ? "target-error" : "tunnel"
|
|
2404
|
+
},
|
|
2405
|
+
body
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
async function waitForTunnelResponse(input) {
|
|
2409
|
+
const startedAt = Date.now();
|
|
2410
|
+
while (Date.now() - startedAt < input.waitMs) {
|
|
2411
|
+
const response = await input.store.readResponse(input.requestId);
|
|
2412
|
+
if (response) {
|
|
2413
|
+
await input.store.cleanup(input.requestId);
|
|
2414
|
+
return response;
|
|
2415
|
+
}
|
|
2416
|
+
await new Promise((resolve3) => setTimeout(resolve3, input.pollIntervalMs));
|
|
2417
|
+
}
|
|
2418
|
+
return null;
|
|
2419
|
+
}
|
|
2420
|
+
async function resolveAuthenticatedState(input, request) {
|
|
2421
|
+
if (typeof input === "function") {
|
|
2422
|
+
return await input(request);
|
|
2423
|
+
}
|
|
2424
|
+
return input;
|
|
2425
|
+
}
|
|
2426
|
+
function createVercelGhostTunnelHandler(options) {
|
|
2427
|
+
return async function handler(request, response) {
|
|
2428
|
+
const host = getHeaderValue(request.headers.host);
|
|
2429
|
+
const protocol = getTrustedProtocol(request);
|
|
2430
|
+
try {
|
|
2431
|
+
const authenticated = typeof options.authenticated !== "undefined" ? await resolveAuthenticatedState(options.authenticated, request) : void 0;
|
|
2432
|
+
const resolved = await resolveGhostTunnelRequest({
|
|
2433
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
2434
|
+
...typeof options.localghostConfig !== "undefined" ? { localghostConfig: options.localghostConfig } : {},
|
|
2435
|
+
...options.ghostTunnelFile ? { ghostTunnelFile: options.ghostTunnelFile } : {},
|
|
2436
|
+
host,
|
|
2437
|
+
domain: options.domain,
|
|
2438
|
+
protocol,
|
|
2439
|
+
...typeof authenticated === "boolean" ? { authenticated } : {}
|
|
2440
|
+
});
|
|
2441
|
+
if (!resolved.entry) {
|
|
2442
|
+
writeResponse(response, renderGhostTunnelRouteMissingResponse(resolved));
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
if (resolved.ghostTunnel.transport.kind === "ip") {
|
|
2446
|
+
if (!options.ipSigningSecret) {
|
|
2447
|
+
writeResponse(response, renderGhostTunnelTransportRejectedResponse("Ghost Tunnel IP transport requires ipSigningSecret in the deployed handler.", 500));
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
try {
|
|
2451
|
+
const redirect = resolveGhostTunnelIpRedirect({
|
|
2452
|
+
requestUrl: getTrustedRequestUrl(request, host, protocol),
|
|
2453
|
+
host: resolved.route.host,
|
|
2454
|
+
entryPort: resolved.entry.port,
|
|
2455
|
+
signingSecret: options.ipSigningSecret,
|
|
2456
|
+
transport: resolved.ghostTunnel.transport
|
|
2457
|
+
});
|
|
2458
|
+
writeResponse(response, renderGhostTunnelIpRedirectResponse(redirect.url));
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2461
|
+
const status = /expired/i.test(message) ? 410 : 400;
|
|
2462
|
+
writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, status));
|
|
2463
|
+
}
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
if (resolved.ghostTunnel.transport.kind === "tunnel") {
|
|
2467
|
+
const transport = resolved.ghostTunnel.transport;
|
|
2468
|
+
const store = getTunnelStore(options, transport.store.namespace);
|
|
2469
|
+
const route = await store.getRoute(resolved.route.host);
|
|
2470
|
+
if (!route) {
|
|
2471
|
+
writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
try {
|
|
2475
|
+
const requestBody = await readRequestBody(request, transport.maxRequestBodyBytes);
|
|
2476
|
+
const queuedRequest = createGhostTunnelQueuedRequest({
|
|
2477
|
+
host: resolved.route.host,
|
|
2478
|
+
method: request.method ?? "GET",
|
|
2479
|
+
path: getTunnelRequestPath(request),
|
|
2480
|
+
headers: normalizeRequestHeaders(request.headers),
|
|
2481
|
+
...requestBody ? { body: requestBody } : {},
|
|
2482
|
+
ttlSeconds: transport.requestTtlSeconds
|
|
2483
|
+
});
|
|
2484
|
+
await store.enqueueRequest(queuedRequest, transport.requestTtlSeconds);
|
|
2485
|
+
const tunnelResponse = await waitForTunnelResponse({
|
|
2486
|
+
store,
|
|
2487
|
+
requestId: queuedRequest.id,
|
|
2488
|
+
waitMs: transport.waitMs,
|
|
2489
|
+
pollIntervalMs: transport.pollIntervalMs
|
|
2490
|
+
});
|
|
2491
|
+
writeResponse(response, tunnelResponse ? renderGhostTunnelQueuedResponse(tunnelResponse) : renderGhostTunnelTunnelTimeoutResponse());
|
|
2492
|
+
} catch (error) {
|
|
2493
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2494
|
+
writeResponse(response, renderGhostTunnelTransportRejectedResponse(message, 400));
|
|
2495
|
+
}
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
writeResponse(response, renderGhostTunnelRelayOfflineResponse(resolved));
|
|
2499
|
+
} catch (error) {
|
|
2500
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2501
|
+
writeResponse(response, {
|
|
2502
|
+
status: /authenticated/i.test(message) ? 401 : 404,
|
|
2503
|
+
headers: {
|
|
2504
|
+
"content-type": "text/plain; charset=utf-8",
|
|
2505
|
+
"cache-control": "no-store",
|
|
2506
|
+
"x-localghost-relay": "rejected"
|
|
2507
|
+
},
|
|
2508
|
+
body: message
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
|
|
1426
2514
|
// src/update-check.ts
|
|
1427
|
-
import { existsSync as
|
|
2515
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
1428
2516
|
import { homedir as homedir2 } from "os";
|
|
1429
|
-
import { dirname as dirname4, join as
|
|
2517
|
+
import { dirname as dirname4, join as join9 } from "path";
|
|
1430
2518
|
var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
|
|
1431
|
-
var LOCALGHOST_VERSION = "0.1.
|
|
2519
|
+
var LOCALGHOST_VERSION = "0.1.13";
|
|
1432
2520
|
var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1433
2521
|
var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1434
2522
|
var UPDATE_CHECK_TIMEOUT_MS = 900;
|
|
@@ -1440,13 +2528,13 @@ function isUpdateCheckDisabled(env = process.env) {
|
|
|
1440
2528
|
}
|
|
1441
2529
|
function getUpdateCheckCachePath(env = process.env) {
|
|
1442
2530
|
if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
|
|
1443
|
-
const cacheRoot = env.XDG_CACHE_HOME ||
|
|
1444
|
-
return
|
|
2531
|
+
const cacheRoot = env.XDG_CACHE_HOME || join9(homedir2(), ".cache");
|
|
2532
|
+
return join9(cacheRoot, "localghost", "update-check.json");
|
|
1445
2533
|
}
|
|
1446
2534
|
function readCache(path = getUpdateCheckCachePath()) {
|
|
1447
|
-
if (!
|
|
2535
|
+
if (!existsSync7(path)) return null;
|
|
1448
2536
|
try {
|
|
1449
|
-
return JSON.parse(
|
|
2537
|
+
return JSON.parse(readFileSync7(path, "utf8"));
|
|
1450
2538
|
} catch {
|
|
1451
2539
|
return null;
|
|
1452
2540
|
}
|
|
@@ -1590,12 +2678,16 @@ ${message}`);
|
|
|
1590
2678
|
markUpdateNotified(result, cachePath);
|
|
1591
2679
|
}
|
|
1592
2680
|
export {
|
|
2681
|
+
DEFAULT_GHOST_TUNNEL_IP_TRANSPORT_TTL_SECONDS,
|
|
2682
|
+
DEFAULT_GHOST_TUNNEL_RESPONSE_TTL_SECONDS,
|
|
2683
|
+
DEFAULT_GHOST_TUNNEL_TRANSPORT_QUERY_PARAM,
|
|
1593
2684
|
DEFAULT_RELAY_ALLOWED_TARGET_HOSTS,
|
|
1594
2685
|
DEFAULT_RELAY_BLOCKED_PORTS,
|
|
1595
2686
|
DEFAULT_RELAY_LIMITS,
|
|
1596
2687
|
DEFAULT_RELAY_TARGET_POLICY,
|
|
1597
2688
|
LOCALGHOST_ACTIVITY_VERSION,
|
|
1598
2689
|
LOCALGHOST_CONFIG_FILE,
|
|
2690
|
+
LOCALGHOST_GHOST_TUNNEL_FILE,
|
|
1599
2691
|
LOCALGHOST_PACKAGE_NAME,
|
|
1600
2692
|
LOCALGHOST_STATE_FILE,
|
|
1601
2693
|
LOCALGHOST_VERSION,
|
|
@@ -1611,13 +2703,28 @@ export {
|
|
|
1611
2703
|
checkForUpdate,
|
|
1612
2704
|
compareVersions,
|
|
1613
2705
|
constructGhostTunnelHost,
|
|
2706
|
+
constructGhostTunnelIpUrl,
|
|
1614
2707
|
constructGhostTunnelURL,
|
|
1615
2708
|
constructGhostTunnelUrl,
|
|
2709
|
+
createGhostTunnelQueuedRequest,
|
|
2710
|
+
createGhostTunnelRouteHeartbeat,
|
|
2711
|
+
createMemoryGhostTunnelStore,
|
|
2712
|
+
createRedisGhostTunnelStore,
|
|
2713
|
+
createRedisGhostTunnelStoreFromEnv,
|
|
1616
2714
|
createRelayRouteRegistration,
|
|
2715
|
+
createVercelGhostTunnelHandler,
|
|
2716
|
+
decodeGhostTunnelBody,
|
|
1617
2717
|
defineLocalghostConfig,
|
|
2718
|
+
detectDevCommand,
|
|
2719
|
+
detectDevPackageManager,
|
|
2720
|
+
detectDevServices,
|
|
1618
2721
|
detectPackageManager,
|
|
2722
|
+
encodeGhostTunnelBody,
|
|
1619
2723
|
findAvailablePort,
|
|
2724
|
+
findGhostTunnelEntry,
|
|
1620
2725
|
findLocalMdnsHosts,
|
|
2726
|
+
formatDetectedDevCommand,
|
|
2727
|
+
formatDetectedDevServices,
|
|
1621
2728
|
formatDomainRoutes,
|
|
1622
2729
|
formatGhostTunnel,
|
|
1623
2730
|
formatUpdateMessage,
|
|
@@ -1629,6 +2736,7 @@ export {
|
|
|
1629
2736
|
getGhostTunnelDisplayUrl,
|
|
1630
2737
|
getGhostTunnelDisplayUrls,
|
|
1631
2738
|
getGhostTunnelEntryHost,
|
|
2739
|
+
getGhostTunnelPath,
|
|
1632
2740
|
getGhostTunnelPreviewUrl,
|
|
1633
2741
|
getGhostTunnelWildcardHost,
|
|
1634
2742
|
getLocalghostActivityPath,
|
|
@@ -1645,6 +2753,7 @@ export {
|
|
|
1645
2753
|
isProductionLike,
|
|
1646
2754
|
isRelayRouteActive,
|
|
1647
2755
|
isUpdateCheckDisabled,
|
|
2756
|
+
listGhostTunnelEntries,
|
|
1648
2757
|
listLocalghostRuns,
|
|
1649
2758
|
listLocalghostSetups,
|
|
1650
2759
|
markUpdateNotified,
|
|
@@ -1656,6 +2765,7 @@ export {
|
|
|
1656
2765
|
patchLocalghostState,
|
|
1657
2766
|
pruneLocalghostActivity,
|
|
1658
2767
|
readDevHosts,
|
|
2768
|
+
readGhostTunnelEntries,
|
|
1659
2769
|
readLocalghostActivity,
|
|
1660
2770
|
readLocalghostProjectConfig,
|
|
1661
2771
|
readLocalghostState,
|
|
@@ -1666,17 +2776,28 @@ export {
|
|
|
1666
2776
|
removeManagedBlock,
|
|
1667
2777
|
removeSystemHosts,
|
|
1668
2778
|
renderCaddyfile,
|
|
2779
|
+
renderCompactLocalghostBanner,
|
|
2780
|
+
renderGhostTunnelRelayOfflineResponse,
|
|
2781
|
+
renderGhostTunnelRouteMissingResponse,
|
|
1669
2782
|
renderHostsBlock,
|
|
2783
|
+
renderLocalghostBanner,
|
|
1670
2784
|
renderRelayOfflineResponse,
|
|
1671
2785
|
resolveDevHostsPath,
|
|
1672
2786
|
resolveGhostTunnelConfig,
|
|
2787
|
+
resolveGhostTunnelIpRedirect,
|
|
2788
|
+
resolveGhostTunnelPath,
|
|
2789
|
+
resolveGhostTunnelRequest,
|
|
1673
2790
|
resolveLocalghostContext,
|
|
2791
|
+
resolveRedisGhostTunnelEnv,
|
|
1674
2792
|
runCaddy,
|
|
1675
2793
|
runDoctor,
|
|
1676
2794
|
sanitizeProjectName,
|
|
2795
|
+
serveGhostTunnelLocalRequest,
|
|
1677
2796
|
shouldNotifyAboutUpdate,
|
|
2797
|
+
signGhostTunnelIpTransportClaim,
|
|
1678
2798
|
signRelayRouteClaim,
|
|
1679
2799
|
startCaddy,
|
|
2800
|
+
startGhostTunnelAgent,
|
|
1680
2801
|
stripRelayForwardHeaders,
|
|
1681
2802
|
trustCaddy,
|
|
1682
2803
|
unregisterLocalghostRun,
|
|
@@ -1684,6 +2805,7 @@ export {
|
|
|
1684
2805
|
updateSystemHosts,
|
|
1685
2806
|
upsertManagedBlock,
|
|
1686
2807
|
validateCaddyfile,
|
|
2808
|
+
verifyGhostTunnelIpTransportClaim,
|
|
1687
2809
|
verifyRelayRouteClaim,
|
|
1688
2810
|
writeCaddyfile,
|
|
1689
2811
|
writeLocalghostActivity,
|