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.
- package/README.md +53 -2
- package/dist/bin/tunnelme.js +182 -0
- package/dist/src/certStore.js +199 -0
- package/dist/src/client.js +123 -0
- package/dist/src/config.js +66 -0
- package/dist/src/firewall.js +41 -0
- package/dist/src/network.js +154 -0
- package/dist/src/server.js +258 -0
- package/dist/src/types.js +2 -0
- package/dist/src/util.js +103 -0
- package/package.json +18 -4
- package/bin/tunnelme.js +0 -108
- package/src/certStore.js +0 -185
- package/src/client.js +0 -107
- package/src/config.js +0 -37
- package/src/server.js +0 -240
- package/src/util.js +0 -71
|
@@ -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
|
+
}
|
package/dist/src/util.js
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
}
|