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');