tunnelmate 0.1.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 ADDED
@@ -0,0 +1,171 @@
1
+ # tunnelme
2
+
3
+ Reverse-proxies a public domain to a localhost port, for dev/testing — a small
4
+ self-hosted alternative to ngrok, built for your own domain + your own box.
5
+
6
+ ## How it works
7
+
8
+ Two pieces, both in this one CLI:
9
+
10
+ - **`tunnelme serve`** — runs on the internet-facing machine (in your case: the
11
+ machine your router forwards ports 80/443 to). Terminates TLS using
12
+ automatically-provisioned Let's Encrypt certificates, chosen by SNI per
13
+ domain, and relays raw bytes to whichever client registered that domain.
14
+ - **`tunnelme`** (a.k.a. `tunnelme run`) — the client. Connects out to the
15
+ server's control channel, registers a domain, and forwards traffic to
16
+ `localhost:<port>`.
17
+
18
+ The server never parses HTTP itself — it just forwards decrypted TLS bytes
19
+ through to the client, which hands them to your local app. That means it
20
+ transparently supports HTTP/1.1 keep-alive, WebSocket upgrades (HMR, etc.),
21
+ anything TCP-based — not just plain request/response.
22
+
23
+ ## Install (global, callable from anywhere)
24
+
25
+ Once published to npm (package name `tunnelmate`, command stays `tunnelme`):
26
+
27
+ ```powershell
28
+ npm install -g tunnelmate
29
+ ```
30
+
31
+ For local development against this source tree instead:
32
+
33
+ ```powershell
34
+ cd C:\codes\tunnel
35
+ npm install
36
+ npm link
37
+ ```
38
+
39
+ Either way, `tunnelme` ends up on your PATH. Test with `tunnelme --version`
40
+ from any directory.
41
+
42
+ ## One-time setup on your machine
43
+
44
+ Since your router already forwards TCP 80 and 443 to this machine, and your
45
+ DNS is already pointed at your static IP, you just need to run the server
46
+ component here:
47
+
48
+ ```powershell
49
+ # Run as Administrator (binding ports 80/443 on Windows requires elevation)
50
+ tunnelme serve --tls acme --email you@example.com
51
+ ```
52
+
53
+ Defaults: HTTP on :80, HTTPS on :443, control channel on :7000 (localhost
54
+ only — do **not** forward 7000 through your router; it's for your dev
55
+ machines to reach, not the public internet).
56
+
57
+ Make sure Windows Firewall allows inbound on 80/443 (and 7000 if your client
58
+ runs on a different LAN machine).
59
+
60
+ Certificates are cached under `~/.tunnelme/certs` and auto-renew (checked
61
+ whenever a domain is used, renewed once inside 30 days of expiry).
62
+
63
+ **First run for a new domain**: Let's Encrypt requires your domain's A record
64
+ to already resolve to your static IP, and port 80 to be reachable from the
65
+ internet (used for the HTTP-01 challenge) — both of which you already have.
66
+ If you want to test the flow without hitting Let's Encrypt's rate limits,
67
+ add `--staging` first, then drop it once it's working end-to-end.
68
+
69
+ ## Expose a local dev server
70
+
71
+ If your app and `serve` run on the **same machine**, skip the second
72
+ terminal entirely by passing `--port`/`--url` straight to `serve` — it starts
73
+ the server and the tunnel together in one process:
74
+
75
+ ```powershell
76
+ tunnelme serve --tls acme --email you@example.com --port 3000 --url app.example.com
77
+ ```
78
+
79
+ (`--config <path>` works here too, for multiple tunnels — see below.)
80
+
81
+ If your app runs on a **different machine**, run `serve` there once, then run
82
+ the client separately wherever the app lives:
83
+
84
+ ```powershell
85
+ tunnelme --port 3000 --url app.example.com --server ws://192.168.1.50:7000
86
+ ```
87
+
88
+ (`--server` points at whichever machine is running `serve`; use `localhost`
89
+ if they're the same box, or its LAN IP otherwise.)
90
+
91
+ Visit `https://app.example.com` — it now proxies to `localhost:3000`.
92
+
93
+ ## Multiple routes via config file
94
+
95
+ `tunnels.yaml`:
96
+
97
+ ```yaml
98
+ server: ws://localhost:7000
99
+ token: some-shared-secret # optional, must match `serve --token`
100
+ tunnels:
101
+ - port: 3000
102
+ url: app.example.com
103
+ - port: 8080
104
+ url: api.example.com
105
+ - port: 5173
106
+ url: admin.example.com
107
+ ```
108
+
109
+ ```powershell
110
+ tunnelme --config .\tunnels.yaml
111
+ ```
112
+
113
+ All tunnels share one control connection and reconnect automatically if it
114
+ drops.
115
+
116
+ ## Securing the control channel
117
+
118
+ Anyone who can reach the control port (7000) can register a domain and start
119
+ receiving its traffic. If more than just you can reach it on your LAN, set a
120
+ shared secret:
121
+
122
+ ```powershell
123
+ tunnelme serve --token "long-random-string" ...
124
+ tunnelme --port 3000 --url app.example.com --token "long-random-string"
125
+ ```
126
+
127
+ ## Diagnostics
128
+
129
+ **Request logging** — `tunnelme serve` logs every request it sees on both
130
+ :80 and :443 (method, path, remote address, domain), plus connection
131
+ open/close events. Useful for confirming traffic is actually reaching the
132
+ tunnel server at all before worrying about your local app.
133
+
134
+ **`GET /host/live`** — every registered domain answers this path directly
135
+ from the tunnel server itself, without forwarding to your local app:
136
+
137
+ ```powershell
138
+ curl https://app.example.com/host/live
139
+ # {"live":true,"domain":"app.example.com","checkedAt":"..."}
140
+ ```
141
+
142
+ This proves DNS + port-forwarding + TLS + an actively-connected client are
143
+ all working, independent of whether your local app is up — handy for
144
+ isolating "is the tunnel broken" from "is my app broken".
145
+
146
+ ## Running `serve` continuously
147
+
148
+ For a long-running setup, run it under a process manager so it survives
149
+ reboots/crashes, e.g. [pm2](https://pm2.keymetrics.io/) or NSSM as a Windows
150
+ service:
151
+
152
+ ```powershell
153
+ npm install -g pm2
154
+ pm2 start tunnelme --name tunnelme-server -- serve --tls acme --email you@example.com
155
+ ```
156
+
157
+ ## CLI reference
158
+
159
+ ```
160
+ tunnelme --port <port> --url <domain> [--server <ws-url>] [--token <token>]
161
+ tunnelme --config <path> [--server <ws-url>] [--token <token>]
162
+ tunnelme serve [--http-port 80] [--https-port 443] [--control-port 7000]
163
+ [--tls acme|self-signed] [--email <email>] [--staging]
164
+ [--certs-dir <path>] [--token <token>]
165
+ [--port <port> --url <domain>] [--config <path>]
166
+ ```
167
+
168
+ The last line of `serve`'s options is optional: pass `--port`/`--url` (or
169
+ `--config`) to also run a local tunnel in the same process, against the
170
+ server's own control port — one terminal instead of two, when both run on
171
+ the same machine.
@@ -0,0 +1,108 @@
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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "tunnelmate",
3
+ "version": "0.1.0",
4
+ "description": "Reverse-proxy a public domain to a localhost port, for dev/testing.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "keywords": [
8
+ "tunnel",
9
+ "reverse-proxy",
10
+ "localhost",
11
+ "ngrok",
12
+ "dev-server",
13
+ "self-hosted"
14
+ ],
15
+ "bin": {
16
+ "tunnelme": "bin/tunnelme.js"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "src"
24
+ ],
25
+ "dependencies": {
26
+ "acme-client": "^5.4.0",
27
+ "commander": "^12.1.0",
28
+ "js-yaml": "^4.1.0",
29
+ "selfsigned": "^2.4.1",
30
+ "ws": "^8.18.0"
31
+ }
32
+ }
@@ -0,0 +1,185 @@
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 ADDED
@@ -0,0 +1,107 @@
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 ADDED
@@ -0,0 +1,37 @@
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 ADDED
@@ -0,0 +1,240 @@
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 };
package/src/util.js ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ function log(...args) {
6
+ console.log(new Date().toISOString(), ...args);
7
+ }
8
+
9
+ /** Formats an Error for logging, unwrapping AggregateError (whose own .message is often empty). */
10
+ function describeError(err) {
11
+ if (!err) return String(err);
12
+ if (Array.isArray(err.errors) && err.errors.length > 0) {
13
+ return err.errors.map((e) => e.message || String(e)).join('; ');
14
+ }
15
+ return err.message || err.code || String(err);
16
+ }
17
+
18
+ /** Constant-time string comparison, safe for comparing against a network-supplied secret. */
19
+ function secureCompare(a, b) {
20
+ const ha = crypto.createHash('sha256').update(String(a)).digest();
21
+ const hb = crypto.createHash('sha256').update(String(b)).digest();
22
+ return crypto.timingSafeEqual(ha, hb);
23
+ }
24
+
25
+ /** Bidirectionally pipes two duplex streams and destroys both if either errors or closes. */
26
+ function pipeBidirectional(a, b) {
27
+ a.pipe(b);
28
+ b.pipe(a);
29
+
30
+ let cleaned = false;
31
+ const cleanup = () => {
32
+ if (cleaned) return;
33
+ cleaned = true;
34
+ a.destroy();
35
+ b.destroy();
36
+ };
37
+
38
+ a.on('error', cleanup);
39
+ b.on('error', cleanup);
40
+ a.on('close', cleanup);
41
+ b.on('close', cleanup);
42
+ }
43
+
44
+ const REQUEST_LINE_RE = /^([A-Z]+) (\S+) HTTP\/\d\.\d$/;
45
+ const MAX_LEFTOVER_BYTES = 8192;
46
+
47
+ /**
48
+ * Passively taps a socket carrying raw HTTP bytes and logs each request line
49
+ * it spots (method + path), without consuming or altering the stream --
50
+ * safe to use alongside a .pipe() of the same socket. Best-effort: after a
51
+ * protocol upgrade (e.g. WebSocket) traffic is no longer HTTP and simply
52
+ * won't match, so logging naturally goes quiet for that connection.
53
+ */
54
+ function logRequestLines(socket, label) {
55
+ let leftover = '';
56
+ socket.on('data', (chunk) => {
57
+ leftover += chunk.toString('latin1');
58
+ let idx;
59
+ while ((idx = leftover.indexOf('\r\n')) !== -1) {
60
+ const line = leftover.slice(0, idx);
61
+ leftover = leftover.slice(idx + 2);
62
+ const match = REQUEST_LINE_RE.exec(line);
63
+ if (match) log(`${label} ${match[1]} ${match[2]}`);
64
+ }
65
+ if (leftover.length > MAX_LEFTOVER_BYTES) {
66
+ leftover = leftover.slice(-1024);
67
+ }
68
+ });
69
+ }
70
+
71
+ module.exports = { log, secureCompare, pipeBidirectional, describeError, logRequestLines };