termites 1.0.2 → 1.0.4

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/.termites.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "passwordHash": "c24b1a8a39097744476af90309f1c765dc9fc1b7ee9d55e124d8533dea50f00f",
3
+ "sessionToken": "db7c768a5beddd2da037f3feff5c0322481e66e71b459f40c3cee20323a255ce"
4
+ }
package/bin/termites.js CHANGED
@@ -9,17 +9,21 @@ function showHelp() {
9
9
  Termites - Web terminal with server-client architecture
10
10
 
11
11
  Usage:
12
- termites server Start the server
12
+ termites server [options] Start the server
13
13
  termites client [url] Connect to a server
14
14
 
15
- Options:
15
+ Server Options:
16
+ --no-client Don't start local client (default: starts client)
17
+
18
+ Environment:
16
19
  PORT=<port> Set server port (default: 6789)
17
20
 
18
21
  Examples:
19
- termites server Start server on port 6789
20
- PORT=8080 termites server Start server on port 8080
22
+ termites server Start server with local client
23
+ termites server --no-client Start server only
24
+ PORT=8080 termites server Start on port 8080
21
25
  termites client Connect to ws://localhost:6789
22
- termites client 192.168.1.100:6789
26
+ termites client myserver Connect to ws://myserver:6789
23
27
  `);
24
28
  }
25
29
 
@@ -31,9 +35,23 @@ if (!command || command === '-h' || command === '--help' || command === 'help')
31
35
  const rootDir = path.join(__dirname, '..');
32
36
 
33
37
  if (command === 'server') {
38
+ const noClient = args.includes('--no-client');
39
+ const startClient = !noClient;
40
+
41
+ // Set environment variable for server to know whether to start client
42
+ process.env.START_CLIENT = startClient ? 'true' : 'false';
43
+
34
44
  require(path.join(rootDir, 'server.js'));
35
45
  } else if (command === 'client') {
36
- const serverUrl = args[1] || 'ws://localhost:6789';
46
+ let serverUrl = args[1] || 'ws://localhost:6789';
47
+ // Add ws:// prefix if missing
48
+ if (!serverUrl.startsWith('ws://') && !serverUrl.startsWith('wss://')) {
49
+ serverUrl = 'ws://' + serverUrl;
50
+ }
51
+ // Add default port if not specified
52
+ if (!serverUrl.match(/:\d+/)) {
53
+ serverUrl = serverUrl.replace(/^(wss?:\/\/[^\/]+)/, '$1:6789');
54
+ }
37
55
  process.argv = [process.argv[0], path.join(rootDir, 'client.js'), serverUrl];
38
56
  require(path.join(rootDir, 'client.js'));
39
57
  } else {
package/client.js CHANGED
@@ -13,7 +13,7 @@ function getSystemInfo() {
13
13
  return { username, hostname, cwd, platform };
14
14
  }
15
15
 
16
- class WebShellClient {
16
+ class TermitesClient {
17
17
  constructor(serverUrl) {
18
18
  this.serverUrl = serverUrl.endsWith('/client') ? serverUrl : serverUrl + '/client';
19
19
  this.ws = null;
@@ -26,12 +26,12 @@ class WebShellClient {
26
26
  }
27
27
 
28
28
  connect() {
29
- console.log(`连接服务器: ${this.serverUrl}`);
29
+ console.log(`Connecting to server: ${this.serverUrl}`);
30
30
 
31
31
  this.ws = new WebSocket(this.serverUrl);
32
32
 
33
33
  this.ws.on('open', () => {
34
- console.log('已连接到服务器');
34
+ console.log('Connected to server');
35
35
  this.reconnectDelay = 1000;
36
36
 
37
37
  // Register with server
@@ -49,23 +49,23 @@ class WebShellClient {
49
49
  const data = JSON.parse(message);
50
50
  this.handleServerMessage(data);
51
51
  } catch (e) {
52
- console.error('解析服务器消息失败:', e);
52
+ console.error('Failed to parse server message:', e);
53
53
  }
54
54
  });
55
55
 
56
56
  this.ws.on('close', () => {
57
- console.log('与服务器断开连接');
57
+ console.log('Disconnected from server');
58
58
  this.stopShell();
59
59
  this.scheduleReconnect();
60
60
  });
61
61
 
62
62
  this.ws.on('error', (err) => {
63
- console.error('WebSocket 错误:', err.message);
63
+ console.error('WebSocket error:', err.message);
64
64
  });
65
65
  }
66
66
 
67
67
  scheduleReconnect() {
68
- console.log(`${this.reconnectDelay / 1000} 秒后重连...`);
68
+ console.log(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
69
69
  setTimeout(() => {
70
70
  this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
71
71
  this.connect();
@@ -100,7 +100,7 @@ class WebShellClient {
100
100
  env: process.env
101
101
  });
102
102
 
103
- console.log(`Shell 已启动 (PID: ${this.pty.pid})`);
103
+ console.log(`Shell started (PID: ${this.pty.pid})`);
104
104
 
105
105
  this.pty.onData((data) => {
106
106
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
@@ -112,7 +112,7 @@ class WebShellClient {
112
112
  });
113
113
 
114
114
  this.pty.onExit(() => {
115
- console.log('Shell 已退出');
115
+ console.log('Shell exited');
116
116
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
117
117
  this.ws.send(JSON.stringify({ type: 'exit' }));
118
118
  }
@@ -135,12 +135,16 @@ let serverUrl = SERVER_URL;
135
135
  if (!serverUrl.startsWith('ws://') && !serverUrl.startsWith('wss://')) {
136
136
  serverUrl = 'ws://' + serverUrl;
137
137
  }
138
+ // Add default port if not specified
139
+ if (!serverUrl.match(/:\d+/) && !serverUrl.includes('localhost')) {
140
+ serverUrl = serverUrl.replace(/^(wss?:\/\/[^\/]+)/, '$1:6789');
141
+ }
138
142
 
139
- console.log('WebShell Client');
140
- console.log(`系统信息: ${getSystemInfo().username}@${getSystemInfo().hostname}`);
143
+ console.log('Termites Client');
144
+ console.log(`System: ${getSystemInfo().username}@${getSystemInfo().hostname}`);
141
145
  console.log(`Shell: ${SHELL}`);
142
146
 
143
- new WebShellClient(serverUrl);
147
+ new TermitesClient(serverUrl);
144
148
 
145
149
  process.on('SIGINT', () => process.exit(0));
146
150
  process.on('SIGTERM', () => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termites",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Web terminal with server-client architecture for remote shell access",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server.js CHANGED
@@ -2,15 +2,49 @@
2
2
  const http = require('http');
3
3
  const WebSocket = require('ws');
4
4
  const crypto = require('crypto');
5
+ const { spawn } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
5
8
 
6
9
  const PORT = process.env.PORT || 6789;
10
+ const START_CLIENT = process.env.START_CLIENT !== 'false'; // default true
11
+ const CONFIG_FILE = path.join(__dirname, '.termites.json');
7
12
 
8
- class WebShellServer {
13
+ // Load or create config
14
+ function loadConfig() {
15
+ try {
16
+ if (fs.existsSync(CONFIG_FILE)) {
17
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
18
+ }
19
+ } catch (e) {}
20
+ return { passwordHash: null };
21
+ }
22
+
23
+ function saveConfig(config) {
24
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
25
+ }
26
+
27
+ function hashPassword(password) {
28
+ return crypto.createHash('sha256').update(password).digest('hex');
29
+ }
30
+
31
+ class TermitesServer {
9
32
  constructor(port) {
10
33
  this.port = port;
11
34
  this.clients = new Map(); // clientId -> { ws, info, outputBuffer }
12
35
  this.browsers = new Set(); // browser WebSocket connections
13
36
  this.selectedClient = null; // currently selected client for each browser
37
+ this.config = loadConfig();
38
+ // Use saved sessionToken or generate new one
39
+ if (this.config.passwordHash) {
40
+ if (!this.config.sessionToken) {
41
+ this.config.sessionToken = crypto.randomBytes(32).toString('hex');
42
+ saveConfig(this.config);
43
+ }
44
+ this.sessionToken = this.config.sessionToken;
45
+ } else {
46
+ this.sessionToken = null;
47
+ }
14
48
 
15
49
  this.startServer();
16
50
  }
@@ -24,6 +58,14 @@ class WebShellServer {
24
58
 
25
59
  this.wss.on('connection', (ws, req) => {
26
60
  const isClient = req.url === '/client';
61
+ console.log(`WebSocket connection: ${req.url}, isClient: ${isClient}`);
62
+
63
+ // Browser connections require auth (if password is set)
64
+ if (!isClient && this.config.passwordHash && !this.checkSession(req)) {
65
+ console.log('WebSocket rejected: unauthorized browser connection');
66
+ ws.close(1008, 'Unauthorized');
67
+ return;
68
+ }
27
69
 
28
70
  if (isClient) {
29
71
  this.handleClientConnection(ws);
@@ -33,15 +75,64 @@ class WebShellServer {
33
75
  });
34
76
 
35
77
  this.httpServer.listen(this.port, () => {
36
- console.log(`WebShell Server 启动: http://localhost:${this.port}`);
37
- console.log(`客户端连接地址: ws://localhost:${this.port}/client`);
78
+ console.log(`Termites Server started: http://localhost:${this.port}`);
79
+ console.log(`Client connection: ws://localhost:${this.port}/client`);
80
+ if (!this.config.passwordHash) {
81
+ console.log('Warning: No password set, please visit browser to set password');
82
+ }
83
+
84
+ // Auto-start local client if enabled
85
+ if (START_CLIENT) {
86
+ this.startLocalClient();
87
+ }
88
+ });
89
+ }
90
+
91
+ checkSession(req) {
92
+ const cookies = this.parseCookies(req.headers.cookie || '');
93
+ const valid = cookies.session === this.sessionToken;
94
+ if (!valid) {
95
+ console.log('Session check failed. Cookie:', req.headers.cookie ? 'present' : 'missing');
96
+ console.log(' Expected:', this.sessionToken?.substring(0, 16) + '...');
97
+ console.log(' Got:', cookies.session?.substring(0, 16) + '...');
98
+ }
99
+ return valid;
100
+ }
101
+
102
+ parseCookies(cookieStr) {
103
+ const cookies = {};
104
+ cookieStr.split(';').forEach(pair => {
105
+ const [key, val] = pair.trim().split('=');
106
+ if (key) cookies[key] = val;
107
+ });
108
+ return cookies;
109
+ }
110
+
111
+ startLocalClient() {
112
+ console.log('Starting local client...');
113
+ const clientPath = path.join(__dirname, 'client.js');
114
+ const serverUrl = `ws://localhost:${this.port}/client`;
115
+
116
+ this.localClient = spawn(process.execPath, [clientPath, serverUrl], {
117
+ stdio: 'inherit'
118
+ });
119
+ global.localClient = this.localClient;
120
+
121
+ this.localClient.on('error', (err) => {
122
+ console.error('Failed to start local client:', err.message);
123
+ });
124
+
125
+ this.localClient.on('exit', (code) => {
126
+ console.log(`Local client exited (code: ${code})`);
127
+ this.localClient = null;
128
+ global.localClient = null;
38
129
  });
39
130
  }
