portless 0.12.0 → 0.13.1
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 +33 -3
- package/dist/cli.js +1124 -101
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -39,9 +39,9 @@ var gray = dim;
|
|
|
39
39
|
var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
|
|
40
40
|
|
|
41
41
|
// src/cli.ts
|
|
42
|
-
import * as
|
|
43
|
-
import * as
|
|
44
|
-
import { spawn as spawn3, spawnSync as
|
|
42
|
+
import * as fs9 from "fs";
|
|
43
|
+
import * as path9 from "path";
|
|
44
|
+
import { spawn as spawn3, spawnSync as spawnSync4 } from "child_process";
|
|
45
45
|
import { StringDecoder } from "string_decoder";
|
|
46
46
|
|
|
47
47
|
// src/certs.ts
|
|
@@ -757,10 +757,15 @@ function untrustCAWindows(caCertPath) {
|
|
|
757
757
|
// src/tailscale.ts
|
|
758
758
|
import { spawnSync } from "child_process";
|
|
759
759
|
var TAILSCALE_BINARY = "tailscale";
|
|
760
|
+
var TAILSCALE_COMMAND_TIMEOUT_MS = 3e4;
|
|
760
761
|
var PREFERRED_SERVE_PORTS = [443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450];
|
|
761
762
|
var FUNNEL_PORTS = [443, 8443, 1e4];
|
|
762
763
|
function defaultRunner(args) {
|
|
763
|
-
const result = spawnSync(TAILSCALE_BINARY, args, {
|
|
764
|
+
const result = spawnSync(TAILSCALE_BINARY, args, {
|
|
765
|
+
encoding: "utf-8",
|
|
766
|
+
killSignal: "SIGKILL",
|
|
767
|
+
timeout: TAILSCALE_COMMAND_TIMEOUT_MS
|
|
768
|
+
});
|
|
764
769
|
return {
|
|
765
770
|
status: result.status,
|
|
766
771
|
stdout: result.stdout ?? "",
|
|
@@ -812,11 +817,52 @@ function statusToDnsName(status) {
|
|
|
812
817
|
"Could not determine Tailscale node DNS name from `tailscale status --json`. Is Tailscale connected?"
|
|
813
818
|
);
|
|
814
819
|
}
|
|
815
|
-
function
|
|
820
|
+
function isFunnelCapability(value) {
|
|
821
|
+
const normalized = value.toLowerCase();
|
|
822
|
+
return normalized === "funnel" || normalized.endsWith("/funnel");
|
|
823
|
+
}
|
|
824
|
+
function isHttpsCapability(value) {
|
|
825
|
+
const normalized = value.toLowerCase();
|
|
826
|
+
return normalized === "https" || normalized.endsWith("/https");
|
|
827
|
+
}
|
|
828
|
+
function hasCapability(status, predicate) {
|
|
829
|
+
const capabilities = status.Self?.Capabilities;
|
|
830
|
+
if (Array.isArray(capabilities) && capabilities.some(predicate)) {
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
const capMap = status.Self?.CapMap;
|
|
834
|
+
return Boolean(capMap && Object.keys(capMap).some(predicate));
|
|
835
|
+
}
|
|
836
|
+
function hasHttpsCapability(status) {
|
|
837
|
+
return hasCapability(status, isHttpsCapability);
|
|
838
|
+
}
|
|
839
|
+
function hasFunnelCapability(status) {
|
|
840
|
+
return hasCapability(status, isFunnelCapability);
|
|
841
|
+
}
|
|
842
|
+
function throwHttpsNotEnabled() {
|
|
843
|
+
throw new Error(
|
|
844
|
+
"Tailscale HTTPS is not enabled on your tailnet. Enable HTTPS certificates in Tailscale DNS settings, then run portless again."
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
function throwFunnelNotEnabled(status) {
|
|
848
|
+
const nodeId = status.Self?.ID;
|
|
849
|
+
const enableUrl = typeof nodeId === "string" && nodeId.length > 0 ? ` Visit https://login.tailscale.com/f/funnel?node=${nodeId} to enable it.` : "";
|
|
850
|
+
throw new Error(
|
|
851
|
+
"Tailscale Funnel is not enabled on your tailnet. Enable Funnel for this node, then run portless again." + enableUrl
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
function ensureTailscaleReady(options = {}) {
|
|
855
|
+
const runner = options.runner ?? defaultRunner;
|
|
816
856
|
runOrThrow(["version"], "check tailscale version", runner);
|
|
817
857
|
const statusResult = runOrThrow(["status", "--json"], "read tailscale status", runner);
|
|
818
858
|
const status = parseStatusJson(statusResult.stdout);
|
|
819
859
|
const dnsName = statusToDnsName(status);
|
|
860
|
+
if (options.requireHttps && !hasHttpsCapability(status)) {
|
|
861
|
+
throwHttpsNotEnabled();
|
|
862
|
+
}
|
|
863
|
+
if (options.requireFunnel && !hasFunnelCapability(status)) {
|
|
864
|
+
throwFunnelNotEnabled(status);
|
|
865
|
+
}
|
|
820
866
|
return {
|
|
821
867
|
dnsName,
|
|
822
868
|
baseUrl: `https://${dnsName}`
|
|
@@ -868,6 +914,16 @@ function isConflictError(stderr, stdout) {
|
|
|
868
914
|
${stdout}`.toLowerCase();
|
|
869
915
|
return text.includes("already in use") || text.includes("already exists") || text.includes("port conflict") || text.includes("address already");
|
|
870
916
|
}
|
|
917
|
+
function isFunnelNotEnabledError(stderr, stdout) {
|
|
918
|
+
const text = `${stderr}
|
|
919
|
+
${stdout}`.toLowerCase();
|
|
920
|
+
return text.includes("funnel is not enabled on your tailnet");
|
|
921
|
+
}
|
|
922
|
+
function formatFunnelNotEnabledError(stderr, stdout) {
|
|
923
|
+
const details = normalizeSpace(`${stderr}
|
|
924
|
+
${stdout}`);
|
|
925
|
+
return "Tailscale Funnel is not enabled on your tailnet. Enable Funnel for this node, then run portless again." + (details ? ` Tailscale said: ${details}` : "");
|
|
926
|
+
}
|
|
871
927
|
var CONFLICT_MESSAGES = {
|
|
872
928
|
serve: "Stop the existing serve or let portless auto-assign a different port.",
|
|
873
929
|
funnel: "Tailscale Funnel supports ports 443, 8443, and 10000."
|
|
@@ -882,9 +938,20 @@ function register(mode, localPort, httpsPort, runner) {
|
|
|
882
938
|
"Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
|
|
883
939
|
);
|
|
884
940
|
}
|
|
941
|
+
if (mode === "funnel" && isFunnelNotEnabledError(result.stderr, result.stdout)) {
|
|
942
|
+
throw new Error(formatFunnelNotEnabledError(result.stderr, result.stdout));
|
|
943
|
+
}
|
|
944
|
+
if (mode === "funnel" && errno.code === "ETIMEDOUT") {
|
|
945
|
+
throw new Error(
|
|
946
|
+
"Tailscale Funnel registration timed out. Make sure Funnel is enabled on your tailnet, then run portless again."
|
|
947
|
+
);
|
|
948
|
+
}
|
|
885
949
|
throw new Error(`Failed to register tailscale ${mode}: ${result.error.message}`);
|
|
886
950
|
}
|
|
887
951
|
if (result.status !== 0) {
|
|
952
|
+
if (mode === "funnel" && isFunnelNotEnabledError(result.stderr, result.stdout)) {
|
|
953
|
+
throw new Error(formatFunnelNotEnabledError(result.stderr, result.stdout));
|
|
954
|
+
}
|
|
888
955
|
if (isConflictError(result.stderr, result.stdout)) {
|
|
889
956
|
throw new Error(
|
|
890
957
|
`Tailscale ${mode === "funnel" ? "Funnel " : ""}HTTPS port ${httpsPort} is already in use. ` + CONFLICT_MESSAGES[mode]
|
|
@@ -1489,12 +1556,12 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
|
|
|
1489
1556
|
throw new Error(`minPort (${minPort}) must be <= maxPort (${maxPort})`);
|
|
1490
1557
|
}
|
|
1491
1558
|
const tryPort = (port) => {
|
|
1492
|
-
return new Promise((
|
|
1559
|
+
return new Promise((resolve4) => {
|
|
1493
1560
|
const server = net.createServer();
|
|
1494
1561
|
server.listen(port, () => {
|
|
1495
|
-
server.close(() =>
|
|
1562
|
+
server.close(() => resolve4(true));
|
|
1496
1563
|
});
|
|
1497
|
-
server.on("error", () =>
|
|
1564
|
+
server.on("error", () => resolve4(false));
|
|
1498
1565
|
});
|
|
1499
1566
|
};
|
|
1500
1567
|
for (let i = 0; i < RANDOM_PORT_ATTEMPTS; i++) {
|
|
@@ -1511,7 +1578,7 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
|
|
|
1511
1578
|
throw new Error(`No free port found in range ${minPort}-${maxPort}`);
|
|
1512
1579
|
}
|
|
1513
1580
|
function isProxyRunning(port, tls2 = false) {
|
|
1514
|
-
return new Promise((
|
|
1581
|
+
return new Promise((resolve4) => {
|
|
1515
1582
|
const requestFn = tls2 ? https.request : http.request;
|
|
1516
1583
|
const req = requestFn(
|
|
1517
1584
|
{
|
|
@@ -1524,26 +1591,26 @@ function isProxyRunning(port, tls2 = false) {
|
|
|
1524
1591
|
},
|
|
1525
1592
|
(res) => {
|
|
1526
1593
|
res.resume();
|
|
1527
|
-
|
|
1594
|
+
resolve4(res.headers[PORTLESS_HEADER.toLowerCase()] === "1");
|
|
1528
1595
|
}
|
|
1529
1596
|
);
|
|
1530
|
-
req.on("error", () =>
|
|
1597
|
+
req.on("error", () => resolve4(false));
|
|
1531
1598
|
req.on("timeout", () => {
|
|
1532
1599
|
req.destroy();
|
|
1533
|
-
|
|
1600
|
+
resolve4(false);
|
|
1534
1601
|
});
|
|
1535
1602
|
req.end();
|
|
1536
1603
|
});
|
|
1537
1604
|
}
|
|
1538
1605
|
function isPortListening(port) {
|
|
1539
|
-
return new Promise((
|
|
1606
|
+
return new Promise((resolve4) => {
|
|
1540
1607
|
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
1541
1608
|
let settled = false;
|
|
1542
1609
|
const finish = (result) => {
|
|
1543
1610
|
if (settled) return;
|
|
1544
1611
|
settled = true;
|
|
1545
1612
|
socket.destroy();
|
|
1546
|
-
|
|
1613
|
+
resolve4(result);
|
|
1547
1614
|
};
|
|
1548
1615
|
socket.setTimeout(SOCKET_TIMEOUT_MS);
|
|
1549
1616
|
socket.once("connect", () => finish(true));
|
|
@@ -1607,7 +1674,7 @@ function findPidOnPort(port) {
|
|
|
1607
1674
|
}
|
|
1608
1675
|
async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
|
|
1609
1676
|
for (let i = 0; i < maxAttempts; i++) {
|
|
1610
|
-
await new Promise((
|
|
1677
|
+
await new Promise((resolve4) => setTimeout(resolve4, intervalMs));
|
|
1611
1678
|
if (await isProxyRunning(port, tls2)) {
|
|
1612
1679
|
return true;
|
|
1613
1680
|
}
|
|
@@ -1831,7 +1898,7 @@ function isInternalInterface(iname, macStr, internal) {
|
|
|
1831
1898
|
return false;
|
|
1832
1899
|
}
|
|
1833
1900
|
function probeDefaultRouteIPv4() {
|
|
1834
|
-
return new Promise((
|
|
1901
|
+
return new Promise((resolve4, reject) => {
|
|
1835
1902
|
const socket = createSocket({ type: "udp4", reuseAddr: true });
|
|
1836
1903
|
socket.on("error", (error) => {
|
|
1837
1904
|
socket.close();
|
|
@@ -1843,7 +1910,7 @@ function probeDefaultRouteIPv4() {
|
|
|
1843
1910
|
socket.close();
|
|
1844
1911
|
socket.unref();
|
|
1845
1912
|
if (addr && "address" in addr && addr.address && addr.address !== NO_ROUTE_IP) {
|
|
1846
|
-
|
|
1913
|
+
resolve4(addr.address);
|
|
1847
1914
|
} else {
|
|
1848
1915
|
reject(new Error("No route to host"));
|
|
1849
1916
|
}
|
|
@@ -2524,6 +2591,908 @@ function hasTurboConfig(wsRoot) {
|
|
|
2524
2591
|
}
|
|
2525
2592
|
}
|
|
2526
2593
|
|
|
2594
|
+
// src/service.ts
|
|
2595
|
+
import * as fs8 from "fs";
|
|
2596
|
+
import * as os2 from "os";
|
|
2597
|
+
import * as path8 from "path";
|
|
2598
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2599
|
+
var DEFAULT_SERVICE_PORT = getProtocolPort(true);
|
|
2600
|
+
var SERVICE_LABEL = "sh.portless.proxy";
|
|
2601
|
+
var SYSTEMD_SERVICE = "portless.service";
|
|
2602
|
+
var WINDOWS_TASK_NAME = "Portless Proxy";
|
|
2603
|
+
var INTERNAL_ELEVATED_ENV = "PORTLESS_INTERNAL_SERVICE_ELEVATED";
|
|
2604
|
+
var SERVICE_ENV_KEYS = /* @__PURE__ */ new Set(["PORTLESS_SYNC_HOSTS"]);
|
|
2605
|
+
var DEFAULT_SERVICE_CONFIG = {
|
|
2606
|
+
proxyPort: DEFAULT_SERVICE_PORT,
|
|
2607
|
+
useHttps: true,
|
|
2608
|
+
customCertPath: null,
|
|
2609
|
+
customKeyPath: null,
|
|
2610
|
+
lanMode: false,
|
|
2611
|
+
lanIp: null,
|
|
2612
|
+
lanIpExplicit: false,
|
|
2613
|
+
tld: DEFAULT_TLD,
|
|
2614
|
+
useWildcard: false,
|
|
2615
|
+
extraEnv: {}
|
|
2616
|
+
};
|
|
2617
|
+
function defaultRunner2(command, args, options) {
|
|
2618
|
+
return spawnSync3(command, args, {
|
|
2619
|
+
encoding: "utf-8",
|
|
2620
|
+
stdio: options?.stdio ?? "pipe"
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
function isSupportedPlatform(platform) {
|
|
2624
|
+
return platform === "darwin" || platform === "linux" || platform === "win32";
|
|
2625
|
+
}
|
|
2626
|
+
function xmlEscape(value) {
|
|
2627
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2628
|
+
}
|
|
2629
|
+
function systemdEscape(value) {
|
|
2630
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
2631
|
+
}
|
|
2632
|
+
function windowsQuote(value) {
|
|
2633
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
2634
|
+
}
|
|
2635
|
+
function xmlUnescape(value) {
|
|
2636
|
+
return value.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
|
2637
|
+
}
|
|
2638
|
+
function parseBooleanEnv(value) {
|
|
2639
|
+
if (value === void 0) return null;
|
|
2640
|
+
if (value === "1" || value === "true") return true;
|
|
2641
|
+
if (value === "0" || value === "false") return false;
|
|
2642
|
+
return null;
|
|
2643
|
+
}
|
|
2644
|
+
function parsePortValue(value, source) {
|
|
2645
|
+
const port = parseInt(value, 10);
|
|
2646
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
2647
|
+
throw new Error(`${source} must be a number between 1 and 65535.`);
|
|
2648
|
+
}
|
|
2649
|
+
return port;
|
|
2650
|
+
}
|
|
2651
|
+
function getFlagValue(args, index, flag) {
|
|
2652
|
+
const value = args[index + 1];
|
|
2653
|
+
if (!value || value.startsWith("-")) {
|
|
2654
|
+
throw new Error(`${flag} requires a value.`);
|
|
2655
|
+
}
|
|
2656
|
+
return value;
|
|
2657
|
+
}
|
|
2658
|
+
function resolveServicePath(value) {
|
|
2659
|
+
const expanded = value === "~" ? os2.homedir() : value.startsWith("~/") || value.startsWith("~\\") ? path8.join(os2.homedir(), value.slice(2)) : value;
|
|
2660
|
+
return path8.resolve(expanded);
|
|
2661
|
+
}
|
|
2662
|
+
function normalizeServiceInstallPaths(config) {
|
|
2663
|
+
return {
|
|
2664
|
+
...config,
|
|
2665
|
+
stateDir: config.stateDir ? resolveServicePath(config.stateDir) : void 0,
|
|
2666
|
+
customCertPath: config.customCertPath ? resolveServicePath(config.customCertPath) : null,
|
|
2667
|
+
customKeyPath: config.customKeyPath ? resolveServicePath(config.customKeyPath) : null
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
function collectServiceExtraEnv(env) {
|
|
2671
|
+
const extraEnv = {};
|
|
2672
|
+
for (const key of SERVICE_ENV_KEYS) {
|
|
2673
|
+
const value = env[key];
|
|
2674
|
+
if (value) extraEnv[key] = value;
|
|
2675
|
+
}
|
|
2676
|
+
return extraEnv;
|
|
2677
|
+
}
|
|
2678
|
+
function parseServiceInstallConfig(args, env = process.env, options = {}) {
|
|
2679
|
+
const config = {
|
|
2680
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
2681
|
+
extraEnv: collectServiceExtraEnv(env)
|
|
2682
|
+
};
|
|
2683
|
+
if (env.PORTLESS_STATE_DIR) {
|
|
2684
|
+
config.stateDir = env.PORTLESS_STATE_DIR;
|
|
2685
|
+
}
|
|
2686
|
+
const envHttps = parseBooleanEnv(env.PORTLESS_HTTPS);
|
|
2687
|
+
if (envHttps !== null) {
|
|
2688
|
+
config.useHttps = envHttps;
|
|
2689
|
+
}
|
|
2690
|
+
const envLan = parseBooleanEnv(env.PORTLESS_LAN);
|
|
2691
|
+
if (envLan !== null) {
|
|
2692
|
+
config.lanMode = envLan;
|
|
2693
|
+
}
|
|
2694
|
+
if (env.PORTLESS_LAN_IP) {
|
|
2695
|
+
config.lanMode = true;
|
|
2696
|
+
config.lanIp = env.PORTLESS_LAN_IP;
|
|
2697
|
+
config.lanIpExplicit = true;
|
|
2698
|
+
}
|
|
2699
|
+
if (env.PORTLESS_TLD) {
|
|
2700
|
+
const tld = env.PORTLESS_TLD.trim().toLowerCase();
|
|
2701
|
+
const err = validateTld(tld);
|
|
2702
|
+
if (err) throw new Error(`PORTLESS_TLD: ${err}`);
|
|
2703
|
+
config.tld = tld;
|
|
2704
|
+
}
|
|
2705
|
+
const envWildcard = parseBooleanEnv(env.PORTLESS_WILDCARD);
|
|
2706
|
+
if (envWildcard !== null) {
|
|
2707
|
+
config.useWildcard = envWildcard;
|
|
2708
|
+
}
|
|
2709
|
+
if (env.PORTLESS_PORT) {
|
|
2710
|
+
config.proxyPort = parsePortValue(env.PORTLESS_PORT, "PORTLESS_PORT");
|
|
2711
|
+
} else {
|
|
2712
|
+
config.proxyPort = getProtocolPort(config.useHttps);
|
|
2713
|
+
}
|
|
2714
|
+
const tokens = args[0] === "service" ? args.slice(2) : args;
|
|
2715
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
2716
|
+
const token = tokens[i];
|
|
2717
|
+
switch (token) {
|
|
2718
|
+
case "-p":
|
|
2719
|
+
case "--port":
|
|
2720
|
+
config.proxyPort = parsePortValue(getFlagValue(tokens, i, token), token);
|
|
2721
|
+
i += 1;
|
|
2722
|
+
break;
|
|
2723
|
+
case "--https":
|
|
2724
|
+
config.useHttps = true;
|
|
2725
|
+
break;
|
|
2726
|
+
case "--no-tls":
|
|
2727
|
+
config.useHttps = false;
|
|
2728
|
+
break;
|
|
2729
|
+
case "--lan":
|
|
2730
|
+
config.lanMode = true;
|
|
2731
|
+
break;
|
|
2732
|
+
case "--ip":
|
|
2733
|
+
config.lanMode = true;
|
|
2734
|
+
config.lanIp = getFlagValue(tokens, i, token);
|
|
2735
|
+
config.lanIpExplicit = true;
|
|
2736
|
+
i += 1;
|
|
2737
|
+
break;
|
|
2738
|
+
case "--tld": {
|
|
2739
|
+
const tld = getFlagValue(tokens, i, token).trim().toLowerCase();
|
|
2740
|
+
const err = validateTld(tld);
|
|
2741
|
+
if (err) throw new Error(err);
|
|
2742
|
+
config.tld = tld;
|
|
2743
|
+
i += 1;
|
|
2744
|
+
break;
|
|
2745
|
+
}
|
|
2746
|
+
case "--wildcard":
|
|
2747
|
+
config.useWildcard = true;
|
|
2748
|
+
break;
|
|
2749
|
+
case "--cert":
|
|
2750
|
+
config.customCertPath = getFlagValue(tokens, i, token);
|
|
2751
|
+
config.useHttps = true;
|
|
2752
|
+
i += 1;
|
|
2753
|
+
break;
|
|
2754
|
+
case "--key":
|
|
2755
|
+
config.customKeyPath = getFlagValue(tokens, i, token);
|
|
2756
|
+
config.useHttps = true;
|
|
2757
|
+
i += 1;
|
|
2758
|
+
break;
|
|
2759
|
+
case "--state-dir":
|
|
2760
|
+
config.stateDir = getFlagValue(tokens, i, token);
|
|
2761
|
+
i += 1;
|
|
2762
|
+
break;
|
|
2763
|
+
case "--foreground":
|
|
2764
|
+
case "--skip-trust":
|
|
2765
|
+
if (!options.allowRuntimeFlags) {
|
|
2766
|
+
throw new Error(`Unknown service install option "${token}".`);
|
|
2767
|
+
}
|
|
2768
|
+
break;
|
|
2769
|
+
default:
|
|
2770
|
+
throw new Error(`Unknown service install option "${token}".`);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
if (config.customCertPath && !config.customKeyPath || !config.customCertPath && config.customKeyPath) {
|
|
2774
|
+
throw new Error("--cert and --key must be used together.");
|
|
2775
|
+
}
|
|
2776
|
+
if (!env.PORTLESS_PORT && !tokens.includes("--port") && !tokens.includes("-p")) {
|
|
2777
|
+
config.proxyPort = getProtocolPort(config.useHttps);
|
|
2778
|
+
}
|
|
2779
|
+
if (!config.lanMode) {
|
|
2780
|
+
config.lanIp = null;
|
|
2781
|
+
config.lanIpExplicit = false;
|
|
2782
|
+
}
|
|
2783
|
+
return config;
|
|
2784
|
+
}
|
|
2785
|
+
function readPasswdHome(username) {
|
|
2786
|
+
try {
|
|
2787
|
+
const passwd = fs8.readFileSync("/etc/passwd", "utf-8");
|
|
2788
|
+
for (const line of passwd.split("\n")) {
|
|
2789
|
+
const fields = line.split(":");
|
|
2790
|
+
if (fields[0] === username && fields[5]) {
|
|
2791
|
+
return fields[5];
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
} catch {
|
|
2795
|
+
}
|
|
2796
|
+
return null;
|
|
2797
|
+
}
|
|
2798
|
+
function resolveUserContext(platform) {
|
|
2799
|
+
if (platform === "win32") {
|
|
2800
|
+
const home = process.env.USERPROFILE || os2.homedir();
|
|
2801
|
+
return { home, username: process.env.USERNAME };
|
|
2802
|
+
}
|
|
2803
|
+
const sudoUser = process.env.SUDO_USER;
|
|
2804
|
+
const sudoUid = process.env.SUDO_UID;
|
|
2805
|
+
const sudoGid = process.env.SUDO_GID;
|
|
2806
|
+
if (sudoUser && sudoUser !== "root") {
|
|
2807
|
+
const home = process.env.HOME && process.env.HOME !== "/var/root" && process.env.HOME !== "/root" ? process.env.HOME : readPasswdHome(sudoUser) || (platform === "darwin" ? path8.posix.join("/Users", sudoUser) : path8.posix.join("/home", sudoUser));
|
|
2808
|
+
return { home, uid: sudoUid, gid: sudoGid, username: sudoUser };
|
|
2809
|
+
}
|
|
2810
|
+
const userInfo2 = os2.userInfo();
|
|
2811
|
+
return {
|
|
2812
|
+
home: os2.homedir(),
|
|
2813
|
+
uid: process.getuid?.()?.toString(),
|
|
2814
|
+
gid: process.getgid?.()?.toString(),
|
|
2815
|
+
username: userInfo2.username
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
function buildProxyCommand(entryScript, serviceConfig) {
|
|
2819
|
+
const proxyConfig = buildProxyStartConfig({
|
|
2820
|
+
useHttps: serviceConfig.useHttps,
|
|
2821
|
+
customCertPath: serviceConfig.customCertPath,
|
|
2822
|
+
customKeyPath: serviceConfig.customKeyPath,
|
|
2823
|
+
lanMode: serviceConfig.lanMode,
|
|
2824
|
+
lanIp: serviceConfig.lanIp,
|
|
2825
|
+
lanIpExplicit: serviceConfig.lanIpExplicit,
|
|
2826
|
+
tld: serviceConfig.tld,
|
|
2827
|
+
useWildcard: serviceConfig.useWildcard,
|
|
2828
|
+
foreground: true,
|
|
2829
|
+
includePort: true,
|
|
2830
|
+
proxyPort: serviceConfig.proxyPort,
|
|
2831
|
+
skipTrust: true
|
|
2832
|
+
});
|
|
2833
|
+
return [entryScript, "proxy", "start", ...proxyConfig.args];
|
|
2834
|
+
}
|
|
2835
|
+
function buildServiceEnv(ctx) {
|
|
2836
|
+
const env = {
|
|
2837
|
+
PORTLESS_STATE_DIR: ctx.stateDir,
|
|
2838
|
+
PORTLESS_PORT: ctx.config.proxyPort.toString(),
|
|
2839
|
+
PORTLESS_HTTPS: ctx.config.useHttps ? "1" : "0",
|
|
2840
|
+
PORTLESS_LAN: ctx.config.lanMode ? "1" : "0",
|
|
2841
|
+
PORTLESS_WILDCARD: ctx.config.useWildcard ? "1" : "0",
|
|
2842
|
+
...ctx.config.extraEnv
|
|
2843
|
+
};
|
|
2844
|
+
if (ctx.config.lanMode && ctx.config.lanIpExplicit && ctx.config.lanIp) {
|
|
2845
|
+
env.PORTLESS_LAN_IP = ctx.config.lanIp;
|
|
2846
|
+
}
|
|
2847
|
+
if (ctx.config.lanMode) {
|
|
2848
|
+
env.PORTLESS_TLD = "local";
|
|
2849
|
+
} else if (ctx.config.tld !== DEFAULT_TLD) {
|
|
2850
|
+
env.PORTLESS_TLD = ctx.config.tld;
|
|
2851
|
+
}
|
|
2852
|
+
if (ctx.platform === "win32") {
|
|
2853
|
+
env.USERPROFILE = ctx.user.home;
|
|
2854
|
+
env.PATH = ctx.pathEnv;
|
|
2855
|
+
} else {
|
|
2856
|
+
env.HOME = ctx.user.home;
|
|
2857
|
+
if (ctx.user.uid) env.SUDO_UID = ctx.user.uid;
|
|
2858
|
+
if (ctx.user.gid) env.SUDO_GID = ctx.user.gid;
|
|
2859
|
+
}
|
|
2860
|
+
return env;
|
|
2861
|
+
}
|
|
2862
|
+
function defaultStateDir(platform, userHome) {
|
|
2863
|
+
return platform === "win32" ? path8.win32.join(userHome, ".portless") : path8.posix.join(userHome, ".portless");
|
|
2864
|
+
}
|
|
2865
|
+
function buildLaunchdPlist(ctx, programArguments) {
|
|
2866
|
+
const env = buildServiceEnv(ctx);
|
|
2867
|
+
const envEntries = Object.entries(env).map(
|
|
2868
|
+
([key, value]) => ` <key>${xmlEscape(key)}</key>
|
|
2869
|
+
<string>${xmlEscape(value)}</string>`
|
|
2870
|
+
).join("\n");
|
|
2871
|
+
const args = programArguments.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n");
|
|
2872
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2873
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2874
|
+
<plist version="1.0">
|
|
2875
|
+
<dict>
|
|
2876
|
+
<key>Label</key>
|
|
2877
|
+
<string>${SERVICE_LABEL}</string>
|
|
2878
|
+
<key>ProgramArguments</key>
|
|
2879
|
+
<array>
|
|
2880
|
+
${args}
|
|
2881
|
+
</array>
|
|
2882
|
+
<key>EnvironmentVariables</key>
|
|
2883
|
+
<dict>
|
|
2884
|
+
${envEntries}
|
|
2885
|
+
</dict>
|
|
2886
|
+
<key>RunAtLoad</key>
|
|
2887
|
+
<true/>
|
|
2888
|
+
<key>KeepAlive</key>
|
|
2889
|
+
<true/>
|
|
2890
|
+
<key>StandardOutPath</key>
|
|
2891
|
+
<string>${xmlEscape(path8.posix.join(ctx.stateDir, "service.log"))}</string>
|
|
2892
|
+
<key>StandardErrorPath</key>
|
|
2893
|
+
<string>${xmlEscape(path8.posix.join(ctx.stateDir, "service.log"))}</string>
|
|
2894
|
+
</dict>
|
|
2895
|
+
</plist>
|
|
2896
|
+
`;
|
|
2897
|
+
}
|
|
2898
|
+
function buildSystemdUnit(ctx, execStart) {
|
|
2899
|
+
const env = buildServiceEnv(ctx);
|
|
2900
|
+
const envLines = Object.entries(env).map(([key, value]) => `Environment=${key}=${systemdEscape(value)}`).join("\n");
|
|
2901
|
+
return `[Unit]
|
|
2902
|
+
Description=Portless HTTPS proxy
|
|
2903
|
+
After=network-online.target
|
|
2904
|
+
Wants=network-online.target
|
|
2905
|
+
|
|
2906
|
+
[Service]
|
|
2907
|
+
Type=simple
|
|
2908
|
+
${envLines}
|
|
2909
|
+
Environment=PATH=${systemdEscape(ctx.pathEnv)}
|
|
2910
|
+
ExecStart=${execStart.map(systemdEscape).join(" ")}
|
|
2911
|
+
Restart=on-failure
|
|
2912
|
+
RestartSec=2
|
|
2913
|
+
KillSignal=SIGTERM
|
|
2914
|
+
TimeoutStopSec=5
|
|
2915
|
+
|
|
2916
|
+
[Install]
|
|
2917
|
+
WantedBy=multi-user.target
|
|
2918
|
+
`;
|
|
2919
|
+
}
|
|
2920
|
+
function buildWindowsScript(ctx, command) {
|
|
2921
|
+
const env = buildServiceEnv(ctx);
|
|
2922
|
+
const setEnv = Object.entries(env).map(([key, value]) => `set "${key}=${value.replace(/"/g, "").replace(/%/g, "%%")}"`).join("\r\n");
|
|
2923
|
+
const proxyCommand = [windowsQuote(ctx.nodePath), ...command.map(windowsQuote)].join(" ");
|
|
2924
|
+
return `@echo off\r
|
|
2925
|
+
${setEnv}\r
|
|
2926
|
+
${proxyCommand}\r
|
|
2927
|
+
`;
|
|
2928
|
+
}
|
|
2929
|
+
function buildServiceSpec(options) {
|
|
2930
|
+
const installConfig = {
|
|
2931
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
2932
|
+
...options.installConfig,
|
|
2933
|
+
extraEnv: options.installConfig?.extraEnv ?? {}
|
|
2934
|
+
};
|
|
2935
|
+
const stateDir = options.stateDir || installConfig.stateDir || defaultStateDir(options.platform, options.userHome);
|
|
2936
|
+
const normalizedConfig = {
|
|
2937
|
+
...installConfig,
|
|
2938
|
+
stateDir
|
|
2939
|
+
};
|
|
2940
|
+
const ctx = {
|
|
2941
|
+
platform: options.platform,
|
|
2942
|
+
nodePath: options.nodePath,
|
|
2943
|
+
entryScript: options.entryScript,
|
|
2944
|
+
stateDir,
|
|
2945
|
+
user: {
|
|
2946
|
+
home: options.userHome,
|
|
2947
|
+
uid: options.uid,
|
|
2948
|
+
gid: options.gid,
|
|
2949
|
+
username: options.username
|
|
2950
|
+
},
|
|
2951
|
+
pathEnv: options.pathEnv || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
2952
|
+
programData: options.programData || "C:\\ProgramData",
|
|
2953
|
+
config: normalizedConfig
|
|
2954
|
+
};
|
|
2955
|
+
const proxyCommand = buildProxyCommand(ctx.entryScript, ctx.config);
|
|
2956
|
+
if (ctx.platform === "darwin") {
|
|
2957
|
+
const programArguments = [ctx.nodePath, ...proxyCommand];
|
|
2958
|
+
return {
|
|
2959
|
+
platform: "darwin",
|
|
2960
|
+
label: SERVICE_LABEL,
|
|
2961
|
+
plistPath: `/Library/LaunchDaemons/${SERVICE_LABEL}.plist`,
|
|
2962
|
+
plist: buildLaunchdPlist(ctx, programArguments),
|
|
2963
|
+
stateDir: ctx.stateDir,
|
|
2964
|
+
config: ctx.config,
|
|
2965
|
+
programArguments
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
if (ctx.platform === "linux") {
|
|
2969
|
+
const execStart = [ctx.nodePath, ...proxyCommand];
|
|
2970
|
+
return {
|
|
2971
|
+
platform: "linux",
|
|
2972
|
+
serviceName: SYSTEMD_SERVICE,
|
|
2973
|
+
unitPath: `/etc/systemd/system/${SYSTEMD_SERVICE}`,
|
|
2974
|
+
unit: buildSystemdUnit(ctx, execStart),
|
|
2975
|
+
stateDir: ctx.stateDir,
|
|
2976
|
+
config: ctx.config,
|
|
2977
|
+
execStart
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
const scriptDir = path8.win32.join(ctx.programData, "portless", "service");
|
|
2981
|
+
const scriptPath = path8.win32.join(scriptDir, "portless-service.cmd");
|
|
2982
|
+
const script = buildWindowsScript(ctx, proxyCommand);
|
|
2983
|
+
const taskRun = windowsQuote(scriptPath);
|
|
2984
|
+
return {
|
|
2985
|
+
platform: "win32",
|
|
2986
|
+
taskName: WINDOWS_TASK_NAME,
|
|
2987
|
+
stateDir: ctx.stateDir,
|
|
2988
|
+
config: ctx.config,
|
|
2989
|
+
scriptDir,
|
|
2990
|
+
scriptPath,
|
|
2991
|
+
script,
|
|
2992
|
+
taskRun,
|
|
2993
|
+
createArgs: [
|
|
2994
|
+
"/Create",
|
|
2995
|
+
"/TN",
|
|
2996
|
+
WINDOWS_TASK_NAME,
|
|
2997
|
+
"/SC",
|
|
2998
|
+
"ONSTART",
|
|
2999
|
+
"/RU",
|
|
3000
|
+
"SYSTEM",
|
|
3001
|
+
"/RL",
|
|
3002
|
+
"HIGHEST",
|
|
3003
|
+
"/TR",
|
|
3004
|
+
taskRun,
|
|
3005
|
+
"/F"
|
|
3006
|
+
],
|
|
3007
|
+
runArgs: ["/Run", "/TN", WINDOWS_TASK_NAME],
|
|
3008
|
+
deleteArgs: ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"],
|
|
3009
|
+
queryArgs: ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
3012
|
+
function currentServiceSpec(entryScript, installConfig) {
|
|
3013
|
+
if (!isSupportedPlatform(process.platform)) {
|
|
3014
|
+
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
3015
|
+
}
|
|
3016
|
+
const user = resolveUserContext(process.platform);
|
|
3017
|
+
const stateDir = installConfig?.stateDir || process.env.PORTLESS_STATE_DIR || defaultStateDir(process.platform, user.home);
|
|
3018
|
+
const config = installConfig ?? {
|
|
3019
|
+
...DEFAULT_SERVICE_CONFIG,
|
|
3020
|
+
stateDir,
|
|
3021
|
+
extraEnv: collectServiceExtraEnv(process.env)
|
|
3022
|
+
};
|
|
3023
|
+
return buildServiceSpec({
|
|
3024
|
+
platform: process.platform,
|
|
3025
|
+
nodePath: process.execPath,
|
|
3026
|
+
entryScript,
|
|
3027
|
+
userHome: user.home,
|
|
3028
|
+
uid: user.uid,
|
|
3029
|
+
gid: user.gid,
|
|
3030
|
+
username: user.username,
|
|
3031
|
+
stateDir,
|
|
3032
|
+
pathEnv: process.env.PATH,
|
|
3033
|
+
programData: process.env.ProgramData,
|
|
3034
|
+
installConfig: config
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
function parseQuotedWords(input, options = {}) {
|
|
3038
|
+
const words = [];
|
|
3039
|
+
let current = "";
|
|
3040
|
+
let inQuote = false;
|
|
3041
|
+
let inWord = false;
|
|
3042
|
+
const unescapeBackslash = options.unescapeBackslash ?? true;
|
|
3043
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
3044
|
+
const char = input[i];
|
|
3045
|
+
if (char === '"') {
|
|
3046
|
+
inQuote = !inQuote;
|
|
3047
|
+
inWord = true;
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
if (char === "\\" && i + 1 < input.length && (input[i + 1] === '"' || unescapeBackslash && input[i + 1] === "\\")) {
|
|
3051
|
+
current += input[i + 1];
|
|
3052
|
+
inWord = true;
|
|
3053
|
+
i += 1;
|
|
3054
|
+
continue;
|
|
3055
|
+
}
|
|
3056
|
+
if (/\s/.test(char) && !inQuote) {
|
|
3057
|
+
if (inWord) {
|
|
3058
|
+
words.push(current);
|
|
3059
|
+
current = "";
|
|
3060
|
+
inWord = false;
|
|
3061
|
+
}
|
|
3062
|
+
continue;
|
|
3063
|
+
}
|
|
3064
|
+
current += char;
|
|
3065
|
+
inWord = true;
|
|
3066
|
+
}
|
|
3067
|
+
if (inWord) {
|
|
3068
|
+
words.push(current);
|
|
3069
|
+
}
|
|
3070
|
+
return words;
|
|
3071
|
+
}
|
|
3072
|
+
function parsePlistStrings(block) {
|
|
3073
|
+
return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) => xmlUnescape(match[1]));
|
|
3074
|
+
}
|
|
3075
|
+
function parsePlistEnv(block) {
|
|
3076
|
+
const env = {};
|
|
3077
|
+
for (const match of block.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
|
|
3078
|
+
env[xmlUnescape(match[1])] = xmlUnescape(match[2]);
|
|
3079
|
+
}
|
|
3080
|
+
return env;
|
|
3081
|
+
}
|
|
3082
|
+
function readInstalledServiceSnapshot(spec) {
|
|
3083
|
+
try {
|
|
3084
|
+
if (spec.platform === "darwin") {
|
|
3085
|
+
if (!fs8.existsSync(spec.plistPath)) return null;
|
|
3086
|
+
const plist = fs8.readFileSync(spec.plistPath, "utf-8");
|
|
3087
|
+
const argsBlock = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
3088
|
+
const envBlock = plist.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
|
|
3089
|
+
if (!argsBlock) return null;
|
|
3090
|
+
return {
|
|
3091
|
+
command: parsePlistStrings(argsBlock[1]),
|
|
3092
|
+
env: envBlock ? parsePlistEnv(envBlock[1]) : {}
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
if (spec.platform === "linux") {
|
|
3096
|
+
if (!fs8.existsSync(spec.unitPath)) return null;
|
|
3097
|
+
const unit = fs8.readFileSync(spec.unitPath, "utf-8");
|
|
3098
|
+
const env2 = {};
|
|
3099
|
+
let command = null;
|
|
3100
|
+
for (const line of unit.split("\n")) {
|
|
3101
|
+
if (line.startsWith("Environment=")) {
|
|
3102
|
+
const entry = line.slice("Environment=".length);
|
|
3103
|
+
const eq = entry.indexOf("=");
|
|
3104
|
+
if (eq > 0) {
|
|
3105
|
+
const key = entry.slice(0, eq);
|
|
3106
|
+
const value = parseQuotedWords(entry.slice(eq + 1))[0] ?? "";
|
|
3107
|
+
env2[key] = value;
|
|
3108
|
+
}
|
|
3109
|
+
} else if (line.startsWith("ExecStart=")) {
|
|
3110
|
+
command = parseQuotedWords(line.slice("ExecStart=".length));
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
return command ? { command, env: env2 } : null;
|
|
3114
|
+
}
|
|
3115
|
+
if (!fs8.existsSync(spec.scriptPath)) return null;
|
|
3116
|
+
const script = fs8.readFileSync(spec.scriptPath, "utf-8");
|
|
3117
|
+
const env = {};
|
|
3118
|
+
let commandLine = null;
|
|
3119
|
+
for (const rawLine of script.split(/\r?\n/)) {
|
|
3120
|
+
const line = rawLine.trim();
|
|
3121
|
+
if (!line || line.toLowerCase() === "@echo off") continue;
|
|
3122
|
+
const envMatch = line.match(/^set "([^=]+)=(.*)"$/);
|
|
3123
|
+
if (envMatch) {
|
|
3124
|
+
env[envMatch[1]] = envMatch[2].replace(/%%/g, "%");
|
|
3125
|
+
continue;
|
|
3126
|
+
}
|
|
3127
|
+
commandLine = line;
|
|
3128
|
+
}
|
|
3129
|
+
return commandLine ? { command: parseQuotedWords(commandLine, { unescapeBackslash: false }), env } : null;
|
|
3130
|
+
} catch {
|
|
3131
|
+
return null;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
function installedConfigFromSnapshot(snapshot, fallback) {
|
|
3135
|
+
const proxyIndex = snapshot.command.findIndex(
|
|
3136
|
+
(arg, index) => arg === "proxy" && snapshot.command[index + 1] === "start"
|
|
3137
|
+
);
|
|
3138
|
+
if (proxyIndex === -1) return null;
|
|
3139
|
+
try {
|
|
3140
|
+
const parsed = parseServiceInstallConfig(
|
|
3141
|
+
["service", "install", ...snapshot.command.slice(proxyIndex + 2)],
|
|
3142
|
+
snapshot.env,
|
|
3143
|
+
{ allowRuntimeFlags: true }
|
|
3144
|
+
);
|
|
3145
|
+
const stateDir = parsed.stateDir || snapshot.env.PORTLESS_STATE_DIR || fallback.stateDir;
|
|
3146
|
+
return { ...parsed, stateDir };
|
|
3147
|
+
} catch {
|
|
3148
|
+
return null;
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
function readInstalledServiceConfig(spec) {
|
|
3152
|
+
const snapshot = readInstalledServiceSnapshot(spec);
|
|
3153
|
+
if (!snapshot) return null;
|
|
3154
|
+
return installedConfigFromSnapshot(snapshot, spec.config);
|
|
3155
|
+
}
|
|
3156
|
+
function collectPortlessEnvArgs(env = process.env, omit = /* @__PURE__ */ new Set()) {
|
|
3157
|
+
const envArgs = [];
|
|
3158
|
+
for (const key of Object.keys(env)) {
|
|
3159
|
+
if (key.startsWith("PORTLESS_") && env[key] && !omit.has(key)) {
|
|
3160
|
+
envArgs.push(`${key}=${env[key]}`);
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
return envArgs;
|
|
3164
|
+
}
|
|
3165
|
+
function buildElevatedEnvArgs(options) {
|
|
3166
|
+
const extraEnv = options.extraEnv ?? {};
|
|
3167
|
+
const overrideKeys = /* @__PURE__ */ new Set(["PORTLESS_STATE_DIR", ...Object.keys(extraEnv)]);
|
|
3168
|
+
return [
|
|
3169
|
+
"env",
|
|
3170
|
+
...collectPortlessEnvArgs(options.env, overrideKeys),
|
|
3171
|
+
...Object.entries(extraEnv).map(([key, value]) => `${key}=${value}`),
|
|
3172
|
+
`HOME=${options.home}`,
|
|
3173
|
+
`PORTLESS_STATE_DIR=${options.stateDir}`
|
|
3174
|
+
];
|
|
3175
|
+
}
|
|
3176
|
+
function buildServiceUninstallSudoArgs(entryScript, options = {}) {
|
|
3177
|
+
const env = options.env ?? process.env;
|
|
3178
|
+
const home = options.home ?? os2.homedir();
|
|
3179
|
+
const stateDir = options.stateDir ?? env.PORTLESS_STATE_DIR ?? path8.join(home, ".portless");
|
|
3180
|
+
return [
|
|
3181
|
+
...buildElevatedEnvArgs({ home, stateDir, env }),
|
|
3182
|
+
options.nodePath ?? process.execPath,
|
|
3183
|
+
entryScript,
|
|
3184
|
+
"service",
|
|
3185
|
+
"uninstall"
|
|
3186
|
+
];
|
|
3187
|
+
}
|
|
3188
|
+
function requireUnixElevation(args, runner) {
|
|
3189
|
+
if (process.platform !== "darwin" && process.platform !== "linux") return;
|
|
3190
|
+
if ((process.getuid?.() ?? -1) === 0) return;
|
|
3191
|
+
if (process.env[INTERNAL_ELEVATED_ENV] === "1") return;
|
|
3192
|
+
const home = os2.homedir();
|
|
3193
|
+
const stateDir = process.env.PORTLESS_STATE_DIR || path8.join(home, ".portless");
|
|
3194
|
+
const result = runner(
|
|
3195
|
+
"sudo",
|
|
3196
|
+
[
|
|
3197
|
+
...buildElevatedEnvArgs({
|
|
3198
|
+
home,
|
|
3199
|
+
stateDir,
|
|
3200
|
+
extraEnv: { [INTERNAL_ELEVATED_ENV]: "1" }
|
|
3201
|
+
}),
|
|
3202
|
+
process.execPath,
|
|
3203
|
+
args[0],
|
|
3204
|
+
...args.slice(1)
|
|
3205
|
+
],
|
|
3206
|
+
{ stdio: "inherit" }
|
|
3207
|
+
);
|
|
3208
|
+
process.exit(result.status ?? 1);
|
|
3209
|
+
}
|
|
3210
|
+
function runRequired(runner, command, args) {
|
|
3211
|
+
const result = runner(command, args);
|
|
3212
|
+
if (result.status !== 0) {
|
|
3213
|
+
const detail = result.stderr || result.stdout || result.error?.message || `${command} failed`;
|
|
3214
|
+
throw new Error(detail.trim());
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
function runOptional(runner, command, args) {
|
|
3218
|
+
runner(command, args);
|
|
3219
|
+
}
|
|
3220
|
+
function isPermissionError(err) {
|
|
3221
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
3222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3223
|
+
return code === "EACCES" || code === "EPERM" || /permission denied|operation not permitted|access is denied/i.test(message);
|
|
3224
|
+
}
|
|
3225
|
+
function stopProxyOnPort(entryScript, runner, proxyPort) {
|
|
3226
|
+
runRequired(runner, process.execPath, [
|
|
3227
|
+
entryScript,
|
|
3228
|
+
"proxy",
|
|
3229
|
+
"stop",
|
|
3230
|
+
"--port",
|
|
3231
|
+
proxyPort.toString()
|
|
3232
|
+
]);
|
|
3233
|
+
}
|
|
3234
|
+
async function stopExistingProxy(entryScript, runner, proxyPort) {
|
|
3235
|
+
const ports = /* @__PURE__ */ new Set();
|
|
3236
|
+
try {
|
|
3237
|
+
const currentState = await discoverState();
|
|
3238
|
+
if (currentState.port !== proxyPort && await isProxyRunning(currentState.port)) {
|
|
3239
|
+
ports.add(currentState.port);
|
|
3240
|
+
}
|
|
3241
|
+
} catch {
|
|
3242
|
+
}
|
|
3243
|
+
ports.add(proxyPort);
|
|
3244
|
+
for (const port of ports) {
|
|
3245
|
+
stopProxyOnPort(entryScript, runner, port);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
function prepareServiceState(stateDir) {
|
|
3249
|
+
fs8.mkdirSync(stateDir, { recursive: true });
|
|
3250
|
+
fixOwnership(stateDir);
|
|
3251
|
+
}
|
|
3252
|
+
function prepareTrust(stateDir) {
|
|
3253
|
+
try {
|
|
3254
|
+
ensureCerts(stateDir);
|
|
3255
|
+
} catch (err) {
|
|
3256
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
3257
|
+
throw new Error(
|
|
3258
|
+
`Failed to generate certificates in ${stateDir}. Ensure OpenSSL is installed.
|
|
3259
|
+
${detail}`
|
|
3260
|
+
);
|
|
3261
|
+
}
|
|
3262
|
+
if (isCATrusted(stateDir)) return;
|
|
3263
|
+
console.log(colors_default.gray("Trusting portless CA for service startup..."));
|
|
3264
|
+
const trustResult = trustCA(stateDir);
|
|
3265
|
+
if (trustResult.trusted) {
|
|
3266
|
+
console.log(colors_default.green("CA added to the system trust store."));
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
console.warn(colors_default.yellow("Could not add the CA to the system trust store."));
|
|
3270
|
+
if (trustResult.error) {
|
|
3271
|
+
console.warn(colors_default.gray(trustResult.error));
|
|
3272
|
+
}
|
|
3273
|
+
console.warn(colors_default.yellow("Run `portless trust` if browsers show certificate warnings."));
|
|
3274
|
+
}
|
|
3275
|
+
function ensureServiceConfigSupported(config) {
|
|
3276
|
+
if (!config.lanMode) return;
|
|
3277
|
+
const mdnsSupport = isMdnsSupported();
|
|
3278
|
+
if (mdnsSupport.supported) return;
|
|
3279
|
+
const reason = mdnsSupport.reason ? `
|
|
3280
|
+
${mdnsSupport.reason}` : "";
|
|
3281
|
+
throw new Error(
|
|
3282
|
+
`LAN mode requires mDNS publishing, which is not supported on this platform.${reason}`
|
|
3283
|
+
);
|
|
3284
|
+
}
|
|
3285
|
+
async function installService(entryScript, runner, args) {
|
|
3286
|
+
const installConfig = normalizeServiceInstallPaths(parseServiceInstallConfig(args));
|
|
3287
|
+
ensureServiceConfigSupported(installConfig);
|
|
3288
|
+
requireUnixElevation([entryScript, ...args], runner);
|
|
3289
|
+
const spec = currentServiceSpec(entryScript, installConfig);
|
|
3290
|
+
prepareServiceState(spec.stateDir);
|
|
3291
|
+
if (spec.config.useHttps && !spec.config.customCertPath) {
|
|
3292
|
+
prepareTrust(spec.stateDir);
|
|
3293
|
+
}
|
|
3294
|
+
if (spec.platform === "darwin") {
|
|
3295
|
+
runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
|
|
3296
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
3297
|
+
fs8.writeFileSync(spec.plistPath, spec.plist);
|
|
3298
|
+
fs8.chmodSync(spec.plistPath, 420);
|
|
3299
|
+
runRequired(runner, "chown", ["root:wheel", spec.plistPath]);
|
|
3300
|
+
runRequired(runner, "launchctl", ["bootstrap", "system", spec.plistPath]);
|
|
3301
|
+
runRequired(runner, "launchctl", ["enable", `system/${spec.label}`]);
|
|
3302
|
+
runRequired(runner, "launchctl", ["kickstart", "-k", `system/${spec.label}`]);
|
|
3303
|
+
} else if (spec.platform === "linux") {
|
|
3304
|
+
runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
|
|
3305
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
3306
|
+
fs8.writeFileSync(spec.unitPath, spec.unit);
|
|
3307
|
+
fs8.chmodSync(spec.unitPath, 420);
|
|
3308
|
+
runRequired(runner, "systemctl", ["daemon-reload"]);
|
|
3309
|
+
runRequired(runner, "systemctl", ["enable", spec.serviceName]);
|
|
3310
|
+
runRequired(runner, "systemctl", ["restart", spec.serviceName]);
|
|
3311
|
+
} else {
|
|
3312
|
+
runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
|
|
3313
|
+
await stopExistingProxy(entryScript, runner, spec.config.proxyPort);
|
|
3314
|
+
fs8.mkdirSync(spec.scriptDir, { recursive: true });
|
|
3315
|
+
fs8.writeFileSync(spec.scriptPath, spec.script);
|
|
3316
|
+
runRequired(runner, "schtasks", spec.createArgs);
|
|
3317
|
+
runOptional(runner, "schtasks", spec.runArgs);
|
|
3318
|
+
}
|
|
3319
|
+
console.log(colors_default.green("Portless service installed."));
|
|
3320
|
+
console.log(colors_default.gray(`State directory: ${spec.stateDir}`));
|
|
3321
|
+
console.log(colors_default.gray(`Proxy port: ${spec.config.proxyPort}`));
|
|
3322
|
+
}
|
|
3323
|
+
async function uninstallService(entryScript, runner) {
|
|
3324
|
+
requireUnixElevation([entryScript, "service", "uninstall"], runner);
|
|
3325
|
+
const spec = currentServiceSpec(entryScript);
|
|
3326
|
+
if (spec.platform === "darwin") {
|
|
3327
|
+
runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
|
|
3328
|
+
fs8.rmSync(spec.plistPath, { force: true });
|
|
3329
|
+
} else if (spec.platform === "linux") {
|
|
3330
|
+
runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
|
|
3331
|
+
fs8.rmSync(spec.unitPath, { force: true });
|
|
3332
|
+
runOptional(runner, "systemctl", ["daemon-reload"]);
|
|
3333
|
+
} else {
|
|
3334
|
+
runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
|
|
3335
|
+
runOptional(runner, "schtasks", spec.deleteArgs);
|
|
3336
|
+
fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
|
|
3337
|
+
}
|
|
3338
|
+
console.log(colors_default.green("Portless service uninstalled."));
|
|
3339
|
+
}
|
|
3340
|
+
function tryUninstallService(entryScript, runner = defaultRunner2) {
|
|
3341
|
+
let installed = false;
|
|
3342
|
+
try {
|
|
3343
|
+
const spec = currentServiceSpec(entryScript);
|
|
3344
|
+
if (spec.platform === "darwin") {
|
|
3345
|
+
installed = fs8.existsSync(spec.plistPath);
|
|
3346
|
+
if (!installed) return { removed: false, installed: false };
|
|
3347
|
+
runOptional(runner, "launchctl", ["bootout", "system", spec.plistPath]);
|
|
3348
|
+
fs8.rmSync(spec.plistPath, { force: true });
|
|
3349
|
+
} else if (spec.platform === "linux") {
|
|
3350
|
+
installed = fs8.existsSync(spec.unitPath);
|
|
3351
|
+
if (!installed) return { removed: false, installed: false };
|
|
3352
|
+
runOptional(runner, "systemctl", ["disable", "--now", spec.serviceName]);
|
|
3353
|
+
fs8.rmSync(spec.unitPath, { force: true });
|
|
3354
|
+
runOptional(runner, "systemctl", ["daemon-reload"]);
|
|
3355
|
+
} else {
|
|
3356
|
+
const query = runner("schtasks", ["/Query", "/TN", spec.taskName, "/FO", "LIST"]);
|
|
3357
|
+
installed = query.status === 0;
|
|
3358
|
+
if (!installed) return { removed: false, installed: false };
|
|
3359
|
+
runOptional(runner, "schtasks", ["/End", "/TN", spec.taskName]);
|
|
3360
|
+
runRequired(runner, "schtasks", spec.deleteArgs);
|
|
3361
|
+
fs8.rmSync(spec.scriptDir, { recursive: true, force: true });
|
|
3362
|
+
}
|
|
3363
|
+
return { removed: true, installed: true };
|
|
3364
|
+
} catch (err) {
|
|
3365
|
+
return {
|
|
3366
|
+
removed: false,
|
|
3367
|
+
installed,
|
|
3368
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3369
|
+
needsElevation: installed && isPermissionError(err)
|
|
3370
|
+
};
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
async function getServiceStatus(entryScript, runner) {
|
|
3374
|
+
const spec = currentServiceSpec(entryScript);
|
|
3375
|
+
const installedConfig = readInstalledServiceConfig(spec) ?? spec.config;
|
|
3376
|
+
const proxyRunning = await isProxyRunning(installedConfig.proxyPort, installedConfig.useHttps);
|
|
3377
|
+
if (spec.platform === "darwin") {
|
|
3378
|
+
const installed2 = fs8.existsSync(spec.plistPath);
|
|
3379
|
+
const result = runner("launchctl", ["print", `system/${spec.label}`]);
|
|
3380
|
+
const output2 = `${result.stdout || ""}${result.stderr || ""}`;
|
|
3381
|
+
const managerState = result.status === 0 && /state = running|pid = \d+/.test(output2) ? "running" : installed2 ? "installed" : "not installed";
|
|
3382
|
+
return {
|
|
3383
|
+
installed: installed2,
|
|
3384
|
+
managerState,
|
|
3385
|
+
proxyRunning,
|
|
3386
|
+
config: installedConfig,
|
|
3387
|
+
details: spec.plistPath
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
if (spec.platform === "linux") {
|
|
3391
|
+
const enabled2 = runner("systemctl", ["is-enabled", spec.serviceName]);
|
|
3392
|
+
const active = runner("systemctl", ["is-active", spec.serviceName]);
|
|
3393
|
+
const installed2 = enabled2.status === 0 || active.status === 0 || fs8.existsSync(spec.unitPath);
|
|
3394
|
+
const activeText = (active.stdout || "").trim();
|
|
3395
|
+
return {
|
|
3396
|
+
installed: installed2,
|
|
3397
|
+
managerState: active.status === 0 ? activeText || "active" : installed2 ? "installed" : "not installed",
|
|
3398
|
+
proxyRunning,
|
|
3399
|
+
config: installedConfig,
|
|
3400
|
+
details: spec.unitPath
|
|
3401
|
+
};
|
|
3402
|
+
}
|
|
3403
|
+
const query = runner("schtasks", spec.queryArgs);
|
|
3404
|
+
const output = `${query.stdout || ""}${query.stderr || ""}`;
|
|
3405
|
+
const installed = query.status === 0;
|
|
3406
|
+
const stateMatch = output.match(/^\s*Status:\s*(.+)$/im);
|
|
3407
|
+
return {
|
|
3408
|
+
installed,
|
|
3409
|
+
managerState: installed ? stateMatch?.[1]?.trim() || "installed" : "not installed",
|
|
3410
|
+
proxyRunning,
|
|
3411
|
+
config: installedConfig,
|
|
3412
|
+
details: spec.taskName
|
|
3413
|
+
};
|
|
3414
|
+
}
|
|
3415
|
+
async function printServiceStatus(entryScript, runner) {
|
|
3416
|
+
const status = await getServiceStatus(entryScript, runner);
|
|
3417
|
+
const config = status.config;
|
|
3418
|
+
console.log(colors_default.bold("portless service"));
|
|
3419
|
+
console.log(` Manager state: ${status.managerState}`);
|
|
3420
|
+
console.log(` Installed: ${status.installed ? "yes" : "no"}`);
|
|
3421
|
+
console.log(
|
|
3422
|
+
` Proxy on ${config.proxyPort}: ${status.proxyRunning ? "responding" : "not responding"}`
|
|
3423
|
+
);
|
|
3424
|
+
console.log(` HTTPS: ${config.useHttps ? "yes" : "no"}`);
|
|
3425
|
+
console.log(` TLD: ${config.lanMode ? "local" : config.tld}`);
|
|
3426
|
+
console.log(` LAN mode: ${config.lanMode ? "yes" : "no"}`);
|
|
3427
|
+
if (config.lanIpExplicit && config.lanIp) {
|
|
3428
|
+
console.log(` LAN IP: ${config.lanIp}`);
|
|
3429
|
+
}
|
|
3430
|
+
console.log(` Wildcard: ${config.useWildcard ? "yes" : "no"}`);
|
|
3431
|
+
console.log(` State directory: ${config.stateDir}`);
|
|
3432
|
+
if (status.details) {
|
|
3433
|
+
console.log(` Service entry: ${status.details}`);
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
function printServiceHelp() {
|
|
3437
|
+
console.log(`
|
|
3438
|
+
${colors_default.bold("portless service")} - Start portless automatically when the OS starts.
|
|
3439
|
+
|
|
3440
|
+
${colors_default.bold("Usage:")}
|
|
3441
|
+
${colors_default.cyan("portless service install")} Install and start the HTTPS service on port 443
|
|
3442
|
+
${colors_default.cyan("portless service install --lan")} Enable LAN mode for the startup service
|
|
3443
|
+
${colors_default.cyan("portless service install -p 8443")} Use a custom proxy port
|
|
3444
|
+
${colors_default.cyan("portless service uninstall")} Stop and remove the startup service
|
|
3445
|
+
${colors_default.cyan("portless service status")} Show service and proxy status
|
|
3446
|
+
|
|
3447
|
+
${colors_default.bold("Install options:")}
|
|
3448
|
+
-p, --port <number> Port for the proxy service
|
|
3449
|
+
--no-tls Disable HTTPS
|
|
3450
|
+
--https Enable HTTPS
|
|
3451
|
+
--lan Enable LAN mode
|
|
3452
|
+
--ip <address> Pin a specific LAN IP
|
|
3453
|
+
--tld <tld> Use a custom TLD outside LAN mode
|
|
3454
|
+
--wildcard Allow subdomain fallback
|
|
3455
|
+
--cert <path> Use a custom TLS certificate
|
|
3456
|
+
--key <path> Use a custom TLS private key
|
|
3457
|
+
--state-dir <path> Use a custom service state directory
|
|
3458
|
+
|
|
3459
|
+
${colors_default.bold("Notes:")}
|
|
3460
|
+
The service uses the default clean URL mode unless options or PORTLESS_*
|
|
3461
|
+
environment variables are provided during install.
|
|
3462
|
+
macOS and Linux install a root-owned service so port 443 can bind at boot.
|
|
3463
|
+
Windows installs a Task Scheduler startup task that runs as SYSTEM.
|
|
3464
|
+
`);
|
|
3465
|
+
}
|
|
3466
|
+
async function handleService(args, options) {
|
|
3467
|
+
const action = args[1];
|
|
3468
|
+
const runner = options.runner || defaultRunner2;
|
|
3469
|
+
if (!action || action === "--help" || action === "-h") {
|
|
3470
|
+
printServiceHelp();
|
|
3471
|
+
process.exit(0);
|
|
3472
|
+
}
|
|
3473
|
+
try {
|
|
3474
|
+
if (action === "install") {
|
|
3475
|
+
await installService(options.entryScript, runner, args);
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
if (action === "uninstall") {
|
|
3479
|
+
await uninstallService(options.entryScript, runner);
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
if (action === "status") {
|
|
3483
|
+
await printServiceStatus(options.entryScript, runner);
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
console.error(colors_default.red(`Error: Unknown service command "${action}".`));
|
|
3487
|
+
printServiceHelp();
|
|
3488
|
+
process.exit(1);
|
|
3489
|
+
} catch (err) {
|
|
3490
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3491
|
+
console.error(colors_default.red("Error:"), message);
|
|
3492
|
+
process.exit(1);
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
|
|
2527
3496
|
// src/cli.ts
|
|
2528
3497
|
var chalk = colors_default;
|
|
2529
3498
|
var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
|
|
@@ -2676,16 +3645,16 @@ function getEntryScript() {
|
|
|
2676
3645
|
function isLocallyInstalled() {
|
|
2677
3646
|
let dir = process.cwd();
|
|
2678
3647
|
for (; ; ) {
|
|
2679
|
-
if (
|
|
3648
|
+
if (fs9.existsSync(path9.join(dir, "node_modules", "portless", "package.json"))) {
|
|
2680
3649
|
return true;
|
|
2681
3650
|
}
|
|
2682
|
-
const parent =
|
|
3651
|
+
const parent = path9.dirname(dir);
|
|
2683
3652
|
if (parent === dir) break;
|
|
2684
3653
|
dir = parent;
|
|
2685
3654
|
}
|
|
2686
3655
|
return false;
|
|
2687
3656
|
}
|
|
2688
|
-
function
|
|
3657
|
+
function collectPortlessEnvArgs2() {
|
|
2689
3658
|
const envArgs = [];
|
|
2690
3659
|
for (const key of Object.keys(process.env)) {
|
|
2691
3660
|
if (key.startsWith("PORTLESS_") && process.env[key]) {
|
|
@@ -2697,7 +3666,35 @@ function collectPortlessEnvArgs() {
|
|
|
2697
3666
|
function sudoStop(port) {
|
|
2698
3667
|
const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
|
|
2699
3668
|
console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
|
|
2700
|
-
const result =
|
|
3669
|
+
const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...stopArgs], {
|
|
3670
|
+
stdio: "inherit",
|
|
3671
|
+
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3672
|
+
});
|
|
3673
|
+
return result.status === 0;
|
|
3674
|
+
}
|
|
3675
|
+
function runCleanWithSudo(reason) {
|
|
3676
|
+
console.log(colors_default.yellow(`${reason} Requesting sudo...`));
|
|
3677
|
+
const home = process.env.HOME;
|
|
3678
|
+
const result = spawnSync4(
|
|
3679
|
+
"sudo",
|
|
3680
|
+
[
|
|
3681
|
+
"env",
|
|
3682
|
+
...collectPortlessEnvArgs2(),
|
|
3683
|
+
...home ? [`HOME=${home}`] : [],
|
|
3684
|
+
process.execPath,
|
|
3685
|
+
getEntryScript(),
|
|
3686
|
+
"clean"
|
|
3687
|
+
],
|
|
3688
|
+
{
|
|
3689
|
+
stdio: "inherit",
|
|
3690
|
+
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3691
|
+
}
|
|
3692
|
+
);
|
|
3693
|
+
return result.status === 0;
|
|
3694
|
+
}
|
|
3695
|
+
function runServiceUninstallWithSudo(reason) {
|
|
3696
|
+
console.log(colors_default.yellow(`${reason} Requesting sudo...`));
|
|
3697
|
+
const result = spawnSync4("sudo", buildServiceUninstallSudoArgs(getEntryScript()), {
|
|
2701
3698
|
stdio: "inherit",
|
|
2702
3699
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
2703
3700
|
});
|
|
@@ -2715,11 +3712,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2715
3712
|
console.warn(chalk.yellow(`LAN mode disabled: ${reason}`));
|
|
2716
3713
|
}
|
|
2717
3714
|
const routesPath = store.getRoutesPath();
|
|
2718
|
-
if (!
|
|
2719
|
-
|
|
3715
|
+
if (!fs9.existsSync(routesPath)) {
|
|
3716
|
+
fs9.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
|
|
2720
3717
|
}
|
|
2721
3718
|
try {
|
|
2722
|
-
|
|
3719
|
+
fs9.chmodSync(routesPath, FILE_MODE);
|
|
2723
3720
|
} catch {
|
|
2724
3721
|
}
|
|
2725
3722
|
fixOwnership(routesPath);
|
|
@@ -2779,7 +3776,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2779
3776
|
}
|
|
2780
3777
|
};
|
|
2781
3778
|
try {
|
|
2782
|
-
watcher =
|
|
3779
|
+
watcher = fs9.watch(routesPath, () => {
|
|
2783
3780
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
2784
3781
|
debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
|
|
2785
3782
|
});
|
|
@@ -2829,8 +3826,8 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2829
3826
|
redirectServer.listen(80);
|
|
2830
3827
|
}
|
|
2831
3828
|
server.listen(proxyPort, () => {
|
|
2832
|
-
|
|
2833
|
-
|
|
3829
|
+
fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
|
|
3830
|
+
fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
|
|
2834
3831
|
writeTlsMarker(store.dir, isTls);
|
|
2835
3832
|
writeTldFile(store.dir, tld);
|
|
2836
3833
|
writeLanMarker(store.dir, activeLanIp);
|
|
@@ -2846,7 +3843,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2846
3843
|
console.log(chalk.gray("Services are discoverable as <name>.local on your network"));
|
|
2847
3844
|
if (isTls) {
|
|
2848
3845
|
console.log(chalk.yellow("For HTTPS on devices, install the CA certificate:"));
|
|
2849
|
-
console.log(chalk.gray(` ${
|
|
3846
|
+
console.log(chalk.gray(` ${path9.join(store.dir, "ca.pem")}`));
|
|
2850
3847
|
}
|
|
2851
3848
|
if (!lanIpPinned) {
|
|
2852
3849
|
lanMonitor = startLanIpMonitor({
|
|
@@ -2878,11 +3875,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions, lanIp, strict) {
|
|
|
2878
3875
|
redirectServer.close();
|
|
2879
3876
|
}
|
|
2880
3877
|
try {
|
|
2881
|
-
|
|
3878
|
+
fs9.unlinkSync(store.pidPath);
|
|
2882
3879
|
} catch {
|
|
2883
3880
|
}
|
|
2884
3881
|
try {
|
|
2885
|
-
|
|
3882
|
+
fs9.unlinkSync(store.portFilePath);
|
|
2886
3883
|
} catch {
|
|
2887
3884
|
}
|
|
2888
3885
|
writeTlsMarker(store.dir, false);
|
|
@@ -2912,7 +3909,7 @@ function sudoStopOrHint(port) {
|
|
|
2912
3909
|
}
|
|
2913
3910
|
async function stopProxy(store, proxyPort, _tls) {
|
|
2914
3911
|
const pidPath = store.pidPath;
|
|
2915
|
-
if (!
|
|
3912
|
+
if (!fs9.existsSync(pidPath)) {
|
|
2916
3913
|
if (await isProxyRunning(proxyPort)) {
|
|
2917
3914
|
console.log(colors_default.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
|
|
2918
3915
|
const pid = findPidOnPort(proxyPort);
|
|
@@ -2920,7 +3917,7 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
2920
3917
|
try {
|
|
2921
3918
|
process.kill(pid, "SIGTERM");
|
|
2922
3919
|
try {
|
|
2923
|
-
|
|
3920
|
+
fs9.unlinkSync(store.portFilePath);
|
|
2924
3921
|
} catch {
|
|
2925
3922
|
}
|
|
2926
3923
|
writeTlsMarker(store.dir, false);
|
|
@@ -2958,10 +3955,10 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
2958
3955
|
return;
|
|
2959
3956
|
}
|
|
2960
3957
|
try {
|
|
2961
|
-
const pid = parseInt(
|
|
3958
|
+
const pid = parseInt(fs9.readFileSync(pidPath, "utf-8"), 10);
|
|
2962
3959
|
if (isNaN(pid)) {
|
|
2963
3960
|
console.error(colors_default.red("Corrupted PID file. Removing it."));
|
|
2964
|
-
|
|
3961
|
+
fs9.unlinkSync(pidPath);
|
|
2965
3962
|
writeTlsMarker(store.dir, false);
|
|
2966
3963
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
2967
3964
|
writeLanMarker(store.dir, null);
|
|
@@ -2975,9 +3972,9 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
2975
3972
|
return;
|
|
2976
3973
|
}
|
|
2977
3974
|
console.log(colors_default.yellow("Proxy process is no longer running. Cleaning up stale files."));
|
|
2978
|
-
|
|
3975
|
+
fs9.unlinkSync(pidPath);
|
|
2979
3976
|
try {
|
|
2980
|
-
|
|
3977
|
+
fs9.unlinkSync(store.portFilePath);
|
|
2981
3978
|
} catch {
|
|
2982
3979
|
}
|
|
2983
3980
|
writeTlsMarker(store.dir, false);
|
|
@@ -2992,16 +3989,16 @@ async function stopProxy(store, proxyPort, _tls) {
|
|
|
2992
3989
|
)
|
|
2993
3990
|
);
|
|
2994
3991
|
console.log(colors_default.yellow("Removing stale PID file."));
|
|
2995
|
-
|
|
3992
|
+
fs9.unlinkSync(pidPath);
|
|
2996
3993
|
writeTlsMarker(store.dir, false);
|
|
2997
3994
|
writeTldFile(store.dir, DEFAULT_TLD);
|
|
2998
3995
|
writeLanMarker(store.dir, null);
|
|
2999
3996
|
return;
|
|
3000
3997
|
}
|
|
3001
3998
|
process.kill(pid, "SIGTERM");
|
|
3002
|
-
|
|
3999
|
+
fs9.unlinkSync(pidPath);
|
|
3003
4000
|
try {
|
|
3004
|
-
|
|
4001
|
+
fs9.unlinkSync(store.portFilePath);
|
|
3005
4002
|
} catch {
|
|
3006
4003
|
}
|
|
3007
4004
|
writeTlsMarker(store.dir, false);
|
|
@@ -3124,7 +4121,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
|
|
|
3124
4121
|
proxyPort: startPort
|
|
3125
4122
|
});
|
|
3126
4123
|
const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
|
|
3127
|
-
const result =
|
|
4124
|
+
const result = spawnSync4(process.execPath, startArgs, {
|
|
3128
4125
|
stdio: "inherit",
|
|
3129
4126
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3130
4127
|
});
|
|
@@ -3142,10 +4139,10 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
|
|
|
3142
4139
|
if (!discovered) {
|
|
3143
4140
|
console.error(colors_default.red("Failed to start proxy."));
|
|
3144
4141
|
const fallbackDir = resolveStateDir(effectivePort);
|
|
3145
|
-
const logPath =
|
|
4142
|
+
const logPath = path9.join(fallbackDir, "proxy.log");
|
|
3146
4143
|
console.error(colors_default.blue("Try starting it manually:"));
|
|
3147
4144
|
console.error(colors_default.cyan(` ${manualStartCommand}`));
|
|
3148
|
-
if (
|
|
4145
|
+
if (fs9.existsSync(logPath)) {
|
|
3149
4146
|
console.error(colors_default.gray(`Logs: ${logPath}`));
|
|
3150
4147
|
}
|
|
3151
4148
|
process.exit(1);
|
|
@@ -3163,14 +4160,17 @@ portless
|
|
|
3163
4160
|
let tsBaseUrl;
|
|
3164
4161
|
if (wantsTailscale) {
|
|
3165
4162
|
try {
|
|
3166
|
-
const tsReady = ensureTailscaleReady(
|
|
4163
|
+
const tsReady = ensureTailscaleReady({
|
|
4164
|
+
requireFunnel: wantsFunnel,
|
|
4165
|
+
requireHttps: true
|
|
4166
|
+
});
|
|
3167
4167
|
tsBaseUrl = tsReady.baseUrl;
|
|
3168
4168
|
} catch (err) {
|
|
3169
4169
|
const message = err instanceof Error ? err.message : String(err);
|
|
3170
4170
|
console.error(colors_default.red(`Error: ${message}`));
|
|
3171
4171
|
if (message.includes("not found")) {
|
|
3172
4172
|
console.error(colors_default.blue("Install Tailscale: https://tailscale.com/download"));
|
|
3173
|
-
} else {
|
|
4173
|
+
} else if (!message.includes("not enabled on your tailnet")) {
|
|
3174
4174
|
console.error(colors_default.blue("Make sure Tailscale is connected:"));
|
|
3175
4175
|
console.error(colors_default.cyan(" tailscale up"));
|
|
3176
4176
|
}
|
|
@@ -3299,7 +4299,7 @@ portless
|
|
|
3299
4299
|
} catch {
|
|
3300
4300
|
}
|
|
3301
4301
|
}
|
|
3302
|
-
const basename5 =
|
|
4302
|
+
const basename5 = path9.basename(commandArgs[0]);
|
|
3303
4303
|
const isExpo = basename5 === "expo";
|
|
3304
4304
|
const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
|
|
3305
4305
|
const hostBind = isExpoLan ? void 0 : "127.0.0.1";
|
|
@@ -3309,8 +4309,8 @@ portless
|
|
|
3309
4309
|
injectFrameworkFlags(commandArgs, port);
|
|
3310
4310
|
const caEnv = {};
|
|
3311
4311
|
if (tls2 && !process.env.NODE_EXTRA_CA_CERTS) {
|
|
3312
|
-
const caPath =
|
|
3313
|
-
if (
|
|
4312
|
+
const caPath = path9.join(stateDir, "ca.pem");
|
|
4313
|
+
if (fs9.existsSync(caPath)) {
|
|
3314
4314
|
caEnv.NODE_EXTRA_CA_CERTS = caPath;
|
|
3315
4315
|
}
|
|
3316
4316
|
}
|
|
@@ -3507,6 +4507,9 @@ ${colors_default.bold("Install:")}
|
|
|
3507
4507
|
${colors_default.cyan("npm install -g portless")} Global (recommended)
|
|
3508
4508
|
${colors_default.cyan("npm install -D portless")} Project dev dependency
|
|
3509
4509
|
|
|
4510
|
+
${colors_default.bold("Requirements:")}
|
|
4511
|
+
Node.js 24+
|
|
4512
|
+
|
|
3510
4513
|
${colors_default.bold("Usage:")}
|
|
3511
4514
|
${colors_default.cyan("portless")} Run dev script through proxy
|
|
3512
4515
|
${colors_default.cyan("portless")} From monorepo root: run all workspace packages
|
|
@@ -3515,6 +4518,7 @@ ${colors_default.bold("Usage:")}
|
|
|
3515
4518
|
${colors_default.cyan("portless <name> <cmd>")} Run with an explicit app name
|
|
3516
4519
|
${colors_default.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon)
|
|
3517
4520
|
${colors_default.cyan("portless proxy stop")} Stop the proxy
|
|
4521
|
+
${colors_default.cyan("portless service install")} Start proxy automatically when the OS starts
|
|
3518
4522
|
${colors_default.cyan("portless get <name>")} Print URL for a service (for cross-service refs)
|
|
3519
4523
|
${colors_default.cyan("portless alias <name> <port>")} Register a static route (e.g. for Docker)
|
|
3520
4524
|
${colors_default.cyan("portless alias --remove <name>")} Remove a static route
|
|
@@ -3532,6 +4536,9 @@ ${colors_default.bold("Examples:")}
|
|
|
3532
4536
|
portless myapp next dev # -> https://myapp.localhost
|
|
3533
4537
|
portless run next dev # -> https://<project>.localhost
|
|
3534
4538
|
portless run next dev # in worktree -> https://<worktree>.<project>.localhost
|
|
4539
|
+
portless service install # Start HTTPS proxy on OS startup
|
|
4540
|
+
portless service install --lan # Persist LAN mode in the startup service
|
|
4541
|
+
portless service install --wildcard # Persist wildcard routing in the startup service
|
|
3535
4542
|
portless get backend # -> https://backend.localhost
|
|
3536
4543
|
portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
|
|
3537
4544
|
portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
|
|
@@ -3590,7 +4597,9 @@ ${colors_default.bold("Tailscale sharing:")}
|
|
|
3590
4597
|
Each app is root-mounted on its own Tailscale HTTPS port (443, then 8443,
|
|
3591
4598
|
8444, etc.) so no basePath configuration is needed.
|
|
3592
4599
|
Use --funnel to expose your dev server to the public internet via
|
|
3593
|
-
Tailscale Funnel. Requires Tailscale CLI to be installed and connected
|
|
4600
|
+
Tailscale Funnel. Requires Tailscale CLI to be installed and connected,
|
|
4601
|
+
with Tailscale HTTPS certificates enabled. Funnel must also be enabled
|
|
4602
|
+
on your tailnet.
|
|
3594
4603
|
${colors_default.cyan("portless myapp --tailscale next dev")}
|
|
3595
4604
|
${colors_default.cyan("portless myapp --funnel next dev")}
|
|
3596
4605
|
|
|
@@ -3609,6 +4618,7 @@ ${colors_default.bold("Options:")}
|
|
|
3609
4618
|
--foreground Run proxy in foreground (for debugging)
|
|
3610
4619
|
--tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
|
|
3611
4620
|
--wildcard Allow unregistered subdomains to fall back to parent route
|
|
4621
|
+
--state-dir <path> Use a custom state directory with service install
|
|
3612
4622
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
3613
4623
|
--tailscale Share the app on your Tailscale network (tailnet)
|
|
3614
4624
|
--funnel Share the app publicly via Tailscale Funnel
|
|
@@ -3621,6 +4631,7 @@ ${colors_default.bold("Environment variables:")}
|
|
|
3621
4631
|
PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
|
|
3622
4632
|
PORTLESS_HTTPS=0 Disable HTTPS (same as --no-tls)
|
|
3623
4633
|
PORTLESS_LAN=1 Enable LAN mode when set to 1 (set in .bashrc / .zshrc)
|
|
4634
|
+
PORTLESS_LAN_IP=<address> Pin a specific LAN IP for LAN mode
|
|
3624
4635
|
PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
|
|
3625
4636
|
PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
|
|
3626
4637
|
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
|
|
@@ -3650,20 +4661,20 @@ ${colors_default.bold("Skip portless:")}
|
|
|
3650
4661
|
PORTLESS=0 pnpm dev # Runs command directly without proxy
|
|
3651
4662
|
|
|
3652
4663
|
${colors_default.bold("Reserved names:")}
|
|
3653
|
-
run, get, alias, hosts, list, trust, clean, prune, proxy are subcommands and
|
|
4664
|
+
run, get, alias, hosts, list, trust, clean, prune, proxy, service are subcommands and
|
|
3654
4665
|
cannot be used as app names directly. Use "portless run" to infer the name,
|
|
3655
4666
|
or "portless --name <name>" to force any name including reserved ones.
|
|
3656
4667
|
`);
|
|
3657
4668
|
process.exit(0);
|
|
3658
4669
|
}
|
|
3659
4670
|
function printVersion() {
|
|
3660
|
-
console.log("0.
|
|
4671
|
+
console.log("0.13.1");
|
|
3661
4672
|
process.exit(0);
|
|
3662
4673
|
}
|
|
3663
4674
|
async function handleTrust() {
|
|
3664
4675
|
const { dir } = await discoverState();
|
|
3665
|
-
if (!
|
|
3666
|
-
|
|
4676
|
+
if (!fs9.existsSync(dir)) {
|
|
4677
|
+
fs9.mkdirSync(dir, { recursive: true });
|
|
3667
4678
|
}
|
|
3668
4679
|
const { caGenerated } = ensureCerts(dir);
|
|
3669
4680
|
if (caGenerated) {
|
|
@@ -3675,14 +4686,14 @@ async function handleTrust() {
|
|
|
3675
4686
|
console.log(colors_default.gray("Browsers will now trust portless HTTPS certificates."));
|
|
3676
4687
|
return;
|
|
3677
4688
|
}
|
|
3678
|
-
const
|
|
3679
|
-
if (
|
|
4689
|
+
const isPermissionError2 = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
|
|
4690
|
+
if (isPermissionError2 && !isWindows && process.getuid?.() !== 0) {
|
|
3680
4691
|
console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
|
|
3681
|
-
const sudoResult =
|
|
4692
|
+
const sudoResult = spawnSync4(
|
|
3682
4693
|
"sudo",
|
|
3683
4694
|
[
|
|
3684
4695
|
"env",
|
|
3685
|
-
...
|
|
4696
|
+
...collectPortlessEnvArgs2(),
|
|
3686
4697
|
`PORTLESS_STATE_DIR=${dir}`,
|
|
3687
4698
|
process.execPath,
|
|
3688
4699
|
getEntryScript(),
|
|
@@ -3704,10 +4715,11 @@ async function handleClean(args) {
|
|
|
3704
4715
|
console.log(`
|
|
3705
4716
|
${colors_default.bold("portless clean")} - Remove portless artifacts from this machine.
|
|
3706
4717
|
|
|
3707
|
-
Stops the proxy if it is running,
|
|
3708
|
-
when it was installed by portless,
|
|
3709
|
-
(~/.portless, the system state
|
|
3710
|
-
and removes the portless block
|
|
4718
|
+
Stops the proxy if it is running, uninstalls the startup service if installed,
|
|
4719
|
+
removes the local CA from the OS trust store when it was installed by portless,
|
|
4720
|
+
deletes known files under state directories (~/.portless, the system state
|
|
4721
|
+
directory, and PORTLESS_STATE_DIR when set), and removes the portless block
|
|
4722
|
+
from ${HOSTS_DISPLAY}.
|
|
3711
4723
|
|
|
3712
4724
|
Only allowlisted filenames under each state directory are deleted. Custom
|
|
3713
4725
|
certificate paths from --cert and --key are never removed.
|
|
@@ -3728,6 +4740,23 @@ ${colors_default.bold("Options:")}
|
|
|
3728
4740
|
console.error(colors_default.cyan(" portless clean --help"));
|
|
3729
4741
|
process.exit(1);
|
|
3730
4742
|
}
|
|
4743
|
+
const serviceResult = tryUninstallService(getEntryScript());
|
|
4744
|
+
if (serviceResult.removed) {
|
|
4745
|
+
console.log(colors_default.green("Removed startup service."));
|
|
4746
|
+
} else if (serviceResult.needsElevation && !isWindows && (process.getuid?.() ?? -1) !== 0) {
|
|
4747
|
+
if (!runServiceUninstallWithSudo("Removing the startup service requires elevated privileges.")) {
|
|
4748
|
+
console.error(colors_default.red("Failed to remove startup service with sudo."));
|
|
4749
|
+
process.exit(1);
|
|
4750
|
+
}
|
|
4751
|
+
} else if (serviceResult.error) {
|
|
4752
|
+
const adminHint = isWindows ? " Run as Administrator and try again." : "";
|
|
4753
|
+
const message = `Could not remove startup service: ${serviceResult.error}${adminHint}`;
|
|
4754
|
+
if (serviceResult.installed) {
|
|
4755
|
+
console.error(colors_default.red(message));
|
|
4756
|
+
process.exit(1);
|
|
4757
|
+
}
|
|
4758
|
+
console.warn(colors_default.yellow(message));
|
|
4759
|
+
}
|
|
3731
4760
|
console.log(colors_default.cyan("Stopping proxy if it is running..."));
|
|
3732
4761
|
const { dir, port, tls: tls2 } = await discoverState();
|
|
3733
4762
|
const store = new RouteStore(dir, {
|
|
@@ -3746,8 +4775,8 @@ ${colors_default.bold("Options:")}
|
|
|
3746
4775
|
}
|
|
3747
4776
|
const stateDirs = collectStateDirsForCleanup();
|
|
3748
4777
|
for (const stateDir of stateDirs) {
|
|
3749
|
-
const caPath =
|
|
3750
|
-
if (!
|
|
4778
|
+
const caPath = path9.join(stateDir, "ca.pem");
|
|
4779
|
+
if (!fs9.existsSync(caPath)) continue;
|
|
3751
4780
|
const wasTrusted = isCATrusted(stateDir);
|
|
3752
4781
|
if (!wasTrusted) continue;
|
|
3753
4782
|
const untrustResult = untrustCA(stateDir);
|
|
@@ -3769,18 +4798,7 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
|
|
|
3769
4798
|
if (cleanHostsFile()) {
|
|
3770
4799
|
console.log(colors_default.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
|
|
3771
4800
|
} else if (!isWindows && process.getuid?.() !== 0) {
|
|
3772
|
-
|
|
3773
|
-
colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
|
|
3774
|
-
);
|
|
3775
|
-
const result = spawnSync3(
|
|
3776
|
-
"sudo",
|
|
3777
|
-
["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
|
|
3778
|
-
{
|
|
3779
|
-
stdio: "inherit",
|
|
3780
|
-
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
3781
|
-
}
|
|
3782
|
-
);
|
|
3783
|
-
if (result.status !== 0) {
|
|
4801
|
+
if (!runCleanWithSudo(`Updating ${HOSTS_DISPLAY} requires elevated privileges.`)) {
|
|
3784
4802
|
console.error(colors_default.red(`Failed to update ${HOSTS_DISPLAY}. Run: sudo portless clean`));
|
|
3785
4803
|
process.exit(1);
|
|
3786
4804
|
}
|
|
@@ -4011,9 +5029,9 @@ ${colors_default.bold("Auto-sync:")}
|
|
|
4011
5029
|
`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
|
|
4012
5030
|
)
|
|
4013
5031
|
);
|
|
4014
|
-
const result =
|
|
5032
|
+
const result = spawnSync4(
|
|
4015
5033
|
"sudo",
|
|
4016
|
-
["env", ...
|
|
5034
|
+
["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "clean"],
|
|
4017
5035
|
{
|
|
4018
5036
|
stdio: "inherit",
|
|
4019
5037
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
@@ -4064,9 +5082,9 @@ ${colors_default.bold("Usage: portless hosts <command>")}
|
|
|
4064
5082
|
console.log(
|
|
4065
5083
|
colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
|
|
4066
5084
|
);
|
|
4067
|
-
const result =
|
|
5085
|
+
const result = spawnSync4(
|
|
4068
5086
|
"sudo",
|
|
4069
|
-
["env", ...
|
|
5087
|
+
["env", ...collectPortlessEnvArgs2(), process.execPath, getEntryScript(), "hosts", "sync"],
|
|
4070
5088
|
{
|
|
4071
5089
|
stdio: "inherit",
|
|
4072
5090
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
@@ -4355,7 +5373,7 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4355
5373
|
if (!hasExplicitPort) {
|
|
4356
5374
|
console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
|
|
4357
5375
|
}
|
|
4358
|
-
const result =
|
|
5376
|
+
const result = spawnSync4("sudo", ["env", ...collectPortlessEnvArgs2(), ...startArgs], {
|
|
4359
5377
|
stdio: "inherit",
|
|
4360
5378
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
4361
5379
|
});
|
|
@@ -4365,8 +5383,8 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4365
5383
|
console.log(colors_default.green(`Proxy started on port ${proxyPort}.`));
|
|
4366
5384
|
} else {
|
|
4367
5385
|
console.error(colors_default.red("Proxy process started but is not responding."));
|
|
4368
|
-
const logPath2 =
|
|
4369
|
-
if (
|
|
5386
|
+
const logPath2 = path9.join(resolveStateDir(proxyPort), "proxy.log");
|
|
5387
|
+
if (fs9.existsSync(logPath2)) {
|
|
4370
5388
|
console.error(colors_default.gray(`Logs: ${logPath2}`));
|
|
4371
5389
|
}
|
|
4372
5390
|
}
|
|
@@ -4405,8 +5423,8 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4405
5423
|
store.ensureDir();
|
|
4406
5424
|
if (customCertPath && customKeyPath) {
|
|
4407
5425
|
try {
|
|
4408
|
-
const cert =
|
|
4409
|
-
const key =
|
|
5426
|
+
const cert = fs9.readFileSync(customCertPath);
|
|
5427
|
+
const key = fs9.readFileSync(customKeyPath);
|
|
4410
5428
|
const certStr = cert.toString("utf-8");
|
|
4411
5429
|
const keyStr = key.toString("utf-8");
|
|
4412
5430
|
if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
|
|
@@ -4451,9 +5469,9 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4451
5469
|
console.warn(colors_default.cyan(" portless trust"));
|
|
4452
5470
|
}
|
|
4453
5471
|
}
|
|
4454
|
-
const cert =
|
|
4455
|
-
const key =
|
|
4456
|
-
const ca =
|
|
5472
|
+
const cert = fs9.readFileSync(certs.certPath);
|
|
5473
|
+
const key = fs9.readFileSync(certs.keyPath);
|
|
5474
|
+
const ca = fs9.readFileSync(certs.caPath);
|
|
4457
5475
|
tlsOptions = {
|
|
4458
5476
|
cert,
|
|
4459
5477
|
key,
|
|
@@ -4468,11 +5486,11 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4468
5486
|
return;
|
|
4469
5487
|
}
|
|
4470
5488
|
store.ensureDir();
|
|
4471
|
-
const logPath =
|
|
4472
|
-
const logFd =
|
|
5489
|
+
const logPath = path9.join(stateDir, "proxy.log");
|
|
5490
|
+
const logFd = fs9.openSync(logPath, "a");
|
|
4473
5491
|
try {
|
|
4474
5492
|
try {
|
|
4475
|
-
|
|
5493
|
+
fs9.chmodSync(logPath, FILE_MODE);
|
|
4476
5494
|
} catch {
|
|
4477
5495
|
}
|
|
4478
5496
|
fixOwnership(logPath);
|
|
@@ -4503,13 +5521,13 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4503
5521
|
});
|
|
4504
5522
|
child.unref();
|
|
4505
5523
|
} finally {
|
|
4506
|
-
|
|
5524
|
+
fs9.closeSync(logFd);
|
|
4507
5525
|
}
|
|
4508
5526
|
if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
|
|
4509
5527
|
console.error(colors_default.red("Proxy failed to start (timed out waiting for it to listen)."));
|
|
4510
5528
|
console.error(colors_default.blue("Try starting the proxy in the foreground to see the error:"));
|
|
4511
5529
|
console.error(colors_default.cyan(" portless proxy start --foreground"));
|
|
4512
|
-
if (
|
|
5530
|
+
if (fs9.existsSync(logPath)) {
|
|
4513
5531
|
console.error(colors_default.gray(`Logs: ${logPath}`));
|
|
4514
5532
|
}
|
|
4515
5533
|
process.exit(1);
|
|
@@ -4661,8 +5679,8 @@ async function spawnProxiedApp(app, stateDir, proxyPort, tls2, tld, exitCodes) {
|
|
|
4661
5679
|
PORTLESS_URL: url
|
|
4662
5680
|
};
|
|
4663
5681
|
if (tls2) {
|
|
4664
|
-
const caPath =
|
|
4665
|
-
if (
|
|
5682
|
+
const caPath = path9.join(stateDir, "ca.pem");
|
|
5683
|
+
if (fs9.existsSync(caPath)) {
|
|
4666
5684
|
env.NODE_EXTRA_CA_CERTS = caPath;
|
|
4667
5685
|
}
|
|
4668
5686
|
}
|
|
@@ -4744,7 +5762,7 @@ async function handleDefaultMulti(wsRoot, globalScript, extraArgs = []) {
|
|
|
4744
5762
|
}
|
|
4745
5763
|
const apps = [];
|
|
4746
5764
|
for (const pkg of packages) {
|
|
4747
|
-
const rel =
|
|
5765
|
+
const rel = path9.relative(wsRoot, pkg.dir).replace(/\\/g, "/");
|
|
4748
5766
|
const rootOverride = loaded ? resolveAppConfig(loaded.config, loaded.configDir, pkg.dir) : null;
|
|
4749
5767
|
let pkgConfig;
|
|
4750
5768
|
try {
|
|
@@ -4852,8 +5870,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
|
|
|
4852
5870
|
PORTLESS_URL: url
|
|
4853
5871
|
};
|
|
4854
5872
|
if (tls2) {
|
|
4855
|
-
const caPath =
|
|
4856
|
-
if (
|
|
5873
|
+
const caPath = path9.join(stateDir, "ca.pem");
|
|
5874
|
+
if (fs9.existsSync(caPath)) {
|
|
4857
5875
|
entry.NODE_EXTRA_CA_CERTS = caPath;
|
|
4858
5876
|
}
|
|
4859
5877
|
}
|
|
@@ -4902,8 +5920,8 @@ async function runWithTurbo(wsRoot, stateDir, proxyPort, tls2, tld, scriptName,
|
|
|
4902
5920
|
};
|
|
4903
5921
|
process.on("SIGINT", cleanup);
|
|
4904
5922
|
process.on("SIGTERM", cleanup);
|
|
4905
|
-
const exitCode = await new Promise((
|
|
4906
|
-
turboChild.on("exit", (code) =>
|
|
5923
|
+
const exitCode = await new Promise((resolve4) => {
|
|
5924
|
+
turboChild.on("exit", (code) => resolve4(code));
|
|
4907
5925
|
});
|
|
4908
5926
|
cleanup();
|
|
4909
5927
|
if (exitCode !== 0 && exitCode !== null) {
|
|
@@ -4967,8 +5985,8 @@ async function runWithDirectSpawn(stateDir, proxyPort, tls2, tld, proxiedApps, t
|
|
|
4967
5985
|
process.on("SIGTERM", cleanup);
|
|
4968
5986
|
await Promise.all(
|
|
4969
5987
|
children.map(
|
|
4970
|
-
(child) => new Promise((
|
|
4971
|
-
child.on("exit", () =>
|
|
5988
|
+
(child) => new Promise((resolve4) => {
|
|
5989
|
+
child.on("exit", () => resolve4());
|
|
4972
5990
|
})
|
|
4973
5991
|
)
|
|
4974
5992
|
);
|
|
@@ -5055,6 +6073,7 @@ async function handleNamedMode(args) {
|
|
|
5055
6073
|
}
|
|
5056
6074
|
}
|
|
5057
6075
|
const safeName = parsed.name.split(".").map((label) => truncateLabel(label)).join(".");
|
|
6076
|
+
parseHostname(safeName, DEFAULT_TLD);
|
|
5058
6077
|
const { dir, port, tls: tls2, tld, lanMode, lanIp } = await discoverState();
|
|
5059
6078
|
const store = new RouteStore(dir, {
|
|
5060
6079
|
onWarning: (msg) => console.warn(colors_default.yellow(msg))
|
|
@@ -5166,7 +6185,7 @@ async function main() {
|
|
|
5166
6185
|
args.shift();
|
|
5167
6186
|
}
|
|
5168
6187
|
const skipPortless = process.env.PORTLESS === "0" || process.env.PORTLESS === "false" || process.env.PORTLESS === "skip";
|
|
5169
|
-
if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean")) {
|
|
6188
|
+
if (skipPortless && (isRunCommand || args.length === 0 || args.length >= 2 && args[0] !== "proxy" && args[0] !== "clean" && args[0] !== "service")) {
|
|
5170
6189
|
const parsed = isRunCommand ? parseRunArgs(args) : parseAppArgs(args);
|
|
5171
6190
|
let commandArgs = parsed.commandArgs;
|
|
5172
6191
|
if (commandArgs.length === 0 && (isRunCommand || args.length === 0)) {
|
|
@@ -5230,6 +6249,10 @@ async function main() {
|
|
|
5230
6249
|
await handleProxy(args);
|
|
5231
6250
|
return;
|
|
5232
6251
|
}
|
|
6252
|
+
if (args[0] === "service") {
|
|
6253
|
+
await handleService(args, { entryScript: getEntryScript() });
|
|
6254
|
+
return;
|
|
6255
|
+
}
|
|
5233
6256
|
}
|
|
5234
6257
|
if (isRunCommand) {
|
|
5235
6258
|
await handleRunMode(args, globalScript);
|