@mjasano/devtunnel 1.2.0 → 1.5.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
@@ -328,7 +491,26 @@ function stopTunnel(id) {
328
491
  }
329
492
 
330
493
  // WebSocket handling
331
- wss.on('connection', async (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
+
332
514
  console.log('Client connected');
333
515
 
334
516
  let currentSession = null;
@@ -359,6 +541,12 @@ wss.on('connection', async (ws) => {
359
541
  ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
360
542
 
361
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
+
362
550
  try {
363
551
  const msg = JSON.parse(message.toString());
364
552
 
@@ -502,6 +690,7 @@ wss.on('connection', async (ws) => {
502
690
 
503
691
  ws.on('close', () => {
504
692
  console.log('Client disconnected');
693
+ wsRateLimits.delete(ws);
505
694
  if (currentSession) {
506
695
  currentSession.clients.delete(ws);
507
696
  currentSession.lastAccess = Date.now();
@@ -537,19 +726,19 @@ async function getMemoryInfo() {
537
726
  const pageSize = 16384; // macOS default page size (16KB on Apple Silicon)
538
727
  const lines = output.split('\n');
539
728
 
540
- let free = 0, active = 0, inactive = 0, wired = 0, compressed = 0, purgeable = 0;
729
+ let _free = 0, active = 0, _inactive = 0, wired = 0, compressed = 0, _purgeable = 0;
541
730
 
542
731
  lines.forEach(line => {
543
732
  const match = line.match(/^(.+):\s+(\d+)/);
544
733
  if (match) {
545
734
  const value = parseInt(match[2]) * pageSize;
546
735
  const key = match[1].toLowerCase();
547
- if (key.includes('free')) free = value;
736
+ if (key.includes('free')) _free = value;
548
737
  else if (key.includes('active')) active = value;
549
- else if (key.includes('inactive')) inactive = value;
738
+ else if (key.includes('inactive')) _inactive = value;
550
739
  else if (key.includes('wired')) wired = value;
551
740
  else if (key.includes('compressed')) compressed = value;
552
- else if (key.includes('purgeable')) purgeable = value;
741
+ else if (key.includes('purgeable')) _purgeable = value;
553
742
  }
554
743
  });
555
744
 
@@ -769,6 +958,86 @@ app.post('/api/files/write', async (req, res) => {
769
958
  }
770
959
  });
771
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
+
772
1041
  // REST API
773
1042
  app.get('/api/sessions', (req, res) => {
774
1043
  const sessionList = Array.from(sessions.values()).map(s => ({
@@ -0,0 +1,204 @@
1
+ const { describe, it, before, after } = require('node:test');
2
+ const assert = require('node:assert');
3
+ const http = require('node:http');
4
+ const { spawn } = require('node:child_process');
5
+ const path = require('node:path');
6
+
7
+ let serverProcess;
8
+ const serverPort = 3099;
9
+
10
+ function makeRequest(method, path, body = null) {
11
+ return new Promise((resolve, reject) => {
12
+ const options = {
13
+ hostname: 'localhost',
14
+ port: serverPort,
15
+ path,
16
+ method,
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ },
20
+ };
21
+
22
+ const req = http.request(options, (res) => {
23
+ let data = '';
24
+ res.on('data', (chunk) => (data += chunk));
25
+ res.on('end', () => {
26
+ try {
27
+ resolve({ status: res.statusCode, data: JSON.parse(data) });
28
+ } catch {
29
+ resolve({ status: res.statusCode, data });
30
+ }
31
+ });
32
+ });
33
+
34
+ req.on('error', reject);
35
+
36
+ if (body) {
37
+ req.write(JSON.stringify(body));
38
+ }
39
+ req.end();
40
+ });
41
+ }
42
+
43
+ function waitForServer(port, timeout = 10000) {
44
+ return new Promise((resolve, reject) => {
45
+ const start = Date.now();
46
+
47
+ const check = () => {
48
+ const req = http.get(`http://localhost:${port}/health`, (res) => {
49
+ if (res.statusCode === 200) {
50
+ resolve();
51
+ } else {
52
+ retry();
53
+ }
54
+ });
55
+
56
+ req.on('error', retry);
57
+ req.setTimeout(1000);
58
+ };
59
+
60
+ const retry = () => {
61
+ if (Date.now() - start > timeout) {
62
+ reject(new Error('Server startup timeout'));
63
+ } else {
64
+ setTimeout(check, 200);
65
+ }
66
+ };
67
+
68
+ check();
69
+ });
70
+ }
71
+
72
+ describe('DevTunnel Server API', () => {
73
+ before(async () => {
74
+ const serverPath = path.join(__dirname, '..', 'server.js');
75
+ serverProcess = spawn('node', [serverPath], {
76
+ env: { ...process.env, PORT: serverPort },
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ });
79
+
80
+ serverProcess.stdout.on('data', (data) => {
81
+ console.log(`[server] ${data.toString().trim()}`);
82
+ });
83
+
84
+ serverProcess.stderr.on('data', (data) => {
85
+ console.error(`[server] ${data.toString().trim()}`);
86
+ });
87
+
88
+ await waitForServer(serverPort);
89
+ });
90
+
91
+ after(() => {
92
+ if (serverProcess) {
93
+ serverProcess.kill('SIGTERM');
94
+ }
95
+ });
96
+
97
+ describe('GET /health', () => {
98
+ it('should return health status', async () => {
99
+ const { status, data } = await makeRequest('GET', '/health');
100
+ assert.strictEqual(status, 200);
101
+ assert.strictEqual(data.status, 'ok');
102
+ assert.strictEqual(typeof data.sessions, 'number');
103
+ assert.strictEqual(typeof data.tunnels, 'number');
104
+ });
105
+ });
106
+
107
+ describe('GET /api/system', () => {
108
+ it('should return system information', async () => {
109
+ const { status, data } = await makeRequest('GET', '/api/system');
110
+ assert.strictEqual(status, 200);
111
+ assert.ok(data.hostname);
112
+ assert.ok(data.platform);
113
+ assert.ok(data.arch);
114
+ assert.strictEqual(typeof data.uptime, 'number');
115
+ assert.ok(data.cpu);
116
+ assert.ok(data.memory);
117
+ assert.ok(Array.isArray(data.loadavg));
118
+ });
119
+ });
120
+
121
+ describe('GET /api/sessions', () => {
122
+ it('should return empty session list initially', async () => {
123
+ const { status, data } = await makeRequest('GET', '/api/sessions');
124
+ assert.strictEqual(status, 200);
125
+ assert.ok(Array.isArray(data));
126
+ });
127
+ });
128
+
129
+ describe('GET /api/tunnels', () => {
130
+ it('should return empty tunnel list initially', async () => {
131
+ const { status, data } = await makeRequest('GET', '/api/tunnels');
132
+ assert.strictEqual(status, 200);
133
+ assert.ok(Array.isArray(data));
134
+ });
135
+ });
136
+
137
+ describe('GET /api/files', () => {
138
+ it('should return file listing', async () => {
139
+ const { status, data } = await makeRequest('GET', '/api/files');
140
+ assert.strictEqual(status, 200);
141
+ assert.strictEqual(data.path, '');
142
+ assert.ok(Array.isArray(data.items));
143
+ });
144
+ });
145
+
146
+ describe('POST /api/files/write', () => {
147
+ it('should reject request without path', async () => {
148
+ const { status, data } = await makeRequest('POST', '/api/files/write', {
149
+ content: 'test',
150
+ });
151
+ assert.strictEqual(status, 400);
152
+ assert.strictEqual(data.error, 'Path required');
153
+ });
154
+ });
155
+
156
+ describe('POST /api/files/delete', () => {
157
+ it('should reject request without path', async () => {
158
+ const { status, data } = await makeRequest('POST', '/api/files/delete', {});
159
+ assert.strictEqual(status, 400);
160
+ assert.strictEqual(data.error, 'Path required');
161
+ });
162
+
163
+ it('should return 404 for non-existent file', async () => {
164
+ const { status, data } = await makeRequest('POST', '/api/files/delete', {
165
+ path: 'non-existent-file-12345.txt',
166
+ });
167
+ assert.strictEqual(status, 404);
168
+ assert.strictEqual(data.error, 'File not found');
169
+ });
170
+ });
171
+
172
+ describe('POST /api/files/rename', () => {
173
+ it('should reject request without paths', async () => {
174
+ const { status, data } = await makeRequest('POST', '/api/files/rename', {});
175
+ assert.strictEqual(status, 400);
176
+ assert.strictEqual(data.error, 'Both oldPath and newPath required');
177
+ });
178
+
179
+ it('should return 404 for non-existent source file', async () => {
180
+ const { status, data } = await makeRequest('POST', '/api/files/rename', {
181
+ oldPath: 'non-existent-file-12345.txt',
182
+ newPath: 'new-name.txt',
183
+ });
184
+ assert.strictEqual(status, 404);
185
+ assert.strictEqual(data.error, 'File not found');
186
+ });
187
+ });
188
+
189
+ describe('POST /api/tunnels', () => {
190
+ it('should reject request without valid port', async () => {
191
+ const { status, data } = await makeRequest('POST', '/api/tunnels', {});
192
+ assert.strictEqual(status, 400);
193
+ assert.strictEqual(data.error, 'Valid port number required');
194
+ });
195
+
196
+ it('should reject request with invalid port type', async () => {
197
+ const { status, data } = await makeRequest('POST', '/api/tunnels', {
198
+ port: 'invalid',
199
+ });
200
+ assert.strictEqual(status, 400);
201
+ assert.strictEqual(data.error, 'Valid port number required');
202
+ });
203
+ });
204
+ });