@hamedb89/localghost 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -327
- package/apps/macos-widget/LocalghostWidget.entitlements +12 -0
- package/apps/macos-widget/LocalghostWidget.swift +701 -48
- package/apps/macos-widget/Resources/localghost-logo-source.png +0 -0
- package/apps/macos-widget/Resources/localghost-widget-ui-reference.png +0 -0
- package/apps/macos-widget/Shared/LocalghostWidgetSnapshot.swift +50 -0
- package/apps/macos-widget/WidgetExtension/LocalghostDesktopWidget.swift +176 -0
- package/apps/macos-widget/WidgetExtension/LocalghostDesktopWidgetExtension.entitlements +12 -0
- package/apps/macos-widget/build.sh +8 -1
- package/apps/macos-widget/project.yml +40 -0
- package/assets/ghost-tunnel-app-icon.png +0 -0
- package/assets/ghost-tunnel-portal.png +0 -0
- package/assets/ghost-tunnel-wordmark.png +0 -0
- package/dist/cli.js +528 -63
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +130 -5
- package/dist/index.js +765 -31
- package/dist/index.js.map +1 -1
- package/dist/tunnel-DzfLXZ8O.d.ts +126 -0
- package/dist/vite.d.ts +3 -1
- package/dist/vite.js +660 -36
- package/dist/vite.js.map +1 -1
- package/docs/flows.md +19 -0
- package/docs/ghost-tunnel.md +293 -0
- package/docs/github.md +6 -6
- package/docs/localghost.1.md +10 -4
- package/docs/macos-widget.md +68 -6
- package/package.json +6 -2
- package/dist/config-Cde1Bich.d.ts +0 -31
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ function isProcessRunning(pid) {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
function emptyActivity() {
|
|
22
|
-
return { version: LOCALGHOST_ACTIVITY_VERSION, runs: [] };
|
|
22
|
+
return { version: LOCALGHOST_ACTIVITY_VERSION, runs: [], setups: [] };
|
|
23
23
|
}
|
|
24
24
|
function readLocalghostActivity(path = getLocalghostActivityPath()) {
|
|
25
25
|
if (!existsSync(path)) return emptyActivity();
|
|
@@ -27,7 +27,8 @@ function readLocalghostActivity(path = getLocalghostActivityPath()) {
|
|
|
27
27
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
28
28
|
return {
|
|
29
29
|
version: LOCALGHOST_ACTIVITY_VERSION,
|
|
30
|
-
runs: Array.isArray(parsed.runs) ? parsed.runs : []
|
|
30
|
+
runs: Array.isArray(parsed.runs) ? parsed.runs : [],
|
|
31
|
+
setups: Array.isArray(parsed.setups) ? parsed.setups : []
|
|
31
32
|
};
|
|
32
33
|
} catch {
|
|
33
34
|
return emptyActivity();
|
|
@@ -42,22 +43,29 @@ function writeLocalghostActivity(activity, path = getLocalghostActivityPath()) {
|
|
|
42
43
|
function createRunId(input, pid) {
|
|
43
44
|
return `${input.projectName}:${input.mode}:${pid}:${Date.now()}`;
|
|
44
45
|
}
|
|
46
|
+
function createSetupId(input) {
|
|
47
|
+
return `${input.projectName}:${input.cwd}:${input.configPath ?? ""}`;
|
|
48
|
+
}
|
|
45
49
|
function pruneLocalghostActivity(path = getLocalghostActivityPath()) {
|
|
46
50
|
const activity = readLocalghostActivity(path);
|
|
47
51
|
const activeRuns = activity.runs.filter((run) => isProcessRunning(run.pid));
|
|
48
52
|
const pruned = activeRuns.length !== activity.runs.length;
|
|
49
53
|
if (pruned) {
|
|
50
|
-
writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: activeRuns }, path);
|
|
54
|
+
writeLocalghostActivity({ ...activity, version: LOCALGHOST_ACTIVITY_VERSION, runs: activeRuns }, path);
|
|
51
55
|
}
|
|
52
56
|
return {
|
|
53
57
|
path,
|
|
54
58
|
pruned,
|
|
55
|
-
runs: activeRuns
|
|
59
|
+
runs: activeRuns,
|
|
60
|
+
setups: activity.setups
|
|
56
61
|
};
|
|
57
62
|
}
|
|
58
63
|
function listLocalghostRuns(path = getLocalghostActivityPath()) {
|
|
59
64
|
return pruneLocalghostActivity(path).runs;
|
|
60
65
|
}
|
|
66
|
+
function listLocalghostSetups(path = getLocalghostActivityPath()) {
|
|
67
|
+
return pruneLocalghostActivity(path).setups;
|
|
68
|
+
}
|
|
61
69
|
function registerLocalghostRun(input, path = getLocalghostActivityPath()) {
|
|
62
70
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
63
71
|
const pid = input.pid ?? process.pid;
|
|
@@ -81,14 +89,43 @@ function registerLocalghostRun(input, path = getLocalghostActivityPath()) {
|
|
|
81
89
|
entries: input.entries
|
|
82
90
|
};
|
|
83
91
|
const current = pruneLocalghostActivity(path).runs.filter((run) => run.id !== record.id);
|
|
84
|
-
|
|
92
|
+
const activity = readLocalghostActivity(path);
|
|
93
|
+
writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: [...current, record], setups: activity.setups }, path);
|
|
94
|
+
return record;
|
|
95
|
+
}
|
|
96
|
+
function registerLocalghostSetup(input, path = getLocalghostActivityPath()) {
|
|
97
|
+
const record = {
|
|
98
|
+
id: input.id ?? createSetupId(input),
|
|
99
|
+
cwd: input.cwd,
|
|
100
|
+
projectName: input.projectName,
|
|
101
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
102
|
+
...input.configPath ? { configPath: input.configPath } : {},
|
|
103
|
+
...input.caddyfilePath ? { caddyfilePath: input.caddyfilePath } : {},
|
|
104
|
+
...typeof input.https === "boolean" ? { https: input.https } : {},
|
|
105
|
+
entries: input.entries
|
|
106
|
+
};
|
|
107
|
+
const activity = pruneLocalghostActivity(path);
|
|
108
|
+
const setups = activity.setups.filter((setup) => setup.id !== record.id);
|
|
109
|
+
writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs: activity.runs, setups: [...setups, record] }, path);
|
|
85
110
|
return record;
|
|
86
111
|
}
|
|
87
112
|
function unregisterLocalghostRun(id, path = getLocalghostActivityPath()) {
|
|
88
113
|
const activity = readLocalghostActivity(path);
|
|
89
114
|
const runs = activity.runs.filter((run) => run.id !== id);
|
|
90
115
|
if (runs.length !== activity.runs.length) {
|
|
91
|
-
writeLocalghostActivity({ version: LOCALGHOST_ACTIVITY_VERSION, runs }, path);
|
|
116
|
+
writeLocalghostActivity({ ...activity, version: LOCALGHOST_ACTIVITY_VERSION, runs }, path);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function unregisterLocalghostSetup(options, path = getLocalghostActivityPath()) {
|
|
120
|
+
const activity = readLocalghostActivity(path);
|
|
121
|
+
const setups = activity.setups.filter((setup) => {
|
|
122
|
+
if (setup.cwd !== options.cwd) return true;
|
|
123
|
+
if (options.projectName && setup.projectName !== options.projectName) return true;
|
|
124
|
+
if (options.configPath && setup.configPath !== options.configPath) return true;
|
|
125
|
+
return false;
|
|
126
|
+
});
|
|
127
|
+
if (setups.length !== activity.setups.length) {
|
|
128
|
+
writeLocalghostActivity({ ...activity, version: LOCALGHOST_ACTIVITY_VERSION, setups }, path);
|
|
92
129
|
}
|
|
93
130
|
}
|
|
94
131
|
|
|
@@ -291,7 +328,8 @@ async function trustCaddy(path) {
|
|
|
291
328
|
}
|
|
292
329
|
|
|
293
330
|
// src/context.ts
|
|
294
|
-
import { existsSync as existsSync3 } from "fs";
|
|
331
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
|
|
332
|
+
import { join as join4 } from "path";
|
|
295
333
|
import { pathToFileURL } from "url";
|
|
296
334
|
|
|
297
335
|
// src/port.ts
|
|
@@ -320,6 +358,343 @@ async function findAvailablePort(startPort, options = {}) {
|
|
|
320
358
|
throw new Error(`No available port found from ${startPort} to ${startPort + maxAttempts - 1}.`);
|
|
321
359
|
}
|
|
322
360
|
|
|
361
|
+
// src/tunnel.ts
|
|
362
|
+
import { domainToASCII } from "url";
|
|
363
|
+
var DEFAULT_GHOST_TUNNEL_SUBDOMAIN = "ghost";
|
|
364
|
+
var DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS = ["route", "project", "owner"];
|
|
365
|
+
var DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR = "-";
|
|
366
|
+
var DEFAULT_GHOST_TUNNEL_MODE = "manual";
|
|
367
|
+
function isResolvedGhostTunnelConfig(value) {
|
|
368
|
+
return typeof value === "object" && value !== null && "enabled" in value;
|
|
369
|
+
}
|
|
370
|
+
function toGhostTunnelConfig(options) {
|
|
371
|
+
return isResolvedGhostTunnelConfig(options) ? options : resolveGhostTunnelConfig(options);
|
|
372
|
+
}
|
|
373
|
+
function stripHostPort(value) {
|
|
374
|
+
const trimmed = value.trim().toLowerCase();
|
|
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;
|
|
380
|
+
}
|
|
381
|
+
function normalizeDomain(value) {
|
|
382
|
+
const host = stripHostPort(value.replace(/^\*\./, ""));
|
|
383
|
+
const ascii = domainToASCII(host);
|
|
384
|
+
if (!ascii || ascii.length > 253 || ascii.includes("..")) return null;
|
|
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;
|
|
389
|
+
}
|
|
390
|
+
function isValidHostLabel(value) {
|
|
391
|
+
return value.length > 0 && value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
|
392
|
+
}
|
|
393
|
+
function isValidNamespaceTag(value) {
|
|
394
|
+
return /^[a-z][a-z0-9_]*$/i.test(value);
|
|
395
|
+
}
|
|
396
|
+
function isNamespaceTagList(options) {
|
|
397
|
+
return Array.isArray(options);
|
|
398
|
+
}
|
|
399
|
+
function assertValidSubdomain(value) {
|
|
400
|
+
if (!isValidHostLabel(value)) {
|
|
401
|
+
throw new Error(`Invalid ghost tunnel subdomain: ${value}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function normalizeDomains(domains) {
|
|
405
|
+
const values = typeof domains === "string" ? [domains] : [...domains ?? []];
|
|
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)];
|
|
412
|
+
}
|
|
413
|
+
function parseGhostTunnelMode(value) {
|
|
414
|
+
return value ?? DEFAULT_GHOST_TUNNEL_MODE;
|
|
415
|
+
}
|
|
416
|
+
function resolveNamespaceConfig(options) {
|
|
417
|
+
const tags = isNamespaceTagList(options) ? [...options] : [...options?.tags ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_TAGS];
|
|
418
|
+
let separator = DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
|
|
419
|
+
let spreadTag = tags.includes("project") ? "project" : void 0;
|
|
420
|
+
if (options && !isNamespaceTagList(options)) {
|
|
421
|
+
separator = options.separator ?? DEFAULT_GHOST_TUNNEL_NAMESPACE_SEPARATOR;
|
|
422
|
+
spreadTag = options.spreadTag === false ? false : options.spreadTag ?? spreadTag;
|
|
423
|
+
}
|
|
424
|
+
if (tags.length === 0) {
|
|
425
|
+
throw new Error("Ghost tunnel namespace must include at least one tag.");
|
|
426
|
+
}
|
|
427
|
+
for (const tag of tags) {
|
|
428
|
+
if (!isValidNamespaceTag(tag)) {
|
|
429
|
+
throw new Error(`Invalid ghost tunnel namespace tag: ${tag}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (spreadTag && !tags.includes(spreadTag)) {
|
|
433
|
+
throw new Error(`Ghost tunnel namespace spreadTag must be listed in tags: ${spreadTag}`);
|
|
434
|
+
}
|
|
435
|
+
if (!separator || separator.length > 8 || !/^[a-z0-9-]+$/.test(separator)) {
|
|
436
|
+
throw new Error(`Invalid ghost tunnel namespace separator: ${separator}`);
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
tags,
|
|
440
|
+
separator,
|
|
441
|
+
...spreadTag ? { spreadTag } : {}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function normalizeNamespaceValue(tag, value, separator, options = {}) {
|
|
445
|
+
const normalized = normalizeDomain(value);
|
|
446
|
+
if (!normalized || normalized.includes(".")) {
|
|
447
|
+
throw new Error(`Invalid ghost tunnel namespace value for ${tag}: ${value}`);
|
|
448
|
+
}
|
|
449
|
+
if (!options.allowSeparator && normalized.includes(separator)) {
|
|
450
|
+
throw new Error(`Ghost tunnel namespace value for ${tag} cannot include separator "${separator}": ${value}`);
|
|
451
|
+
}
|
|
452
|
+
return normalized;
|
|
453
|
+
}
|
|
454
|
+
function createNamespaceSlug(config, values) {
|
|
455
|
+
const parts = config.tags.map((tag) => {
|
|
456
|
+
const value = values[tag];
|
|
457
|
+
if (!value) {
|
|
458
|
+
throw new Error(`Missing ghost tunnel namespace value: ${tag}`);
|
|
459
|
+
}
|
|
460
|
+
return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
|
|
461
|
+
});
|
|
462
|
+
const slug = parts.join(config.separator);
|
|
463
|
+
if (!isValidHostLabel(slug)) {
|
|
464
|
+
throw new Error(`Ghost tunnel namespace is too long for a DNS label: ${slug}`);
|
|
465
|
+
}
|
|
466
|
+
return slug;
|
|
467
|
+
}
|
|
468
|
+
function createNamespaceDisplaySlug(config, values = {}) {
|
|
469
|
+
return config.tags.map((tag) => {
|
|
470
|
+
const value = values[tag];
|
|
471
|
+
if (!value) return `<${tag}>`;
|
|
472
|
+
try {
|
|
473
|
+
return normalizeNamespaceValue(tag, value, config.separator, { allowSeparator: tag === config.spreadTag });
|
|
474
|
+
} catch {
|
|
475
|
+
return `<${tag}>`;
|
|
476
|
+
}
|
|
477
|
+
}).join(config.separator);
|
|
478
|
+
}
|
|
479
|
+
function getPreviewDefaults(preview, defaults) {
|
|
480
|
+
return {
|
|
481
|
+
domain: preview?.domain ?? defaults?.domain,
|
|
482
|
+
route: preview?.route ?? defaults?.route,
|
|
483
|
+
project: preview?.project ?? defaults?.project,
|
|
484
|
+
owner: preview?.owner ?? defaults?.owner,
|
|
485
|
+
values: {
|
|
486
|
+
...defaults?.values ?? {},
|
|
487
|
+
...preview?.values ?? {}
|
|
488
|
+
},
|
|
489
|
+
path: preview?.path,
|
|
490
|
+
protocol: preview?.protocol
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function getDisplayValues(input) {
|
|
494
|
+
return {
|
|
495
|
+
...input.route ? { route: input.route } : {},
|
|
496
|
+
...input.project ? { project: input.project } : {},
|
|
497
|
+
...input.owner ? { owner: input.owner } : {},
|
|
498
|
+
...input.values
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function getDisplayDefaults(config, defaults) {
|
|
502
|
+
return config.mode === "public" && !config.preview ? void 0 : defaults;
|
|
503
|
+
}
|
|
504
|
+
function createDisplayUrl(config, defaults, domain) {
|
|
505
|
+
const input = getPreviewDefaults(config.preview, getDisplayDefaults(config, defaults));
|
|
506
|
+
const protocol = input.protocol ?? "https";
|
|
507
|
+
const slug = createNamespaceDisplaySlug(config.namespace, getDisplayValues(input));
|
|
508
|
+
const entryHost = domain ? getGhostTunnelEntryHost(domain, config) : input.domain ? getGhostTunnelEntryHost(input.domain, config) : `${config.subdomain}.*`;
|
|
509
|
+
const url = `${protocol}://${slug}.${entryHost}/`;
|
|
510
|
+
if (!input.path) return url;
|
|
511
|
+
return `${url}${input.path.replace(/^\/+/, "")}`;
|
|
512
|
+
}
|
|
513
|
+
function createDisplayUrls(config, defaults) {
|
|
514
|
+
const displayDefaults = getDisplayDefaults(config, defaults);
|
|
515
|
+
const domains = config.domains.length > 0 ? config.domains : displayDefaults?.domain ? [displayDefaults.domain] : [];
|
|
516
|
+
const urls = domains.length > 0 ? domains.map((domain) => createDisplayUrl(config, displayDefaults, domain)) : [createDisplayUrl(config, displayDefaults)];
|
|
517
|
+
return [...new Set(urls)];
|
|
518
|
+
}
|
|
519
|
+
function maybeConstructPreviewUrl(config, defaults) {
|
|
520
|
+
if (!config.preview) return void 0;
|
|
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
|
+
});
|
|
533
|
+
}
|
|
534
|
+
function parseNamespaceSlug(slug, config) {
|
|
535
|
+
const parts = slug.split(config.separator);
|
|
536
|
+
if (parts.length < config.tags.length) return null;
|
|
537
|
+
if (parts.length !== config.tags.length && !config.spreadTag) return null;
|
|
538
|
+
const namespace = {};
|
|
539
|
+
const spreadIndex = config.spreadTag ? config.tags.indexOf(config.spreadTag) : -1;
|
|
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;
|
|
548
|
+
}
|
|
549
|
+
return namespace;
|
|
550
|
+
}
|
|
551
|
+
function resolveGhostTunnelConfig(options, defaults) {
|
|
552
|
+
if (options === false || typeof options === "undefined") {
|
|
553
|
+
return {
|
|
554
|
+
enabled: false,
|
|
555
|
+
mode: DEFAULT_GHOST_TUNNEL_MODE,
|
|
556
|
+
domains: [],
|
|
557
|
+
subdomain: DEFAULT_GHOST_TUNNEL_SUBDOMAIN,
|
|
558
|
+
namespace: resolveNamespaceConfig(void 0),
|
|
559
|
+
displayUrls: [],
|
|
560
|
+
requireHttps: true,
|
|
561
|
+
requireAuth: true
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
const config = typeof options === "string" ? { mode: options } : options;
|
|
565
|
+
const subdomain = config.subdomain ?? DEFAULT_GHOST_TUNNEL_SUBDOMAIN;
|
|
566
|
+
assertValidSubdomain(subdomain);
|
|
567
|
+
const domains = normalizeDomains(config.domains);
|
|
568
|
+
const enabled = config.enabled ?? true;
|
|
569
|
+
const resolved = {
|
|
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;
|
|
582
|
+
}
|
|
583
|
+
const previewUrl = maybeConstructPreviewUrl(resolved, defaults);
|
|
584
|
+
const displayUrls = previewUrl ? [previewUrl] : createDisplayUrls(resolved, defaults);
|
|
585
|
+
return {
|
|
586
|
+
...resolved,
|
|
587
|
+
displayUrls,
|
|
588
|
+
...displayUrls[0] ? { displayUrl: displayUrls[0] } : {},
|
|
589
|
+
...previewUrl ? { previewUrl } : {}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function getGhostTunnelEntryHost(domain, options = {}) {
|
|
593
|
+
const config = toGhostTunnelConfig(options);
|
|
594
|
+
const normalizedDomain = normalizeDomain(domain);
|
|
595
|
+
if (!normalizedDomain) {
|
|
596
|
+
throw new Error(`Invalid ghost tunnel domain: ${domain}`);
|
|
597
|
+
}
|
|
598
|
+
return `${config.subdomain}.${normalizedDomain}`;
|
|
599
|
+
}
|
|
600
|
+
function getGhostTunnelWildcardHost(domain, options = {}) {
|
|
601
|
+
return `*.${getGhostTunnelEntryHost(domain, options)}`;
|
|
602
|
+
}
|
|
603
|
+
function constructGhostTunnelHost(input) {
|
|
604
|
+
const config = toGhostTunnelConfig(input.ghostTunnel ?? {});
|
|
605
|
+
if (!config.enabled) {
|
|
606
|
+
throw new Error("Ghost tunnel is not enabled.");
|
|
607
|
+
}
|
|
608
|
+
const namespaceValues = {
|
|
609
|
+
route: input.route,
|
|
610
|
+
project: input.project,
|
|
611
|
+
owner: input.owner,
|
|
612
|
+
...input.values ?? {}
|
|
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(/^\/+/, "")}`;
|
|
623
|
+
}
|
|
624
|
+
if (input.searchParams instanceof URLSearchParams) {
|
|
625
|
+
url.search = input.searchParams.toString();
|
|
626
|
+
} else if (input.searchParams) {
|
|
627
|
+
for (const [key, value] of Object.entries(input.searchParams)) {
|
|
628
|
+
if (typeof value !== "undefined" && value !== null) {
|
|
629
|
+
url.searchParams.set(key, String(value));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return url.toString();
|
|
634
|
+
}
|
|
635
|
+
var constructGhostTunnelURL = constructGhostTunnelUrl;
|
|
636
|
+
function getGhostTunnelDefaultDisplayUrl(options = {}, defaults) {
|
|
637
|
+
const config = toGhostTunnelConfig(options);
|
|
638
|
+
if (!config.enabled) return null;
|
|
639
|
+
return createDisplayUrl(config, defaults);
|
|
640
|
+
}
|
|
641
|
+
function getGhostTunnelDisplayUrl(options, defaults) {
|
|
642
|
+
const config = toGhostTunnelConfig(options);
|
|
643
|
+
if (!config.enabled) return null;
|
|
644
|
+
return config.displayUrl ?? config.previewUrl ?? getGhostTunnelDefaultDisplayUrl(config, defaults);
|
|
645
|
+
}
|
|
646
|
+
function getGhostTunnelDisplayUrls(options, defaults) {
|
|
647
|
+
const config = toGhostTunnelConfig(options);
|
|
648
|
+
if (!config.enabled) return [];
|
|
649
|
+
if (config.displayUrls.length > 0) return config.displayUrls;
|
|
650
|
+
const displayUrl = getGhostTunnelDisplayUrl(config, defaults);
|
|
651
|
+
return displayUrl ? [displayUrl] : [];
|
|
652
|
+
}
|
|
653
|
+
function getGhostTunnelPreviewUrl(options) {
|
|
654
|
+
const config = toGhostTunnelConfig(options);
|
|
655
|
+
if (!config.enabled) return null;
|
|
656
|
+
return config.previewUrl ?? maybeConstructPreviewUrl(config) ?? null;
|
|
657
|
+
}
|
|
658
|
+
function parseGhostTunnelHost(host, domain, options = {}) {
|
|
659
|
+
const config = toGhostTunnelConfig(options);
|
|
660
|
+
if (!config.enabled) return null;
|
|
661
|
+
const normalizedHost = normalizeDomain(host);
|
|
662
|
+
const normalizedDomain = normalizeDomain(domain);
|
|
663
|
+
if (!normalizedHost || !normalizedDomain) return null;
|
|
664
|
+
const entryHost = getGhostTunnelEntryHost(normalizedDomain, config);
|
|
665
|
+
const suffix = `.${entryHost}`;
|
|
666
|
+
if (!normalizedHost.endsWith(suffix)) return null;
|
|
667
|
+
const slug = normalizedHost.slice(0, -suffix.length);
|
|
668
|
+
if (!isValidHostLabel(slug)) return null;
|
|
669
|
+
const namespace = parseNamespaceSlug(slug, config.namespace);
|
|
670
|
+
if (!namespace) return null;
|
|
671
|
+
return {
|
|
672
|
+
host: normalizedHost,
|
|
673
|
+
slug,
|
|
674
|
+
namespace,
|
|
675
|
+
entryHost,
|
|
676
|
+
wildcardHost: `*.${entryHost}`,
|
|
677
|
+
domain: normalizedDomain
|
|
678
|
+
};
|
|
679
|
+
}
|
|
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
|
+
|
|
323
698
|
// src/context.ts
|
|
324
699
|
var LOCALGHOST_PROJECT_CONFIG_FILES = [
|
|
325
700
|
"localghost.config.mjs",
|
|
@@ -344,6 +719,25 @@ function envHttps() {
|
|
|
344
719
|
if (!value) return void 0;
|
|
345
720
|
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
|
346
721
|
}
|
|
722
|
+
function getPackageName(cwd) {
|
|
723
|
+
try {
|
|
724
|
+
const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf8"));
|
|
725
|
+
return typeof pkg.name === "string" ? pkg.name : void 0;
|
|
726
|
+
} catch {
|
|
727
|
+
return void 0;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function getPackageOwner(cwd) {
|
|
731
|
+
const packageName = getPackageName(cwd);
|
|
732
|
+
if (!packageName?.startsWith("@")) return void 0;
|
|
733
|
+
return packageName.slice(1).split("/")[0];
|
|
734
|
+
}
|
|
735
|
+
function getLocalOwner(cwd) {
|
|
736
|
+
return sanitizeProjectName(process.env.LOCALGHOST_OWNER ?? getPackageOwner(cwd) ?? process.env.USER ?? process.env.USERNAME ?? "local");
|
|
737
|
+
}
|
|
738
|
+
function getRouteName(primaryHost, fallback) {
|
|
739
|
+
return sanitizeProjectName(primaryHost.split(".")[0] ?? fallback);
|
|
740
|
+
}
|
|
347
741
|
function readOptionsFromContext(options) {
|
|
348
742
|
return {
|
|
349
743
|
cwd: options.cwd ?? process.cwd(),
|
|
@@ -382,11 +776,12 @@ function addDefaultWwwAliases(entries) {
|
|
|
382
776
|
function defined(input) {
|
|
383
777
|
return Object.fromEntries(Object.entries(input).filter(([, value]) => typeof value !== "undefined"));
|
|
384
778
|
}
|
|
385
|
-
async function
|
|
386
|
-
|
|
387
|
-
|
|
779
|
+
async function readLocalghostProjectConfig(options = {}) {
|
|
780
|
+
const cwd = options.cwd ?? process.cwd();
|
|
781
|
+
if (options.configFile === false) return { config: {} };
|
|
782
|
+
const candidates = options.configFile ? [options.configFile] : LOCALGHOST_PROJECT_CONFIG_FILES;
|
|
388
783
|
const path = candidates.map((candidate) => resolveDevHostsPath({ cwd, fileName: candidate }).path).find((candidate) => existsSync3(candidate));
|
|
389
|
-
if (!path) return {};
|
|
784
|
+
if (!path) return { config: {} };
|
|
390
785
|
const imported = await import(`${pathToFileURL(path).href}?localghost=${Date.now()}`);
|
|
391
786
|
const config = imported.default ?? imported;
|
|
392
787
|
return { config, path };
|
|
@@ -396,7 +791,10 @@ function defineLocalghostConfig(config) {
|
|
|
396
791
|
}
|
|
397
792
|
async function resolveLocalghostContext(options = {}) {
|
|
398
793
|
const cwd = options.cwd ?? process.cwd();
|
|
399
|
-
const projectConfig = await
|
|
794
|
+
const projectConfig = await readLocalghostProjectConfig({
|
|
795
|
+
cwd,
|
|
796
|
+
...typeof options.localghostConfig !== "undefined" ? { configFile: options.localghostConfig } : {}
|
|
797
|
+
});
|
|
400
798
|
const merged = {
|
|
401
799
|
...projectConfig.config,
|
|
402
800
|
...defined(options)
|
|
@@ -405,7 +803,7 @@ async function resolveLocalghostContext(options = {}) {
|
|
|
405
803
|
const resolvedPath = resolveDevHostsPath(readOptions);
|
|
406
804
|
const configEntries = readDevHosts(readOptions);
|
|
407
805
|
const requestedPort = merged.port ?? envPort() ?? configEntries[0]?.port ?? 5173;
|
|
408
|
-
const dynamicPort = merged.dynamicPort ?? envDynamicPort() ??
|
|
806
|
+
const dynamicPort = merged.dynamicPort ?? envDynamicPort() ?? true;
|
|
409
807
|
const bindHost = merged.bindHost ?? "127.0.0.1";
|
|
410
808
|
const probeHost = typeof bindHost === "string" ? bindHost : "127.0.0.1";
|
|
411
809
|
const port = dynamicPort ? await findAvailablePort(requestedPort, { host: probeHost }) : requestedPort;
|
|
@@ -413,9 +811,15 @@ async function resolveLocalghostContext(options = {}) {
|
|
|
413
811
|
const entries = wwwAlias ? addDefaultWwwAliases(withRuntimePort(configEntries, requestedPort, port)) : withRuntimePort(configEntries, requestedPort, port);
|
|
414
812
|
const hosts = uniqueHosts(entries);
|
|
415
813
|
const primaryHost = merged.primaryHost ?? entries.find((entry) => entry.port === port)?.host ?? hosts[0] ?? `${sanitizeProjectName(getProjectName(cwd))}.localhost`;
|
|
814
|
+
const projectName = sanitizeProjectName(merged.project ?? getProjectName(cwd));
|
|
815
|
+
const ghostTunnel = resolveGhostTunnelConfig(merged.ghostTunnel, {
|
|
816
|
+
route: getRouteName(primaryHost, projectName),
|
|
817
|
+
project: projectName,
|
|
818
|
+
owner: getLocalOwner(cwd)
|
|
819
|
+
});
|
|
416
820
|
return {
|
|
417
821
|
cwd,
|
|
418
|
-
projectName
|
|
822
|
+
projectName,
|
|
419
823
|
readOptions,
|
|
420
824
|
configPath: resolvedPath.path,
|
|
421
825
|
configFileName: resolvedPath.fileName,
|
|
@@ -429,6 +833,7 @@ async function resolveLocalghostContext(options = {}) {
|
|
|
429
833
|
primaryHost,
|
|
430
834
|
https: merged.https ?? envHttps() ?? false,
|
|
431
835
|
wwwAlias,
|
|
836
|
+
ghostTunnel,
|
|
432
837
|
...projectConfig.path ? { projectConfigPath: projectConfig.path } : {}
|
|
433
838
|
};
|
|
434
839
|
}
|
|
@@ -486,7 +891,7 @@ function getProductionEnvKeys() {
|
|
|
486
891
|
// src/hosts-file.ts
|
|
487
892
|
import { writeFileSync as writeFileSync3 } from "fs";
|
|
488
893
|
import { tmpdir } from "os";
|
|
489
|
-
import { join as
|
|
894
|
+
import { join as join5 } from "path";
|
|
490
895
|
import { execa as execa3 } from "execa";
|
|
491
896
|
function escapeRegExp(value) {
|
|
492
897
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -528,7 +933,7 @@ function removeManagedBlock(existing, projectName) {
|
|
|
528
933
|
}
|
|
529
934
|
async function writeSystemHostsFile(hostsPath, next, projectName) {
|
|
530
935
|
const sanitizedProjectName = sanitizeProjectName(projectName);
|
|
531
|
-
const tempPath =
|
|
936
|
+
const tempPath = join5(tmpdir(), `localghost-${sanitizedProjectName}-hosts`);
|
|
532
937
|
writeFileSync3(tempPath, next, "utf8");
|
|
533
938
|
if (process.platform === "win32") {
|
|
534
939
|
throw new Error(`Windows support: run as administrator and copy ${tempPath} to ${hostsPath}.`);
|
|
@@ -561,11 +966,11 @@ async function removeSystemHosts(projectName) {
|
|
|
561
966
|
}
|
|
562
967
|
|
|
563
968
|
// src/init.ts
|
|
564
|
-
import { existsSync as existsSync4, readFileSync as
|
|
565
|
-
import { join as
|
|
969
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
970
|
+
import { join as join6 } from "path";
|
|
566
971
|
function detectPackageManager(cwd = process.cwd()) {
|
|
567
|
-
if (existsSync4(
|
|
568
|
-
if (existsSync4(
|
|
972
|
+
if (existsSync4(join6(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
973
|
+
if (existsSync4(join6(cwd, "yarn.lock"))) return "yarn";
|
|
569
974
|
return "npm";
|
|
570
975
|
}
|
|
571
976
|
function packageRunCommand(packageManager, script) {
|
|
@@ -590,7 +995,7 @@ function renderConfig(options) {
|
|
|
590
995
|
}
|
|
591
996
|
function readPackageJson(path) {
|
|
592
997
|
try {
|
|
593
|
-
return JSON.parse(
|
|
998
|
+
return JSON.parse(readFileSync5(path, "utf8"));
|
|
594
999
|
} catch {
|
|
595
1000
|
return null;
|
|
596
1001
|
}
|
|
@@ -642,7 +1047,7 @@ function initLocalghost(options = {}) {
|
|
|
642
1047
|
const apiPort = options.apiPort ?? 8787;
|
|
643
1048
|
const packageManager = options.packageManager ?? detectPackageManager(cwd);
|
|
644
1049
|
const configFile = options.configFile ?? LOCALGHOST_CONFIG_FILE;
|
|
645
|
-
const configPath =
|
|
1050
|
+
const configPath = join6(cwd, configFile);
|
|
646
1051
|
const configExists = existsSync4(configPath);
|
|
647
1052
|
if (configExists && !options.force) {
|
|
648
1053
|
return {
|
|
@@ -659,7 +1064,7 @@ function initLocalghost(options = {}) {
|
|
|
659
1064
|
};
|
|
660
1065
|
}
|
|
661
1066
|
writeTextFile(configPath, renderConfig({ host, port, apiHost, apiPort }));
|
|
662
|
-
const packageJsonPath =
|
|
1067
|
+
const packageJsonPath = join6(cwd, "package.json");
|
|
663
1068
|
const packageJsonChanged = options.writeScripts ? updatePackageScripts(packageJsonPath, configFile) : false;
|
|
664
1069
|
return {
|
|
665
1070
|
configPath,
|
|
@@ -676,7 +1081,277 @@ function initLocalghost(options = {}) {
|
|
|
676
1081
|
};
|
|
677
1082
|
}
|
|
678
1083
|
|
|
1084
|
+
// src/relay.ts
|
|
1085
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
1086
|
+
import { domainToASCII as domainToASCII2 } from "url";
|
|
1087
|
+
var DEFAULT_RELAY_ALLOWED_TARGET_HOSTS = ["localhost", "127.0.0.1", "::1"];
|
|
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");
|
|
1118
|
+
}
|
|
1119
|
+
function base64UrlDecode(value) {
|
|
1120
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
1121
|
+
}
|
|
1122
|
+
function signPayload(payload, secret) {
|
|
1123
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
1124
|
+
}
|
|
1125
|
+
function secureEqual(left, right) {
|
|
1126
|
+
const leftBuffer = Buffer.from(left);
|
|
1127
|
+
const rightBuffer = Buffer.from(right);
|
|
1128
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
1129
|
+
}
|
|
1130
|
+
function normalizeHost(host) {
|
|
1131
|
+
const trimmed = host.trim().toLowerCase().replace(/\.$/, "");
|
|
1132
|
+
if (!trimmed || trimmed.includes("*") || trimmed.includes("/") || trimmed.includes(":")) return null;
|
|
1133
|
+
const ascii = domainToASCII2(trimmed);
|
|
1134
|
+
if (!ascii || ascii.includes("..")) return null;
|
|
1135
|
+
return HOST_PATTERN2.test(ascii) ? ascii : null;
|
|
1136
|
+
}
|
|
1137
|
+
function normalizeTargetHost(host) {
|
|
1138
|
+
const trimmed = host.trim().toLowerCase();
|
|
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);
|
|
1143
|
+
}
|
|
1144
|
+
function isValidIpv4(value) {
|
|
1145
|
+
return value.split(".").every((part) => {
|
|
1146
|
+
const octet = Number(part);
|
|
1147
|
+
return Number.isInteger(octet) && octet >= 0 && octet <= 255 && String(octet) === part;
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
function isPrivateIpv4(value) {
|
|
1151
|
+
if (!isValidIpv4(value)) return false;
|
|
1152
|
+
const [first = 0, second = 0] = value.split(".").map((part) => Number(part));
|
|
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) {
|
|
1159
|
+
return {
|
|
1160
|
+
allowedHosts: policy?.allowedHosts ?? DEFAULT_RELAY_TARGET_POLICY.allowedHosts,
|
|
1161
|
+
blockedPorts: policy?.blockedPorts ?? DEFAULT_RELAY_TARGET_POLICY.blockedPorts,
|
|
1162
|
+
allowPrivateNetworkTargets: policy?.allowPrivateNetworkTargets ?? DEFAULT_RELAY_TARGET_POLICY.allowPrivateNetworkTargets
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
function mergeLimits(limits) {
|
|
1166
|
+
const merged = {
|
|
1167
|
+
...DEFAULT_RELAY_LIMITS,
|
|
1168
|
+
...limits ?? {}
|
|
1169
|
+
};
|
|
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
|
+
}
|
|
1177
|
+
function assertExactRelayHost(host) {
|
|
1178
|
+
const normalized = normalizeHost(host);
|
|
1179
|
+
if (!normalized) {
|
|
1180
|
+
throw new Error(`Relay route claims must use an exact hostname: ${host}`);
|
|
1181
|
+
}
|
|
1182
|
+
return normalized;
|
|
1183
|
+
}
|
|
1184
|
+
function assertRelayLocalTarget(target, policyInput) {
|
|
1185
|
+
if (!target || typeof target !== "object") {
|
|
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
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
protocol,
|
|
1212
|
+
host,
|
|
1213
|
+
port: target.port
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
function authenticateRelayAgentToken(input) {
|
|
1217
|
+
const expected = `Bearer ${input.agentToken}`;
|
|
1218
|
+
return typeof input.authorizationHeader === "string" && secureEqual(input.authorizationHeader, expected);
|
|
1219
|
+
}
|
|
1220
|
+
function signRelayRouteClaim(claim, signingSecret) {
|
|
1221
|
+
const payload = {
|
|
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);
|
|
1230
|
+
return {
|
|
1231
|
+
payload,
|
|
1232
|
+
token: `${encodedPayload}.${signature}`
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
function verifyRelayRouteClaim(token, signingSecret, options) {
|
|
1236
|
+
const [encodedPayload, signature] = token.split(".");
|
|
1237
|
+
if (!encodedPayload || !signature || token.split(".").length !== 2) {
|
|
1238
|
+
throw new Error("Invalid relay route claim token.");
|
|
1239
|
+
}
|
|
1240
|
+
const expectedSignature = signPayload(encodedPayload, signingSecret);
|
|
1241
|
+
if (!secureEqual(signature, expectedSignature)) {
|
|
1242
|
+
throw new Error("Invalid relay route claim signature.");
|
|
1243
|
+
}
|
|
1244
|
+
const parsed = JSON.parse(base64UrlDecode(encodedPayload));
|
|
1245
|
+
const host = assertExactRelayHost(parsed.host);
|
|
1246
|
+
if (parsed.scope !== options.expectedScope) {
|
|
1247
|
+
throw new Error("Relay route claim scope mismatch.");
|
|
1248
|
+
}
|
|
1249
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1250
|
+
if (Date.parse(parsed.expiresAt) <= now.getTime()) {
|
|
1251
|
+
throw new Error("Relay route claim has expired.");
|
|
1252
|
+
}
|
|
1253
|
+
if (!parsed.agentId) {
|
|
1254
|
+
throw new Error("Relay route claim requires an agentId.");
|
|
1255
|
+
}
|
|
1256
|
+
return { ...parsed, host };
|
|
1257
|
+
}
|
|
1258
|
+
function createRelayRouteRegistration(input) {
|
|
1259
|
+
if (!authenticateRelayAgentToken({
|
|
1260
|
+
agentToken: input.agentToken,
|
|
1261
|
+
...typeof input.authorizationHeader !== "undefined" ? { authorizationHeader: input.authorizationHeader } : {}
|
|
1262
|
+
})) {
|
|
1263
|
+
throw new Error("Relay route registration requires an authenticated local agent.");
|
|
1264
|
+
}
|
|
1265
|
+
const claim = verifyRelayRouteClaim(input.claimToken, input.signingSecret, {
|
|
1266
|
+
expectedScope: input.expectedScope,
|
|
1267
|
+
...input.now ? { now: input.now } : {}
|
|
1268
|
+
});
|
|
1269
|
+
const target = assertRelayLocalTarget(input.target, input.targetPolicy);
|
|
1270
|
+
const access = input.publicMode === true ? "public" : input.access ?? "private";
|
|
1271
|
+
const passwordProtected = input.passwordProtected ?? false;
|
|
1272
|
+
const authRequired = input.authRequired ?? false;
|
|
1273
|
+
if (access === "public" && input.publicMode !== true) {
|
|
1274
|
+
throw new Error("Relay public mode must be explicitly enabled.");
|
|
1275
|
+
}
|
|
1276
|
+
if (access === "private" && !passwordProtected && !authRequired) {
|
|
1277
|
+
throw new Error("Private relay previews require password or auth.");
|
|
1278
|
+
}
|
|
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
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
function isRelayRouteActive(route, options) {
|
|
1292
|
+
if (!options.agentConnected) return false;
|
|
1293
|
+
return Date.parse(route.expiresAt) > (options.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
1294
|
+
}
|
|
1295
|
+
function stripRelayForwardHeaders(headers) {
|
|
1296
|
+
const stripped = {};
|
|
1297
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1298
|
+
if (typeof value === "undefined") continue;
|
|
1299
|
+
const lowerName = name.toLowerCase();
|
|
1300
|
+
if (HOP_BY_HOP_HEADERS.has(lowerName)) continue;
|
|
1301
|
+
if (lowerName.startsWith("x-localghost-")) continue;
|
|
1302
|
+
stripped[name] = value;
|
|
1303
|
+
}
|
|
1304
|
+
return stripped;
|
|
1305
|
+
}
|
|
1306
|
+
function redactRelayHeaders(headers) {
|
|
1307
|
+
const redacted = {};
|
|
1308
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1309
|
+
if (typeof value === "undefined") continue;
|
|
1310
|
+
redacted[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? "[redacted]" : value;
|
|
1311
|
+
}
|
|
1312
|
+
return redacted;
|
|
1313
|
+
}
|
|
1314
|
+
function redactRelayLogUrl(input) {
|
|
1315
|
+
const url = new URL(input, "http://localghost.invalid");
|
|
1316
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
1317
|
+
if (TOKEN_QUERY_PATTERN.test(key)) {
|
|
1318
|
+
url.searchParams.set(key, "[redacted]");
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return input.startsWith("http://") || input.startsWith("https://") ? url.toString() : `${url.pathname}${url.search}`;
|
|
1322
|
+
}
|
|
1323
|
+
function renderRelayOfflineResponse() {
|
|
1324
|
+
return {
|
|
1325
|
+
status: 503,
|
|
1326
|
+
headers: {
|
|
1327
|
+
"content-type": "text/html; charset=utf-8",
|
|
1328
|
+
"cache-control": "no-store"
|
|
1329
|
+
},
|
|
1330
|
+
body: [
|
|
1331
|
+
"<!doctype html>",
|
|
1332
|
+
"<html>",
|
|
1333
|
+
'<head><meta charset="utf-8"><title>Preview offline</title></head>',
|
|
1334
|
+
"<body><h1>Preview offline</h1><p>The local agent is not connected. Try again later.</p></body>",
|
|
1335
|
+
"</html>"
|
|
1336
|
+
].join("")
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
|
|
679
1340
|
// src/routes.ts
|
|
1341
|
+
var ansi = {
|
|
1342
|
+
cyan: "\x1B[36m",
|
|
1343
|
+
dim: "\x1B[2m",
|
|
1344
|
+
green: "\x1B[32m",
|
|
1345
|
+
reset: "\x1B[0m",
|
|
1346
|
+
yellow: "\x1B[33m"
|
|
1347
|
+
};
|
|
1348
|
+
function colorize(value, color, enabled) {
|
|
1349
|
+
return enabled ? `${color}${value}${ansi.reset}` : value;
|
|
1350
|
+
}
|
|
1351
|
+
function colorizeUrl(value, enabled) {
|
|
1352
|
+
if (!enabled) return value;
|
|
1353
|
+
return colorize(value.replace(/\*/g, `${ansi.yellow}*${ansi.cyan}`), ansi.cyan, enabled);
|
|
1354
|
+
}
|
|
680
1355
|
function getDomainRoutes(entries, options = {}) {
|
|
681
1356
|
const protocol = options.https === true ? "https" : "http";
|
|
682
1357
|
return [...entries].sort((left, right) => left.host.localeCompare(right.host) || left.port - right.port).map((entry) => ({
|
|
@@ -696,13 +1371,40 @@ function formatDomainRoutes(entries, options = {}) {
|
|
|
696
1371
|
...routes.map((route) => ` ${route.url} -> ${route.upstream}`)
|
|
697
1372
|
].join("\n");
|
|
698
1373
|
}
|
|
1374
|
+
function formatGhostTunnel(config, options = {}) {
|
|
1375
|
+
if (!config.enabled) return null;
|
|
1376
|
+
const color = options.color === true;
|
|
1377
|
+
const label = options.label ?? "expected";
|
|
1378
|
+
const labelColor = label === "running" ? ansi.green : ansi.dim;
|
|
1379
|
+
const lines = [
|
|
1380
|
+
"localghost ghost tunnel",
|
|
1381
|
+
` mode: ${config.mode}`
|
|
1382
|
+
];
|
|
1383
|
+
const urls = config.displayUrls.length > 0 ? config.displayUrls : config.displayUrl ? [config.displayUrl] : [];
|
|
1384
|
+
if (urls.length === 0) {
|
|
1385
|
+
lines.push(` ${label}: unavailable`);
|
|
1386
|
+
} else if (urls.length === 1) {
|
|
1387
|
+
lines.push(` ${colorize(label, labelColor, color)}: ${colorizeUrl(urls[0], color)}`);
|
|
1388
|
+
} else {
|
|
1389
|
+
lines.push(` ${colorize(label, labelColor, color)}:`);
|
|
1390
|
+
for (const url of urls) {
|
|
1391
|
+
lines.push(` ${colorizeUrl(url, color)}`);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
if (options.verbose) {
|
|
1395
|
+
lines.push(` domains: ${config.domains.length > 0 ? config.domains.join(", ") : "*"}`);
|
|
1396
|
+
lines.push(` access: ${config.requireAuth ? "auth required" : "app decides"}`);
|
|
1397
|
+
lines.push(` transport: ${config.requireHttps ? "https required" : "http allowed"}`);
|
|
1398
|
+
}
|
|
1399
|
+
return lines.join("\n");
|
|
1400
|
+
}
|
|
699
1401
|
|
|
700
1402
|
// src/state.ts
|
|
701
1403
|
import { existsSync as existsSync5 } from "fs";
|
|
702
|
-
import { join as
|
|
1404
|
+
import { join as join7 } from "path";
|
|
703
1405
|
var LOCALGHOST_STATE_FILE = "ops/local/localghost-state.json";
|
|
704
1406
|
function getLocalghostStatePath(cwd = process.cwd()) {
|
|
705
|
-
return
|
|
1407
|
+
return join7(cwd, LOCALGHOST_STATE_FILE);
|
|
706
1408
|
}
|
|
707
1409
|
function readLocalghostState(cwd = process.cwd()) {
|
|
708
1410
|
const path = getLocalghostStatePath(cwd);
|
|
@@ -722,11 +1424,11 @@ function patchLocalghostState(cwd, patch) {
|
|
|
722
1424
|
}
|
|
723
1425
|
|
|
724
1426
|
// src/update-check.ts
|
|
725
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as
|
|
1427
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
726
1428
|
import { homedir as homedir2 } from "os";
|
|
727
|
-
import { dirname as dirname4, join as
|
|
1429
|
+
import { dirname as dirname4, join as join8 } from "path";
|
|
728
1430
|
var LOCALGHOST_PACKAGE_NAME = "@hamedb89/localghost";
|
|
729
|
-
var LOCALGHOST_VERSION = "0.1.
|
|
1431
|
+
var LOCALGHOST_VERSION = "0.1.10";
|
|
730
1432
|
var UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
731
1433
|
var UPDATE_CHECK_NOTIFY_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
732
1434
|
var UPDATE_CHECK_TIMEOUT_MS = 900;
|
|
@@ -738,13 +1440,13 @@ function isUpdateCheckDisabled(env = process.env) {
|
|
|
738
1440
|
}
|
|
739
1441
|
function getUpdateCheckCachePath(env = process.env) {
|
|
740
1442
|
if (env.LOCALGHOST_UPDATE_CHECK_CACHE) return env.LOCALGHOST_UPDATE_CHECK_CACHE;
|
|
741
|
-
const cacheRoot = env.XDG_CACHE_HOME ||
|
|
742
|
-
return
|
|
1443
|
+
const cacheRoot = env.XDG_CACHE_HOME || join8(homedir2(), ".cache");
|
|
1444
|
+
return join8(cacheRoot, "localghost", "update-check.json");
|
|
743
1445
|
}
|
|
744
1446
|
function readCache(path = getUpdateCheckCachePath()) {
|
|
745
1447
|
if (!existsSync6(path)) return null;
|
|
746
1448
|
try {
|
|
747
|
-
return JSON.parse(
|
|
1449
|
+
return JSON.parse(readFileSync6(path, "utf8"));
|
|
748
1450
|
} catch {
|
|
749
1451
|
return null;
|
|
750
1452
|
}
|
|
@@ -888,6 +1590,10 @@ ${message}`);
|
|
|
888
1590
|
markUpdateNotified(result, cachePath);
|
|
889
1591
|
}
|
|
890
1592
|
export {
|
|
1593
|
+
DEFAULT_RELAY_ALLOWED_TARGET_HOSTS,
|
|
1594
|
+
DEFAULT_RELAY_BLOCKED_PORTS,
|
|
1595
|
+
DEFAULT_RELAY_LIMITS,
|
|
1596
|
+
DEFAULT_RELAY_TARGET_POLICY,
|
|
891
1597
|
LOCALGHOST_ACTIVITY_VERSION,
|
|
892
1598
|
LOCALGHOST_CONFIG_FILE,
|
|
893
1599
|
LOCALGHOST_PACKAGE_NAME,
|
|
@@ -896,20 +1602,35 @@ export {
|
|
|
896
1602
|
UPDATE_CHECK_CACHE_TTL_MS,
|
|
897
1603
|
UPDATE_CHECK_NOTIFY_TTL_MS,
|
|
898
1604
|
UPDATE_CHECK_TIMEOUT_MS,
|
|
1605
|
+
assertExactRelayHost,
|
|
899
1606
|
assertLocalDevelopment,
|
|
1607
|
+
assertRelayLocalTarget,
|
|
1608
|
+
assertSecureGhostTunnelRequest,
|
|
1609
|
+
authenticateRelayAgentToken,
|
|
900
1610
|
checkCaddy,
|
|
901
1611
|
checkForUpdate,
|
|
902
1612
|
compareVersions,
|
|
1613
|
+
constructGhostTunnelHost,
|
|
1614
|
+
constructGhostTunnelURL,
|
|
1615
|
+
constructGhostTunnelUrl,
|
|
1616
|
+
createRelayRouteRegistration,
|
|
903
1617
|
defineLocalghostConfig,
|
|
904
1618
|
detectPackageManager,
|
|
905
1619
|
findAvailablePort,
|
|
906
1620
|
findLocalMdnsHosts,
|
|
907
1621
|
formatDomainRoutes,
|
|
1622
|
+
formatGhostTunnel,
|
|
908
1623
|
formatUpdateMessage,
|
|
909
1624
|
getCaddyfilePath,
|
|
910
1625
|
getConfigFileCandidates,
|
|
911
1626
|
getDevHostsPath,
|
|
912
1627
|
getDomainRoutes,
|
|
1628
|
+
getGhostTunnelDefaultDisplayUrl,
|
|
1629
|
+
getGhostTunnelDisplayUrl,
|
|
1630
|
+
getGhostTunnelDisplayUrls,
|
|
1631
|
+
getGhostTunnelEntryHost,
|
|
1632
|
+
getGhostTunnelPreviewUrl,
|
|
1633
|
+
getGhostTunnelWildcardHost,
|
|
913
1634
|
getLocalghostActivityPath,
|
|
914
1635
|
getLocalghostStatePath,
|
|
915
1636
|
getProductionEnvKeys,
|
|
@@ -922,35 +1643,48 @@ export {
|
|
|
922
1643
|
isPortAvailable,
|
|
923
1644
|
isProcessRunning,
|
|
924
1645
|
isProductionLike,
|
|
1646
|
+
isRelayRouteActive,
|
|
925
1647
|
isUpdateCheckDisabled,
|
|
926
1648
|
listLocalghostRuns,
|
|
1649
|
+
listLocalghostSetups,
|
|
927
1650
|
markUpdateNotified,
|
|
928
1651
|
maybeNotifyAboutUpdate,
|
|
929
1652
|
packageAddCommand,
|
|
930
1653
|
packageRunCommand,
|
|
931
1654
|
parseDevHosts,
|
|
1655
|
+
parseGhostTunnelHost,
|
|
932
1656
|
patchLocalghostState,
|
|
933
1657
|
pruneLocalghostActivity,
|
|
934
1658
|
readDevHosts,
|
|
935
1659
|
readLocalghostActivity,
|
|
1660
|
+
readLocalghostProjectConfig,
|
|
936
1661
|
readLocalghostState,
|
|
1662
|
+
redactRelayHeaders,
|
|
1663
|
+
redactRelayLogUrl,
|
|
937
1664
|
registerLocalghostRun,
|
|
1665
|
+
registerLocalghostSetup,
|
|
938
1666
|
removeManagedBlock,
|
|
939
1667
|
removeSystemHosts,
|
|
940
1668
|
renderCaddyfile,
|
|
941
1669
|
renderHostsBlock,
|
|
1670
|
+
renderRelayOfflineResponse,
|
|
942
1671
|
resolveDevHostsPath,
|
|
1672
|
+
resolveGhostTunnelConfig,
|
|
943
1673
|
resolveLocalghostContext,
|
|
944
1674
|
runCaddy,
|
|
945
1675
|
runDoctor,
|
|
946
1676
|
sanitizeProjectName,
|
|
947
1677
|
shouldNotifyAboutUpdate,
|
|
1678
|
+
signRelayRouteClaim,
|
|
948
1679
|
startCaddy,
|
|
1680
|
+
stripRelayForwardHeaders,
|
|
949
1681
|
trustCaddy,
|
|
950
1682
|
unregisterLocalghostRun,
|
|
1683
|
+
unregisterLocalghostSetup,
|
|
951
1684
|
updateSystemHosts,
|
|
952
1685
|
upsertManagedBlock,
|
|
953
1686
|
validateCaddyfile,
|
|
1687
|
+
verifyRelayRouteClaim,
|
|
954
1688
|
writeCaddyfile,
|
|
955
1689
|
writeLocalghostActivity,
|
|
956
1690
|
writeLocalghostState
|