From 42ecfa2375326e285a4b3506328da62dd804ba10 Mon Sep 17 00:00:00 2001 From: elijahr2411 Date: Thu, 2 Feb 2023 21:19:55 -0500 Subject: [PATCH] You can now take turns and control the VM. --- package.json | 10 +++- src/Framebuffer.ts | 44 ++++++++++++++++ src/QEMUVM.ts | 125 ++++++++++++++++++++++++++++++++++++++++----- src/QMPClient.ts | 70 ++++++++++++++++++++----- src/RateLimiter.ts | 2 +- src/User.ts | 8 +-- src/WSServer.ts | 74 +++++++++++++++++++++++---- src/index.ts | 22 ++++---- tsconfig.json | 6 +-- 9 files changed, 306 insertions(+), 55 deletions(-) create mode 100644 src/Framebuffer.ts diff --git a/package.json b/package.json index 60d4a6e..8f7d852 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,18 @@ "license": "GPL-3.0", "dependencies": { "@types/node": "^18.11.18", + "@types/sharp": "^0.31.1", "@types/ws": "^8.5.4", + "async-mutex": "^0.4.0", + "execa": "^6.1.0", "fs": "^0.0.1-security", + "jimp": "^0.16.2", "mnemonist": "^0.39.5", - "rfb2": "^0.2.2", + "rfb2": "github:elijahr2411/node-rfb2", + "sharp": "^0.31.3", "toml": "^3.0.0", "typescript": "^4.9.5", "ws": "^8.12.0" - } + }, + "type": "module" } diff --git a/src/Framebuffer.ts b/src/Framebuffer.ts new file mode 100644 index 0000000..38deb5f --- /dev/null +++ b/src/Framebuffer.ts @@ -0,0 +1,44 @@ +import { Mutex } from "async-mutex"; + +export default class Framebuffer { + private fb : Buffer; + private writemutex : Mutex; + size : {height : number, width : number}; + constructor() { + this.fb = Buffer.alloc(1); + this.size = {height: 0, width: 0}; + this.writemutex = new Mutex(); + } + setSize(w : number, h : number) { + var size = h * w * 4; + this.size.height = h; + this.size.width = w; + this.fb = Buffer.alloc(size); + } + loadDirtyRect(rect : Buffer, x : number, y : number, width : number, height : number) { + if (this.fb.length < rect.length) + throw new Error("Dirty rect larger than framebuffer (did you forget to set the size?)"); + this.writemutex.runExclusive(() => { + return new Promise((res, rej) => { + var byteswritten = 0; + for (var i = 0; i < height; i++) { + byteswritten += rect.copy(this.fb, 4 * ((y + i) * this.size.width + x), byteswritten, byteswritten + (width * 4)); + } + res(); + }) + }); + } + getFb() : Promise { + return new Promise(async (res, rej) => { + var v = await this.writemutex.runExclusive(() => { + return new Promise((reso, reje) => { + var buff = Buffer.alloc(this.fb.length); + this.fb.copy(buff); + reso(buff); + }); + }); + res(v); + }) + } + +} \ No newline at end of file diff --git a/src/QEMUVM.ts b/src/QEMUVM.ts index 2f0ca04..051581f 100644 --- a/src/QEMUVM.ts +++ b/src/QEMUVM.ts @@ -1,9 +1,9 @@ import { EventEmitter } from "events"; -import IConfig from "./IConfig"; +import IConfig from "./IConfig.js"; import * as rfb from 'rfb2'; import * as fs from 'fs'; -import { spawn, ChildProcess } from "child_process"; -import QMPClient from "./QMPClient"; +import { execa, ExecaChildProcess } from "execa"; +import QMPClient from "./QMPClient.js"; export default class QEMUVM extends EventEmitter { vnc? : rfb.RfbClient; @@ -11,9 +11,16 @@ export default class QEMUVM extends EventEmitter { qmpSock : string; qmpClient : QMPClient; qemuCmd : string; - qemuProcess? : ChildProcess; + qemuProcess? : ExecaChildProcess; qmpErrorLevel : number; vncErrorLevel : number; + processRestartErrorLevel : number; + expectedExit : boolean; + + vncReconnectTimeout? : NodeJS.Timer; + qmpReconnectTimeout? : NodeJS.Timer; + qemuRestartTimeout? : NodeJS.Timer; + constructor(Config : IConfig) { super(); if (Config.vm.vncPort < 5900) { @@ -25,12 +32,15 @@ export default class QEMUVM extends EventEmitter { this.qemuCmd = `${Config.vm.qemuArgs} -snapshot -no-shutdown -vnc 127.0.0.1:${this.vncPort - 5900} -qmp unix:${this.qmpSock},server`; this.qmpErrorLevel = 0; this.vncErrorLevel = 0; + this.processRestartErrorLevel = 0; + this.expectedExit = false; this.qmpClient = new QMPClient(this.qmpSock); this.qmpClient.on('connected', () => this.qmpConnected()); + this.qmpClient.on('close', () => this.qmpClosed()); } - Start() { - return new Promise(async (res, rej) => { + Start() : Promise { + return new Promise(async (res, rej) => { if (fs.existsSync(this.qmpSock)) try { fs.unlinkSync(this.qmpSock); @@ -39,20 +49,34 @@ export default class QEMUVM extends EventEmitter { process.exit(-1); } var qemuArr = this.qemuCmd.split(" "); - this.qemuProcess = spawn(qemuArr[0], qemuArr.slice(1)); - process.on("beforeExit", () => { - this.qemuProcess?.kill(9); - }); + this.qemuProcess = execa(qemuArr[0], qemuArr.slice(1)); this.qemuProcess.stderr?.on('data', (d) => console.log(d.toString())); this.qemuProcess.on('spawn', () => { - setTimeout(() => { - this.qmpClient.connect(); + setTimeout(async () => { + await this.qmpClient.connect(); }, 1000) }); + this.qemuProcess.on('exit', () => { + if (this.expectedExit) return; + clearTimeout(this.qmpReconnectTimeout); + clearTimeout(this.vncReconnectTimeout); + this.processRestartErrorLevel++; + if (this.processRestartErrorLevel > 4) { + console.error("[FATAL] QEMU failed to launch 5 times."); + process.exit(-1); + } + console.warn("QEMU exited unexpectly. Restarting in 3 seconds..."); + this.qmpClient.disconnect(); + this.vnc?.end(); + this.qemuRestartTimeout = setTimeout(() => this.Start(), 3000); + }); + this.once('vncconnect', () => res()); }); } private qmpConnected() { + this.qmpErrorLevel = 0; + this.processRestartErrorLevel = 0; console.log("QMP Connected"); setTimeout(() => this.startVNC(), 1000); } @@ -62,9 +86,86 @@ export default class QEMUVM extends EventEmitter { host: "127.0.0.1", port: this.vncPort, }); + this.vnc.on("close", () => this.vncClosed()); + this.vnc.on("connect", () => this.vncConnected()); + this.vnc.on("rect", (r) => this.onVNCRect(r)); + this.vnc.on("resize", (s) => this.onVNCSize(s)); + } + + getSize() { + if (!this.vnc) return {height:0,width:0}; + return {height: this.vnc.height, width: this.vnc.width} } private qmpClosed() { + if (this.expectedExit) return; + this.qmpErrorLevel++; + if (this.qmpErrorLevel > 4) { + console.error("[FATAL] Failed to connect to QMP after 5 attempts"); + process.exit(1); + } + console.warn("Failed to connect to QMP. Retrying in 3 seconds..."); + this.qmpReconnectTimeout = setTimeout(() => this.qmpClient.connect(), 3000); + } + private vncClosed() { + if (this.expectedExit) return; + this.vncErrorLevel++; + if (this.vncErrorLevel > 4) { + console.error("[FATAL] Failed to connect to VNC after 5 attempts"); + process.exit(1); + } + try { + this.vnc?.end(); + } catch {}; + console.warn("Failed to connect to VNC. Retrying in 3 seconds..."); + this.vncReconnectTimeout = setTimeout(() => this.startVNC(), 3000); + } + + private vncConnected() { + this.emit('vncconnect'); + this.vncErrorLevel = 0; + } + private async onVNCRect(rect : any) { + var buff = Buffer.alloc(rect.height * rect.width * 4) + var offset = 0; + for (var i = 0; i < rect.data.length; i += 4) { + buff[offset++] = rect.data[i + 2]; + buff[offset++] = rect.data[i + 1]; + buff[offset++] = rect.data[i]; + buff[offset++] = 255; + } + this.emit("dirtyrect", buff, rect.x, rect.y, rect.width, rect.height); + if (!this.vnc) throw new Error(); + this.vnc.requestUpdate(true, 0, 0, this.vnc.height, this.vnc.width); + } + + private onVNCSize(size : any) { + this.emit("size", {height: size.height, width: size.width}); + } + + Reboot() : Promise { + return this.qmpClient.reboot(); + } + + async Restore() { + await this.Stop(); + this.expectedExit = false; + this.Start(); + } + + Stop() : Promise { + return new Promise(async (res, rej) => { + this.expectedExit = true; + this.vnc?.end(); + this.qmpClient.disconnect(); + var killTimeout = setTimeout(() => { + console.log("Force killing QEMU after 10 seconds of waiting for shutdown"); + this.qemuProcess?.kill(9); + }, 10000) + await this.qemuProcess; + clearTimeout(killTimeout); + res(); + }) } } \ No newline at end of file diff --git a/src/QMPClient.ts b/src/QMPClient.ts index dd494a7..ed213bb 100644 --- a/src/QMPClient.ts +++ b/src/QMPClient.ts @@ -1,40 +1,52 @@ import EventEmitter from "events"; import { Socket } from "net"; +import { Mutex } from "async-mutex"; export default class QMPClient extends EventEmitter { socketfile : string; socket : Socket; connected : boolean; sentConnected : boolean; + cmdMutex : Mutex; // So command outputs don't get mixed up constructor(socketfile : string) { super(); this.socketfile = socketfile; this.socket = new Socket(); this.connected = false; this.sentConnected = false; + this.cmdMutex = new Mutex(); } - connect() { - if (this.connected) return; - try { - this.socket.connect(this.socketfile); - } catch (e) { - this.emit("") - } - this.connected = true; - this.socket.on('data', (data) => this.onData(data)); - this.socket.on('close', () => this.onClose()); + connect() : Promise { + return new Promise((res, rej) => { + if (this.connected) {res(); return;} + try { + this.socket.connect(this.socketfile); + } catch (e) { + this.onClose(); + } + this.connected = true; + this.socket.on('error', (err) => false); // Disable throwing if QMP errors + this.socket.on('data', (data) => this.onData(data)); + this.socket.on('close', () => this.onClose()); + this.once('connected', () => res()); + }) } - private onData(data : Buffer) { + disconnect() { + this.connected = false; + this.socket.destroy(); + } + + private async onData(data : Buffer) { var msgraw = data.toString(); var msg = JSON.parse(msgraw); - console.log(msg); if (msg.QMP) { if (this.sentConnected) return; - this.socket.write(JSON.stringify({ execute: "qmp_capabilities" })); + await this.execute({ execute: "qmp_capabilities" }); this.emit('connected'); this.sentConnected = true; } + if (msg.return) this.emit("qmpreturn", msg.return); } private onClose() { @@ -42,4 +54,36 @@ export default class QMPClient extends EventEmitter { this.sentConnected = false; this.emit('close'); } + + async reboot() { + if (!this.connected) return; + await this.execute({"execute": "system_reset"}); + } + + async ExitQEMU() { + if (!this.connected) return; + await this.execute({"execute": "quit"}); + } + + execute(args : object) { + return new Promise(async (res, rej) => { + var result:any = await this.cmdMutex.runExclusive(() => { + // I kinda hate having two promises but IDK how else to do it /shrug + return new Promise((reso, reje) => { + this.once('qmpreturn', (e) => { + reso(e); + }); + this.socket.write(JSON.stringify(args)); + }); + }); + res(result); + }); + } + + runMonitorCmd(command : string) { + return new Promise(async (res, rej) => { + var result : any = await this.execute({execute: "human-monitor-command", arguments: {"command-line": command}}); + res(result); + }); + } } \ No newline at end of file diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts index 2ed4dfe..11f219f 100644 --- a/src/RateLimiter.ts +++ b/src/RateLimiter.ts @@ -1,4 +1,4 @@ -import { EventEmitter } from "stream"; +import { EventEmitter } from "events"; // Class to ratelimit a resource (chatting, logging in, etc) export default class RateLimiter extends EventEmitter { diff --git a/src/User.ts b/src/User.ts index c6501d9..6bfcde6 100644 --- a/src/User.ts +++ b/src/User.ts @@ -1,8 +1,8 @@ -import * as Utilities from './Utilities'; -import * as guacutils from './guacutils'; +import * as Utilities from './Utilities.js'; +import * as guacutils from './guacutils.js'; import {WebSocket} from 'ws'; -import IConfig from './IConfig'; -import RateLimiter from './RateLimiter'; +import IConfig from './IConfig.js'; +import RateLimiter from './RateLimiter.js'; export class User { socket : WebSocket; nopSendInterval : NodeJS.Timer; diff --git a/src/WSServer.ts b/src/WSServer.ts index ae038d1..f9be9a2 100644 --- a/src/WSServer.ts +++ b/src/WSServer.ts @@ -1,14 +1,19 @@ import {WebSocketServer, WebSocket} from 'ws'; import * as http from 'http'; -import IConfig from './IConfig'; +import IConfig from './IConfig.js'; import internal from 'stream'; -import * as Utilities from './Utilities'; -import { User, Rank } from './User'; -import * as guacutils from './guacutils'; +import * as Utilities from './Utilities.js'; +import { User, Rank } from './User.js'; +import * as guacutils from './guacutils.js'; import * as fs from 'fs'; -import { CircularBuffer, Queue } from 'mnemonist'; +// I hate that you have to do it like this +import CircularBuffer from 'mnemonist/circular-buffer.js'; +import Queue from 'mnemonist/queue.js'; import { createHash } from 'crypto'; import { isIP } from 'net'; +import QEMUVM from './QEMUVM.js'; +import Framebuffer from './Framebuffer.js'; +import sharp, { Sharp } from 'sharp'; export default class WSServer { private Config : IConfig; @@ -21,7 +26,9 @@ export default class WSServer { private TurnInterval? : NodeJS.Timer; private TurnIntervalRunning : boolean; private ModPerms : number; - constructor(config : IConfig) { + private VM : QEMUVM; + private framebuffer : Framebuffer; + constructor(config : IConfig, vm : QEMUVM) { this.ChatHistory = new CircularBuffer<{user:string,msg:string}>(Array, 5); this.TurnQueue = new Queue(); this.TurnTime = 0; @@ -33,6 +40,12 @@ export default class WSServer { this.socket = new WebSocketServer({noServer: true}); this.server.on('upgrade', (req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) => this.httpOnUpgrade(req, socket, head)); this.socket.on('connection', (ws : WebSocket, req : http.IncomingMessage) => this.onConnection(ws, req)); + var initSize = vm.getSize(); + this.framebuffer = new Framebuffer(); + this.newsize(initSize); + this.VM = vm; + this.VM.on("dirtyrect", (j, x, y, w, h) => this.newrect(j, x, y, w, h)); + this.VM.on("size", (s) => this.newsize(s)); } listen() { @@ -123,13 +136,12 @@ export default class WSServer { //@ts-ignore this.clients.forEach((c) => c.sendMsg(guacutils.encode("remuser", "1", user.username))); } - fuck = fs.readFileSync("/home/elijah/Pictures/thumb.txt").toString(); - private onMessage(client : User, message : string) { + private async onMessage(client : User, message : string) { var msgArr = guacutils.decode(message); if (msgArr.length < 1) return; switch (msgArr[0]) { case "list": - client.sendMsg(guacutils.encode("list", this.Config.collabvm.node, this.Config.collabvm.displayname, this.fuck)) + client.sendMsg(guacutils.encode("list", this.Config.collabvm.node, this.Config.collabvm.displayname, await this.getThumbnail())); break; case "connect": if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { @@ -140,8 +152,10 @@ export default class WSServer { client.sendMsg(guacutils.encode("connect", "1", "1", "1", "0")); if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd)); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - client.sendMsg(guacutils.encode("size", "0", "400", "300")); - client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.fuck)); + client.sendMsg(guacutils.encode("size", "0", this.framebuffer.size.width.toString(), this.framebuffer.size.height.toString())); + var jpg = await sharp(await this.framebuffer.getFb(), {raw: {height: this.framebuffer.size.height, width: this.framebuffer.size.width, channels: 4}}).jpeg().toBuffer(); + var jpg64 = jpg.toString("base64"); + client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64)); break; case "rename": if (!client.RenameRateLimit.request()) return; @@ -231,6 +245,23 @@ export default class WSServer { } this.sendTurnUpdate(); break; + case "mouse": + if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; + if (!this.VM.vnc) throw new Error("VNC Client was undefined"); + var x = parseInt(msgArr[1]); + var y = parseInt(msgArr[2]); + var mask = parseInt(msgArr[3]); + if (x === undefined || y === undefined || mask === undefined) return; + this.VM.vnc.pointerEvent(x, y, mask); + break; + case "key": + if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; + if (!this.VM.vnc) throw new Error("VNC Client was undefined"); + var keysym = parseInt(msgArr[1]); + var down = parseInt(msgArr[2]); + if (keysym === undefined || (down !== 0 && down !== 1)) return; + this.VM.vnc.keyEvent(keysym, down); + break; case "admin": if (msgArr.length < 2) return; switch (msgArr[1]) { @@ -312,4 +343,25 @@ export default class WSServer { this.nextTurn(); } } + + private async newrect(buff : Buffer, x : number, y : number, width : number, height : number) { + var jpg = await sharp(buff, {raw: {height: height, width: width, channels: 4}}).jpeg().toBuffer(); + var jpg64 = jpg.toString("base64"); + this.clients.filter(c => c.connectedToNode).forEach(c => c.sendMsg(guacutils.encode("png", "0", "0", x.toString(), y.toString(), jpg64))); + this.framebuffer.loadDirtyRect(buff, x, y, width, height); + } + + private newsize(size : {height:number,width:number}) { + this.framebuffer.setSize(size.width, size.height); + this.clients.filter(c => c.connectedToNode).forEach(c => c.sendMsg(guacutils.encode("size", "0", size.width.toString(), size.height.toString()))); + } + + getThumbnail() : Promise { + return new Promise(async (res, rej) => { + var jpg = await sharp(await this.framebuffer.getFb(), {raw: {height: this.framebuffer.size.height, width: this.framebuffer.size.width, channels: 4}}) + .resize(400, 300, {fit: 'fill'}) + .jpeg().toBuffer(); + res(jpg.toString("base64")); + }) + } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 8b93730..31d31f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import * as toml from 'toml'; -import IConfig from './IConfig'; +import IConfig from './IConfig.js'; import * as fs from "fs"; -import WSServer from './WSServer'; -import QEMUVM from './QEMUVM'; +import WSServer from './WSServer.js'; +import QEMUVM from './QEMUVM.js'; // Parse the config file @@ -20,10 +20,14 @@ try { process.exit(1); } -// Fire up the VM -var VM = new QEMUVM(Config); -VM.Start(); -// Start up the websocket server -var WS = new WSServer(Config); -WS.listen(); \ No newline at end of file +async function start() { + // Fire up the VM + var VM = new QEMUVM(Config); + await VM.Start(); + + // Start up the websocket server + var WS = new WSServer(Config, VM); + WS.listen(); +} +start(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index ca81fa6..720d20d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,16 +25,16 @@ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ + "module": "ES2015", /* Specify what module code is generated. */ "rootDir": "./src", /* Specify the root folder within your source files. */ - // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "moduleSuffixes": [".js", ".d.ts", ".ts", ".mjs", ".cjs", ".json"], /* List of file name suffixes to search when resolving a module. */ // "resolveJsonModule": true, /* Enable importing .json files. */ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */