api-response-manager 2.5.4 → 2.6.1
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 +1 -1
- package/bin/arm.js +12 -1
- package/commands/serve.js +613 -0
- package/commands/tunnel.js +12 -0
- package/package.json +3 -1
- package/utils/api.js +11 -0
- package/utils/e2eEncryption.js +162 -0
package/README.md
CHANGED
package/bin/arm.js
CHANGED
|
@@ -26,6 +26,7 @@ const ingressCommand = require('../commands/ingress');
|
|
|
26
26
|
const accountCommand = require('../commands/account');
|
|
27
27
|
const installCommand = require('../commands/install');
|
|
28
28
|
const uninstallCommand = require('../commands/uninstall');
|
|
29
|
+
const serveCommand = require('../commands/serve');
|
|
29
30
|
|
|
30
31
|
// CLI setup
|
|
31
32
|
program
|
|
@@ -58,9 +59,10 @@ program
|
|
|
58
59
|
.option('-n, --name <name>', 'Tunnel name')
|
|
59
60
|
.option('-a, --auth', 'Enable basic authentication')
|
|
60
61
|
.option('-r, --rate-limit <limit>', 'Rate limit (requests per minute)', '60')
|
|
61
|
-
.option('-p, --protocol <protocol>', 'Protocol (http, https, tcp, ssh, ws, wss)', 'http')
|
|
62
|
+
.option('-p, --protocol <protocol>', 'Protocol (http, https, tcp, ssh, ws, wss, udp)', 'http')
|
|
62
63
|
.option('--ssl', 'Enable SSL/HTTPS')
|
|
63
64
|
.option('-d, --domain <domain>', 'Custom domain')
|
|
65
|
+
.option('--e2e', 'Enable end-to-end encryption')
|
|
64
66
|
.option('--json', 'Output in JSON format (for CI/CD automation)')
|
|
65
67
|
.action(tunnelCommand.start);
|
|
66
68
|
|
|
@@ -143,6 +145,15 @@ program
|
|
|
143
145
|
.option('--tls', 'Enable TLS for ingress')
|
|
144
146
|
.action(tunnelCommand.configureIngress);
|
|
145
147
|
|
|
148
|
+
// Serve command - file sharing
|
|
149
|
+
program
|
|
150
|
+
.command('serve')
|
|
151
|
+
.description('Share a directory over the internet')
|
|
152
|
+
.argument('[directory]', 'Directory to serve (default: current directory)', '.')
|
|
153
|
+
.option('-p, --port <port>', 'Local port for file server', '8000')
|
|
154
|
+
.option('-s, --subdomain <subdomain>', 'Custom subdomain')
|
|
155
|
+
.action(serveCommand.serve);
|
|
156
|
+
|
|
146
157
|
// IP Whitelist commands
|
|
147
158
|
program
|
|
148
159
|
.command('tunnel:ip-whitelist:add')
|
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const ora = require('ora');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const url = require('url');
|
|
7
|
+
const mime = require('mime-types');
|
|
8
|
+
const api = require('../utils/api');
|
|
9
|
+
const config = require('../utils/config');
|
|
10
|
+
|
|
11
|
+
// File server for serving static files
|
|
12
|
+
class FileServer {
|
|
13
|
+
constructor(directory, port) {
|
|
14
|
+
this.directory = path.resolve(directory);
|
|
15
|
+
this.port = port;
|
|
16
|
+
this.server = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
start() {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
this.server = http.createServer((req, res) => {
|
|
22
|
+
this.handleRequest(req, res);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
this.server.on('error', (err) => {
|
|
26
|
+
if (err.code === 'EADDRINUSE') {
|
|
27
|
+
reject(new Error(`Port ${this.port} is already in use`));
|
|
28
|
+
} else {
|
|
29
|
+
reject(err);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
this.server.listen(this.port, () => {
|
|
34
|
+
resolve();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
handleRequest(req, res) {
|
|
40
|
+
const parsedUrl = url.parse(req.url, true);
|
|
41
|
+
let pathname = decodeURIComponent(parsedUrl.pathname);
|
|
42
|
+
|
|
43
|
+
// Prevent directory traversal
|
|
44
|
+
pathname = pathname.replace(/\.\./g, '');
|
|
45
|
+
|
|
46
|
+
let filePath = path.join(this.directory, pathname);
|
|
47
|
+
|
|
48
|
+
// Check if path exists
|
|
49
|
+
fs.stat(filePath, (err, stats) => {
|
|
50
|
+
if (err) {
|
|
51
|
+
if (err.code === 'ENOENT') {
|
|
52
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
53
|
+
res.end(this.generate404Page(pathname));
|
|
54
|
+
} else {
|
|
55
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
56
|
+
res.end('Internal Server Error');
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (stats.isDirectory()) {
|
|
62
|
+
// Check for index.html
|
|
63
|
+
const indexPath = path.join(filePath, 'index.html');
|
|
64
|
+
if (fs.existsSync(indexPath)) {
|
|
65
|
+
this.serveFile(indexPath, res);
|
|
66
|
+
} else {
|
|
67
|
+
// Generate directory listing
|
|
68
|
+
this.serveDirectoryListing(filePath, pathname, res);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
this.serveFile(filePath, res);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
serveFile(filePath, res) {
|
|
77
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
78
|
+
// Handle JSX/TSX files as JavaScript for development
|
|
79
|
+
let contentType;
|
|
80
|
+
if (ext === '.jsx' || ext === '.tsx') {
|
|
81
|
+
contentType = 'application/javascript';
|
|
82
|
+
} else if (ext === '.ts') {
|
|
83
|
+
contentType = 'application/javascript';
|
|
84
|
+
} else {
|
|
85
|
+
contentType = mime.lookup(ext) || 'application/octet-stream';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fs.readFile(filePath, (err, data) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
91
|
+
res.end('Error reading file');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stats = fs.statSync(filePath);
|
|
96
|
+
res.writeHead(200, {
|
|
97
|
+
'Content-Type': contentType,
|
|
98
|
+
'Content-Length': stats.size,
|
|
99
|
+
'Cache-Control': 'no-cache',
|
|
100
|
+
'Access-Control-Allow-Origin': '*'
|
|
101
|
+
});
|
|
102
|
+
res.end(data);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
serveDirectoryListing(dirPath, urlPath, res) {
|
|
107
|
+
fs.readdir(dirPath, { withFileTypes: true }, (err, entries) => {
|
|
108
|
+
if (err) {
|
|
109
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
110
|
+
res.end('Error reading directory');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const html = this.generateDirectoryHTML(entries, urlPath, dirPath);
|
|
115
|
+
res.writeHead(200, {
|
|
116
|
+
'Content-Type': 'text/html',
|
|
117
|
+
'Cache-Control': 'no-cache'
|
|
118
|
+
});
|
|
119
|
+
res.end(html);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
generateDirectoryHTML(entries, urlPath, dirPath) {
|
|
124
|
+
const normalizedPath = urlPath.endsWith('/') ? urlPath : urlPath + '/';
|
|
125
|
+
|
|
126
|
+
let items = entries.map(entry => {
|
|
127
|
+
const isDir = entry.isDirectory();
|
|
128
|
+
const name = entry.name;
|
|
129
|
+
const href = normalizedPath + encodeURIComponent(name) + (isDir ? '/' : '');
|
|
130
|
+
const icon = isDir ? '📁' : this.getFileIcon(name);
|
|
131
|
+
const stats = fs.statSync(path.join(dirPath, name));
|
|
132
|
+
const size = isDir ? '-' : this.formatSize(stats.size);
|
|
133
|
+
const modified = stats.mtime.toLocaleString();
|
|
134
|
+
|
|
135
|
+
return `
|
|
136
|
+
<tr>
|
|
137
|
+
<td class="icon">${icon}</td>
|
|
138
|
+
<td class="name"><a href="${href}">${name}${isDir ? '/' : ''}</a></td>
|
|
139
|
+
<td class="size">${size}</td>
|
|
140
|
+
<td class="modified">${modified}</td>
|
|
141
|
+
</tr>
|
|
142
|
+
`;
|
|
143
|
+
}).join('');
|
|
144
|
+
|
|
145
|
+
// Add parent directory link if not at root
|
|
146
|
+
if (urlPath !== '/' && urlPath !== '') {
|
|
147
|
+
const parentPath = path.dirname(urlPath) || '/';
|
|
148
|
+
items = `
|
|
149
|
+
<tr>
|
|
150
|
+
<td class="icon">📂</td>
|
|
151
|
+
<td class="name"><a href="${parentPath}">..</a></td>
|
|
152
|
+
<td class="size">-</td>
|
|
153
|
+
<td class="modified">-</td>
|
|
154
|
+
</tr>
|
|
155
|
+
` + items;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return `
|
|
159
|
+
<!DOCTYPE html>
|
|
160
|
+
<html>
|
|
161
|
+
<head>
|
|
162
|
+
<meta charset="UTF-8">
|
|
163
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
164
|
+
<title>Index of ${urlPath || '/'}</title>
|
|
165
|
+
<style>
|
|
166
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
167
|
+
body {
|
|
168
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
169
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
170
|
+
color: #e4e4e7;
|
|
171
|
+
min-height: 100vh;
|
|
172
|
+
padding: 2rem;
|
|
173
|
+
}
|
|
174
|
+
.container {
|
|
175
|
+
max-width: 1000px;
|
|
176
|
+
margin: 0 auto;
|
|
177
|
+
}
|
|
178
|
+
h1 {
|
|
179
|
+
font-size: 1.5rem;
|
|
180
|
+
margin-bottom: 1.5rem;
|
|
181
|
+
color: #a78bfa;
|
|
182
|
+
}
|
|
183
|
+
.path {
|
|
184
|
+
color: #94a3b8;
|
|
185
|
+
font-family: monospace;
|
|
186
|
+
}
|
|
187
|
+
table {
|
|
188
|
+
width: 100%;
|
|
189
|
+
border-collapse: collapse;
|
|
190
|
+
background: rgba(255,255,255,0.05);
|
|
191
|
+
border-radius: 0.5rem;
|
|
192
|
+
overflow: hidden;
|
|
193
|
+
}
|
|
194
|
+
th, td {
|
|
195
|
+
padding: 0.75rem 1rem;
|
|
196
|
+
text-align: left;
|
|
197
|
+
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
198
|
+
}
|
|
199
|
+
th {
|
|
200
|
+
background: rgba(255,255,255,0.1);
|
|
201
|
+
font-weight: 600;
|
|
202
|
+
color: #94a3b8;
|
|
203
|
+
font-size: 0.875rem;
|
|
204
|
+
}
|
|
205
|
+
tr:hover {
|
|
206
|
+
background: rgba(255,255,255,0.05);
|
|
207
|
+
}
|
|
208
|
+
a {
|
|
209
|
+
color: #60a5fa;
|
|
210
|
+
text-decoration: none;
|
|
211
|
+
}
|
|
212
|
+
a:hover {
|
|
213
|
+
text-decoration: underline;
|
|
214
|
+
}
|
|
215
|
+
.icon { width: 40px; text-align: center; }
|
|
216
|
+
.size { width: 100px; color: #94a3b8; font-size: 0.875rem; }
|
|
217
|
+
.modified { color: #94a3b8; font-size: 0.875rem; }
|
|
218
|
+
.footer {
|
|
219
|
+
margin-top: 2rem;
|
|
220
|
+
text-align: center;
|
|
221
|
+
color: #64748b;
|
|
222
|
+
font-size: 0.875rem;
|
|
223
|
+
}
|
|
224
|
+
.footer a { color: #a78bfa; }
|
|
225
|
+
</style>
|
|
226
|
+
</head>
|
|
227
|
+
<body>
|
|
228
|
+
<div class="container">
|
|
229
|
+
<h1>📂 Index of <span class="path">${urlPath || '/'}</span></h1>
|
|
230
|
+
<table>
|
|
231
|
+
<thead>
|
|
232
|
+
<tr>
|
|
233
|
+
<th></th>
|
|
234
|
+
<th>Name</th>
|
|
235
|
+
<th>Size</th>
|
|
236
|
+
<th>Modified</th>
|
|
237
|
+
</tr>
|
|
238
|
+
</thead>
|
|
239
|
+
<tbody>
|
|
240
|
+
${items}
|
|
241
|
+
</tbody>
|
|
242
|
+
</table>
|
|
243
|
+
<div class="footer">
|
|
244
|
+
Served by <a href="https://tunnelapi.in">TunnelAPI</a> File Server
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</body>
|
|
248
|
+
</html>
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
generate404Page(pathname) {
|
|
253
|
+
return `
|
|
254
|
+
<!DOCTYPE html>
|
|
255
|
+
<html>
|
|
256
|
+
<head>
|
|
257
|
+
<meta charset="UTF-8">
|
|
258
|
+
<title>404 Not Found</title>
|
|
259
|
+
<style>
|
|
260
|
+
body {
|
|
261
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
262
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
263
|
+
color: #e4e4e7;
|
|
264
|
+
min-height: 100vh;
|
|
265
|
+
display: flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
justify-content: center;
|
|
268
|
+
text-align: center;
|
|
269
|
+
}
|
|
270
|
+
h1 { font-size: 4rem; color: #ef4444; }
|
|
271
|
+
p { color: #94a3b8; margin-top: 1rem; }
|
|
272
|
+
a { color: #60a5fa; }
|
|
273
|
+
</style>
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
<div>
|
|
277
|
+
<h1>404</h1>
|
|
278
|
+
<p>File not found: ${pathname}</p>
|
|
279
|
+
<p><a href="/">← Back to root</a></p>
|
|
280
|
+
</div>
|
|
281
|
+
</body>
|
|
282
|
+
</html>
|
|
283
|
+
`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
getFileIcon(filename) {
|
|
287
|
+
const ext = path.extname(filename).toLowerCase();
|
|
288
|
+
const icons = {
|
|
289
|
+
'.html': '🌐', '.htm': '🌐',
|
|
290
|
+
'.css': '🎨',
|
|
291
|
+
'.js': '📜', '.ts': '📜', '.jsx': '📜', '.tsx': '📜',
|
|
292
|
+
'.json': '📋',
|
|
293
|
+
'.md': '📝', '.txt': '📝',
|
|
294
|
+
'.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.gif': '🖼️', '.svg': '🖼️', '.webp': '🖼️',
|
|
295
|
+
'.pdf': '📕',
|
|
296
|
+
'.zip': '📦', '.tar': '📦', '.gz': '📦', '.rar': '📦',
|
|
297
|
+
'.mp3': '🎵', '.wav': '🎵', '.ogg': '🎵',
|
|
298
|
+
'.mp4': '🎬', '.webm': '🎬', '.avi': '🎬', '.mov': '🎬',
|
|
299
|
+
'.py': '🐍',
|
|
300
|
+
'.go': '🐹',
|
|
301
|
+
'.rs': '🦀',
|
|
302
|
+
'.java': '☕',
|
|
303
|
+
'.rb': '💎',
|
|
304
|
+
'.php': '🐘',
|
|
305
|
+
'.sh': '🖥️', '.bash': '🖥️',
|
|
306
|
+
'.yml': '⚙️', '.yaml': '⚙️',
|
|
307
|
+
'.xml': '📰',
|
|
308
|
+
'.sql': '🗃️',
|
|
309
|
+
'.env': '🔐',
|
|
310
|
+
};
|
|
311
|
+
return icons[ext] || '📄';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
formatSize(bytes) {
|
|
315
|
+
if (bytes === 0) return '0 B';
|
|
316
|
+
const k = 1024;
|
|
317
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
318
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
319
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
stop() {
|
|
323
|
+
return new Promise((resolve) => {
|
|
324
|
+
if (this.server) {
|
|
325
|
+
this.server.close(() => resolve());
|
|
326
|
+
} else {
|
|
327
|
+
resolve();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Serve command - share a directory over the internet
|
|
334
|
+
async function serve(directory, options) {
|
|
335
|
+
const dir = directory || '.';
|
|
336
|
+
const port = parseInt(options.port) || 8000;
|
|
337
|
+
const subdomain = options.subdomain;
|
|
338
|
+
|
|
339
|
+
// Validate directory exists
|
|
340
|
+
const resolvedDir = path.resolve(dir);
|
|
341
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
342
|
+
console.error(chalk.red(`\n✗ Directory not found: ${resolvedDir}\n`));
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!fs.statSync(resolvedDir).isDirectory()) {
|
|
347
|
+
console.error(chalk.red(`\n✗ Not a directory: ${resolvedDir}\n`));
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log(chalk.blue.bold('\n📂 Starting File Server...\n'));
|
|
352
|
+
|
|
353
|
+
const spinner = ora('Checking file transfer limits...').start();
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
// Check file transfer limit before starting
|
|
357
|
+
const limitCheck = await api.checkFileTransferLimit();
|
|
358
|
+
if (!limitCheck.allowed) {
|
|
359
|
+
spinner.fail(chalk.red('File transfer limit reached'));
|
|
360
|
+
console.error(chalk.red(`\n✗ ${limitCheck.msg}\n`));
|
|
361
|
+
console.log(chalk.yellow('💡 Upgrade your plan at https://tunnelapi.in/pricing to get more transfer quota.\n'));
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Show remaining quota
|
|
366
|
+
if (limitCheck.limitMB) {
|
|
367
|
+
spinner.succeed(chalk.green(`Transfer quota: ${limitCheck.usedMB} MB / ${limitCheck.limitMB} MB used (${limitCheck.remainingMB} MB remaining)`));
|
|
368
|
+
} else {
|
|
369
|
+
spinner.succeed(chalk.green('Transfer quota: Unlimited'));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
spinner.start('Starting local file server...');
|
|
373
|
+
|
|
374
|
+
// Start local file server
|
|
375
|
+
const fileServer = new FileServer(resolvedDir, port);
|
|
376
|
+
await fileServer.start();
|
|
377
|
+
spinner.succeed(chalk.green(`Local file server running on port ${port}`));
|
|
378
|
+
|
|
379
|
+
// Create tunnel
|
|
380
|
+
spinner.start('Creating tunnel...');
|
|
381
|
+
|
|
382
|
+
const tunnelData = {
|
|
383
|
+
name: subdomain || `file-server-${Date.now()}`,
|
|
384
|
+
subdomain: subdomain,
|
|
385
|
+
localPort: port,
|
|
386
|
+
protocol: 'http'
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const response = await api.createTunnel(tunnelData);
|
|
390
|
+
const tunnel = response.tunnel;
|
|
391
|
+
|
|
392
|
+
spinner.succeed(chalk.green('Tunnel created successfully!'));
|
|
393
|
+
|
|
394
|
+
// Display info
|
|
395
|
+
console.log(chalk.gray('\n┌─────────────────────────────────────────────────────────┐'));
|
|
396
|
+
console.log(chalk.gray('│') + chalk.white.bold(' File Server Information ') + chalk.gray('│'));
|
|
397
|
+
console.log(chalk.gray('├─────────────────────────────────────────────────────────┤'));
|
|
398
|
+
console.log(chalk.gray('│') + chalk.gray(' Directory: ') + chalk.white(resolvedDir.substring(0, 40).padEnd(40)) + chalk.gray('│'));
|
|
399
|
+
console.log(chalk.gray('│') + chalk.gray(' Local Port: ') + chalk.white(String(port).padEnd(40)) + chalk.gray('│'));
|
|
400
|
+
console.log(chalk.gray('│') + chalk.gray(' Public URL: ') + chalk.cyan(tunnel.publicUrl.padEnd(40)) + chalk.gray('│'));
|
|
401
|
+
console.log(chalk.gray('└─────────────────────────────────────────────────────────┘\n'));
|
|
402
|
+
|
|
403
|
+
// Display QR code
|
|
404
|
+
const qrcode = require('qrcode-terminal');
|
|
405
|
+
console.log(chalk.yellow.bold('📱 Scan QR Code to access on mobile:\n'));
|
|
406
|
+
qrcode.generate(tunnel.publicUrl, { small: true }, (qr) => {
|
|
407
|
+
console.log(qr);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
console.log(chalk.gray('\nPress Ctrl+C to stop serving files\n'));
|
|
411
|
+
|
|
412
|
+
// Connect tunnel client
|
|
413
|
+
const tunnelCommand = require('./tunnel');
|
|
414
|
+
// We need to connect the tunnel client to forward requests
|
|
415
|
+
await connectFileTunnel(tunnel.id || tunnel._id, tunnel.subdomain, port);
|
|
416
|
+
|
|
417
|
+
// Handle shutdown
|
|
418
|
+
process.on('SIGINT', async () => {
|
|
419
|
+
console.log(chalk.yellow('\n\n🛑 Stopping file server...'));
|
|
420
|
+
await fileServer.stop();
|
|
421
|
+
try {
|
|
422
|
+
await api.deleteTunnel(tunnel.id || tunnel._id);
|
|
423
|
+
} catch (e) {
|
|
424
|
+
// Ignore errors on cleanup
|
|
425
|
+
}
|
|
426
|
+
console.log(chalk.green('File server stopped.\n'));
|
|
427
|
+
process.exit(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
} catch (error) {
|
|
431
|
+
spinner.fail(chalk.red('Failed to start file server'));
|
|
432
|
+
console.error(chalk.red(`\n✗ ${error.response?.data?.msg || error.message}\n`));
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Connect tunnel client for file serving
|
|
438
|
+
async function connectFileTunnel(tunnelId, subdomain, localPort) {
|
|
439
|
+
const WebSocket = require('ws');
|
|
440
|
+
const axios = require('axios');
|
|
441
|
+
|
|
442
|
+
const tunnelServerUrl = config.get('tunnelServerUrl') || 'wss://tunnel.tunnelapi.in';
|
|
443
|
+
const token = api.getToken();
|
|
444
|
+
const userId = config.get('userId');
|
|
445
|
+
|
|
446
|
+
// Track total bytes transferred (persists across reconnects)
|
|
447
|
+
let totalBytesTransferred = 0;
|
|
448
|
+
let limitReached = false;
|
|
449
|
+
let heartbeatInterval = null;
|
|
450
|
+
let reconnectAttempts = 0;
|
|
451
|
+
const maxReconnectAttempts = 50; // Allow many reconnects
|
|
452
|
+
let isShuttingDown = false;
|
|
453
|
+
|
|
454
|
+
function connect() {
|
|
455
|
+
if (isShuttingDown) return;
|
|
456
|
+
|
|
457
|
+
const ws = new WebSocket(tunnelServerUrl);
|
|
458
|
+
|
|
459
|
+
ws.on('open', () => {
|
|
460
|
+
reconnectAttempts = 0; // Reset on successful connection
|
|
461
|
+
console.log(chalk.green('✓ Connected to tunnel server'));
|
|
462
|
+
|
|
463
|
+
ws.send(JSON.stringify({
|
|
464
|
+
type: 'register',
|
|
465
|
+
tunnelId,
|
|
466
|
+
subdomain,
|
|
467
|
+
localPort,
|
|
468
|
+
protocol: 'http',
|
|
469
|
+
authToken: token,
|
|
470
|
+
userId,
|
|
471
|
+
persistent: true // File serving tunnels should not have idle timeout
|
|
472
|
+
}));
|
|
473
|
+
|
|
474
|
+
// Clear any existing heartbeat
|
|
475
|
+
if (heartbeatInterval) {
|
|
476
|
+
clearInterval(heartbeatInterval);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Heartbeat every 15 seconds to keep connection alive
|
|
480
|
+
heartbeatInterval = setInterval(() => {
|
|
481
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
482
|
+
ws.send(JSON.stringify({ type: 'heartbeat', tunnelId, subdomain }));
|
|
483
|
+
}
|
|
484
|
+
}, 15000);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
ws.on('message', async (data) => {
|
|
488
|
+
const message = JSON.parse(data.toString());
|
|
489
|
+
|
|
490
|
+
if (message.type === 'registered') {
|
|
491
|
+
console.log(chalk.green.bold('\n🎉 File Server is now publicly accessible!\n'));
|
|
492
|
+
console.log(chalk.white('Share this URL:'));
|
|
493
|
+
console.log(chalk.cyan.bold(` ${message.publicUrl}\n`));
|
|
494
|
+
} else if (message.type === 'request') {
|
|
495
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
496
|
+
|
|
497
|
+
// Check if limit was reached
|
|
498
|
+
if (limitReached) {
|
|
499
|
+
console.log(chalk.red(`[${timestamp}]`), chalk.red('BLOCKED'), chalk.white(message.path), chalk.red('- Transfer limit reached'));
|
|
500
|
+
ws.send(JSON.stringify({
|
|
501
|
+
type: 'response',
|
|
502
|
+
requestId: message.requestId,
|
|
503
|
+
statusCode: 429,
|
|
504
|
+
headers: { 'content-type': 'application/json' },
|
|
505
|
+
body: Buffer.from(JSON.stringify({
|
|
506
|
+
error: 'Monthly file transfer limit reached',
|
|
507
|
+
message: 'Upgrade your plan at https://tunnelapi.in/pricing for more transfer quota.'
|
|
508
|
+
})).toString('base64'),
|
|
509
|
+
encoding: 'base64'
|
|
510
|
+
}));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
console.log(chalk.gray(`[${timestamp}]`), chalk.blue(message.method), chalk.white(message.path));
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const localUrl = `http://localhost:${localPort}${message.path}`;
|
|
518
|
+
|
|
519
|
+
const response = await axios({
|
|
520
|
+
method: message.method.toLowerCase(),
|
|
521
|
+
url: localUrl,
|
|
522
|
+
headers: { ...message.headers, host: `localhost:${localPort}` },
|
|
523
|
+
data: message.body,
|
|
524
|
+
validateStatus: () => true,
|
|
525
|
+
responseType: 'arraybuffer',
|
|
526
|
+
maxRedirects: 0,
|
|
527
|
+
timeout: 30000
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const cleanHeaders = { ...response.headers };
|
|
531
|
+
delete cleanHeaders['transfer-encoding'];
|
|
532
|
+
delete cleanHeaders['connection'];
|
|
533
|
+
|
|
534
|
+
const responseData = Buffer.from(response.data);
|
|
535
|
+
const bodyBase64 = responseData.toString('base64');
|
|
536
|
+
const bytesTransferred = responseData.length;
|
|
537
|
+
|
|
538
|
+
// Track file transfer usage
|
|
539
|
+
try {
|
|
540
|
+
await api.trackFileTransfer(userId, bytesTransferred);
|
|
541
|
+
totalBytesTransferred += bytesTransferred;
|
|
542
|
+
} catch (trackError) {
|
|
543
|
+
if (trackError.response?.data?.limitReached) {
|
|
544
|
+
limitReached = true;
|
|
545
|
+
console.log(chalk.red.bold('\n⚠️ Monthly file transfer limit reached!'));
|
|
546
|
+
console.log(chalk.yellow('💡 Upgrade your plan at https://tunnelapi.in/pricing for more transfer quota.\n'));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
ws.send(JSON.stringify({
|
|
551
|
+
type: 'response',
|
|
552
|
+
requestId: message.requestId,
|
|
553
|
+
statusCode: response.status,
|
|
554
|
+
headers: cleanHeaders,
|
|
555
|
+
body: bodyBase64,
|
|
556
|
+
encoding: 'base64'
|
|
557
|
+
}));
|
|
558
|
+
} catch (error) {
|
|
559
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
560
|
+
ws.send(JSON.stringify({
|
|
561
|
+
type: 'response',
|
|
562
|
+
requestId: message.requestId,
|
|
563
|
+
statusCode: 502,
|
|
564
|
+
headers: { 'content-type': 'application/json' },
|
|
565
|
+
body: Buffer.from(JSON.stringify({ error: 'File server error' })).toString('base64'),
|
|
566
|
+
encoding: 'base64'
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
ws.on('error', (error) => {
|
|
573
|
+
console.error(chalk.red(`WebSocket error: ${error.message}`));
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
ws.on('close', (code, reason) => {
|
|
577
|
+
if (heartbeatInterval) {
|
|
578
|
+
clearInterval(heartbeatInterval);
|
|
579
|
+
heartbeatInterval = null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (isShuttingDown) {
|
|
583
|
+
console.log(chalk.yellow('Tunnel connection closed'));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
reconnectAttempts++;
|
|
588
|
+
if (reconnectAttempts <= maxReconnectAttempts) {
|
|
589
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 30000); // Exponential backoff, max 30s
|
|
590
|
+
console.log(chalk.yellow(`Connection lost. Reconnecting in ${delay / 1000}s... (attempt ${reconnectAttempts}/${maxReconnectAttempts})`));
|
|
591
|
+
setTimeout(connect, delay);
|
|
592
|
+
} else {
|
|
593
|
+
console.log(chalk.red('Max reconnection attempts reached. Please restart the serve command.'));
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Handle process shutdown
|
|
598
|
+
process.on('SIGINT', () => {
|
|
599
|
+
isShuttingDown = true;
|
|
600
|
+
if (heartbeatInterval) {
|
|
601
|
+
clearInterval(heartbeatInterval);
|
|
602
|
+
}
|
|
603
|
+
ws.close();
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Start initial connection
|
|
608
|
+
connect();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
module.exports = {
|
|
612
|
+
serve
|
|
613
|
+
};
|
package/commands/tunnel.js
CHANGED
|
@@ -4,12 +4,21 @@ const WebSocket = require('ws');
|
|
|
4
4
|
const Table = require('cli-table3');
|
|
5
5
|
const axios = require('axios');
|
|
6
6
|
const net = require('net');
|
|
7
|
+
const qrcode = require('qrcode-terminal');
|
|
7
8
|
const api = require('../utils/api');
|
|
8
9
|
const config = require('../utils/config');
|
|
9
10
|
|
|
10
11
|
// Track TCP connections for SSH/TCP tunnels
|
|
11
12
|
const tcpConnections = new Map();
|
|
12
13
|
|
|
14
|
+
// Generate and display QR code for URL
|
|
15
|
+
function displayQRCode(url, small = true) {
|
|
16
|
+
console.log(chalk.yellow.bold('\n📱 Scan QR Code to access on mobile:\n'));
|
|
17
|
+
qrcode.generate(url, { small }, (qr) => {
|
|
18
|
+
console.log(qr);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
// Start tunnel
|
|
14
23
|
async function start(port, options) {
|
|
15
24
|
const jsonOutput = options.json || false;
|
|
@@ -157,6 +166,9 @@ async function connectTunnelClient(tunnelId, subdomain, localPort, protocol = 'h
|
|
|
157
166
|
console.log(chalk.gray('SSH with Key (passwordless):'));
|
|
158
167
|
console.log(chalk.cyan(` ssh -i ~/.ssh/your_key.pem -o ProxyCommand="echo 'SUBDOMAIN:${subdomain}' | nc %h %p" user@${message.tcpHost} -p ${message.tcpPort}\n`));
|
|
159
168
|
}
|
|
169
|
+
} else {
|
|
170
|
+
// Display QR code for HTTP/HTTPS tunnels (not for TCP/SSH)
|
|
171
|
+
displayQRCode(message.publicUrl);
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
console.log(chalk.gray('Press Ctrl+C to stop the tunnel\n'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api-response-manager",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"description": "Command-line interface for API Response Manager",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -62,8 +62,10 @@
|
|
|
62
62
|
"conf": "^10.2.0",
|
|
63
63
|
"dotenv": "^16.3.1",
|
|
64
64
|
"inquirer": "^8.2.5",
|
|
65
|
+
"mime-types": "^3.0.2",
|
|
65
66
|
"open": "^8.4.0",
|
|
66
67
|
"ora": "^5.4.1",
|
|
68
|
+
"qrcode-terminal": "^0.12.0",
|
|
67
69
|
"update-notifier": "^6.0.2",
|
|
68
70
|
"ws": "^8.14.2"
|
|
69
71
|
},
|
package/utils/api.js
CHANGED
|
@@ -109,6 +109,17 @@ class APIClient {
|
|
|
109
109
|
return response.data;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// File Transfer
|
|
113
|
+
async checkFileTransferLimit() {
|
|
114
|
+
const response = await this.client.get('/tunnels/file-transfer/check');
|
|
115
|
+
return response.data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async trackFileTransfer(userId, bytes) {
|
|
119
|
+
const response = await this.client.post('/tunnels/file-transfer/track', { userId, bytes });
|
|
120
|
+
return response.data;
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
// Webhooks
|
|
113
124
|
async createWebhook(data) {
|
|
114
125
|
const response = await this.client.post('/webhooks', data);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
class E2EEncryptionClient {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.algorithm = 'aes-256-gcm';
|
|
6
|
+
this.keyLength = 32;
|
|
7
|
+
this.ivLength = 16;
|
|
8
|
+
this.authTagLength = 16;
|
|
9
|
+
this.ecdh = null;
|
|
10
|
+
this.publicKey = null;
|
|
11
|
+
this.privateKey = null;
|
|
12
|
+
this.sharedSecret = null;
|
|
13
|
+
this.serverPublicKey = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Initialize client-side encryption
|
|
17
|
+
initialize() {
|
|
18
|
+
this.ecdh = crypto.createECDH('secp256k1');
|
|
19
|
+
this.ecdh.generateKeys();
|
|
20
|
+
this.publicKey = this.ecdh.getPublicKey('base64');
|
|
21
|
+
this.privateKey = this.ecdh.getPrivateKey('base64');
|
|
22
|
+
return this.publicKey;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Set server public key and compute shared secret
|
|
26
|
+
setServerPublicKey(serverPublicKey) {
|
|
27
|
+
this.serverPublicKey = serverPublicKey;
|
|
28
|
+
const publicKeyBuffer = Buffer.from(serverPublicKey, 'base64');
|
|
29
|
+
const sharedSecret = this.ecdh.computeSecret(publicKeyBuffer);
|
|
30
|
+
this.sharedSecret = crypto.createHash('sha256').update(sharedSecret).digest();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check if E2E is ready
|
|
35
|
+
isReady() {
|
|
36
|
+
return this.sharedSecret !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Get client public key
|
|
40
|
+
getPublicKey() {
|
|
41
|
+
return this.publicKey;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Encrypt data
|
|
45
|
+
encrypt(plaintext) {
|
|
46
|
+
if (!this.sharedSecret) {
|
|
47
|
+
throw new Error('E2E encryption not initialized');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const iv = crypto.randomBytes(this.ivLength);
|
|
51
|
+
const cipher = crypto.createCipheriv(this.algorithm, this.sharedSecret, iv, {
|
|
52
|
+
authTagLength: this.authTagLength
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
|
56
|
+
encrypted += cipher.final('base64');
|
|
57
|
+
const authTag = cipher.getAuthTag();
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
encrypted,
|
|
61
|
+
iv: iv.toString('base64'),
|
|
62
|
+
authTag: authTag.toString('base64')
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Decrypt data
|
|
67
|
+
decrypt(encryptedData) {
|
|
68
|
+
if (!this.sharedSecret) {
|
|
69
|
+
throw new Error('E2E encryption not initialized');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { encrypted, iv, authTag } = encryptedData;
|
|
73
|
+
const ivBuffer = Buffer.from(iv, 'base64');
|
|
74
|
+
const authTagBuffer = Buffer.from(authTag, 'base64');
|
|
75
|
+
|
|
76
|
+
const decipher = crypto.createDecipheriv(this.algorithm, this.sharedSecret, ivBuffer, {
|
|
77
|
+
authTagLength: this.authTagLength
|
|
78
|
+
});
|
|
79
|
+
decipher.setAuthTag(authTagBuffer);
|
|
80
|
+
|
|
81
|
+
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
|
|
82
|
+
decrypted += decipher.final('utf8');
|
|
83
|
+
|
|
84
|
+
return decrypted;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Encrypt binary data
|
|
88
|
+
encryptBinary(buffer) {
|
|
89
|
+
if (!this.sharedSecret) {
|
|
90
|
+
throw new Error('E2E encryption not initialized');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const iv = crypto.randomBytes(this.ivLength);
|
|
94
|
+
const cipher = crypto.createCipheriv(this.algorithm, this.sharedSecret, iv, {
|
|
95
|
+
authTagLength: this.authTagLength
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
|
99
|
+
const authTag = cipher.getAuthTag();
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
encrypted: encrypted.toString('base64'),
|
|
103
|
+
iv: iv.toString('base64'),
|
|
104
|
+
authTag: authTag.toString('base64')
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Decrypt binary data
|
|
109
|
+
decryptBinary(encryptedData) {
|
|
110
|
+
if (!this.sharedSecret) {
|
|
111
|
+
throw new Error('E2E encryption not initialized');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { encrypted, iv, authTag } = encryptedData;
|
|
115
|
+
const encryptedBuffer = Buffer.from(encrypted, 'base64');
|
|
116
|
+
const ivBuffer = Buffer.from(iv, 'base64');
|
|
117
|
+
const authTagBuffer = Buffer.from(authTag, 'base64');
|
|
118
|
+
|
|
119
|
+
const decipher = crypto.createDecipheriv(this.algorithm, this.sharedSecret, ivBuffer, {
|
|
120
|
+
authTagLength: this.authTagLength
|
|
121
|
+
});
|
|
122
|
+
decipher.setAuthTag(authTagBuffer);
|
|
123
|
+
|
|
124
|
+
return Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Encrypt a message object
|
|
128
|
+
encryptMessage(message) {
|
|
129
|
+
if (!this.isReady()) {
|
|
130
|
+
return message;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const plaintext = JSON.stringify(message);
|
|
134
|
+
const encrypted = this.encrypt(plaintext);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
type: 'e2e-encrypted',
|
|
138
|
+
payload: encrypted
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Decrypt a message object
|
|
143
|
+
decryptMessage(encryptedMessage) {
|
|
144
|
+
if (encryptedMessage.type !== 'e2e-encrypted') {
|
|
145
|
+
return encryptedMessage;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const decrypted = this.decrypt(encryptedMessage.payload);
|
|
149
|
+
return JSON.parse(decrypted);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Reset encryption state
|
|
153
|
+
reset() {
|
|
154
|
+
this.ecdh = null;
|
|
155
|
+
this.publicKey = null;
|
|
156
|
+
this.privateKey = null;
|
|
157
|
+
this.sharedSecret = null;
|
|
158
|
+
this.serverPublicKey = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = E2EEncryptionClient;
|