forked from extern/dockge
231 lines
6.3 KiB
TypeScript
231 lines
6.3 KiB
TypeScript
|
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,
|
||
|
getComposeTerminalName,
|
||
|
getCryptoRandomInt,
|
||
|
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;
|
||
|
|
||
|
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) {
|
||
|
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) {
|
||
|
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public start() {
|
||
|
if (this._ptyProcess) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
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.push(data);
|
||
|
if (this.server.io) {
|
||
|
this.server.io.to(this.name).emit("terminalWrite", this.name, data);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// On Exit
|
||
|
this._ptyProcess.onExit((res) => {
|
||
|
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);
|
||
|
|
||
|
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() {
|
||
|
this._ptyProcess?.kill();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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> {
|
||
|
const terminal = new Terminal(server, terminalName, file, args, cwd);
|
||
|
terminal.rows = PROGRESS_TERMINAL_ROWS;
|
||
|
|
||
|
if (socket) {
|
||
|
terminal.join(socket);
|
||
|
}
|
||
|
|
||
|
return new Promise((resolve) => {
|
||
|
terminal.onExit((exitCode : number) => {
|
||
|
resolve(exitCode);
|
||
|
});
|
||
|
terminal.start();
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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);
|
||
|
}
|
||
|
}
|