import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' import { EventEmitter } from 'node:events' import { userInfo } from 'node:os' import { resolve } from 'node:path' import { StringDecoder } from 'node:string_decoder' import { WebSocketServer } from 'ws' import { OUTBOUND_PROXY_ENVIRONMENT_KEYS } from '../../../../platform/lib/outbound-proxy.mjs' const SESSION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ const MAX_INPUT_BYTES = 64 * 1024 const MAX_OUTPUT_BYTES = 256 * 1024 const MAX_SOCKET_BUFFER_BYTES = 1024 * 1024 const MAX_HELPER_INPUT_BUFFER_BYTES = 256 * 1024 const MAX_SESSIONS = 32 const MIN_COLS = 2 const MAX_COLS = 500 const MIN_ROWS = 1 const MAX_ROWS = 200 function dimensions(value = {}) { if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['cols', 'rows'].includes(key))) throw new Error('Terminal dimensions are invalid') const cols = value.cols ?? 80 const rows = value.rows ?? 24 if (!Number.isSafeInteger(cols) || cols < MIN_COLS || cols > MAX_COLS || !Number.isSafeInteger(rows) || rows < MIN_ROWS || rows > MAX_ROWS) { throw new Error('Terminal dimensions are outside the supported range') } return { cols, rows } } function exactMessage(value, keys) { return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',') } class TerminalSession extends EventEmitter { constructor({ id, helperPath, cwd, env, cols, rows, reconnectMs, report, python }) { super() this.id = id this.createdAt = new Date().toISOString() this.updatedAt = this.createdAt this.status = 'running' this.exit = null this.output = Buffer.alloc(0) this.connections = new Set() this.everConnected = false this.reconnectMs = reconnectMs this.report = report this.decoder = new StringDecoder('utf8') this.outputDecoder = new StringDecoder('utf8') this.lineBuffer = '' this.closed = false this.terminationTimers = [] this.child = spawn(python, [helperPath, cwd, String(cols), String(rows)], { cwd, env: { ...env, TERM: 'xterm-256color' }, stdio: ['pipe', 'pipe', 'pipe'], }) this.childClosed = new Promise(resolveClosed => this.child.once('close', resolveClosed)) this.child.stdout.on('data', chunk => this.consume(chunk)) this.child.stderr.on('data', chunk => this.report('terminal.helper.stderr', { sessionId: this.id, bytes: chunk.byteLength, level: 'warning', })) this.child.once('error', error => this.fail(error)) this.child.once('exit', (code, signal) => { if (this.status === 'running') this.finish({ code, signal }) }) this.child.once('close', () => { for (const timer of this.terminationTimers) clearTimeout(timer) }) this.scheduleExpiry() } publicStatus() { return Object.freeze({ sessionId: this.id, status: this.status, createdAt: this.createdAt, updatedAt: this.updatedAt, connected: this.connections.size > 0, exit: this.exit, }) } scheduleExpiry() { clearTimeout(this.expiry) this.expiry = setTimeout(() => { if (this.connections.size === 0) this.terminate('reconnect-expired') }, this.reconnectMs) this.expiry.unref() } sendCommand(value) { if (this.status !== 'running' || !this.child.stdin.writable) return false this.child.stdin.write(`${JSON.stringify(value)}\n`) return true } consume(chunk) { this.lineBuffer += this.decoder.write(chunk) for (let newline = this.lineBuffer.indexOf('\n'); newline !== -1; newline = this.lineBuffer.indexOf('\n')) { const line = this.lineBuffer.slice(0, newline) this.lineBuffer = this.lineBuffer.slice(newline + 1) try { const value = JSON.parse(line) if (value.type === 'output' && typeof value.data === 'string') this.broadcastOutput(Buffer.from(value.data, 'base64')) else if (value.type === 'exit') this.finish({ code: value.code ?? null, signal: value.signal ?? null }) else if (value.type === 'error') this.fail(new Error(String(value.message))) else this.fail(new Error('PTY helper emitted an invalid message')) } catch (error) { this.fail(error) } } } broadcastOutput(bytes) { this.output = Buffer.concat([this.output, bytes]) if (this.output.byteLength > MAX_OUTPUT_BYTES) this.output = this.output.subarray(this.output.byteLength - MAX_OUTPUT_BYTES) const data = this.outputDecoder.write(bytes) if (data === '') return const message = JSON.stringify({ type: 'output', data }) for (const socket of this.connections) if (socket.readyState === 1) { if (socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) socket.close(1013, 'terminal client is too slow') else socket.send(message) } } attach(socket) { clearTimeout(this.expiry) const reconnect = this.everConnected this.everConnected = true this.connections.add(socket) void this.report(reconnect ? 'terminal.session.reconnected' : 'terminal.session.connected', { sessionId: this.id }) if (this.output.byteLength > 0) socket.send(JSON.stringify({ type: 'output', data: this.output.toString('utf8') })) if (this.exit !== null) socket.send(JSON.stringify({ type: 'exit', ...this.exit })) socket.on('message', data => this.handleMessage(socket, data)) socket.once('close', () => { this.connections.delete(socket) void this.report('terminal.session.disconnected', { sessionId: this.id }) if (!this.closed && this.connections.size === 0) this.scheduleExpiry() }) socket.once('error', error => this.report('terminal.session.connection.failed', { error, sessionId: this.id })) } handleMessage(socket, data) { try { if (Buffer.byteLength(data) > MAX_INPUT_BYTES + 1024) throw new Error('Terminal message is too large') const value = JSON.parse(data.toString()) if (value.type === 'input') { if (!exactMessage(value, ['type', 'data']) || typeof value.data !== 'string' || Buffer.byteLength(value.data) > MAX_INPUT_BYTES) throw new Error('Terminal input is invalid') if (this.child.stdin.writableLength > MAX_HELPER_INPUT_BUFFER_BYTES) { socket.close(1013, 'terminal input is too fast') return } this.sendCommand({ type: 'input', data: Buffer.from(value.data).toString('base64') }) } else if (value.type === 'resize') { if (!exactMessage(value, ['type', 'cols', 'rows'])) throw new Error('Terminal resize is invalid') this.sendCommand({ type: 'resize', ...dimensions({ cols: value.cols, rows: value.rows }) }) } else throw new Error('Terminal message type is invalid') } catch { socket.close(1008, 'invalid terminal message') } } finish(exit) { if (this.status !== 'running') return this.status = 'exited' this.exit = Object.freeze({ code: exit.code ?? null, signal: exit.signal ?? null }) this.updatedAt = new Date().toISOString() for (const socket of this.connections) if (socket.readyState === 1) { socket.send(JSON.stringify({ type: 'exit', ...this.exit })) } void this.report('terminal.session.exited', { sessionId: this.id, ...this.exit }) if (this.connections.size === 0) this.scheduleExpiry() this.emit('terminal-exit') } fail(error) { if (this.status !== 'running') return void this.report('terminal.session.failed', { error, sessionId: this.id }) this.terminate('failed') } terminate(reason = 'closed') { if (this.closed) return this.closed = true clearTimeout(this.expiry) if (this.status === 'running') { this.sendCommand({ type: 'signal', signal: 15 }) for (const [delay, signal] of [[2_000, 'SIGTERM'], [4_000, 'SIGKILL']]) { const timer = setTimeout(() => this.child.kill(signal), delay) timer.unref() this.terminationTimers.push(timer) } } this.child.stdin.end() for (const socket of this.connections) socket.close(1001, 'terminal session closed') this.connections.clear() void this.report('terminal.session.closed', { reason, sessionId: this.id }) this.emit('terminal-close') } async stop(reason) { this.terminate(reason) let timeout await Promise.race([ this.childClosed, new Promise(resolveTimeout => { timeout = setTimeout(resolveTimeout, 5_000) }), ]) clearTimeout(timeout) } } export class TerminalSessionManager { constructor({ helperPath = new URL('./pty-helper.py', import.meta.url).pathname, cwd = '/root', dshHome = '/data/dsh', env = process.env, python = '/usr/bin/python3', reconnectMs = 30_000, report = async () => {}, } = {}) { this.helperPath = resolve(helperPath) this.cwd = resolve(cwd) this.env = { ...env, HOME: userInfo().homedir, DSH_HOME: dshHome } for (const key of OUTBOUND_PROXY_ENVIRONMENT_KEYS) delete this.env[key] this.python = python this.reconnectMs = reconnectMs this.report = (message, fields) => Promise.resolve().then(() => report(message, fields)).catch(() => {}) this.sessions = new Map() this.webSockets = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_INPUT_BYTES + 1024 }) } create(value = {}, environment = {}) { if (this.sessions.size >= MAX_SESSIONS) { const error = new Error('Terminal session limit reached') error.statusCode = 409 throw error } const { cols, rows } = dimensions(value) const sessionId = randomUUID() const session = new TerminalSession({ id: sessionId, helperPath: this.helperPath, cwd: this.cwd, env: { ...this.env, ...environment }, cols, rows, reconnectMs: this.reconnectMs, report: this.report, python: this.python, }) this.sessions.set(sessionId, session) session.once('terminal-close', () => this.sessions.delete(sessionId)) void this.report('terminal.session.created', { sessionId }) return session.publicStatus() } require(id) { if (!SESSION_PATTERN.test(id) || !this.sessions.has(id)) { const error = new Error('Terminal session was not found') error.statusCode = 404 throw error } return this.sessions.get(id) } status(id) { return this.require(id).publicStatus() } close(id) { const session = this.require(id) session.terminate('deleted') return { sessionId: id, closed: true } } upgrade(request, socket, head, id) { const session = this.require(id) this.webSockets.handleUpgrade(request, socket, head, webSocket => session.attach(webSocket)) } async shutdown() { await Promise.all([...this.sessions.values()].map(session => session.stop('management-stopping'))) this.webSockets.close() } } export const terminalSessionInternals = Object.freeze({ dimensions, SESSION_PATTERN })