tunnelmate 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,11 +28,14 @@ Once published to npm (package name `tunnelmate`, command stays `tunnelme`):
28
28
  npm install -g tunnelmate
29
29
  ```
30
30
 
31
- For local development against this source tree instead:
31
+ For local development against this source tree instead (the source is
32
+ TypeScript, compiled to `dist/` — the `tunnelme` bin points at the compiled
33
+ output, so build once before linking, and after any source change):
32
34
 
33
35
  ```powershell
34
36
  cd C:\codes\tunnel
35
37
  npm install
38
+ npm run build
36
39
  npm link
37
40
  ```
38
41
 
@@ -66,6 +69,53 @@ internet (used for the HTTP-01 challenge) — both of which you already have.
66
69
  If you want to test the flow without hitting Let's Encrypt's rate limits,
67
70
  add `--staging` first, then drop it once it's working end-to-end.
68
71
 
72
+ ## Skipping the manual setup
73
+
74
+ Everything above (port-forwarding, DNS, firewall, Administrator privileges)
75
+ is inherent to being reachable from the internet without relying on someone
76
+ else's infrastructure (unlike, say, Cloudflare Tunnel, which avoids all of
77
+ that specifically by routing your traffic through Cloudflare's network
78
+ instead of yours). A few flags automate what can be automated:
79
+
80
+ **`--quick`** — don't own a domain, or just want to test something right
81
+ now? Skip `--url` entirely:
82
+
83
+ ```powershell
84
+ tunnelme serve --tls acme --email you@example.com --port 3000 --quick
85
+ ```
86
+
87
+ This detects your public IP and builds a working URL via
88
+ [sslip.io](https://sslip.io)'s wildcard DNS (e.g.
89
+ `https://p3000.24-78-95-66.sslip.io`) — no domain registration, no DNS
90
+ records to configure, and no traffic relay (sslip.io only ever answers a DNS
91
+ query; your data goes straight from the visitor to your machine, same as
92
+ with a real domain).
93
+
94
+ **`--upnp`** — attempts to configure port forwarding on your router
95
+ automatically via UPnP/NAT-PMP, instead of doing it by hand in the router's
96
+ admin page:
97
+
98
+ ```powershell
99
+ tunnelme serve --tls acme --email you@example.com --port 3000 --quick --upnp
100
+ ```
101
+
102
+ Not all routers support or allow this (many ISP-provided routers disable it
103
+ by default) — if none is found, it logs that and falls back to needing
104
+ manual port-forwarding, same as before.
105
+
106
+ **`--setup-firewall`** — adds the Windows Firewall inbound rules for you
107
+ (needs an Administrator terminal; safe to run repeatedly):
108
+
109
+ ```powershell
110
+ tunnelme serve --tls acme --email you@example.com --port 3000 --quick --setup-firewall
111
+ ```
112
+
113
+ Put together, `--quick --upnp --setup-firewall` (run as Administrator) gets
114
+ about as close to "one command, zero manual network configuration" as a
115
+ fully self-hosted tunnel can get — the one thing that still can't be
116
+ automated is your static IP itself, since that's an ISP-level property, not
117
+ something software on your machine controls.
118
+
69
119
  ## Expose a local dev server
70
120
 
71
121
  If your app and `serve` run on the **same machine**, skip the second
@@ -162,7 +212,8 @@ tunnelme --config <path> [--server <ws-url>] [--token <token>]
162
212
  tunnelme serve [--http-port 80] [--https-port 443] [--control-port 7000]
163
213
  [--tls acme|self-signed] [--email <email>] [--staging]
164
214
  [--certs-dir <path>] [--token <token>]
