import { DockgeServer } from "./dockge-server"; import * as os from "node:os"; import * as pty from "@homebridge/node-pty-prebuilt-multiarch"; import { LimitQueue } from "./utils/limit-queue"; import { DockgeSocket } from "./util-server"; import { allowedCommandList, allowedRawKeys, PROGRESS_TERMINAL_ROWS, TERMINAL_COLS, TERMINAL_ROWS } from "./util-common"; import { sync as commandExistsSync } from "command-exists"; import { log } from "./log"; /** * Terminal for running commands, no user interaction */ export class Terminal { protected static terminalMap : Map = new Map(); protected _ptyProcess? : pty.IPty; protected server : DockgeServer; protected buffer : LimitQueue = new LimitQueue(100); protected _name : string; protected file : string; protected args : string | string[]; protected cwd : string; protected callback? : (exitCode : number) => void; protected _rows : number = TERMINAL_ROWS; protected _cols : number = TERMINAL_COLS; public enableKeepAlive : boolean = false; protected keepAliveInterval? : NodeJS.Timeout; constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) { this.server = server; this._name = name; //this._name = "terminal-" + Date.now() + "-" + getCryptoRandomInt(0, 1000000); this.file = file; this.args = args; this.cwd = cwd; Terminal.terminalMap.set(this.name, this); } get rows() { return this._rows; } set rows(rows : number) { this._rows = rows; try { this.ptyProcess?.resize(this.cols, this.rows); } catch (e) { if (e instanceof Error) { log.debug("Terminal", "Failed to resize terminal: " + e.message); } } } get cols() { return this._cols; } set cols(cols : number) { this._cols = cols; try { this.ptyProcess?.resize(this.cols, this.rows); } catch (e) { if (e instanceof Error) { log.debug("Terminal", "Failed to resize terminal: " + e.message); } } } public start() { if (this._ptyProcess) { return; } if (this.enableKeepAlive) { log.debug("Terminal", "Keep alive enabled for terminal " + this.name); // Close if there is no clients this.keepAliveInterval = setInterval(() => { const clients = this.server.io.sockets.adapter.rooms.get(this.name); const numClients = clients ? clients.size : 0; if (numClients === 0) { log.debug("Terminal", "Terminal " + this.name + " has no client, closing..."); this.close(); } else { log.debug("Terminal", "Terminal " + this.name + " has " + numClients + " client(s)"); } }, 60 * 1000); } else { log.debug("Terminal", "Keep alive disabled for terminal " + this.name); } try { this._ptyProcess = pty.spawn(this.file, this.args, { name: this.name, cwd: this.cwd, cols: TERMINAL_COLS, rows: this.rows, }); // On Data this._ptyProcess.onData((data) => { this.buffer.pushItem(data); if (this.server.io) { this.server.io.to(this.name).emit("terminalWrite", this.name, data); } }); // On Exit this._ptyProcess.onExit(this.exit); } catch (error) { if (error instanceof Error) { clearInterval(this.keepAliveInterval); log.error("Terminal", "Failed to start terminal: " + error.message); const exitCode = Number(error.message.split(" ").pop()); this.exit({ exitCode, }); } } } /** * Exit event handler * @param res */ protected exit = (res : {exitCode: number, signal?: number | undefined}) => { this.server.io.to(this.name).emit("terminalExit", this.name, res.exitCode); // Remove room this.server.io.in(this.name).socketsLeave(this.name); Terminal.terminalMap.delete(this.name); log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode); clearInterval(this.keepAliveInterval); if (this.callback) { this.callback(res.exitCode); } }; public onExit(callback : (exitCode : number) => void) { this.callback = callback; } public join(socket : DockgeSocket) { socket.join(this.name); } public leave(socket : DockgeSocket) { socket.leave(this.name); } public get ptyProcess() { return this._ptyProcess; } public get name() { return this._name; } /** * Get the terminal output string */ getBuffer() : string { if (this.buffer.length === 0) { return ""; } return this.buffer.join(""); } close() { clearInterval(this.keepAliveInterval); // Send Ctrl+C to the terminal this.ptyProcess?.write("\x03"); } /** * Get a running and non-exited terminal * @param name */ public static getTerminal(name : string) : Terminal | undefined { return Terminal.terminalMap.get(name); } public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal { // Since exited terminal will be removed from the map, it is safe to get the terminal from the map let terminal = Terminal.getTerminal(name); if (!terminal) { terminal = new Terminal(server, name, file, args, cwd); } return terminal; } public static exec(server : DockgeServer, socket : DockgeSocket | undefined, terminalName : string, file : string, args : string | string[], cwd : string) : Promise { return new Promise((resolve, reject) => { // check if terminal exists if (Terminal.terminalMap.has(terminalName)) { reject("Another operation is already running, please try again later."); return; } let terminal = new Terminal(server, terminalName, file, args, cwd); terminal.rows = PROGRESS_TERMINAL_ROWS; if (socket) { terminal.join(socket); } terminal.onExit((exitCode : number) => { resolve(exitCode); }); terminal.start(); }); } public static getTerminalCount() { return Terminal.terminalMap.size; } } /** * Interactive terminal * Mainly used for container exec */ export class InteractiveTerminal extends Terminal { public write(input : string) { this.ptyProcess?.write(input); } resetCWD() { const cwd = process.cwd(); this.ptyProcess?.write(`cd "${cwd}"\r`); } } /** * User interactive terminal that use bash or powershell with limited commands such as docker, ls, cd, dir */ export class MainTerminal extends InteractiveTerminal { constructor(server : DockgeServer, name : string) { let shell; if (os.platform() === "win32") { if (commandExistsSync("pwsh.exe")) { shell = "pwsh.exe"; } else { shell = "powershell.exe"; } } else { shell = "bash"; } super(server, name, shell, [], server.stacksDir); } public write(input : string) { // For like Ctrl + C if (allowedRawKeys.includes(input)) { super.write(input); return; } // Check if the command is allowed const cmdParts = input.split(" "); const executable = cmdParts[0].trim(); log.debug("console", "Executable: " + executable); log.debug("console", "Executable length: " + executable.length); if (!allowedCommandList.includes(executable)) { throw new Error("Command not allowed."); } super.write(input); } }