diff --git a/config.example.toml b/config.example.toml index 0ba3885..66e3412 100644 --- a/config.example.toml +++ b/config.example.toml @@ -11,6 +11,11 @@ origin = false # Origins to accept connections from. originAllowedDomains = ["computernewb.com"] +[tcp] +enabled = false +host = "0.0.0.0" +port = 6014 + [auth] enabled = false apiEndpoint = "" @@ -24,6 +29,9 @@ vote = true [vm] +type = "qemu" + +[qemu] qemuArgs = "qemu-system-x86_64" vncPort = 5900 snapshots = true @@ -33,6 +41,14 @@ snapshots = true # Comment out qmpSockDir if you're using Windows. qmpSockDir = "/tmp/" +[vncvm] +vncHost = "127.0.0.1" +vncPort = 5900 +# startCmd = "" +# stopCmd = "" +# rebootCmd = "" +# restoreCmd = "" + [collabvm] node = "acoolvm" displayname = "A Really Cool CollabVM Instance" diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index b75287d..48b5bb2 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -1,5 +1,3 @@ -import { WebSocketServer, WebSocket } from 'ws'; -import * as http from 'http'; import IConfig from './IConfig.js'; import * as Utilities from './Utilities.js'; import { User, Rank } from './User.js'; @@ -8,19 +6,17 @@ import * as guacutils from './guacutils.js'; import CircularBuffer from 'mnemonist/circular-buffer.js'; import Queue from 'mnemonist/queue.js'; import { createHash } from 'crypto'; -import { isIP } from 'node:net'; import { QemuVM, QemuVmDefinition } from '@cvmts/qemu'; -import { IPData, IPDataManager } from './IPData.js'; +import { IPDataManager } from './IPData.js'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import AuthManager from './AuthManager.js'; import { Size, Rect, Logger } from '@cvmts/shared'; - import { JPEGEncoder } from './JPEGEncoder.js'; +import VM from './VM.js'; // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before -const __filename = import.meta.filename; const __dirname = import.meta.dirname; const kCVMTSAssetsRoot = path.resolve(__dirname, '../../assets'); @@ -78,14 +74,14 @@ export default class CollabVMServer { // Indefinite turn private indefiniteTurn: User | null; private ModPerms: number; - private VM: QemuVM; + private VM: VM; // Authentication manager private auth: AuthManager | null; private logger = new Logger('CVMTS.Server'); - constructor(config: IConfig, vm: QemuVM, auth: AuthManager | null) { + constructor(config: IConfig, vm: VM, auth: AuthManager | null) { this.Config = config; this.ChatHistory = new CircularBuffer(Array, this.Config.collabvm.maxChatHistoryLength); this.TurnQueue = new Queue(); @@ -214,7 +210,7 @@ export default class CollabVMServer { return; } client.connectedToNode = true; - client.sendMsg(guacutils.encode('connect', '1', '1', this.Config.vm.snapshots ? '1' : '0', '0')); + client.sendMsg(guacutils.encode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode('chat', '', this.Config.collabvm.motd)); if (this.screenHidden) { @@ -247,7 +243,7 @@ export default class CollabVMServer { return; } - client.sendMsg(guacutils.encode('connect', '1', '1', this.Config.vm.snapshots ? '1' : '0', '0')); + client.sendMsg(guacutils.encode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode('chat', '', this.Config.collabvm.motd)); @@ -361,7 +357,7 @@ export default class CollabVMServer { this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false); break; case 'vote': - if (!this.Config.vm.snapshots) return; + if (!this.VM.SnapshotsSupported()) return; if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; if (!client.connectedToNode) return; if (msgArr.length !== 2) return; @@ -461,7 +457,7 @@ export default class CollabVMServer { // Reboot if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.reboot)) return; if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return; - this.VM.MonitorCommand('system_reset'); + await this.VM.Reboot(); break; case '12': // Ban diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index d438e56..6c7f10d 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -1,3 +1,5 @@ +import VNCVMDef from "./VNCVM/VNCVMDef"; + export default interface IConfig { http: { host: string; @@ -24,6 +26,9 @@ export default interface IConfig { }; }; vm: { + type: "qemu" | "vncvm"; + }; + qemu: { qemuArgs: string; vncPort: number; snapshots: boolean; @@ -31,6 +36,7 @@ export default interface IConfig { qmpPort: number | null; qmpSockDir: string | null; }; + vncvm: VNCVMDef; collabvm: { node: string; displayname: string; diff --git a/cvmts/src/VM.ts b/cvmts/src/VM.ts new file mode 100644 index 0000000..11b8a06 --- /dev/null +++ b/cvmts/src/VM.ts @@ -0,0 +1,11 @@ +import VMDisplay from "./VMDisplay.js"; + +export default interface VM { + Start(): Promise; + Stop(): Promise; + Reboot(): Promise; + Reset(): Promise; + MonitorCommand(command: string): Promise; + GetDisplay(): VMDisplay; + SnapshotsSupported(): boolean; +} \ No newline at end of file diff --git a/cvmts/src/VMDisplay.ts b/cvmts/src/VMDisplay.ts new file mode 100644 index 0000000..adee89f --- /dev/null +++ b/cvmts/src/VMDisplay.ts @@ -0,0 +1,12 @@ +import { Size } from "@cvmts/shared"; +import EventEmitter from "node:events"; + +export default interface VMDisplay extends EventEmitter { + Connect(): void; + Disconnect(): void; + Connected(): boolean; + Buffer(): Buffer; + Size(): Size; + MouseEvent(x: number, y: number, buttons: number): void; + KeyboardEvent(keysym: number, pressed: boolean): void; +} \ No newline at end of file diff --git a/cvmts/src/VNCVM/VNCVM.ts b/cvmts/src/VNCVM/VNCVM.ts new file mode 100644 index 0000000..52d1cee --- /dev/null +++ b/cvmts/src/VNCVM/VNCVM.ts @@ -0,0 +1,174 @@ +import EventEmitter from "events"; +import VNCVMDef from "./VNCVMDef"; +import VM from "../VM"; +import VMDisplay from "../VMDisplay"; +import { Clamp, Logger, Rect, Size, Sleep } from "@cvmts/shared"; +import { VncClient } from '@computernewb/nodejs-rfb'; +import { BatchRects } from "@cvmts/qemu"; +import { execaCommand } from "execa"; + +export default class VNCVM extends EventEmitter implements VM, VMDisplay { + def : VNCVMDef; + logger: Logger; + private displayVnc = new VncClient({ + debug: false, + fps: 60, + encodings: [ + VncClient.consts.encodings.raw, + VncClient.consts.encodings.pseudoDesktopSize + ] + }); + private vncShouldReconnect: boolean = false; + + constructor(def : VNCVMDef) { + super(); + this.def = def; + this.logger = new Logger(`CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}`); + + this.displayVnc.on('connectTimeout', () => { + this.Reconnect(); + }); + + this.displayVnc.on('authError', () => { + this.Reconnect(); + }); + + this.displayVnc.on('disconnect', () => { + this.logger.Info('Disconnected'); + this.Reconnect(); + }); + + this.displayVnc.on('closed', () => { + this.Reconnect(); + }); + + 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.emit('connected'); + + this.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight }); + //this.emit('rect', { x: 0, y: 0, width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight }); + this.emit('frame'); + }); + + this.displayVnc.on('desktopSizeChanged', (size: Size) => { + this.emit('resize', size); + }); + + let rects: Rect[] = []; + + this.displayVnc.on('rectUpdateProcessed', (rect: Rect) => { + rects.push(rect); + }); + + this.displayVnc.on('frameUpdated', (fb: Buffer) => { + // use the cvmts batcher + let batched = BatchRects(this.Size(), rects); + this.emit('rect', batched); + + // unbatched (watch the performace go now) + //for(let rect of rects) + // this.emit('rect', rect); + + rects = []; + + this.emit('frame'); + }); + } + + + 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; + + if (!this.vncShouldReconnect) return; + + // 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, + }); + } + + 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; + } + + SnapshotsSupported(): boolean { + return true; + } + + Connect(): void { + this.vncShouldReconnect = true; + this.Reconnect(); + } + + Disconnect(): void { + this.vncShouldReconnect = false; + this.displayVnc.disconnect(); + } + + Connected(): boolean { + return this.displayVnc.connected; + } + + Buffer(): Buffer { + return this.displayVnc.fb; + } + + Size(): Size { + if (!this.displayVnc.connected) + return { + width: 0, + height: 0 + }; + + return { + width: this.displayVnc.clientWidth, + height: this.displayVnc.clientHeight + }; + } + + MouseEvent(x: number, y: number, buttons: number): void { + 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 { + if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed); + } +} \ No newline at end of file diff --git a/cvmts/src/VNCVM/VNCVMDef.ts b/cvmts/src/VNCVM/VNCVMDef.ts new file mode 100644 index 0000000..b6d34d9 --- /dev/null +++ b/cvmts/src/VNCVM/VNCVMDef.ts @@ -0,0 +1,8 @@ +export default interface VNCVMDef { + vncHost : string; + vncPort : number; + startCmd : string | null; + stopCmd : string | null; + rebootCmd : string | null; + restoreCmd : string | null; +} \ No newline at end of file diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index 37f7ee6..3ead48a 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -10,6 +10,8 @@ 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'; let logger = new Shared.Logger('CVMTS.Init'); @@ -31,28 +33,53 @@ try { process.exit(1); } -async function start() { - // Print a warning if qmpSockDir is set - // and the host OS is Windows, as this - // configuration will very likely not work. - if (process.platform === 'win32' && Config.vm.qmpSockDir) { - logger.Warning("You appear to have the option 'qmpSockDir' enabled in the config."); - logger.Warning('This is not supported on Windows, and you will likely run into issues.'); - logger.Warning('To remove this warning, use the qmpHost and qmpPort options instead.'); - } +let exiting = false; +let VM : VM; +async function stop() { + if (exiting) return; + exiting = true; + await VM.Stop(); + process.exit(0); +} + +async function start() { // Init the auth manager if enabled let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; + switch (Config.vm.type) { + case "qemu": { + // Print a warning if qmpSockDir is set + // and the host OS is Windows, as this + // configuration will very likely not work. + if (process.platform === 'win32' && Config.qemu.qmpSockDir !== null) { + logger.Warning("You appear to have the option 'qmpSockDir' enabled in the config."); + logger.Warning('This is not supported on Windows, and you will likely run into issues.'); + logger.Warning('To remove this warning, use the qmpHost and qmpPort options instead.'); + } - // Fire up the VM - let def: QemuVmDefinition = { - id: Config.collabvm.node, - command: Config.vm.qemuArgs - }; + // Fire up the VM + let def: QemuVmDefinition = { + id: Config.collabvm.node, + command: Config.qemu.qemuArgs + }; + + VM = new QemuVM(def); + break; + } + case "vncvm": { + VM = new VNCVM(Config.vncvm); + break; + } + default: { + logger.Error('Invalid VM type in config: {0}', Config.vm.type); + process.exit(1); + return; + } + } + process.on('SIGINT', async () => await stop()); + process.on('SIGTERM', async () => await stop()); - var VM = new QemuVM(def); await VM.Start(); - // Start up the server var CVM = new CollabVMServer(Config, VM, auth); diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index ca1e396..b1ab475 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -78,6 +78,14 @@ export class QemuVM extends EventEmitter { await this.StartQemu(cmd); } + SnapshotsSupported() : boolean { + return gVMShouldSnapshot; + } + + async Reboot() : Promise { + await this.MonitorCommand('system_reset'); + } + async Stop() { // This is called in certain lifecycle places where we can't safely assert state yet //this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM');