terminal.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { DockgeServer } from "./dockge-server";
  2. import * as os from "node:os";
  3. import * as pty from "@homebridge/node-pty-prebuilt-multiarch";
  4. import { LimitQueue } from "./utils/limit-queue";
  5. import { DockgeSocket } from "./util-server";
  6. import {
  7. allowedCommandList, allowedRawKeys,
  8. getComposeTerminalName,
  9. getCryptoRandomInt,
  10. PROGRESS_TERMINAL_ROWS,
  11. TERMINAL_COLS,
  12. TERMINAL_ROWS
  13. } from "./util-common";
  14. import { sync as commandExistsSync } from "command-exists";
  15. import { log } from "./log";
  16. /**
  17. * Terminal for running commands, no user interaction
  18. */
  19. export class Terminal {
  20. protected static terminalMap : Map<string, Terminal> = new Map();
  21. protected _ptyProcess? : pty.IPty;
  22. protected server : DockgeServer;
  23. protected buffer : LimitQueue<string> = new LimitQueue(100);
  24. protected _name : string;
  25. protected file : string;
  26. protected args : string | string[];
  27. protected cwd : string;
  28. protected callback? : (exitCode : number) => void;
  29. protected _rows : number = TERMINAL_ROWS;
  30. protected _cols : number = TERMINAL_COLS;
  31. constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) {
  32. this.server = server;
  33. this._name = name;
  34. //this._name = "terminal-" + Date.now() + "-" + getCryptoRandomInt(0, 1000000);
  35. this.file = file;
  36. this.args = args;
  37. this.cwd = cwd;
  38. Terminal.terminalMap.set(this.name, this);
  39. }
  40. get rows() {
  41. return this._rows;
  42. }
  43. set rows(rows : number) {
  44. this._rows = rows;
  45. try {
  46. this.ptyProcess?.resize(this.cols, this.rows);
  47. } catch (e) {
  48. if (e instanceof Error) {
  49. log.debug("Terminal", "Failed to resize terminal: " + e.message);
  50. }
  51. }
  52. }
  53. get cols() {
  54. return this._cols;
  55. }
  56. set cols(cols : number) {
  57. this._cols = cols;
  58. try {
  59. this.ptyProcess?.resize(this.cols, this.rows);
  60. } catch (e) {
  61. if (e instanceof Error) {
  62. log.debug("Terminal", "Failed to resize terminal: " + e.message);
  63. }
  64. }
  65. }
  66. public start() {
  67. if (this._ptyProcess) {
  68. return;
  69. }
  70. this._ptyProcess = pty.spawn(this.file, this.args, {
  71. name: this.name,
  72. cwd: this.cwd,
  73. cols: TERMINAL_COLS,
  74. rows: this.rows,
  75. });
  76. // On Data
  77. this._ptyProcess.onData((data) => {
  78. this.buffer.pushItem(data);
  79. if (this.server.io) {
  80. this.server.io.to(this.name).emit("terminalWrite", this.name, data);
  81. }
  82. });
  83. // On Exit
  84. this._ptyProcess.onExit((res) => {
  85. this.server.io.to(this.name).emit("terminalExit", this.name, res.exitCode);
  86. // Remove room
  87. this.server.io.in(this.name).socketsLeave(this.name);
  88. Terminal.terminalMap.delete(this.name);
  89. log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode);
  90. if (this.callback) {
  91. this.callback(res.exitCode);
  92. }
  93. });
  94. }
  95. public onExit(callback : (exitCode : number) => void) {
  96. this.callback = callback;
  97. }
  98. public join(socket : DockgeSocket) {
  99. socket.join(this.name);
  100. }
  101. public leave(socket : DockgeSocket) {
  102. socket.leave(this.name);
  103. }
  104. public get ptyProcess() {
  105. return this._ptyProcess;
  106. }
  107. public get name() {
  108. return this._name;
  109. }
  110. /**
  111. * Get the terminal output string
  112. */
  113. getBuffer() : string {
  114. if (this.buffer.length === 0) {
  115. return "";
  116. }
  117. return this.buffer.join("");
  118. }
  119. close() {
  120. this._ptyProcess?.kill();
  121. }
  122. /**
  123. * Get a running and non-exited terminal
  124. * @param name
  125. */
  126. public static getTerminal(name : string) : Terminal | undefined {
  127. return Terminal.terminalMap.get(name);
  128. }
  129. public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal {
  130. // Since exited terminal will be removed from the map, it is safe to get the terminal from the map
  131. let terminal = Terminal.getTerminal(name);
  132. if (!terminal) {
  133. terminal = new Terminal(server, name, file, args, cwd);
  134. }
  135. return terminal;
  136. }
  137. public static exec(server : DockgeServer, socket : DockgeSocket | undefined, terminalName : string, file : string, args : string | string[], cwd : string) : Promise<number> {
  138. const terminal = new Terminal(server, terminalName, file, args, cwd);
  139. terminal.rows = PROGRESS_TERMINAL_ROWS;
  140. if (socket) {
  141. terminal.join(socket);
  142. }
  143. return new Promise((resolve) => {
  144. terminal.onExit((exitCode : number) => {
  145. resolve(exitCode);
  146. });
  147. terminal.start();
  148. });
  149. }
  150. }
  151. /**
  152. * Interactive terminal
  153. * Mainly used for container exec
  154. */
  155. export class InteractiveTerminal extends Terminal {
  156. public write(input : string) {
  157. this.ptyProcess?.write(input);
  158. }
  159. resetCWD() {
  160. const cwd = process.cwd();
  161. this.ptyProcess?.write(`cd "${cwd}"\r`);
  162. }
  163. }
  164. /**
  165. * User interactive terminal that use bash or powershell with limited commands such as docker, ls, cd, dir
  166. */
  167. export class MainTerminal extends InteractiveTerminal {
  168. constructor(server : DockgeServer, name : string) {
  169. let shell;
  170. if (os.platform() === "win32") {
  171. if (commandExistsSync("pwsh.exe")) {
  172. shell = "pwsh.exe";
  173. } else {
  174. shell = "powershell.exe";
  175. }
  176. } else {
  177. shell = "bash";
  178. }
  179. super(server, name, shell, [], server.stacksDir);
  180. }
  181. public write(input : string) {
  182. // For like Ctrl + C
  183. if (allowedRawKeys.includes(input)) {
  184. super.write(input);
  185. return;
  186. }
  187. // Check if the command is allowed
  188. const cmdParts = input.split(" ");
  189. const executable = cmdParts[0].trim();
  190. log.debug("console", "Executable: " + executable);
  191. log.debug("console", "Executable length: " + executable.length);
  192. if (!allowedCommandList.includes(executable)) {
  193. throw new Error("Command not allowed.");
  194. }
  195. super.write(input);
  196. }
  197. }