dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import base64
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import termios
|
||||
|
||||
|
||||
def emit(value):
|
||||
sys.stdout.write(json.dumps(value, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def resize(master, cols, rows):
|
||||
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
|
||||
|
||||
def write_all(target, data):
|
||||
remaining = memoryview(data)
|
||||
while remaining:
|
||||
written = os.write(target, remaining)
|
||||
remaining = remaining[written:]
|
||||
|
||||
|
||||
def terminate(pid, requested_signal):
|
||||
try:
|
||||
os.killpg(pid, requested_signal)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except PermissionError:
|
||||
os.kill(pid, requested_signal)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
raise RuntimeError("usage: pty-helper.py <cwd> <cols> <rows>")
|
||||
cwd = sys.argv[1]
|
||||
cols = int(sys.argv[2])
|
||||
rows = int(sys.argv[3])
|
||||
pid, master = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.umask(0o002)
|
||||
os.execvpe("/bin/bash", ["/bin/bash", "-i"], os.environ)
|
||||
def stop_child(received_signal, _frame):
|
||||
terminate(pid, signal.SIGTERM if received_signal in (signal.SIGINT, signal.SIGTERM) else received_signal)
|
||||
signal.signal(signal.SIGINT, stop_child)
|
||||
signal.signal(signal.SIGTERM, stop_child)
|
||||
resize(master, cols, rows)
|
||||
stdin_fd = sys.stdin.fileno()
|
||||
stdin_buffer = b""
|
||||
stdin_open = True
|
||||
while True:
|
||||
readable, _, _ = select.select([master] + ([stdin_fd] if stdin_open else []), [], [], 0.25)
|
||||
if master in readable:
|
||||
try:
|
||||
data = os.read(master, 16384)
|
||||
except OSError:
|
||||
data = b""
|
||||
if not data:
|
||||
break
|
||||
emit({"type": "output", "data": base64.b64encode(data).decode("ascii")})
|
||||
if stdin_open and stdin_fd in readable:
|
||||
data = os.read(stdin_fd, 65536)
|
||||
if not data:
|
||||
stdin_open = False
|
||||
terminate(pid, signal.SIGHUP)
|
||||
else:
|
||||
stdin_buffer += data
|
||||
while b"\n" in stdin_buffer:
|
||||
line, stdin_buffer = stdin_buffer.split(b"\n", 1)
|
||||
command = json.loads(line.decode("utf-8"))
|
||||
command_type = command.get("type")
|
||||
if command_type == "input":
|
||||
write_all(master, base64.b64decode(command["data"], validate=True))
|
||||
elif command_type == "resize":
|
||||
resize(master, int(command["cols"]), int(command["rows"]))
|
||||
elif command_type == "signal":
|
||||
terminate(pid, int(command["signal"]))
|
||||
else:
|
||||
raise RuntimeError("unknown PTY command")
|
||||
finished, status = os.waitpid(pid, os.WNOHANG)
|
||||
if finished == pid:
|
||||
if os.WIFEXITED(status):
|
||||
emit({"type": "exit", "code": os.WEXITSTATUS(status), "signal": None})
|
||||
else:
|
||||
emit({"type": "exit", "code": None, "signal": os.WTERMSIG(status)})
|
||||
os.close(master)
|
||||
return
|
||||
_, status = os.waitpid(pid, 0)
|
||||
if os.WIFEXITED(status):
|
||||
emit({"type": "exit", "code": os.WEXITSTATUS(status), "signal": None})
|
||||
else:
|
||||
emit({"type": "exit", "code": None, "signal": os.WTERMSIG(status)})
|
||||
os.close(master)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
emit({"type": "error", "message": str(error)})
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,288 @@
|
||||
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 })
|
||||
Reference in new issue
Block a user