@mjasano/devtunnel 1.1.0 → 1.4.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/server.js CHANGED
@@ -10,9 +10,172 @@ const crypto = require('crypto');
10
10
 
11
11
  const app = express();
12
12
  const server = http.createServer(app);
13
- const wss = new WebSocket.Server({ server });
14
13
 
15
- app.use(express.json());
14
+ // Authentication configuration
15
+ const PASSCODE = process.env.PASSCODE || null;
16
+ const AUTH_TOKEN_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
17
+ const authTokens = new Map(); // token -> { createdAt }
18
+
19
+ // Generate random token
20
+ function generateAuthToken() {
21
+ return crypto.randomBytes(32).toString('hex');
22
+ }
23
+
24
+ // Validate auth token
25
+ function validateAuthToken(token) {
26
+ if (!token) return false;
27
+ const session = authTokens.get(token);
28
+ if (!session) return false;
29
+ if (Date.now() - session.createdAt > AUTH_TOKEN_EXPIRY) {
30
+ authTokens.delete(token);
31
+ return false;
32
+ }
33
+ return true;
34
+ }
35
+
36
+ // Auth middleware
37
+ function authMiddleware(req, res, next) {
38
+ // If no passcode configured, allow all
39
+ if (!PASSCODE) return next();
40
+
41
+ // Check for auth token in cookie or header
42
+ const token = req.cookies?.authToken || req.headers['x-auth-token'];
43
+
44
+ if (validateAuthToken(token)) {
45
+ return next();
46
+ }
47
+
48
+ // Allow access to login endpoint and static login page
49
+ if (req.path === '/api/auth/login' || req.path === '/api/auth/status') {
50
+ return next();
51
+ }
52
+
53
+ res.status(401).json({ error: 'Authentication required' });
54
+ }
55
+
56
+ // Cookie parser middleware
57
+ app.use((req, _res, next) => {
58
+ const cookieHeader = req.headers.cookie;
59
+ req.cookies = {};
60
+ if (cookieHeader) {
61
+ cookieHeader.split(';').forEach(cookie => {
62
+ const [name, ...rest] = cookie.trim().split('=');
63
+ req.cookies[name] = rest.join('=');
64
+ });
65
+ }
66
+ next();
67
+ });
68
+
69
+ // WebSocket security configuration
70
+ const WS_MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB max message size
71
+ const WS_RATE_LIMIT_WINDOW = 1000; // 1 second
72
+ const WS_RATE_LIMIT_MAX = 100; // max 100 messages per second
73
+
74
+ const wss = new WebSocket.Server({
75
+ server,
76
+ maxPayload: WS_MAX_MESSAGE_SIZE
77
+ });
78
+
79
+ // Rate limiting for WebSocket connections
80
+ const wsRateLimits = new Map();
81
+
82
+ function checkRateLimit(ws) {
83
+ const now = Date.now();
84
+ let limit = wsRateLimits.get(ws);
85
+
86
+ if (!limit) {
87
+ limit = { count: 0, resetAt: now + WS_RATE_LIMIT_WINDOW };
88
+ wsRateLimits.set(ws, limit);
89
+ }
90
+
91
+ if (now > limit.resetAt) {
92
+ limit.count = 0;
93
+ limit.resetAt = now + WS_RATE_LIMIT_WINDOW;
94
+ }
95
+
96
+ limit.count++;
97
+
98
+ if (limit.count > WS_RATE_LIMIT_MAX) {
99
+ return false;
100
+ }
101
+
102
+ return true;
103
+ }
104
+
105
+ app.use(express.json({ limit: '5mb' }));
106
+
107
+ // Serve login page without auth
108
+ app.get('/login', (_req, res) => {
109
+ res.sendFile(path.join(__dirname, 'public', 'login.html'));
110
+ });
111
+
112
+ // Auth status endpoint (no auth required)
113
+ app.get('/api/auth/status', (req, res) => {
114
+ const requiresAuth = !!PASSCODE;
115
+ const token = req.cookies?.authToken || req.headers['x-auth-token'];
116
+ const authenticated = !requiresAuth || validateAuthToken(token);
117
+ res.json({ requiresAuth, authenticated });
118
+ });
119
+
120
+ // Login endpoint (no auth required)
121
+ app.post('/api/auth/login', (req, res) => {
122
+ if (!PASSCODE) {
123
+ return res.json({ success: true, message: 'No passcode required' });
124
+ }
125
+
126
+ const { passcode } = req.body;
127
+
128
+ if (passcode === PASSCODE) {
129
+ const token = generateAuthToken();
130
+ authTokens.set(token, { createdAt: Date.now() });
131
+
132
+ res.cookie('authToken', token, {
133
+ httpOnly: true,
134
+ secure: process.env.NODE_ENV === 'production',
135
+ maxAge: AUTH_TOKEN_EXPIRY,
136
+ sameSite: 'strict'
137
+ });
138
+
139
+ res.json({ success: true, token });
140
+ } else {
141
+ res.status(401).json({ success: false, error: 'Invalid passcode' });
142
+ }
143
+ });
144
+
145
+ // Logout endpoint
146
+ app.post('/api/auth/logout', (req, res) => {
147
+ const token = req.cookies?.authToken || req.headers['x-auth-token'];
148
+ if (token) {
149
+ authTokens.delete(token);
150
+ }
151
+ res.clearCookie('authToken');
152
+ res.json({ success: true });
153
+ });
154
+
155
+ // Apply auth middleware to all API routes
156
+ app.use('/api', authMiddleware);
157
+
158
+ // Serve static files (with auth check for main app)
159
+ app.use((req, res, next) => {
160
+ // Allow login page without auth
161
+ if (req.path === '/login.html' || req.path === '/login') {
162
+ return next();
163
+ }
164
+
165
+ // If auth required and not authenticated, redirect to login
166
+ if (PASSCODE) {
167
+ const token = req.cookies?.authToken || req.headers['x-auth-token'];
168
+ if (!validateAuthToken(token)) {
169
+ // For HTML requests, redirect to login
170
+ if (req.accepts('html')) {
171
+ return res.redirect('/login');
172
+ }
173
+ return res.status(401).json({ error: 'Authentication required' });
174
+ }
175
+ }
176
+ next();
177
+ });
178
+
16
179
  app.use(express.static(path.join(__dirname, 'public')));