40
131
 
41
132
  // Handle shell client connections
42
133
  handleClientConnection(ws) {
43
134
  const clientId = crypto.randomUUID();
44
- console.log(`新客户端连接: ${clientId}`);
135
+ console.log(`New client connected: ${clientId}`);
45
136
 
46
137
  const clientData = {
47
138
  ws,
@@ -55,12 +146,12 @@ class WebShellServer {
55
146
  const data = JSON.parse(message);
56
147
  this.handleClientMessage(clientId, data);
57
148
  } catch (e) {
58
- console.error('解析客户端消息失败:', e);
149
+ console.error('Failed to parse client message:', e);
59
150
  }
60
151
  });
61
152
 
62
153
  ws.on('close', () => {
63
- console.log(`客户端断开: ${clientId}`);
154
+ console.log(`Client disconnected: ${clientId}`);
64
155
  this.clients.delete(clientId);
65
156
  this.broadcastToBrowsers({
66
157
  type: 'client-disconnected',
@@ -76,7 +167,7 @@ class WebShellServer {
76
167
  switch (data.type) {
77
168
  case 'register':
78
169
  client.info = data.info;
79
- console.log(`客户端注册: ${data.info.username}@${data.info.hostname}`);
170
+ console.log(`Client registered: ${data.info.username}@${data.info.hostname}`);
80
171
  this.broadcastToBrowsers({
81
172
  type: 'client-connected',
82
173
  client: { id: clientId, ...data.info }
@@ -97,20 +188,23 @@ class WebShellServer {
97
188
  break;
98
189
 
99
190
  case 'exit':
100
- console.log(`客户端 shell 退出: ${clientId}`);
191
+ console.log(`Client shell exited: ${clientId}`);
101
192
  break;
102
193
  }
103
194
  }
104
195
 
105
196
  // Handle browser connections
106
197
  handleBrowserConnection(ws) {
107
- console.log('新浏览器连接');
198
+ console.log('New browser connected');
199
+ console.log('Current clients count:', this.clients.size);
108
200
  this.browsers.add(ws);
109
201
 
110
202
  // Send current client list
203
+ const clientList = this.getClientList();
204
+ console.log('Sending client list:', clientList.length, 'clients');
111
205
  ws.send(JSON.stringify({
112
206
  type: 'clients',
113
- list: this.getClientList()
207
+ list: clientList
114
208
  }));
115
209
 
116
210
  ws.on('message', (message) => {
@@ -118,7 +212,7 @@ class WebShellServer {
118
212
  const data = JSON.parse(message);
119
213
  this.handleBrowserMessage(ws, data);
120
214
  } catch (e) {
121
- console.error('解析浏览器消息失败:', e);
215
+ console.error('Failed to parse browser message:', e);
122
216
  }
123
217
  });
124
218
 
@@ -197,7 +291,84 @@ class WebShellServer {
197
291
  }
198
292
 
199
293
  handleHttpRequest(req, res) {
200
- if (req.url === '/' || req.url === '/index.html') {
294
+ const url = req.url.split('?')[0];
295
+
296
+ // Setup password API
297
+ if (url === '/api/setup' && req.method === 'POST') {
298
+ let body = '';
299
+ req.on('data', chunk => body += chunk);
300
+ req.on('end', () => {
301
+ try {
302
+ const { password } = JSON.parse(body);
303
+ if (!password || password.length < 4) {
304
+ res.writeHead(400, { 'Content-Type': 'application/json' });
305
+ res.end(JSON.stringify({ success: false, error: 'Password must be at least 4 characters' }));
306
+ return;
307
+ }
308
+ // Only allow setup if no password exists
309
+ if (this.config.passwordHash) {
310
+ res.writeHead(403, { 'Content-Type': 'application/json' });
311
+ res.end(JSON.stringify({ success: false, error: 'Password already set' }));
312
+ return;
313
+ }
314
+ this.config.passwordHash = hashPassword(password);
315
+ this.config.sessionToken = crypto.randomBytes(32).toString('hex');
316
+ this.sessionToken = this.config.sessionToken;
317
+ saveConfig(this.config);
318
+ console.log('Password has been set');
319
+ res.writeHead(200, {
320
+ 'Content-Type': 'application/json',
321
+ 'Set-Cookie': `session=${this.sessionToken}; Path=/; HttpOnly; SameSite=Strict`
322
+ });
323
+ res.end(JSON.stringify({ success: true }));
324
+ } catch (e) {
325
+ res.writeHead(400, { 'Content-Type': 'application/json' });
326
+ res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
327
+ }
328
+ });
329
+ return;
330
+ }
331
+
332
+ // Login API
333
+ if (url === '/api/login' && req.method === 'POST') {
334
+ let body = '';
335
+ req.on('data', chunk => body += chunk);
336
+ req.on('end', () => {
337
+ try {
338
+ const { password } = JSON.parse(body);
339
+ if (hashPassword(password) === this.config.passwordHash) {
340
+ res.writeHead(200, {
341
+ 'Content-Type': 'application/json',
342
+ 'Set-Cookie': `session=${this.sessionToken}; Path=/; HttpOnly; SameSite=Strict`
343
+ });
344
+ res.end(JSON.stringify({ success: true }));
345
+ } else {
346
+ res.writeHead(401, { 'Content-Type': 'application/json' });
347
+ res.end(JSON.stringify({ success: false, error: 'Wrong password' }));
348
+ }
349
+ } catch (e) {
350
+ res.writeHead(400, { 'Content-Type': 'application/json' });
351
+ res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
352
+ }
353
+ });
354
+ return;
355
+ }
356
+
357
+ // No password set - show setup page
358
+ if (!this.config.passwordHash) {
359
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
360
+ res.end(this.getSetupPage());
361
+ return;
362
+ }
363
+
364
+ // Check session for protected pages
365
+ if (!this.checkSession(req)) {
366
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
367
+ res.end(this.getLoginPage());
368
+ return;
369
+ }
370
+
371
+ if (url === '/' || url === '/index.html') {
201
372
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
202
373
  res.end(this.getHtmlPage());
203
374
  } else {
@@ -206,13 +377,200 @@ class WebShellServer {
206
377
  }
207
378
  }
208
379
 
380
+ getSetupPage() {
381
+ return `<!DOCTYPE html>
382
+ <html lang="en">
383
+ <head>
384
+ <meta charset="UTF-8">
385
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
386
+ <title>Termites - Setup</title>
387
+ <style>
388
+ * { margin: 0; padding: 0; box-sizing: border-box; }
389
+ body {
390
+ min-height: 100vh; display: flex; align-items: center; justify-content: center;
391
+ background: #1a1a2e; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
392
+ }
393
+ .setup-box {
394
+ background: #16213e; padding: 40px; border-radius: 12px; width: 360px;
395
+ box-shadow: 0 10px 40px rgba(0,0,0,0.3);
396
+ }
397
+ h1 { color: #eee; margin-bottom: 12px; font-size: 20px; text-align: center; }
398
+ .subtitle { color: #888; font-size: 13px; text-align: center; margin-bottom: 30px; }
399
+ .input-group { margin-bottom: 20px; }
400
+ label { display: block; color: #888; margin-bottom: 8px; font-size: 13px; }
401
+ input[type="password"] {
402
+ width: 100%; padding: 12px 16px; border: 2px solid #2a2a4a; border-radius: 8px;
403
+ background: #0f0f23; color: #eee; font-size: 16px; outline: none;
404
+ transition: border-color 0.2s;
405
+ }
406
+ input[type="password"]:focus { border-color: #50fa7b; }
407
+ button {
408
+ width: 100%; padding: 14px; border: none; border-radius: 8px;
409
+ background: #50fa7b; color: #1a1a2e; font-size: 16px; font-weight: bold;
410
+ cursor: pointer; transition: background 0.2s;
411
+ }
412
+ button:hover { background: #69ff94; }
413
+ button:disabled { background: #555; color: #888; cursor: not-allowed; }
414
+ .error { color: #ff6b6b; font-size: 13px; margin-top: 12px; text-align: center; display: none; }
415
+ </style>
416
+ </head>
417
+ <body>
418
+ <div class="setup-box">
419
+ <h1>Termites Setup</h1>
420
+ <p class="subtitle">Set a password to protect your terminal</p>
421
+ <form id="setup-form">
422
+ <div class="input-group">
423
+ <label>Password</label>
424
+ <input type="password" id="password" placeholder="Enter password (min 4 chars)" autofocus>
425
+ </div>
426
+ <div class="input-group">
427
+ <label>Confirm Password</label>
428
+ <input type="password" id="confirm" placeholder="Confirm password">
429
+ </div>
430
+ <button type="submit">Set Password</button>
431
+ <div class="error" id="error"></div>
432
+ </form>
433
+ </div>
434
+ <script>
435
+ document.getElementById('setup-form').onsubmit = async (e) => {
436
+ e.preventDefault();
437
+ const btn = e.target.querySelector('button');
438
+ const error = document.getElementById('error');
439
+ const password = document.getElementById('password').value;
440
+ const confirm = document.getElementById('confirm').value;
441
+
442
+ error.style.display = 'none';
443
+
444
+ if (password.length < 4) {
445
+ error.textContent = 'Password must be at least 4 characters';
446
+ error.style.display = 'block';
447
+ return;
448
+ }
449
+ if (password !== confirm) {
450
+ error.textContent = 'Passwords do not match';
451
+ error.style.display = 'block';
452
+ return;
453
+ }
454
+
455
+ btn.disabled = true;
456
+ btn.textContent = 'Setting up...';
457
+
458
+ try {
459
+ const res = await fetch('/api/setup', {
460
+ method: 'POST',
461
+ headers: { 'Content-Type': 'application/json' },
462
+ body: JSON.stringify({ password })
463
+ });
464
+ const data = await res.json();
465
+ if (data.success) {
466
+ location.reload();
467
+ } else {
468
+ error.textContent = data.error || 'Setup failed';
469
+ error.style.display = 'block';
470
+ }
471
+ } catch (err) {
472
+ error.textContent = 'Network error';
473
+ error.style.display = 'block';
474
+ }
475
+ btn.disabled = false;
476
+ btn.textContent = 'Set Password';
477
+ };
478
+ </script>
479
+ </body>
480
+ </html>`;
481
+ }
482
+
483
+ getLoginPage() {
484
+ return `<!DOCTYPE html>
485
+ <html lang="en">
486
+ <head>
487
+ <meta charset="UTF-8">
488
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
489
+ <title>Termites - Login</title>
490
+ <style>
491
+ * { margin: 0; padding: 0; box-sizing: border-box; }
492
+ body {
493
+ min-height: 100vh; display: flex; align-items: center; justify-content: center;
494
+ background: #1a1a2e; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
495
+ }
496
+ .login-box {
497
+ background: #16213e; padding: 40px; border-radius: 12px; width: 320px;
498
+ box-shadow: 0 10px 40px rgba(0,0,0,0.3);
499
+ }
500
+ h1 { color: #eee; margin-bottom: 30px; font-size: 20px; text-align: center; }
501
+ .input-group { margin-bottom: 20px; }
502
+ label { display: block; color: #888; margin-bottom: 8px; font-size: 13px; }
503
+ input[type="password"] {
504
+ width: 100%; padding: 12px 16px; border: 2px solid #2a2a4a; border-radius: 8px;
505
+ background: #0f0f23; color: #eee; font-size: 16px; outline: none;
506
+ transition: border-color 0.2s;
507
+ }
508
+ input[type="password"]:focus { border-color: #e94560; }
509
+ button {
510
+ width: 100%; padding: 14px; border: none; border-radius: 8px;
511
+ background: #e94560; color: white; font-size: 16px; font-weight: bold;
512
+ cursor: pointer; transition: background 0.2s;
513
+ }
514
+ button:hover { background: #ff6b6b; }
515
+ button:disabled { background: #555; cursor: not-allowed; }
516
+ .error { color: #ff6b6b; font-size: 13px; margin-top: 12px; text-align: center; display: none; }
517
+ </style>
518
+ </head>
519
+ <body>
520
+ <div class="login-box">
521
+ <h1>Termites</h1>
522
+ <form id="login-form">
523
+ <div class="input-group">
524
+ <label>Password</label>
525
+ <input type="password" id="password" placeholder="Enter password" autofocus>
526
+ </div>
527
+ <button type="submit">Login</button>
528
+ <div class="error" id="error"></div>
529
+ </form>
530
+ </div>
531
+ <script>
532
+ document.getElementById('login-form').onsubmit = async (e) => {
533
+ e.preventDefault();
534
+ const btn = e.target.querySelector('button');
535
+ const error = document.getElementById('error');
536
+ const password = document.getElementById('password').value;
537
+
538
+ btn.disabled = true;
539
+ btn.textContent = 'Logging in...';
540
+ error.style.display = 'none';
541
+
542
+ try {
543
+ const res = await fetch('/api/login', {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json' },
546
+ body: JSON.stringify({ password })
547
+ });
548
+ const data = await res.json();
549
+ if (data.success) {
550
+ location.reload();
551
+ } else {
552
+ error.textContent = data.error || 'Login failed';
553
+ error.style.display = 'block';
554
+ }
555
+ } catch (err) {
556
+ error.textContent = 'Network error';
557
+ error.style.display = 'block';
558
+ }
559
+ btn.disabled = false;
560
+ btn.textContent = 'Login';
561
+ };
562
+ </script>
563
+ </body>
564
+ </html>`;
565
+ }
566
+
209
567
  getHtmlPage() {
210
568
  return `<!DOCTYPE html>
211
- <html lang="zh-CN">
569
+ <html lang="en">
212
570
  <head>
213
571
  <meta charset="UTF-8">
214
572
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
215
- <title>WebShell</title>
573
+ <title>Termites</title>
216
574
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
217
575
  <style>
218
576
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -272,21 +630,35 @@ class WebShellServer {
272
630
  .font-size-row input { flex: 1; }
273
631
  .font-size-row span { font-size: 12px; min-width: 36px; }
274
632
  .empty-clients { padding: 16px; font-size: 12px; opacity: 0.5; text-align: center; }
633
+ /* Mobile toolbar */
634
+ .mobile-toolbar {
635
+ display: none; flex-shrink: 0; padding: 6px 8px; gap: 6px;
636
+ border-bottom: 1px solid; overflow-x: auto; -webkit-overflow-scrolling: touch;
637
+ }
638
+ .mobile-toolbar.show { display: flex; }
639
+ .mobile-toolbar button {
640
+ flex-shrink: 0; padding: 8px 12px; border: 1px solid; border-radius: 6px;
641
+ background: transparent; color: inherit; font-size: 12px; font-family: monospace;
642
+ cursor: pointer; transition: all 0.15s; min-width: 44px;
643
+ }
644
+ .mobile-toolbar button:active { opacity: 0.6; transform: scale(0.95); }
645
+ .mobile-toolbar button.active { background: var(--btn-active, rgba(255,255,255,0.15)); }
646
+ .mobile-toolbar .sep { width: 1px; background: currentColor; opacity: 0.2; margin: 0 4px; }
275
647
  </style>
276
648
  </head>
277
649
  <body>
278
650
  <div class="overlay" id="overlay"></div>
279
651
  <div class="drawer" id="drawer">
280
652
  <div class="drawer-header">
281
- <h3>WebShell</h3>
653
+ <h3>Termites</h3>
282
654
  <button class="drawer-close" id="drawer-close">&times;</button>
283
655
  </div>
284
656
  <div class="drawer-content">
285
657
  <div class="drawer-section">
286
- <div class="drawer-section-header">⚙ 设置</div>
658
+ <div class="drawer-section-header">⚙ Settings</div>
287
659
  <div class="settings-section">
288
660
  <div class="setting-group">
289
- <label>主题</label>
661
+ <label>Theme</label>
290
662
  <select id="theme-select">
291
663
  <option value="solarized-light">Solarized Light</option>
292
664
  <option value="solarized-dark">Solarized Dark</option>
@@ -297,7 +669,7 @@ class WebShellServer {
297
669
  </select>
298
670
  </div>
299
671
  <div class="setting-group">
300
- <label>字体</label>
672
+ <label>Font</label>
301
673
  <select id="font-select">
302
674
  <option value="Monaco, Menlo, monospace">Monaco</option>
303
675
  <option value="'Fira Code', monospace">Fira Code</option>
@@ -307,25 +679,48 @@ class WebShellServer {
307
679
  </select>
308
680
  </div>
309
681
  <div class="setting-group">
310
- <label>字体大小</label>
682
+ <label>Font Size</label>
311
683
  <div class="font-size-row">
312
684
  <input type="range" id="font-size" min="10" max="24" value="14">
313
685
  <span id="font-size-value">14px</span>
314
686
  </div>
315
687
  </div>
688
+ <div class="setting-group">
689
+ <label>Toolbar</label>
690
+ <select id="toolbar-select">
691
+ <option value="auto">Auto (detect mobile)</option>
692
+ <option value="show">Always show</option>
693
+ <option value="hide">Always hide</option>
694
+ </select>
695
+ </div>
316
696
  </div>
317
697
  </div>
318
698
  <div class="drawer-section">
319
- <div class="drawer-section-header">◉ 客户端</div>
699
+ <div class="drawer-section-header">◉ Clients</div>
320
700
  <div class="client-list" id="client-list">
321
- <div class="empty-clients">等待客户端连接...</div>
701
+ <div class="empty-clients">Waiting for clients...</div>
322
702
  </div>
323
703
  </div>
324
704
  </div>
325
705
  </div>
326
706
  <div class="header">
327
707
  <button class="menu-btn" id="menu-btn"><span></span><span></span><span></span></button>
328
- <div class="header-title" id="header-title"><span class="no-client">未连接</span></div>
708
+ <div class="header-title" id="header-title"><span class="no-client">Not connected</span></div>
709
+ </div>
710
+ <div class="mobile-toolbar" id="mobile-toolbar">
711
+ <button data-key="Escape">Esc</button>
712
+ <button data-key="Tab">Tab</button>
713
+ <button data-mod="ctrl" class="mod-btn">Ctrl</button>
714
+ <button data-mod="alt" class="mod-btn">Alt</button>
715
+ <div class="sep"></div>
716
+ <button data-key="ArrowUp">↑</button>
717
+ <button data-key="ArrowDown">↓</button>
718
+ <button data-key="ArrowLeft">←</button>
719
+ <button data-key="ArrowRight">→</button>
720
+ <div class="sep"></div>
721
+ <button data-seq="\\x03">^C</button>
722
+ <button data-seq="\\x04">^D</button>
723
+ <button data-seq="\\x1a">^Z</button>
329
724
  </div>
330
725
  <div id="terminal-container"></div>
331
726
 
@@ -414,6 +809,8 @@ class WebShellServer {
414
809
  let currentTheme = 'solarized-light';
415
810
  let currentFont = 'Monaco, Menlo, monospace';
416
811
  let currentFontSize = 14;
812
+ let currentToolbar = 'auto';
813
+ let modifiers = { ctrl: false, alt: false };
417
814
 
418
815
  function loadSettings() {
419
816
  const saved = localStorage.getItem('webshell-settings');
@@ -422,16 +819,18 @@ class WebShellServer {
422
819
  currentTheme = s.theme || currentTheme;
423
820
  currentFont = s.font || currentFont;
424
821
  currentFontSize = s.fontSize || currentFontSize;
822
+ currentToolbar = s.toolbar || currentToolbar;
425
823
  }
426
824
  document.getElementById('theme-select').value = currentTheme;
427
825
  document.getElementById('font-select').value = currentFont;
428
826
  document.getElementById('font-size').value = currentFontSize;
429
827
  document.getElementById('font-size-value').textContent = currentFontSize + 'px';
828
+ document.getElementById('toolbar-select').value = currentToolbar;
430
829
  }
431
830
 
432
831
  function saveSettings() {
433
832
  localStorage.setItem('webshell-settings', JSON.stringify({
434
- theme: currentTheme, font: currentFont, fontSize: currentFontSize
833
+ theme: currentTheme, font: currentFont, fontSize: currentFontSize, toolbar: currentToolbar
435
834
  }));
436
835
  }
437
836
 
@@ -450,6 +849,10 @@ class WebShellServer {
450
849
  drawer.style.color = t.foreground;
451
850
  document.querySelectorAll('.drawer-section').forEach(el => el.style.borderColor = t.headerBorder);
452
851
  document.querySelector('.drawer-header').style.borderColor = t.headerBorder;
852
+ const toolbar = document.getElementById('mobile-toolbar');
853
+ toolbar.style.background = t.headerBg;
854
+ toolbar.style.borderColor = t.headerBorder;
855
+ toolbar.style.color = t.foreground;
453
856
  const style = document.documentElement.style;
454
857
  style.setProperty('--user-color', t.userColor);
455
858
  style.setProperty('--host-color', t.hostColor);
@@ -480,6 +883,38 @@ class WebShellServer {
480
883
  saveSettings();
481
884
  }
482
885
 
886
+ function applyToolbar(value) {
887
+ currentToolbar = value;
888
+ updateToolbarVisibility();
889
+ saveSettings();
890
+ if (term) fitAddon.fit();
891
+ }
892
+
893
+ function updateToolbarVisibility() {
894
+ const toolbar = document.getElementById('mobile-toolbar');
895
+ let shouldShow = false;
896
+
897
+ if (currentToolbar === 'show') {
898
+ shouldShow = true;
899
+ } else if (currentToolbar === 'hide') {
900
+ shouldShow = false;
901
+ } else {
902
+ // Auto: detect touch device or small screen
903
+ const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
904
+ const isSmallScreen = window.innerWidth < 768;
905
+ const isMobileUA = /Mobile|Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
906
+ shouldShow = isTouchDevice || isSmallScreen || isMobileUA;
907
+ }
908
+
909
+ if (shouldShow) {
910
+ toolbar.classList.add('show');
911
+ document.body.classList.add('toolbar-visible');
912
+ } else {
913
+ toolbar.classList.remove('show');
914
+ document.body.classList.remove('toolbar-visible');
915
+ }
916
+ }
917
+
483
918
  function setupDrawer() {
484
919
  const menuBtn = document.getElementById('menu-btn');
485
920
  const drawer = document.getElementById('drawer');
@@ -493,13 +928,80 @@ class WebShellServer {
493
928
  document.getElementById('theme-select').onchange = e => applyTheme(e.target.value);
494
929
  document.getElementById('font-select').onchange = e => applyFont(e.target.value);
495
930
  document.getElementById('font-size').oninput = e => applyFontSize(parseInt(e.target.value));
931
+ document.getElementById('toolbar-select').onchange = e => applyToolbar(e.target.value);
932
+ }
933
+
934
+ function setupMobileToolbar() {
935
+ const toolbar = document.getElementById('mobile-toolbar');
936
+ updateToolbarVisibility();
937
+
938
+ const keyMap = {
939
+ 'Escape': '\\x1b',
940
+ 'Tab': '\\t',
941
+ 'ArrowUp': '\\x1b[A',
942
+ 'ArrowDown': '\\x1b[B',
943
+ 'ArrowRight': '\\x1b[C',
944
+ 'ArrowLeft': '\\x1b[D'
945
+ };
946
+ // Ctrl + key sequences
947
+ const ctrlKeyMap = {
948
+ 'Escape': '\\x1b',
949
+ 'Tab': '\\t',
950
+ 'ArrowUp': '\\x1b[1;5A',
951
+ 'ArrowDown': '\\x1b[1;5B',
952
+ 'ArrowRight': '\\x1b[1;5C',
953
+ 'ArrowLeft': '\\x1b[1;5D'
954
+ };
955
+
956
+ toolbar.querySelectorAll('button').forEach(btn => {
957
+ btn.addEventListener('click', (e) => {
958
+ e.preventDefault();
959
+ const mod = btn.dataset.mod;
960
+ const key = btn.dataset.key;
961
+ const seq = btn.dataset.seq;
962
+
963
+ if (mod) {
964
+ modifiers[mod] = !modifiers[mod];
965
+ btn.classList.toggle('active', modifiers[mod]);
966
+ return;
967
+ }
968
+
969
+ let data = '';
970
+ if (seq) {
971
+ data = seq;
972
+ } else if (key) {
973
+ if (modifiers.ctrl) {
974
+ // Ctrl + key
975
+ if (ctrlKeyMap[key]) {
976
+ data = ctrlKeyMap[key];
977
+ } else if (key.length === 1) {
978
+ // Ctrl + letter (e.g., Ctrl+C = \\x03)
979
+ data = String.fromCharCode(key.toUpperCase().charCodeAt(0) - 64);
980
+ } else {
981
+ data = keyMap[key] || '';
982
+ }
983
+ } else {
984
+ data = keyMap[key] || '';
985
+ }
986
+ }
987
+
988
+ if (data && ws?.readyState === WebSocket.OPEN && selectedClientId) {
989
+ ws.send(JSON.stringify({ type: 'input', clientId: selectedClientId, text: data }));
990
+ // Reset modifiers after use
991
+ modifiers.ctrl = false;
992
+ modifiers.alt = false;
993
+ toolbar.querySelectorAll('.mod-btn').forEach(b => b.classList.remove('active'));
994
+ }
995
+ term.focus();
996
+ });
997
+ });
496
998
  }
497
999
 
498
1000
  function updateClientList() {
499
1001
  const listEl = document.getElementById('client-list');
500
1002
  const t = themes[currentTheme];
501
1003
  if (clients.length === 0) {
502
- listEl.innerHTML = '<div class="empty-clients">等待客户端连接...</div>';
1004
+ listEl.innerHTML = '<div class="empty-clients">Waiting for clients...</div>';
503
1005
  return;
504
1006
  }
505
1007
  listEl.innerHTML = clients.map(c => {
@@ -538,6 +1040,7 @@ class WebShellServer {
538
1040
  function init() {
539
1041
  loadSettings();
540
1042
  setupDrawer();
1043
+ setupMobileToolbar();
541
1044
  term = new Terminal({
542
1045
  theme: themes[currentTheme], fontFamily: currentFont,
543
1046
  fontSize: currentFontSize, cursorBlink: true
@@ -548,7 +1051,18 @@ class WebShellServer {
548
1051
  fitAddon.fit();
549
1052
  term.onData(data => {
550
1053
  if (ws?.readyState === WebSocket.OPEN && selectedClientId) {
551
- ws.send(JSON.stringify({ type: 'input', clientId: selectedClientId, text: data }));
1054
+ let sendData = data;
1055
+ // Apply Ctrl modifier to keyboard input
1056
+ if (modifiers.ctrl && data.length === 1) {
1057
+ const code = data.toUpperCase().charCodeAt(0);
1058
+ if (code >= 65 && code <= 90) { // A-Z
1059
+ sendData = String.fromCharCode(code - 64);
1060
+ }
1061
+ // Reset Ctrl after use
1062
+ modifiers.ctrl = false;
1063
+ document.querySelectorAll('.mod-btn').forEach(b => b.classList.remove('active'));
1064
+ }
1065
+ ws.send(JSON.stringify({ type: 'input', clientId: selectedClientId, text: sendData }));
552
1066
  }
553
1067
  });
554
1068
  window.addEventListener('resize', () => {
@@ -567,6 +1081,7 @@ class WebShellServer {
567
1081
  function connect() {
568
1082
  ws = new WebSocket('ws://' + location.host);
569
1083
  ws.onopen = () => {
1084
+ console.log('WebSocket connected');
570
1085
  if (selectedClientId) {
571
1086
  ws.send(JSON.stringify({
572
1087
  type: 'resize', clientId: selectedClientId,
@@ -574,6 +1089,9 @@ class WebShellServer {
574
1089
  }));
575
1090
  }
576
1091
  };
1092
+ ws.onerror = (e) => {
1093
+ console.error('WebSocket error:', e);
1094
+ };
577
1095
  ws.onmessage = e => {
578
1096
  const d = JSON.parse(e.data);
579
1097
  switch (d.type) {
@@ -598,7 +1116,7 @@ class WebShellServer {
598
1116
  selectedClientId = null;
599
1117
  term.clear();
600
1118
  document.getElementById('header-title').innerHTML =
601
- '<span class="no-client">已断开</span>';
1119
+ '<span class="no-client">Disconnected</span>';
602
1120
  if (clients.length > 0) selectClient(clients[0].id);
603
1121
  }
604
1122
  break;
@@ -609,7 +1127,15 @@ class WebShellServer {
609
1127
  break;
610
1128
  }
611
1129
  };
612
- ws.onclose = () => setTimeout(connect, 3000);
1130
+ ws.onclose = (e) => {
1131
+ // Don't reconnect if unauthorized (code 1008)
1132
+ if (e.code === 1008) {
1133
+ console.log('WebSocket closed: unauthorized, redirecting to login');
1134
+ location.reload();
1135
+ return;
1136
+ }
1137
+ setTimeout(connect, 3000);
1138
+ };
613
1139
  }
614
1140
 
615
1141
  init();
@@ -619,7 +1145,13 @@ class WebShellServer {
619
1145
  }
620
1146
  }
621
1147
 
622
- new WebShellServer(PORT);
1148
+ new TermitesServer(PORT);
623
1149
 
624
1150
  process.on('SIGINT', () => process.exit(0));
625
- process.on('SIGTERM', () => process.exit(0));
1151
+ process.on('SIGTERM', () => process.exit(0));
1152
+ process.on('exit', () => {
1153
+ // Clean up local client on exit
1154
+ if (global.localClient) {
1155
+ global.localClient.kill();
1156
+ }
1157
+ });