portless 0.3.0 → 0.4.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/dist/{chunk-Y5OVKUR4.js → chunk-VRBD6YAY.js} +88 -15
- package/dist/cli.js +643 -69
- package/dist/index.d.ts +22 -5
- package/dist/index.js +1 -1
- package/package.json +11 -30
- package/README.md +0 -131
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
|
}
|
|
@@ -192,6 +599,28 @@ function spawnCommand(commandArgs, options) {
|
|
|
192
599
|
process.exit(code ?? 1);
|
|
193
600
|
});
|
|
194
601
|
}
|
|
602
|
+
var FRAMEWORKS_NEEDING_PORT = {
|
|
603
|
+
vite: { strictPort: true },
|
|
604
|
+
"react-router": { strictPort: true },
|
|
605
|
+
astro: { strictPort: false },
|
|
606
|
+
ng: { strictPort: false }
|
|
607
|
+
};
|
|
608
|
+
function injectFrameworkFlags(commandArgs, port) {
|
|
609
|
+
const cmd = commandArgs[0];
|
|
610
|
+
if (!cmd) return;
|
|
611
|
+
const basename2 = path2.basename(cmd);
|
|
612
|
+
const framework = FRAMEWORKS_NEEDING_PORT[basename2];
|
|
613
|
+
if (!framework) return;
|
|
614
|
+
if (!commandArgs.includes("--port")) {
|
|
615
|
+
commandArgs.push("--port", port.toString());
|
|
616
|
+
if (framework.strictPort) {
|
|
617
|
+
commandArgs.push("--strictPort");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (!commandArgs.includes("--host")) {
|
|
621
|
+
commandArgs.push("--host", "127.0.0.1");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
195
624
|
function prompt(question) {
|
|
196
625
|
const rl = readline.createInterface({
|
|
197
626
|
input: process.stdin,
|
|
@@ -211,14 +640,15 @@ var DEBOUNCE_MS = 100;
|
|
|
211
640
|
var POLL_INTERVAL_MS = 3e3;
|
|
212
641
|
var EXIT_TIMEOUT_MS = 2e3;
|
|
213
642
|
var SUDO_SPAWN_TIMEOUT_MS = 3e4;
|
|
214
|
-
function startProxyServer(store, proxyPort) {
|
|
643
|
+
function startProxyServer(store, proxyPort, tlsOptions) {
|
|
215
644
|
store.ensureDir();
|
|
645
|
+
const isTls = !!tlsOptions;
|
|
216
646
|
const routesPath = store.getRoutesPath();
|
|
217
|
-
if (!
|
|
218
|
-
|
|
647
|
+
if (!fs3.existsSync(routesPath)) {
|
|
648
|
+
fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
|
|
219
649
|
}
|
|
220
650
|
try {
|
|
221
|
-
|
|
651
|
+
fs3.chmodSync(routesPath, FILE_MODE);
|
|
222
652
|
} catch {
|
|
223
653
|
}
|
|
224
654
|
let cachedRoutes = store.loadRoutes();
|
|
@@ -232,7 +662,7 @@ function startProxyServer(store, proxyPort) {
|
|
|
232
662
|
}
|
|
233
663
|
};
|
|
234
664
|
try {
|
|
235
|
-
watcher =
|
|
665
|
+
watcher = fs3.watch(routesPath, () => {
|
|
236
666
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
237
667
|
debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
|
|
238
668
|
});
|
|
@@ -243,7 +673,8 @@ function startProxyServer(store, proxyPort) {
|
|
|
243
673
|
const server = createProxyServer({
|
|
244
674
|
getRoutes: () => cachedRoutes,
|
|
245
675
|
proxyPort,
|
|
246
|
-
onError: (msg) => console.error(chalk.red(msg))
|
|
676
|
+
onError: (msg) => console.error(chalk.red(msg)),
|
|
677
|
+
tls: tlsOptions
|
|
247
678
|
});
|
|
248
679
|
server.on("error", (err) => {
|
|
249
680
|
if (err.code === "EADDRINUSE") {
|
|
@@ -264,9 +695,11 @@ function startProxyServer(store, proxyPort) {
|
|
|
264
695
|
process.exit(1);
|
|
265
696
|
});
|
|
266
697
|
server.listen(proxyPort, () => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
698
|
+
fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
|
|
699
|
+
fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
|
|
700
|
+
writeTlsMarker(store.dir, isTls);
|
|
701
|
+
const proto = isTls ? "HTTPS/2" : "HTTP";
|
|
702
|
+
console.log(chalk.green(`${proto} proxy listening on port ${proxyPort}`));
|
|
270
703
|
});
|
|
271
704
|
let exiting = false;
|
|
272
705
|
const cleanup = () => {
|
|
@@ -278,13 +711,14 @@ function startProxyServer(store, proxyPort) {
|
|
|
278
711
|
watcher.close();
|
|
279
712
|
}
|
|
280
713
|
try {
|
|
281
|
-
|
|
714
|
+
fs3.unlinkSync(store.pidPath);
|
|
282
715
|
} catch {
|
|
283
716
|
}
|
|
284
717
|
try {
|
|
285
|
-
|
|
718
|
+
fs3.unlinkSync(store.portFilePath);
|
|
286
719
|
} catch {
|
|
287
720
|
}
|
|
721
|
+
writeTlsMarker(store.dir, false);
|
|
288
722
|
server.close(() => process.exit(0));
|
|
289
723
|
setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
|
|
290
724
|
};
|
|
@@ -293,19 +727,19 @@ function startProxyServer(store, proxyPort) {
|
|
|
293
727
|
console.log(chalk.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
|
|
294
728
|
console.log(chalk.gray(`Routes file: ${store.getRoutesPath()}`));
|
|
295
729
|
}
|
|
296
|
-
async function stopProxy(store, proxyPort) {
|
|
730
|
+
async function stopProxy(store, proxyPort, tls2) {
|
|
297
731
|
const pidPath = store.pidPath;
|
|
298
732
|
const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
|
|
299
733
|
const sudoHint = needsSudo ? "sudo " : "";
|
|
300
|
-
if (!
|
|
301
|
-
if (await isProxyRunning(proxyPort)) {
|
|
734
|
+
if (!fs3.existsSync(pidPath)) {
|
|
735
|
+
if (await isProxyRunning(proxyPort, tls2)) {
|
|
302
736
|
console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
|
|
303
737
|
const pid = findPidOnPort(proxyPort);
|
|
304
738
|
if (pid !== null) {
|
|
305
739
|
try {
|
|
306
740
|
process.kill(pid, "SIGTERM");
|
|
307
741
|
try {
|
|
308
|
-
|
|
742
|
+
fs3.unlinkSync(store.portFilePath);
|
|
309
743
|
} catch {
|
|
310
744
|
}
|
|
311
745
|
console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
|
|
@@ -336,37 +770,37 @@ async function stopProxy(store, proxyPort) {
|
|
|
336
770
|
return;
|
|
337
771
|
}
|
|
338
772
|
try {
|
|
339
|
-
const pid = parseInt(
|
|
773
|
+
const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
|
|
340
774
|
if (isNaN(pid)) {
|
|
341
775
|
console.error(chalk.red("Corrupted PID file. Removing it."));
|
|
342
|
-
|
|
776
|
+
fs3.unlinkSync(pidPath);
|
|
343
777
|
return;
|
|
344
778
|
}
|
|
345
779
|
try {
|
|
346
780
|
process.kill(pid, 0);
|
|
347
781
|
} catch {
|
|
348
782
|
console.log(chalk.yellow("Proxy process is no longer running. Cleaning up stale files."));
|
|
349
|
-
|
|
783
|
+
fs3.unlinkSync(pidPath);
|
|
350
784
|
try {
|
|
351
|
-
|
|
785
|
+
fs3.unlinkSync(store.portFilePath);
|
|
352
786
|
} catch {
|
|
353
787
|
}
|
|
354
788
|
return;
|
|
355
789
|
}
|
|
356
|
-
if (!await isProxyRunning(proxyPort)) {
|
|
790
|
+
if (!await isProxyRunning(proxyPort, tls2)) {
|
|
357
791
|
console.log(
|
|
358
792
|
chalk.yellow(
|
|
359
793
|
`PID file exists but port ${proxyPort} is not listening. The PID may have been recycled.`
|
|
360
794
|
)
|
|
361
795
|
);
|
|
362
796
|
console.log(chalk.yellow("Removing stale PID file."));
|
|
363
|
-
|
|
797
|
+
fs3.unlinkSync(pidPath);
|
|
364
798
|
return;
|
|
365
799
|
}
|
|
366
800
|
process.kill(pid, "SIGTERM");
|
|
367
|
-
|
|
801
|
+
fs3.unlinkSync(pidPath);
|
|
368
802
|
try {
|
|
369
|
-
|
|
803
|
+
fs3.unlinkSync(store.portFilePath);
|
|
370
804
|
} catch {
|
|
371
805
|
}
|
|
372
806
|
console.log(chalk.green("Proxy stopped."));
|
|
@@ -383,7 +817,7 @@ async function stopProxy(store, proxyPort) {
|
|
|
383
817
|
}
|
|
384
818
|
}
|
|
385
819
|
}
|
|
386
|
-
function listRoutes(store, proxyPort) {
|
|
820
|
+
function listRoutes(store, proxyPort, tls2) {
|
|
387
821
|
const routes = store.loadRoutes();
|
|
388
822
|
if (routes.length === 0) {
|
|
389
823
|
console.log(chalk.yellow("No active routes."));
|
|
@@ -392,23 +826,23 @@ function listRoutes(store, proxyPort) {
|
|
|
392
826
|
}
|
|
393
827
|
console.log(chalk.blue.bold("\nActive routes:\n"));
|
|
394
828
|
for (const route of routes) {
|
|
395
|
-
const url = formatUrl(route.hostname, proxyPort);
|
|
829
|
+
const url = formatUrl(route.hostname, proxyPort, tls2);
|
|
396
830
|
console.log(
|
|
397
831
|
` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
|
|
398
832
|
);
|
|
399
833
|
}
|
|
400
834
|
console.log();
|
|
401
835
|
}
|
|
402
|
-
async function runApp(store, proxyPort, stateDir, name, commandArgs) {
|
|
836
|
+
async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2) {
|
|
403
837
|
const hostname = parseHostname(name);
|
|
404
|
-
const appUrl = formatUrl(hostname, proxyPort);
|
|
405
838
|
console.log(chalk.blue.bold(`
|
|
406
839
|
portless
|
|
407
840
|
`));
|
|
408
841
|
console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
|
|
409
|
-
if (!await isProxyRunning(proxyPort)) {
|
|
842
|
+
if (!await isProxyRunning(proxyPort, tls2)) {
|
|
410
843
|
const defaultPort = getDefaultPort();
|
|
411
844
|
const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
|
|
845
|
+
const wantHttps = isHttpsEnvEnabled();
|
|
412
846
|
if (needsSudo) {
|
|
413
847
|
if (!process.stdin.isTTY) {
|
|
414
848
|
console.error(chalk.red("Proxy is not running."));
|
|
@@ -429,7 +863,9 @@ portless
|
|
|
429
863
|
return;
|
|
430
864
|
}
|
|
431
865
|
console.log(chalk.yellow("Starting proxy (requires sudo)..."));
|
|
432
|
-
const
|
|
866
|
+
const startArgs = [process.execPath, process.argv[1], "proxy", "start"];
|
|
867
|
+
if (wantHttps) startArgs.push("--https");
|
|
868
|
+
const result = spawnSync("sudo", startArgs, {
|
|
433
869
|
stdio: "inherit",
|
|
434
870
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
435
871
|
});
|
|
@@ -441,7 +877,9 @@ portless
|
|
|
441
877
|
}
|
|
442
878
|
} else {
|
|
443
879
|
console.log(chalk.yellow("Starting proxy..."));
|
|
444
|
-
const
|
|
880
|
+
const startArgs = [process.argv[1], "proxy", "start"];
|
|
881
|
+
if (wantHttps) startArgs.push("--https");
|
|
882
|
+
const result = spawnSync(process.execPath, startArgs, {
|
|
445
883
|
stdio: "inherit",
|
|
446
884
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
447
885
|
});
|
|
@@ -452,16 +890,18 @@ portless
|
|
|
452
890
|
process.exit(1);
|
|
453
891
|
}
|
|
454
892
|
}
|
|
455
|
-
|
|
893
|
+
const autoTls = readTlsMarker(stateDir);
|
|
894
|
+
if (!await waitForProxy(defaultPort, void 0, void 0, autoTls)) {
|
|
456
895
|
console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
|
|
457
|
-
const logPath =
|
|
896
|
+
const logPath = path3.join(stateDir, "proxy.log");
|
|
458
897
|
console.error(chalk.blue("Try starting the proxy manually to see the error:"));
|
|
459
898
|
console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start`));
|
|
460
|
-
if (
|
|
899
|
+
if (fs3.existsSync(logPath)) {
|
|
461
900
|
console.error(chalk.gray(`Logs: ${logPath}`));
|
|
462
901
|
}
|
|
463
902
|
process.exit(1);
|
|
464
903
|
}
|
|
904
|
+
tls2 = autoTls;
|
|
465
905
|
console.log(chalk.green("Proxy started in background"));
|
|
466
906
|
} else {
|
|
467
907
|
console.log(chalk.gray("-- Proxy is running"));
|
|
@@ -469,13 +909,20 @@ portless
|
|
|
469
909
|
const port = await findFreePort();
|
|
470
910
|
console.log(chalk.green(`-- Using port ${port}`));
|
|
471
911
|
store.addRoute(hostname, port, process.pid);
|
|
912
|
+
const finalUrl = formatUrl(hostname, proxyPort, tls2);
|
|
472
913
|
console.log(chalk.cyan.bold(`
|
|
473
|
-
-> ${
|
|
914
|
+
-> ${finalUrl}
|
|
474
915
|
`));
|
|
475
|
-
|
|
916
|
+
injectFrameworkFlags(commandArgs, port);
|
|
917
|
+
console.log(chalk.gray(`Running: PORT=${port} HOST=127.0.0.1 ${commandArgs.join(" ")}
|
|
476
918
|
`));
|
|
477
919
|
spawnCommand(commandArgs, {
|
|
478
|
-
env: {
|
|
920
|
+
env: {
|
|
921
|
+
...process.env,
|
|
922
|
+
PORT: port.toString(),
|
|
923
|
+
HOST: "127.0.0.1",
|
|
924
|
+
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ".localhost"
|
|
925
|
+
},
|
|
479
926
|
onCleanup: () => {
|
|
480
927
|
try {
|
|
481
928
|
store.removeRoute(hostname);
|
|
@@ -512,14 +959,18 @@ ${chalk.bold("Install:")}
|
|
|
512
959
|
|
|
513
960
|
${chalk.bold("Usage:")}
|
|
514
961
|
${chalk.cyan("portless proxy start")} Start the proxy (background daemon)
|
|
962
|
+
${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS (auto-generates certs)
|
|
515
963
|
${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
|
|
516
964
|
${chalk.cyan("portless proxy stop")} Stop the proxy
|
|
517
965
|
${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
|
|
518
966
|
${chalk.cyan("portless list")} Show active routes
|
|
967
|
+
${chalk.cyan("portless trust")} Add local CA to system trust store
|
|
519
968
|
|
|
520
969
|
${chalk.bold("Examples:")}
|
|
521
970
|
portless proxy start # Start proxy on port 1355
|
|
971
|
+
portless proxy start --https # Start with HTTPS/2 (faster page loads)
|
|
522
972
|
portless myapp next dev # -> http://myapp.localhost:1355
|
|
973
|
+
portless myapp vite dev # -> http://myapp.localhost:1355
|
|
523
974
|
portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
|
|
524
975
|
|
|
525
976
|
${chalk.bold("In package.json:")}
|
|
@@ -534,14 +985,26 @@ ${chalk.bold("How it works:")}
|
|
|
534
985
|
2. Run your apps - they auto-start the proxy and register automatically
|
|
535
986
|
3. Access via http://<name>.localhost:1355
|
|
536
987
|
4. .localhost domains auto-resolve to 127.0.0.1
|
|
988
|
+
5. Frameworks that ignore PORT (Vite, Astro, React Router, Angular) get
|
|
989
|
+
--port and --host flags injected automatically
|
|
990
|
+
|
|
991
|
+
${chalk.bold("HTTP/2 + HTTPS:")}
|
|
992
|
+
Use --https for HTTP/2 multiplexing (faster dev server page loads).
|
|
993
|
+
On first use, portless generates a local CA and adds it to your
|
|
994
|
+
system trust store. No browser warnings. No sudo required on macOS.
|
|
537
995
|
|
|
538
996
|
${chalk.bold("Options:")}
|
|
539
997
|
-p, --port <number> Port for the proxy to listen on (default: 1355)
|
|
540
998
|
Ports < 1024 require sudo
|
|
999
|
+
--https Enable HTTP/2 + TLS with auto-generated certs
|
|
1000
|
+
--cert <path> Use a custom TLS certificate (implies --https)
|
|
1001
|
+
--key <path> Use a custom TLS private key (implies --https)
|
|
1002
|
+
--no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
|
|
541
1003
|
--foreground Run proxy in foreground (for debugging)
|
|
542
1004
|
|
|
543
1005
|
${chalk.bold("Environment variables:")}
|
|
544
1006
|
PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
|
|
1007
|
+
PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
|
|
545
1008
|
PORTLESS_STATE_DIR=<path> Override the state directory
|
|
546
1009
|
PORTLESS=0 | PORTLESS=skip Run command directly without proxy
|
|
547
1010
|
|
|
@@ -552,24 +1015,40 @@ ${chalk.bold("Skip portless:")}
|
|
|
552
1015
|
process.exit(0);
|
|
553
1016
|
}
|
|
554
1017
|
if (args[0] === "--version" || args[0] === "-v") {
|
|
555
|
-
console.log("0.
|
|
1018
|
+
console.log("0.4.1");
|
|
556
1019
|
process.exit(0);
|
|
557
1020
|
}
|
|
1021
|
+
if (args[0] === "trust") {
|
|
1022
|
+
const { dir: dir2 } = await discoverState();
|
|
1023
|
+
const result = trustCA(dir2);
|
|
1024
|
+
if (result.trusted) {
|
|
1025
|
+
console.log(chalk.green("Local CA added to system trust store."));
|
|
1026
|
+
console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
|
|
1027
|
+
} else {
|
|
1028
|
+
console.error(chalk.red(`Failed to trust CA: ${result.error}`));
|
|
1029
|
+
if (result.error?.includes("sudo")) {
|
|
1030
|
+
console.error(chalk.blue("Run with sudo:"));
|
|
1031
|
+
console.error(chalk.cyan(" sudo portless trust"));
|
|
1032
|
+
}
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
}
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
558
1037
|
if (args[0] === "list") {
|
|
559
|
-
const { dir: dir2, port: port2 } = await discoverState();
|
|
1038
|
+
const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
|
|
560
1039
|
const store2 = new RouteStore(dir2, {
|
|
561
1040
|
onWarning: (msg) => console.warn(chalk.yellow(msg))
|
|
562
1041
|
});
|
|
563
|
-
listRoutes(store2, port2);
|
|
1042
|
+
listRoutes(store2, port2, tls3);
|
|
564
1043
|
return;
|
|
565
1044
|
}
|
|
566
1045
|
if (args[0] === "proxy") {
|
|
567
1046
|
if (args[1] === "stop") {
|
|
568
|
-
const { dir: dir2, port: port2 } = await discoverState();
|
|
1047
|
+
const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
|
|
569
1048
|
const store3 = new RouteStore(dir2, {
|
|
570
1049
|
onWarning: (msg) => console.warn(chalk.yellow(msg))
|
|
571
1050
|
});
|
|
572
|
-
await stopProxy(store3, port2);
|
|
1051
|
+
await stopProxy(store3, port2, tls3);
|
|
573
1052
|
return;
|
|
574
1053
|
}
|
|
575
1054
|
if (args[1] !== "start") {
|
|
@@ -577,6 +1056,7 @@ ${chalk.bold("Skip portless:")}
|
|
|
577
1056
|
${chalk.bold("Usage: portless proxy <command>")}
|
|
578
1057
|
|
|
579
1058
|
${chalk.cyan("portless proxy start")} Start the proxy (daemon)
|
|
1059
|
+
${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS
|
|
580
1060
|
${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
|
|
581
1061
|
${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
|
|
582
1062
|
${chalk.cyan("portless proxy stop")} Stop the proxy
|
|
@@ -602,6 +1082,32 @@ ${chalk.bold("Usage: portless proxy <command>")}
|
|
|
602
1082
|
process.exit(1);
|
|
603
1083
|
}
|
|
604
1084
|
}
|
|
1085
|
+
const hasNoTls = args.includes("--no-tls");
|
|
1086
|
+
const hasHttpsFlag = args.includes("--https");
|
|
1087
|
+
const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
|
|
1088
|
+
let customCertPath = null;
|
|
1089
|
+
let customKeyPath = null;
|
|
1090
|
+
const certIdx = args.indexOf("--cert");
|
|
1091
|
+
if (certIdx !== -1) {
|
|
1092
|
+
customCertPath = args[certIdx + 1] || null;
|
|
1093
|
+
if (!customCertPath || customCertPath.startsWith("-")) {
|
|
1094
|
+
console.error(chalk.red("Error: --cert requires a file path."));
|
|
1095
|
+
process.exit(1);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
const keyIdx = args.indexOf("--key");
|
|
1099
|
+
if (keyIdx !== -1) {
|
|
1100
|
+
customKeyPath = args[keyIdx + 1] || null;
|
|
1101
|
+
if (!customKeyPath || customKeyPath.startsWith("-")) {
|
|
1102
|
+
console.error(chalk.red("Error: --key requires a file path."));
|
|
1103
|
+
process.exit(1);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
|
|
1107
|
+
console.error(chalk.red("Error: --cert and --key must be used together."));
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
const useHttps = wantHttps || !!(customCertPath && customKeyPath);
|
|
605
1111
|
const stateDir = resolveStateDir(proxyPort);
|
|
606
1112
|
const store2 = new RouteStore(stateDir, {
|
|
607
1113
|
onWarning: (msg) => console.warn(chalk.yellow(msg))
|
|
@@ -626,23 +1132,90 @@ ${chalk.bold("Usage: portless proxy <command>")}
|
|
|
626
1132
|
console.error(chalk.cyan(" portless proxy start"));
|
|
627
1133
|
process.exit(1);
|
|
628
1134
|
}
|
|
1135
|
+
let tlsOptions;
|
|
1136
|
+
if (useHttps) {
|
|
1137
|
+
store2.ensureDir();
|
|
1138
|
+
if (customCertPath && customKeyPath) {
|
|
1139
|
+
try {
|
|
1140
|
+
const cert = fs3.readFileSync(customCertPath);
|
|
1141
|
+
const key = fs3.readFileSync(customKeyPath);
|
|
1142
|
+
const certStr = cert.toString("utf-8");
|
|
1143
|
+
const keyStr = key.toString("utf-8");
|
|
1144
|
+
if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
|
|
1145
|
+
console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
|
|
1146
|
+
console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
|
|
1147
|
+
process.exit(1);
|
|
1148
|
+
}
|
|
1149
|
+
if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
|
|
1150
|
+
console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
|
|
1151
|
+
console.error(
|
|
1152
|
+
chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----")
|
|
1153
|
+
);
|
|
1154
|
+
process.exit(1);
|
|
1155
|
+
}
|
|
1156
|
+
tlsOptions = { cert, key };
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1159
|
+
console.error(chalk.red(`Error reading certificate files: ${message}`));
|
|
1160
|
+
process.exit(1);
|
|
1161
|
+
}
|
|
1162
|
+
} else {
|
|
1163
|
+
console.log(chalk.gray("Ensuring TLS certificates..."));
|
|
1164
|
+
const certs = ensureCerts(stateDir);
|
|
1165
|
+
if (certs.caGenerated) {
|
|
1166
|
+
console.log(chalk.green("Generated local CA certificate."));
|
|
1167
|
+
}
|
|
1168
|
+
if (!isCATrusted(stateDir)) {
|
|
1169
|
+
console.log(chalk.yellow("Adding CA to system trust store..."));
|
|
1170
|
+
const trustResult = trustCA(stateDir);
|
|
1171
|
+
if (trustResult.trusted) {
|
|
1172
|
+
console.log(
|
|
1173
|
+
chalk.green("CA added to system trust store. Browsers will trust portless certs.")
|
|
1174
|
+
);
|
|
1175
|
+
} else {
|
|
1176
|
+
console.warn(chalk.yellow("Could not add CA to system trust store."));
|
|
1177
|
+
if (trustResult.error) {
|
|
1178
|
+
console.warn(chalk.gray(trustResult.error));
|
|
1179
|
+
}
|
|
1180
|
+
console.warn(
|
|
1181
|
+
chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
|
|
1182
|
+
);
|
|
1183
|
+
console.warn(chalk.cyan(" portless trust"));
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
const cert = fs3.readFileSync(certs.certPath);
|
|
1187
|
+
const key = fs3.readFileSync(certs.keyPath);
|
|
1188
|
+
tlsOptions = {
|
|
1189
|
+
cert,
|
|
1190
|
+
key,
|
|
1191
|
+
SNICallback: createSNICallback(stateDir, cert, key)
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
629
1195
|
if (isForeground) {
|
|
630
1196
|
console.log(chalk.blue.bold("\nportless proxy\n"));
|
|
631
|
-
startProxyServer(store2, proxyPort);
|
|
1197
|
+
startProxyServer(store2, proxyPort, tlsOptions);
|
|
632
1198
|
return;
|
|
633
1199
|
}
|
|
634
1200
|
store2.ensureDir();
|
|
635
|
-
const logPath =
|
|
636
|
-
const logFd =
|
|
1201
|
+
const logPath = path3.join(stateDir, "proxy.log");
|
|
1202
|
+
const logFd = fs3.openSync(logPath, "a");
|
|
637
1203
|
try {
|
|
638
1204
|
try {
|
|
639
|
-
|
|
1205
|
+
fs3.chmodSync(logPath, FILE_MODE);
|
|
640
1206
|
} catch {
|
|
641
1207
|
}
|
|
642
1208
|
const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
|
|
643
1209
|
if (portFlagIndex !== -1) {
|
|
644
1210
|
daemonArgs.push("--port", proxyPort.toString());
|
|
645
1211
|
}
|
|
1212
|
+
if (useHttps) {
|
|
1213
|
+
if (customCertPath && customKeyPath) {
|
|
1214
|
+
daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
|
|
1215
|
+
} else {
|
|
1216
|
+
daemonArgs.push("--https");
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
646
1219
|
const child = spawn2(process.execPath, daemonArgs, {
|
|
647
1220
|
detached: true,
|
|
648
1221
|
stdio: ["ignore", logFd, logFd],
|
|
@@ -650,19 +1223,20 @@ ${chalk.bold("Usage: portless proxy <command>")}
|
|
|
650
1223
|
});
|
|
651
1224
|
child.unref();
|
|
652
1225
|
} finally {
|
|
653
|
-
|
|
1226
|
+
fs3.closeSync(logFd);
|
|
654
1227
|
}
|
|
655
|
-
if (!await waitForProxy(proxyPort)) {
|
|
1228
|
+
if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
|
|
656
1229
|
console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
|
|
657
1230
|
console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
|
|
658
1231
|
const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
|
|
659
1232
|
console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
|
|
660
|
-
if (
|
|
1233
|
+
if (fs3.existsSync(logPath)) {
|
|
661
1234
|
console.error(chalk.gray(`Logs: ${logPath}`));
|
|
662
1235
|
}
|
|
663
1236
|
process.exit(1);
|
|
664
1237
|
}
|
|
665
|
-
|
|
1238
|
+
const proto = useHttps ? "HTTPS/2" : "HTTP";
|
|
1239
|
+
console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
|
|
666
1240
|
return;
|
|
667
1241
|
}
|
|
668
1242
|
const name = args[0];
|
|
@@ -675,11 +1249,11 @@ ${chalk.bold("Usage: portless proxy <command>")}
|
|
|
675
1249
|
console.error(chalk.cyan(" portless myapp next dev"));
|
|
676
1250
|
process.exit(1);
|
|
677
1251
|
}
|
|
678
|
-
const { dir, port } = await discoverState();
|
|
1252
|
+
const { dir, port, tls: tls2 } = await discoverState();
|
|
679
1253
|
const store = new RouteStore(dir, {
|
|
680
1254
|
onWarning: (msg) => console.warn(chalk.yellow(msg))
|
|
681
1255
|
});
|
|
682
|
-
await runApp(store, port, dir, name, commandArgs);
|
|
1256
|
+
await runApp(store, port, dir, name, commandArgs, tls2);
|
|
683
1257
|
}
|
|
684
1258
|
main().catch((err) => {
|
|
685
1259
|
const message = err instanceof Error ? err.message : String(err);
|