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/bin/tunnelme.js DELETED
@@ -1,108 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- const path = require('path');
5
- const os = require('os');
6
- const { Command } = require('commander');
7
- const { loadConfig } = require('../src/config');
8
- const { startClient } = require('../src/client');
9
- const { startServer } = require('../src/server');
10
-
11
- const program = new Command();
12
-
13
- program
14
- .name('tunnelme')
15
- .description('Reverse-proxy a public domain to a localhost port, for dev/testing.')
16
- .version(require('../package.json').version);
17
-
18
- program
19
- .command('run', { isDefault: true })
20
- .description('Start the client: expose a local port under a public domain')
21
- .option('-p, --port <port>', 'local port to expose', (v) => parseInt(v, 10))
22
- .option('-u, --url <domain>', 'public domain to route to the local port')
23
- .option('-c, --config <path>', 'config file with multiple tunnels (.yaml/.json)')
24
- .option('-s, --server <url>', 'tunnelme server control address')
25
- .option('-t, --token <token>', 'shared secret expected by the server')
26
- .action((opts) => {
27
- const DEFAULT_SERVER = 'ws://localhost:7000';
28
- let serverUrl;
29
- let token = opts.token || null;
30
- let tunnels;
31
-
32
- if (opts.config) {
33
- const cfg = loadConfig(opts.config);
34
- serverUrl = opts.server || cfg.server || DEFAULT_SERVER;
35
- token = opts.token || cfg.token || null;
36
- tunnels = cfg.tunnels.map((t) => ({ port: t.port, domain: t.url }));
37
- } else {
38
- serverUrl = opts.server || DEFAULT_SERVER;
39
- if (opts.port === undefined || Number.isNaN(opts.port) || !opts.url) {
40
- console.error('Error: --port and --url are required (or pass --config)');
41
- process.exit(1);
42
- }
43
- tunnels = [{ port: opts.port, domain: opts.url }];
44
- }
45
-
46
- startClient({ serverUrl, token, tunnels });
47
- });
48
-
49
- program
50
- .command('serve')
51
- .description('Start the tunnel server (run this on the internet-facing machine)')
52
- .option('--http-port <port>', 'plain HTTP port (ACME challenges + redirect)', (v) => parseInt(v, 10), 80)
53
- .option('--https-port <port>', 'public HTTPS entrypoint', (v) => parseInt(v, 10), 443)
54
- .option('--control-port <port>', 'control channel port for clients to connect to', (v) => parseInt(v, 10), 7000)
55
- .option('--certs-dir <path>', 'where to store certificates', path.join(os.homedir(), '.tunnelme', 'certs'))
56
- .option('--tls <mode>', 'acme (Let\'s Encrypt) or self-signed', 'acme')
57
- .option('--email <email>', 'contact email for Let\'s Encrypt')
58
- .option('--staging', 'use Let\'s Encrypt staging directory (for testing)', false)
59
- .option('-t, --token <token>', 'require clients to present this shared secret')
60
- .option('-p, --port <port>', 'also run a local tunnel: local port to expose', (v) => parseInt(v, 10))
61
- .option('-u, --url <domain>', 'also run a local tunnel: public domain for --port')
62
- .option('-c, --config <path>', 'also run local tunnel(s) from a config file (.yaml/.json)')
63
- .action((opts) => {
64
- if (opts.tls === 'acme' && !opts.email) {
65
- console.error('Error: --email is required when --tls=acme (Let\'s Encrypt requires a contact email)');
66
- process.exit(1);
67
- }
68
- if ((opts.port !== undefined) !== Boolean(opts.url)) {
69
- console.error('Error: --port and --url must be used together');
70
- process.exit(1);
71
- }
72
-
73
- const { controlHttp } = startServer({
74
- httpPort: opts.httpPort,
75
- httpsPort: opts.httpsPort,
76
- controlPort: opts.controlPort,
77
- certsDir: opts.certsDir,
78
- tlsMode: opts.tls,
79
- acmeEmail: opts.email,
80
- staging: opts.staging,
81
- token: opts.token || null,
82
- });
83
-
84
- // Optionally also run the client in this same process, against the
85
- // server's own control port, so `serve` + a tunnel can run from one
86
- // terminal instead of two. Wait for the control channel to actually be
87
- // listening first, so the client's first connection attempt doesn't race it.
88
- if (opts.config || opts.port !== undefined) {
89
- controlHttp.once('listening', () => {
90
- if (opts.config) {
91
- const cfg = loadConfig(opts.config);
92
- startClient({
93
- serverUrl: `ws://localhost:${opts.controlPort}`,
94
- token: opts.token || cfg.token || null,
95
- tunnels: cfg.tunnels.map((t) => ({ port: t.port, domain: t.url })),
96
- });
97
- } else {
98
- startClient({
99
- serverUrl: `ws://localhost:${opts.controlPort}`,
100
- token: opts.token || null,
101
- tunnels: [{ port: opts.port, domain: opts.url }],
102
- });
103
- }
104
- });
105
- }
106
- });
107
-
108
- program.parse();
package/src/certStore.js DELETED
@@ -1,185 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const tls = require('tls');
6
- const acme = require('acme-client');
7
- const selfsigned = require('selfsigned');
8
-
9
- const LETS_ENCRYPT_PROD = 'https://acme-v02.api.letsencrypt.org/directory';
10
- const LETS_ENCRYPT_STAGING = 'https://acme-v02.api.letsencrypt.org/directory'.replace('acme-v02', 'acme-staging-v02');
11
-
12
- const RENEW_WITHIN_MS = 30 * 24 * 60 * 60 * 1000; // renew if <30 days left
13
-
14
- class CertStore {
15
- /**
16
- * @param {object} opts
17
- * @param {string} opts.certsDir directory to persist certs/account key
18
- * @param {'acme'|'self-signed'} opts.mode
19
- * @param {string} [opts.acmeEmail] contact email for Let's Encrypt account
20
- * @param {boolean} [opts.staging] use LE staging directory (for testing, no rate limits)
21
- */
22
- constructor(opts) {
23
- this.certsDir = opts.certsDir;
24
- this.mode = opts.mode;
25
- this.acmeEmail = opts.acmeEmail;
26
- this.staging = !!opts.staging;
27
-
28
- this.cache = new Map(); // domain -> { ctx, expiresAt }
29
- this.pending = new Map(); // domain -> Promise<ctx>
30
- this.challenges = new Map(); // token -> keyAuthorization
31
- this._acmeClient = null;
32
-
33
- fs.mkdirSync(this.certsDir, { recursive: true });
34
- }
35
-
36
- domainDir(domain) {
37
- return path.join(this.certsDir, domain);
38
- }
39
-
40
- loadFromDisk(domain) {
41
- const dir = this.domainDir(domain);
42
- const certPath = path.join(dir, 'cert.pem');
43
- const keyPath = path.join(dir, 'key.pem');
44
- if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) return null;
45
-
46
- const cert = fs.readFileSync(certPath, 'utf8');
47
- const key = fs.readFileSync(keyPath, 'utf8');
48
- const expiresAt = this._certExpiry(cert);
49
- return { cert, key, expiresAt };
50
- }
51
-
52
- saveToDisk(domain, { cert, key }) {
53
- const dir = this.domainDir(domain);
54
- fs.mkdirSync(dir, { recursive: true });
55
- fs.writeFileSync(path.join(dir, 'cert.pem'), cert, { mode: 0o644 });
56
- fs.writeFileSync(path.join(dir, 'key.pem'), key, { mode: 0o600 });
57
- }
58
-
59
- _certExpiry(certPem) {
60
- try {
61
- const cert = new (require('crypto').X509Certificate)(certPem);
62
- return new Date(cert.validTo).getTime();
63
- } catch {
64
- return 0;
65
- }
66
- }
67
-
68
- /** Returns a tls.SecureContext for the given domain, provisioning/renewing as needed. */
69
- async getSecureContext(domain) {
70
- const cached = this.cache.get(domain);
71
- if (cached && cached.expiresAt - Date.now() > RENEW_WITHIN_MS) {
72
- return cached.ctx;
73
- }
74
-
75
- const fromDisk = this.loadFromDisk(domain);
76
- if (fromDisk && fromDisk.expiresAt - Date.now() > RENEW_WITHIN_MS) {
77
- const ctx = tls.createSecureContext({ cert: fromDisk.cert, key: fromDisk.key });
78
- this.cache.set(domain, { ctx, expiresAt: fromDisk.expiresAt });
79
- return ctx;
80
- }
81
-
82
- if (this.pending.has(domain)) return this.pending.get(domain);
83
-
84
- const provisioning = this._provision(domain)
85
- .then(({ cert, key }) => {
86
- this.saveToDisk(domain, { cert, key });
87
- const ctx = tls.createSecureContext({ cert, key });
88
- this.cache.set(domain, { ctx, expiresAt: this._certExpiry(cert) });
89
- this.pending.delete(domain);
90
- return ctx;
91
- })
92
- .catch((err) => {
93
- this.pending.delete(domain);
94
- throw err;
95
- });
96
-
97
- this.pending.set(domain, provisioning);
98
- return provisioning;
99
- }
100
-
101
- async _provision(domain) {
102
- if (this.mode === 'self-signed') return this._provisionSelfSigned(domain);
103
- return this._provisionAcme(domain);
104
- }
105
-
106
- _provisionSelfSigned(domain) {
107
- const attrs = [{ name: 'commonName', value: domain }];
108
- const pems = selfsigned.generate(attrs, {
109
- days: 365,
110
- keySize: 2048,
111
- extensions: [
112
- { name: 'basicConstraints', cA: false },
113
- { name: 'subjectAltName', altNames: [{ type: 2, value: domain }] },
114
- ],
115
- });
116
- return { cert: pems.cert, key: pems.private };
117
- }
118
-
119
- async _getAcmeClient() {
120
- if (this._acmeClient) return this._acmeClient;
121
-
122
- const accountKeyPath = path.join(this.certsDir, 'account-key.pem');
123
- let accountKey;
124
- if (fs.existsSync(accountKeyPath)) {
125
- accountKey = fs.readFileSync(accountKeyPath);
126
- } else {
127
- accountKey = await acme.forge.createPrivateKey();
128
- fs.writeFileSync(accountKeyPath, accountKey, { mode: 0o600 });
129
- }
130
-
131
- this._acmeClient = new acme.Client({
132
- directoryUrl: this.staging ? LETS_ENCRYPT_STAGING : LETS_ENCRYPT_PROD,
133
- accountKey,
134
- });
135
- return this._acmeClient;
136
- }
137
-
138
- async _provisionAcme(domain) {
139
- const client = await this._getAcmeClient();
140
- const [key, csr] = await acme.forge.createCsr({ commonName: domain });
141
-
142
- let cert;
143
- try {
144
- cert = await client.auto({
145
- csr,
146
- email: this.acmeEmail,
147
- termsOfServiceAgreed: true,
148
- challengePriority: ['http-01'],
149
- challengeCreateFn: async (authz, challenge, keyAuthorization) => {
150
- if (challenge.type !== 'http-01') return;
151
- this.challenges.set(challenge.token, keyAuthorization);
152
- },
153
- challengeRemoveFn: async (authz, challenge) => {
154
- this.challenges.delete(challenge.token);
155
- },
156
- });
157
- } catch (err) {
158
- // acme-client's retry logic has a known bug: a pure network failure
159
- // (no HTTP response at all) talking to Let's Encrypt, after retries
160
- // are exhausted, throws this exact confusing TypeError instead of the
161
- // real network error. Surface a clearer, actionable message instead.
162
- if (err instanceof TypeError && /reading 'config'/.test(err.message)) {
163
- throw new Error(
164
- `Failed to reach Let's Encrypt while requesting a certificate for ${domain} ` +
165
- "(a network-level failure was hidden by a bug in the acme-client library). " +
166
- 'Check outbound internet access from this machine to acme-v02.api.letsencrypt.org, ' +
167
- 'and that inbound port 80 is reachable from the internet for the HTTP-01 challenge ' +
168
- '(Let\'s Encrypt must be able to fetch http://' +
169
- domain +
170
- '/.well-known/acme-challenge/... from outside your network). Then try again.'
171
- );
172
- }
173
- throw err;
174
- }
175
-
176
- return { cert: cert.toString(), key: key.toString() };
177
- }
178
-
179
- /** Used by the plain :80 server to answer ACME http-01 challenge requests. */
180
- getChallengeResponse(token) {
181
- return this.challenges.get(token) || null;
182
- }
183
- }
184
-
185
- module.exports = { CertStore };
package/src/client.js DELETED
@@ -1,107 +0,0 @@
1
- 'use strict';
2
-
3
- const net = require('net');
4
- const WebSocket = require('ws');
5
- const { createWebSocketStream } = require('ws');
6
- const { log, pipeBidirectional, describeError } = require('./util');
7
-
8
- const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000];
9
-
10
- /**
11
- * @param {object} opts
12
- * @param {string} opts.serverUrl e.g. "ws://localhost:7000"
13
- * @param {string|null} opts.token
14
- * @param {{port:number, domain:string}[]} opts.tunnels
15
- */
16
- function startClient(opts) {
17
- const { serverUrl, token = null, tunnels } = opts;
18
- const portByDomain = new Map(tunnels.map((t) => [t.domain, t.port]));
19
-
20
- let attempt = 0;
21
- let stopped = false;
22
- let currentWs = null;
23
-
24
- function connect() {
25
- if (stopped) return;
26
- const ws = new WebSocket(`${serverUrl}/_tunnelme/control`);
27
- currentWs = ws;
28
-
29
- ws.on('open', () => {
30
- attempt = 0;
31
- log('connected to tunnel server, registering...');
32
- ws.send(
33
- JSON.stringify({
34
- type: 'register',
35
- token,
36
- tunnels: tunnels.map((t) => ({ domain: t.domain, port: t.port })),
37
- })
38
- );
39
- });
40
-
41
- ws.on('message', (raw) => {
42
- let msg;
43
- try {
44
- msg = JSON.parse(raw.toString());
45
- } catch {
46
- return;
47
- }
48
-
49
- if (msg.type === 'registered') {
50
- if (!Array.isArray(msg.domains)) return;
51
- for (const domain of msg.domains) {
52
- const port = portByDomain.get(domain);
53
- log(`tunnel active: https://${domain} -> localhost:${port}`);
54
- }
55
- } else if (msg.type === 'error') {
56
- log('server error:', msg.message);
57
- } else if (msg.type === 'conn') {
58
- handleConnRequest(msg.id, msg.domain);
59
- }
60
- });
61
-
62
- ws.on('close', () => {
63
- if (stopped) return;
64
- log('disconnected from tunnel server, reconnecting...');
65
- scheduleReconnect();
66
- });
67
-
68
- ws.on('error', (err) => {
69
- log('control connection error:', describeError(err));
70
- });
71
- }
72
-
73
- function scheduleReconnect() {
74
- if (stopped) return;
75
- const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
76
- attempt += 1;
77
- setTimeout(connect, delay);
78
- }
79
-
80
- function handleConnRequest(id, domain) {
81
- const port = portByDomain.get(domain);
82
- if (!port) return;
83
-
84
- const dataWs = new WebSocket(`${serverUrl}/_tunnelme/data?id=${encodeURIComponent(id)}`);
85
-
86
- dataWs.on('open', () => {
87
- const dataStream = createWebSocketStream(dataWs, { decodeStrings: false });
88
- const localSocket = net.connect(port, 'localhost');
89
- pipeBidirectional(localSocket, dataStream);
90
- });
91
-
92
- dataWs.on('error', (err) => {
93
- log(`data connection error for ${domain}:`, describeError(err));
94
- });
95
- }
96
-
97
- connect();
98
-
99
- return {
100
- stop() {
101
- stopped = true;
102
- if (currentWs) currentWs.close();
103
- },
104
- };
105
- }
106
-
107
- module.exports = { startClient };
package/src/config.js DELETED
@@ -1,37 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const yaml = require('js-yaml');
6
-
7
- /**
8
- * Loads a tunnelme config file (.yaml/.yml/.json).
9
- * Shape:
10
- * {
11
- * server: "ws://localhost:7000", // control-channel address of `tunnelme serve`
12
- * token: "shared-secret", // optional, must match server --token
13
- * tunnels: [ { port: 3000, url: "app.example.com" }, ... ]
14
- * }
15
- */
16
- function loadConfig(configPath) {
17
- const resolved = path.resolve(configPath);
18
- const raw = fs.readFileSync(resolved, 'utf8');
19
- const ext = path.extname(resolved).toLowerCase();
20
-
21
- const data = ext === '.json' ? JSON.parse(raw) : yaml.load(raw);
22
-
23
- if (!data || typeof data !== 'object') {
24
- throw new Error(`Config file ${resolved} did not parse to an object`);
25
- }
26
- if (!Array.isArray(data.tunnels) || data.tunnels.length === 0) {
27
- throw new Error(`Config file ${resolved} must define a non-empty "tunnels" array`);
28
- }
29
- for (const [i, t] of data.tunnels.entries()) {
30
- if (t.port === undefined || t.port === null || !t.url) {
31
- throw new Error(`tunnels[${i}] must have both "port" and "url"`);
32
- }
33
- }
34
- return data;
35
- }
36
-
37
- module.exports = { loadConfig };
package/src/server.js DELETED
@@ -1,240 +0,0 @@
1
- 'use strict';
2
-
3
- const http = require('http');
4
- const tls = require('tls');
5
- const crypto = require('crypto');
6
- const { WebSocketServer, WebSocket, createWebSocketStream } = require('ws');
7
- const { CertStore } = require('./certStore');
8
- const { log, secureCompare, pipeBidirectional, logRequestLines } = require('./util');
9
-
10
- const CONN_WAIT_TIMEOUT_MS = 15000;
11
- const LIVE_CHECK_PATH = '/host/live';
12
- const LIVE_CHECK_LINE_RE = /^(GET|HEAD) \/host\/live(\?\S*)? HTTP\/\d\.\d\r?$/;
13
-
14
- function liveCheckBody(domain) {
15
- return JSON.stringify({ live: true, domain, checkedAt: new Date().toISOString() });
16
- }
17
-
18
- function startServer(opts) {
19
- const {
20
- httpPort = 80,
21
- httpsPort = 443,
22
- controlPort = 7000,
23
- certsDir,
24
- tlsMode = 'acme', // 'acme' | 'self-signed'
25
- acmeEmail,
26
- staging = false,
27
- token = null,
28
- } = opts;
29
-
30
- const certStore = new CertStore({ certsDir, mode: tlsMode, acmeEmail, staging });
31
-
32
- /** @type {Map<string, WebSocket>} domain -> control connection */
33
- const domainClients = new Map();
34
- /** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
35
- const pendingConns = new Map();
36
-
37
- // ---- Control + data WebSocket server (LAN/localhost only, not internet-exposed) ----
38
- const controlHttp = http.createServer((req, res) => {
39
- res.writeHead(200, { 'content-type': 'text/plain' });
40
- res.end('tunnelme control channel\n');
41
- });
42
- const wss = new WebSocketServer({ noServer: true });
43
-
44
- controlHttp.on('upgrade', (req, socket, head) => {
45
- const url = new URL(req.url, 'http://internal');
46
- if (url.pathname === '/_tunnelme/control') {
47
- wss.handleUpgrade(req, socket, head, (ws) => handleControlConnection(ws));
48
- } else if (url.pathname === '/_tunnelme/data') {
49
- const id = url.searchParams.get('id');
50
- wss.handleUpgrade(req, socket, head, (ws) => handleDataConnection(ws, id));
51
- } else {
52
- socket.destroy();
53
- }
54
- });
55
-
56
- function handleControlConnection(ws) {
57
- const ownedDomains = new Set();
58
-
59
- ws.on('message', (raw) => {
60
- let msg;
61
- try {
62
- msg = JSON.parse(raw.toString());
63
- } catch {
64
- return;
65
- }
66
-
67
- if (msg.type === 'register') {
68
- if (token && !secureCompare(msg.token || '', token)) {
69
- ws.send(JSON.stringify({ type: 'error', message: 'invalid token' }));
70
- ws.close();
71
- return;
72
- }
73
- if (!Array.isArray(msg.tunnels)) {
74
- ws.send(JSON.stringify({ type: 'error', message: '"tunnels" must be an array' }));
75
- return;
76
- }
77
- const registered = [];
78
- for (const t of msg.tunnels) {
79
- if (!t || !t.domain || t.port === undefined || t.port === null) continue;
80
- domainClients.set(t.domain, ws);
81
- ownedDomains.add(t.domain);
82
- registered.push(t.domain);
83
- log(`registered ${t.domain} -> client's localhost:${t.port}`);
84
- }
85
- ws.send(JSON.stringify({ type: 'registered', domains: registered }));
86
- }
87
- });
88
-
89
- ws.on('close', () => {
90
- for (const domain of ownedDomains) {
91
- if (domainClients.get(domain) === ws) {
92
- domainClients.delete(domain);
93
- log(`unregistered ${domain}`);
94
- }
95
- }
96
- });
97
-
98
- ws.on('error', () => {});
99
- }
100
-
101
- function handleDataConnection(ws, id) {
102
- const pending = pendingConns.get(id);
103
- if (!pending) {
104
- ws.close();
105
- return;
106
- }
107
- pendingConns.delete(id);
108
- clearTimeout(pending.timer);
109
- pending.resolve(createWebSocketStream(ws, { decodeStrings: false }));
110
- }
111
-
112
- /** Ask the owning client to open a data connection for `domain`; resolves to a duplex stream. */
113
- function requestProxyConnection(domain) {
114
- const ws = domainClients.get(domain);
115
- if (!ws || ws.readyState !== WebSocket.OPEN) return Promise.reject(new Error('no client connected for domain'));
116
-
117
- const id = crypto.randomUUID();
118
- return new Promise((resolve, reject) => {
119
- const timer = setTimeout(() => {
120
- pendingConns.delete(id);
121
- reject(new Error('timed out waiting for client data connection'));
122
- }, CONN_WAIT_TIMEOUT_MS);
123
-
124
- pendingConns.set(id, { resolve, reject, timer });
125
- ws.send(JSON.stringify({ type: 'conn', id, domain }));
126
- });
127
- }
128
-
129
- // ---- Plain :80 server: ACME http-01 challenges + redirect to https ----
130
- const httpServer = http.createServer((req, res) => {
131
- const remote = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
132
- log(`${remote} -> ${req.headers.host || '(no host)'} ${req.method} ${req.url}`);
133
- const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
134
- if (url.pathname.startsWith('/.well-known/acme-challenge/')) {
135
- const token_ = url.pathname.split('/').pop();
136
- const keyAuth = certStore.getChallengeResponse(token_);
137
- if (keyAuth) {
138
- res.writeHead(200, { 'content-type': 'text/plain' });
139
- res.end(keyAuth);
140
- return;
141
- }
142
- res.writeHead(404);
143
- res.end('not found');
144
- return;
145
- }
146
- const host = (req.headers.host || '').split(':')[0];
147
- if (!domainClients.has(host)) {
148
- res.writeHead(404);
149
- res.end('not found');
150
- return;
151
- }
152
- if (url.pathname === LIVE_CHECK_PATH) {
153
- res.writeHead(200, { 'content-type': 'application/json' });
154
- res.end(liveCheckBody(host));
155
- return;
156
- }
157
- const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
158
- res.writeHead(301, { location: `https://${host}${portSuffix}${req.url}` });
159
- res.end();
160
- });
161
-
162
- // ---- TLS :443 server: SNI-routed, only for registered domains. Raw byte
163
- // forwarding only -- no HTTP parsing here, so HTTP/1.1, keep-alive and
164
- // WebSocket upgrades all pass through transparently to the client's local app. ----
165
- const httpsServer = tls.createServer(
166
- {
167
- SNICallback: (servername, cb) => {
168
- if (!domainClients.has(servername)) {
169
- cb(new Error(`unknown domain: ${servername}`));
170
- return;
171
- }
172
- certStore
173
- .getSecureContext(servername)
174
- .then((ctx) => cb(null, ctx))
175
- .catch((err) => {
176
- log(`cert error for ${servername}:`, err.message);
177
- cb(err);
178
- });
179
- },
180
- },
181
- (tlsSocket) => {
182
- const domain = tlsSocket.servername;
183
- if (!domain || !domainClients.has(domain)) {
184
- tlsSocket.destroy();
185
- return;
186
- }
187
- proxyRawConnection(tlsSocket, domain);
188
- }
189
- );
190
-
191
- httpsServer.on('tlsClientError', () => {});
192
-
193
- function proxyRawConnection(socket, domain) {
194
- const remote = `${socket.remoteAddress}:${socket.remotePort}`;
195
- log(`connection from ${remote} for ${domain}`);
196
- socket.on('close', () => log(`connection closed from ${remote} for ${domain}`));
197
-
198
- // Peek at the first chunk to catch the /host/live health check, which the
199
- // server answers directly (proves the tunnel is reachable without needing
200
- // the local app to be up). Anything else is pushed back with unshift()
201
- // and proxied exactly as before. pause()+unshift() happen synchronously
202
- // within this handler so no bytes are lost between the peek and the retry.
203
- socket.once('data', (chunk) => {
204
- const firstLine = chunk.toString('latin1').split('\r\n', 1)[0];
205
- if (LIVE_CHECK_LINE_RE.test(firstLine)) {
206
- log(`${remote} -> ${domain} GET ${LIVE_CHECK_PATH} (answered by tunnelme server)`);
207
- const body = liveCheckBody(domain);
208
- socket.end(
209
- `HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`
210
- );
211
- return;
212
- }
213
- socket.pause();
214
- socket.unshift(chunk);
215
- forwardToClient(socket, domain, remote);
216
- });
217
- }
218
-
219
- async function forwardToClient(socket, domain, remote) {
220
- try {
221
- // Wait for the client's data connection before attaching any 'data'
222
- // consumer -- attaching one earlier would switch the socket into
223
- // flowing mode and could drop bytes that arrive before pipe() is wired up.
224
- const dataStream = await requestProxyConnection(domain);
225
- logRequestLines(socket, `${remote} -> ${domain}`);
226
- pipeBidirectional(socket, dataStream);
227
- } catch (err) {
228
- log(`proxy failed for ${domain}:`, err.message);
229
- socket.destroy();
230
- }
231
- }
232
-
233
- controlHttp.listen(controlPort, () => log(`control channel listening on ws://0.0.0.0:${controlPort}`));
234
- httpServer.listen(httpPort, () => log(`http (acme + redirect) listening on :${httpPort}`));
235
- httpsServer.listen(httpsPort, () => log(`https tunnel entrypoint listening on :${httpsPort}`));
236
-
237
- return { controlHttp, httpServer, httpsServer };
238
- }
239
-
240
- module.exports = { startServer };