tunnelmate 0.1.0 → 0.2.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.
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setupWindowsFirewall = setupWindowsFirewall;
4
+ const child_process_1 = require("child_process");
5
+ const util_1 = require("./util");
6
+ /**
7
+ * Adds Windows Firewall inbound allow rules for the given TCP ports.
8
+ * Requires an Administrator terminal; idempotent (safe to run repeatedly).
9
+ * No-ops with a message on non-Windows platforms.
10
+ */
11
+ function setupWindowsFirewall(ports) {
12
+ if (process.platform !== 'win32') {
13
+ (0, util_1.log)('--setup-firewall is only implemented for Windows; configure your firewall manually on this platform');
14
+ return;
15
+ }
16
+ for (const port of ports) {
17
+ const ruleName = `tunnelme TCP ${port}`;
18
+ const script = `if (-not (Get-NetFirewallRule -DisplayName '${ruleName}' -ErrorAction SilentlyContinue)) { ` +
19
+ `New-NetFirewallRule -DisplayName '${ruleName}' -Direction Inbound -Protocol TCP -LocalPort ${port} -Action Allow -ErrorAction Stop | Out-Null; ` +
20
+ `Write-Output 'created' } else { Write-Output 'exists' }`;
21
+ try {
22
+ const output = (0, child_process_1.execFileSync)('powershell', ['-NoProfile', '-Command', script], { stdio: 'pipe' })
23
+ .toString()
24
+ .trim();
25
+ if (output === 'created') {
26
+ (0, util_1.log)(`firewall: added inbound rule for TCP ${port} ("${ruleName}")`);
27
+ }
28
+ else {
29
+ (0, util_1.log)(`firewall: inbound rule for TCP ${port} already exists ("${ruleName}")`);
30
+ }
31
+ }
32
+ catch (err) {
33
+ const e = err;
34
+ const msg = e.stderr ? e.stderr.toString().trim() : e.message;
35
+ (0, util_1.log)(`firewall: failed to add rule for TCP ${port}: ${msg}`);
36
+ if (/access is denied|requested operation requires elevation/i.test(msg)) {
37
+ (0, util_1.log)('firewall: this requires an Administrator terminal -- re-run as Administrator');
38
+ }
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.detectPublicIp = detectPublicIp;
37
+ exports.quickDomain = quickDomain;
38
+ exports.autoPortForward = autoPortForward;
39
+ const os = __importStar(require("os"));
40
+ const https = __importStar(require("https"));
41
+ const util_1 = require("./util");
42
+ const IP_ECHO_SERVICES = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'];
43
+ // @achingbrain/nat-port-mapper is ESM-only; this project is CommonJS, so it
44
+ // must be loaded via dynamic import() rather than require(). (Type-only
45
+ // imports above are erased at compile time and don't trigger this issue.)
46
+ function loadUpnpNat() {
47
+ return Promise.resolve().then(() => __importStar(require('@achingbrain/nat-port-mapper'))).then((mod) => mod.upnpNat);
48
+ }
49
+ function fetchText(url, timeoutMs = 5000) {
50
+ return new Promise((resolve, reject) => {
51
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
52
+ if (res.statusCode !== 200) {
53
+ res.resume();
54
+ reject(new Error(`${url} returned HTTP ${res.statusCode}`));
55
+ return;
56
+ }
57
+ let body = '';
58
+ res.on('data', (c) => (body += c));
59
+ res.on('end', () => resolve(body.trim()));
60
+ });
61
+ req.on('timeout', () => req.destroy(new Error(`${url} timed out`)));
62
+ req.on('error', reject);
63
+ });
64
+ }
65
+ /** Picks this machine's primary LAN IPv4 address (non-internal). */
66
+ function localIp() {
67
+ const ifaces = os.networkInterfaces();
68
+ for (const name of Object.keys(ifaces)) {
69
+ for (const iface of ifaces[name] || []) {
70
+ if (iface.family === 'IPv4' && !iface.internal)
71
+ return iface.address;
72
+ }
73
+ }
74
+ throw new Error('Could not determine a local LAN IPv4 address');
75
+ }
76
+ /**
77
+ * Best-effort public IP detection: tries asking the router directly via
78
+ * UPnP/NAT-PMP first (no third party involved), falls back to a plain HTTP
79
+ * echo service if the router doesn't support/allow that.
80
+ */
81
+ async function detectPublicIp() {
82
+ try {
83
+ const upnpNat = await loadUpnpNat();
84
+ const client = upnpNat();
85
+ for await (const gateway of client.findGateways({ signal: AbortSignal.timeout(4000) })) {
86
+ try {
87
+ const ip = await gateway.externalIp();
88
+ await gateway.stop();
89
+ if (ip)
90
+ return ip;
91
+ }
92
+ catch {
93
+ await gateway.stop().catch(() => { });
94
+ }
95
+ break;
96
+ }
97
+ }
98
+ catch {
99
+ // no UPnP/NAT-PMP gateway reachable -- fall through to HTTP echo
100
+ }
101
+ for (const url of IP_ECHO_SERVICES) {
102
+ try {
103
+ const ip = await fetchText(url);
104
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip))
105
+ return ip;
106
+ }
107
+ catch {
108
+ // try next service
109
+ }
110
+ }
111
+ throw new Error('Could not determine public IP (tried UPnP/NAT-PMP and HTTP echo services)');
112
+ }
113
+ /** Builds a working public hostname for `port` using sslip.io's wildcard DNS -- no domain ownership needed, no traffic relay. */
114
+ function quickDomain(port, ip) {
115
+ return `p${port}.${ip.split('.').join('-')}.sslip.io`;
116
+ }
117
+ /**
118
+ * Best-effort automatic port forwarding via UPnP/NAT-PMP for the given TCP
119
+ * ports. Returns a stop() function that removes the mappings, or null if no
120
+ * compatible router was found. Mappings auto-renew for as long as the
121
+ * process runs (handled by the underlying library).
122
+ */
123
+ async function autoPortForward(ports) {
124
+ const host = localIp();
125
+ const upnpNat = await loadUpnpNat();
126
+ const client = upnpNat({ description: 'tunnelme' });
127
+ let gateway = null;
128
+ try {
129
+ for await (const gw of client.findGateways({ signal: AbortSignal.timeout(4000) })) {
130
+ gateway = gw;
131
+ break;
132
+ }
133
+ }
134
+ catch (err) {
135
+ (0, util_1.log)(`UPnP: gateway discovery failed: ${err.message}`);
136
+ }
137
+ if (!gateway) {
138
+ (0, util_1.log)('UPnP: no compatible router found (UPnP/NAT-PMP may be disabled) -- set up port forwarding manually');
139
+ return null;
140
+ }
141
+ for (const port of ports) {
142
+ try {
143
+ const mapping = await gateway.map(port, host, { externalPort: port, protocol: 'tcp' });
144
+ (0, util_1.log)(`UPnP: mapped external port ${mapping.externalPort} -> ${host}:${port}`);
145
+ }
146
+ catch (err) {
147
+ (0, util_1.log)(`UPnP: failed to map port ${port}: ${err.message}`);
148
+ }
149
+ }
150
+ const foundGateway = gateway;
151
+ return async () => {
152
+ await foundGateway.stop().catch(() => { });
153
+ };
154
+ }
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.startServer = startServer;
37
+ const http = __importStar(require("http"));
38
+ const tls = __importStar(require("tls"));
39
+ const crypto = __importStar(require("crypto"));
40
+ const ws_1 = require("ws");
41
+ const certStore_1 = require("./certStore");
42
+ const util_1 = require("./util");
43
+ const CONN_WAIT_TIMEOUT_MS = 15000;
44
+ const LIVE_CHECK_PATH = '/host/live';
45
+ const LIVE_CHECK_LINE_RE = /^(GET|HEAD) \/host\/live(\?\S*)? HTTP\/\d\.\d\r?$/;
46
+ function liveCheckBody(domain) {
47
+ return JSON.stringify({ live: true, domain, checkedAt: new Date().toISOString() });
48
+ }
49
+ function startServer(opts) {
50
+ const { httpPort = 80, httpsPort = 443, controlPort = 7000, certsDir, tlsMode = 'acme', acmeEmail, staging = false, token = null, } = opts;
51
+ const certStore = new certStore_1.CertStore({ certsDir, mode: tlsMode, acmeEmail, staging });
52
+ const domainClients = new Map();
53
+ const pendingConns = new Map();
54
+ // ---- Control + data WebSocket server (LAN/localhost only, not internet-exposed) ----
55
+ const controlHttp = http.createServer((_req, res) => {
56
+ res.writeHead(200, { 'content-type': 'text/plain' });
57
+ res.end('tunnelme control channel\n');
58
+ });
59
+ const wss = new ws_1.WebSocketServer({ noServer: true });
60
+ controlHttp.on('upgrade', (req, socket, head) => {
61
+ const url = new URL(req.url || '', 'http://internal');
62
+ if (url.pathname === '/_tunnelme/control') {
63
+ wss.handleUpgrade(req, socket, head, (ws) => handleControlConnection(ws));
64
+ }
65
+ else if (url.pathname === '/_tunnelme/data') {
66
+ const id = url.searchParams.get('id') || '';
67
+ wss.handleUpgrade(req, socket, head, (ws) => handleDataConnection(ws, id));
68
+ }
69
+ else {
70
+ socket.destroy();
71
+ }
72
+ });
73
+ function handleControlConnection(ws) {
74
+ const ownedDomains = new Set();
75
+ ws.on('message', (raw) => {
76
+ let msg;
77
+ try {
78
+ msg = JSON.parse(raw.toString());
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ if (msg.type === 'register') {
84
+ if (token && !(0, util_1.secureCompare)(msg.token || '', token)) {
85
+ ws.send(JSON.stringify({ type: 'error', message: 'invalid token' }));
86
+ ws.close();
87
+ return;
88
+ }
89
+ if (!Array.isArray(msg.tunnels)) {
90
+ ws.send(JSON.stringify({ type: 'error', message: '"tunnels" must be an array' }));
91
+ return;
92
+ }
93
+ const registered = [];
94
+ for (const t of msg.tunnels) {
95
+ if (!t || !t.domain || t.port === undefined || t.port === null)
96
+ continue;
97
+ domainClients.set(t.domain, ws);
98
+ ownedDomains.add(t.domain);
99
+ registered.push(t.domain);
100
+ (0, util_1.log)(`registered ${t.domain} -> client's localhost:${t.port}`);
101
+ }
102
+ ws.send(JSON.stringify({ type: 'registered', domains: registered }));
103
+ }
104
+ });
105
+ ws.on('close', () => {
106
+ for (const domain of ownedDomains) {
107
+ if (domainClients.get(domain) === ws) {
108
+ domainClients.delete(domain);
109
+ (0, util_1.log)(`unregistered ${domain}`);
110
+ }
111
+ }
112
+ });
113
+ ws.on('error', () => { });
114
+ }
115
+ function handleDataConnection(ws, id) {
116
+ const pending = pendingConns.get(id);
117
+ if (!pending) {
118
+ ws.close();
119
+ return;
120
+ }
121
+ pendingConns.delete(id);
122
+ clearTimeout(pending.timer);
123
+ pending.resolve((0, ws_1.createWebSocketStream)(ws, { decodeStrings: false }));
124
+ }
125
+ /** Ask the owning client to open a data connection for `domain`; resolves to a duplex stream. */
126
+ function requestProxyConnection(domain) {
127
+ const ws = domainClients.get(domain);
128
+ if (!ws || ws.readyState !== ws_1.WebSocket.OPEN)
129
+ return Promise.reject(new Error('no client connected for domain'));
130
+ const id = crypto.randomUUID();
131
+ return new Promise((resolve, reject) => {
132
+ const timer = setTimeout(() => {
133
+ pendingConns.delete(id);
134
+ reject(new Error('timed out waiting for client data connection'));
135
+ }, CONN_WAIT_TIMEOUT_MS);
136
+ pendingConns.set(id, { resolve, reject, timer });
137
+ ws.send(JSON.stringify({ type: 'conn', id, domain }));
138
+ });
139
+ }
140
+ // ---- Plain :80 server: ACME http-01 challenges + redirect to https ----
141
+ const httpServer = http.createServer((req, res) => {
142
+ const remote = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
143
+ (0, util_1.log)(`${remote} -> ${req.headers.host || '(no host)'} ${req.method} ${req.url}`);
144
+ const url = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
145
+ if (url.pathname.startsWith('/.well-known/acme-challenge/')) {
146
+ const challengeToken = url.pathname.split('/').pop() || '';
147
+ const keyAuth = certStore.getChallengeResponse(challengeToken);
148
+ if (keyAuth) {
149
+ res.writeHead(200, { 'content-type': 'text/plain' });
150
+ res.end(keyAuth);
151
+ return;
152
+ }
153
+ res.writeHead(404);
154
+ res.end('not found');
155
+ return;
156
+ }
157
+ const host = (req.headers.host || '').split(':')[0];
158
+ if (!domainClients.has(host)) {
159
+ res.writeHead(404);
160
+ res.end('not found');
161
+ return;
162
+ }
163
+ if (url.pathname === LIVE_CHECK_PATH) {
164
+ res.writeHead(200, { 'content-type': 'application/json' });
165
+ res.end(liveCheckBody(host));
166
+ return;
167
+ }
168
+ const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
169
+ res.writeHead(301, { location: `https://${host}${portSuffix}${req.url}` });
170
+ res.end();
171
+ });
172
+ // ---- TLS :443 server: SNI-routed, only for registered domains. Raw byte
173
+ // forwarding only -- no HTTP parsing here, so HTTP/1.1, keep-alive and
174
+ // WebSocket upgrades all pass through transparently to the client's local app. ----
175
+ const httpsServer = tls.createServer({
176
+ SNICallback: (servername, cb) => {
177
+ if (!domainClients.has(servername)) {
178
+ cb(new Error(`unknown domain: ${servername}`));
179
+ return;
180
+ }
181
+ certStore
182
+ .getSecureContext(servername)
183
+ .then((ctx) => cb(null, ctx))
184
+ .catch((err) => {
185
+ (0, util_1.log)(`cert error for ${servername}:`, err.message);
186
+ cb(err);
187
+ });
188
+ },
189
+ }, (tlsSocket) => {
190
+ const domain = tlsSocket.servername;
191
+ if (!domain || !domainClients.has(domain)) {
192
+ tlsSocket.destroy();
193
+ return;
194
+ }
195
+ proxyRawConnection(tlsSocket, domain);
196
+ });
197
+ httpsServer.on('tlsClientError', () => { });
198
+ function proxyRawConnection(socket, domain) {
199
+ const remote = `${socket.remoteAddress}:${socket.remotePort}`;
200
+ (0, util_1.log)(`connection from ${remote} for ${domain}`);
201
+ socket.on('close', () => (0, util_1.log)(`connection closed from ${remote} for ${domain}`));
202
+ // Peek at the first chunk to catch the /host/live health check, which the
203
+ // server answers directly (proves the tunnel is reachable without needing
204
+ // the local app to be up). Anything else is pushed back with unshift()
205
+ // and proxied exactly as before. pause()+unshift() happen synchronously
206
+ // within this handler so no bytes are lost between the peek and the retry.
207
+ socket.once('data', (chunk) => {
208
+ const firstLine = chunk.toString('latin1').split('\r\n', 1)[0];
209
+ if (LIVE_CHECK_LINE_RE.test(firstLine)) {
210
+ (0, util_1.log)(`${remote} -> ${domain} GET ${LIVE_CHECK_PATH} (answered by tunnelme server)`);
211
+ const body = liveCheckBody(domain);
212
+ socket.end(`HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`);
213
+ return;
214
+ }
215
+ socket.pause();
216
+ socket.unshift(chunk);
217
+ forwardToClient(socket, domain, remote);
218
+ });
219
+ }
220
+ async function forwardToClient(socket, domain, remote) {
221
+ try {
222
+ // Wait for the client's data connection before attaching any 'data'
223
+ // consumer -- attaching one earlier would switch the socket into
224
+ // flowing mode and could drop bytes that arrive before pipe() is wired up.
225
+ const dataStream = await requestProxyConnection(domain);
226
+ (0, util_1.logRequestLines)(socket, `${remote} -> ${domain}`);
227
+ (0, util_1.pipeBidirectional)(socket, dataStream);
228
+ }
229
+ catch (err) {
230
+ (0, util_1.log)(`proxy failed for ${domain}:`, err.message);
231
+ socket.destroy();
232
+ }
233
+ }
234
+ function onListenError(label, port) {
235
+ return (err) => {
236
+ if (err.code === 'EACCES') {
237
+ (0, util_1.log)(`Failed to listen on :${port} (${label}): permission denied. ` +
238
+ (process.platform === 'win32'
239
+ ? 'Binding to ports below 1024 requires an Administrator terminal -- re-run as Administrator.'
240
+ : 'Binding to ports below 1024 requires root -- re-run with sudo, or use a port above 1024.'));
241
+ }
242
+ else if (err.code === 'EADDRINUSE') {
243
+ (0, util_1.log)(`Failed to listen on :${port} (${label}): another process is already using this port.`);
244
+ }
245
+ else {
246
+ (0, util_1.log)(`Failed to listen on :${port} (${label}):`, err.message);
247
+ }
248
+ process.exit(1);
249
+ };
250
+ }
251
+ controlHttp.on('error', onListenError('control channel', controlPort));
252
+ httpServer.on('error', onListenError('http', httpPort));
253
+ httpsServer.on('error', onListenError('https', httpsPort));
254
+ controlHttp.listen(controlPort, () => (0, util_1.log)(`control channel listening on ws://0.0.0.0:${controlPort}`));
255
+ httpServer.listen(httpPort, () => (0, util_1.log)(`http (acme + redirect) listening on :${httpPort}`));
256
+ httpsServer.listen(httpsPort, () => (0, util_1.log)(`https tunnel entrypoint listening on :${httpsPort}`));
257
+ return { controlHttp, httpServer, httpsServer };
258
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.log = log;
37
+ exports.describeError = describeError;
38
+ exports.secureCompare = secureCompare;
39
+ exports.pipeBidirectional = pipeBidirectional;
40
+ exports.logRequestLines = logRequestLines;
41
+ const crypto = __importStar(require("crypto"));
42
+ function log(...args) {
43
+ console.log(new Date().toISOString(), ...args);
44
+ }
45
+ /** Formats an Error for logging, unwrapping AggregateError (whose own .message is often empty). */
46
+ function describeError(err) {
47
+ if (!err)
48
+ return String(err);
49
+ const e = err;
50
+ if (Array.isArray(e.errors) && e.errors.length > 0) {
51
+ return e.errors.map((sub) => sub.message || String(sub)).join('; ');
52
+ }
53
+ return e.message || e.code || String(err);
54
+ }
55
+ /** Constant-time string comparison, safe for comparing against a network-supplied secret. */
56
+ function secureCompare(a, b) {
57
+ const ha = crypto.createHash('sha256').update(String(a)).digest();
58
+ const hb = crypto.createHash('sha256').update(String(b)).digest();
59
+ return crypto.timingSafeEqual(ha, hb);
60
+ }
61
+ /** Bidirectionally pipes two duplex streams and destroys both if either errors or closes. */
62
+ function pipeBidirectional(a, b) {
63
+ a.pipe(b);
64
+ b.pipe(a);
65
+ let cleaned = false;
66
+ const cleanup = () => {
67
+ if (cleaned)
68
+ return;
69
+ cleaned = true;
70
+ a.destroy();
71
+ b.destroy();
72
+ };
73
+ a.on('error', cleanup);
74
+ b.on('error', cleanup);
75
+ a.on('close', cleanup);
76
+ b.on('close', cleanup);
77
+ }
78
+ const REQUEST_LINE_RE = /^([A-Z]+) (\S+) HTTP\/\d\.\d$/;
79
+ const MAX_LEFTOVER_BYTES = 8192;
80
+ /**
81
+ * Passively taps a socket carrying raw HTTP bytes and logs each request line
82
+ * it spots (method + path), without consuming or altering the stream --
83
+ * safe to use alongside a .pipe() of the same socket. Best-effort: after a
84
+ * protocol upgrade (e.g. WebSocket) traffic is no longer HTTP and simply
85
+ * won't match, so logging naturally goes quiet for that connection.
86
+ */
87
+ function logRequestLines(socket, label) {
88
+ let leftover = '';
89
+ socket.on('data', (chunk) => {
90
+ leftover += chunk.toString('latin1');
91
+ let idx;
92
+ while ((idx = leftover.indexOf('\r\n')) !== -1) {
93
+ const line = leftover.slice(0, idx);
94
+ leftover = leftover.slice(idx + 2);
95
+ const match = REQUEST_LINE_RE.exec(line);
96
+ if (match)
97
+ log(`${label} ${match[1]} ${match[2]}`);
98
+ }
99
+ if (leftover.length > MAX_LEFTOVER_BYTES) {
100
+ leftover = leftover.slice(-1024);
101
+ }
102
+ });
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelmate",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Reverse-proxy a public domain to a localhost port, for dev/testing.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -13,20 +13,34 @@
13
13
  "self-hosted"
14
14
  ],
15
15
  "bin": {
16
- "tunnelme": "bin/tunnelme.js"
16
+ "tunnelme": "dist/bin/tunnelme.js"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=18"
20
20
  },
21
21
  "files": [
22
- "bin",
23
- "src"
22
+ "dist"
24
23
  ],
25
24
  "dependencies": {
25
+ "@achingbrain/nat-port-mapper": "^4.0.5",
26
26
  "acme-client": "^5.4.0",
27
27
  "commander": "^12.1.0",
28
28
  "js-yaml": "^4.1.0",
29
29
  "selfsigned": "^2.4.1",
30
30
  "ws": "^8.18.0"
31
+ },
32
+ "devDependencies": {
33
+ "@commitlint/cli": "^21.2.3",
34
+ "@commitlint/config-conventional": "^21.2.3",
35
+ "@types/js-yaml": "^4.0.9",
36
+ "@types/node": "^26.6.2",
37
+ "@types/ws": "^8.18.1",
38
+ "husky": "^9.1.7",
39
+ "typescript": "^7.0.2"
40
+ },
41
+ "scripts": {
42
+ "prepare": "husky",
43
+ "build": "tsc",
44
+ "prepublishOnly": "npm run build"
31
45
  }
32
46
  }