termites 1.0.3 → 1.0.5
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 +4 -0
- package/bin/termites.js +20 -5
- package/client.js +12 -12
- package/package.json +1 -1
- package/server.js +561 -29
package/.termites.json
ADDED
package/bin/termites.js
CHANGED
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const pkg = require('../package.json');
|
|
3
4
|
|
|
4
5
|
const args = process.argv.slice(2);
|
|
5
6
|
const command = args[0];
|
|
6
7
|
|
|
8
|
+
if (command === '-v' || command === '--version' || command === 'version') {
|
|
9
|
+
console.log(`termites v${pkg.version}`);
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
function showHelp() {
|
|
8
14
|
console.log(`
|
|
9
15
|
Termites - Web terminal with server-client architecture
|
|
10
16
|
|
|
11
17
|
Usage:
|
|
12
|
-
termites server
|
|
18
|
+
termites server [options] Start the server
|
|
13
19
|
termites client [url] Connect to a server
|
|
14
20
|
|
|
15
|
-
Options:
|
|
21
|
+
Server Options:
|
|
22
|
+
--no-client Don't start local client (default: starts client)
|
|
23
|
+
|
|
24
|
+
Environment:
|
|
16
25
|
PORT=<port> Set server port (default: 6789)
|
|
17
26
|
|
|
18
27
|
Examples:
|
|
19
|
-
termites server Start server
|
|
20
|
-
|
|
28
|
+
termites server Start server with local client
|
|
29
|
+
termites server --no-client Start server only
|
|
30
|
+
PORT=8080 termites server Start on port 8080
|
|
21
31
|
termites client Connect to ws://localhost:6789
|
|
22
32
|
termites client myserver Connect to ws://myserver:6789
|
|
23
|
-
termites client 192.168.1.100
|
|
24
33
|
`);
|
|
25
34
|
}
|
|
26
35
|
|
|
@@ -32,6 +41,12 @@ if (!command || command === '-h' || command === '--help' || command === 'help')
|
|
|
32
41
|
const rootDir = path.join(__dirname, '..');
|
|
33
42
|
|
|
34
43
|
if (command === 'server') {
|
|
44
|
+
const noClient = args.includes('--no-client');
|
|
45
|
+
const startClient = !noClient;
|
|
46
|
+
|
|
47
|
+
// Set environment variable for server to know whether to start client
|
|
48
|
+
process.env.START_CLIENT = startClient ? 'true' : 'false';
|
|
49
|
+
|
|
35
50
|
require(path.join(rootDir, 'server.js'));
|
|
36
51
|
} else if (command === 'client') {
|
|
37
52
|
let serverUrl = args[1] || 'ws://localhost:6789';
|
package/client.js
CHANGED
|
@@ -13,7 +13,7 @@ function getSystemInfo() {
|
|
|
13
13
|
return { username, hostname, cwd, platform };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
class
|
|
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(
|
|
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('
|
|
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
|
|
63
|
+
console.error('WebSocket error:', err.message);
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
scheduleReconnect() {
|
|
68
|
-
console.log(
|
|
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
|
|
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
|
}
|
|
@@ -140,11 +140,11 @@ if (!serverUrl.match(/:\d+/) && !serverUrl.includes('localhost')) {
|
|
|
140
140
|
serverUrl = serverUrl.replace(/^(wss?:\/\/[^\/]+)/, '$1:6789');
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
console.log('
|
|
144
|
-
console.log(
|
|
143
|
+
console.log('Termites Client');
|
|
144
|
+
console.log(`System: ${getSystemInfo().username}@${getSystemInfo().hostname}`);
|
|
145
145
|
console.log(`Shell: ${SHELL}`);
|
|
146
146
|
|
|
147
|
-
new
|
|
147
|
+
new TermitesClient(serverUrl);
|
|
148
148
|
|
|
149
149
|
process.on('SIGINT', () => process.exit(0));
|
|
150
150
|
process.on('SIGTERM', () => process.exit(0));
|
package/package.json
CHANGED
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
|
-
|
|
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(`
|
|
37
|
-
console.log(
|
|
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(
|
|
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('
|
|
149
|
+
console.error('Failed to parse client message:', e);
|
|
59
150
|
}
|
|
60
151
|
});
|
|
61
152
|
|
|
62
153
|
ws.on('close', () => {
|
|
63
|
-
console.log(
|
|
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(
|
|
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(
|
|
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:
|
|
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('
|
|
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
|
-
|
|
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="
|
|
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>
|
|
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>
|
|
653
|
+
<h3>Termites</h3>
|
|
282
654
|
<button class="drawer-close" id="drawer-close">×</button>
|
|
283
655
|
</div>
|
|
284
656
|
<div class="drawer-content">
|
|
285
657
|
<div class="drawer-section">
|
|
286
|
-
<div class="drawer-section-header">⚙
|
|
658
|
+
<div class="drawer-section-header">⚙ Settings</div>
|
|
287
659
|
<div class="settings-section">
|
|
288
660
|
<div class="setting-group">
|
|
289
|
-
<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
|
|
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
|
|
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">◉
|
|
699
|
+
<div class="drawer-section-header">◉ Clients</div>
|
|
320
700
|
<div class="client-list" id="client-list">
|
|
321
|
-
<div class="empty-clients"
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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"
|
|
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 = () =>
|
|
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
|
|
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
|
+
});
|