17
180
 
18
181
  // Session configuration
@@ -30,6 +193,98 @@ function generateSessionId() {
30
193
  return crypto.randomBytes(16).toString('hex');
31
194
  }
32
195
 
196
+ // Get list of tmux sessions
197
+ async function getTmuxSessions() {
198
+ return new Promise((resolve) => {
199
+ const tmux = spawn('tmux', ['list-sessions', '-F', '#{session_name}:#{session_windows}:#{session_attached}']);
200
+ let output = '';
201
+
202
+ tmux.stdout.on('data', (data) => {
203
+ output += data.toString();
204
+ });
205
+
206
+ tmux.on('close', (code) => {
207
+ if (code !== 0 || !output.trim()) {
208
+ resolve([]);
209
+ return;
210
+ }
211
+
212
+ const sessions = output.trim().split('\n').map(line => {
213
+ const [name, windows, attached] = line.split(':');
214
+ return {
215
+ name,
216
+ windows: parseInt(windows) || 1,
217
+ attached: attached === '1'
218
+ };
219
+ });
220
+
221
+ resolve(sessions);
222
+ });
223
+
224
+ tmux.on('error', () => {
225
+ resolve([]);
226
+ });
227
+ });
228
+ }
229
+
230
+ // Create session attached to tmux
231
+ function createTmuxSession(tmuxSessionName) {
232
+ const id = `tmux-${tmuxSessionName}-${Date.now()}`;
233
+
234
+ const ptyProcess = pty.spawn('tmux', ['attach-session', '-t', tmuxSessionName], {
235
+ name: 'xterm-256color',
236
+ cols: 80,
237
+ rows: 24,
238
+ cwd: process.env.WORKSPACE || os.homedir(),
239
+ env: { ...process.env, TERM: 'xterm-256color' }
240
+ });
241
+
242
+ const session = {
243
+ id,
244
+ pty: ptyProcess,
245
+ outputBuffer: '',
246
+ clients: new Set(),
247
+ lastAccess: Date.now(),
248
+ createdAt: Date.now(),
249
+ alive: true,
250
+ type: 'tmux',
251
+ tmuxSession: tmuxSessionName
252
+ };
253
+
254
+ ptyProcess.onData((data) => {
255
+ session.outputBuffer += data;
256
+ if (session.outputBuffer.length > OUTPUT_BUFFER_SIZE) {
257
+ session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_SIZE);
258
+ }
259
+
260
+ session.clients.forEach(ws => {
261
+ if (ws.readyState === WebSocket.OPEN) {
262
+ ws.send(JSON.stringify({ type: 'output', data }));
263
+ }
264
+ });
265
+ });
266
+
267
+ ptyProcess.onExit(({ exitCode, signal }) => {
268
+ console.log(`Tmux session ${tmuxSessionName} detached with code ${exitCode}`);
269
+ session.alive = false;
270
+
271
+ session.clients.forEach(ws => {
272
+ if (ws.readyState === WebSocket.OPEN) {
273
+ ws.send(JSON.stringify({ type: 'exit', exitCode, signal }));
274
+ }
275
+ });
276
+
277
+ setTimeout(() => {
278
+ sessions.delete(id);
279
+ broadcastSessionList();
280
+ }, 5000);
281
+ });
282
+
283
+ sessions.set(id, session);
284
+ console.log(`Tmux session attached: ${tmuxSessionName}`);
285
+ return session;
286
+ }
287
+
33
288
  // Create or get existing session
