portless 0.13.0 → 0.14.0
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 +31 -2
- package/dist/{chunk-3WLVQXFE.js → chunk-OZM4AEYL.js} +22 -4
- package/dist/cli.js +786 -85
- package/dist/index.d.ts +10 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
parseHostname,
|
|
14
14
|
shouldAutoSyncHosts,
|
|
15
15
|
syncHostsFile
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-OZM4AEYL.js";
|
|
17
17
|
|
|
18
18
|
// src/colors.ts
|
|
19
19
|
function supportsColor() {
|
|
@@ -41,7 +41,7 @@ var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
|
|
|
41
41
|
// src/cli.ts
|
|
42
42
|
import * as fs9 from "fs";
|
|
43
43
|
import * as path9 from "path";
|
|
44
|
-
import { spawn as
|
|
44
|
+
import { spawn as spawn4, spawnSync as spawnSync5 } from "child_process";
|
|
45
45
|
import { StringDecoder } from "string_decoder";
|
|
46
46
|
|
|
47
47
|
// src/certs.ts
|
|
@@ -999,6 +999,178 @@ function formatTailscaleUrl(baseUrl, httpsPort) {
|
|
|
999
999
|
return `${trimmed}:${httpsPort}`;
|
|
1000
1000
|
}
|
|
1001
1001
|
|
|
1002
|
+
// src/ngrok.ts
|
|
1003
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1004
|
+
var NGROK_BINARY = "ngrok";
|
|
1005
|
+
var NGROK_START_TIMEOUT_MS = 3e4;
|
|
1006
|
+
var NGROK_COMMAND_TIMEOUT_MS = 1e4;
|
|
1007
|
+
var OUTPUT_BUFFER_LIMIT = 16384;
|
|
1008
|
+
function defaultSpawner(args) {
|
|
1009
|
+
return spawn(NGROK_BINARY, args, {
|
|
1010
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1011
|
+
windowsHide: true
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
function defaultRunner2(args) {
|
|
1015
|
+
const result = spawnSync2(NGROK_BINARY, args, {
|
|
1016
|
+
encoding: "utf-8",
|
|
1017
|
+
killSignal: "SIGKILL",
|
|
1018
|
+
timeout: NGROK_COMMAND_TIMEOUT_MS
|
|
1019
|
+
});
|
|
1020
|
+
return {
|
|
1021
|
+
status: result.status,
|
|
1022
|
+
stdout: result.stdout ?? "",
|
|
1023
|
+
stderr: result.stderr ?? "",
|
|
1024
|
+
...result.error ? { error: result.error } : {}
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
function normalizeSpace2(value) {
|
|
1028
|
+
return value.trim().replace(/\s+/g, " ");
|
|
1029
|
+
}
|
|
1030
|
+
function formatSpawnError(error) {
|
|
1031
|
+
const errno = error;
|
|
1032
|
+
if (errno.code === "ENOENT") {
|
|
1033
|
+
return new Error(
|
|
1034
|
+
"ngrok CLI not found. Install ngrok (https://ngrok.com/download) and ensure `ngrok` is on PATH."
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
return new Error(`Failed to start ngrok: ${error.message}`);
|
|
1038
|
+
}
|
|
1039
|
+
function formatOutputError(output) {
|
|
1040
|
+
const details = normalizeSpace2(output);
|
|
1041
|
+
const lower = details.toLowerCase();
|
|
1042
|
+
if (lower.includes("authtoken") || lower.includes("authentication") || lower.includes("not logged in")) {
|
|
1043
|
+
return new Error(
|
|
1044
|
+
"ngrok could not start because authentication is not configured. Run `ngrok config add-authtoken <token>`, then run portless again."
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
return new Error(
|
|
1048
|
+
`Failed to start ngrok tunnel: ${details || "ngrok exited before printing a public URL"}`
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
function ensureNgrokAvailable(runner = defaultRunner2) {
|
|
1052
|
+
const result = runner(["version"]);
|
|
1053
|
+
if (result.error) {
|
|
1054
|
+
throw formatSpawnError(result.error);
|
|
1055
|
+
}
|
|
1056
|
+
if (result.status !== 0) {
|
|
1057
|
+
const details = normalizeSpace2(result.stderr || result.stdout);
|
|
1058
|
+
throw new Error(`Failed to check ngrok version: ${details || "unknown ngrok error"}`);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function cleanUrl(value) {
|
|
1062
|
+
return value.replace(/[),.]+$/g, "");
|
|
1063
|
+
}
|
|
1064
|
+
function extractNgrokUrl(output) {
|
|
1065
|
+
const urlMatches = output.matchAll(/https:\/\/[^\s"'<>]+/g);
|
|
1066
|
+
for (const match of urlMatches) {
|
|
1067
|
+
const raw = match[0];
|
|
1068
|
+
const matchIndex = match.index ?? 0;
|
|
1069
|
+
const before = output.slice(Math.max(0, matchIndex - 80), matchIndex).toLowerCase();
|
|
1070
|
+
const looksLikeTunnel = before.includes("forwarding") || before.includes("url=") || before.includes('"url"') || before.includes("started tunnel");
|
|
1071
|
+
if (!looksLikeTunnel) continue;
|
|
1072
|
+
const candidate = cleanUrl(raw);
|
|
1073
|
+
try {
|
|
1074
|
+
const parsed = new URL(candidate);
|
|
1075
|
+
if (parsed.hostname === "ngrok.com" || parsed.hostname.endsWith(".ngrok.com")) {
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
return parsed.toString().replace(/\/$/, "");
|
|
1079
|
+
} catch {
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
function buildNgrokArgs(localPort, hostHeader = "rewrite") {
|
|
1086
|
+
return [
|
|
1087
|
+
"http",
|
|
1088
|
+
"--log=stdout",
|
|
1089
|
+
"--log-format=logfmt",
|
|
1090
|
+
`--host-header=${hostHeader}`,
|
|
1091
|
+
`http://127.0.0.1:${localPort}`
|
|
1092
|
+
];
|
|
1093
|
+
}
|
|
1094
|
+
function startNgrok(localPort, options = {}) {
|
|
1095
|
+
const spawner = options.spawner ?? defaultSpawner;
|
|
1096
|
+
const timeoutMs = options.timeoutMs ?? NGROK_START_TIMEOUT_MS;
|
|
1097
|
+
const args = buildNgrokArgs(localPort, options.hostHeader);
|
|
1098
|
+
let child;
|
|
1099
|
+
try {
|
|
1100
|
+
child = spawner(args);
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
return Promise.reject(formatSpawnError(err instanceof Error ? err : new Error(String(err))));
|
|
1103
|
+
}
|
|
1104
|
+
return new Promise((resolve4, reject) => {
|
|
1105
|
+
let settled = false;
|
|
1106
|
+
let started = false;
|
|
1107
|
+
let output = "";
|
|
1108
|
+
const settle = (fn) => {
|
|
1109
|
+
if (settled) return;
|
|
1110
|
+
settled = true;
|
|
1111
|
+
clearTimeout(timer);
|
|
1112
|
+
fn();
|
|
1113
|
+
};
|
|
1114
|
+
const appendOutput = (chunk) => {
|
|
1115
|
+
if (settled) return;
|
|
1116
|
+
output += chunk.toString();
|
|
1117
|
+
if (output.length > OUTPUT_BUFFER_LIMIT) {
|
|
1118
|
+
output = output.slice(-OUTPUT_BUFFER_LIMIT);
|
|
1119
|
+
}
|
|
1120
|
+
const url = extractNgrokUrl(output);
|
|
1121
|
+
if (url) {
|
|
1122
|
+
settle(() => {
|
|
1123
|
+
started = true;
|
|
1124
|
+
resolve4({ url, pid: child.pid, child });
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
const timer = setTimeout(() => {
|
|
1129
|
+
try {
|
|
1130
|
+
child.kill("SIGTERM");
|
|
1131
|
+
} catch {
|
|
1132
|
+
}
|
|
1133
|
+
settle(
|
|
1134
|
+
() => reject(
|
|
1135
|
+
new Error(
|
|
1136
|
+
"Timed out waiting for ngrok to print a public URL. Check that ngrok is authenticated and can connect."
|
|
1137
|
+
)
|
|
1138
|
+
)
|
|
1139
|
+
);
|
|
1140
|
+
}, timeoutMs);
|
|
1141
|
+
child.stdout?.on("data", appendOutput);
|
|
1142
|
+
child.stderr?.on("data", appendOutput);
|
|
1143
|
+
child.on("error", (err) => {
|
|
1144
|
+
settle(() => reject(formatSpawnError(err)));
|
|
1145
|
+
});
|
|
1146
|
+
child.on("exit", (code, signal) => {
|
|
1147
|
+
if (settled) {
|
|
1148
|
+
if (started) options.onExit?.(code, signal);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
settle(() => {
|
|
1152
|
+
const suffix = signal ? ` (signal ${signal})` : code !== null ? ` (exit ${code})` : "";
|
|
1153
|
+
const error = formatOutputError(output);
|
|
1154
|
+
reject(new Error(`${error.message}${suffix}`));
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
function stopNgrokProcess(child) {
|
|
1160
|
+
if (!child) return;
|
|
1161
|
+
try {
|
|
1162
|
+
child.kill("SIGTERM");
|
|
1163
|
+
} catch {
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
function stopNgrok(route) {
|
|
1167
|
+
if (!route.ngrokPid) return;
|
|
1168
|
+
try {
|
|
1169
|
+
process.kill(route.ngrokPid, "SIGTERM");
|
|
1170
|
+
} catch {
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1002
1174
|
// src/auto.ts
|
|
1003
1175
|
import { createHash as createHash2 } from "crypto";
|
|
1004
1176
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
@@ -1181,7 +1353,7 @@ import * as net from "net";
|
|
|
1181
1353
|
import * as os from "os";
|
|
1182
1354
|
import * as path3 from "path";
|
|
1183
1355
|
import * as readline from "readline";
|
|
1184
|
-
import { execSync, spawn } from "child_process";
|
|
1356
|
+
import { execSync, spawn as spawn2 } from "child_process";
|
|
1185
1357
|
var isWindows = process.platform === "win32";
|
|
1186
1358
|
var FALLBACK_PROXY_PORT = 1355;
|
|
1187
1359
|
var PRIVILEGED_PORT_THRESHOLD = 1024;
|
|
@@ -1556,12 +1728,12 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
|
|
|
1556
1728
|
throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
|
|
1557
1729
|
}
|
|
1558
1730
|
const tryPort = (port) => {
|
|
1559
|
-
return new Promise((
|
|
1731
|
+
return new Promise((resolve4) => {
|
|
1560
1732
|
const server = net.createServer();
|
|
1561
1733
|
server.listen(port, () => {
|
|
1562
|
-
server.close(() =>
|
|
1734
|
+
server.close(() => resolve4(true));
|
|
1563
1735
|
});
|
|
1564
|
-
server.on("error", () =>
|
|
1736
|
+
server.on("error", () => resolve4(false));
|
|
1565
1737
|
});
|
|
1566
1738
|
};
|
|
1567
1739
|
for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
|
|
@@ -1578,7 +1750,7 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
|
|
|
1578
1750
|
throw new Error(`No free port found in range ${minPort}-${maxPort}`);
|
|
1579
1751
|
}
|
|
1580
1752
|
function isProxyRunning(port, tls2 = false) {
|
|
1581
|
-
return new Promise((
|
|
1753
|
+
return new Promise((resolve4) => {
|
|
1582
1754
|
const requestFn = tls2 ? https.request : http.request;
|
|
1583
1755
|
const req = requestFn(
|
|
1584
1756
|
{
|
|
@@ -1591,26 +1763,26 @@ function isProxyRunning(port, tls2 = false) {
|
|
|
1591
1763
|
},
|
|
1592
1764
|
(res) => {
|
|
1593
1765
|
res.resume();
|
|
1594
|
-
|
|
1766
|
+
resolve4(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
|
|
1595
1767
|
}
|
|
1596
1768
|
);
|
|
1597
|
-
req.on("error", () =>
|
|
1769
|
+
req.on("error", () => resolve4(false));
|
|
1598
1770
|
req.on("timeout", () => {
|
|
1599
1771
|
req.destroy();
|
|
1600
|
-
|
|
1772
|
+
resolve4(false);
|
|
1601
1773
|
});
|
|
1602
1774
|
req.end();
|
|
1603
1775
|
});
|
|
1604
1776
|
}
|
|
1605
1777
|
function isPortListening(port) {
|
|
1606
|
-
return new Promise((
|
|
1778
|
+
return new Promise((resolve4) => {
|
|
1607
1779
|
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
1608
1780
|
let settled = false;
|
|
1609
1781
|
const finish = (result) => {
|
|
1610
1782
|
if (settled) return;
|
|
1611
1783
|
settled = true;
|
|
1612
1784
|
socket.destroy();
|
|
1613
|
-
|
|
1785
|
+
resolve4(result);
|
|
1614
1786
|
};
|
|
1615
1787
|
socket.setTimeout(SOCKET_TIMEOUT_MS);
|
|
1616
1788
|
socket.once("connect", () => finish(true));
|
|
@@ -1674,7 +1846,7 @@ function findPidOnPort(port) {
|
|
|
1674
1846
|
}
|
|
1675
1847
|
async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
|
|
1676
1848
|
for (let i = 0; i < maxAttempts; i++) {
|
|
1677
|
-
await new Promise((
|
|
1849
|
+
await new Promise((resolve4) => setTimeout(resolve4, intervalMs));
|
|
1678
1850
|
if (await isProxyRunning(port, tls2)) {
|
|
1679
1851
|
return true;
|
|
1680
1852
|
}
|
|
@@ -1718,10 +1890,10 @@ function spawnCommand(commandArgs, options) {
|
|
|
1718
1890
|
}
|
|
1719
1891
|
}
|
|
1720
1892
|
}
|
|
1721
|
-
const child = isWindows ?
|
|
1893
|
+
const child = isWindows ? spawn2("cmd.exe", ["/d", "/s", "/c", commandArgs.join(" ")], {
|
|
1722
1894
|
stdio: "inherit",
|
|
1723
1895
|
env
|
|
1724
|
-
}) :
|
|
1896
|
+
}) : spawn2("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
|
|
1725
1897
|
stdio: "inherit",
|
|
1726
1898
|
env,
|
|
1727
1899
|
detached: true
|
|
@@ -1867,7 +2039,7 @@ function removePortlessStateFiles(dir) {
|
|
|
1867
2039
|
}
|
|
1868
2040
|
|
|
1869
2041
|
// src/mdns.ts
|
|
1870
|
-
import { spawn as
|
|
2042
|
+
import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
|
|
1871
2043
|
|
|
1872
2044
|
// src/lan-ip.ts
|
|
1873
2045
|
import { createSocket } from "dgram";
|
|
@@ -1898,7 +2070,7 @@ function isInternalInterface(iname, macStr, internal) {
|
|
|
1898
2070
|
return false;
|
|
1899
2071
|
}
|
|
1900
2072
|
function probeDefaultRouteIPv4() {
|
|
1901
|
-
return new Promise((
|
|
2073
|
+
return new Promise((resolve4, reject) => {
|
|
1902
2074
|
const socket = createSocket({ type: "udp4", reuseAddr: true });
|
|
1903
2075
|
socket.on("error", (error) => {
|
|
1904
2076
|
socket.close();
|
|
@@ -1910,7 +2082,7 @@ function probeDefaultRouteIPv4() {
|
|
|
1910
2082
|
socket.close();
|
|
1911
2083
|
socket.unref();
|
|
1912
2084
|
if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
|
|
1913
|
-
|
|
2085
|
+
resolve4(addr.address);
|
|
1914
2086
|
} else {
|
|
1915
2087
|
reject(new Error("No route to host"));
|
|
1916
2088
|
}
|
|
@@ -1983,7 +2155,7 @@ function getMdnsPublisher() {
|
|
|
1983
2155
|
return null;
|
|
1984
2156
|
}
|
|
1985
2157
|
function hasCommand(command, probeArgs) {
|
|
1986
|
-
const result =
|
|
2158
|
+
const result = spawnSync3(command, probeArgs, {
|
|
1987
2159
|
stdio: "ignore",
|
|
1988
2160
|
timeout: 1e3,
|
|
1989
2161
|
windowsHide: true
|
|
@@ -2042,7 +2214,7 @@ function publish(hostname, port, ip, onError) {
|
|
|
2042
2214
|
if (!publisher) {
|
|
2043
2215
|
return;
|
|
2044
2216
|
}
|
|
2045
|
-
const child =
|
|
2217
|
+
const child = spawn3(publisher.command, publisher.buildArgs(fqdn, name, port, ip), {
|
|
2046
2218
|
stdio: "ignore",
|
|
2047
2219
|
detached: false
|
|
2048
2220
|
});
|
|
@@ -2595,14 +2767,27 @@ function hasTurboConfig(wsRoot) {
|
|
|
2595
2767
|
import * as fs8 from "fs";
|
|
2596
2768
|
import * as os2 from "os";
|
|
2597
2769
|
import * as path8 from "path";
|
|
2598
|
-
import { spawnSync as
|
|
2599
|
-
var
|
|
2770
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
2771
|
+
var DEFAULT_SERVICE_PORT = getProtocolPort(true);
|
|
2600
2772
|
var SERVICE_LABEL = "sh.portless.proxy";
|
|
2601
2773
|
var SYSTEMD_SERVICE = "portless.service";
|
|
2602
2774
|
var WINDOWS_TASK_NAME = "Portless Proxy";
|
|
2603
2775
|
var INTERNAL_ELEVATED_ENV = "PORTLESS_INTERNAL_SERVICE_ELEVATED";
|
|
2604
|
-
|
|
2605
|
-
|
|
2776
|
+
var SERVICE_ENV_KEYS = /* @__PURE__ */ new Set(["PORTLESS_SYNC_HOSTS"]);
|
|
2777
|
+
var DEFAULT_SERVICE_CONFIG = {
|
|
2778
|
+
proxyPort: DEFAULT_SERVICE_PORT,
|
|
2779
|
+
useHttps: true,
|
|
2780
|
+
customCertPath: null,
|
|
2781
|
+
customKeyPath: null,
|
|
2782
|
+
lanMode: false,
|
|
2783
|
+
lanIp: null,
|
|
2784
|
+
lanIpExplicit: false,
|
|
2785
|
+
tld: DEFAULT_TLD,
|
|
2786
|
+
useWildcard: false,
|
|
2787
|
+
extraEnv: {}
|
|
2788
|
+
};
|
|
2789
|
+
function defaultRunner3(command, args, options) {
|
|
2790
|
+
return spawnSync4(command, args, {
|
|
2606
2791
|
encoding: "utf-8",
|
|
2607
2792
|
stdio: options?.stdio ?? "pipe"
|
|
2608
2793
|
});
|
|
@@ -2619,6 +2804,156 @@ function systemdEscape(value) {
|
|
|
2619
2804
|
function windowsQuote(value) {
|
|
2620
2805
|
return `"${value.replace(/"/g, '\\"')}"`;
|
|
2621
2806
|
}
|
|
2807
|
+
function xmlUnescape(value) {
|
|
2808
|
+
return value.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
|
2809
|
+
}
|
|
2810
|
+
function parseBooleanEnv(value) {
|
|
2811
|
+
if (value === void 0) return null;
|
|
2812
|
+
if (value === "1" || value === "true") return true;
|
|
2813
|
+
if (value === "0" || value === "false") return false;
|
|
2814
|
+
return null;
|
|
2815
|
+
}
|
|
2816
|
+
function parsePortValue(value, source) {
|
|
2817
|
+
const port = parseInt(value, 10);
|
|
2818
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
2819
|
+
throw new Error(`${source} must be a number between 1 and 65535.`);
|
|
2820
|
+
}
|
|
2821
|
+
return port;
|
|
2822
|
+
}
|
|
2823
|
+
function getFlagValue(args, index, flag) {
|
|
2824
|
+
const value = args[index + 1];
|
|
2825
|
+
if (!value || value.startsWith("-")) {
|
|
2826
|
+
throw new Error(`${flag} requires a value.`);
|
|
2827
|
+
}
|
|
2828
|
+
return value;
|
|
2829
|
+
}
|
|
2830
|
+
function resolveServicePath(value) {
|
|
2831
|
+
const expanded = value === "~" ? os2.homedir() : value.startsWith("~/") || value.startsWith("~\\") ? path8.join(os2.homedir(), value.slice(2)) : value;
|
|
2832
|
+
return path8.resolve(expanded);
|
|
2833
|
+
}
|
|
2834
|
+
function normalizeServiceInstallPaths(config) {
|
|
2835
|
+
return {
|
|
2836
|
+
...config,
|
|
2837
|
+
stateDir: config.stateDir ? resolveServicePath(config.stateDir) : void 0,
|
|
2838
|
+
customCertPath: config.customCertPath ? resolveServicePath(config.customCertPath) : null,
|
|
2839
|
+
customKeyPath: config.customKeyPath ? resolveServicePath(config.customKeyPath) : null
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
function collectServiceExtraEnv(env) {
|
|
2843
|
+
const extraEnv = {};
|
|
2844
|
+
for (const key of SERVICE_ENV_KEYS) {
|
|
2845
|
+
const value = env[key];
|
|
2846
|
+
if (value) extraEnv[key] = value;
|
|
2847
|
+
}
|
|
2848
|
+
return extraEnv;
|
|
2849
|
+
}
|
|
2850
|
+
function parseServiceInstallConfig(args, env = process.env, options = {}) {
|
|
2851
|
+
const config = {
|
|
2852
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
2853
|
+
extraEnv: collectServiceExtraEnv(env)
|
|
2854
|
+
};
|
|
2855
|
+
if (env.PORTLESS_STATE_DIR) {
|
|
2856
|
+
config.stateDir = env.PORTLESS_STATE_DIR;
|
|
2857
|
+
}
|
|
2858
|
+
const envHttps = parseBooleanEnv(env.PORTLESS_HTTPS);
|
|
2859
|
+
if (envHttps !== null) {
|
|
2860
|
+
config.useHttps = envHttps;
|
|
2861
|
+
}
|
|
2862
|
+
const envLan = parseBooleanEnv(env.PORTLESS_LAN);
|
|
2863
|
+
if (envLan !== null) {
|
|
2864
|
+
config.lanMode = envLan;
|
|
2865
|
+
}
|
|
2866
|
+
if (env.PORTLESS_LAN_IP) {
|
|
2867
|
+
config.lanMode = true;
|
|
2868
|
+
config.lanIp = env.PORTLESS_LAN_IP;
|
|
2869
|
+
config.lanIpExplicit = true;
|
|
2870
|
+
}
|
|
2871
|
+
if (env.PORTLESS_TLD) {
|
|
2872
|
+
const tld = env.PORTLESS_TLD.trim().toLowerCase();
|
|
2873
|
+
const err = validateTld(tld);
|
|
2874
|
+
if (err) throw new Error(`PORTLESS_TLD: ${err}`);
|
|
2875
|
+
config.tld = tld;
|
|
2876
|
+
}
|
|
2877
|
+
const envWildcard = parseBooleanEnv(env.PORTLESS_WILDCARD);
|
|
2878
|
+
if (envWildcard !== null) {
|
|
2879
|
+
config.useWildcard = envWildcard;
|
|
2880
|
+
}
|
|
2881
|
+
if (env.PORTLESS_PORT) {
|
|
2882
|
+
config.proxyPort = parsePortValue(env.PORTLESS_PORT, "PORTLESS_PORT");
|
|
2883
|
+
} else {
|
|
2884
|
+
config.proxyPort = getProtocolPort(config.useHttps);
|
|
2885
|
+
}
|
|
2886
|
+
const tokens = args[0] === "service" ? args.slice(2) : args;
|
|
2887
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
2888
|
+
const token = tokens[i];
|
|
2889
|
+
switch (token) {
|
|
2890
|
+
case "-p":
|
|
2891
|
+
case "--port":
|
|
2892
|
+
config.proxyPort = parsePortValue(getFlagValue(tokens, i, token), token);
|
|
2893
|
+
i += 1;
|
|
2894
|
+
break;
|
|
2895
|
+
case "--https":
|
|
2896
|
+
config.useHttps = true;
|
|
2897
|
+
break;
|
|
2898
|
+
case "--no-tls":
|
|
2899
|
+
config.useHttps = false;
|
|
2900
|
+
break;
|
|
2901
|
+
case "--lan":
|
|
2902
|
+
config.lanMode = true;
|
|
2903
|
+
break;
|
|
2904
|
+
case "--ip":
|
|
2905
|
+
config.lanMode = true;
|
|
2906
|
+
config.lanIp = getFlagValue(tokens, i, token);
|
|
2907
|
+
config.lanIpExplicit = true;
|
|
2908
|
+
i += 1;
|
|
2909
|
+
break;
|
|
2910
|
+
case "--tld": {
|
|
2911
|
+
const tld = getFlagValue(tokens, i, token).trim().toLowerCase();
|
|
2912
|
+
const err = validateTld(tld);
|
|
2913
|
+
if (err) throw new Error(err);
|
|
2914
|
+
config.tld = tld;
|
|
2915
|
+
i += 1;
|
|
2916
|
+
break;
|
|
2917
|
+
}
|
|
2918
|
+
case "--wildcard":
|
|
2919
|
+
config.useWildcard = true;
|
|
2920
|
+
break;
|
|
2921
|
+
case "--cert":
|
|
2922
|
+
config.customCertPath = getFlagValue(tokens, i, token);
|
|
2923
|
+
config.useHttps = true;
|
|
2924
|
+
i += 1;
|
|
2925
|
+
break;
|
|
2926
|
+
case "--key":
|
|
2927
|
+
config.customKeyPath = getFlagValue(tokens, i, token);
|
|
2928
|
+
config.useHttps = true;
|
|
2929
|
+
i += 1;
|
|
2930
|
+
break;
|
|
2931
|
+
case "--state-dir":
|
|
2932
|
+
config.stateDir = getFlagValue(tokens, i, token);
|
|
2933
|
+
i += 1;
|
|
2934
|
+
break;
|
|
2935
|
+
case "--foreground":
|
|
2936
|
+
case "--skip-trust":
|
|
2937
|
+
if (!options.allowRuntimeFlags) {
|
|
2938
|
+
throw new Error(`Unknown service install option "${token}".`);
|
|
2939
|
+
}
|
|
2940
|
+
break;
|
|
2941
|
+
default:
|
|
2942
|
+
throw new Error(`Unknown service install option "${token}".`);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
if (config.customCertPath && !config.customKeyPath || !config.customCertPath && config.customKeyPath) {
|
|
2946
|
+
throw new Error("--cert and --key must be used together.");
|
|
2947
|
+
}
|
|
2948
|
+
if (!env.PORTLESS_PORT && !tokens.includes("--port") && !tokens.includes("-p")) {
|
|
2949
|
+
config.proxyPort = getProtocolPort(config.useHttps);
|
|
2950
|
+
}
|
|
2951
|
+
if (!config.lanMode) {
|
|
2952
|
+
config.lanIp = null;
|
|
2953
|
+
config.lanIpExplicit = false;
|
|
2954
|
+
}
|
|
2955
|
+
return config;
|
|
2956
|
+
}
|
|
2622
2957
|
function readPasswdHome(username) {
|
|
2623
2958
|
try {
|
|
2624
2959
|
const passwd = fs8.readFileSync("/etc/passwd", "utf-8");
|
|
@@ -2652,22 +2987,40 @@ function resolveUserContext(platform) {
|
|
|
2652
2987
|
username: userInfo2.username
|
|
2653
2988
|
};
|
|
2654
2989
|
}
|
|
2655
|
-
function buildProxyCommand(entryScript) {
|
|
2656
|
-
const
|
|
2657
|
-
useHttps:
|
|
2658
|
-
|
|
2659
|
-
|
|
2990
|
+
function buildProxyCommand(entryScript, serviceConfig) {
|
|
2991
|
+
const proxyConfig = buildProxyStartConfig({
|
|
2992
|
+
useHttps: serviceConfig.useHttps,
|
|
2993
|
+
customCertPath: serviceConfig.customCertPath,
|
|
2994
|
+
customKeyPath: serviceConfig.customKeyPath,
|
|
2995
|
+
lanMode: serviceConfig.lanMode,
|
|
2996
|
+
lanIp: serviceConfig.lanIp,
|
|
2997
|
+
lanIpExplicit: serviceConfig.lanIpExplicit,
|
|
2998
|
+
tld: serviceConfig.tld,
|
|
2999
|
+
useWildcard: serviceConfig.useWildcard,
|
|
2660
3000
|
foreground: true,
|
|
2661
3001
|
includePort: true,
|
|
2662
|
-
proxyPort:
|
|
3002
|
+
proxyPort: serviceConfig.proxyPort,
|
|
2663
3003
|
skipTrust: true
|
|
2664
3004
|
});
|
|
2665
|
-
return [entryScript, "proxy", "start", ...
|
|
3005
|
+
return [entryScript, "proxy", "start", ...proxyConfig.args];
|
|
2666
3006
|
}
|
|
2667
3007
|
function buildServiceEnv(ctx) {
|
|
2668
3008
|
const env = {
|
|
2669
|
-
PORTLESS_STATE_DIR: ctx.stateDir
|
|
3009
|
+
PORTLESS_STATE_DIR: ctx.stateDir,
|
|
3010
|
+
PORTLESS_PORT: ctx.config.proxyPort.toString(),
|
|
3011
|
+
PORTLESS_HTTPS: ctx.config.useHttps ? "1" : "0",
|
|
3012
|
+
PORTLESS_LAN: ctx.config.lanMode ? "1" : "0",
|
|
3013
|
+
PORTLESS_WILDCARD: ctx.config.useWildcard ? "1" : "0",
|
|
3014
|
+
...ctx.config.extraEnv
|
|
2670
3015
|
};
|
|
3016
|
+
if (ctx.config.lanMode && ctx.config.lanIpExplicit && ctx.config.lanIp) {
|
|
3017
|
+
env.PORTLESS_LAN_IP = ctx.config.lanIp;
|
|
3018
|
+
}
|
|
3019
|
+
if (ctx.config.lanMode) {
|
|
3020
|
+
env.PORTLESS_TLD = "local";
|
|
3021
|
+
} else if (ctx.config.tld !== DEFAULT_TLD) {
|
|
3022
|
+
env.PORTLESS_TLD = ctx.config.tld;
|
|
3023
|
+
}
|
|
2671
3024
|
if (ctx.platform === "win32") {
|
|
2672
3025
|
env.USERPROFILE = ctx.user.home;
|
|
2673
3026
|
env.PATH = ctx.pathEnv;
|
|
@@ -2746,11 +3099,21 @@ ${proxyCommand}\r
|
|
|
2746
3099
|
`;
|
|
2747
3100
|
}
|
|
2748
3101
|
function buildServiceSpec(options) {
|
|
3102
|
+
const installConfig = {
|
|
3103
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
3104
|
+
...options.installConfig,
|
|
3105
|
+
extraEnv: options.installConfig?.extraEnv ?? {}
|
|
3106
|
+
};
|
|
3107
|
+
const stateDir = options.stateDir || installConfig.stateDir || defaultStateDir(options.platform, options.userHome);
|
|
3108
|
+
const normalizedConfig = {
|
|
3109
|
+
...installConfig,
|
|
3110
|
+
stateDir
|
|
3111
|
+
};
|
|
2749
3112
|
const ctx = {
|
|
2750
3113
|
platform: options.platform,
|
|
2751
3114
|
nodePath: options.nodePath,
|
|
2752
3115
|
entryScript: options.entryScript,
|
|
2753
|
-
stateDir
|
|
3116
|
+
stateDir,
|
|
2754
3117
|
user: {
|
|
2755
3118
|
home: options.userHome,
|
|
2756
3119
|
uid: options.uid,
|
|
@@ -2758,9 +3121,10 @@ function buildServiceSpec(options) {
|
|
|
2758
3121
|
username: options.username
|
|
2759
3122
|
},
|
|
2760
3123
|
pathEnv: options.pathEnv || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
2761
|
-
programData: options.programData || "C:\\ProgramData"
|
|
3124
|
+
programData: options.programData || "C:\\ProgramData",
|
|
3125
|
+
config: normalizedConfig
|
|
2762
3126
|
};
|
|
2763
|
-
const proxyCommand = buildProxyCommand(ctx.entryScript);
|
|
3127
|
+
const proxyCommand = buildProxyCommand(ctx.entryScript, ctx.config);
|
|
2764
3128
|
if (ctx.platform === "darwin") {
|
|
2765
3129
|
const programArguments = [ctx.nodePath, ...proxyCommand];
|
|
2766
3130
|
return {
|
|
@@ -2769,6 +3133,7 @@ function buildServiceSpec(options) {
|
|
|
2769
3133
|
plistPath: `/Library/LaunchDaemons/${SERVICE_LABEL}.plist`,
|
|
2770
3134
|
plist: buildLaunchdPlist(ctx, programArguments),
|
|
2771
3135
|
stateDir: ctx.stateDir,
|
|
3136
|
+
config: ctx.config,
|
|
2772
3137
|
programArguments
|
|
2773
3138
|
};
|
|
2774
3139
|
}
|
|
@@ -2780,6 +3145,7 @@ function buildServiceSpec(options) {
|
|
|
2780
3145
|
unitPath: `/etc/systemd/system/${SYSTEMD_SERVICE}`,
|
|
2781
3146
|
unit: buildSystemdUnit(ctx, execStart),
|
|
2782
3147
|
stateDir: ctx.stateDir,
|
|
3148
|
+
config: ctx.config,
|
|
2783
3149
|
execStart
|
|
2784
3150
|
};
|
|
2785
3151
|
}
|
|
@@ -2791,6 +3157,7 @@ function buildServiceSpec(options) {
|
|
|
2791
3157
|
platform: "win32",
|
|
2792
3158
|
taskName: WINDOWS_TASK_NAME,
|
|
2793
3159
|
stateDir: ctx.stateDir,
|
|
3160
|
+
config: ctx.config,
|
|
2794
3161
|
scriptDir,
|
|
2795
3162
|
scriptPath,
|
|
2796
3163
|
script,
|
|
@@ -2814,11 +3181,17 @@ function buildServiceSpec(options) {
|
|
|
2814
3181
|
queryArgs: ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]
|
|
2815
3182
|
};
|
|
2816
3183
|
}
|
|
2817
|
-
function currentServiceSpec(entryScript) {
|
|
3184
|
+
function currentServiceSpec(entryScript, installConfig) {
|
|
2818
3185
|
if (!isSupportedPlatform(process.platform)) {
|
|
2819
3186
|
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
2820
3187
|
}
|
|
2821
3188
|
const user = resolveUserContext(process.platform);
|
|
3189
|
+
const stateDir = installConfig?.stateDir || process.env.PORTLESS_STATE_DIR || defaultStateDir(process.platform, user.home);
|
|
3190
|
+
const config = installConfig ?? {
|
|
3191
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
3192
|
+
stateDir,
|
|
3193
|
+
extraEnv: collectServiceExtraEnv(process.env)
|
|
3194
|
+
};
|
|
2822
3195
|
return buildServiceSpec({
|
|
2823
3196
|
platform: process.platform,
|
|
2824
3197
|
nodePath: process.execPath,
|
|
@@ -2827,11 +3200,131 @@ function currentServiceSpec(entryScript) {
|
|
|
2827
3200
|
uid: user.uid,
|
|
2828
3201
|
gid: user.gid,
|
|
2829
3202
|
username: user.username,
|
|
2830
|
-
stateDir
|
|
3203
|
+
stateDir,
|
|
2831
3204
|
pathEnv: process.env.PATH,
|
|
2832
|
-
programData: process.env.ProgramData
|
|
3205
|
+
programData: process.env.ProgramData,
|
|
3206
|
+
installConfig: config
|
|
2833
3207
|
});
|
|
2834
3208
|
}
|
|
3209
|
+
function parseQuotedWords(input, options = {}) {
|
|
3210
|
+
const words = [];
|
|
3211
|
+
let current = "";
|
|
3212
|
+
let inQuote = false;
|
|
3213
|
+
let inWord = false;
|
|
3214
|
+
const unescapeBackslash = options.unescapeBackslash ?? true;
|
|
3215
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
3216
|
+
const char = input[i];
|
|
3217
|
+
if (char === '"') {
|
|
3218
|
+
inQuote = !inQuote;
|
|
3219
|
+
inWord = true;
|
|
3220
|
+
continue;
|
|
3221
|
+
}
|
|
3222
|
+
if (char === "\\" && i + 1 < input.length && (input[i + 1] === '"' || unescapeBackslash && input[i + 1] === "\\")) {
|
|
3223
|
+
current += input[i + 1];
|
|
3224
|
+
inWord = true;
|
|
3225
|
+
i += 1;
|
|
3226
|
+
continue;
|
|
3227
|
+
}
|
|
3228
|
+
if (/\s/.test(char) && !inQuote) {
|
|
3229
|
+
if (inWord) {
|
|
3230
|
+
words.push(current);
|
|
3231
|
+
current = "";
|
|
3232
|
+
inWord = false;
|
|
3233
|
+
}
|
|
3234
|
+
continue;
|
|
3235
|
+
}
|
|
3236
|
+
current += char;
|
|
3237
|
+
inWord = true;
|
|
3238
|
+
}
|
|
3239
|
+
if (inWord) {
|
|
3240
|
+
words.push(current);
|
|
3241
|
+
}
|
|
3242
|
+
return words;
|
|
3243
|
+
}
|
|
3244
|
+
function parsePlistStrings(block) {
|
|
3245
|
+
return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) => xmlUnescape(match[1]));
|
|
3246
|
+
}
|
|
3247
|
+
function parsePlistEnv(block) {
|
|
3248
|
+
const env = {};
|
|
3249
|
+
for (const match of block.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
|
|
3250
|
+
env[xmlUnescape(match[1])] = xmlUnescape(match[2]);
|
|
3251
|
+
}
|
|
3252
|
+
return env;
|
|
3253
|
+
}
|
|
3254
|
+
function readInstalledServiceSnapshot(spec) {
|
|
3255
|
+
try {
|
|
3256
|
+
if (spec.platform === "darwin") {
|
|
3257
|
+
if (!fs8.existsSync(spec.plistPath)) return null;
|
|
3258
|
+
const plist = fs8.readFileSync(spec.plistPath, "utf-8");
|
|
3259
|
+
const argsBlock = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
3260
|
+
const envBlock = plist.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
|
|
3261
|
+
if (!argsBlock) return null;
|
|
3262
|
+
return {
|
|
3263
|
+
command: parsePlistStrings(argsBlock[1]),
|
|
3264
|
+
env: envBlock ? parsePlistEnv(envBlock[1]) : {}
|
|
3265
|
+
};
|
|
3266
|
+
}
|
|
3267
|
+
if (spec.platform === "linux") {
|
|
3268
|
+
if (!fs8.existsSync(spec.unitPath)) return null;
|
|
3269
|
+
const unit = fs8.readFileSync(spec.unitPath, "utf-8");
|
|
3270
|
+
const env2 = {};
|
|
3271
|
+
let command = null;
|
|
3272
|
+
for (const line of unit.split("\n")) {
|
|
3273
|
+
if (line.startsWith("Environment=")) {
|
|
3274
|
+
const entry = line.slice("Environment=".length);
|
|
3275
|
+
const eq = entry.indexOf("=");
|
|
3276
|
+
if (eq > 0) {
|
|
3277
|
+
const key = entry.slice(0, eq);
|
|
3278
|
+
const value = parseQuotedWords(entry.slice(eq + 1))[0] ?? "";
|
|
3279
|
+
env2[key] = value;
|
|
3280
|
+
}
|
|
3281
|
+
} else if (line.startsWith("ExecStart=")) {
|
|
3282
|
+
command = parseQuotedWords(line.slice("ExecStart=".length));
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
return command ? { command, env: env2 } : null;
|
|
3286
|
+
}
|
|
3287
|
+
if (!fs8.existsSync(spec.scriptPath)) return null;
|
|
3288
|
+
const script = fs8.readFileSync(spec.scriptPath, "utf-8");
|
|
3289
|
+
const env = {};
|
|
3290
|
+
let commandLine = null;
|
|
3291
|
+
for (const rawLine of script.split(/\r?\n/)) {
|
|
3292
|
+
const line = rawLine.trim();
|
|
3293
|
+
if (!line || line.toLowerCase() === "@echo off") continue;
|
|
3294
|
+
const envMatch = line.match(/^set "([^=]+)=(.*)"$/);
|
|
3295
|
+
if (envMatch) {
|
|
3296
|
+
env[envMatch[1]] = envMatch[2].replace(/%%/g, "%");
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
commandLine = line;
|
|
3300
|
+
}
|
|
3301
|
+
return commandLine ? { command: parseQuotedWords(commandLine, { unescapeBackslash: false }), env } : null;
|
|
3302
|
+
} catch {
|
|
3303
|
+
return null;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
function installedConfigFromSnapshot(snapshot, fallback) {
|
|
3307
|
+
const proxyIndex = snapshot.command.findIndex(
|
|
3308
|
+
(arg, index) => arg === "proxy" && snapshot.command[index + 1] === "start"
|
|
3309
|
+
);
|
|
3310
|
+
if (proxyIndex === -1) return null;
|
|
3311
|
+
try {
|
|
3312
|
+
const parsed = parseServiceInstallConfig(
|
|
3313
|
+
["service", "install", ...snapshot.command.slice(proxyIndex + 2)],
|
|
3314
|
+
snapshot.env,
|
|
3315
|
+
{ allowRuntimeFlags: true }
|
|
3316
|
+
);
|
|
3317
|
+
const stateDir = parsed.stateDir || snapshot.env.PORTLESS_STATE_DIR || fallback.stateDir;
|
|
3318
|
+
return { ...parsed, stateDir };
|
|
3319
|
+
} catch {
|
|
3320
|
+
return null;
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
function readInstalledServiceConfig(spec) {
|
|
3324
|
+
const snapshot = readInstalledServiceSnapshot(spec);
|
|
3325
|
+
if (!snapshot) return null;
|
|
3326
|
+
return installedConfigFromSnapshot(snapshot, spec.config);
|
|
3327
|
+
}
|
|
2835
3328
|
function collectPortlessEnvArgs(env = process.env, omit = /* @__PURE__ */ new Set()) {
|
|
2836
3329
|
const envArgs = [];
|
|
2837
3330
|
for (const key of Object.keys(env)) {
|
|
@@ -2901,18 +3394,34 @@ function isPermissionError(err) {
|
|
|
2901
3394
|
const message = err instanceof Error ? err.message : String(err);
|
|
2902
3395
|
return code === "EACCES" || code === "EPERM" || /permission denied|operation not permitted|access is denied/i.test(message);
|
|
2903
3396
|
}
|
|
2904
|
-
function
|
|
3397
|
+
function stopProxyOnPort(entryScript, runner, proxyPort) {
|
|
2905
3398
|
runRequired(runner, process.execPath, [
|
|
2906
3399
|
entryScript,
|
|
2907
3400
|
"proxy",
|
|
2908
3401
|
"stop",
|
|
2909
3402
|
"--port",
|
|
2910
|
-
|
|
3403
|
+
proxyPort.toString()
|
|
2911
3404
|
]);
|
|
2912
3405
|
}
|
|
2913
|
-
function
|
|
3406
|
+
async function stopExistingProxy(entryScript, runner, proxyPort) {
|
|
3407
|
+
const ports = /* @__PURE__ */ new Set();
|
|
3408
|
+
try {
|
|
3409
|
+
const currentState = await discoverState();
|
|
3410
|
+
if (currentState.port !== proxyPort && await isProxyRunning(currentState.port)) {
|
|
3411
|
+
ports.add(currentState.port);
|
|
3412
|
+
}
|
|
3413
|
+
} catch {
|
|
3414
|
+
}
|
|
3415
|
+
ports.add(proxyPort);
|
|
3416
|
+
for (const port of ports) {
|
|
3417
|
+
stopProxyOnPort(entryScript, runner, port);
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
function prepareServiceState(stateDir) {
|
|
2914
3421
|
fs8.mkdirSync(stateDir, { recursive: true });
|
|
2915
3422
|
fixOwnership(stateDir);
|
|
3423
|
+
}
|
|
3424
|
+
function prepareTrust(stateDir) {
|
|
2916
3425
|
try {
|
|
2917
3426
|
ensureCerts(stateDir);
|
|
2918
3427
|
} catch (err) {
|
|
@@ -2935,13 +3444,28 @@ ${detail}`
|
|
|
2935
3444
|
}
|
|
2936
3445
|
console.warn(colors_default.yellow("Run `portless trust` if browsers show certificate warnings."));
|
|
2937
3446
|
}
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
const
|
|
2941
|
-
|
|
3447
|
+
function ensureServiceConfigSupported(config) {
|
|
3448
|
+
if (!config.lanMode) return;
|
|
3449
|
+
const mdnsSupport = isMdnsSupported();
|
|
3450
|
+
if (mdnsSupport.supported) return;
|
|
3451
|
+
const reason = mdnsSupport.reason ? `
|
|
3452
|
+
${mdnsSupport.reason}` : "";
|
|
3453
|
+
throw new Error(
|
|
3454
|
+
`LAN mode requires mDNS publishing, which is not supported on this platform.${reason}`
|
|
3455
|
+
);
|
|
3456
|
+
}
|
|
3457
|
+
async function installService(entryScript, runner, args) {
|
|
3458
|
+
const installConfig = normalizeServiceInstallPaths(parseServiceInstallConfig(args));
|
|
3459
|
+
ensureServiceConfigSupported(installConfig);
|
|
3460
|
+
requireUnixElevation([entryScript, ...args], runner);
|
|
3461
|
+
const spec = currentServiceSpec(entryScript, installConfig);
|
|
3462
|
+
prepareServiceState(spec.stateDir);
|
|
3463
|
+
if (spec.config.useHttps && !spec.config.customCertPath) {
|
|
3464
|
+
prepareTrust(spec.stateDir);
|
|
3465
|
+
}
|
|
2942
3466
|
if (spec.platform === "darwin") {
|
|
2943
3467
|
runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
|
|
2944
|
-
stopExistingProxy(entryScript, runner);
|
|
3468
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
2945
3469
|
fs8.writeFileSync(spec.plistPath, spec.plist);
|
|
2946
3470
|
fs8.chmodSync(spec.plistPath, 420);
|
|
2947
3471
|
runRequired(runner, "chown", ["root:wheel", spec.plistPath]);
|
|
@@ -2950,7 +3474,7 @@ async function installService(entryScript, runner) {
|
|
|
2950
3474
|
runRequired(runner, "launchctl", ["kickstart", "-k", `system/${spec.label}`]);
|
|
2951
3475
|
} else if (spec.platform === "linux") {
|
|
2952
3476
|
runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
|
|
2953
|
-
stopExistingProxy(entryScript, runner);
|
|
3477
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
2954
3478
|
fs8.writeFileSync(spec.unitPath, spec.unit);
|
|
2955
3479
|
fs8.chmodSync(spec.unitPath, 420);
|
|
2956
3480
|
runRequired(runner, "systemctl", ["daemon-reload"]);
|
|
@@ -2958,7 +3482,7 @@ async function installService(entryScript, runner) {
|
|
|
2958
3482
|
runRequired(runner, "systemctl", ["restart", spec.serviceName]);
|
|
2959
3483
|
} else {
|
|
2960
3484
|
runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
|
|
2961
|
-
stopExistingProxy(entryScript, runner);
|
|
3485
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
2962
3486
|
fs8.mkdirSync(spec.scriptDir, { recursive: true });
|
|
2963
3487
|
fs8.writeFileSync(spec.scriptPath, spec.script);
|
|
2964
3488
|
runRequired(runner, "schtasks", spec.createArgs);
|
|
@@ -2966,6 +3490,7 @@ async function installService(entryScript, runner) {
|
|
|
2966
3490
|
}
|
|
2967
3491
|
console.log(colors_default.green("Portless service installed."));
|
|
2968
3492
|
console.log(colors_default.gray(`State directory: ${spec.stateDir}`));
|
|
3493
|
+
console.log(colors_default.gray(`Proxy port: ${spec.config.proxyPort}`));
|
|
2969
3494
|
}
|
|
2970
3495
|
async function uninstallService(entryScript, runner) {
|
|
2971
3496
|
requireUnixElevation([entryScript, "service", "uninstall"], runner);
|
|
@@ -2984,7 +3509,7 @@ async function uninstallService(entryScript, runner) {
|
|
|
2984
3509
|
}
|
|
2985
3510
|
console.log(colors_default.green("Portless service uninstalled."));
|
|
2986
3511
|
}
|
|
2987
|
-
function tryUninstallService(entryScript, runner =
|
|
3512
|
+
function tryUninstallService(entryScript, runner = defaultRunner3) {
|
|
2988
3513
|
let installed = false;
|
|
2989
3514
|
try {
|
|
2990
3515
|
const spec = currentServiceSpec(entryScript);
|
|
@@ -3019,13 +3544,20 @@ function tryUninstallService(entryScript, runner = defaultRunner2) {
|
|
|
3019
3544
|
}
|
|
3020
3545
|
async function getServiceStatus(entryScript, runner) {
|
|
3021
3546
|
const spec = currentServiceSpec(entryScript);
|
|
3022
|
-
const
|
|
3547
|
+
const installedConfig = readInstalledServiceConfig(spec) ?? spec.config;
|
|
3548
|
+
const proxyRunning = await isProxyRunning(installedConfig.proxyPort, installedConfig.useHttps);
|
|
3023
3549
|
if (spec.platform === "darwin") {
|
|
3024
3550
|
const installed2 = fs8.existsSync(spec.plistPath);
|
|
3025
3551
|
const result = runner("launchctl", ["print", `system/${spec.label}`]);
|
|
3026
3552
|
const output2 = `${result.stdout || ""}${result.stderr || ""}`;
|
|
3027
3553
|
const managerState = result.status === 0 && /state = running|pid = \d+/.test(output2) ? "running" : installed2 ? "installed" : "not installed";
|
|
3028
|
-
return {
|
|
3554
|
+
return {
|
|
3555
|
+
installed: installed2,
|
|
3556
|
+
managerState,
|
|
3557
|
+
proxyRunning,
|
|
3558
|
+
config: installedConfig,
|
|
3559
|
+
details: spec.plistPath
|
|
3560
|
+
};
|
|
3029
3561
|
}
|
|
3030
3562
|
if (spec.platform === "linux") {
|
|
3031
3563
|
const enabled2 = runner("systemctl", ["is-enabled", spec.serviceName]);
|
|
@@ -3036,6 +3568,7 @@ async function getServiceStatus(entryScript, runner) {
|
|
|
3036
3568
|
installed: installed2,
|
|
3037
3569
|
managerState: active.status === 0 ? activeText || "active" : installed2 ? "installed" : "not installed",
|
|
3038
3570
|
proxyRunning,
|
|
3571
|
+
config: installedConfig,
|
|
3039
3572
|
details: spec.unitPath
|
|
3040
3573
|
};
|
|
3041
3574
|
}
|
|
@@ -3047,17 +3580,27 @@ async function getServiceStatus(entryScript, runner) {
|
|
|
3047
3580
|
installed,
|
|
3048
3581
|
managerState: installed ? stateMatch?.[1]?.trim() || "installed" : "not installed",
|
|
3049
3582
|
proxyRunning,
|
|
3583
|
+
config: installedConfig,
|
|
3050
3584
|
details: spec.taskName
|
|
3051
3585
|
};
|
|
3052
3586
|
}
|
|
3053
3587
|
async function printServiceStatus(entryScript, runner) {
|
|
3054
|
-
const spec = currentServiceSpec(entryScript);
|
|
3055
3588
|
const status = await getServiceStatus(entryScript, runner);
|
|
3589
|
+
const config = status.config;
|
|
3056
3590
|
console.log(colors_default.bold("portless service"));
|
|
3057
3591
|
console.log(` Manager state: ${status.managerState}`);
|
|
3058
3592
|
console.log(` Installed: ${status.installed ? "yes" : "no"}`);
|
|
3059
|
-
console.log(
|
|
3060
|
-
|
|
3593
|
+
console.log(
|
|
3594
|
+
` Proxy on ${config.proxyPort}: ${status.proxyRunning ? "responding" : "not responding"}`
|
|
3595
|
+
);
|
|
3596
|
+
console.log(` HTTPS: ${config.useHttps ? "yes" : "no"}`);
|
|
3597
|
+
console.log(` TLD: ${config.lanMode ? "local" : config.tld}`);
|
|
3598
|
+
console.log(` LAN mode: ${config.lanMode ? "yes" : "no"}`);
|
|
3599
|
+
if (config.lanIpExplicit && config.lanIp) {
|
|
3600
|
+
console.log(` LAN IP: ${config.lanIp}`);
|
|
3601
|
+
}
|
|
3602
|
+
console.log(` Wildcard: ${config.useWildcard ? "yes" : "no"}`);
|
|
3603
|
+
console.log(` State directory: ${config.stateDir}`);
|
|
3061
3604
|
if (status.details) {
|
|
3062
3605
|
console.log(` Service entry: ${status.details}`);
|
|
3063
3606
|
}
|
|
@@ -3067,26 +3610,41 @@ function printServiceHelp() {
|
|
|
3067
3610
|
${colors_default.bold("portless service")} - Start portless automatically when the OS starts.
|
|
3068
3611
|
|
|
3069
3612
|
${colors_default.bold("Usage:")}
|
|
3070
|
-
${colors_default.cyan("portless service install")}
|
|
3071
|
-
${colors_default.cyan("portless service
|
|
3072
|
-
${colors_default.cyan("portless service
|
|
3613
|
+
${colors_default.cyan("portless service install")} Install and start the HTTPS service on port 443
|
|
3614
|
+
${colors_default.cyan("portless service install --lan")} Enable LAN mode for the startup service
|
|
3615
|
+
${colors_default.cyan("portless service install -p 8443")} Use a custom proxy port
|
|
3616
|
+
${colors_default.cyan("portless service uninstall")} Stop and remove the startup service
|
|
3617
|
+
${colors_default.cyan("portless service status")} Show service and proxy status
|
|
3618
|
+
|
|
3619
|
+
${colors_default.bold("Install options:")}
|
|
3620
|
+
-p, --port <number> Port for the proxy service
|
|
3621
|
+
--no-tls Disable HTTPS
|
|
3622
|
+
--https Enable HTTPS
|
|
3623
|
+
--lan Enable LAN mode
|
|
3624
|
+
--ip <address> Pin a specific LAN IP
|
|
3625
|
+
--tld <tld> Use a custom TLD outside LAN mode
|
|
3626
|
+
--wildcard Allow subdomain fallback
|
|
3627
|
+
--cert <path> Use a custom TLS certificate
|
|
3628
|
+
--key <path> Use a custom TLS private key
|
|
3629
|
+
--state-dir <path> Use a custom service state directory
|
|
3073
3630
|
|
|
3074
3631
|
${colors_default.bold("Notes:")}
|
|
3075
|
-
The service uses the default clean URL mode
|
|
3632
|
+
The service uses the default clean URL mode unless options or PORTLESS_*
|
|
3633
|
+
environment variables are provided during install.
|
|
3076
3634
|
macOS and Linux install a root-owned service so port 443 can bind at boot.
|
|
3077
3635
|
Windows installs a Task Scheduler startup task that runs as SYSTEM.
|
|
3078
3636
|
`);
|
|
3079
3637
|
}
|
|
3080
3638
|
async function handleService(args, options) {
|
|
3081
3639
|
const action = args[1];
|
|
3082
|
-
const runner = options.runner ||
|
|
3640
|
+
const runner = options.runner || defaultRunner3;
|
|
3083
3641
|
if (!action || action === "--help" || action === "-h") {
|
|
3084
3642
|
printServiceHelp();
|
|
3085
3643
|
process.exit(0);
|
|
3086
3644
|
}
|
|
3087
3645
|
try {
|
|
3088
3646
|
if (action === "install") {
|
|
3089
|
-
await installService(options.entryScript, runner);
|
|
3647
|
+
await installService(options.entryScript, runner, args);
|
|
3090
3648
|
return;
|
|
3091
3649
|
}
|
|
3092
3650
|
if (action === "uninstall") {
|
|
@@ -3280,7 +3838,7 @@ function collectPortlessEnvArgs2() {
|
|
|
3280
3838
|
function sudoStop(port) {
|
|
3281
3839
|
const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
|
|
3282
3840
|
console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
|
|
3283
|
-
const result =
|
|
3841
|
+
const result = spawnSync5("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
|
|
3284
3842
|
stdio: "inherit",
|
|
3285
3843
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3286
3844
|
});
|
|
@@ -3289,7 +3847,7 @@ function sudoStop(port) {
|
|
|
3289
3847
|
function runCleanWithSudo(reason) {
|
|
3290
3848
|
console.log(colors_default.yellow(`${reason} Requesting sudo...`));
|
|
3291
3849
|
const home = process.env.HOME;
|
|
3292
|
-
const result =
|
|
3850
|
+
const result = spawnSync5(
|
|
3293
3851
|
"sudo",
|
|
3294
3852
|
[
|
|
3295
3853
|
"env",
|
|
@@ -3308,12 +3866,20 @@ function runCleanWithSudo(reason) {
|
|
|
3308
3866
|
}
|
|
3309
3867
|
function runServiceUninstallWithSudo(reason) {
|
|
3310
3868
|
console.log(colors_default.yellow(`${reason} Requesting sudo...`));
|
|
3311
|
-
const result =
|
|
3869
|
+
const result = spawnSync5("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
|
|
3312
3870
|
stdio: "inherit",
|
|
3313
3871
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3314
3872
|
});
|
|
3315
3873
|
return result.status === 0;
|
|
3316
3874
|
}
|
|
3875
|
+
function isEnabledEnv(value) {
|
|
3876
|
+
return value === "1" || value === "true";
|
|
3877
|
+
}
|
|
3878
|
+
function formatProcessExitSuffix(code, signal) {
|
|
3879
|
+
if (signal) return ` (signal ${signal})`;
|
|
3880
|
+
if (code !== null) return ` (exit ${code})`;
|
|
3881
|
+
return "";
|
|
3882
|
+
}
|
|
3317
3883
|
function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
3318
3884
|
store.ensureDir();
|
|
3319
3885
|
const isTls = !!tlsOptions;
|
|
@@ -3652,6 +4218,9 @@ function listRoutes(store, proxyPort, tls2) {
|
|
|
3652
4218
|
const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
|
|
3653
4219
|
console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
|
|
3654
4220
|
}
|
|
4221
|
+
if (route.ngrokUrl) {
|
|
4222
|
+
console.log(` ${colors_default.gray("ngrok:")} ${colors_default.green(route.ngrokUrl)}`);
|
|
4223
|
+
}
|
|
3655
4224
|
}
|
|
3656
4225
|
console.log();
|
|
3657
4226
|
}
|
|
@@ -3735,7 +4304,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
|
|
|
3735
4304
|
proxyPort: startPort
|
|
3736
4305
|
});
|
|
3737
4306
|
const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
|
|
3738
|
-
const result =
|
|
4307
|
+
const result = spawnSync5(process.execPath, startArgs, {
|
|
3739
4308
|
stdio: "inherit",
|
|
3740
4309
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3741
4310
|
});
|
|
@@ -3769,8 +4338,9 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
|
|
|
3769
4338
|
console.log(chalk.blue.bold(`
|
|
3770
4339
|
portless
|
|
3771
4340
|
`));
|
|
3772
|
-
const wantsFunnel = process.env.PORTLESS_FUNNEL
|
|
3773
|
-
const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE
|
|
4341
|
+
const wantsFunnel = isEnabledEnv(process.env.PORTLESS_FUNNEL);
|
|
4342
|
+
const wantsTailscale = wantsFunnel || isEnabledEnv(process.env.PORTLESS_TAILSCALE);
|
|
4343
|
+
const wantsNgrok = isEnabledEnv(process.env.PORTLESS_NGROK);
|
|
3774
4344
|
let tsBaseUrl;
|
|
3775
4345
|
if (wantsTailscale) {
|
|
3776
4346
|
try {
|
|
@@ -3791,6 +4361,18 @@ portless
|
|
|
3791
4361
|
process.exit(1);
|
|
3792
4362
|
}
|
|
3793
4363
|
}
|
|
4364
|
+
if (wantsNgrok) {
|
|
4365
|
+
try {
|
|
4366
|
+
ensureNgrokAvailable();
|
|
4367
|
+
} catch (err) {
|
|
4368
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4369
|
+
console.error(colors_default.red(`Error: ${message}`));
|
|
4370
|
+
if (message.includes("not found")) {
|
|
4371
|
+
console.error(colors_default.blue("Install ngrok: https://ngrok.com/download"));
|
|
4372
|
+
}
|
|
4373
|
+
process.exit(1);
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
3794
4376
|
let desired;
|
|
3795
4377
|
try {
|
|
3796
4378
|
desired = resolveProxyDesiredState(lanMode);
|
|
@@ -3876,6 +4458,36 @@ portless
|
|
|
3876
4458
|
}
|
|
3877
4459
|
let tailscaleHttpsPort;
|
|
3878
4460
|
let tailscaleUrl;
|
|
4461
|
+
let ngrokUrl;
|
|
4462
|
+
let ngrokProcess;
|
|
4463
|
+
let stoppingNgrok = false;
|
|
4464
|
+
let ngrokRouteReady = false;
|
|
4465
|
+
let ngrokExitHandled = false;
|
|
4466
|
+
let pendingNgrokExit;
|
|
4467
|
+
const handleNgrokExit = (code, signal) => {
|
|
4468
|
+
if (stoppingNgrok || ngrokExitHandled) return;
|
|
4469
|
+
if (!ngrokRouteReady) {
|
|
4470
|
+
pendingNgrokExit = { code, signal };
|
|
4471
|
+
return;
|
|
4472
|
+
}
|
|
4473
|
+
ngrokExitHandled = true;
|
|
4474
|
+
ngrokUrl = void 0;
|
|
4475
|
+
console.warn(
|
|
4476
|
+
colors_default.yellow(
|
|
4477
|
+
`Warning: ngrok tunnel for ${hostname} stopped${formatProcessExitSuffix(
|
|
4478
|
+
code,
|
|
4479
|
+
signal
|
|
4480
|
+
)}. Removing its public URL from the route list.`
|
|
4481
|
+
)
|
|
4482
|
+
);
|
|
4483
|
+
try {
|
|
4484
|
+
store.updateRoute(hostname, {
|
|
4485
|
+
ngrokUrl: null,
|
|
4486
|
+
ngrokPid: null
|
|
4487
|
+
});
|
|
4488
|
+
} catch {
|
|
4489
|
+
}
|
|
4490
|
+
};
|
|
3879
4491
|
if (wantsTailscale && tsBaseUrl) {
|
|
3880
4492
|
const maxAttempts = 3;
|
|
3881
4493
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -3913,6 +4525,51 @@ portless
|
|
|
3913
4525
|
} catch {
|
|
3914
4526
|
}
|
|
3915
4527
|
}
|
|
4528
|
+
if (wantsNgrok) {
|
|
4529
|
+
try {
|
|
4530
|
+
ngrokProcess = await startNgrok(port, {
|
|
4531
|
+
hostHeader: hostname,
|
|
4532
|
+
onExit: handleNgrokExit
|
|
4533
|
+
});
|
|
4534
|
+
ngrokUrl = ngrokProcess.url;
|
|
4535
|
+
console.log(chalk.green(` ngrok -> ${ngrokUrl}`));
|
|
4536
|
+
console.log(chalk.gray(" (accessible from the public internet via ngrok)\n"));
|
|
4537
|
+
try {
|
|
4538
|
+
store.updateRoute(hostname, {
|
|
4539
|
+
ngrokUrl,
|
|
4540
|
+
ngrokPid: ngrokProcess.pid
|
|
4541
|
+
});
|
|
4542
|
+
} catch {
|
|
4543
|
+
} finally {
|
|
4544
|
+
ngrokRouteReady = true;
|
|
4545
|
+
if (pendingNgrokExit) {
|
|
4546
|
+
handleNgrokExit(pendingNgrokExit.code, pendingNgrokExit.signal);
|
|
4547
|
+
pendingNgrokExit = void 0;
|
|
4548
|
+
}
|
|
4549
|
+
}
|
|
4550
|
+
} catch (err) {
|
|
4551
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4552
|
+
console.error(colors_default.red(`Error: ${message}`));
|
|
4553
|
+
if (message.includes("not found")) {
|
|
4554
|
+
console.error(colors_default.blue("Install ngrok: https://ngrok.com/download"));
|
|
4555
|
+
} else if (message.includes("authentication")) {
|
|
4556
|
+
console.error(colors_default.blue("Configure ngrok authentication:"));
|
|
4557
|
+
console.error(colors_default.cyan(" ngrok config add-authtoken <token>"));
|
|
4558
|
+
}
|
|
4559
|
+
try {
|
|
4560
|
+
unregisterTailscale({
|
|
4561
|
+
tailscaleHttpsPort,
|
|
4562
|
+
tailscaleFunnel: wantsFunnel || void 0
|
|
4563
|
+
});
|
|
4564
|
+
} catch {
|
|
4565
|
+
}
|
|
4566
|
+
try {
|
|
4567
|
+
store.removeRoute(hostname);
|
|
4568
|
+
} catch {
|
|
4569
|
+
}
|
|
4570
|
+
process.exit(1);
|
|
4571
|
+
}
|
|
4572
|
+
}
|
|
3916
4573
|
const basename5 = path9.basename(commandArgs[0]);
|
|
3917
4574
|
const isExpo = basename5 === "expo";
|
|
3918
4575
|
const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
|
|
@@ -3947,9 +4604,12 @@ portless
|
|
|
3947
4604
|
// own LAN discovery natively.
|
|
3948
4605
|
...lanMode ? { PORTLESS_LAN: "1" } : {},
|
|
3949
4606
|
...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
|
|
4607
|
+
...ngrokUrl ? { PORTLESS_NGROK_URL: ngrokUrl } : {},
|
|
3950
4608
|
...caEnv
|
|
3951
4609
|
},
|
|
3952
4610
|
onCleanup: () => {
|
|
4611
|
+
stoppingNgrok = true;
|
|
4612
|
+
stopNgrokProcess(ngrokProcess?.child);
|
|
3953
4613
|
try {
|
|
3954
4614
|
unregisterTailscale({
|
|
3955
4615
|
tailscaleHttpsPort,
|
|
@@ -3986,7 +4646,7 @@ function appPortFromEnv() {
|
|
|
3986
4646
|
}
|
|
3987
4647
|
return port;
|
|
3988
4648
|
}
|
|
3989
|
-
function
|
|
4649
|
+
function applySharingFlag(flag) {
|
|
3990
4650
|
if (flag === "--tailscale") {
|
|
3991
4651
|
process.env.PORTLESS_TAILSCALE = "1";
|
|
3992
4652
|
return true;
|
|
@@ -3996,6 +4656,10 @@ function applyTailscaleFlag(flag) {
|
|
|
3996
4656
|
process.env.PORTLESS_TAILSCALE = "1";
|
|
3997
4657
|
return true;
|
|
3998
4658
|
}
|
|
4659
|
+
if (flag === "--ngrok") {
|
|
4660
|
+
process.env.PORTLESS_NGROK = "1";
|
|
4661
|
+
return true;
|
|
4662
|
+
}
|
|
3999
4663
|
return false;
|
|
4000
4664
|
}
|
|
4001
4665
|
function parseRunArgs(args) {
|
|
@@ -4021,6 +4685,9 @@ ${colors_default.bold("Options:")}
|
|
|
4021
4685
|
--name <name> Override the inferred base name (worktree prefix still applies)
|
|
4022
4686
|
--force Kill the existing process and take over its route
|
|
4023
4687
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
4688
|
+
--tailscale Share the app on your Tailscale network (tailnet)
|
|
4689
|
+
--funnel Share the app publicly via Tailscale Funnel
|
|
4690
|
+
--ngrok Share the app publicly via ngrok
|
|
4024
4691
|
--help, -h Show this help
|
|
4025
4692
|
|
|
4026
4693
|
${colors_default.bold("Name inference (in order):")}
|
|
@@ -4054,11 +4721,13 @@ ${colors_default.bold("Examples:")}
|
|
|
4054
4721
|
process.exit(1);
|
|
4055
4722
|
}
|
|
4056
4723
|
name = args[i];
|
|
4057
|
-
} else if (
|
|
4724
|
+
} else if (applySharingFlag(args[i])) {
|
|
4058
4725
|
} else {
|
|
4059
4726
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
4060
4727
|
console.error(
|
|
4061
|
-
colors_default.blue(
|
|
4728
|
+
colors_default.blue(
|
|
4729
|
+
"Known flags: --name, --force, --app-port, --tailscale, --funnel, --ngrok, --help"
|
|
4730
|
+
)
|
|
4062
4731
|
);
|
|
4063
4732
|
process.exit(1);
|
|
4064
4733
|
}
|
|
@@ -4080,10 +4749,12 @@ function parseAppArgs(args) {
|
|
|
4080
4749
|
} else if (args[i] === "--app-port") {
|
|
4081
4750
|
i++;
|
|
4082
4751
|
appPort = parseAppPort(args[i]);
|
|
4083
|
-
} else if (
|
|
4752
|
+
} else if (applySharingFlag(args[i])) {
|
|
4084
4753
|
} else {
|
|
4085
4754
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
4086
|
-
console.error(
|
|
4755
|
+
console.error(
|
|
4756
|
+
colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok")
|
|
4757
|
+
);
|
|
4087
4758
|
process.exit(1);
|
|
4088
4759
|
}
|
|
4089
4760
|
i++;
|
|
@@ -4099,10 +4770,12 @@ function parseAppArgs(args) {
|
|
|
4099
4770
|
} else if (args[i] === "--app-port") {
|
|
4100
4771
|
i++;
|
|
4101
4772
|
appPort = parseAppPort(args[i]);
|
|
4102
|
-
} else if (
|
|
4773
|
+
} else if (applySharingFlag(args[i])) {
|
|
4103
4774
|
} else {
|
|
4104
4775
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
4105
|
-
console.error(
|
|
4776
|
+
console.error(
|
|
4777
|
+
colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok")
|
|
4778
|
+
);
|
|
4106
4779
|
process.exit(1);
|
|
4107
4780
|
}
|
|
4108
4781
|
i++;
|
|
@@ -4121,6 +4794,9 @@ ${colors_default.bold("Install:")}
|
|
|
4121
4794
|
${colors_default.cyan("npm install -g portless")} Global (recommended)
|
|
4122
4795
|
${colors_default.cyan("npm install -D portless")} Project dev dependency
|
|
4123
4796
|
|
|
4797
|
+
${colors_default.bold("Requirements:")}
|
|
4798
|
+
Node.js 24+
|
|
4799
|
+
|
|
4124
4800
|
${colors_default.bold("Usage:")}
|
|
4125
4801
|
${colors_default.cyan("portless")} Run dev script through proxy
|
|
4126
4802
|
${colors_default.cyan("portless")} From monorepo root: run all workspace packages
|
|
@@ -4148,9 +4824,12 @@ ${colors_default.bold("Examples:")}
|
|
|
4148
4824
|
portless run next dev # -> https://<project>.localhost
|
|
4149
4825
|
portless run next dev # in worktree -> https://<worktree>.<project>.localhost
|
|
4150
4826
|
portless service install # Start HTTPS proxy on OS startup
|
|
4827
|
+
portless service install --lan # Persist LAN mode in the startup service
|
|
4828
|
+
portless service install --wildcard # Persist wildcard routing in the startup service
|
|
4151
4829
|
portless get backend # -> https://backend.localhost
|
|
4152
4830
|
portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
|
|
4153
4831
|
portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
|
|
4832
|
+
portless myapp --ngrok next dev # -> also https://<random>.ngrok.app (public)
|
|
4154
4833
|
|
|
4155
4834
|
${colors_default.bold("Configuration (portless.json):")}
|
|
4156
4835
|
Optional. Portless works out of the box by running the "dev" script
|
|
@@ -4212,6 +4891,11 @@ ${colors_default.bold("Tailscale sharing:")}
|
|
|
4212
4891
|
${colors_default.cyan("portless myapp --tailscale next dev")}
|
|
4213
4892
|
${colors_default.cyan("portless myapp --funnel next dev")}
|
|
4214
4893
|
|
|
4894
|
+
${colors_default.bold("ngrok sharing:")}
|
|
4895
|
+
Use --ngrok to expose your dev server to the public internet with ngrok.
|
|
4896
|
+
Requires the ngrok CLI to be installed and authenticated.
|
|
4897
|
+
${colors_default.cyan("portless myapp --ngrok next dev")}
|
|
4898
|
+
|
|
4215
4899
|
${colors_default.bold("Options:")}
|
|
4216
4900
|
run [--name <name>] <cmd> Infer project name (or override with --name)
|
|
4217
4901
|
Adds worktree prefix in git worktrees
|
|
@@ -4227,9 +4911,11 @@ ${colors_default.bold("Options:")}
|
|
|
4227
4911
|
--foreground Run proxy in foreground (for debugging)
|
|
4228
4912
|
--tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
|
|
4229
4913
|
--wildcard Allow unregistered subdomains to fall back to parent route
|
|
4914
|
+
--state-dir <path> Use a custom state directory with service install
|
|
4230
4915
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
4231
4916
|
--tailscale Share the app on your Tailscale network (tailnet)
|
|
4232
4917
|
--funnel Share the app publicly via Tailscale Funnel
|
|
4918
|
+
--ngrok Share the app publicly via ngrok
|
|
4233
4919
|
--force Kill the existing process and take over its route
|
|
4234
4920
|
--name <name> Use <name> as the app name (bypasses subcommand dispatch)
|
|
4235
4921
|
-- Stop flag parsing; everything after is passed to the child
|
|
@@ -4239,11 +4925,13 @@ ${colors_default.bold("Environment variables:")}
|
|
|
4239
4925
|
PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
|
|
4240
4926
|
PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
|
|
4241
4927
|
PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
|
|
4928
|
+
PORTLESS_LAN_IP=<address> Pin a specific LAN IP for LAN mode
|
|
4242
4929
|
PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
|
|
4243
4930
|
PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
|
|
4244
4931
|
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
|
|
4245
4932
|
PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
|
|
4246
4933
|
PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
|
|
4934
|
+
PORTLESS_NGROK=1 Share apps publicly via ngrok (same as --ngrok)
|
|
4247
4935
|
PORTLESS_STATE_DIR=<path> Override the state directory
|
|
4248
4936
|
PORTLESS=0 Run command directly without proxy
|
|
4249
4937
|
|
|
@@ -4253,6 +4941,7 @@ ${colors_default.bold("Child process environment:")}
|
|
|
4253
4941
|
PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
|
|
4254
4942
|
PORTLESS_LAN Set to 1 when proxy is in LAN mode
|
|
4255
4943
|
PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
|
|
4944
|
+
PORTLESS_NGROK_URL ngrok URL of the app (when --ngrok is active)
|
|
4256
4945
|
NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
|
|
4257
4946
|
|
|
4258
4947
|
${colors_default.bold("Safari / DNS:")}
|
|
@@ -4275,7 +4964,7 @@ ${colors_default.bold("Reserved names:")}
|
|
|
4275
4964
|
process.exit(0);
|
|
4276
4965
|
}
|
|
4277
4966
|
function printVersion() {
|
|
4278
|
-
console.log("0.
|
|
4967
|
+
console.log("0.14.0");
|
|
4279
4968
|
process.exit(0);
|
|
4280
4969
|
}
|
|
4281
4970
|
async function handleTrust() {
|
|
@@ -4296,7 +4985,7 @@ async function handleTrust() {
|
|
|
4296
4985
|
const isPermissionError2 = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
|
|
4297
4986
|
if (isPermissionError2 && !isWindows && process.getuid?.() !== 0) {
|
|
4298
4987
|
console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
|
|
4299
|
-
const sudoResult =
|
|
4988
|
+
const sudoResult = spawnSync5(
|
|
4300
4989
|
"sudo",
|
|
4301
4990
|
[
|
|
4302
4991
|
"env",
|
|
@@ -4379,6 +5068,10 @@ ${colors_default.bold("Options:")}
|
|
|
4379
5068
|
} catch {
|
|
4380
5069
|
}
|
|
4381
5070
|
}
|
|
5071
|
+
if (route.ngrokPid) {
|
|
5072
|
+
stopNgrok(route);
|
|
5073
|
+
console.log(colors_default.green(`Stopped ngrok tunnel for ${route.hostname}.`));
|
|
5074
|
+
}
|
|
4382
5075
|
}
|
|
4383
5076
|
const stateDirs = collectStateDirsForCleanup();
|
|
4384
5077
|
for (const stateDir of stateDirs) {
|
|
@@ -4458,6 +5151,10 @@ ${colors_default.bold("Options:")}
|
|
|
4458
5151
|
} catch {
|
|
4459
5152
|
}
|
|
4460
5153
|
}
|
|
5154
|
+
if (route.ngrokPid) {
|
|
5155
|
+
stopNgrok(route);
|
|
5156
|
+
console.log(` ${route.hostname} - stopped ngrok tunnel`);
|
|
5157
|
+
}
|
|
4461
5158
|
}
|
|
4462
5159
|
let killed = 0;
|
|
4463
5160
|
for (const route of stale) {
|
|
@@ -4636,7 +5333,7 @@ ${colors_default.bold("Auto-sync:")}
|
|
|
4636
5333
|
`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
|
|
4637
5334
|
)
|
|
4638
5335
|
);
|
|
4639
|
-
const result =
|
|
5336
|
+
const result = spawnSync5(
|
|
4640
5337
|
"sudo",
|
|
4641
5338
|
["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
|
|
4642
5339
|
{
|
|
@@ -4689,7 +5386,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
|
|
|
4689
5386
|
console.log(
|
|
4690
5387
|
colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
|
|
4691
5388
|
);
|
|
4692
|
-
const result =
|
|
5389
|
+
const result = spawnSync5(
|
|
4693
5390
|
"sudo",
|
|
4694
5391
|
["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
|
|
4695
5392
|
{
|
|
@@ -4980,7 +5677,7 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4980
5677
|
if (!hasExplicitPort) {
|
|
4981
5678
|
console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
|
|
4982
5679
|
}
|
|
4983
|
-
const result =
|
|
5680
|
+
const result = spawnSync5("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
|
|
4984
5681
|
stdio: "inherit",
|
|
4985
5682
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
4986
5683
|
});
|
|
@@ -5120,7 +5817,7 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
5120
5817
|
skipTrust: true
|
|
5121
5818
|
}).args
|
|
5122
5819
|
];
|
|
5123
|
-
const child =
|
|
5820
|
+
const child = spawn4(process.execPath, daemonArgs, {
|
|
5124
5821
|
detached: true,
|
|
5125
5822
|
stdio: ["ignore", logFd, logFd],
|
|
5126
5823
|
env: process.env,
|
|
@@ -5226,7 +5923,7 @@ async function handleDefaultSingle(cwd, scriptName, appConfig) {
|
|
|
5226
5923
|
);
|
|
5227
5924
|
}
|
|
5228
5925
|
function spawnChildProcess(commandArgs, env, cwd) {
|
|
5229
|
-
return
|
|
5926
|
+
return spawn4(commandArgs[0], commandArgs.slice(1), {
|
|
5230
5927
|
stdio: ["ignore", "pipe", "pipe"],
|
|
5231
5928
|
env,
|
|
5232
5929
|
cwd,
|
|
@@ -5497,7 +6194,7 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
|
|
|
5497
6194
|
const pm = detectPackageManager(wsRoot);
|
|
5498
6195
|
const useRootScript = hasScript(scriptName, wsRoot);
|
|
5499
6196
|
const turboArgs = useRootScript ? [pm, "run", scriptName, ...extraArgs] : pm === "npm" ? ["npx", "turbo", "run", scriptName, ...extraArgs] : pm === "bun" ? ["bunx", "turbo", "run", scriptName, ...extraArgs] : [pm, "exec", "turbo", "run", scriptName, ...extraArgs];
|
|
5500
|
-
const turboChild =
|
|
6197
|
+
const turboChild = spawn4(turboArgs[0], turboArgs.slice(1), {
|
|
5501
6198
|
stdio: "inherit",
|
|
5502
6199
|
cwd: wsRoot,
|
|
5503
6200
|
env: {
|
|
@@ -5527,8 +6224,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
|
|
|
5527
6224
|
};
|
|
5528
6225
|
process.on("SIGINT", cleanup);
|
|
5529
6226
|
process.on("SIGTERM", cleanup);
|
|
5530
|
-
const exitCode = await new Promise((
|
|
5531
|
-
turboChild.on("exit", (code) =>
|
|
6227
|
+
const exitCode = await new Promise((resolve4) => {
|
|
6228
|
+
turboChild.on("exit", (code) => resolve4(code));
|
|
5532
6229
|
});
|
|
5533
6230
|
cleanup();
|
|
5534
6231
|
if (exitCode !== 0 && exitCode !== null) {
|
|
@@ -5592,8 +6289,8 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
|
|
|
5592
6289
|
process.on("SIGTERM", cleanup);
|
|
5593
6290
|
await Promise.all(
|
|
5594
6291
|
children.map(
|
|
5595
|
-
(child) => new Promise((
|
|
5596
|
-
child.on("exit", () =>
|
|
6292
|
+
(child) => new Promise((resolve4) => {
|
|
6293
|
+
child.on("exit", () => resolve4());
|
|
5597
6294
|
})
|
|
5598
6295
|
)
|
|
5599
6296
|
);
|
|
@@ -5680,6 +6377,7 @@ async function handleNamedMode(args) {
|
|
|
5680
6377
|
}
|
|
5681
6378
|
}
|
|
5682
6379
|
const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
|
|
6380
|
+
parseHostname(safeName, DEFAULT_TLD);
|
|
5683
6381
|
const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
|
|
5684
6382
|
const store = new RouteStore(dir, {
|
|
5685
6383
|
onWarning: (msg) => console.warn(colors_default.yellow(msg))
|
|
@@ -5759,6 +6457,9 @@ async function main() {
|
|
|
5759
6457
|
process.env.PORTLESS_FUNNEL = "1";
|
|
5760
6458
|
process.env.PORTLESS_TAILSCALE = "1";
|
|
5761
6459
|
}
|
|
6460
|
+
if (stripGlobalFlag("--ngrok", false)) {
|
|
6461
|
+
process.env.PORTLESS_NGROK = "1";
|
|
6462
|
+
}
|
|
5762
6463
|
const scriptResult = stripGlobalFlag("--script", true);
|
|
5763
6464
|
if (scriptResult === false) {
|
|
5764
6465
|
console.error(colors_default.red("Error: --script requires a script name."));
|