diff --git a/cvmts/package.json b/cvmts/package.json index dac6c67..0e6968a 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -13,7 +13,7 @@ "license": "GPL-3.0", "dependencies": { "@computernewb/nodejs-rfb": "^0.3.0", - "@computernewb/superqemu": "^0.2.3", + "@computernewb/superqemu": "0.2.4-alpha0", "@cvmts/cvm-rs": "*", "@maxmind/geoip2-node": "^5.0.0", "execa": "^8.0.1", diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 8b56199..1f2cb10 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -12,12 +12,12 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import AuthManager from './AuthManager.js'; import { JPEGEncoder } from './JPEGEncoder.js'; -import VM from './VM.js'; +import VM from './vm/interface.js'; import { ReaderModel } from '@maxmind/geoip2-node'; import * as msgpack from 'msgpackr'; import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/collab-vm-1.2-binary-protocol'; -import { Size, Rect } from './VMDisplay.js'; +import { Size, Rect } from './Utilities.js'; import pino from 'pino'; import { BanManager } from './BanManager.js'; @@ -123,33 +123,34 @@ export default class CollabVMServer { this.VM = vm; - // this probably should be made general at some point, - // and the VM interface allowed to return a nullable display + // the VM interface should probably be allowed to return a nullable display // but i cba let self = this; - if (config.vm.type == 'qemu') { - (vm as QemuVM).on('statechange', (newState: VMState) => { - if (newState == VMState.Started) { - self.logger.info('VM started'); - // well aware this sucks but whatever - self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size)); - self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect)); - self.VM.GetDisplay().on('frame', () => self.OnDisplayFrame()); + + vm.Events().on('statechange', (newState: VMState) => { + if (newState == VMState.Started) { + self.logger.info('VM started'); + + // start the display + if (self.VM.GetDisplay() == null) { + self.VM.StartDisplay(); } - if (newState == VMState.Stopped) { - setTimeout(async () => { - self.logger.info('restarting VM'); - await self.VM.Start(); - }, kRestartTimeout); - } - }); - } else { - // this sucks too fix this - self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size)); - self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect)); - self.VM.GetDisplay().on('frame', () => self.OnDisplayFrame()); - } + self.VM.GetDisplay()?.on('connected', () => { + // well aware this sucks but whatever + self.VM.GetDisplay()?.on('resize', (size: Size) => self.OnDisplayResized(size)); + self.VM.GetDisplay()?.on('rect', (rect: Rect) => self.OnDisplayRectangle(rect)); + self.VM.GetDisplay()?.on('frame', () => self.OnDisplayFrame()); + }); + } + + if (newState == VMState.Stopped) { + setTimeout(async () => { + self.logger.info('restarting VM'); + await self.VM.Start(); + }, kRestartTimeout); + } + }); // authentication manager this.auth = auth; @@ -681,7 +682,8 @@ export default class CollabVMServer { break; case '1': this.screenHidden = false; - let displaySize = this.VM.GetDisplay().Size(); + let displaySize = this.VM.GetDisplay()?.Size(); + if (displaySize == undefined) return; let encoded = await this.MakeRectData({ x: 0, @@ -888,8 +890,7 @@ export default class CollabVMServer { let promises: Promise[] = []; - for(let rect of self.rectQueue) - promises.push(doRect(rect)); + for (let rect of self.rectQueue) promises.push(doRect(rect)); this.rectQueue = []; @@ -942,7 +943,7 @@ export default class CollabVMServer { let display = this.VM.GetDisplay(); // oh well - if (!display.Connected()) return ''; + if (!display?.Connected()) return ''; let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size()); return buf.toString('base64'); diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index 0c309c1..2217718 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -1,4 +1,4 @@ -import VNCVMDef from './VNCVM/VNCVMDef'; +import VNCVMDef from './vm/vnc/VNCVMDef'; export default interface IConfig { http: { diff --git a/cvmts/src/JPEGEncoder.ts b/cvmts/src/JPEGEncoder.ts index 763b3c5..8c182a0 100644 --- a/cvmts/src/JPEGEncoder.ts +++ b/cvmts/src/JPEGEncoder.ts @@ -1,4 +1,4 @@ -import { Size, Rect } from './VMDisplay.js'; +import { Size, Rect } from './Utilities'; import sharp from 'sharp'; import * as cvm from '@cvmts/cvm-rs'; diff --git a/cvmts/src/Utilities.ts b/cvmts/src/Utilities.ts index efdcf81..be61dbe 100644 --- a/cvmts/src/Utilities.ts +++ b/cvmts/src/Utilities.ts @@ -1,5 +1,21 @@ import { Permissions } from './IConfig'; +export type Size = { + width: number; + height: number; +}; + +export type Rect = { + x: number; + y: number; + width: number; + height: number; +}; + +export function Clamp(input: number, min: number, max: number) { + return Math.min(Math.max(input, min), max); +} + export function Randint(min: number, max: number) { return Math.floor(Math.random() * (max - min) + min); } diff --git a/cvmts/src/VM.ts b/cvmts/src/VM.ts deleted file mode 100644 index 0038e9b..0000000 --- a/cvmts/src/VM.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { VMState } from '@computernewb/superqemu'; -import { VMDisplay } from './VMDisplay.js'; - -export default interface VM { - Start(): Promise; - Stop(): Promise; - Reboot(): Promise; - Reset(): Promise; - MonitorCommand(command: string): Promise; - GetDisplay(): VMDisplay; - GetState(): VMState; - SnapshotsSupported(): boolean; -} diff --git a/cvmts/src/display/batch.ts b/cvmts/src/display/batch.ts new file mode 100644 index 0000000..d23c396 --- /dev/null +++ b/cvmts/src/display/batch.ts @@ -0,0 +1,41 @@ +import { Size, Rect } from "../Utilities"; + +export function BatchRects(size: Size, rects: Array): Rect { + var mergedX = size.width; + var mergedY = size.height; + var mergedHeight = 0; + var mergedWidth = 0; + + // can't batch these + if (rects.length == 0) { + return { + x: 0, + y: 0, + width: size.width, + height: size.height + }; + } + + if (rects.length == 1) { + if (rects[0].width == size.width && rects[0].height == size.height) { + return rects[0]; + } + } + + rects.forEach((r) => { + if (r.x < mergedX) mergedX = r.x; + if (r.y < mergedY) mergedY = r.y; + }); + + rects.forEach((r) => { + if (r.height + r.y - mergedY > mergedHeight) mergedHeight = r.height + r.y - mergedY; + if (r.width + r.x - mergedX > mergedWidth) mergedWidth = r.width + r.x - mergedX; + }); + + return { + x: mergedX, + y: mergedY, + width: mergedWidth, + height: mergedHeight + }; +} diff --git a/cvmts/src/VMDisplay.ts b/cvmts/src/display/interface.ts similarity index 58% rename from cvmts/src/VMDisplay.ts rename to cvmts/src/display/interface.ts index 559e9b8..463fbf9 100644 --- a/cvmts/src/VMDisplay.ts +++ b/cvmts/src/display/interface.ts @@ -1,18 +1,5 @@ import EventEmitter from 'node:events'; - -// not great but whatever -// nodejs-rfb COULD probably export them though. -export type Size = { - width: number; - height: number; -}; - -export type Rect = { - x: number; - y: number; - width: number; - height: number; -}; +import { Size, Rect } from '../Utilities'; export interface VMDisplay extends EventEmitter { Connect(): void; diff --git a/cvmts/src/VNCVM/VNCVM.ts b/cvmts/src/display/vnc.ts similarity index 50% rename from cvmts/src/VNCVM/VNCVM.ts rename to cvmts/src/display/vnc.ts index d2106a9..65bbf99 100644 --- a/cvmts/src/VNCVM/VNCVM.ts +++ b/cvmts/src/display/vnc.ts @@ -1,35 +1,49 @@ -import EventEmitter from 'events'; -import VNCVMDef from './VNCVMDef'; -import VM from '../VM'; -import { Size, Rect, VMDisplay } from '../VMDisplay'; import { VncClient } from '@computernewb/nodejs-rfb'; -import { BatchRects, VMState } from '@computernewb/superqemu'; -import { execaCommand } from 'execa'; -import pino from 'pino'; +import { EventEmitter } from 'node:events'; +import { Clamp } from '../Utilities.js'; +import { BatchRects } from './batch.js'; +import { VMDisplay } from './interface.js'; -function Clamp(input: number, min: number, max: number) { - return Math.min(Math.max(input, min), max); -} +import { Size, Rect } from '../Utilities.js'; -async function Sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +const kQemuFps = 60; -export default class VNCVM extends EventEmitter implements VM, VMDisplay { - def: VNCVMDef; - logger; +export type VncRect = { + x: number; + y: number; + width: number; + height: number; +}; + +// events: +// +// 'resize' -> (w, h) -> done when resize occurs +// 'rect' -> (x, y, ImageData) -> framebuffer +// 'frame' -> () -> done at end of frame + +// TODO: replace with a non-asshole VNC client +export class VncDisplay extends EventEmitter implements VMDisplay { private displayVnc = new VncClient({ debug: false, - fps: 60, - encodings: [VncClient.consts.encodings.raw, VncClient.consts.encodings.pseudoDesktopSize] - }); - private vncShouldReconnect: boolean = false; + fps: kQemuFps, - constructor(def: VNCVMDef) { + encodings: [ + VncClient.consts.encodings.raw, + + //VncClient.consts.encodings.pseudoQemuAudio, + VncClient.consts.encodings.pseudoDesktopSize + // For now? + //VncClient.consts.encodings.pseudoCursor + ] + }); + + private vncShouldReconnect: boolean = false; + private vncConnectOpts: any; + + constructor(vncConnectOpts: any) { super(); - this.def = def; - // TODO: Now that we're using an actual structured logger can we please - this.logger = pino({ name: `CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}` }); + + this.vncConnectOpts = vncConnectOpts; this.displayVnc.on('connectTimeout', () => { this.Reconnect(); @@ -40,7 +54,6 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { }); this.displayVnc.on('disconnect', () => { - this.logger.info('Disconnected'); this.Reconnect(); }); @@ -49,10 +62,9 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { }); this.displayVnc.on('firstFrameUpdate', () => { - this.logger.info('Connected'); // apparently this library is this good. // at least it's better than the two others which exist. - this.displayVnc.changeFps(60); + this.displayVnc.changeFps(kQemuFps); this.emit('connected'); this.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight }); @@ -85,15 +97,6 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { }); } - async Reset(): Promise { - if (this.def.restoreCmd) await execaCommand(this.def.restoreCmd, { shell: true }); - else { - await this.Stop(); - await Sleep(1000); - await this.Start(); - } - } - private Reconnect() { if (this.displayVnc.connected) return; @@ -102,58 +105,24 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { // TODO: this should also give up after a max tries count // if we fail after max tries, emit a event - this.displayVnc.connect({ - host: this.def.vncHost, - port: this.def.vncPort, - path: null - }); + this.displayVnc.connect(this.vncConnectOpts); } - async Start(): Promise { - this.logger.info('Connecting'); - if (this.def.startCmd) await execaCommand(this.def.startCmd, { shell: true }); - this.Connect(); - } - - async Stop(): Promise { - this.logger.info('Disconnecting'); - this.Disconnect(); - if (this.def.stopCmd) await execaCommand(this.def.stopCmd, { shell: true }); - } - - async Reboot(): Promise { - if (this.def.rebootCmd) await execaCommand(this.def.rebootCmd, { shell: true }); - } - - async MonitorCommand(command: string): Promise { - // TODO: This can maybe run a specified command? - return 'This VM does not support monitor commands.'; - } - - GetDisplay(): VMDisplay { - return this; - } - - GetState(): VMState { - // for now! - return VMState.Started; - } - - SnapshotsSupported(): boolean { - return true; - } - - Connect(): void { + Connect() { this.vncShouldReconnect = true; this.Reconnect(); } - Disconnect(): void { + Disconnect() { this.vncShouldReconnect = false; this.displayVnc.disconnect(); + + // bye bye! + this.displayVnc.removeAllListeners(); + this.removeAllListeners(); } - Connected(): boolean { + Connected() { return this.displayVnc.connected; } @@ -174,11 +143,11 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { }; } - MouseEvent(x: number, y: number, buttons: number): void { + MouseEvent(x: number, y: number, buttons: number) { if (this.displayVnc.connected) this.displayVnc.sendPointerEvent(Clamp(x, 0, this.displayVnc.clientWidth), Clamp(y, 0, this.displayVnc.clientHeight), buttons); } - KeyboardEvent(keysym: number, pressed: boolean): void { + KeyboardEvent(keysym: number, pressed: boolean) { if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed); } } diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index ccfba7e..147bf1a 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -3,18 +3,19 @@ import IConfig from './IConfig.js'; import * as fs from 'fs'; import CollabVMServer from './CollabVMServer.js'; -import { QemuVM, QemuVmDefinition } from '@computernewb/superqemu'; +import { QemuVmDefinition } from '@computernewb/superqemu'; import AuthManager from './AuthManager.js'; import WSServer from './WebSocket/WSServer.js'; import { User } from './User.js'; import TCPServer from './TCP/TCPServer.js'; -import VM from './VM.js'; -import VNCVM from './VNCVM/VNCVM.js'; +import VM from './vm/interface.js'; +import VNCVM from './vm/vnc/VNCVM.js'; import GeoIPDownloader from './GeoIPDownloader.js'; import pino from 'pino'; import { Database } from './Database.js'; import { BanManager } from './BanManager.js'; +import { QemuVMShim } from './vm/qemu.js'; let logger = pino(); @@ -80,7 +81,7 @@ async function start() { vncPort: Config.qemu.vncPort, }; - VM = new QemuVM(def); + VM = new QemuVMShim(def); break; } case 'vncvm': { diff --git a/cvmts/src/vm/interface.ts b/cvmts/src/vm/interface.ts new file mode 100644 index 0000000..825d02a --- /dev/null +++ b/cvmts/src/vm/interface.ts @@ -0,0 +1,34 @@ +import { VMState } from '@computernewb/superqemu'; +import { VMDisplay } from '../display/interface.js'; +import { EventEmitter } from 'node:events'; + +// Abstraction of VM interface +export default interface VM { + // Starts the VM. + Start(): Promise; + + // Stops the VM. + Stop(): Promise; + + // Reboots the VM. + Reboot(): Promise; + + // Resets the VM. + Reset(): Promise; + + // Monitor command + MonitorCommand(command: string): Promise; + + // Start/connect the display + StartDisplay(): void; + + // Gets the current active display + // TODO: this could probaly be replaced with an event or something + GetDisplay(): VMDisplay | null; + + GetState(): VMState; + + SnapshotsSupported(): boolean; + + Events(): EventEmitter; +} diff --git a/cvmts/src/vm/qemu.ts b/cvmts/src/vm/qemu.ts new file mode 100644 index 0000000..38eab09 --- /dev/null +++ b/cvmts/src/vm/qemu.ts @@ -0,0 +1,89 @@ +import EventEmitter from 'events'; +import VM from './interface.js'; +import { QemuVM, QemuVmDefinition, VMState } from '@computernewb/superqemu'; +import { VMDisplay } from '../display/interface.js'; +import { VncDisplay } from '../display/vnc.js'; +import pino from 'pino'; + +// shim over superqemu because it diverges from the VM interface +export class QemuVMShim implements VM { + private vm; + private display: VncDisplay | null = null; + private logger; + + constructor(def: QemuVmDefinition) { + this.vm = new QemuVM(def); + this.logger = pino({ name: `CVMTS.QemuVMShim/${def.id}` }); + } + + Start(): Promise { + return this.vm.Start(); + } + + async Stop(): Promise { + await this.vm.Stop(); + + this.display?.Disconnect(); + this.display = null; + } + + Reboot(): Promise { + return this.vm.Reboot(); + } + + Reset(): Promise { + return this.vm.Reset(); + } + + MonitorCommand(command: string): Promise { + return this.vm.MonitorCommand(command); + } + + StartDisplay(): void { + // boot it up + let info = this.vm.GetDisplayInfo(); + + if (info == null) throw new Error('its dead jim'); + + switch (info.type) { + case 'vnc-tcp': + this.display = new VncDisplay({ + host: info.host || '127.0.0.1', + port: info.port || 5900, + path: null + }); + break; + case 'vnc-uds': + this.display = new VncDisplay({ + path: info.path + }); + break; + } + + let self = this; + + this.display?.on('connected', () => { + // The VM can now be considered started + self.logger.info('Display connected'); + }); + + // now that QMP has connected, connect to the display + self.display?.Connect(); + } + + GetDisplay(): VMDisplay | null { + return this.display; + } + + GetState(): VMState { + return this.vm.GetState(); + } + + SnapshotsSupported(): boolean { + return this.vm.SnapshotsSupported(); + } + + Events(): EventEmitter { + return this.vm; + } +} diff --git a/cvmts/src/vm/vnc/VNCVM.ts b/cvmts/src/vm/vnc/VNCVM.ts new file mode 100644 index 0000000..678a227 --- /dev/null +++ b/cvmts/src/vm/vnc/VNCVM.ts @@ -0,0 +1,111 @@ +import EventEmitter from 'events'; +import VNCVMDef from './VNCVMDef'; +import VM from '../interface.js'; +import { VMDisplay } from '../../display/interface.js'; +import { VMState } from '@computernewb/superqemu'; +import { execaCommand } from 'execa'; +import pino from 'pino'; +import { VncDisplay } from '../../display/vnc.js'; + +function Clamp(input: number, min: number, max: number) { + return Math.min(Math.max(input, min), max); +} + +async function Sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default class VNCVM extends EventEmitter implements VM { + def: VNCVMDef; + logger; + private vnc: VncDisplay | null = null; + private state = VMState.Stopped; + + constructor(def: VNCVMDef) { + super(); + this.def = def; + // TODO: Now that we're using an actual structured logger can we please + this.logger = pino({ name: `CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}` }); + } + + async Reset(): Promise { + if (this.def.restoreCmd) await execaCommand(this.def.restoreCmd, { shell: true }); + else { + await this.Stop(); + await Sleep(1000); + await this.Start(); + } + } + + private Connect() { + if (this.vnc) { + this.Disconnect(); + } + + this.vnc = new VncDisplay({ + host: this.def.vncHost, + port: this.def.vncPort + }); + + let self = this; + this.vnc.on('connected', () => { + self.logger.info('Connected'); + self.SetState(VMState.Started); + }); + } + + private Disconnect() { + if (this.vnc) { + this.vnc.Disconnect(); + this.vnc.removeAllListeners(); + this.vnc = null; + } + } + + private SetState(newState: VMState) { + this.state = newState; + this.emit('statechange', newState); + } + + StartDisplay(): void { + this.logger.info('Connecting to VNC server'); + this.Connect(); + } + + async Start(): Promise { + if (this.def.startCmd) await execaCommand(this.def.startCmd, { shell: true }); + this.SetState(VMState.Started); + } + + async Stop(): Promise { + this.logger.info('Disconnecting'); + this.Disconnect(); + if (this.def.stopCmd) await execaCommand(this.def.stopCmd, { shell: true }); + this.SetState(VMState.Stopped); + } + + async Reboot(): Promise { + if (this.def.rebootCmd) await execaCommand(this.def.rebootCmd, { shell: true }); + } + + async MonitorCommand(command: string): Promise { + // TODO: This can maybe run a specified command? + return 'This VM does not support monitor commands.'; + } + + GetDisplay(): VMDisplay | null { + return this.vnc; + } + + GetState(): VMState { + return this.state; + } + + SnapshotsSupported(): boolean { + return true; + } + + Events(): EventEmitter { + return this; + } +} diff --git a/cvmts/src/VNCVM/VNCVMDef.ts b/cvmts/src/vm/vnc/VNCVMDef.ts similarity index 100% rename from cvmts/src/VNCVM/VNCVMDef.ts rename to cvmts/src/vm/vnc/VNCVMDef.ts diff --git a/yarn.lock b/yarn.lock index 0271616..f88c004 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41,14 +41,13 @@ __metadata: languageName: node linkType: hard -"@computernewb/superqemu@npm:^0.2.3": - version: 0.2.3 - resolution: "@computernewb/superqemu@npm:0.2.3" +"@computernewb/superqemu@npm:0.2.4-alpha0": + version: 0.2.4-alpha0 + resolution: "@computernewb/superqemu@npm:0.2.4-alpha0" dependencies: - "@computernewb/nodejs-rfb": "npm:^0.3.0" execa: "npm:^8.0.1" pino: "npm:^9.3.1" - checksum: 10c0/70d63278f4cdd6e5521a9bf62b9492380c96a94dcbb2e719e7396a4139c4238560ad7deea86ea163af6fc8c526b9f658e7f5e7586391fe4b57f5257467a16eb1 + checksum: 10c0/ac002c2da734db0fc8823a4ae6c7361ef9cf2aa15fd0345376163ddd1f124654fd8d7576c3e9831ec57f6c41263683c3684e31db8066c31cff9abfc7a55a7346 languageName: node linkType: hard @@ -76,7 +75,7 @@ __metadata: resolution: "@cvmts/cvmts@workspace:cvmts" dependencies: "@computernewb/nodejs-rfb": "npm:^0.3.0" - "@computernewb/superqemu": "npm:^0.2.3" + "@computernewb/superqemu": "npm:0.2.4-alpha0" "@cvmts/cvm-rs": "npm:*" "@maxmind/geoip2-node": "npm:^5.0.0" "@types/node": "npm:^20.12.5"