@mjasano/devtunnel 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/.claude/settings.local.json +18 -0
- package/Dockerfile +41 -0
- package/bin/cli.js +250 -0
- package/docker-compose.yml +14 -0
- package/package.json +27 -0
- package/public/index.html +814 -0
- package/server.js +458 -0
package/server.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const WebSocket = require('ws');
|
|
4
|
+
const pty = require('@homebridge/node-pty-prebuilt-multiarch');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const app = express();
|
|
11
|
+
const server = http.createServer(app);
|
|
12
|
+
const wss = new WebSocket.Server({ server });
|
|
13
|
+
|
|
14
|
+
app.use(express.json());
|
|
15
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
16
|
+
|
|
17
|
+
// Session configuration
|
|
18
|
+
const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours
|
|
19
|
+
const OUTPUT_BUFFER_SIZE = 50000; // Keep last 50KB of output
|
|
20
|
+
|
|
21
|
+
// Store persistent terminal sessions
|
|
22
|
+
const sessions = new Map();
|
|
23
|
+
|
|
24
|
+
// Store active tunnels
|
|
25
|
+
const tunnels = new Map();
|
|
26
|
+
|
|
27
|
+
// Generate session ID
|
|
28
|
+
function generateSessionId() {
|
|
29
|
+
return crypto.randomBytes(16).toString('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Create or get existing session
|
|
33
|
+
function getOrCreateSession(sessionId = null) {
|
|
34
|
+
// If sessionId provided and exists, return it
|
|
35
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
36
|
+
const session = sessions.get(sessionId);
|
|
37
|
+
session.lastAccess = Date.now();
|
|
38
|
+
return session;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Create new session
|
|
42
|
+
const id = sessionId || generateSessionId();
|
|
43
|
+
const shell = os.platform() === 'win32' ? 'powershell.exe' : '/bin/zsh';
|
|
44
|
+
|
|
45
|
+
const ptyProcess = pty.spawn(shell, [], {
|
|
46
|
+
name: 'xterm-256color',
|
|
47
|
+
cols: 80,
|
|
48
|
+
rows: 24,
|
|
49
|
+
cwd: process.env.WORKSPACE || os.homedir(),
|
|
50
|
+
env: { ...process.env, TERM: 'xterm-256color' }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const session = {
|
|
54
|
+
id,
|
|
55
|
+
pty: ptyProcess,
|
|
56
|
+
outputBuffer: '',
|
|
57
|
+
clients: new Set(),
|
|
58
|
+
lastAccess: Date.now(),
|
|
59
|
+
createdAt: Date.now(),
|
|
60
|
+
alive: true
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Buffer output for reconnection
|
|
64
|
+
ptyProcess.onData((data) => {
|
|
65
|
+
session.outputBuffer += data;
|
|
66
|
+
// Trim buffer if too large
|
|
67
|
+
if (session.outputBuffer.length > OUTPUT_BUFFER_SIZE) {
|
|
68
|
+
session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_SIZE);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Send to all connected clients
|
|
72
|
+
session.clients.forEach(ws => {
|
|
73
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
74
|
+
ws.send(JSON.stringify({ type: 'output', data }));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
80
|
+
console.log(`Session ${id} terminal exited with code ${exitCode}`);
|
|
81
|
+
session.alive = false;
|
|
82
|
+
|
|
83
|
+
session.clients.forEach(ws => {
|
|
84
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
85
|
+
ws.send(JSON.stringify({ type: 'exit', exitCode, signal }));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Remove session after a delay
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
sessions.delete(id);
|
|
92
|
+
broadcastSessionList();
|
|
93
|
+
}, 5000);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
sessions.set(id, session);
|
|
97
|
+
console.log(`Session created: ${id}`);
|
|
98
|
+
return session;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Clean up inactive sessions
|
|
102
|
+
setInterval(() => {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
sessions.forEach((session, id) => {
|
|
105
|
+
if (session.clients.size === 0 && now - session.lastAccess > SESSION_TIMEOUT) {
|
|
106
|
+
console.log(`Session ${id} timed out, cleaning up`);
|
|
107
|
+
if (session.pty) {
|
|
108
|
+
session.pty.kill();
|
|
109
|
+
}
|
|
110
|
+
sessions.delete(id);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}, 60000); // Check every minute
|
|
114
|
+
|
|
115
|
+
// Broadcast session list to all clients
|
|
116
|
+
function broadcastSessionList() {
|
|
117
|
+
const sessionList = Array.from(sessions.values()).map(s => ({
|
|
118
|
+
id: s.id,
|
|
119
|
+
createdAt: s.createdAt,
|
|
120
|
+
lastAccess: s.lastAccess,
|
|
121
|
+
alive: s.alive,
|
|
122
|
+
clients: s.clients.size
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
wss.clients.forEach(client => {
|
|
126
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
127
|
+
client.send(JSON.stringify({ type: 'sessions', data: sessionList }));
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Broadcast tunnel updates to all connected clients
|
|
133
|
+
function broadcastTunnelUpdate() {
|
|
134
|
+
const tunnelList = Array.from(tunnels.values()).map(t => ({
|
|
135
|
+
id: t.id,
|
|
136
|
+
port: t.port,
|
|
137
|
+
url: t.url,
|
|
138
|
+
status: t.status
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
wss.clients.forEach(client => {
|
|
142
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
143
|
+
client.send(JSON.stringify({ type: 'tunnels', data: tunnelList }));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Create a new tunnel
|
|
149
|
+
function createTunnel(port) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const id = `tunnel-${port}-${Date.now()}`;
|
|
152
|
+
|
|
153
|
+
for (const tunnel of tunnels.values()) {
|
|
154
|
+
if (tunnel.port === port && tunnel.status === 'active') {
|
|
155
|
+
reject(new Error(`Tunnel already exists for port ${port}`));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
161
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const tunnel = {
|
|
165
|
+
id,
|
|
166
|
+
port,
|
|
167
|
+
url: null,
|
|
168
|
+
status: 'connecting',
|
|
169
|
+
process: tunnelProcess
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
tunnels.set(id, tunnel);
|
|
173
|
+
broadcastTunnelUpdate();
|
|
174
|
+
|
|
175
|
+
let urlFound = false;
|
|
176
|
+
|
|
177
|
+
const handleOutput = (data) => {
|
|
178
|
+
const output = data.toString();
|
|
179
|
+
console.log(`[Tunnel ${port}] ${output}`);
|
|
180
|
+
|
|
181
|
+
const urlMatch = output.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
|
|
182
|
+
if (urlMatch && !urlFound) {
|
|
183
|
+
urlFound = true;
|
|
184
|
+
tunnel.url = urlMatch[0];
|
|
185
|
+
tunnel.status = 'active';
|
|
186
|
+
broadcastTunnelUpdate();
|
|
187
|
+
resolve(tunnel);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
tunnelProcess.stdout.on('data', handleOutput);
|
|
192
|
+
tunnelProcess.stderr.on('data', handleOutput);
|
|
193
|
+
|
|
194
|
+
tunnelProcess.on('error', (err) => {
|
|
195
|
+
console.error(`Tunnel process error: ${err.message}`);
|
|
196
|
+
tunnel.status = 'error';
|
|
197
|
+
broadcastTunnelUpdate();
|
|
198
|
+
if (!urlFound) {
|
|
199
|
+
reject(err);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
tunnelProcess.on('exit', (code) => {
|
|
204
|
+
console.log(`Tunnel for port ${port} exited with code ${code}`);
|
|
205
|
+
tunnel.status = 'stopped';
|
|
206
|
+
broadcastTunnelUpdate();
|
|
207
|
+
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
tunnels.delete(id);
|
|
210
|
+
broadcastTunnelUpdate();
|
|
211
|
+
}, 5000);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
if (!urlFound) {
|
|
216
|
+
tunnel.status = 'error';
|
|
217
|
+
broadcastTunnelUpdate();
|
|
218
|
+
reject(new Error('Timeout waiting for tunnel URL'));
|
|
219
|
+
}
|
|
220
|
+
}, 30000);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stopTunnel(id) {
|
|
225
|
+
const tunnel = tunnels.get(id);
|
|
226
|
+
if (tunnel && tunnel.process) {
|
|
227
|
+
tunnel.process.kill();
|
|
228
|
+
tunnel.status = 'stopping';
|
|
229
|
+
broadcastTunnelUpdate();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// WebSocket handling
|
|
236
|
+
wss.on('connection', (ws) => {
|
|
237
|
+
console.log('Client connected');
|
|
238
|
+
|
|
239
|
+
let currentSession = null;
|
|
240
|
+
|
|
241
|
+
// Send current tunnel list
|
|
242
|
+
const tunnelList = Array.from(tunnels.values()).map(t => ({
|
|
243
|
+
id: t.id,
|
|
244
|
+
port: t.port,
|
|
245
|
+
url: t.url,
|
|
246
|
+
status: t.status
|
|
247
|
+
}));
|
|
248
|
+
ws.send(JSON.stringify({ type: 'tunnels', data: tunnelList }));
|
|
249
|
+
|
|
250
|
+
// Send session list
|
|
251
|
+
const sessionList = Array.from(sessions.values()).map(s => ({
|
|
252
|
+
id: s.id,
|
|
253
|
+
createdAt: s.createdAt,
|
|
254
|
+
lastAccess: s.lastAccess,
|
|
255
|
+
alive: s.alive,
|
|
256
|
+
clients: s.clients.size
|
|
257
|
+
}));
|
|
258
|
+
ws.send(JSON.stringify({ type: 'sessions', data: sessionList }));
|
|
259
|
+
|
|
260
|
+
ws.on('message', (message) => {
|
|
261
|
+
try {
|
|
262
|
+
const msg = JSON.parse(message.toString());
|
|
263
|
+
|
|
264
|
+
switch (msg.type) {
|
|
265
|
+
case 'attach':
|
|
266
|
+
// Attach to existing or new session
|
|
267
|
+
try {
|
|
268
|
+
currentSession = getOrCreateSession(msg.sessionId);
|
|
269
|
+
currentSession.clients.add(ws);
|
|
270
|
+
|
|
271
|
+
// Send session info
|
|
272
|
+
ws.send(JSON.stringify({
|
|
273
|
+
type: 'attached',
|
|
274
|
+
sessionId: currentSession.id,
|
|
275
|
+
alive: currentSession.alive
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
// Send buffered output
|
|
279
|
+
if (currentSession.outputBuffer) {
|
|
280
|
+
ws.send(JSON.stringify({
|
|
281
|
+
type: 'output',
|
|
282
|
+
data: currentSession.outputBuffer
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
broadcastSessionList();
|
|
287
|
+
console.log(`Client attached to session ${currentSession.id}`);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
ws.send(JSON.stringify({
|
|
290
|
+
type: 'error',
|
|
291
|
+
message: err.message
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
|
|
296
|
+
case 'input':
|
|
297
|
+
if (currentSession && currentSession.pty && currentSession.alive) {
|
|
298
|
+
currentSession.pty.write(msg.data);
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
|
|
302
|
+
case 'resize':
|
|
303
|
+
if (currentSession && currentSession.pty && msg.cols && msg.rows) {
|
|
304
|
+
currentSession.pty.resize(msg.cols, msg.rows);
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
|
|
308
|
+
case 'create-tunnel':
|
|
309
|
+
if (msg.port) {
|
|
310
|
+
createTunnel(msg.port)
|
|
311
|
+
.then(tunnel => {
|
|
312
|
+
ws.send(JSON.stringify({
|
|
313
|
+
type: 'tunnel-created',
|
|
314
|
+
data: { id: tunnel.id, port: tunnel.port, url: tunnel.url }
|
|
315
|
+
}));
|
|
316
|
+
})
|
|
317
|
+
.catch(err => {
|
|
318
|
+
ws.send(JSON.stringify({
|
|
319
|
+
type: 'tunnel-error',
|
|
320
|
+
error: err.message
|
|
321
|
+
}));
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
|
|
326
|
+
case 'stop-tunnel':
|
|
327
|
+
if (msg.id) {
|
|
328
|
+
stopTunnel(msg.id);
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
|
|
332
|
+
case 'kill-session':
|
|
333
|
+
if (msg.sessionId) {
|
|
334
|
+
const session = sessions.get(msg.sessionId);
|
|
335
|
+
if (session && session.pty) {
|
|
336
|
+
session.pty.kill();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
|
|
341
|
+
default:
|
|
342
|
+
console.log('Unknown message type:', msg.type);
|
|
343
|
+
}
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.error('Error processing message:', err);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
ws.on('close', () => {
|
|
350
|
+
console.log('Client disconnected');
|
|
351
|
+
if (currentSession) {
|
|
352
|
+
currentSession.clients.delete(ws);
|
|
353
|
+
currentSession.lastAccess = Date.now();
|
|
354
|
+
broadcastSessionList();
|
|
355
|
+
// Note: We don't kill the session here!
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
ws.on('error', (err) => {
|
|
360
|
+
console.error('WebSocket error:', err);
|
|
361
|
+
if (currentSession) {
|
|
362
|
+
currentSession.clients.delete(ws);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// REST API
|
|
368
|
+
app.get('/api/sessions', (req, res) => {
|
|
369
|
+
const sessionList = Array.from(sessions.values()).map(s => ({
|
|
370
|
+
id: s.id,
|
|
371
|
+
createdAt: s.createdAt,
|
|
372
|
+
lastAccess: s.lastAccess,
|
|
373
|
+
alive: s.alive,
|
|
374
|
+
clients: s.clients.size
|
|
375
|
+
}));
|
|
376
|
+
res.json(sessionList);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
app.delete('/api/sessions/:id', (req, res) => {
|
|
380
|
+
const session = sessions.get(req.params.id);
|
|
381
|
+
if (session) {
|
|
382
|
+
if (session.pty) {
|
|
383
|
+
session.pty.kill();
|
|
384
|
+
}
|
|
385
|
+
sessions.delete(req.params.id);
|
|
386
|
+
broadcastSessionList();
|
|
387
|
+
res.json({ success: true });
|
|
388
|
+
} else {
|
|
389
|
+
res.status(404).json({ error: 'Session not found' });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
app.get('/api/tunnels', (req, res) => {
|
|
394
|
+
const tunnelList = Array.from(tunnels.values()).map(t => ({
|
|
395
|
+
id: t.id,
|
|
396
|
+
port: t.port,
|
|
397
|
+
url: t.url,
|
|
398
|
+
status: t.status
|
|
399
|
+
}));
|
|
400
|
+
res.json(tunnelList);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
app.post('/api/tunnels', async (req, res) => {
|
|
404
|
+
const { port } = req.body;
|
|
405
|
+
if (!port || typeof port !== 'number') {
|
|
406
|
+
return res.status(400).json({ error: 'Valid port number required' });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const tunnel = await createTunnel(port);
|
|
411
|
+
res.json({ id: tunnel.id, port: tunnel.port, url: tunnel.url });
|
|
412
|
+
} catch (err) {
|
|
413
|
+
res.status(500).json({ error: err.message });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
app.delete('/api/tunnels/:id', (req, res) => {
|
|
418
|
+
if (stopTunnel(req.params.id)) {
|
|
419
|
+
res.json({ success: true });
|
|
420
|
+
} else {
|
|
421
|
+
res.status(404).json({ error: 'Tunnel not found' });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
app.get('/health', (req, res) => {
|
|
426
|
+
res.json({
|
|
427
|
+
status: 'ok',
|
|
428
|
+
sessions: sessions.size,
|
|
429
|
+
tunnels: tunnels.size
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const PORT = process.env.PORT || 3000;
|
|
434
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
435
|
+
console.log(`DevTunnel server running on http://localhost:${PORT}`);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Graceful shutdown
|
|
439
|
+
process.on('SIGINT', () => {
|
|
440
|
+
console.log('\nShutting down...');
|
|
441
|
+
|
|
442
|
+
tunnels.forEach((tunnel) => {
|
|
443
|
+
if (tunnel.process) {
|
|
444
|
+
tunnel.process.kill();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
sessions.forEach((session) => {
|
|
449
|
+
if (session.pty) {
|
|
450
|
+
session.pty.kill();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
server.close(() => {
|
|
455
|
+
console.log('Server closed');
|
|
456
|
+
process.exit(0);
|
|
457
|
+
});
|
|
458
|
+
});
|