portless 0.3.0 → 0.4.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 +29 -0
- package/dist/{chunk-Y5OVKUR4.js → chunk-VRBD6YAY.js} +88 -15
- package/dist/cli.js +610 -67
- package/dist/index.d.ts +22 -5
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,11 +86,34 @@ flowchart TD
|
|
|
86
86
|
|
|
87
87
|
Apps are assigned a random port (4000-4999) via the `PORT` environment variable. Most frameworks (Next.js, Vite, etc.) respect this automatically.
|
|
88
88
|
|
|
89
|
+
## HTTP/2 + HTTPS
|
|
90
|
+
|
|
91
|
+
Enable HTTP/2 for faster dev server page loads. Browsers limit HTTP/1.1 to 6 connections per host, which bottlenecks dev servers that serve many unbundled files (Vite, Nuxt, etc.). HTTP/2 multiplexes all requests over a single connection.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Start with HTTPS/2 -- generates certs and trusts them automatically
|
|
95
|
+
portless proxy start --https
|
|
96
|
+
|
|
97
|
+
# First run prompts for sudo once to add the CA to your system trust store.
|
|
98
|
+
# After that, no prompts. No browser warnings.
|
|
99
|
+
|
|
100
|
+
# Make it permanent (add to .bashrc / .zshrc)
|
|
101
|
+
export PORTLESS_HTTPS=1
|
|
102
|
+
portless proxy start # HTTPS by default now
|
|
103
|
+
|
|
104
|
+
# Use your own certs (e.g., from mkcert)
|
|
105
|
+
portless proxy start --cert ./cert.pem --key ./key.pem
|
|
106
|
+
|
|
107
|
+
# If you skipped sudo on first run, trust the CA later
|
|
108
|
+
sudo portless trust
|
|
109
|
+
```
|
|
110
|
+
|
|
89
111
|
## Commands
|
|
90
112
|
|
|
91
113
|
```bash
|
|
92
114
|
portless <name> <cmd> [args...] # Run app at http://<name>.localhost:1355
|
|
93
115
|
portless list # Show active routes
|
|
116
|
+
portless trust # Add local CA to system trust store
|
|
94
117
|
|
|
95
118
|
# Disable portless (run command directly)
|
|
96
119
|
PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
|
|
@@ -98,6 +121,7 @@ PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
|
|
|
98
121
|
|
|
99
122
|
# Proxy control
|
|
100
123
|
portless proxy start # Start the proxy (port 1355, daemon)
|
|
124
|
+
portless proxy start --https # Start with HTTP/2 + TLS
|
|
101
125
|
portless proxy start -p 80 # Start on port 80 (requires sudo)
|
|
102
126
|
portless proxy start --foreground # Start in foreground (for debugging)
|
|
103
127
|
portless proxy stop # Stop the proxy
|
|
@@ -105,10 +129,15 @@ portless proxy stop # Stop the proxy
|
|
|
105
129
|
# Options
|
|
106
130
|
-p, --port <number> # Port for the proxy (default: 1355)
|
|
107
131
|
# Ports < 1024 require sudo
|
|
132
|
+
--https # Enable HTTP/2 + TLS with auto-generated certs
|
|
133
|
+
--cert <path> # Use a custom TLS certificate (implies --https)
|
|
134
|
+
--key <path> # Use a custom TLS private key (implies --https)
|
|
135
|
+
--no-tls # Disable HTTPS (overrides PORTLESS_HTTPS)
|
|
108
136
|
--foreground # Run proxy in foreground instead of daemon
|
|
109
137
|
|
|
110
138
|
# Environment variables
|
|
111
139
|
PORTLESS_PORT=<number> # Override the default proxy port
|
|
140
|
+
PORTLESS_HTTPS=1 # Always enable HTTPS
|
|
112
141
|
PORTLESS_STATE_DIR=<path> # Override the state directory
|
|
113
142
|
|
|
114
143
|
# Info
|
|
@@ -5,8 +5,10 @@ function isErrnoException(err) {
|
|
|
5
5
|
function escapeHtml(str) {
|
|
6
6
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7
7
|
}
|
|
8
|
-
function formatUrl(hostname, proxyPort) {
|
|
9
|
-
|
|
8
|
+
function formatUrl(hostname, proxyPort, tls = false) {
|
|
9
|
+
const proto = tls ? "https" : "http";
|
|
10
|
+
const defaultPort = tls ? 443 : 80;
|
|
11
|
+
return proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`;
|
|
10
12
|
}
|
|
11
13
|
function parseHostname(input) {
|
|
12
14
|
let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
|
|
@@ -30,24 +32,40 @@ function parseHostname(input) {
|
|
|
30
32
|
|
|
31
33
|
// src/proxy.ts
|
|
32
34
|
import * as http from "http";
|
|
35
|
+
import * as http2 from "http2";
|
|
36
|
+
import * as net from "net";
|
|
33
37
|
var PORTLESS_HEADER = "X-Portless";
|
|
34
|
-
|
|
38
|
+
var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
39
|
+
"connection",
|
|
40
|
+
"keep-alive",
|
|
41
|
+
"proxy-connection",
|
|
42
|
+
"transfer-encoding",
|
|
43
|
+
"upgrade"
|
|
44
|
+
]);
|
|
45
|
+
function getRequestHost(req) {
|
|
46
|
+
const authority = req.headers[":authority"];
|
|
47
|
+
if (typeof authority === "string" && authority) return authority;
|
|
48
|
+
return req.headers.host || "";
|
|
49
|
+
}
|
|
50
|
+
function buildForwardedHeaders(req, tls) {
|
|
35
51
|
const headers = {};
|
|
36
52
|
const remoteAddress = req.socket.remoteAddress || "127.0.0.1";
|
|
37
|
-
const proto = "http";
|
|
38
|
-
const
|
|
53
|
+
const proto = tls ? "https" : "http";
|
|
54
|
+
const defaultPort = tls ? "443" : "80";
|
|
55
|
+
const hostHeader = getRequestHost(req);
|
|
39
56
|
headers["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remoteAddress}` : remoteAddress;
|
|
40
57
|
headers["x-forwarded-proto"] = req.headers["x-forwarded-proto"] || proto;
|
|
41
58
|
headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || hostHeader;
|
|
42
|
-
headers["x-forwarded-port"] = req.headers["x-forwarded-port"] || hostHeader.split(":")[1] ||
|
|
59
|
+
headers["x-forwarded-port"] = req.headers["x-forwarded-port"] || hostHeader.split(":")[1] || defaultPort;
|
|
43
60
|
return headers;
|
|
44
61
|
}
|
|
45
62
|
function createProxyServer(options) {
|
|
46
|
-
const { getRoutes, proxyPort, onError = (msg) => console.error(msg) } = options;
|
|
63
|
+
const { getRoutes, proxyPort, onError = (msg) => console.error(msg), tls } = options;
|
|
64
|
+
const isTls = !!tls;
|
|
47
65
|
const handleRequest = (req, res) => {
|
|
48
66
|
res.setHeader(PORTLESS_HEADER, "1");
|
|
49
67
|
const routes = getRoutes();
|
|
50
|
-
const host = (req
|
|
68
|
+
const host = getRequestHost(req).split(":")[0];
|
|
51
69
|
if (!host) {
|
|
52
70
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
53
71
|
res.end("Missing Host header");
|
|
@@ -66,7 +84,7 @@ function createProxyServer(options) {
|
|
|
66
84
|
${routes.length > 0 ? `
|
|
67
85
|
<h2>Active apps:</h2>
|
|
68
86
|
<ul>
|
|
69
|
-
${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort))}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
|
|
87
|
+
${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
|
|
70
88
|
</ul>
|
|
71
89
|
` : "<p><em>No apps running.</em></p>"}
|
|
72
90
|
<p>Start an app with: <code>portless ${safeHost.replace(".localhost", "")} your-command</code></p>
|
|
@@ -75,11 +93,16 @@ function createProxyServer(options) {
|
|
|
75
93
|
`);
|
|
76
94
|
return;
|
|
77
95
|
}
|
|
78
|
-
const forwardedHeaders = buildForwardedHeaders(req);
|
|
96
|
+
const forwardedHeaders = buildForwardedHeaders(req, isTls);
|
|
79
97
|
const proxyReqHeaders = { ...req.headers };
|
|
80
98
|
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
|
81
99
|
proxyReqHeaders[key] = value;
|
|
82
100
|
}
|
|
101
|
+
for (const key of Object.keys(proxyReqHeaders)) {
|
|
102
|
+
if (key.startsWith(":")) {
|
|
103
|
+
delete proxyReqHeaders[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
83
106
|
const proxyReq = http.request(
|
|
84
107
|
{
|
|
85
108
|
hostname: "127.0.0.1",
|
|
@@ -89,12 +112,18 @@ function createProxyServer(options) {
|
|
|
89
112
|
headers: proxyReqHeaders
|
|
90
113
|
},
|
|
91
114
|
(proxyRes) => {
|
|
92
|
-
|
|
115
|
+
const responseHeaders = { ...proxyRes.headers };
|
|
116
|
+
if (isTls) {
|
|
117
|
+
for (const h of HOP_BY_HOP_HEADERS) {
|
|
118
|
+
delete responseHeaders[h];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
|
|
93
122
|
proxyRes.pipe(res);
|
|
94
123
|
}
|
|
95
124
|
);
|
|
96
125
|
proxyReq.on("error", (err) => {
|
|
97
|
-
onError(`Proxy error for ${req
|
|
126
|
+
onError(`Proxy error for ${getRequestHost(req)}: ${err.message}`);
|
|
98
127
|
if (!res.headersSent) {
|
|
99
128
|
const errWithCode = err;
|
|
100
129
|
const message = errWithCode.code === "ECONNREFUSED" ? "Bad Gateway: the target app is not responding. It may have crashed." : "Bad Gateway: the target app may not be running.";
|
|
@@ -116,17 +145,22 @@ function createProxyServer(options) {
|
|
|
116
145
|
};
|
|
117
146
|
const handleUpgrade = (req, socket, head) => {
|
|
118
147
|
const routes = getRoutes();
|
|
119
|
-
const host = (req
|
|
148
|
+
const host = getRequestHost(req).split(":")[0];
|
|
120
149
|
const route = routes.find((r) => r.hostname === host);
|
|
121
150
|
if (!route) {
|
|
122
151
|
socket.destroy();
|
|
123
152
|
return;
|
|
124
153
|
}
|
|
125
|
-
const forwardedHeaders = buildForwardedHeaders(req);
|
|
154
|
+
const forwardedHeaders = buildForwardedHeaders(req, isTls);
|
|
126
155
|
const proxyReqHeaders = { ...req.headers };
|
|
127
156
|
for (const [key, value] of Object.entries(forwardedHeaders)) {
|
|
128
157
|
proxyReqHeaders[key] = value;
|
|
129
158
|
}
|
|
159
|
+
for (const key of Object.keys(proxyReqHeaders)) {
|
|
160
|
+
if (key.startsWith(":")) {
|
|
161
|
+
delete proxyReqHeaders[key];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
130
164
|
const proxyReq = http.request({
|
|
131
165
|
hostname: "127.0.0.1",
|
|
132
166
|
port: route.port,
|
|
@@ -152,7 +186,7 @@ function createProxyServer(options) {
|
|
|
152
186
|
socket.on("error", () => proxySocket.destroy());
|
|
153
187
|
});
|
|
154
188
|
proxyReq.on("error", (err) => {
|
|
155
|
-
onError(`WebSocket proxy error for ${req
|
|
189
|
+
onError(`WebSocket proxy error for ${getRequestHost(req)}: ${err.message}`);
|
|
156
190
|
socket.destroy();
|
|
157
191
|
});
|
|
158
192
|
proxyReq.on("response", (res) => {
|
|
@@ -173,6 +207,44 @@ function createProxyServer(options) {
|
|
|
173
207
|
}
|
|
174
208
|
proxyReq.end();
|
|
175
209
|
};
|
|
210
|
+
if (tls) {
|
|
211
|
+
const h2Server = http2.createSecureServer({
|
|
212
|
+
cert: tls.cert,
|
|
213
|
+
key: tls.key,
|
|
214
|
+
allowHTTP1: true,
|
|
215
|
+
...tls.SNICallback ? { SNICallback: tls.SNICallback } : {}
|
|
216
|
+
});
|
|
217
|
+
h2Server.on("request", (req, res) => {
|
|
218
|
+
handleRequest(req, res);
|
|
219
|
+
});
|
|
220
|
+
h2Server.on("upgrade", (req, socket, head) => {
|
|
221
|
+
handleUpgrade(req, socket, head);
|
|
222
|
+
});
|
|
223
|
+
const plainServer = http.createServer(handleRequest);
|
|
224
|
+
plainServer.on("upgrade", handleUpgrade);
|
|
225
|
+
const wrapper = net.createServer((socket) => {
|
|
226
|
+
socket.once("readable", () => {
|
|
227
|
+
const buf = socket.read(1);
|
|
228
|
+
if (!buf) {
|
|
229
|
+
socket.destroy();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
socket.unshift(buf);
|
|
233
|
+
if (buf[0] === 22) {
|
|
234
|
+
h2Server.emit("connection", socket);
|
|
235
|
+
} else {
|
|
236
|
+
plainServer.emit("connection", socket);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
const origClose = wrapper.close.bind(wrapper);
|
|
241
|
+
wrapper.close = function(cb) {
|
|
242
|
+
h2Server.close();
|
|
243
|
+
plainServer.close();
|
|
244
|
+
return origClose(cb);
|
|
245
|
+
};
|
|
246
|
+
return wrapper;
|
|
247
|
+
}
|
|
176
248
|
const httpServer = http.createServer(handleRequest);
|
|
177
249
|
httpServer.on("upgrade", handleUpgrade);
|
|
178
250
|
return httpServer;
|
|
@@ -190,6 +262,7 @@ function isValidRoute(value) {
|
|
|
190
262
|
return typeof value === "object" && value !== null && typeof value.hostname === "string" && typeof value.port === "number" && typeof value.pid === "number";
|
|
191
263
|
}
|
|
192
264
|
var RouteStore = class _RouteStore {
|
|
265
|
+
/** The state directory path. */
|
|
193
266
|
dir;
|
|
194
267
|
routesPath;
|
|
195
268
|
lockPath;
|
package/dist/cli.js
CHANGED
|
@@ -7,26 +7,401 @@ import {
|
|
|
7
7
|
formatUrl,
|
|
8
8
|
isErrnoException,
|
|
9
9
|
parseHostname
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-VRBD6YAY.js";
|
|
11
11
|
|
|
12
12
|
// src/cli.ts
|
|
13
13
|
import chalk from "chalk";
|
|
14
|
-
import * as
|
|
15
|
-
import * as
|
|
14
|
+
import * as fs3 from "fs";
|
|
15
|
+
import * as path3 from "path";
|
|
16
16
|
import { spawn as spawn2, spawnSync } from "child_process";
|
|
17
17
|
|
|
18
|
-
// src/
|
|
18
|
+
// src/certs.ts
|
|
19
19
|
import * as fs from "fs";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import * as crypto from "crypto";
|
|
22
|
+
import * as tls from "tls";
|
|
23
|
+
import { execFile as execFileCb, execFileSync } from "child_process";
|
|
24
|
+
import { promisify } from "util";
|
|
25
|
+
var CA_VALIDITY_DAYS = 3650;
|
|
26
|
+
function fixOwnership(...paths) {
|
|
27
|
+
const uid = process.env.SUDO_UID;
|
|
28
|
+
const gid = process.env.SUDO_GID;
|
|
29
|
+
if (!uid || process.getuid?.() !== 0) return;
|
|
30
|
+
for (const p of paths) {
|
|
31
|
+
try {
|
|
32
|
+
fs.chownSync(p, parseInt(uid, 10), parseInt(gid || uid, 10));
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
var SERVER_VALIDITY_DAYS = 365;
|
|
38
|
+
var EXPIRY_BUFFER_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
39
|
+
var CA_COMMON_NAME = "portless Local CA";
|
|
40
|
+
var OPENSSL_TIMEOUT_MS = 15e3;
|
|
41
|
+
var CA_KEY_FILE = "ca-key.pem";
|
|
42
|
+
var CA_CERT_FILE = "ca.pem";
|
|
43
|
+
var SERVER_KEY_FILE = "server-key.pem";
|
|
44
|
+
var SERVER_CERT_FILE = "server.pem";
|
|
45
|
+
function fileExists(filePath) {
|
|
46
|
+
try {
|
|
47
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function isCertValid(certPath) {
|
|
54
|
+
try {
|
|
55
|
+
const pem = fs.readFileSync(certPath, "utf-8");
|
|
56
|
+
const cert = new crypto.X509Certificate(pem);
|
|
57
|
+
const expiry = new Date(cert.validTo).getTime();
|
|
58
|
+
return Date.now() + EXPIRY_BUFFER_MS < expiry;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function openssl(args, options) {
|
|
64
|
+
try {
|
|
65
|
+
return execFileSync("openssl", args, {
|
|
66
|
+
encoding: "utf-8",
|
|
67
|
+
timeout: OPENSSL_TIMEOUT_MS,
|
|
68
|
+
input: options?.input,
|
|
69
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
73
|
+
throw new Error(
|
|
74
|
+
`openssl failed: ${message}
|
|
75
|
+
|
|
76
|
+
Make sure openssl is installed (ships with macOS and most Linux distributions).`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
var execFileAsync = promisify(execFileCb);
|
|
81
|
+
async function opensslAsync(args) {
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execFileAsync("openssl", args, {
|
|
84
|
+
encoding: "utf-8",
|
|
85
|
+
timeout: OPENSSL_TIMEOUT_MS
|
|
86
|
+
});
|
|
87
|
+
return stdout;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
throw new Error(
|
|
91
|
+
`openssl failed: ${message}
|
|
92
|
+
|
|
93
|
+
Make sure openssl is installed (ships with macOS and most Linux distributions).`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function generateCA(stateDir) {
|
|
98
|
+
const keyPath = path.join(stateDir, CA_KEY_FILE);
|
|
99
|
+
const certPath = path.join(stateDir, CA_CERT_FILE);
|
|
100
|
+
openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", keyPath]);
|
|
101
|
+
openssl([
|
|
102
|
+
"req",
|
|
103
|
+
"-new",
|
|
104
|
+
"-x509",
|
|
105
|
+
"-key",
|
|
106
|
+
keyPath,
|
|
107
|
+
"-out",
|
|
108
|
+
certPath,
|
|
109
|
+
"-days",
|
|
110
|
+
CA_VALIDITY_DAYS.toString(),
|
|
111
|
+
"-subj",
|
|
112
|
+
`/CN=${CA_COMMON_NAME}`,
|
|
113
|
+
"-addext",
|
|
114
|
+
"basicConstraints=critical,CA:TRUE",
|
|
115
|
+
"-addext",
|
|
116
|
+
"keyUsage=critical,keyCertSign,cRLSign"
|
|
117
|
+
]);
|
|
118
|
+
fs.chmodSync(keyPath, 384);
|
|
119
|
+
fs.chmodSync(certPath, 420);
|
|
120
|
+
fixOwnership(keyPath, certPath);
|
|
121
|
+
return { certPath, keyPath };
|
|
122
|
+
}
|
|
123
|
+
function generateServerCert(stateDir) {
|
|
124
|
+
const caKeyPath = path.join(stateDir, CA_KEY_FILE);
|
|
125
|
+
const caCertPath = path.join(stateDir, CA_CERT_FILE);
|
|
126
|
+
const serverKeyPath = path.join(stateDir, SERVER_KEY_FILE);
|
|
127
|
+
const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
|
|
128
|
+
const csrPath = path.join(stateDir, "server.csr");
|
|
129
|
+
const extPath = path.join(stateDir, "server-ext.cnf");
|
|
130
|
+
openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", serverKeyPath]);
|
|
131
|
+
openssl(["req", "-new", "-key", serverKeyPath, "-out", csrPath, "-subj", "/CN=localhost"]);
|
|
132
|
+
fs.writeFileSync(
|
|
133
|
+
extPath,
|
|
134
|
+
[
|
|
135
|
+
"authorityKeyIdentifier=keyid,issuer",
|
|
136
|
+
"basicConstraints=CA:FALSE",
|
|
137
|
+
"keyUsage=digitalSignature,keyEncipherment",
|
|
138
|
+
"extendedKeyUsage=serverAuth",
|
|
139
|
+
"subjectAltName=DNS:localhost,DNS:*.localhost"
|
|
140
|
+
].join("\n") + "\n"
|
|
141
|
+
);
|
|
142
|
+
openssl([
|
|
143
|
+
"x509",
|
|
144
|
+
"-req",
|
|
145
|
+
"-in",
|
|
146
|
+
csrPath,
|
|
147
|
+
"-CA",
|
|
148
|
+
caCertPath,
|
|
149
|
+
"-CAkey",
|
|
150
|
+
caKeyPath,
|
|
151
|
+
"-CAcreateserial",
|
|
152
|
+
"-out",
|
|
153
|
+
serverCertPath,
|
|
154
|
+
"-days",
|
|
155
|
+
SERVER_VALIDITY_DAYS.toString(),
|
|
156
|
+
"-extfile",
|
|
157
|
+
extPath
|
|
158
|
+
]);
|
|
159
|
+
for (const tmp of [csrPath, extPath]) {
|
|
160
|
+
try {
|
|
161
|
+
fs.unlinkSync(tmp);
|
|
162
|
+
} catch {
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
fs.chmodSync(serverKeyPath, 384);
|
|
166
|
+
fs.chmodSync(serverCertPath, 420);
|
|
167
|
+
fixOwnership(serverKeyPath, serverCertPath);
|
|
168
|
+
return { certPath: serverCertPath, keyPath: serverKeyPath };
|
|
169
|
+
}
|
|
170
|
+
function ensureCerts(stateDir) {
|
|
171
|
+
const caCertPath = path.join(stateDir, CA_CERT_FILE);
|
|
172
|
+
const caKeyPath = path.join(stateDir, CA_KEY_FILE);
|
|
173
|
+
const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
|
|
174
|
+
let caGenerated = false;
|
|
175
|
+
if (!fileExists(caCertPath) || !fileExists(caKeyPath) || !isCertValid(caCertPath)) {
|
|
176
|
+
generateCA(stateDir);
|
|
177
|
+
caGenerated = true;
|
|
178
|
+
}
|
|
179
|
+
if (caGenerated || !fileExists(serverCertPath) || !isCertValid(serverCertPath)) {
|
|
180
|
+
generateServerCert(stateDir);
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
certPath: serverCertPath,
|
|
184
|
+
keyPath: path.join(stateDir, SERVER_KEY_FILE),
|
|
185
|
+
caPath: caCertPath,
|
|
186
|
+
caGenerated
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function isCATrusted(stateDir) {
|
|
190
|
+
const caCertPath = path.join(stateDir, CA_CERT_FILE);
|
|
191
|
+
if (!fileExists(caCertPath)) return false;
|
|
192
|
+
if (process.platform === "darwin") {
|
|
193
|
+
return isCATrustedMacOS(caCertPath);
|
|
194
|
+
} else if (process.platform === "linux") {
|
|
195
|
+
return isCATrustedLinux(stateDir);
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
function isCATrustedMacOS(caCertPath) {
|
|
200
|
+
try {
|
|
201
|
+
const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
|
|
202
|
+
for (const keychain of [loginKeychainPath(), "/Library/Keychains/System.keychain"]) {
|
|
203
|
+
try {
|
|
204
|
+
const result = execFileSync("security", ["find-certificate", "-a", "-Z", keychain], {
|
|
205
|
+
encoding: "utf-8",
|
|
206
|
+
timeout: 5e3,
|
|
207
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
208
|
+
});
|
|
209
|
+
if (result.toLowerCase().includes(fingerprint)) return true;
|
|
210
|
+
} catch {
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function loginKeychainPath() {
|
|
219
|
+
try {
|
|
220
|
+
const result = execFileSync("security", ["default-keychain"], {
|
|
221
|
+
encoding: "utf-8",
|
|
222
|
+
timeout: 5e3
|
|
223
|
+
}).trim();
|
|
224
|
+
const match = result.match(/"(.+)"/);
|
|
225
|
+
if (match) return match[1];
|
|
226
|
+
} catch {
|
|
227
|
+
}
|
|
228
|
+
const home = process.env.HOME || `/Users/${process.env.USER || "unknown"}`;
|
|
229
|
+
return path.join(home, "Library", "Keychains", "login.keychain-db");
|
|
230
|
+
}
|
|
231
|
+
function isCATrustedLinux(stateDir) {
|
|
232
|
+
const systemCertPath = `/usr/local/share/ca-certificates/portless-ca.crt`;
|
|
233
|
+
if (!fileExists(systemCertPath)) return false;
|
|
234
|
+
try {
|
|
235
|
+
const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
|
|
236
|
+
const installed = fs.readFileSync(systemCertPath, "utf-8").trim();
|
|
237
|
+
return ours === installed;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
var HOST_CERTS_DIR = "host-certs";
|
|
243
|
+
function sanitizeHostForFilename(hostname) {
|
|
244
|
+
return hostname.replace(/\./g, "_").replace(/[^a-z0-9_-]/gi, "");
|
|
245
|
+
}
|
|
246
|
+
async function generateHostCertAsync(stateDir, hostname) {
|
|
247
|
+
const caKeyPath = path.join(stateDir, CA_KEY_FILE);
|
|
248
|
+
const caCertPath = path.join(stateDir, CA_CERT_FILE);
|
|
249
|
+
const hostDir = path.join(stateDir, HOST_CERTS_DIR);
|
|
250
|
+
if (!fs.existsSync(hostDir)) {
|
|
251
|
+
await fs.promises.mkdir(hostDir, { recursive: true, mode: 493 });
|
|
252
|
+
fixOwnership(hostDir);
|
|
253
|
+
}
|
|
254
|
+
const safeName = sanitizeHostForFilename(hostname);
|
|
255
|
+
const keyPath = path.join(hostDir, `${safeName}-key.pem`);
|
|
256
|
+
const certPath = path.join(hostDir, `${safeName}.pem`);
|
|
257
|
+
const csrPath = path.join(hostDir, `${safeName}.csr`);
|
|
258
|
+
const extPath = path.join(hostDir, `${safeName}-ext.cnf`);
|
|
259
|
+
await opensslAsync(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", keyPath]);
|
|
260
|
+
await opensslAsync(["req", "-new", "-key", keyPath, "-out", csrPath, "-subj", `/CN=${hostname}`]);
|
|
261
|
+
const sans = [`DNS:${hostname}`];
|
|
262
|
+
const parts = hostname.split(".");
|
|
263
|
+
if (parts.length >= 2) {
|
|
264
|
+
sans.push(`DNS:*.${parts.slice(1).join(".")}`);
|
|
265
|
+
}
|
|
266
|
+
await fs.promises.writeFile(
|
|
267
|
+
extPath,
|
|
268
|
+
[
|
|
269
|
+
"authorityKeyIdentifier=keyid,issuer",
|
|
270
|
+
"basicConstraints=CA:FALSE",
|
|
271
|
+
"keyUsage=digitalSignature,keyEncipherment",
|
|
272
|
+
"extendedKeyUsage=serverAuth",
|
|
273
|
+
`subjectAltName=${sans.join(",")}`
|
|
274
|
+
].join("\n") + "\n"
|
|
275
|
+
);
|
|
276
|
+
await opensslAsync([
|
|
277
|
+
"x509",
|
|
278
|
+
"-req",
|
|
279
|
+
"-in",
|
|
280
|
+
csrPath,
|
|
281
|
+
"-CA",
|
|
282
|
+
caCertPath,
|
|
283
|
+
"-CAkey",
|
|
284
|
+
caKeyPath,
|
|
285
|
+
"-CAcreateserial",
|
|
286
|
+
"-out",
|
|
287
|
+
certPath,
|
|
288
|
+
"-days",
|
|
289
|
+
SERVER_VALIDITY_DAYS.toString(),
|
|
290
|
+
"-extfile",
|
|
291
|
+
extPath
|
|
292
|
+
]);
|
|
293
|
+
for (const tmp of [csrPath, extPath]) {
|
|
294
|
+
try {
|
|
295
|
+
await fs.promises.unlink(tmp);
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
await fs.promises.chmod(keyPath, 384);
|
|
300
|
+
await fs.promises.chmod(certPath, 420);
|
|
301
|
+
fixOwnership(keyPath, certPath);
|
|
302
|
+
return { certPath, keyPath };
|
|
303
|
+
}
|
|
304
|
+
function isSimpleLocalhostSubdomain(hostname) {
|
|
305
|
+
const parts = hostname.split(".");
|
|
306
|
+
return parts.length === 2 && parts[1] === "localhost";
|
|
307
|
+
}
|
|
308
|
+
function createSNICallback(stateDir, defaultCert, defaultKey) {
|
|
309
|
+
const cache = /* @__PURE__ */ new Map();
|
|
310
|
+
const pending = /* @__PURE__ */ new Map();
|
|
311
|
+
const defaultCtx = tls.createSecureContext({ cert: defaultCert, key: defaultKey });
|
|
312
|
+
return (servername, cb) => {
|
|
313
|
+
if (servername === "localhost" || isSimpleLocalhostSubdomain(servername)) {
|
|
314
|
+
cb(null, defaultCtx);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (cache.has(servername)) {
|
|
318
|
+
cb(null, cache.get(servername));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const safeName = sanitizeHostForFilename(servername);
|
|
322
|
+
const hostDir = path.join(stateDir, HOST_CERTS_DIR);
|
|
323
|
+
const certPath = path.join(hostDir, `${safeName}.pem`);
|
|
324
|
+
const keyPath = path.join(hostDir, `${safeName}-key.pem`);
|
|
325
|
+
if (fileExists(certPath) && fileExists(keyPath) && isCertValid(certPath)) {
|
|
326
|
+
try {
|
|
327
|
+
const ctx = tls.createSecureContext({
|
|
328
|
+
cert: fs.readFileSync(certPath),
|
|
329
|
+
key: fs.readFileSync(keyPath)
|
|
330
|
+
});
|
|
331
|
+
cache.set(servername, ctx);
|
|
332
|
+
cb(null, ctx);
|
|
333
|
+
return;
|
|
334
|
+
} catch {
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (pending.has(servername)) {
|
|
338
|
+
pending.get(servername).then((ctx) => cb(null, ctx)).catch((err) => cb(err instanceof Error ? err : new Error(String(err))));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const promise = generateHostCertAsync(stateDir, servername).then(async (generated) => {
|
|
342
|
+
const [cert, key] = await Promise.all([
|
|
343
|
+
fs.promises.readFile(generated.certPath),
|
|
344
|
+
fs.promises.readFile(generated.keyPath)
|
|
345
|
+
]);
|
|
346
|
+
return tls.createSecureContext({ cert, key });
|
|
347
|
+
});
|
|
348
|
+
pending.set(servername, promise);
|
|
349
|
+
promise.then((ctx) => {
|
|
350
|
+
cache.set(servername, ctx);
|
|
351
|
+
pending.delete(servername);
|
|
352
|
+
cb(null, ctx);
|
|
353
|
+
}).catch((err) => {
|
|
354
|
+
pending.delete(servername);
|
|
355
|
+
cb(err instanceof Error ? err : new Error(String(err)));
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function trustCA(stateDir) {
|
|
360
|
+
const caCertPath = path.join(stateDir, CA_CERT_FILE);
|
|
361
|
+
if (!fileExists(caCertPath)) {
|
|
362
|
+
return { trusted: false, error: "CA certificate not found. Run with --https first." };
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
if (process.platform === "darwin") {
|
|
366
|
+
const keychain = loginKeychainPath();
|
|
367
|
+
execFileSync(
|
|
368
|
+
"security",
|
|
369
|
+
["add-trusted-cert", "-r", "trustRoot", "-k", keychain, caCertPath],
|
|
370
|
+
{ stdio: "pipe", timeout: 3e4 }
|
|
371
|
+
);
|
|
372
|
+
return { trusted: true };
|
|
373
|
+
} else if (process.platform === "linux") {
|
|
374
|
+
const dest = "/usr/local/share/ca-certificates/portless-ca.crt";
|
|
375
|
+
fs.copyFileSync(caCertPath, dest);
|
|
376
|
+
execFileSync("update-ca-certificates", [], { stdio: "pipe", timeout: 3e4 });
|
|
377
|
+
return { trusted: true };
|
|
378
|
+
}
|
|
379
|
+
return { trusted: false, error: `Unsupported platform: ${process.platform}` };
|
|
380
|
+
} catch (err) {
|
|
381
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
382
|
+
if (message.includes("authorization") || message.includes("permission") || message.includes("EACCES")) {
|
|
383
|
+
return {
|
|
384
|
+
trusted: false,
|
|
385
|
+
error: "Permission denied. Try: sudo portless trust"
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return { trusted: false, error: message };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/cli-utils.ts
|
|
393
|
+
import * as fs2 from "fs";
|
|
20
394
|
import * as http from "http";
|
|
395
|
+
import * as https from "https";
|
|
21
396
|
import * as net from "net";
|
|
22
397
|
import * as os from "os";
|
|
23
|
-
import * as
|
|
398
|
+
import * as path2 from "path";
|
|
24
399
|
import * as readline from "readline";
|
|
25
400
|
import { execSync, spawn } from "child_process";
|
|
26
401
|
var DEFAULT_PROXY_PORT = 1355;
|
|
27
402
|
var PRIVILEGED_PORT_THRESHOLD = 1024;
|
|
28
403
|
var SYSTEM_STATE_DIR = "/tmp/portless";
|
|
29
|
-
var USER_STATE_DIR =
|
|
404
|
+
var USER_STATE_DIR = path2.join(os.homedir(), ".portless");
|
|
30
405
|
var MIN_APP_PORT = 4e3;
|
|
31
406
|
var MAX_APP_PORT = 4999;
|
|
32
407
|
var RANDOM_PORT_ATTEMPTS = 50;
|
|
@@ -56,29 +431,59 @@ function resolveStateDir(port) {
|
|
|
56
431
|
}
|
|
57
432
|
function readPortFromDir(dir) {
|
|
58
433
|
try {
|
|
59
|
-
const raw =
|
|
434
|
+
const raw = fs2.readFileSync(path2.join(dir, "proxy.port"), "utf-8").trim();
|
|
60
435
|
const port = parseInt(raw, 10);
|
|
61
436
|
return isNaN(port) ? null : port;
|
|
62
437
|
} catch {
|
|
63
438
|
return null;
|
|
64
439
|
}
|
|
65
440
|
}
|
|
441
|
+
var TLS_MARKER_FILE = "proxy.tls";
|
|
442
|
+
function readTlsMarker(dir) {
|
|
443
|
+
try {
|
|
444
|
+
return fs2.existsSync(path2.join(dir, TLS_MARKER_FILE));
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function writeTlsMarker(dir, enabled) {
|
|
450
|
+
const markerPath = path2.join(dir, TLS_MARKER_FILE);
|
|
451
|
+
if (enabled) {
|
|
452
|
+
fs2.writeFileSync(markerPath, "1", { mode: 420 });
|
|
453
|
+
} else {
|
|
454
|
+
try {
|
|
455
|
+
fs2.unlinkSync(markerPath);
|
|
456
|
+
} catch {
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function isHttpsEnvEnabled() {
|
|
461
|
+
const val = process.env.PORTLESS_HTTPS;
|
|
462
|
+
return val === "1" || val === "true";
|
|
463
|
+
}
|
|
66
464
|
async function discoverState() {
|
|
67
465
|
if (process.env.PORTLESS_STATE_DIR) {
|
|
68
466
|
const dir = process.env.PORTLESS_STATE_DIR;
|
|
69
467
|
const port = readPortFromDir(dir) ?? getDefaultPort();
|
|
70
|
-
|
|
468
|
+
const tls2 = readTlsMarker(dir);
|
|
469
|
+
return { dir, port, tls: tls2 };
|
|
71
470
|
}
|
|
72
471
|
const userPort = readPortFromDir(USER_STATE_DIR);
|
|
73
|
-
if (userPort !== null
|
|
74
|
-
|
|
472
|
+
if (userPort !== null) {
|
|
473
|
+
const tls2 = readTlsMarker(USER_STATE_DIR);
|
|
474
|
+
if (await isProxyRunning(userPort, tls2)) {
|
|
475
|
+
return { dir: USER_STATE_DIR, port: userPort, tls: tls2 };
|
|
476
|
+
}
|
|
75
477
|
}
|
|
76
478
|
const systemPort = readPortFromDir(SYSTEM_STATE_DIR);
|
|
77
|
-
if (systemPort !== null
|
|
78
|
-
|
|
479
|
+
if (systemPort !== null) {
|
|
480
|
+
const tls2 = readTlsMarker(SYSTEM_STATE_DIR);
|
|
481
|
+
if (await isProxyRunning(systemPort, tls2)) {
|
|
482
|
+
return { dir: SYSTEM_STATE_DIR, port: systemPort, tls: tls2 };
|
|
483
|
+
}
|
|
79
484
|
}
|
|
80
485
|
const defaultPort = getDefaultPort();
|
|
81
|
-
return { dir: resolveStateDir(defaultPort), port: defaultPort };
|
|
486
|
+
return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
|
|
82
487
|
}
|
|
83
488
|
async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
|
|
84
489
|
if (minPort > maxPort) {
|
|
@@ -106,15 +511,17 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
|
|
|
106
511
|
}
|
|
107
512
|
throw new Error(`No free port found in range ${minPort}-${maxPort}`);
|
|
108
513
|
}
|
|
109
|
-
function isProxyRunning(port) {
|
|
514
|
+
function isProxyRunning(port, tls2 = false) {
|
|
110
515
|
return new Promise((resolve) => {
|
|
111
|
-
const
|
|
516
|
+
const requestFn = tls2 ? https.request : http.request;
|
|
517
|
+
const req = requestFn(
|
|
112
518
|
{
|
|
113
519
|
hostname: "127.0.0.1",
|
|
114
520
|
port,
|
|
115
521
|
path: "/",
|
|
116
522
|
method: "HEAD",
|
|
117
|
-
timeout: SOCKET_TIMEOUT_MS
|
|
523
|
+
timeout: SOCKET_TIMEOUT_MS,
|
|
524
|
+
...tls2 ? { rejectUnauthorized: false } : {}
|
|
118
525
|
},
|
|
119
526
|
(res) => {
|
|
120
527
|
res.resume();
|
|
@@ -141,10 +548,10 @@ function findPidOnPort(port) {
|
|
|
141
548
|
return null;
|
|
142
549
|
}
|
|
143
550
|
}
|
|
144
|
-
async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS) {
|
|
551
|
+
async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
|
|
145
552
|
for (let i = 0; i < maxAttempts; i++) {
|
|
146
553
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
147
|
-
if (await isProxyRunning(port)) {
|
|
554
|
+
if (await isProxyRunning(port, tls2)) {
|
|
148
555
|
return true;
|
|
149
556
|
}
|
|
150
557
|
}
|
|
@@ -211,14 +618,15 @@ var DEBOUNCE_MS = 100;
|
|
|
211
618
|
var POLL_INTERVAL_MS = 3e3;
|
|
212
619
|
var EXIT_TIMEOUT_MS = 2e3;
|
|
213
620
|
var SUDO_SPAWN_TIMEOUT_MS = 3e4;
|
|
214
|
-
function startProxyServer(store, proxyPort) {
|
|
621
|
+
function startProxyServer(store, proxyPort, tlsOptions) {
|
|
215
622
|
store.ensureDir();
|
|
623
|
+
const isTls = !!tlsOptions;
|
|
216
624
|
const routesPath = store.getRoutesPath();
|
|
217
|
-
if (!
|
|
218
|
-
|
|
625
|
+
if (!fs3.existsSync(routesPath)) {
|
|
626
|
+
fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
|
|
219
627
|
}
|
|
220
628
|
try {
|
|
221
|
-
|
|
629
|
+
fs3.chmodSync(routesPath, FILE_MODE);
|
|
222
630
|
} catch {
|
|
223
631
|
}
|
|
224
632
|
let cachedRoutes = store.loadRoutes();
|
|
@@ -232,7 +640,7 @@ function startProxyServer(store, proxyPort) {
|
|
|
232
640
|
}
|
|
233
641
|
};
|
|
234
642
|
try {
|
|
235
|
-
watcher =
|
|
643
|
+
watcher = fs3.watch(routesPath, () => {
|
|
236
644
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
237
645
|
debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
|
|
238
646
|
});
|
|
@@ -243,7 +651,8 @@ function startProxyServer(store, proxyPort) {
|
|
|
243
651
|
const server = createProxyServer({
|
|
244
652
|
getRoutes: () => cachedRoutes,
|
|
245
653
|
proxyPort,
|
|
246
|
-
onError: (msg) => console.error(chalk.red(msg))
|
|
654
|
+
onError: (msg) => console.error(chalk.red(msg)),
|
|
655
|
+
tls: tlsOptions
|
|
247
656
|
});
|
|
248
657
|
server.on("error", (err) => {
|
|
249
658
|
if (err.code === "EADDRINUSE") {
|
|
@@ -264,9 +673,11 @@ function startProxyServer(store, proxyPort) {
|
|
|
264
673
|
process.exit(1);
|
|
265
674
|
});
|
|
266
675
|
server.listen(proxyPort, () => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
676
|
+
fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
|
|
677
|
+
fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
|
|
678
|
+
writeTlsMarker(store.dir, isTls);
|
|
679
|
+
const proto = isTls ? "HTTPS/2" : "HTTP";
|
|
680
|
+
console.log(chalk.green(`${proto} proxy listening on port ${proxyPort}`));
|
|
270
681
|
});
|
|
271
682
|
let exiting = false;
|
|
272
683
|
const cleanup = () => {
|
|
@@ -278,13 +689,14 @@ function startProxyServer(store, proxyPort) {
|
|
|
278
689
|
watcher.close();
|
|
279
690
|
}
|
|
280
691
|
try {
|
|
281
|
-
|
|
692
|
+
fs3.unlinkSync(store.pidPath);
|
|
282
693
|
} catch {
|
|
283
694
|
}
|
|
284
695
|
try {
|
|
285
|
-
|
|
696
|
+
fs3.unlinkSync(store.portFilePath);
|
|
286
697
|
} catch {
|
|
287
698
|
}
|
|
699
|
+
writeTlsMarker(store.dir, false);
|
|
288
700
|
server.close(() => process.exit(0));
|
|
289
701
|
setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
|
|
290
702
|
};
|
|
@@ -293,19 +705,19 @@ function startProxyServer(store, proxyPort) {
|
|
|
293
705
|
console.log(chalk.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
|
|
294
706
|
console.log(chalk.gray(`Routes file: ${store.getRoutesPath()}`));
|
|
295
707
|
}
|
|
296
|
-
async function stopProxy(store, proxyPort) {
|
|
708
|
+
async function stopProxy(store, proxyPort, tls2) {
|
|
297
709
|
const pidPath = store.pidPath;
|
|
298
710
|
const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
|
|
299
711
|
const sudoHint = needsSudo ? "sudo " : "";
|
|
300
|
-
if (!
|
|
301
|
-
if (await isProxyRunning(proxyPort)) {
|
|
712
|
+
if (!fs3.existsSync(pidPath)) {
|
|
713
|
+
if (await isProxyRunning(proxyPort, tls2)) {
|
|
302
714
|
console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
|
|
303
715
|
const pid = findPidOnPort(proxyPort);
|
|
304
716
|
if (pid !== null) {
|
|
305
717
|
try {
|
|
306
718
|
process.kill(pid, "SIGTERM");
|
|
307
719
|
try {
|
|
308
|
-
|
|
720
|
+
fs3.unlinkSync(store.portFilePath);
|
|
309
721
|
} catch {
|
|
310
722
|
}
|
|
311
723
|
console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
|
|
@@ -336,37 +748,37 @@ async function stopProxy(store, proxyPort) {
|
|
|
336
748
|
return;
|
|
337
749
|
}
|
|
338
750
|
try {
|
|
339
|
-
const pid = parseInt(
|
|
751
|
+
const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
|
|
340
752
|
if (isNaN(pid)) {
|
|
341
753
|
console.error(chalk.red("Corrupted PID file. Removing it."));
|
|
342
|
-
|
|
754
|
+
fs3.unlinkSync(pidPath);
|
|
343
755
|
return;
|
|
344
756
|
}
|
|
345
757
|
try {
|
|
346
758
|
process.kill(pid, 0);
|
|
347
759
|
} catch {
|
|
348
760
|
console.log(chalk.yellow("Proxy process is no longer running. Cleaning up stale files."));
|
|
349
|
-
|
|
761
|
+
fs3.unlinkSync(pidPath);
|
|
350
762
|
try {
|
|
351
|
-
|
|
763
|
+
fs3.unlinkSync(store.portFilePath);
|
|
352
764
|
} catch {
|
|
353
765
|
}
|
|
354
766
|
return;
|
|
355
767
|
}
|
|
356
|
-
if (!await isProxyRunning(proxyPort)) {
|
|
768
|
+
if (!await isProxyRunning(proxyPort, tls2)) {
|
|
357
769
|
console.log(
|
|
358
770
|
chalk.yellow(
|
|
359
771
|
`PID file exists but port ${proxyPort} is not listening. The PID may have been recycled.`
|
|
360
772
|
)
|
|
361
773
|
);
|
|
362
774
|
console.log(chalk.yellow("Removing stale PID file."));
|
|
363
|
-
|
|
775
|
+
fs3.unlinkSync(pidPath);
|
|
364
776
|
return;
|
|
365
777
|
}
|
|
366
778
|
process.kill(pid, "SIGTERM");
|
|
367
|
-
|
|
779
|
+
fs3.unlinkSync(pidPath);
|
|
368
780
|
try {
|
|
369
|
-
|
|
781
|
+
fs3.unlinkSync(store.portFilePath);
|
|
370
782
|
} catch {
|
|
371
783
|
}
|
|
372
784
|
console.log(chalk.green("Proxy stopped."));
|
|
@@ -383,7 +795,7 @@ async function stopProxy(store, proxyPort) {
|
|
|
383
795
|
}
|
|
384
796
|
}
|
|
385
797
|
}
|
|
386
|
-
function listRoutes(store, proxyPort) {
|
|
798
|
+
function listRoutes(store, proxyPort, tls2) {
|
|
387
799
|
const routes = store.loadRoutes();
|
|
388
800
|
if (routes.length === 0) {
|
|
389
801
|
console.log(chalk.yellow("No active routes."));
|
|
@@ -392,23 +804,23 @@ function listRoutes(store, proxyPort) {
|
|
|
392
804
|
}
|
|
393
805
|
console.log(chalk.blue.bold("\nActive routes:\n"));
|
|
394
806
|
for (const route of routes) {
|
|
395
|
-
const url = formatUrl(route.hostname, proxyPort);
|
|
807
|
+
const url = formatUrl(route.hostname, proxyPort, tls2);
|
|
396
808
|
console.log(
|
|
397
809
|
` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
|
|
398
810
|
);
|
|
399
811
|
}
|
|
400
812
|
console.log();
|
|
401
813
|
}
|
|
402
|
-
async function runApp(store, proxyPort, stateDir, name, commandArgs) {
|
|
814
|
+
async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2) {
|
|
403
815
|
const hostname = parseHostname(name);
|
|
404
|
-
const appUrl = formatUrl(hostname, proxyPort);
|
|
405
816
|
console.log(chalk.blue.bold(`
|
|
406
817
|
portless
|
|
407
818
|
`));
|
|
408
819
|
console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
|
|
409
|
-
if (!await isProxyRunning(proxyPort)) {
|
|
820
|
+
if (!await isProxyRunning(proxyPort, tls2)) {
|
|
410
821
|
const defaultPort = getDefaultPort();
|
|
411
822
|
const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
|
|
823
|
+
const wantHttps = isHttpsEnvEnabled();
|
|
412
824
|
if (needsSudo) {
|
|
413
825
|
if (!process.stdin.isTTY) {
|
|
414
826
|
console.error(chalk.red("Proxy is not running."));
|
|
@@ -429,7 +841,9 @@ portless
|
|
|
429
841
|
return;
|
|
430
842
|
}
|
|
431
843
|
console.log(chalk.yellow("Starting proxy (requires sudo)..."));
|
|
432
|
-
const
|
|
844
|
+
const startArgs = [process.execPath, process.argv[1], "proxy", "start"];
|
|
845
|
+
if (wantHttps) startArgs.push("--https");
|
|
846
|
+
const result = spawnSync("sudo", startArgs, {
|
|
433
847
|
stdio: "inherit",
|
|
434
848
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
435
849
|
});
|
|
@@ -441,7 +855,9 @@ portless
|
|
|
441
855
|
}
|
|
442
856
|
} else {
|
|
443
857
|
console.log(chalk.yellow("Starting proxy..."));
|
|
444
|
-
const
|
|
858
|
+
const startArgs = [process.argv[1], "proxy", "start"];
|
|
859
|
+
if (wantHttps) startArgs.push("--https");
|
|
860
|
+
const result = spawnSync(process.execPath, startArgs, {
|
|
445
861
|
stdio: "inherit",
|
|
446
862
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
447
863
|
});
|
|
@@ -452,16 +868,18 @@ portless
|
|
|
452
868
|
process.exit(1);
|
|
453
869
|
}
|
|
454
870
|
}
|
|
455
|
-
|
|
871
|
+
const autoTls = readTlsMarker(stateDir);
|
|
872
|
+
if (!await waitForProxy(defaultPort, void 0, void 0, autoTls)) {
|
|
456
873
|
console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
|
|
457
|
-
const logPath =
|
|
874
|
+
const logPath = path3.join(stateDir, "proxy.log");
|
|
458
875
|
console.error(chalk.blue("Try starting the proxy manually to see the error:"));
|
|
459
876
|
console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start`));
|
|
460
|
-
if (
|
|
877
|
+
if (fs3.existsSync(logPath)) {
|
|
461
878
|
console.error(chalk.gray(`Logs: ${logPath}`));
|
|
462
879
|
}
|
|
463
880
|
process.exit(1);
|
|
464
881
|
}
|
|
882
|
+
tls2 = autoTls;
|
|
465
883
|
console.log(chalk.green("Proxy started in background"));
|
|
466
884
|
} else {
|
|
467
885
|
console.log(chalk.gray("-- Proxy is running"));
|
|
@@ -469,8 +887,9 @@ portless
|
|
|
469
887
|
const port = await findFreePort();
|
|
470
888
|
console.log(chalk.green(`-- Using port ${port}`));
|
|
471
889
|
store.addRoute(hostname, port, process.pid);
|
|
890
|
+
const finalUrl = formatUrl(hostname, proxyPort, tls2);
|
|
472
891
|
console.log(chalk.cyan.bold(`
|
|
473
|
-
-> ${
|
|
892
|
+
-> ${finalUrl}
|
|
474
893
|
`));
|
|
475
894
|
console.log(chalk.gray(`Running: PORT=${port} ${commandArgs.join(" ")}
|
|
476
895
|
`));
|
|
@@ -512,13 +931,16 @@ ${chalk.bold("Install:")}
|
|
|
512
931
|
|
|
513
932
|
${chalk.bold("Usage:")}
|
|
514
933
|
${chalk.cyan("portless proxy start")} Start the proxy (background daemon)
|
|
934
|
+
${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS (auto-generates certs)
|
|
515
935
|
${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
|
|
516
936
|
${chalk.cyan("portless proxy stop")} Stop the proxy
|
|
517
937
|
${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
|
|
518
938
|
${chalk.cyan("portless list")} Show active routes
|
|
939
|
+
${chalk.cyan("portless trust")} Add local CA to system trust store
|
|
519
940
|
|
|
520
941
|
${chalk.bold("Examples:")}
|
|
521
942
|
portless proxy start # Start proxy on port 1355
|
|
943
|
+
portless proxy start --https # Start with HTTPS/2 (faster page loads)
|
|
522
944
|
portless myapp next dev # -> http://myapp.localhost:1355
|
|
523
945
|
portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
|
|
524
946
|
|
|
@@ -535,13 +957,23 @@ ${chalk.bold("How it works:")}
|
|
|
535
957
|
3. Access via http://<name>.localhost:1355
|
|
536
958
|
4. .localhost domains auto-resolve to 127.0.0.1
|
|
537
959
|
|
|
960
|
+
${chalk.bold("HTTP/2 + HTTPS:")}
|
|
961
|
+
Use --https for HTTP/2 multiplexing (faster dev server page loads).
|
|
962
|
+
On first use, portless generates a local CA and adds it to your
|
|
963
|
+
system trust store. No browser warnings. No sudo required on macOS.
|
|
964
|
+
|
|
538
965
|
${chalk.bold("Options:")}
|
|
539
966
|
-p, --port <number> Port for the proxy to listen on (default: 1355)
|
|
540
967
|
Ports < 1024 require sudo
|
|
968
|
+
--https Enable HTTP/2 + TLS with auto-generated certs
|
|
969
|
+
--cert <path> Use a custom TLS certificate (implies --https)
|
|
970
|
+
--key <path> Use a custom TLS private key (implies --https)
|
|
971
|
+
--no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
|
|
541
972
|
--foreground Run proxy in foreground (for debugging)
|
|
542
973
|
|
|
543
974
|
${chalk.bold("Environment variables:")}
|
|
544
975
|
PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
|
|
976
|
+
PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
|
|
545
977
|
PORTLESS_STATE_DIR=<path> Override the state directory
|
|
546
978
|
PORTLESS=0 | PORTLESS=skip Run command directly without proxy
|
|
547
979
|
|
|
@@ -552,24 +984,40 @@ ${chalk.bold("Skip portless:")}
|
|
|
552
984
|
process.exit(0);
|
|
553
985
|
}
|
|
554
986
|
if (args[0] === "--version" || args[0] === "-v") {
|
|
555
|
-
console.log("0.
|
|
987
|
+
console.log("0.4.0");
|
|
556
988
|
process.exit(0);
|
|
557
989
|
}
|
|
990
|
+
if (args[0] === "trust") {
|
|
991
|
+
const { dir: dir2 } = await discoverState();
|
|
992
|
+
const result = trustCA(dir2);
|
|
993
|
+
if (result.trusted) {
|
|
994
|
+
console.log(chalk.green("Local CA added to system trust store."));
|
|
995
|
+
console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
|
|
996
|
+
} else {
|
|
997
|
+
console.error(chalk.red(`Failed to trust CA: ${result.error}`));
|
|
998
|
+
if (result.error?.includes("sudo")) {
|
|
999
|
+
console.error(chalk.blue("Run with sudo:"));
|
|
1000
|
+
console.error(chalk.cyan(" sudo portless trust"));
|
|
1001
|
+
}
|
|
1002
|
+
process.exit(1);
|
|
1003
|
+
}
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
558
1006
|
if (args[0] === "list") {
|
|
559
|
-
const { dir: dir2, port: port2 } = await discoverState();
|
|
1007
|
+
const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
|
|
560
1008
|
const store2 = new RouteStore(dir2, {
|
|
561
1009
|
onWarning: (msg) => console.warn(chalk.yellow(msg))
|
|
562
1010
|
});
|
|
563
|
-
listRoutes(store2, port2);
|
|
1011
|
+
listRoutes(store2, port2, tls3);
|
|
564
1012
|
return;
|
|
565
1013
|
}
|
|
566
1014
|
if (args[0] === "proxy") {
|
|
567
1015
|
if (args[1] === "stop") {
|
|
568
|
-
const { dir: dir2, port: port2 } = await discoverState();
|
|
1016
|
+
const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
|
|
569
1017
|
const store3 = new RouteStore(dir2, {
|
|
570
1018
|
onWarning: (msg) => console.warn(chalk.yellow(msg))
|
|
571
1019
|
});
|
|
572
|
-
await stopProxy(store3, port2);
|
|
1020
|
+
await stopProxy(store3, port2, tls3);
|
|
573
1021
|
return;
|
|
574
1022
|
}
|
|
575
1023
|
if (args[1] !== "start") {
|
|
@@ -577,6 +1025,7 @@ ${chalk.bold("Skip portless:")}
|
|
|
577
1025
|
${chalk.bold("Usage: portless proxy <command>")}
|
|
578
1026
|
|
|
579
1027
|
${chalk.cyan("portless proxy start")} Start the proxy (daemon)
|
|
1028
|
+
${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS
|
|
580
1029
|
${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
|
|
581
1030
|
${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
|
|
582
1031
|
${chalk.cyan("portless proxy stop")} Stop the proxy
|
|
@@ -602,6 +1051,32 @@ ${chalk.bold("Usage: portless proxy <command>")}
|
|
|
602
1051
|
process.exit(1);
|
|
603
1052
|
}
|
|
604
1053
|
}
|
|
1054
|
+
const hasNoTls = args.includes("--no-tls");
|
|
1055
|
+
const hasHttpsFlag = args.includes("--https");
|
|
1056
|
+
const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
|
|
1057
|
+
let customCertPath = null;
|
|
1058
|
+
let customKeyPath = null;
|
|
1059
|
+
const certIdx = args.indexOf("--cert");
|
|
1060
|
+
if (certIdx !== -1) {
|
|
1061
|
+
customCertPath = args[certIdx + 1] || null;
|
|
1062
|
+
if (!customCertPath || customCertPath.startsWith("-")) {
|
|
1063
|
+
console.error(chalk.red("Error: --cert requires a file path."));
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
const keyIdx = args.indexOf("--key");
|
|
1068
|
+
if (keyIdx !== -1) {
|
|
1069
|
+
customKeyPath = args[keyIdx + 1] || null;
|
|
1070
|
+
if (!customKeyPath || customKeyPath.startsWith("-")) {
|
|
1071
|
+
console.error(chalk.red("Error: --key requires a file path."));
|
|
1072
|
+
process.exit(1);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
|
|
1076
|
+
console.error(chalk.red("Error: --cert and --key must be used together."));
|
|
1077
|
+
process.exit(1);
|
|
1078
|
+
}
|
|
1079
|
+
const useHttps = wantHttps || !!(customCertPath && customKeyPath);
|
|
605
1080
|
const stateDir = resolveStateDir(proxyPort);
|
|
606
1081
|
const store2 = new RouteStore(stateDir, {
|
|
607
1082
|
onWarning: (msg) => console.warn(chalk.yellow(msg))
|
|
@@ -626,23 +1101,90 @@ ${chalk.bold("Usage: portless proxy <command>")}
|
|
|
626
1101
|
console.error(chalk.cyan(" portless proxy start"));
|
|
627
1102
|
process.exit(1);
|
|
628
1103
|
}
|
|
1104
|
+
let tlsOptions;
|
|
1105
|
+
if (useHttps) {
|
|
1106
|
+
store2.ensureDir();
|
|
1107
|
+
if (customCertPath && customKeyPath) {
|
|
1108
|
+
try {
|
|
1109
|
+
const cert = fs3.readFileSync(customCertPath);
|
|
1110
|
+
const key = fs3.readFileSync(customKeyPath);
|
|
1111
|
+
const certStr = cert.toString("utf-8");
|
|
1112
|
+
const keyStr = key.toString("utf-8");
|
|
1113
|
+
if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
|
|
1114
|
+
console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
|
|
1115
|
+
console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
|
|
1116
|
+
process.exit(1);
|
|
1117
|
+
}
|
|
1118
|
+
if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
|
|
1119
|
+
console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
|
|
1120
|
+
console.error(
|
|
1121
|
+
chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----")
|
|
1122
|
+
);
|
|
1123
|
+
process.exit(1);
|
|
1124
|
+
}
|
|
1125
|
+
tlsOptions = { cert, key };
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1128
|
+
console.error(chalk.red(`Error reading certificate files: ${message}`));
|
|
1129
|
+
process.exit(1);
|
|
1130
|
+
}
|
|
1131
|
+
} else {
|
|
1132
|
+
console.log(chalk.gray("Ensuring TLS certificates..."));
|
|
1133
|
+
const certs = ensureCerts(stateDir);
|
|
1134
|
+
if (certs.caGenerated) {
|
|
1135
|
+
console.log(chalk.green("Generated local CA certificate."));
|
|
1136
|
+
}
|
|
1137
|
+
if (!isCATrusted(stateDir)) {
|
|
1138
|
+
console.log(chalk.yellow("Adding CA to system trust store..."));
|
|
1139
|
+
const trustResult = trustCA(stateDir);
|
|
1140
|
+
if (trustResult.trusted) {
|
|
1141
|
+
console.log(
|
|
1142
|
+
chalk.green("CA added to system trust store. Browsers will trust portless certs.")
|
|
1143
|
+
);
|
|
1144
|
+
} else {
|
|
1145
|
+
console.warn(chalk.yellow("Could not add CA to system trust store."));
|
|
1146
|
+
if (trustResult.error) {
|
|
1147
|
+
console.warn(chalk.gray(trustResult.error));
|
|
1148
|
+
}
|
|
1149
|
+
console.warn(
|
|
1150
|
+
chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
|
|
1151
|
+
);
|
|
1152
|
+
console.warn(chalk.cyan(" portless trust"));
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
const cert = fs3.readFileSync(certs.certPath);
|
|
1156
|
+
const key = fs3.readFileSync(certs.keyPath);
|
|
1157
|
+
tlsOptions = {
|
|
1158
|
+
cert,
|
|
1159
|
+
key,
|
|
1160
|
+
SNICallback: createSNICallback(stateDir, cert, key)
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
629
1164
|
if (isForeground) {
|
|
630
1165
|
console.log(chalk.blue.bold("\nportless proxy\n"));
|
|
631
|
-
startProxyServer(store2, proxyPort);
|
|
1166
|
+
startProxyServer(store2, proxyPort, tlsOptions);
|
|
632
1167
|
return;
|
|
633
1168
|
}
|
|
634
1169
|
store2.ensureDir();
|
|
635
|
-
const logPath =
|
|
636
|
-
const logFd =
|
|
1170
|
+
const logPath = path3.join(stateDir, "proxy.log");
|
|
1171
|
+
const logFd = fs3.openSync(logPath, "a");
|
|
637
1172
|
try {
|
|
638
1173
|
try {
|
|
639
|
-
|
|
1174
|
+
fs3.chmodSync(logPath, FILE_MODE);
|
|
640
1175
|
} catch {
|
|
641
1176
|
}
|
|
642
1177
|
const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
|
|
643
1178
|
if (portFlagIndex !== -1) {
|
|
644
1179
|
daemonArgs.push("--port", proxyPort.toString());
|
|
645
1180
|
}
|
|
1181
|
+
if (useHttps) {
|
|
1182
|
+
if (customCertPath && customKeyPath) {
|
|
1183
|
+
daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
|
|
1184
|
+
} else {
|
|
1185
|
+
daemonArgs.push("--https");
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
646
1188
|
const child = spawn2(process.execPath, daemonArgs, {
|
|
647
1189
|
detached: true,
|
|
648
1190
|
stdio: ["ignore", logFd, logFd],
|
|
@@ -650,19 +1192,20 @@ ${chalk.bold("Usage: portless proxy <command>")}
|
|
|
650
1192
|
});
|
|
651
1193
|
child.unref();
|
|
652
1194
|
} finally {
|
|
653
|
-
|
|
1195
|
+
fs3.closeSync(logFd);
|
|
654
1196
|
}
|
|
655
|
-
if (!await waitForProxy(proxyPort)) {
|
|
1197
|
+
if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
|
|
656
1198
|
console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
|
|
657
1199
|
console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
|
|
658
1200
|
const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
|
|
659
1201
|
console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
|
|
660
|
-
if (
|
|
1202
|
+
if (fs3.existsSync(logPath)) {
|
|
661
1203
|
console.error(chalk.gray(`Logs: ${logPath}`));
|
|
662
1204
|
}
|
|
663
1205
|
process.exit(1);
|
|
664
1206
|
}
|
|
665
|
-
|
|
1207
|
+
const proto = useHttps ? "HTTPS/2" : "HTTP";
|
|
1208
|
+
console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
|
|
666
1209
|
return;
|
|
667
1210
|
}
|
|
668
1211
|
const name = args[0];
|
|
@@ -675,11 +1218,11 @@ ${chalk.bold("Usage: portless proxy <command>")}
|
|
|
675
1218
|
console.error(chalk.cyan(" portless myapp next dev"));
|
|
676
1219
|
process.exit(1);
|
|
677
1220
|
}
|
|
678
|
-
const { dir, port } = await discoverState();
|
|
1221
|
+
const { dir, port, tls: tls2 } = await discoverState();
|
|
679
1222
|
const store = new RouteStore(dir, {
|
|
680
1223
|
onWarning: (msg) => console.warn(chalk.yellow(msg))
|
|
681
1224
|
});
|
|
682
|
-
await runApp(store, port, dir, name, commandArgs);
|
|
1225
|
+
await runApp(store, port, dir, name, commandArgs, tls2);
|
|
683
1226
|
}
|
|
684
1227
|
main().catch((err) => {
|
|
685
1228
|
const message = err instanceof Error ? err.message : String(err);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as node_tls from 'node:tls';
|
|
1
2
|
import * as http from 'node:http';
|
|
3
|
+
import * as net from 'node:net';
|
|
2
4
|
|
|
3
5
|
/** Route info used by the proxy server to map hostnames to ports. */
|
|
4
6
|
interface RouteInfo {
|
|
@@ -12,18 +14,31 @@ interface ProxyServerOptions {
|
|
|
12
14
|
proxyPort: number;
|
|
13
15
|
/** Optional error logger; defaults to console.error. */
|
|
14
16
|
onError?: (message: string) => void;
|
|
17
|
+
/** When provided, enables HTTP/2 over TLS (HTTPS). */
|
|
18
|
+
tls?: {
|
|
19
|
+
cert: Buffer;
|
|
20
|
+
key: Buffer;
|
|
21
|
+
/** SNI callback for per-hostname certificate selection. */
|
|
22
|
+
SNICallback?: (servername: string, cb: (err: Error | null, ctx?: node_tls.SecureContext) => void) => void;
|
|
23
|
+
};
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
/** Response header used to identify a portless proxy (for health checks). */
|
|
18
27
|
declare const PORTLESS_HEADER = "X-Portless";
|
|
28
|
+
/** Server type returned by createProxyServer (plain HTTP/1.1 or net.Server TLS wrapper). */
|
|
29
|
+
type ProxyServer = http.Server | net.Server;
|
|
19
30
|
/**
|
|
20
31
|
* Create an HTTP proxy server that routes requests based on the Host header.
|
|
21
32
|
*
|
|
22
33
|
* Uses Node's built-in http module for proxying (no external dependencies).
|
|
23
34
|
* The `getRoutes` callback is invoked on every request so callers can provide
|
|
24
35
|
* either a static list or a live-updating one.
|
|
36
|
+
*
|
|
37
|
+
* When `tls` is provided, creates an HTTP/2 secure server with HTTP/1.1
|
|
38
|
+
* fallback (`allowHTTP1: true`). This enables HTTP/2 multiplexing for
|
|
39
|
+
* browsers while keeping WebSocket upgrades working over HTTP/1.1.
|
|
25
40
|
*/
|
|
26
|
-
declare function createProxyServer(options: ProxyServerOptions):
|
|
41
|
+
declare function createProxyServer(options: ProxyServerOptions): ProxyServer;
|
|
27
42
|
|
|
28
43
|
/** File permission mode for route and state files. */
|
|
29
44
|
declare const FILE_MODE = 420;
|
|
@@ -37,7 +52,8 @@ interface RouteMapping extends RouteInfo {
|
|
|
37
52
|
* Supports file locking and stale-route cleanup.
|
|
38
53
|
*/
|
|
39
54
|
declare class RouteStore {
|
|
40
|
-
|
|
55
|
+
/** The state directory path. */
|
|
56
|
+
readonly dir: string;
|
|
41
57
|
private readonly routesPath;
|
|
42
58
|
private readonly lockPath;
|
|
43
59
|
readonly pidPath: string;
|
|
@@ -72,13 +88,14 @@ declare function isErrnoException(err: unknown): err is NodeJS.ErrnoException;
|
|
|
72
88
|
*/
|
|
73
89
|
declare function escapeHtml(str: string): string;
|
|
74
90
|
/**
|
|
75
|
-
* Format a .localhost URL
|
|
91
|
+
* Format a .localhost URL. Omits the port when it matches the protocol default
|
|
92
|
+
* (80 for HTTP, 443 for HTTPS).
|
|
76
93
|
*/
|
|
77
|
-
declare function formatUrl(hostname: string, proxyPort: number): string;
|
|
94
|
+
declare function formatUrl(hostname: string, proxyPort: number, tls?: boolean): string;
|
|
78
95
|
/**
|
|
79
96
|
* Parse and normalize a hostname input for use as a .localhost subdomain.
|
|
80
97
|
* Strips protocol prefixes, validates characters, and appends .localhost if needed.
|
|
81
98
|
*/
|
|
82
99
|
declare function parseHostname(input: string): string;
|
|
83
100
|
|
|
84
|
-
export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServerOptions, type RouteInfo, type RouteMapping, RouteStore, createProxyServer, escapeHtml, formatUrl, isErrnoException, parseHostname };
|
|
101
|
+
export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, type RouteInfo, type RouteMapping, RouteStore, createProxyServer, escapeHtml, formatUrl, isErrnoException, parseHostname };
|
package/dist/index.js
CHANGED