2023-11-11 14:18:37 +00:00
|
|
|
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<string, Terminal> = new Map();
|
|
|
|
|
|
|
|
protected _ptyProcess? : pty.IPty;
|
|
|
|
protected server : DockgeServer;
|
|
|
|
protected buffer : LimitQueue<string> = 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;
|
|
|
|
|
2023-11-24 18:04:16 +00:00
|
|
|
public enableKeepAlive : boolean = false;
|
|
|
|
protected keepAliveInterval? : NodeJS.Timeout;
|
|
|
|
|
2023-11-11 14:18:37 +00:00
|
|
|
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) {
|
2023-11-18 07:54:43 +00:00
|
|
|
if (e instanceof Error) {
|
|
|
|
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
|
|
|
}
|
2023-11-11 14:18:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
get cols() {
|
|
|
|
return this._cols;
|
|
|
|
}
|
|
|
|
|
|
|
|
set cols(cols : number) {
|
|
|
|
this._cols = cols;
|
2023-12-16 09:57:21 +00:00
|
|
|
log.debug("Terminal", `Terminal cols: ${this._cols}`); // Added to check if cols is being set when changing terminal size.
|
2023-11-11 14:18:37 +00:00
|
|
|
try {
|
|
|
|
this.ptyProcess?.resize(this.cols, this.rows);
|
|
|
|
} catch (e) {
|
2023-11-18 07:54:43 +00:00
|
|
|
if (e instanceof Error) {
|
|
|
|
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
|
|
|
}
|
2023-11-11 14:18:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public start() {
|
|
|
|
if (this._ptyProcess) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-11-24 18:04:16 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2023-11-21 10:51:38 +00:00
|
|
|
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) {
|
2023-11-24 18:04:16 +00:00
|
|
|
clearInterval(this.keepAliveInterval);
|
|
|
|
|
2023-11-21 10:51:38 +00:00
|
|
|
log.error("Terminal", "Failed to start terminal: " + error.message);
|
|
|
|
const exitCode = Number(error.message.split(" ").pop());
|
|
|
|
this.exit({
|
|
|
|
exitCode,
|
|
|
|
});
|
2023-11-11 14:18:37 +00:00
|
|
|
}
|
2023-11-21 10:51:38 +00:00
|
|
|
}
|
|
|
|
}
|
2023-11-11 14:18:37 +00:00
|
|
|
|
2023-11-21 10:51:38 +00:00
|
|
|
/**
|
|
|
|
* 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);
|
2023-11-11 14:18:37 +00:00
|
|
|
|
2023-11-21 10:51:38 +00:00
|
|
|
// Remove room
|
|
|
|
this.server.io.in(this.name).socketsLeave(this.name);
|
2023-11-11 14:18:37 +00:00
|
|
|
|
2023-11-21 10:51:38 +00:00
|
|
|
Terminal.terminalMap.delete(this.name);
|
|
|
|
log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode);
|
2023-11-11 14:18:37 +00:00
|
|
|
|
2023-11-24 18:04:16 +00:00
|
|
|
clearInterval(this.keepAliveInterval);
|
|
|
|
|
2023-11-21 10:51:38 +00:00
|
|
|
if (this.callback) {
|
|
|
|
this.callback(res.exitCode);
|
|
|
|
}
|
|
|
|
};
|
2023-11-11 14:18:37 +00:00
|
|
|
|
|
|
|
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() {
|
2023-11-24 18:04:16 +00:00
|
|
|
clearInterval(this.keepAliveInterval);
|
|
|
|
// Send Ctrl+C to the terminal
|
|
|
|
this.ptyProcess?.write("\x03");
|
2023-11-11 14:18:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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<number> {
|
2023-11-26 09:51:26 +00:00
|
|
|
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;
|
|
|
|
}
|
2023-11-11 14:18:37 +00:00
|
|
|
|
2023-11-26 09:51:26 +00:00
|
|
|
let terminal = new Terminal(server, terminalName, file, args, cwd);
|
|
|
|
terminal.rows = PROGRESS_TERMINAL_ROWS;
|
|
|
|
|
|
|
|
if (socket) {
|
|
|
|
terminal.join(socket);
|
|
|
|
}
|
2023-11-11 14:18:37 +00:00
|
|
|
|
|
|
|
terminal.onExit((exitCode : number) => {
|
|
|
|
resolve(exitCode);
|
|
|
|
});
|
|
|
|
terminal.start();
|
|
|
|
});
|
|
|
|
}
|
2023-11-24 18:04:16 +00:00
|
|
|
|
|
|
|
public static getTerminalCount() {
|
|
|
|
return Terminal.terminalMap.size;
|
|
|
|
}
|
2023-11-11 14:18:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
|
|
|
}
|