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 +171 -0
- package/bin/tunnelme.js +108 -0
- package/package.json +32 -0
- package/src/certStore.js +185 -0
- package/src/client.js +107 -0
- package/src/config.js +37 -0
- package/src/server.js +240 -0
- package/src/util.js +71 -0
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.
|
package/bin/tunnelme.js
ADDED
|
@@ -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
|
+
}
|
package/src/certStore.js
ADDED
|
@@ -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 };
|