lit-shell.js 0.1.4 → 1.2.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/CHANGELOG.md +69 -0
- package/README.md +189 -62
- package/dist/client/browser-bundle.js +301 -5
- package/dist/client/browser-bundle.js.map +3 -3
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/terminal-client.d.ts +97 -3
- package/dist/client/terminal-client.d.ts.map +1 -1
- package/dist/client/terminal-client.js +298 -4
- package/dist/client/terminal-client.js.map +1 -1
- package/dist/server/circular-buffer.d.ts +55 -0
- package/dist/server/circular-buffer.d.ts.map +1 -0
- package/dist/server/circular-buffer.js +91 -0
- package/dist/server/circular-buffer.js.map +1 -0
- package/dist/server/index.d.ts +5 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/session-manager.d.ts +201 -0
- package/dist/server/session-manager.d.ts.map +1 -0
- package/dist/server/session-manager.js +458 -0
- package/dist/server/session-manager.js.map +1 -0
- package/dist/server/terminal-server.d.ts +75 -5
- package/dist/server/terminal-server.d.ts.map +1 -1
- package/dist/server/terminal-server.js +515 -79
- package/dist/server/terminal-server.js.map +1 -1
- package/dist/shared/types.d.ts +185 -2
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/ui/browser-bundle.js +1853 -88
- package/dist/ui/browser-bundle.js.map +4 -4
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/lit-shell-terminal.d.ts +225 -6
- package/dist/ui/lit-shell-terminal.d.ts.map +1 -1
- package/dist/ui/lit-shell-terminal.js +1605 -60
- package/dist/ui/lit-shell-terminal.js.map +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/styles.js +22 -0
- package/dist/ui/styles.js.map +1 -1
- package/dist/version.d.ts +6 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/package.json +9 -4
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Server-side terminal handler using node-pty
|
|
3
3
|
*
|
|
4
4
|
* Manages PTY sessions and WebSocket connections for web-based terminals.
|
|
5
|
+
* Supports session multiplexing - multiple clients can connect to the same session.
|
|
5
6
|
*/
|
|
6
7
|
import { WebSocketServer } from 'ws';
|
|
7
8
|
import * as path from 'path';
|
|
8
9
|
import * as os from 'os';
|
|
10
|
+
import { exec } from 'child_process';
|
|
11
|
+
import { SessionManager } from './session-manager.js';
|
|
9
12
|
/**
|
|
10
13
|
* Get platform default shell
|
|
11
14
|
*/
|
|
@@ -16,14 +19,14 @@ function getDefaultShell() {
|
|
|
16
19
|
return process.env.SHELL || '/bin/bash';
|
|
17
20
|
}
|
|
18
21
|
/**
|
|
19
|
-
* Terminal server class
|
|
22
|
+
* Terminal server class with session multiplexing support
|
|
20
23
|
*/
|
|
21
24
|
export class TerminalServer {
|
|
22
25
|
constructor(options = {}) {
|
|
23
|
-
this.sessions = new Map();
|
|
24
26
|
this.wss = null;
|
|
25
27
|
this.pty = null;
|
|
26
28
|
this.cleanupInterval = null;
|
|
29
|
+
this.clientIds = new WeakMap();
|
|
27
30
|
this.config = {
|
|
28
31
|
allowedShells: options.allowedShells || [getDefaultShell()],
|
|
29
32
|
allowedPaths: options.allowedPaths || [os.homedir()],
|
|
@@ -33,10 +36,51 @@ export class TerminalServer {
|
|
|
33
36
|
idleTimeout: options.idleTimeout || 30 * 60 * 1000, // 30 minutes
|
|
34
37
|
path: options.path || '/terminal',
|
|
35
38
|
verbose: options.verbose || false,
|
|
39
|
+
// Docker options
|
|
40
|
+
allowDockerExec: options.allowDockerExec || false,
|
|
41
|
+
allowedContainerPatterns: options.allowedContainerPatterns || [],
|
|
42
|
+
defaultContainerShell: options.defaultContainerShell || '/bin/bash',
|
|
43
|
+
dockerPath: options.dockerPath || 'docker',
|
|
44
|
+
// Multiplexing options
|
|
45
|
+
maxClientsPerSession: options.maxClientsPerSession || 10,
|
|
46
|
+
orphanTimeout: options.orphanTimeout || 60000,
|
|
47
|
+
historySize: options.historySize || 50000,
|
|
48
|
+
historyEnabled: options.historyEnabled ?? true,
|
|
49
|
+
maxSessionsTotal: options.maxSessionsTotal || 100,
|
|
36
50
|
};
|
|
37
|
-
//
|
|
51
|
+
// Initialize session manager
|
|
52
|
+
this.sessionManager = new SessionManager({
|
|
53
|
+
maxClientsPerSession: this.config.maxClientsPerSession,
|
|
54
|
+
orphanTimeout: this.config.orphanTimeout,
|
|
55
|
+
historySize: this.config.historySize,
|
|
56
|
+
historyEnabled: this.config.historyEnabled,
|
|
57
|
+
maxSessionsTotal: this.config.maxSessionsTotal,
|
|
58
|
+
verbose: this.config.verbose,
|
|
59
|
+
});
|
|
60
|
+
// Handle session manager events
|
|
61
|
+
this.sessionManager.on('sessionClosed', (sessionId, reason) => {
|
|
62
|
+
this.log(`Session ${sessionId} closed: ${reason}`);
|
|
63
|
+
});
|
|
64
|
+
// Start cleanup interval for idle sessions
|
|
38
65
|
this.cleanupInterval = setInterval(() => this.cleanupSessions(), 60000);
|
|
39
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Generate a unique client ID
|
|
69
|
+
*/
|
|
70
|
+
generateClientId() {
|
|
71
|
+
return `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get or create client ID for a WebSocket
|
|
75
|
+
*/
|
|
76
|
+
getClientId(ws) {
|
|
77
|
+
let clientId = this.clientIds.get(ws);
|
|
78
|
+
if (!clientId) {
|
|
79
|
+
clientId = this.generateClientId();
|
|
80
|
+
this.clientIds.set(ws, clientId);
|
|
81
|
+
}
|
|
82
|
+
return clientId;
|
|
83
|
+
}
|
|
40
84
|
/**
|
|
41
85
|
* Initialize node-pty (lazy load)
|
|
42
86
|
*/
|
|
@@ -86,6 +130,9 @@ export class TerminalServer {
|
|
|
86
130
|
* Can be called directly for manual WebSocket upgrade handling
|
|
87
131
|
*/
|
|
88
132
|
async handleConnection(ws, req) {
|
|
133
|
+
// Generate client ID
|
|
134
|
+
const clientId = this.getClientId(ws);
|
|
135
|
+
this.log(`Assigned client ID: ${clientId}`);
|
|
89
136
|
// Load node-pty
|
|
90
137
|
try {
|
|
91
138
|
await this.initPty();
|
|
@@ -95,40 +142,41 @@ export class TerminalServer {
|
|
|
95
142
|
ws.close();
|
|
96
143
|
return;
|
|
97
144
|
}
|
|
145
|
+
// Send server info to client
|
|
146
|
+
this.sendServerInfo(ws);
|
|
98
147
|
// Handle messages
|
|
99
148
|
ws.on('message', (data) => {
|
|
100
149
|
try {
|
|
101
150
|
const message = JSON.parse(data.toString());
|
|
102
|
-
this.handleMessage(ws, message);
|
|
151
|
+
this.handleMessage(ws, clientId, message);
|
|
103
152
|
}
|
|
104
153
|
catch (error) {
|
|
105
154
|
this.log(`Invalid message: ${error}`, 'error');
|
|
106
155
|
}
|
|
107
156
|
});
|
|
108
157
|
ws.on('close', () => {
|
|
109
|
-
this.log(
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
158
|
+
this.log(`Client ${clientId} disconnected`);
|
|
159
|
+
// Remove client from all sessions (sessions may survive if other clients connected)
|
|
160
|
+
const affectedSessions = this.sessionManager.removeClientFromAllSessions(clientId);
|
|
161
|
+
if (affectedSessions.length > 0) {
|
|
162
|
+
this.log(`Removed client from sessions: ${affectedSessions.join(', ')}`);
|
|
115
163
|
}
|
|
116
164
|
});
|
|
117
165
|
ws.on('error', (error) => {
|
|
118
|
-
this.log(`WebSocket error: ${error.message}`, 'error');
|
|
166
|
+
this.log(`WebSocket error for client ${clientId}: ${error.message}`, 'error');
|
|
119
167
|
});
|
|
120
168
|
}
|
|
121
169
|
/**
|
|
122
170
|
* Handle message from client
|
|
123
171
|
*/
|
|
124
|
-
handleMessage(ws, message) {
|
|
172
|
+
handleMessage(ws, clientId, message) {
|
|
125
173
|
switch (message.type) {
|
|
126
174
|
case 'spawn':
|
|
127
|
-
this.spawnSession(ws, message.options || {});
|
|
175
|
+
this.spawnSession(ws, clientId, message.options || {});
|
|
128
176
|
break;
|
|
129
177
|
case 'data':
|
|
130
178
|
if (message.sessionId) {
|
|
131
|
-
this.writeToSession(message.sessionId, message.data);
|
|
179
|
+
this.writeToSession(message.sessionId, clientId, message.data);
|
|
132
180
|
}
|
|
133
181
|
break;
|
|
134
182
|
case 'resize':
|
|
@@ -138,13 +186,118 @@ export class TerminalServer {
|
|
|
138
186
|
break;
|
|
139
187
|
case 'close':
|
|
140
188
|
if (message.sessionId) {
|
|
141
|
-
this.closeSession(message.sessionId);
|
|
189
|
+
this.closeSession(message.sessionId, clientId);
|
|
142
190
|
}
|
|
143
191
|
break;
|
|
192
|
+
case 'listContainers':
|
|
193
|
+
this.listContainers(ws);
|
|
194
|
+
break;
|
|
195
|
+
// Session multiplexing messages
|
|
196
|
+
case 'listSessions':
|
|
197
|
+
this.handleListSessions(ws, message.filter);
|
|
198
|
+
break;
|
|
199
|
+
case 'join':
|
|
200
|
+
this.handleJoinSession(ws, clientId, message.options);
|
|
201
|
+
break;
|
|
202
|
+
case 'leave':
|
|
203
|
+
this.handleLeaveSession(ws, clientId, message.sessionId);
|
|
204
|
+
break;
|
|
144
205
|
default:
|
|
145
206
|
this.log(`Unknown message type: ${message.type}`, 'warn');
|
|
146
207
|
}
|
|
147
208
|
}
|
|
209
|
+
// ===========================================================================
|
|
210
|
+
// Session Multiplexing Handlers
|
|
211
|
+
// ===========================================================================
|
|
212
|
+
/**
|
|
213
|
+
* Handle list sessions request
|
|
214
|
+
*/
|
|
215
|
+
handleListSessions(ws, filter) {
|
|
216
|
+
const sessions = this.sessionManager.getSessions(filter);
|
|
217
|
+
const sessionInfos = sessions.map((s) => this.sessionManager.toSharedSessionInfo(s));
|
|
218
|
+
this.log(`Listed ${sessions.length} sessions: ${sessions.map(s => s.id).join(', ')}`);
|
|
219
|
+
ws.send(JSON.stringify({
|
|
220
|
+
type: 'sessionList',
|
|
221
|
+
sessions: sessionInfos,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Handle join session request
|
|
226
|
+
*/
|
|
227
|
+
handleJoinSession(ws, clientId, options) {
|
|
228
|
+
this.log(`Client ${clientId} attempting to join session: ${options.sessionId}`);
|
|
229
|
+
const session = this.sessionManager.getSession(options.sessionId);
|
|
230
|
+
if (!session) {
|
|
231
|
+
const allSessions = this.sessionManager.getSessions();
|
|
232
|
+
this.log(`Session not found: ${options.sessionId}. Available sessions: ${allSessions.map(s => s.id).join(', ') || 'none'}`, 'warn');
|
|
233
|
+
this.sendError(ws, `Session not found: ${options.sessionId}`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!session.accepting) {
|
|
237
|
+
this.sendError(ws, `Session is not accepting new clients`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Add client to session
|
|
241
|
+
const success = this.sessionManager.addClient(options.sessionId, clientId, ws);
|
|
242
|
+
if (!success) {
|
|
243
|
+
this.sendError(ws, `Failed to join session: ${options.sessionId}`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Get history if requested
|
|
247
|
+
let history;
|
|
248
|
+
if (options.requestHistory && session.historyEnabled) {
|
|
249
|
+
history = this.sessionManager.getHistory(options.sessionId, options.historyLimit);
|
|
250
|
+
}
|
|
251
|
+
// Send joined response
|
|
252
|
+
ws.send(JSON.stringify({
|
|
253
|
+
type: 'joined',
|
|
254
|
+
sessionId: options.sessionId,
|
|
255
|
+
session: this.sessionManager.toSharedSessionInfo(session),
|
|
256
|
+
history,
|
|
257
|
+
}));
|
|
258
|
+
// Broadcast to other clients
|
|
259
|
+
this.sessionManager.broadcastToSession(options.sessionId, {
|
|
260
|
+
type: 'clientJoined',
|
|
261
|
+
sessionId: options.sessionId,
|
|
262
|
+
clientCount: session.clients.size,
|
|
263
|
+
}, clientId);
|
|
264
|
+
// Send a newline to trigger a fresh prompt for the joining client
|
|
265
|
+
// This ensures the prompt is visible immediately after joining
|
|
266
|
+
if (session.pty) {
|
|
267
|
+
session.pty.write('\n');
|
|
268
|
+
}
|
|
269
|
+
this.log(`Client ${clientId} joined session ${options.sessionId}`);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Handle leave session request
|
|
273
|
+
*/
|
|
274
|
+
handleLeaveSession(ws, clientId, sessionId) {
|
|
275
|
+
const session = this.sessionManager.getSession(sessionId);
|
|
276
|
+
if (!session) {
|
|
277
|
+
this.sendError(ws, `Session not found: ${sessionId}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Remove client from session
|
|
281
|
+
this.sessionManager.removeClient(sessionId, clientId);
|
|
282
|
+
// Send left response
|
|
283
|
+
ws.send(JSON.stringify({
|
|
284
|
+
type: 'left',
|
|
285
|
+
sessionId,
|
|
286
|
+
}));
|
|
287
|
+
// Broadcast to remaining clients
|
|
288
|
+
if (this.sessionManager.hasSession(sessionId)) {
|
|
289
|
+
const updatedSession = this.sessionManager.getSession(sessionId);
|
|
290
|
+
this.sessionManager.broadcastToSession(sessionId, {
|
|
291
|
+
type: 'clientLeft',
|
|
292
|
+
sessionId,
|
|
293
|
+
clientCount: updatedSession.clients.size,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
this.log(`Client ${clientId} left session ${sessionId}`);
|
|
297
|
+
}
|
|
298
|
+
// ===========================================================================
|
|
299
|
+
// Validation Methods
|
|
300
|
+
// ===========================================================================
|
|
148
301
|
/**
|
|
149
302
|
* Validate shell path
|
|
150
303
|
*/
|
|
@@ -170,24 +323,200 @@ export class TerminalServer {
|
|
|
170
323
|
return this.config.allowedPaths.some((allowedPath) => normalizedCwd.startsWith(path.normalize(path.resolve(allowedPath))));
|
|
171
324
|
}
|
|
172
325
|
/**
|
|
173
|
-
*
|
|
326
|
+
* Validate container name/ID against allowed patterns
|
|
174
327
|
*/
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
328
|
+
isContainerAllowed(container) {
|
|
329
|
+
// Docker exec must be enabled
|
|
330
|
+
if (!this.config.allowDockerExec)
|
|
331
|
+
return false;
|
|
332
|
+
// If no patterns specified, all containers allowed (when Docker exec is enabled)
|
|
333
|
+
if (this.config.allowedContainerPatterns.length === 0)
|
|
334
|
+
return true;
|
|
335
|
+
// Check against allowed patterns
|
|
336
|
+
return this.config.allowedContainerPatterns.some((pattern) => {
|
|
337
|
+
try {
|
|
338
|
+
const regex = new RegExp(pattern);
|
|
339
|
+
return regex.test(container);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// If pattern is invalid regex, treat as literal string match
|
|
343
|
+
return container === pattern || container.startsWith(pattern);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
// ===========================================================================
|
|
348
|
+
// Session Spawning
|
|
349
|
+
// ===========================================================================
|
|
350
|
+
/**
|
|
351
|
+
* Spawn a Docker exec session
|
|
352
|
+
*/
|
|
353
|
+
spawnDockerExecSession(ws, clientId, options, sessionId) {
|
|
354
|
+
const container = options.container;
|
|
355
|
+
const shell = options.containerShell || this.config.defaultContainerShell;
|
|
178
356
|
const cols = options.cols || 80;
|
|
179
357
|
const rows = options.rows || 24;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
358
|
+
// Build docker exec args
|
|
359
|
+
const args = ['exec', '-it'];
|
|
360
|
+
// Add user if specified
|
|
361
|
+
if (options.containerUser) {
|
|
362
|
+
args.push('-u', options.containerUser);
|
|
363
|
+
}
|
|
364
|
+
// Add working directory if specified
|
|
365
|
+
if (options.containerCwd) {
|
|
366
|
+
args.push('-w', options.containerCwd);
|
|
367
|
+
}
|
|
368
|
+
// Add environment variables
|
|
369
|
+
if (options.env) {
|
|
370
|
+
for (const [key, value] of Object.entries(options.env)) {
|
|
371
|
+
args.push('-e', `${key}=${value}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Add container
|
|
375
|
+
args.push(container);
|
|
376
|
+
// Check if tmux mode is enabled
|
|
377
|
+
if (options.useTmux) {
|
|
378
|
+
// Use tmux for persistent session
|
|
379
|
+
// tmux new-session -A -s <name> means "attach if exists, create if not"
|
|
380
|
+
const tmuxSessionName = options.tmuxSession || `ls-${sessionId.substring(0, 8)}`;
|
|
381
|
+
args.push('tmux', 'new-session', '-A', '-s', tmuxSessionName);
|
|
382
|
+
this.log(`Spawning Docker exec with tmux: ${this.config.dockerPath} ${args.join(' ')}`);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
// Regular shell
|
|
386
|
+
args.push(shell);
|
|
387
|
+
this.log(`Spawning Docker exec: ${this.config.dockerPath} ${args.join(' ')}`);
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
// Spawn PTY with docker exec
|
|
391
|
+
const ptyProcess = this.pty.spawn(this.config.dockerPath, args, {
|
|
392
|
+
name: 'xterm-256color',
|
|
393
|
+
cols,
|
|
394
|
+
rows,
|
|
395
|
+
env: process.env,
|
|
396
|
+
});
|
|
397
|
+
// Create session via SessionManager
|
|
398
|
+
const session = this.sessionManager.createSession({
|
|
399
|
+
id: sessionId,
|
|
400
|
+
type: 'docker-exec',
|
|
401
|
+
pty: ptyProcess,
|
|
402
|
+
shell: options.useTmux ? 'tmux' : shell,
|
|
403
|
+
cwd: options.containerCwd || '/',
|
|
404
|
+
cols,
|
|
405
|
+
rows,
|
|
406
|
+
ownerId: clientId,
|
|
407
|
+
ownerWs: ws,
|
|
408
|
+
container,
|
|
409
|
+
label: options.label,
|
|
410
|
+
allowJoin: options.allowJoin,
|
|
411
|
+
enableHistory: options.enableHistory,
|
|
412
|
+
orphanTimeout: options.orphanTimeout,
|
|
413
|
+
useTmux: options.useTmux,
|
|
414
|
+
});
|
|
415
|
+
return session;
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
this.log(`Failed to spawn Docker exec: ${error}`, 'error');
|
|
419
|
+
this.sendError(ws, `Failed to exec into container: ${error.message}`, sessionId);
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Spawn a Docker attach session (connects to container's main process)
|
|
425
|
+
*/
|
|
426
|
+
spawnDockerAttachSession(ws, clientId, options, sessionId) {
|
|
427
|
+
const container = options.container;
|
|
428
|
+
const cols = options.cols || 80;
|
|
429
|
+
const rows = options.rows || 24;
|
|
430
|
+
// Build docker attach args
|
|
431
|
+
// --sig-proxy=false prevents signals from being proxied to the container
|
|
432
|
+
// --detach-keys allows detaching without killing the session
|
|
433
|
+
const args = [
|
|
434
|
+
'attach',
|
|
435
|
+
'--sig-proxy=false',
|
|
436
|
+
'--detach-keys=ctrl-p,ctrl-q',
|
|
437
|
+
container,
|
|
438
|
+
];
|
|
439
|
+
this.log(`Spawning Docker attach: ${this.config.dockerPath} ${args.join(' ')}`);
|
|
440
|
+
try {
|
|
441
|
+
// Spawn PTY with docker attach
|
|
442
|
+
const ptyProcess = this.pty.spawn(this.config.dockerPath, args, {
|
|
443
|
+
name: 'xterm-256color',
|
|
444
|
+
cols,
|
|
445
|
+
rows,
|
|
446
|
+
env: process.env,
|
|
447
|
+
});
|
|
448
|
+
// Create session via SessionManager
|
|
449
|
+
const session = this.sessionManager.createSession({
|
|
450
|
+
id: sessionId,
|
|
451
|
+
type: 'docker-attach',
|
|
452
|
+
pty: ptyProcess,
|
|
453
|
+
shell: 'attach',
|
|
454
|
+
cwd: '/',
|
|
455
|
+
cols,
|
|
456
|
+
rows,
|
|
457
|
+
ownerId: clientId,
|
|
458
|
+
ownerWs: ws,
|
|
459
|
+
container,
|
|
460
|
+
label: options.label,
|
|
461
|
+
allowJoin: options.allowJoin,
|
|
462
|
+
enableHistory: options.enableHistory,
|
|
463
|
+
});
|
|
464
|
+
return session;
|
|
186
465
|
}
|
|
187
|
-
|
|
466
|
+
catch (error) {
|
|
467
|
+
this.log(`Failed to spawn Docker attach: ${error}`, 'error');
|
|
468
|
+
this.sendError(ws, `Failed to attach to container: ${error.message}`, sessionId);
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Spawn a new terminal session
|
|
474
|
+
*/
|
|
475
|
+
spawnSession(ws, clientId, options) {
|
|
476
|
+
// Check client session limit
|
|
477
|
+
const clientSessions = this.sessionManager.getClientSessions(clientId);
|
|
478
|
+
if (clientSessions.length >= this.config.maxSessionsPerClient) {
|
|
188
479
|
this.sendError(ws, `Maximum sessions (${this.config.maxSessionsPerClient}) reached`);
|
|
189
480
|
return;
|
|
190
481
|
}
|
|
482
|
+
const sessionId = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
483
|
+
// Check if this is a Docker request
|
|
484
|
+
if (options.container) {
|
|
485
|
+
// Validate container access
|
|
486
|
+
if (!this.isContainerAllowed(options.container)) {
|
|
487
|
+
this.sendError(ws, `Container access not allowed: ${options.container}. Docker exec ${this.config.allowDockerExec ? 'is enabled but container pattern not matched' : 'is disabled'}.`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
let session;
|
|
491
|
+
// Check if attach mode requested
|
|
492
|
+
if (options.attachMode) {
|
|
493
|
+
session = this.spawnDockerAttachSession(ws, clientId, options, sessionId);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
session = this.spawnDockerExecSession(ws, clientId, options, sessionId);
|
|
497
|
+
}
|
|
498
|
+
if (!session)
|
|
499
|
+
return;
|
|
500
|
+
this.setupSessionHandlers(session);
|
|
501
|
+
// Notify client
|
|
502
|
+
ws.send(JSON.stringify({
|
|
503
|
+
type: 'spawned',
|
|
504
|
+
sessionId,
|
|
505
|
+
shell: session.shell,
|
|
506
|
+
cwd: session.cwd,
|
|
507
|
+
cols: session.cols,
|
|
508
|
+
rows: session.rows,
|
|
509
|
+
container: session.container,
|
|
510
|
+
}));
|
|
511
|
+
this.log(`Docker ${options.attachMode ? 'attach' : 'exec'} session spawned: ${sessionId} (container: ${session.container})`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// Regular local shell session
|
|
515
|
+
const shell = options.shell || this.config.defaultShell;
|
|
516
|
+
const cwd = options.cwd || this.config.defaultCwd;
|
|
517
|
+
const cols = options.cols || 80;
|
|
518
|
+
const rows = options.rows || 24;
|
|
519
|
+
const env = options.env || {};
|
|
191
520
|
// Validate shell
|
|
192
521
|
if (!this.isShellAllowed(shell)) {
|
|
193
522
|
this.sendError(ws, `Shell not allowed: ${shell}. Allowed: ${this.config.allowedShells.join(', ')}`);
|
|
@@ -207,40 +536,23 @@ export class TerminalServer {
|
|
|
207
536
|
cwd,
|
|
208
537
|
env: { ...process.env, ...env },
|
|
209
538
|
});
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
// Store session
|
|
213
|
-
const session = {
|
|
539
|
+
// Create session via SessionManager
|
|
540
|
+
const session = this.sessionManager.createSession({
|
|
214
541
|
id: sessionId,
|
|
542
|
+
type: 'local',
|
|
215
543
|
pty: ptyProcess,
|
|
216
|
-
ws,
|
|
217
544
|
shell,
|
|
218
545
|
cwd,
|
|
219
546
|
cols,
|
|
220
547
|
rows,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
session.lastActivity = new Date();
|
|
228
|
-
ws.send(JSON.stringify({
|
|
229
|
-
type: 'data',
|
|
230
|
-
sessionId,
|
|
231
|
-
data,
|
|
232
|
-
}));
|
|
233
|
-
});
|
|
234
|
-
// Handle PTY exit
|
|
235
|
-
ptyProcess.onExit(({ exitCode }) => {
|
|
236
|
-
ws.send(JSON.stringify({
|
|
237
|
-
type: 'exit',
|
|
238
|
-
sessionId,
|
|
239
|
-
exitCode,
|
|
240
|
-
}));
|
|
241
|
-
this.sessions.delete(sessionId);
|
|
242
|
-
this.log(`Session exited: ${sessionId} (code: ${exitCode})`);
|
|
548
|
+
ownerId: clientId,
|
|
549
|
+
ownerWs: ws,
|
|
550
|
+
label: options.label,
|
|
551
|
+
allowJoin: options.allowJoin,
|
|
552
|
+
enableHistory: options.enableHistory,
|
|
553
|
+
orphanTimeout: options.orphanTimeout,
|
|
243
554
|
});
|
|
555
|
+
this.setupSessionHandlers(session);
|
|
244
556
|
// Notify client
|
|
245
557
|
ws.send(JSON.stringify({
|
|
246
558
|
type: 'spawned',
|
|
@@ -257,23 +569,65 @@ export class TerminalServer {
|
|
|
257
569
|
this.sendError(ws, error.message);
|
|
258
570
|
}
|
|
259
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Setup PTY event handlers for a session
|
|
574
|
+
*/
|
|
575
|
+
setupSessionHandlers(session) {
|
|
576
|
+
const sessionId = session.id;
|
|
577
|
+
// Handle PTY output
|
|
578
|
+
session.pty.onData((data) => {
|
|
579
|
+
// Update activity
|
|
580
|
+
this.sessionManager.updateSessionActivity(sessionId);
|
|
581
|
+
// Store in history buffer
|
|
582
|
+
this.sessionManager.appendHistory(sessionId, data);
|
|
583
|
+
// Broadcast to all connected clients
|
|
584
|
+
this.sessionManager.broadcastToSession(sessionId, {
|
|
585
|
+
type: 'data',
|
|
586
|
+
sessionId,
|
|
587
|
+
data,
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
// Handle PTY exit
|
|
591
|
+
session.pty.onExit(({ exitCode }) => {
|
|
592
|
+
// Broadcast exit to all clients
|
|
593
|
+
this.sessionManager.broadcastToSession(sessionId, {
|
|
594
|
+
type: 'exit',
|
|
595
|
+
sessionId,
|
|
596
|
+
exitCode,
|
|
597
|
+
});
|
|
598
|
+
// Also broadcast session closed
|
|
599
|
+
this.sessionManager.broadcastToSession(sessionId, {
|
|
600
|
+
type: 'sessionClosed',
|
|
601
|
+
sessionId,
|
|
602
|
+
reason: 'process_exit',
|
|
603
|
+
});
|
|
604
|
+
// Close the session
|
|
605
|
+
this.sessionManager.closeSession(sessionId, 'process_exit');
|
|
606
|
+
this.log(`Session exited: ${sessionId} (code: ${exitCode})`);
|
|
607
|
+
});
|
|
608
|
+
}
|
|
260
609
|
/**
|
|
261
610
|
* Write data to session
|
|
262
611
|
*/
|
|
263
|
-
writeToSession(sessionId, data) {
|
|
264
|
-
const session = this.
|
|
612
|
+
writeToSession(sessionId, clientId, data) {
|
|
613
|
+
const session = this.sessionManager.getSession(sessionId);
|
|
265
614
|
if (!session) {
|
|
266
615
|
this.log(`Session not found: ${sessionId}`, 'warn');
|
|
267
616
|
return;
|
|
268
617
|
}
|
|
269
|
-
|
|
618
|
+
// Verify client is in session
|
|
619
|
+
if (!this.sessionManager.isClientInSession(sessionId, clientId)) {
|
|
620
|
+
this.log(`Client ${clientId} not in session ${sessionId}`, 'warn');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
this.sessionManager.updateClientActivity(sessionId, clientId);
|
|
270
624
|
session.pty.write(data);
|
|
271
625
|
}
|
|
272
626
|
/**
|
|
273
627
|
* Resize session
|
|
274
628
|
*/
|
|
275
629
|
resizeSession(sessionId, cols, rows) {
|
|
276
|
-
const session = this.
|
|
630
|
+
const session = this.sessionManager.getSession(sessionId);
|
|
277
631
|
if (!session) {
|
|
278
632
|
this.log(`Session not found: ${sessionId}`, 'warn');
|
|
279
633
|
return;
|
|
@@ -283,20 +637,28 @@ export class TerminalServer {
|
|
|
283
637
|
session.pty.resize(cols, rows);
|
|
284
638
|
}
|
|
285
639
|
/**
|
|
286
|
-
* Close session
|
|
640
|
+
* Close session (only owner can close, or force close)
|
|
287
641
|
*/
|
|
288
|
-
closeSession(sessionId) {
|
|
289
|
-
const session = this.
|
|
642
|
+
closeSession(sessionId, clientId) {
|
|
643
|
+
const session = this.sessionManager.getSession(sessionId);
|
|
290
644
|
if (!session)
|
|
291
645
|
return;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
this.
|
|
646
|
+
// Check if client is the owner
|
|
647
|
+
if (session.owner !== clientId) {
|
|
648
|
+
this.log(`Client ${clientId} attempted to close session owned by ${session.owner}`, 'warn');
|
|
649
|
+
// Just remove the client from the session instead
|
|
650
|
+
this.sessionManager.removeClient(sessionId, clientId);
|
|
651
|
+
return;
|
|
297
652
|
}
|
|
298
|
-
|
|
299
|
-
this.
|
|
653
|
+
// Broadcast session closed to all clients
|
|
654
|
+
this.sessionManager.broadcastToSession(sessionId, {
|
|
655
|
+
type: 'sessionClosed',
|
|
656
|
+
sessionId,
|
|
657
|
+
reason: 'owner_closed',
|
|
658
|
+
});
|
|
659
|
+
// Close the session
|
|
660
|
+
this.sessionManager.closeSession(sessionId, 'owner_closed');
|
|
661
|
+
this.log(`Session closed by owner: ${sessionId}`);
|
|
300
662
|
}
|
|
301
663
|
/**
|
|
302
664
|
* Clean up inactive sessions
|
|
@@ -305,16 +667,17 @@ export class TerminalServer {
|
|
|
305
667
|
if (this.config.idleTimeout === 0)
|
|
306
668
|
return;
|
|
307
669
|
const now = Date.now();
|
|
308
|
-
for (const
|
|
670
|
+
for (const session of this.sessionManager.getSessions()) {
|
|
309
671
|
const idleTime = now - session.lastActivity.getTime();
|
|
310
672
|
if (idleTime > this.config.idleTimeout) {
|
|
311
|
-
this.log(`Closing inactive session: ${
|
|
312
|
-
|
|
673
|
+
this.log(`Closing inactive session: ${session.id}`);
|
|
674
|
+
// Broadcast to all clients
|
|
675
|
+
this.sessionManager.broadcastToSession(session.id, {
|
|
313
676
|
type: 'exit',
|
|
314
|
-
sessionId,
|
|
677
|
+
sessionId: session.id,
|
|
315
678
|
exitCode: -1,
|
|
316
|
-
})
|
|
317
|
-
this.closeSession(
|
|
679
|
+
});
|
|
680
|
+
this.sessionManager.closeSession(session.id, 'idle_timeout');
|
|
318
681
|
}
|
|
319
682
|
}
|
|
320
683
|
}
|
|
@@ -347,18 +710,93 @@ export class TerminalServer {
|
|
|
347
710
|
}
|
|
348
711
|
}
|
|
349
712
|
/**
|
|
350
|
-
*
|
|
713
|
+
* Send server info to client
|
|
714
|
+
*/
|
|
715
|
+
sendServerInfo(ws) {
|
|
716
|
+
const info = {
|
|
717
|
+
dockerEnabled: this.config.allowDockerExec,
|
|
718
|
+
allowedShells: this.config.allowedShells,
|
|
719
|
+
defaultShell: this.config.defaultShell,
|
|
720
|
+
defaultContainerShell: this.config.defaultContainerShell,
|
|
721
|
+
};
|
|
722
|
+
ws.send(JSON.stringify({
|
|
723
|
+
type: 'serverInfo',
|
|
724
|
+
info,
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* List available Docker containers
|
|
729
|
+
*/
|
|
730
|
+
listContainers(ws) {
|
|
731
|
+
if (!this.config.allowDockerExec) {
|
|
732
|
+
ws.send(JSON.stringify({
|
|
733
|
+
type: 'containerList',
|
|
734
|
+
containers: [],
|
|
735
|
+
}));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// Run docker ps to get running containers
|
|
739
|
+
exec(`${this.config.dockerPath} ps --format "{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.State}}"`, (error, stdout, stderr) => {
|
|
740
|
+
if (error) {
|
|
741
|
+
this.log(`Failed to list containers: ${error.message}`, 'error');
|
|
742
|
+
ws.send(JSON.stringify({
|
|
743
|
+
type: 'containerList',
|
|
744
|
+
containers: [],
|
|
745
|
+
}));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const containers = [];
|
|
749
|
+
const lines = stdout.trim().split('\n').filter((line) => line.trim());
|
|
750
|
+
for (const line of lines) {
|
|
751
|
+
const [id, name, image, status, state] = line.split('\t');
|
|
752
|
+
if (!id)
|
|
753
|
+
continue;
|
|
754
|
+
// Check if container matches allowed patterns
|
|
755
|
+
if (this.isContainerAllowed(name) || this.isContainerAllowed(id)) {
|
|
756
|
+
containers.push({
|
|
757
|
+
id,
|
|
758
|
+
name,
|
|
759
|
+
image,
|
|
760
|
+
status,
|
|
761
|
+
state: state || 'unknown',
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
ws.send(JSON.stringify({
|
|
766
|
+
type: 'containerList',
|
|
767
|
+
containers,
|
|
768
|
+
}));
|
|
769
|
+
this.log(`Listed ${containers.length} containers`);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Get all active sessions (for external access)
|
|
351
774
|
*/
|
|
352
775
|
getSessions() {
|
|
353
|
-
return
|
|
776
|
+
return this.sessionManager.getSessions().map((session) => ({
|
|
354
777
|
sessionId: session.id,
|
|
355
778
|
shell: session.shell,
|
|
356
779
|
cwd: session.cwd,
|
|
357
780
|
cols: session.cols,
|
|
358
781
|
rows: session.rows,
|
|
359
782
|
createdAt: session.createdAt,
|
|
783
|
+
container: session.container,
|
|
360
784
|
}));
|
|
361
785
|
}
|
|
786
|
+
/**
|
|
787
|
+
* Get all active sessions with multiplexing info
|
|
788
|
+
*/
|
|
789
|
+
getSharedSessions(filter) {
|
|
790
|
+
return this.sessionManager
|
|
791
|
+
.getSessions(filter)
|
|
792
|
+
.map((s) => this.sessionManager.toSharedSessionInfo(s));
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Get session manager statistics
|
|
796
|
+
*/
|
|
797
|
+
getStats() {
|
|
798
|
+
return this.sessionManager.getStats();
|
|
799
|
+
}
|
|
362
800
|
/**
|
|
363
801
|
* Close all sessions and stop server
|
|
364
802
|
*/
|
|
@@ -368,10 +806,8 @@ export class TerminalServer {
|
|
|
368
806
|
clearInterval(this.cleanupInterval);
|
|
369
807
|
this.cleanupInterval = null;
|
|
370
808
|
}
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
this.closeSession(sessionId);
|
|
374
|
-
}
|
|
809
|
+
// Cleanup session manager
|
|
810
|
+
this.sessionManager.cleanup();
|
|
375
811
|
// Close WebSocket server
|
|
376
812
|
if (this.wss) {
|
|
377
813
|
this.wss.close();
|