165
- [--port <port> --url <domain>] [--config <path>]
215
+ [--port <port> [--url <domain> | --quick]] [--config <path>]
216
+ [--upnp] [--setup-firewall]
166
217
  ```
167
218
 
168
219
  The last line of `serve`'s options is optional: pass `--port`/`--url` (or
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ const path = __importStar(require("path"));
38
+ const os = __importStar(require("os"));
39
+ const fs = __importStar(require("fs"));
40
+ const commander_1 = require("commander");
41
+ const config_1 = require("../src/config");
42
+ const client_1 = require("../src/client");
43
+ const server_1 = require("../src/server");
44
+ const network_1 = require("../src/network");
45
+ const firewall_1 = require("../src/firewall");
46
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
47
+ const program = new commander_1.Command();
48
+ program
49
+ .name('tunnelme')
50
+ .description('Reverse-proxy a public domain to a localhost port, for dev/testing.')
51
+ .version(pkg.version);
52
+ program
53
+ .command('run', { isDefault: true })
54
+ .description('Start the client: expose a local port under a public domain')
55
+ .option('-p, --port <port>', 'local port to expose', (v) => parseInt(v, 10))
56
+ .option('-u, --url <domain>', 'public domain to route to the local port')
57
+ .option('-c, --config <path>', 'config file with multiple tunnels (.yaml/.json)')
58
+ .option('-s, --server <url>', 'tunnelme server control address')
59
+ .option('-t, --token <token>', 'shared secret expected by the server')
60
+ .action((opts) => {
61
+ const DEFAULT_SERVER = 'ws://localhost:7000';
62
+ let serverUrl;
63
+ let token = opts.token || null;
64
+ let tunnels;
65
+ if (opts.config) {
66
+ const cfg = (0, config_1.loadConfig)(opts.config);
67
+ serverUrl = opts.server || cfg.server || DEFAULT_SERVER;
68
+ token = opts.token || cfg.token || null;
69
+ tunnels = cfg.tunnels.map((t) => ({ port: t.port, domain: t.url }));
70
+ }
71
+ else {
72
+ serverUrl = opts.server || DEFAULT_SERVER;
73
+ if (opts.port === undefined || Number.isNaN(opts.port) || !opts.url) {
74
+ console.error('Error: --port and --url are required (or pass --config)');
75
+ process.exit(1);
76
+ }
77
+ tunnels = [{ port: opts.port, domain: opts.url }];
78
+ }
79
+ (0, client_1.startClient)({ serverUrl, token, tunnels });
80
+ });
81
+ program
82
+ .command('serve')
83
+ .description('Start the tunnel server (run this on the internet-facing machine)')
84
+ .option('--http-port <port>', 'plain HTTP port (ACME challenges + redirect)', (v) => parseInt(v, 10), 80)
85
+ .option('--https-port <port>', 'public HTTPS entrypoint', (v) => parseInt(v, 10), 443)
86
+ .option('--control-port <port>', 'control channel port for clients to connect to', (v) => parseInt(v, 10), 7000)
87
+ .option('--certs-dir <path>', 'where to store certificates', path.join(os.homedir(), '.tunnelme', 'certs'))
88
+ .option('--tls <mode>', "acme (Let's Encrypt) or self-signed", 'acme')
89
+ .option('--email <email>', "contact email for Let's Encrypt")
90
+ .option('--staging', "use Let's Encrypt staging directory (for testing)", false)
91
+ .option('-t, --token <token>', 'require clients to present this shared secret')
92
+ .option('-p, --port <port>', 'also run a local tunnel: local port to expose', (v) => parseInt(v, 10))
93
+ .option('-u, --url <domain>', 'also run a local tunnel: public domain for --port')
94
+ .option('-c, --config <path>', 'also run local tunnel(s) from a config file (.yaml/.json)')
95
+ .option('-q, --quick', 'auto-generate a public URL for --port via sslip.io -- no domain to own or configure', false)
96
+ .option('--upnp', 'attempt automatic router port forwarding via UPnP/NAT-PMP', false)
97
+ .option('--setup-firewall', 'automatically add Windows Firewall inbound rules for the configured ports', false)
98
+ .action(async (opts) => {
99
+ if (opts.tls === 'acme' && !opts.email) {
100
+ console.error("Error: --email is required when --tls=acme (Let's Encrypt requires a contact email)");
101
+ process.exit(1);
102
+ }
103
+ if (opts.url && opts.quick) {
104
+ console.error('Error: --url and --quick are mutually exclusive');
105
+ process.exit(1);
106
+ }
107
+ if ((opts.url || opts.quick) && opts.port === undefined) {
108
+ console.error('Error: --url/--quick require --port');
109
+ process.exit(1);
110
+ }
111
+ if (opts.port !== undefined && !opts.url && !opts.quick) {
112
+ console.error('Error: --port requires --url (or pass --quick to auto-generate one)');
113
+ process.exit(1);
114
+ }
115
+ const { controlHttp } = (0, server_1.startServer)({
116
+ httpPort: opts.httpPort,
117
+ httpsPort: opts.httpsPort,
118
+ controlPort: opts.controlPort,
119
+ certsDir: opts.certsDir,
120
+ tlsMode: opts.tls,
121
+ acmeEmail: opts.email,
122
+ staging: opts.staging,
123
+ token: opts.token || null,
124
+ });
125
+ let stopUpnp = null;
126
+ if (opts.upnp) {
127
+ stopUpnp = await (0, network_1.autoPortForward)([opts.httpPort, opts.httpsPort]);
128
+ }
129
+ if (opts.setupFirewall) {
130
+ (0, firewall_1.setupWindowsFirewall)([opts.httpPort, opts.httpsPort]);
131
+ }
132
+ let quickUrl = null;
133
+ if (opts.quick && opts.port !== undefined) {
134
+ console.error(`Detecting public IP for --quick (port ${opts.port})...`);
135
+ try {
136
+ const ip = await (0, network_1.detectPublicIp)();
137
+ quickUrl = (0, network_1.quickDomain)(opts.port, ip);
138
+ console.error(`--quick: using https://${quickUrl} (sslip.io resolves this to ${ip} -- no traffic relay, just DNS)`);
139
+ }
140
+ catch (err) {
141
+ console.error(`Error: --quick failed to detect a public IP: ${err.message}`);
142
+ process.exit(1);
143
+ }
144
+ }
145
+ // Optionally also run the client in this same process, against the
146
+ // server's own control port, so `serve` + a tunnel can run from one
147
+ // terminal instead of two. Wait for the control channel to actually be
148
+ // listening first, so the client's first connection attempt doesn't race it.
149
+ if (opts.config || opts.port !== undefined) {
150
+ await new Promise((resolve) => {
151
+ if (controlHttp.listening)
152
+ resolve();
153
+ else
154
+ controlHttp.once('listening', () => resolve());
155
+ });
156
+ if (opts.config) {
157
+ const cfg = (0, config_1.loadConfig)(opts.config);
158
+ (0, client_1.startClient)({
159
+ serverUrl: `ws://localhost:${opts.controlPort}`,
160
+ token: opts.token || cfg.token || null,
161
+ tunnels: cfg.tunnels.map((t) => ({ port: t.port, domain: t.url })),
162
+ });
163
+ }
164
+ else if (opts.port !== undefined) {
165
+ (0, client_1.startClient)({
166
+ serverUrl: `ws://localhost:${opts.controlPort}`,
167
+ token: opts.token || null,
168
+ tunnels: [{ port: opts.port, domain: (opts.url || quickUrl) }],
169
+ });
170
+ }
171
+ }
172
+ if (stopUpnp) {
173
+ const stop = stopUpnp;
174
+ const cleanup = async () => {
175
+ await stop();
176
+ process.exit(0);
177
+ };
178
+ process.on('SIGINT', cleanup);
179
+ process.on('SIGTERM', cleanup);
180
+ }
181
+ });
182
+ program.parse();
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CertStore = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const tls = __importStar(require("tls"));
40
+ const crypto = __importStar(require("crypto"));
41
+ const acme = __importStar(require("acme-client"));
42
+ const selfsigned = __importStar(require("selfsigned"));
43
+ const LETS_ENCRYPT_PROD = 'https://acme-v02.api.letsencrypt.org/directory';
44
+ const LETS_ENCRYPT_STAGING = 'https://acme-v02.api.letsencrypt.org/directory'.replace('acme-v02', 'acme-staging-v02');
45
+ const RENEW_WITHIN_MS = 30 * 24 * 60 * 60 * 1000; // renew if <30 days left
46
+ class CertStore {
47
+ certsDir;
48
+ mode;
49
+ acmeEmail;
50
+ staging;
51
+ cache = new Map();
52
+ pending = new Map();
53
+ challenges = new Map();
54
+ acmeClient = null;
55
+ constructor(opts) {
56
+ this.certsDir = opts.certsDir;
57
+ this.mode = opts.mode;
58
+ this.acmeEmail = opts.acmeEmail;
59
+ this.staging = !!opts.staging;
60
+ fs.mkdirSync(this.certsDir, { recursive: true });
61
+ }
62
+ domainDir(domain) {
63
+ return path.join(this.certsDir, domain);
64
+ }
65
+ loadFromDisk(domain) {
66
+ const dir = this.domainDir(domain);
67
+ const certPath = path.join(dir, 'cert.pem');
68
+ const keyPath = path.join(dir, 'key.pem');
69
+ if (!fs.existsSync(certPath) || !fs.existsSync(keyPath))
70
+ return null;
71
+ const cert = fs.readFileSync(certPath, 'utf8');
72
+ const key = fs.readFileSync(keyPath, 'utf8');
73
+ const expiresAt = this.certExpiry(cert);
74
+ return { cert, key, expiresAt };
75
+ }
76
+ saveToDisk(domain, { cert, key }) {
77
+ const dir = this.domainDir(domain);
78
+ fs.mkdirSync(dir, { recursive: true });
79
+ fs.writeFileSync(path.join(dir, 'cert.pem'), cert, { mode: 0o644 });
80
+ fs.writeFileSync(path.join(dir, 'key.pem'), key, { mode: 0o600 });
81
+ }
82
+ certExpiry(certPem) {
83
+ try {
84
+ const cert = new crypto.X509Certificate(certPem);
85
+ return new Date(cert.validTo).getTime();
86
+ }
87
+ catch {
88
+ return 0;
89
+ }
90
+ }
91
+ /** Returns a tls.SecureContext for the given domain, provisioning/renewing as needed. */
92
+ async getSecureContext(domain) {
93
+ const cached = this.cache.get(domain);
94
+ if (cached && cached.expiresAt - Date.now() > RENEW_WITHIN_MS) {
95
+ return cached.ctx;
96
+ }
97
+ const fromDisk = this.loadFromDisk(domain);
98
+ if (fromDisk && fromDisk.expiresAt - Date.now() > RENEW_WITHIN_MS) {
99
+ const ctx = tls.createSecureContext({ cert: fromDisk.cert, key: fromDisk.key });
100
+ this.cache.set(domain, { ctx, expiresAt: fromDisk.expiresAt });
101
+ return ctx;
102
+ }
103
+ const existingPending = this.pending.get(domain);
104
+ if (existingPending)
105
+ return existingPending;
106
+ const provisioning = this.provision(domain)
107
+ .then(({ cert, key }) => {
108
+ this.saveToDisk(domain, { cert, key });
109
+ const ctx = tls.createSecureContext({ cert, key });
110
+ this.cache.set(domain, { ctx, expiresAt: this.certExpiry(cert) });
111
+ this.pending.delete(domain);
112
+ return ctx;
113
+ })
114
+ .catch((err) => {
115
+ this.pending.delete(domain);
116
+ throw err;
117
+ });
118
+ this.pending.set(domain, provisioning);
119
+ return provisioning;
120
+ }
121
+ async provision(domain) {
122
+ if (this.mode === 'self-signed')
123
+ return this.provisionSelfSigned(domain);
124
+ return this.provisionAcme(domain);
125
+ }
126
+ provisionSelfSigned(domain) {
127
+ const attrs = [{ name: 'commonName', value: domain }];
128
+ const pems = selfsigned.generate(attrs, {
129
+ days: 365,
130
+ keySize: 2048,
131
+ extensions: [
132
+ { name: 'basicConstraints', cA: false },
133
+ { name: 'subjectAltName', altNames: [{ type: 2, value: domain }] },
134
+ ],
135
+ });
136
+ return { cert: pems.cert, key: pems.private };
137
+ }
138
+ async getAcmeClient() {
139
+ if (this.acmeClient)
140
+ return this.acmeClient;
141
+ const accountKeyPath = path.join(this.certsDir, 'account-key.pem');
142
+ let accountKey;
143
+ if (fs.existsSync(accountKeyPath)) {
144
+ accountKey = fs.readFileSync(accountKeyPath);
145
+ }
146
+ else {
147
+ accountKey = await acme.forge.createPrivateKey();
148
+ fs.writeFileSync(accountKeyPath, accountKey, { mode: 0o600 });
149
+ }
150
+ this.acmeClient = new acme.Client({
151
+ directoryUrl: this.staging ? LETS_ENCRYPT_STAGING : LETS_ENCRYPT_PROD,
152
+ accountKey,
153
+ });
154
+ return this.acmeClient;
155
+ }
156
+ async provisionAcme(domain) {
157
+ const client = await this.getAcmeClient();
158
+ const [key, csr] = await acme.forge.createCsr({ commonName: domain });
159
+ let cert;
160
+ try {
161
+ cert = await client.auto({
162
+ csr,
163
+ email: this.acmeEmail,
164
+ termsOfServiceAgreed: true,
165
+ challengePriority: ['http-01'],
166
+ challengeCreateFn: async (_authz, challenge, keyAuthorization) => {
167
+ if (challenge.type !== 'http-01')
168
+ return;
169
+ this.challenges.set(challenge.token, keyAuthorization);
170
+ },
171
+ challengeRemoveFn: async (_authz, challenge) => {
172
+ this.challenges.delete(challenge.token);
173
+ },
174
+ });
175
+ }
176
+ catch (err) {
177
+ // acme-client's retry logic has a known bug: a pure network failure
178
+ // (no HTTP response at all) talking to Let's Encrypt, after retries
179
+ // are exhausted, throws this exact confusing TypeError instead of the
180
+ // real network error. Surface a clearer, actionable message instead.
181
+ if (err instanceof TypeError && /reading 'config'/.test(err.message)) {
182
+ throw new Error(`Failed to reach Let's Encrypt while requesting a certificate for ${domain} ` +
183
+ "(a network-level failure was hidden by a bug in the acme-client library). " +
184
+ 'Check outbound internet access from this machine to acme-v02.api.letsencrypt.org, ' +
185
+ 'and that inbound port 80 is reachable from the internet for the HTTP-01 challenge ' +
186
+ "(Let's Encrypt must be able to fetch http://" +
187
+ domain +
188
+ '/.well-known/acme-challenge/... from outside your network). Then try again.');
189
+ }
190
+ throw err;
191
+ }
192
+ return { cert: cert.toString(), key: key.toString() };
193
+ }
194
+ /** Used by the plain :80 server to answer ACME http-01 challenge requests. */
195
+ getChallengeResponse(token) {
196
+ return this.challenges.get(token) || null;
197
+ }
198
+ }
199
+ exports.CertStore = CertStore;
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.startClient = startClient;
37
+ const net = __importStar(require("net"));
38
+ const ws_1 = __importStar(require("ws"));
39
+ const util_1 = require("./util");
40
+ const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000];
41
+ function startClient(opts) {
42
+ const { serverUrl, token = null, tunnels } = opts;
43
+ const portByDomain = new Map(tunnels.map((t) => [t.domain, t.port]));
44
+ let attempt = 0;
45
+ let stopped = false;
46
+ let currentWs = null;
47
+ function connect() {
48
+ if (stopped)
49
+ return;
50
+ const ws = new ws_1.default(`${serverUrl}/_tunnelme/control`);
51
+ currentWs = ws;
52
+ ws.on('open', () => {
53
+ attempt = 0;
54
+ (0, util_1.log)('connected to tunnel server, registering...');
55
+ ws.send(JSON.stringify({
56
+ type: 'register',
57
+ token,
58
+ tunnels: tunnels.map((t) => ({ domain: t.domain, port: t.port })),
59
+ }));
60
+ });
61
+ ws.on('message', (raw) => {
62
+ let msg;
63
+ try {
64
+ msg = JSON.parse(raw.toString());
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ if (msg.type === 'registered') {
70
+ if (!Array.isArray(msg.domains))
71
+ return;
72
+ for (const domain of msg.domains) {
73
+ const port = portByDomain.get(domain);
74
+ (0, util_1.log)(`tunnel active: https://${domain} -> localhost:${port}`);
75
+ }
76
+ }
77
+ else if (msg.type === 'error') {
78
+ (0, util_1.log)('server error:', msg.message);
79
+ }
80
+ else if (msg.type === 'conn' && msg.id && msg.domain) {
81
+ handleConnRequest(msg.id, msg.domain);
82
+ }
83
+ });
84
+ ws.on('close', () => {
85
+ if (stopped)
86
+ return;
87
+ (0, util_1.log)('disconnected from tunnel server, reconnecting...');
88
+ scheduleReconnect();
89
+ });
90
+ ws.on('error', (err) => {
91
+ (0, util_1.log)('control connection error:', (0, util_1.describeError)(err));
92
+ });
93
+ }
94
+ function scheduleReconnect() {
95
+ if (stopped)
96
+ return;
97
+ const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
98
+ attempt += 1;
99
+ setTimeout(connect, delay);
100
+ }
101
+ function handleConnRequest(id, domain) {
102
+ const port = portByDomain.get(domain);
103
+ if (!port)
104
+ return;
105
+ const dataWs = new ws_1.default(`${serverUrl}/_tunnelme/data?id=${encodeURIComponent(id)}`);
106
+ dataWs.on('open', () => {
107
+ const dataStream = (0, ws_1.createWebSocketStream)(dataWs, { decodeStrings: false });
108
+ const localSocket = net.connect(port, 'localhost');
109
+ (0, util_1.pipeBidirectional)(localSocket, dataStream);
110
+ });
111
+ dataWs.on('error', (err) => {
112
+ (0, util_1.log)(`data connection error for ${domain}:`, (0, util_1.describeError)(err));
113
+ });
114
+ }
115
+ connect();
116
+ return {
117
+ stop() {
118
+ stopped = true;
119
+ if (currentWs)
120
+ currentWs.close();
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadConfig = loadConfig;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const yaml = __importStar(require("js-yaml"));
40
+ /**
41
+ * Loads a tunnelme config file (.yaml/.yml/.json).
42
+ * Shape:
43
+ * {
44
+ * server: "ws://localhost:7000", // control-channel address of `tunnelme serve`
45
+ * token: "shared-secret", // optional, must match server --token
46
+ * tunnels: [ { port: 3000, url: "app.example.com" }, ... ]
47
+ * }
48
+ */
49
+ function loadConfig(configPath) {
50
+ const resolved = path.resolve(configPath);
51
+ const raw = fs.readFileSync(resolved, 'utf8');
52
+ const ext = path.extname(resolved).toLowerCase();
53
+ const data = (ext === '.json' ? JSON.parse(raw) : yaml.load(raw));
54
+ if (!data || typeof data !== 'object') {
55
+ throw new Error(`Config file ${resolved} did not parse to an object`);
56
+ }
57
+ if (!Array.isArray(data.tunnels) || data.tunnels.length === 0) {
58
+ throw new Error(`Config file ${resolved} must define a non-empty "tunnels" array`);
59
+ }
60
+ for (const [i, t] of data.tunnels.entries()) {
61
+ if (t.port === undefined || t.port === null || !t.url) {
62
+ throw new Error(`tunnels[${i}] must have both "port" and "url"`);
63
+ }
64
+ }
65
+ return data;
66
+ }