34
289
  function getOrCreateSession(sessionId = null) {
35
290
  // If sessionId provided and exists, return it
@@ -120,7 +375,9 @@ function broadcastSessionList() {
120
375
  createdAt: s.createdAt,
121
376
  lastAccess: s.lastAccess,
122
377
  alive: s.alive,
123
- clients: s.clients.size
378
+ clients: s.clients.size,
379
+ type: s.type || 'shell',
380
+ tmuxSession: s.tmuxSession
124
381
  }));
125
382
 
126
383
  wss.clients.forEach(client => {
@@ -234,7 +491,26 @@ function stopTunnel(id) {
234
491
  }
235
492
 
236
493
  // WebSocket handling
237
- wss.on('connection', (ws) => {
494
+ wss.on('connection', async (ws, req) => {
495
+ // Authenticate WebSocket connection
496
+ if (PASSCODE) {
497
+ // Parse cookies from upgrade request
498
+ const cookies = {};
499
+ const cookieHeader = req.headers.cookie;
500
+ if (cookieHeader) {
501
+ cookieHeader.split(';').forEach(cookie => {
502
+ const [name, ...rest] = cookie.trim().split('=');
503
+ cookies[name] = rest.join('=');
504
+ });
505
+ }
506
+
507
+ const token = cookies.authToken;
508
+ if (!validateAuthToken(token)) {
509
+ ws.close(4001, 'Authentication required');
510
+ return;
511
+ }
512
+ }
513
+
238
514
  console.log('Client connected');
239
515
 
240
516
  let currentSession = null;
@@ -254,16 +530,44 @@ wss.on('connection', (ws) => {
254
530
  createdAt: s.createdAt,
255
531
  lastAccess: s.lastAccess,
256
532
  alive: s.alive,
257
- clients: s.clients.size
533
+ clients: s.clients.size,
534
+ type: s.type || 'shell',
535
+ tmuxSession: s.tmuxSession
258
536
  }));
259
537
  ws.send(JSON.stringify({ type: 'sessions', data: sessionList }));
260
538
 
539
+ // Send tmux sessions list
540
+ const tmuxSessions = await getTmuxSessions();
541
+ ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
542
+
261
543
  ws.on('message', (message) => {
544
+ // Rate limiting check
545
+ if (!checkRateLimit(ws)) {
546
+ ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' }));
547
+ return;
548
+ }
549
+
262
550
  try {
263
551
  const msg = JSON.parse(message.toString());
264
552
 
265
553
  switch (msg.type) {
554
+ case 'detach':
555
+ // Detach from current session
556
+ if (currentSession) {
557
+ currentSession.clients.delete(ws);
558
+ currentSession.lastAccess = Date.now();
559
+ broadcastSessionList();
560
+ console.log(`Client detached from session ${currentSession.id}`);
561
+ currentSession = null;
562
+ }
563
+ break;
564
+
266
565
  case 'attach':
566
+ // Detach from current session first if any
567
+ if (currentSession) {
568
+ currentSession.clients.delete(ws);
569
+ currentSession.lastAccess = Date.now();
570
+ }
267
571
  // Attach to existing or new session
268
572
  try {
269
573
  currentSession = getOrCreateSession(msg.sessionId);
@@ -339,6 +643,43 @@ wss.on('connection', (ws) => {
339
643
  }
340
644
  break;
341
645
 
646
+ case 'attach-tmux':
647
+ if (msg.tmuxSession) {
648
+ try {
649
+ // Detach from current session if any
650
+ if (currentSession) {
651
+ currentSession.clients.delete(ws);
652
+ currentSession.lastAccess = Date.now();
653
+ broadcastSessionList();
654
+ }
655
+
656
+ currentSession = createTmuxSession(msg.tmuxSession);
657
+ currentSession.clients.add(ws);
658
+
659
+ ws.send(JSON.stringify({
660
+ type: 'attached',
661
+ sessionId: currentSession.id,
662
+ alive: currentSession.alive,
663
+ tmuxSession: msg.tmuxSession
664
+ }));
665
+
666
+ broadcastSessionList();
667
+ console.log(`Client attached to tmux session: ${msg.tmuxSession}`);
668
+ } catch (err) {
669
+ ws.send(JSON.stringify({
670
+ type: 'error',
671
+ message: err.message
672
+ }));
673
+ }
674
+ }
675
+ break;
676
+
677
+ case 'refresh-tmux':
678
+ getTmuxSessions().then(tmuxSessions => {
679
+ ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
680
+ });
681
+ break;
682
+
342
683
  default:
343
684
  console.log('Unknown message type:', msg.type);
344
685
  }
@@ -349,6 +690,7 @@ wss.on('connection', (ws) => {
349
690
 
350
691
  ws.on('close', () => {
351
692
  console.log('Client disconnected');
693
+ wsRateLimits.delete(ws);
352
694
  if (currentSession) {
353
695
  currentSession.clients.delete(ws);
354
696
  currentSession.lastAccess = Date.now();
@@ -365,6 +707,119 @@ wss.on('connection', (ws) => {
365
707
  });
366
708
  });
367
709
 
710
+ // System Monitoring API
711
+ async function getMemoryInfo() {
712
+ const totalMem = os.totalmem();
713
+
714
+ // macOS: use vm_stat for accurate memory info
715
+ if (os.platform() === 'darwin') {
716
+ return new Promise((resolve) => {
717
+ const vmstat = spawn('vm_stat');
718
+ let output = '';
719
+
720
+ vmstat.stdout.on('data', (data) => {
721
+ output += data.toString();
722
+ });
723
+
724
+ vmstat.on('close', () => {
725
+ try {
726
+ const pageSize = 16384; // macOS default page size (16KB on Apple Silicon)
727
+ const lines = output.split('\n');
728
+
729
+ let _free = 0, active = 0, _inactive = 0, wired = 0, compressed = 0, _purgeable = 0;
730
+
731
+ lines.forEach(line => {
732
+ const match = line.match(/^(.+):\s+(\d+)/);
733
+ if (match) {
734
+ const value = parseInt(match[2]) * pageSize;
735
+ const key = match[1].toLowerCase();
736
+ if (key.includes('free')) _free = value;
737
+ else if (key.includes('active')) active = value;
738
+ else if (key.includes('inactive')) _inactive = value;
739
+ else if (key.includes('wired')) wired = value;
740
+ else if (key.includes('compressed')) compressed = value;
741
+ else if (key.includes('purgeable')) _purgeable = value;
742
+ }
743
+ });
744
+
745
+ // App Memory = Active + Wired + Compressed (what Activity Monitor shows)
746
+ const appMemory = active + wired + compressed;
747
+ const usage = Math.round((appMemory / totalMem) * 100);
748
+
749
+ resolve({
750
+ total: totalMem,
751
+ used: appMemory,
752
+ free: totalMem - appMemory,
753
+ usage
754
+ });
755
+ } catch {
756
+ // Fallback to os.freemem()
757
+ const freeMem = os.freemem();
758
+ resolve({
759
+ total: totalMem,
760
+ used: totalMem - freeMem,
761
+ free: freeMem,
762
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
763
+ });
764
+ }
765
+ });
766
+
767
+ vmstat.on('error', () => {
768
+ const freeMem = os.freemem();
769
+ resolve({
770
+ total: totalMem,
771
+ used: totalMem - freeMem,
772
+ free: freeMem,
773
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
774
+ });
775
+ });
776
+ });
777
+ }
778
+
779
+ // Other platforms: use os.freemem()
780
+ const freeMem = os.freemem();
781
+ return {
782
+ total: totalMem,
783
+ used: totalMem - freeMem,
784
+ free: freeMem,
785
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
786
+ };
787
+ }
788
+
789
+ async function getSystemInfo() {
790
+ const cpus = os.cpus();
791
+ const memory = await getMemoryInfo();
792
+
793
+ // Calculate CPU usage
794
+ let totalIdle = 0;
795
+ let totalTick = 0;
796
+ cpus.forEach(cpu => {
797
+ for (const type in cpu.times) {
798
+ totalTick += cpu.times[type];
799
+ }
800
+ totalIdle += cpu.times.idle;
801
+ });
802
+ const cpuUsage = Math.round((1 - totalIdle / totalTick) * 100);
803
+
804
+ return {
805
+ hostname: os.hostname(),
806
+ platform: os.platform(),
807
+ arch: os.arch(),
808
+ uptime: os.uptime(),
809
+ cpu: {
810
+ model: cpus[0]?.model || 'Unknown',
811
+ cores: cpus.length,
812
+ usage: cpuUsage
813
+ },
814
+ memory,
815
+ loadavg: os.loadavg()
816
+ };
817
+ }
818
+
819
+ app.get('/api/system', async (req, res) => {
820
+ res.json(await getSystemInfo());
821
+ });
822
+
368
823
  // File System API
369
824
  const WORKSPACE_ROOT = process.env.WORKSPACE || os.homedir();
370
825
 
@@ -503,6 +958,86 @@ app.post('/api/files/write', async (req, res) => {
503
958
  }
504
959
  });
505
960
 
961
+ // Delete file or directory
962
+ app.post('/api/files/delete', async (req, res) => {
963
+ try {
964
+ const { path: filePath } = req.body;
965
+
966
+ if (!filePath) {
967
+ return res.status(400).json({ error: 'Path required' });
968
+ }
969
+
970
+ const fullPath = path.join(WORKSPACE_ROOT, filePath);
971
+ const normalizedPath = path.normalize(fullPath);
972
+
973
+ if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
974
+ return res.status(403).json({ error: 'Access denied' });
975
+ }
976
+
977
+ // Don't allow deleting the workspace root
978
+ if (normalizedPath === WORKSPACE_ROOT) {
979
+ return res.status(403).json({ error: 'Cannot delete workspace root' });
980
+ }
981
+
982
+ const stats = await fs.stat(normalizedPath);
983
+
984
+ if (stats.isDirectory()) {
985
+ await fs.rm(normalizedPath, { recursive: true });
986
+ } else {
987
+ await fs.unlink(normalizedPath);
988
+ }
989
+
990
+ res.json({ success: true, path: filePath });
991
+ } catch (err) {
992
+ if (err.code === 'ENOENT') {
993
+ res.status(404).json({ error: 'File not found' });
994
+ } else {
995
+ res.status(500).json({ error: err.message });
996
+ }
997
+ }
998
+ });
999
+
1000
+ // Rename file or directory
1001
+ app.post('/api/files/rename', async (req, res) => {
1002
+ try {
1003
+ const { oldPath, newPath } = req.body;
1004
+
1005
+ if (!oldPath || !newPath) {
1006
+ return res.status(400).json({ error: 'Both oldPath and newPath required' });
1007
+ }
1008
+
1009
+ const fullOldPath = path.join(WORKSPACE_ROOT, oldPath);
1010
+ const fullNewPath = path.join(WORKSPACE_ROOT, newPath);
1011
+ const normalizedOldPath = path.normalize(fullOldPath);
1012
+ const normalizedNewPath = path.normalize(fullNewPath);
1013
+
1014
+ if (!normalizedOldPath.startsWith(WORKSPACE_ROOT) || !normalizedNewPath.startsWith(WORKSPACE_ROOT)) {
1015
+ return res.status(403).json({ error: 'Access denied' });
1016
+ }
1017
+
1018
+ // Check if source exists
1019
+ await fs.stat(normalizedOldPath);
1020
+
1021
+ // Check if destination already exists
1022
+ try {
1023
+ await fs.stat(normalizedNewPath);
1024
+ return res.status(409).json({ error: 'Destination already exists' });
1025
+ } catch (err) {
1026
+ if (err.code !== 'ENOENT') throw err;
1027
+ }
1028
+
1029
+ await fs.rename(normalizedOldPath, normalizedNewPath);
1030
+
1031
+ res.json({ success: true, oldPath, newPath });
1032
+ } catch (err) {
1033
+ if (err.code === 'ENOENT') {
1034
+ res.status(404).json({ error: 'File not found' });
1035
+ } else {
1036
+ res.status(500).json({ error: err.message });
1037
+ }
1038
+ }
1039
+ });
1040
+
506
1041
  // REST API
507
1042
  app.get('/api/sessions', (req, res) => {
508
1043
  const sessionList = Array.from(sessions.values()).map(s => ({
@@ -569,9 +1104,51 @@ app.get('/health', (req, res) => {
569
1104
  });
570
1105
  });
571
1106
 
572
- const PORT = process.env.PORT || 3000;
573
- server.listen(PORT, '0.0.0.0', () => {
574
- console.log(`DevTunnel server running on http://localhost:${PORT}`);
1107
+ const DEFAULT_PORT = process.env.PORT || 3000;
1108
+
1109
+ function findAvailablePort(startPort, maxAttempts = 10) {
1110
+ return new Promise((resolve, reject) => {
1111
+ let port = startPort;
1112
+ let attempts = 0;
1113
+
1114
+ const tryPort = () => {
1115
+ if (attempts >= maxAttempts) {
1116
+ reject(new Error(`No available port found after ${maxAttempts} attempts`));
1117
+ return;
1118
+ }
1119
+
1120
+ const testServer = require('net').createServer();
1121
+ testServer.once('error', (err) => {
1122
+ if (err.code === 'EADDRINUSE') {
1123
+ console.log(`Port ${port} is in use, trying ${port + 1}...`);
1124
+ port++;
1125
+ attempts++;
1126
+ tryPort();
1127
+ } else {
1128
+ reject(err);
1129
+ }
1130
+ });
1131
+
1132
+ testServer.once('listening', () => {
1133
+ testServer.close(() => {
1134
+ resolve(port);
1135
+ });
1136
+ });
1137
+
1138
+ testServer.listen(port, '0.0.0.0');
1139
+ };
1140
+
1141
+ tryPort();
1142
+ });
1143
+ }
1144
+
1145
+ findAvailablePort(DEFAULT_PORT).then((PORT) => {
1146
+ server.listen(PORT, '0.0.0.0', () => {
1147
+ console.log(`DevTunnel server running on http://localhost:${PORT}`);
1148
+ });
1149
+ }).catch((err) => {
1150
+ console.error('Failed to start server:', err.message);
1151
+ process.exit(1);
575
1152
  });
576
1153
 
577
1154
  // Graceful shutdown