tunnelmate 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -2
- package/dist/bin/tunnelme.js +182 -0
- package/dist/src/certStore.js +199 -0
- package/dist/src/client.js +123 -0
- package/dist/src/config.js +66 -0
- package/dist/src/firewall.js +41 -0
- package/dist/src/network.js +154 -0
- package/dist/src/server.js +258 -0
- package/dist/src/types.js +2 -0
- package/dist/src/util.js +103 -0
- package/package.json +18 -4
- package/bin/tunnelme.js +0 -108
- package/src/certStore.js +0 -185
- package/src/client.js +0 -107
- package/src/config.js +0 -37
- package/src/server.js +0 -240
- package/src/util.js +0 -71
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
|
+
}
|