@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/.claude/settings.local.json +5 -1
- package/.prettierignore +2 -0
- package/.prettierrc +8 -0
- package/CHANGELOG.md +78 -0
- package/CLAUDE.md +18 -0
- package/README.md +138 -0
- package/bin/cli.js +40 -11
- package/eslint.config.js +32 -0
- package/package.json +12 -3
- package/public/app.js +839 -0
- package/public/index.html +13 -1247
- package/public/login.html +242 -0
- package/public/styles.css +857 -0
- package/server.js +585 -8
- package/test/server.test.js +204 -0
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
|
-
|
|
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
|
|
573
|
-
|
|
574
|
-
|
|
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
|