opentunnel-cli 1.0.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/LICENSE +21 -0
- package/README.md +284 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +1357 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client/NgrokClient.d.ts +40 -0
- package/dist/client/NgrokClient.d.ts.map +1 -0
- package/dist/client/NgrokClient.js +155 -0
- package/dist/client/NgrokClient.js.map +1 -0
- package/dist/client/TunnelClient.d.ts +47 -0
- package/dist/client/TunnelClient.d.ts.map +1 -0
- package/dist/client/TunnelClient.js +435 -0
- package/dist/client/TunnelClient.js.map +1 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +8 -0
- package/dist/client/index.js.map +1 -0
- package/dist/dns/CloudflareDNS.d.ts +45 -0
- package/dist/dns/CloudflareDNS.d.ts.map +1 -0
- package/dist/dns/CloudflareDNS.js +286 -0
- package/dist/dns/CloudflareDNS.js.map +1 -0
- package/dist/dns/DuckDNS.d.ts +20 -0
- package/dist/dns/DuckDNS.d.ts.map +1 -0
- package/dist/dns/DuckDNS.js +109 -0
- package/dist/dns/DuckDNS.js.map +1 -0
- package/dist/dns/index.d.ts +3 -0
- package/dist/dns/index.d.ts.map +1 -0
- package/dist/dns/index.js +9 -0
- package/dist/dns/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/server/CertManager.d.ts +54 -0
- package/dist/server/CertManager.d.ts.map +1 -0
- package/dist/server/CertManager.js +414 -0
- package/dist/server/CertManager.js.map +1 -0
- package/dist/server/TunnelServer.d.ts +42 -0
- package/dist/server/TunnelServer.d.ts.map +1 -0
- package/dist/server/TunnelServer.js +790 -0
- package/dist/server/TunnelServer.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +48 -0
- package/dist/server/index.js.map +1 -0
- package/dist/shared/types.d.ts +147 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +3 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/shared/utils.d.ts +29 -0
- package/dist/shared/utils.d.ts.map +1 -0
- package/dist/shared/utils.js +135 -0
- package/dist/shared/utils.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TunnelServer = void 0;
|
|
7
|
+
const http_1 = __importDefault(require("http"));
|
|
8
|
+
const https_1 = __importDefault(require("https"));
|
|
9
|
+
const net_1 = __importDefault(require("net"));
|
|
10
|
+
const ws_1 = require("ws");
|
|
11
|
+
const events_1 = require("events");
|
|
12
|
+
const utils_1 = require("../shared/utils");
|
|
13
|
+
const CertManager_1 = require("./CertManager");
|
|
14
|
+
const CloudflareDNS_1 = require("../dns/CloudflareDNS");
|
|
15
|
+
const DuckDNS_1 = require("../dns/DuckDNS");
|
|
16
|
+
class TunnelServer extends events_1.EventEmitter {
|
|
17
|
+
constructor(config) {
|
|
18
|
+
super();
|
|
19
|
+
this.httpRedirectServer = null;
|
|
20
|
+
this.clients = new Map();
|
|
21
|
+
this.tunnelsBySubdomain = new Map();
|
|
22
|
+
this.tunnelsByPort = new Map();
|
|
23
|
+
this.usedPorts = new Set();
|
|
24
|
+
this.pendingRequests = new Map();
|
|
25
|
+
this.keepaliveInterval = null;
|
|
26
|
+
this.certManager = null;
|
|
27
|
+
this.isHttps = false;
|
|
28
|
+
this.dnsProvider = null;
|
|
29
|
+
this.config = {
|
|
30
|
+
port: 8080,
|
|
31
|
+
host: "0.0.0.0",
|
|
32
|
+
domain: "localhost",
|
|
33
|
+
basePath: "op",
|
|
34
|
+
tunnelPortRange: { min: 10000, max: 20000 },
|
|
35
|
+
...config,
|
|
36
|
+
};
|
|
37
|
+
this.logger = new utils_1.Logger("Server");
|
|
38
|
+
// Create HTTP server initially (will be upgraded to HTTPS if needed)
|
|
39
|
+
this.httpServer = http_1.default.createServer();
|
|
40
|
+
// Setup request handler
|
|
41
|
+
this.httpServer.on("request", this.handleHttpRequest.bind(this));
|
|
42
|
+
// Create WebSocket server
|
|
43
|
+
this.wss = new ws_1.WebSocketServer({ noServer: true });
|
|
44
|
+
// Handle upgrade requests
|
|
45
|
+
this.httpServer.on("upgrade", (request, socket, head) => {
|
|
46
|
+
const url = new URL(request.url || "/", `http://${request.headers.host}`);
|
|
47
|
+
if (url.pathname === "/_tunnel") {
|
|
48
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
|
49
|
+
this.handleConnection(ws, request);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
socket.destroy();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async setupHttps() {
|
|
58
|
+
if (this.config.https) {
|
|
59
|
+
// Manual HTTPS with provided certificates
|
|
60
|
+
this.logger.info("Setting up HTTPS with provided certificates");
|
|
61
|
+
this.httpServer = https_1.default.createServer({
|
|
62
|
+
cert: this.config.https.cert,
|
|
63
|
+
key: this.config.https.key,
|
|
64
|
+
});
|
|
65
|
+
this.isHttps = true;
|
|
66
|
+
}
|
|
67
|
+
else if (this.config.selfSignedHttps?.enabled) {
|
|
68
|
+
// Self-signed certificates (local/development)
|
|
69
|
+
this.logger.info("Setting up HTTPS with self-signed certificate");
|
|
70
|
+
this.certManager = new CertManager_1.CertManager({
|
|
71
|
+
certsDir: this.config.selfSignedHttps.certsDir,
|
|
72
|
+
});
|
|
73
|
+
// Generate self-signed certificate for the domain
|
|
74
|
+
const domain = `${this.config.basePath}.${this.config.domain}`;
|
|
75
|
+
const certInfo = this.certManager.getOrCreateSelfSignedCert(domain);
|
|
76
|
+
this.httpServer = https_1.default.createServer({
|
|
77
|
+
cert: certInfo.cert,
|
|
78
|
+
key: certInfo.key,
|
|
79
|
+
});
|
|
80
|
+
this.isHttps = true;
|
|
81
|
+
this.logger.info(`Self-signed certificate valid until: ${certInfo.expiresAt.toISOString()}`);
|
|
82
|
+
this.logger.warn("⚠️ Using self-signed certificate - browsers will show security warning");
|
|
83
|
+
}
|
|
84
|
+
else if (this.config.autoHttps?.enabled) {
|
|
85
|
+
// Automatic HTTPS with Let's Encrypt
|
|
86
|
+
this.logger.info("Setting up automatic HTTPS with Let's Encrypt");
|
|
87
|
+
this.certManager = new CertManager_1.CertManager({
|
|
88
|
+
certsDir: this.config.autoHttps.certsDir,
|
|
89
|
+
email: this.config.autoHttps.email,
|
|
90
|
+
production: this.config.autoHttps.production,
|
|
91
|
+
cloudflareToken: this.config.autoHttps.cloudflareToken,
|
|
92
|
+
});
|
|
93
|
+
await this.certManager.initialize();
|
|
94
|
+
// Determine domains to get certificate for
|
|
95
|
+
const wildcardDomain = `*.${this.config.basePath}.${this.config.domain}`;
|
|
96
|
+
const hasCloudflare = this.certManager.hasCloudflare();
|
|
97
|
+
// With Cloudflare, we can get wildcard certificates via DNS-01
|
|
98
|
+
// Without Cloudflare, we can only get single-domain certs via HTTP-01
|
|
99
|
+
const domains = hasCloudflare
|
|
100
|
+
? [this.config.domain, wildcardDomain]
|
|
101
|
+
: [this.config.domain];
|
|
102
|
+
// If using Cloudflare, find the zone first
|
|
103
|
+
if (hasCloudflare) {
|
|
104
|
+
const zoneId = await this.certManager.findCloudflareZone(this.config.domain);
|
|
105
|
+
if (!zoneId) {
|
|
106
|
+
throw new Error(`Could not find Cloudflare zone for domain: ${this.config.domain}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Check for existing certificate
|
|
110
|
+
let certInfo = await this.certManager.getCertificate(domains);
|
|
111
|
+
if (!certInfo) {
|
|
112
|
+
this.logger.info(`Requesting SSL certificate for: ${domains.join(", ")}`);
|
|
113
|
+
// For HTTP-01 challenges (non-wildcard), start challenge server
|
|
114
|
+
if (!hasCloudflare) {
|
|
115
|
+
const challengeServer = this.certManager.createChallengeServer();
|
|
116
|
+
await new Promise((resolve) => {
|
|
117
|
+
challengeServer.listen(80, "0.0.0.0", () => {
|
|
118
|
+
this.logger.info("ACME challenge server started on port 80");
|
|
119
|
+
resolve();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
certInfo = await this.certManager.requestCertificate(domains);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
challengeServer.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
// DNS-01 challenge via Cloudflare (no HTTP server needed)
|
|
131
|
+
this.logger.info("Using DNS-01 challenge via Cloudflare for wildcard certificate");
|
|
132
|
+
certInfo = await this.certManager.requestCertificate(domains);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Create HTTPS server with the certificate
|
|
136
|
+
this.httpServer = https_1.default.createServer({
|
|
137
|
+
cert: certInfo.cert,
|
|
138
|
+
key: certInfo.key,
|
|
139
|
+
});
|
|
140
|
+
// Create HTTP redirect server
|
|
141
|
+
this.httpRedirectServer = http_1.default.createServer((req, res) => {
|
|
142
|
+
const host = req.headers.host || this.config.domain;
|
|
143
|
+
// Handle ACME challenge
|
|
144
|
+
if (req.url?.startsWith("/.well-known/acme-challenge/") && this.certManager) {
|
|
145
|
+
const token = req.url.split("/").pop() || "";
|
|
146
|
+
const keyAuth = this.certManager.handleChallengeRequest(token);
|
|
147
|
+
if (keyAuth) {
|
|
148
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
149
|
+
res.end(keyAuth);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Redirect to HTTPS
|
|
154
|
+
res.writeHead(301, { Location: `https://${host}${req.url}` });
|
|
155
|
+
res.end();
|
|
156
|
+
});
|
|
157
|
+
this.isHttps = true;
|
|
158
|
+
this.logger.info(`SSL certificate valid until: ${certInfo.expiresAt.toISOString()}`);
|
|
159
|
+
}
|
|
160
|
+
// Re-setup request handlers for new server
|
|
161
|
+
if (this.isHttps) {
|
|
162
|
+
this.httpServer.on("request", this.handleHttpRequest.bind(this));
|
|
163
|
+
this.httpServer.on("upgrade", (request, socket, head) => {
|
|
164
|
+
const url = new URL(request.url || "/", `https://${request.headers.host}`);
|
|
165
|
+
if (url.pathname === "/_tunnel") {
|
|
166
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
|
167
|
+
this.handleConnection(ws, request);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
socket.destroy();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async start() {
|
|
177
|
+
// Setup HTTPS if configured
|
|
178
|
+
await this.setupHttps();
|
|
179
|
+
// Setup automatic DNS if configured (disabled - manual DNS configuration preferred)
|
|
180
|
+
// await this.setupAutoDns();
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const port = this.isHttps ? (this.config.port === 8080 ? 443 : this.config.port) : this.config.port;
|
|
183
|
+
this.httpServer.listen(port, this.config.host, () => {
|
|
184
|
+
const protocol = this.isHttps ? "https" : "http";
|
|
185
|
+
this.logger.info(`Server started on ${this.config.host}:${port} (${protocol.toUpperCase()})`);
|
|
186
|
+
this.logger.info(`Domain: ${this.config.domain}`);
|
|
187
|
+
this.logger.info(`Subdomain pattern: *.${this.config.basePath}.${this.config.domain}`);
|
|
188
|
+
if (this.isHttps) {
|
|
189
|
+
this.logger.info(`SSL: Enabled (Let's Encrypt)`);
|
|
190
|
+
}
|
|
191
|
+
if (this.dnsProvider) {
|
|
192
|
+
this.logger.info(`DNS: Automatic management enabled (${this.dnsProvider.name})`);
|
|
193
|
+
}
|
|
194
|
+
// Start HTTP redirect server if using HTTPS
|
|
195
|
+
if (this.httpRedirectServer) {
|
|
196
|
+
this.httpRedirectServer.listen(80, this.config.host, () => {
|
|
197
|
+
this.logger.info("HTTP→HTTPS redirect server on port 80");
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// Start keepalive interval to ping clients and detect dead connections
|
|
201
|
+
this.startKeepalive();
|
|
202
|
+
resolve();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async setupAutoDns() {
|
|
207
|
+
if (!this.config.autoDns?.enabled) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const provider = this.detectDnsProvider();
|
|
211
|
+
if (provider === "cloudflare" && this.config.autoDns.cloudflareToken) {
|
|
212
|
+
this.logger.info("Auto-detected DNS provider: Cloudflare");
|
|
213
|
+
this.logger.info("Setting up automatic DNS management...");
|
|
214
|
+
const cfProvider = new CloudflareDNS_1.CloudflareDNS(this.config.autoDns.cloudflareToken, this.config.domain, this.config.basePath);
|
|
215
|
+
const initialized = await cfProvider.initialize();
|
|
216
|
+
if (!initialized) {
|
|
217
|
+
this.logger.error("Failed to initialize Cloudflare DNS - automatic DNS disabled");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.dnsProvider = cfProvider;
|
|
221
|
+
// Setup wildcard and base records if requested
|
|
222
|
+
if (this.config.autoDns.setupWildcard !== false) {
|
|
223
|
+
const success = await cfProvider.setupDNS();
|
|
224
|
+
if (success) {
|
|
225
|
+
this.logger.info("DNS records configured successfully");
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
this.logger.warn("Some DNS records could not be configured");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (provider === "duckdns" && this.config.autoDns.duckdnsToken) {
|
|
233
|
+
this.logger.info("Auto-detected DNS provider: DuckDNS");
|
|
234
|
+
const duckProvider = new DuckDNS_1.DuckDNS(this.config.autoDns.duckdnsToken);
|
|
235
|
+
this.dnsProvider = duckProvider;
|
|
236
|
+
// Update DuckDNS with server's public IP
|
|
237
|
+
const publicIP = await duckProvider.getPublicIP();
|
|
238
|
+
const duckdnsDomain = this.config.domain.replace(".duckdns.org", "");
|
|
239
|
+
const success = await duckProvider.updateRecord(duckdnsDomain, publicIP);
|
|
240
|
+
if (success) {
|
|
241
|
+
this.logger.info(`DuckDNS updated: ${this.config.domain} -> ${publicIP}`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
this.logger.warn("Failed to update DuckDNS record");
|
|
245
|
+
}
|
|
246
|
+
this.logger.info("Note: DuckDNS uses wildcard DNS - all subdomains will resolve automatically");
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
this.logger.warn("No DNS provider configured or token missing");
|
|
250
|
+
this.logger.info("Provide --cloudflare-token or --duckdns-token for automatic DNS");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
detectDnsProvider() {
|
|
254
|
+
// Explicit provider setting takes priority
|
|
255
|
+
if (this.config.autoDns?.provider) {
|
|
256
|
+
return this.config.autoDns.provider;
|
|
257
|
+
}
|
|
258
|
+
// Auto-detect based on tokens
|
|
259
|
+
if (this.config.autoDns?.cloudflareToken) {
|
|
260
|
+
return "cloudflare";
|
|
261
|
+
}
|
|
262
|
+
if (this.config.autoDns?.duckdnsToken) {
|
|
263
|
+
return "duckdns";
|
|
264
|
+
}
|
|
265
|
+
// Auto-detect based on domain
|
|
266
|
+
if (this.config.domain.endsWith(".duckdns.org")) {
|
|
267
|
+
return "duckdns";
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
stop() {
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
// Stop keepalive
|
|
274
|
+
this.stopKeepalive();
|
|
275
|
+
// Close all tunnels
|
|
276
|
+
for (const client of this.clients.values()) {
|
|
277
|
+
for (const tunnel of client.tunnels.values()) {
|
|
278
|
+
this.closeTunnel(tunnel);
|
|
279
|
+
}
|
|
280
|
+
client.ws.close();
|
|
281
|
+
}
|
|
282
|
+
// Close HTTP redirect server if exists
|
|
283
|
+
if (this.httpRedirectServer) {
|
|
284
|
+
this.httpRedirectServer.close();
|
|
285
|
+
}
|
|
286
|
+
this.wss.close(() => {
|
|
287
|
+
this.httpServer.close(() => {
|
|
288
|
+
this.logger.info("Server stopped");
|
|
289
|
+
resolve();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
startKeepalive() {
|
|
295
|
+
// Ping all clients every 20 seconds
|
|
296
|
+
this.keepaliveInterval = setInterval(() => {
|
|
297
|
+
for (const client of this.clients.values()) {
|
|
298
|
+
if (!client.isAlive) {
|
|
299
|
+
// Client didn't respond to last ping, terminate
|
|
300
|
+
this.logger.warn(`Client ${client.id} not responding, terminating`);
|
|
301
|
+
client.ws.terminate();
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// Mark as not alive, will be set back to true when pong received
|
|
305
|
+
client.isAlive = false;
|
|
306
|
+
// Send WebSocket-level ping
|
|
307
|
+
if (client.ws.readyState === ws_1.WebSocket.OPEN) {
|
|
308
|
+
client.ws.ping();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}, 20000);
|
|
312
|
+
}
|
|
313
|
+
stopKeepalive() {
|
|
314
|
+
if (this.keepaliveInterval) {
|
|
315
|
+
clearInterval(this.keepaliveInterval);
|
|
316
|
+
this.keepaliveInterval = null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
handleConnection(ws, request) {
|
|
320
|
+
const clientId = (0, utils_1.generateId)();
|
|
321
|
+
const client = {
|
|
322
|
+
id: clientId,
|
|
323
|
+
ws,
|
|
324
|
+
authenticated: !this.config.auth?.required,
|
|
325
|
+
tunnels: new Map(),
|
|
326
|
+
createdAt: new Date(),
|
|
327
|
+
lastPong: Date.now(),
|
|
328
|
+
isAlive: true,
|
|
329
|
+
};
|
|
330
|
+
this.clients.set(clientId, client);
|
|
331
|
+
this.logger.info(`Client connected: ${clientId}`);
|
|
332
|
+
// Handle WebSocket-level pong (response to our ping)
|
|
333
|
+
ws.on("pong", () => {
|
|
334
|
+
client.isAlive = true;
|
|
335
|
+
client.lastPong = Date.now();
|
|
336
|
+
});
|
|
337
|
+
ws.on("message", (data) => {
|
|
338
|
+
// Any message counts as activity
|
|
339
|
+
client.isAlive = true;
|
|
340
|
+
client.lastPong = Date.now();
|
|
341
|
+
try {
|
|
342
|
+
const message = (0, utils_1.decodeMessage)(data.toString());
|
|
343
|
+
this.handleMessage(client, message);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
this.logger.error(`Failed to parse message from ${clientId}`);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
ws.on("close", () => {
|
|
350
|
+
this.logger.info(`Client disconnected: ${clientId}`);
|
|
351
|
+
// Close all tunnels for this client
|
|
352
|
+
for (const tunnel of client.tunnels.values()) {
|
|
353
|
+
this.closeTunnel(tunnel);
|
|
354
|
+
}
|
|
355
|
+
this.clients.delete(clientId);
|
|
356
|
+
});
|
|
357
|
+
ws.on("error", (err) => {
|
|
358
|
+
this.logger.error(`Client error ${clientId}:`, err.message);
|
|
359
|
+
});
|
|
360
|
+
// Send auth response if no auth required
|
|
361
|
+
if (!this.config.auth?.required) {
|
|
362
|
+
this.send(ws, {
|
|
363
|
+
type: "auth_response",
|
|
364
|
+
id: (0, utils_1.generateId)(),
|
|
365
|
+
timestamp: Date.now(),
|
|
366
|
+
success: true,
|
|
367
|
+
clientId,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
handleMessage(client, message) {
|
|
372
|
+
switch (message.type) {
|
|
373
|
+
case "auth":
|
|
374
|
+
this.handleAuth(client, message);
|
|
375
|
+
break;
|
|
376
|
+
case "tunnel_request":
|
|
377
|
+
this.handleTunnelRequest(client, message);
|
|
378
|
+
break;
|
|
379
|
+
case "tunnel_close":
|
|
380
|
+
this.handleTunnelClose(client, message.id);
|
|
381
|
+
break;
|
|
382
|
+
case "http_response":
|
|
383
|
+
this.handleHttpResponse(message);
|
|
384
|
+
break;
|
|
385
|
+
case "tcp_data":
|
|
386
|
+
this.handleTcpData(message);
|
|
387
|
+
break;
|
|
388
|
+
case "ping":
|
|
389
|
+
this.send(client.ws, { type: "pong", id: message.id, timestamp: Date.now() });
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
handleAuth(client, message) {
|
|
394
|
+
let success = false;
|
|
395
|
+
if (this.config.auth?.required) {
|
|
396
|
+
if (message.token && this.config.auth.tokens.includes(message.token)) {
|
|
397
|
+
success = true;
|
|
398
|
+
client.authenticated = true;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
success = true;
|
|
403
|
+
client.authenticated = true;
|
|
404
|
+
}
|
|
405
|
+
this.send(client.ws, {
|
|
406
|
+
type: "auth_response",
|
|
407
|
+
id: (0, utils_1.generateId)(),
|
|
408
|
+
timestamp: Date.now(),
|
|
409
|
+
success,
|
|
410
|
+
clientId: success ? client.id : undefined,
|
|
411
|
+
error: success ? undefined : "Invalid token",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
handleTunnelRequest(client, message) {
|
|
415
|
+
if (!client.authenticated) {
|
|
416
|
+
this.send(client.ws, {
|
|
417
|
+
type: "tunnel_response",
|
|
418
|
+
id: (0, utils_1.generateId)(),
|
|
419
|
+
timestamp: Date.now(),
|
|
420
|
+
success: false,
|
|
421
|
+
error: "Not authenticated",
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const config = message.config;
|
|
426
|
+
const tunnelId = (0, utils_1.generateId)();
|
|
427
|
+
let publicUrl;
|
|
428
|
+
if (config.protocol === "http" || config.protocol === "https") {
|
|
429
|
+
// HTTP tunnel - use subdomain pattern: subdomain.op.domain.com
|
|
430
|
+
const subdomain = config.subdomain || (0, utils_1.generateSubdomain)();
|
|
431
|
+
if (this.tunnelsBySubdomain.has(subdomain)) {
|
|
432
|
+
this.send(client.ws, {
|
|
433
|
+
type: "tunnel_response",
|
|
434
|
+
id: (0, utils_1.generateId)(),
|
|
435
|
+
timestamp: Date.now(),
|
|
436
|
+
success: false,
|
|
437
|
+
error: `Subdomain '${subdomain}' is already in use`,
|
|
438
|
+
});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const protocol = this.isHttps ? "https" : "http";
|
|
442
|
+
// Third-level subdomain pattern: myapp.op.domain.com
|
|
443
|
+
publicUrl = `${protocol}://${subdomain}.${this.config.basePath}.${this.config.domain}`;
|
|
444
|
+
// Add port to URL only if not default (80 for http, 443 for https)
|
|
445
|
+
const publicPort = this.config.publicPort || this.config.port;
|
|
446
|
+
const isDefaultPort = (protocol === "http" && publicPort === 80) ||
|
|
447
|
+
(protocol === "https" && publicPort === 443);
|
|
448
|
+
if (!isDefaultPort) {
|
|
449
|
+
publicUrl += `:${publicPort}`;
|
|
450
|
+
}
|
|
451
|
+
const tunnel = {
|
|
452
|
+
id: tunnelId,
|
|
453
|
+
config: { ...config, id: tunnelId, subdomain },
|
|
454
|
+
client,
|
|
455
|
+
publicUrl,
|
|
456
|
+
tcpConnections: new Map(),
|
|
457
|
+
stats: { bytesIn: 0, bytesOut: 0, connections: 0 },
|
|
458
|
+
createdAt: new Date(),
|
|
459
|
+
};
|
|
460
|
+
this.tunnelsBySubdomain.set(subdomain, tunnel);
|
|
461
|
+
client.tunnels.set(tunnelId, tunnel);
|
|
462
|
+
this.logger.info(`HTTP tunnel: ${subdomain}.${this.config.basePath}.${this.config.domain} -> ${config.localHost}:${config.localPort}`);
|
|
463
|
+
// Create DNS record if auto DNS is enabled (Cloudflare only)
|
|
464
|
+
if (this.dnsProvider &&
|
|
465
|
+
this.config.autoDns?.createRecords !== false &&
|
|
466
|
+
this.dnsProvider instanceof CloudflareDNS_1.CloudflareDNS) {
|
|
467
|
+
this.dnsProvider.updateRecord(subdomain).then((success) => {
|
|
468
|
+
if (success) {
|
|
469
|
+
this.logger.info(`DNS record created for ${subdomain}`);
|
|
470
|
+
}
|
|
471
|
+
}).catch((err) => {
|
|
472
|
+
this.logger.warn(`Failed to create DNS record: ${err.message}`);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
else if (config.protocol === "tcp") {
|
|
477
|
+
// TCP tunnel - allocate a port
|
|
478
|
+
// Priority: 1. Explicit remotePort, 2. Same as localPort (if available), 3. Next available
|
|
479
|
+
let port = null;
|
|
480
|
+
if (config.remotePort) {
|
|
481
|
+
// User explicitly requested this port
|
|
482
|
+
if (this.usedPorts.has(config.remotePort)) {
|
|
483
|
+
this.send(client.ws, {
|
|
484
|
+
type: "tunnel_response",
|
|
485
|
+
id: (0, utils_1.generateId)(),
|
|
486
|
+
timestamp: Date.now(),
|
|
487
|
+
success: false,
|
|
488
|
+
error: `Port ${config.remotePort} is already in use`,
|
|
489
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
port = config.remotePort;
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
// Try to use the same port as local (if within range and available)
|
|
496
|
+
const localPort = config.localPort;
|
|
497
|
+
const isInRange = localPort >= this.config.tunnelPortRange.min &&
|
|
498
|
+
localPort <= this.config.tunnelPortRange.max;
|
|
499
|
+
if (isInRange && !this.usedPorts.has(localPort)) {
|
|
500
|
+
port = localPort;
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
// Fallback to next available port
|
|
504
|
+
port = (0, utils_1.getNextAvailablePort)(this.config.tunnelPortRange.min, this.config.tunnelPortRange.max, this.usedPorts);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (!port) {
|
|
508
|
+
this.send(client.ws, {
|
|
509
|
+
type: "tunnel_response",
|
|
510
|
+
id: (0, utils_1.generateId)(),
|
|
511
|
+
timestamp: Date.now(),
|
|
512
|
+
success: false,
|
|
513
|
+
error: "No available ports",
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
publicUrl = `tcp://${this.config.domain}:${port}`;
|
|
518
|
+
const tunnel = {
|
|
519
|
+
id: tunnelId,
|
|
520
|
+
config: { ...config, id: tunnelId, remotePort: port },
|
|
521
|
+
client,
|
|
522
|
+
publicUrl,
|
|
523
|
+
tcpConnections: new Map(),
|
|
524
|
+
stats: { bytesIn: 0, bytesOut: 0, connections: 0 },
|
|
525
|
+
createdAt: new Date(),
|
|
526
|
+
};
|
|
527
|
+
// Create TCP server for this tunnel
|
|
528
|
+
const tcpServer = net_1.default.createServer((socket) => {
|
|
529
|
+
this.handleTcpConnection(tunnel, socket);
|
|
530
|
+
});
|
|
531
|
+
tcpServer.listen(port, this.config.host, () => {
|
|
532
|
+
this.logger.info(`TCP tunnel on port ${port} -> ${config.localHost}:${config.localPort}`);
|
|
533
|
+
});
|
|
534
|
+
tunnel.tcpServer = tcpServer;
|
|
535
|
+
this.usedPorts.add(port);
|
|
536
|
+
this.tunnelsByPort.set(port, tunnel);
|
|
537
|
+
client.tunnels.set(tunnelId, tunnel);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
this.send(client.ws, {
|
|
541
|
+
type: "tunnel_response",
|
|
542
|
+
id: (0, utils_1.generateId)(),
|
|
543
|
+
timestamp: Date.now(),
|
|
544
|
+
success: false,
|
|
545
|
+
error: `Unsupported protocol: ${config.protocol}`,
|
|
546
|
+
});
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
this.send(client.ws, {
|
|
550
|
+
type: "tunnel_response",
|
|
551
|
+
id: (0, utils_1.generateId)(),
|
|
552
|
+
timestamp: Date.now(),
|
|
553
|
+
success: true,
|
|
554
|
+
tunnelId,
|
|
555
|
+
publicUrl,
|
|
556
|
+
});
|
|
557
|
+
this.emit("tunnel:created", { tunnelId, publicUrl, config });
|
|
558
|
+
}
|
|
559
|
+
handleTunnelClose(client, tunnelId) {
|
|
560
|
+
const tunnel = client.tunnels.get(tunnelId);
|
|
561
|
+
if (tunnel) {
|
|
562
|
+
this.closeTunnel(tunnel);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
closeTunnel(tunnel) {
|
|
566
|
+
// Close TCP server if exists
|
|
567
|
+
if (tunnel.tcpServer) {
|
|
568
|
+
tunnel.tcpServer.close();
|
|
569
|
+
const port = tunnel.config.remotePort;
|
|
570
|
+
this.usedPorts.delete(port);
|
|
571
|
+
this.tunnelsByPort.delete(port);
|
|
572
|
+
}
|
|
573
|
+
// Close all TCP connections
|
|
574
|
+
for (const socket of tunnel.tcpConnections.values()) {
|
|
575
|
+
socket.destroy();
|
|
576
|
+
}
|
|
577
|
+
// Remove from subdomain map and delete DNS record
|
|
578
|
+
if (tunnel.config.subdomain) {
|
|
579
|
+
this.tunnelsBySubdomain.delete(tunnel.config.subdomain);
|
|
580
|
+
// Delete DNS record if auto DNS is enabled and deleteOnClose is true (Cloudflare only)
|
|
581
|
+
if (this.dnsProvider &&
|
|
582
|
+
this.config.autoDns?.deleteOnClose &&
|
|
583
|
+
this.dnsProvider instanceof CloudflareDNS_1.CloudflareDNS) {
|
|
584
|
+
this.dnsProvider.deleteRecord(tunnel.config.subdomain).then((success) => {
|
|
585
|
+
if (success) {
|
|
586
|
+
this.logger.info(`DNS record deleted for ${tunnel.config.subdomain}`);
|
|
587
|
+
}
|
|
588
|
+
}).catch((err) => {
|
|
589
|
+
this.logger.warn(`Failed to delete DNS record: ${err.message}`);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// Remove from client
|
|
594
|
+
tunnel.client.tunnels.delete(tunnel.id);
|
|
595
|
+
this.logger.info(`Tunnel closed: ${tunnel.id}`);
|
|
596
|
+
this.emit("tunnel:closed", { tunnelId: tunnel.id });
|
|
597
|
+
}
|
|
598
|
+
async handleHttpRequest(req, res) {
|
|
599
|
+
const host = req.headers.host || "";
|
|
600
|
+
const hostWithoutPort = host.split(":")[0];
|
|
601
|
+
// Check if this is a direct request to the server (API or status)
|
|
602
|
+
if (hostWithoutPort === this.config.domain ||
|
|
603
|
+
hostWithoutPort === `${this.config.basePath}.${this.config.domain}`) {
|
|
604
|
+
if (req.url?.startsWith("/api/")) {
|
|
605
|
+
this.handleApiRequest(req, res);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
// Serve status page
|
|
609
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
610
|
+
res.end(JSON.stringify({
|
|
611
|
+
name: "OpenTunnel Server",
|
|
612
|
+
version: "1.0.0",
|
|
613
|
+
status: "running",
|
|
614
|
+
domain: this.config.domain,
|
|
615
|
+
subdomainPattern: `*.${this.config.basePath}.${this.config.domain}`,
|
|
616
|
+
tunnels: this.getTunnelCount(),
|
|
617
|
+
clients: this.clients.size,
|
|
618
|
+
}));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
// Find tunnel by subdomain (pattern: subdomain.op.domain.com)
|
|
622
|
+
const subdomain = (0, utils_1.extractSubdomain)(hostWithoutPort, this.config.domain, this.config.basePath);
|
|
623
|
+
if (!subdomain) {
|
|
624
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
625
|
+
res.end(JSON.stringify({ error: "Tunnel not found" }));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const tunnel = this.tunnelsBySubdomain.get(subdomain);
|
|
629
|
+
if (!tunnel) {
|
|
630
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
631
|
+
res.end(JSON.stringify({ error: `Tunnel '${subdomain}' not found` }));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// Forward request to client
|
|
635
|
+
const requestId = (0, utils_1.generateId)();
|
|
636
|
+
const bodyChunks = [];
|
|
637
|
+
req.on("data", (chunk) => bodyChunks.push(chunk));
|
|
638
|
+
req.on("end", async () => {
|
|
639
|
+
const body = Buffer.concat(bodyChunks).toString();
|
|
640
|
+
const httpRequest = {
|
|
641
|
+
type: "http_request",
|
|
642
|
+
id: (0, utils_1.generateId)(),
|
|
643
|
+
timestamp: Date.now(),
|
|
644
|
+
tunnelId: tunnel.id,
|
|
645
|
+
requestId,
|
|
646
|
+
method: req.method || "GET",
|
|
647
|
+
path: req.url || "/",
|
|
648
|
+
headers: req.headers,
|
|
649
|
+
body: body || undefined,
|
|
650
|
+
};
|
|
651
|
+
tunnel.stats.bytesIn += body.length;
|
|
652
|
+
tunnel.stats.connections++;
|
|
653
|
+
try {
|
|
654
|
+
const response = await this.waitForResponse(tunnel, requestId, httpRequest);
|
|
655
|
+
res.writeHead(response.statusCode, response.headers);
|
|
656
|
+
if (response.body) {
|
|
657
|
+
// Decode base64 if the response body is encoded (for binary data like gzip)
|
|
658
|
+
const bodyBuffer = response.isBase64
|
|
659
|
+
? Buffer.from(response.body, "base64")
|
|
660
|
+
: Buffer.from(response.body, "utf-8");
|
|
661
|
+
tunnel.stats.bytesOut += bodyBuffer.length;
|
|
662
|
+
res.end(bodyBuffer);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
res.end();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
670
|
+
res.end(JSON.stringify({ error: "Bad gateway - tunnel client not responding" }));
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
waitForResponse(tunnel, requestId, request) {
|
|
675
|
+
return new Promise((resolve, reject) => {
|
|
676
|
+
const timeout = setTimeout(() => {
|
|
677
|
+
this.pendingRequests.delete(requestId);
|
|
678
|
+
reject(new Error("Request timeout"));
|
|
679
|
+
}, 30000);
|
|
680
|
+
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
|
681
|
+
this.send(tunnel.client.ws, request);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
handleHttpResponse(message) {
|
|
685
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
686
|
+
if (pending) {
|
|
687
|
+
clearTimeout(pending.timeout);
|
|
688
|
+
this.pendingRequests.delete(message.requestId);
|
|
689
|
+
pending.resolve(message);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
handleTcpConnection(tunnel, socket) {
|
|
693
|
+
const connectionId = (0, utils_1.generateId)();
|
|
694
|
+
tunnel.tcpConnections.set(connectionId, socket);
|
|
695
|
+
tunnel.stats.connections++;
|
|
696
|
+
this.logger.info(`TCP connection ${connectionId} to tunnel ${tunnel.id}`);
|
|
697
|
+
socket.on("data", (data) => {
|
|
698
|
+
tunnel.stats.bytesIn += data.length;
|
|
699
|
+
this.send(tunnel.client.ws, {
|
|
700
|
+
type: "tcp_data",
|
|
701
|
+
id: (0, utils_1.generateId)(),
|
|
702
|
+
timestamp: Date.now(),
|
|
703
|
+
tunnelId: tunnel.id,
|
|
704
|
+
connectionId,
|
|
705
|
+
data: (0, utils_1.encodeBase64)(data),
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
socket.on("close", () => {
|
|
709
|
+
tunnel.tcpConnections.delete(connectionId);
|
|
710
|
+
this.send(tunnel.client.ws, {
|
|
711
|
+
type: "tcp_close",
|
|
712
|
+
id: (0, utils_1.generateId)(),
|
|
713
|
+
timestamp: Date.now(),
|
|
714
|
+
tunnelId: tunnel.id,
|
|
715
|
+
connectionId,
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
socket.on("error", (err) => {
|
|
719
|
+
this.logger.error(`TCP connection error: ${err.message}`);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
handleTcpData(message) {
|
|
723
|
+
// Find tunnel and connection
|
|
724
|
+
for (const client of this.clients.values()) {
|
|
725
|
+
const tunnel = client.tunnels.get(message.tunnelId);
|
|
726
|
+
if (tunnel) {
|
|
727
|
+
const socket = tunnel.tcpConnections.get(message.connectionId);
|
|
728
|
+
if (socket) {
|
|
729
|
+
const data = (0, utils_1.decodeBase64)(message.data);
|
|
730
|
+
tunnel.stats.bytesOut += data.length;
|
|
731
|
+
socket.write(data);
|
|
732
|
+
}
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
handleApiRequest(req, res) {
|
|
738
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
739
|
+
res.setHeader("Content-Type", "application/json");
|
|
740
|
+
if (url.pathname === "/api/tunnels" && req.method === "GET") {
|
|
741
|
+
const tunnels = this.getAllTunnels();
|
|
742
|
+
res.writeHead(200);
|
|
743
|
+
res.end(JSON.stringify({ tunnels }));
|
|
744
|
+
}
|
|
745
|
+
else if (url.pathname === "/api/stats" && req.method === "GET") {
|
|
746
|
+
res.writeHead(200);
|
|
747
|
+
res.end(JSON.stringify({
|
|
748
|
+
clients: this.clients.size,
|
|
749
|
+
tunnels: this.getTunnelCount(),
|
|
750
|
+
uptime: process.uptime(),
|
|
751
|
+
}));
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
res.writeHead(404);
|
|
755
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
send(ws, message) {
|
|
759
|
+
if (ws.readyState === ws_1.WebSocket.OPEN) {
|
|
760
|
+
ws.send((0, utils_1.encodeMessage)(message));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
getTunnelCount() {
|
|
764
|
+
let count = 0;
|
|
765
|
+
for (const client of this.clients.values()) {
|
|
766
|
+
count += client.tunnels.size;
|
|
767
|
+
}
|
|
768
|
+
return count;
|
|
769
|
+
}
|
|
770
|
+
getAllTunnels() {
|
|
771
|
+
const tunnels = [];
|
|
772
|
+
for (const client of this.clients.values()) {
|
|
773
|
+
for (const tunnel of client.tunnels.values()) {
|
|
774
|
+
tunnels.push({
|
|
775
|
+
id: tunnel.id,
|
|
776
|
+
protocol: tunnel.config.protocol,
|
|
777
|
+
localAddress: `${tunnel.config.localHost}:${tunnel.config.localPort}`,
|
|
778
|
+
publicUrl: tunnel.publicUrl,
|
|
779
|
+
createdAt: tunnel.createdAt,
|
|
780
|
+
bytesIn: tunnel.stats.bytesIn,
|
|
781
|
+
bytesOut: tunnel.stats.bytesOut,
|
|
782
|
+
connections: tunnel.stats.connections,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return tunnels;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
exports.TunnelServer = TunnelServer;
|
|
790
|
+
//# sourceMappingURL=TunnelServer.js.map
|