better display/vm stuff
- moved superqemu's "QemuDisplay" here; the VNC VM and Qemu both share it (and it has been renamed to a less goofy dumb name) - VNC VM has been heavily refactored to just use the VNC display we have (this means only one source of truth, less bugs, and it's generally just Better to share the code imho). this means that future plans to abstract this further (or implement the client in cvm-rs in general) won't cause any explosions, or require duplicate effort - vms are now in src/vm/... just better organization - superqemu doesn't manage a display anymore (or care about it, other than making sure the socket is unlinked on stop). Instead now it provides info for us to setup our own VNC client. This is also why we provide our own shim interface This currently relies on a alpha version of superqemu. Before this is merged into cvmts main I will publish a stable tag and point cvmts to that new version
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@computernewb/nodejs-rfb": "^0.3.0",
|
"@computernewb/nodejs-rfb": "^0.3.0",
|
||||||
"@computernewb/superqemu": "^0.2.3",
|
"@computernewb/superqemu": "0.2.4-alpha0",
|
||||||
"@cvmts/cvm-rs": "*",
|
"@cvmts/cvm-rs": "*",
|
||||||
"@maxmind/geoip2-node": "^5.0.0",
|
"@maxmind/geoip2-node": "^5.0.0",
|
||||||
"execa": "^8.0.1",
|
"execa": "^8.0.1",
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import { readFileSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import AuthManager from './AuthManager.js';
|
import AuthManager from './AuthManager.js';
|
||||||
import { JPEGEncoder } from './JPEGEncoder.js';
|
import { JPEGEncoder } from './JPEGEncoder.js';
|
||||||
import VM from './VM.js';
|
import VM from './vm/interface.js';
|
||||||
import { ReaderModel } from '@maxmind/geoip2-node';
|
import { ReaderModel } from '@maxmind/geoip2-node';
|
||||||
import * as msgpack from 'msgpackr';
|
import * as msgpack from 'msgpackr';
|
||||||
import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/collab-vm-1.2-binary-protocol';
|
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 pino from 'pino';
|
||||||
import { BanManager } from './BanManager.js';
|
import { BanManager } from './BanManager.js';
|
||||||
|
|
||||||
@@ -123,33 +123,34 @@ export default class CollabVMServer {
|
|||||||
|
|
||||||
this.VM = vm;
|
this.VM = vm;
|
||||||
|
|
||||||
// this probably should be made general at some point,
|
// the VM interface should probably be allowed to return a nullable display
|
||||||
// and the VM interface allowed to return a nullable display
|
|
||||||
// but i cba
|
// but i cba
|
||||||
let self = this;
|
let self = this;
|
||||||
if (config.vm.type == 'qemu') {
|
|
||||||
(vm as QemuVM).on('statechange', (newState: VMState) => {
|
vm.Events().on('statechange', (newState: VMState) => {
|
||||||
if (newState == VMState.Started) {
|
if (newState == VMState.Started) {
|
||||||
self.logger.info('VM started');
|
self.logger.info('VM started');
|
||||||
// well aware this sucks but whatever
|
|
||||||
self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size));
|
// start the display
|
||||||
self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect));
|
if (self.VM.GetDisplay() == null) {
|
||||||
self.VM.GetDisplay().on('frame', () => self.OnDisplayFrame());
|
self.VM.StartDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newState == VMState.Stopped) {
|
self.VM.GetDisplay()?.on('connected', () => {
|
||||||
setTimeout(async () => {
|
// well aware this sucks but whatever
|
||||||
self.logger.info('restarting VM');
|
self.VM.GetDisplay()?.on('resize', (size: Size) => self.OnDisplayResized(size));
|
||||||
await self.VM.Start();
|
self.VM.GetDisplay()?.on('rect', (rect: Rect) => self.OnDisplayRectangle(rect));
|
||||||
}, kRestartTimeout);
|
self.VM.GetDisplay()?.on('frame', () => self.OnDisplayFrame());
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
} else {
|
|
||||||
// this sucks too fix this
|
if (newState == VMState.Stopped) {
|
||||||
self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size));
|
setTimeout(async () => {
|
||||||
self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect));
|
self.logger.info('restarting VM');
|
||||||
self.VM.GetDisplay().on('frame', () => self.OnDisplayFrame());
|
await self.VM.Start();
|
||||||
}
|
}, kRestartTimeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// authentication manager
|
// authentication manager
|
||||||
this.auth = auth;
|
this.auth = auth;
|
||||||
@@ -681,7 +682,8 @@ export default class CollabVMServer {
|
|||||||
break;
|
break;
|
||||||
case '1':
|
case '1':
|
||||||
this.screenHidden = false;
|
this.screenHidden = false;
|
||||||
let displaySize = this.VM.GetDisplay().Size();
|
let displaySize = this.VM.GetDisplay()?.Size();
|
||||||
|
if (displaySize == undefined) return;
|
||||||
|
|
||||||
let encoded = await this.MakeRectData({
|
let encoded = await this.MakeRectData({
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -888,8 +890,7 @@ export default class CollabVMServer {
|
|||||||
|
|
||||||
let promises: Promise<void>[] = [];
|
let promises: Promise<void>[] = [];
|
||||||
|
|
||||||
for(let rect of self.rectQueue)
|
for (let rect of self.rectQueue) promises.push(doRect(rect));
|
||||||
promises.push(doRect(rect));
|
|
||||||
|
|
||||||
this.rectQueue = [];
|
this.rectQueue = [];
|
||||||
|
|
||||||
@@ -942,7 +943,7 @@ export default class CollabVMServer {
|
|||||||
let display = this.VM.GetDisplay();
|
let display = this.VM.GetDisplay();
|
||||||
|
|
||||||
// oh well
|
// oh well
|
||||||
if (!display.Connected()) return '';
|
if (!display?.Connected()) return '';
|
||||||
|
|
||||||
let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size());
|
let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size());
|
||||||
return buf.toString('base64');
|
return buf.toString('base64');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import VNCVMDef from './VNCVM/VNCVMDef';
|
import VNCVMDef from './vm/vnc/VNCVMDef';
|
||||||
|
|
||||||
export default interface IConfig {
|
export default interface IConfig {
|
||||||
http: {
|
http: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Size, Rect } from './VMDisplay.js';
|
import { Size, Rect } from './Utilities';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import * as cvm from '@cvmts/cvm-rs';
|
import * as cvm from '@cvmts/cvm-rs';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { Permissions } from './IConfig';
|
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) {
|
export function Randint(min: number, max: number) {
|
||||||
return Math.floor(Math.random() * (max - min) + min);
|
return Math.floor(Math.random() * (max - min) + min);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { VMState } from '@computernewb/superqemu';
|
|
||||||
import { VMDisplay } from './VMDisplay.js';
|
|
||||||
|
|
||||||
export default interface VM {
|
|
||||||
Start(): Promise<void>;
|
|
||||||
Stop(): Promise<void>;
|
|
||||||
Reboot(): Promise<void>;
|
|
||||||
Reset(): Promise<void>;
|
|
||||||
MonitorCommand(command: string): Promise<any>;
|
|
||||||
GetDisplay(): VMDisplay;
|
|
||||||
GetState(): VMState;
|
|
||||||
SnapshotsSupported(): boolean;
|
|
||||||
}
|
|
||||||
41
cvmts/src/display/batch.ts
Normal file
41
cvmts/src/display/batch.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Size, Rect } from "../Utilities";
|
||||||
|
|
||||||
|
export function BatchRects(size: Size, rects: Array<Rect>): 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
import EventEmitter from 'node:events';
|
import EventEmitter from 'node:events';
|
||||||
|
import { Size, Rect } from '../Utilities';
|
||||||
// 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface VMDisplay extends EventEmitter {
|
export interface VMDisplay extends EventEmitter {
|
||||||
Connect(): void;
|
Connect(): void;
|
||||||
@@ -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 { VncClient } from '@computernewb/nodejs-rfb';
|
||||||
import { BatchRects, VMState } from '@computernewb/superqemu';
|
import { EventEmitter } from 'node:events';
|
||||||
import { execaCommand } from 'execa';
|
import { Clamp } from '../Utilities.js';
|
||||||
import pino from 'pino';
|
import { BatchRects } from './batch.js';
|
||||||
|
import { VMDisplay } from './interface.js';
|
||||||
|
|
||||||
function Clamp(input: number, min: number, max: number) {
|
import { Size, Rect } from '../Utilities.js';
|
||||||
return Math.min(Math.max(input, min), max);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function Sleep(ms: number) {
|
const kQemuFps = 60;
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class VNCVM extends EventEmitter implements VM, VMDisplay {
|
export type VncRect = {
|
||||||
def: VNCVMDef;
|
x: number;
|
||||||
logger;
|
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({
|
private displayVnc = new VncClient({
|
||||||
debug: false,
|
debug: false,
|
||||||
fps: 60,
|
fps: kQemuFps,
|
||||||
encodings: [VncClient.consts.encodings.raw, VncClient.consts.encodings.pseudoDesktopSize]
|
|
||||||
});
|
|
||||||
private vncShouldReconnect: boolean = false;
|
|
||||||
|
|
||||||
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();
|
super();
|
||||||
this.def = def;
|
|
||||||
// TODO: Now that we're using an actual structured logger can we please
|
this.vncConnectOpts = vncConnectOpts;
|
||||||
this.logger = pino({ name: `CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}` });
|
|
||||||
|
|
||||||
this.displayVnc.on('connectTimeout', () => {
|
this.displayVnc.on('connectTimeout', () => {
|
||||||
this.Reconnect();
|
this.Reconnect();
|
||||||
@@ -40,7 +54,6 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.displayVnc.on('disconnect', () => {
|
this.displayVnc.on('disconnect', () => {
|
||||||
this.logger.info('Disconnected');
|
|
||||||
this.Reconnect();
|
this.Reconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,10 +62,9 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.displayVnc.on('firstFrameUpdate', () => {
|
this.displayVnc.on('firstFrameUpdate', () => {
|
||||||
this.logger.info('Connected');
|
|
||||||
// apparently this library is this good.
|
// apparently this library is this good.
|
||||||
// at least it's better than the two others which exist.
|
// at least it's better than the two others which exist.
|
||||||
this.displayVnc.changeFps(60);
|
this.displayVnc.changeFps(kQemuFps);
|
||||||
this.emit('connected');
|
this.emit('connected');
|
||||||
|
|
||||||
this.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
|
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<void> {
|
|
||||||
if (this.def.restoreCmd) await execaCommand(this.def.restoreCmd, { shell: true });
|
|
||||||
else {
|
|
||||||
await this.Stop();
|
|
||||||
await Sleep(1000);
|
|
||||||
await this.Start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Reconnect() {
|
private Reconnect() {
|
||||||
if (this.displayVnc.connected) return;
|
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
|
// TODO: this should also give up after a max tries count
|
||||||
// if we fail after max tries, emit a event
|
// if we fail after max tries, emit a event
|
||||||
|
|
||||||
this.displayVnc.connect({
|
this.displayVnc.connect(this.vncConnectOpts);
|
||||||
host: this.def.vncHost,
|
|
||||||
port: this.def.vncPort,
|
|
||||||
path: null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async Start(): Promise<void> {
|
Connect() {
|
||||||
this.logger.info('Connecting');
|
|
||||||
if (this.def.startCmd) await execaCommand(this.def.startCmd, { shell: true });
|
|
||||||
this.Connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
async Stop(): Promise<void> {
|
|
||||||
this.logger.info('Disconnecting');
|
|
||||||
this.Disconnect();
|
|
||||||
if (this.def.stopCmd) await execaCommand(this.def.stopCmd, { shell: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async Reboot(): Promise<void> {
|
|
||||||
if (this.def.rebootCmd) await execaCommand(this.def.rebootCmd, { shell: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async MonitorCommand(command: string): Promise<any> {
|
|
||||||
// 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 {
|
|
||||||
this.vncShouldReconnect = true;
|
this.vncShouldReconnect = true;
|
||||||
this.Reconnect();
|
this.Reconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
Disconnect(): void {
|
Disconnect() {
|
||||||
this.vncShouldReconnect = false;
|
this.vncShouldReconnect = false;
|
||||||
this.displayVnc.disconnect();
|
this.displayVnc.disconnect();
|
||||||
|
|
||||||
|
// bye bye!
|
||||||
|
this.displayVnc.removeAllListeners();
|
||||||
|
this.removeAllListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Connected(): boolean {
|
Connected() {
|
||||||
return this.displayVnc.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);
|
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);
|
if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,18 +3,19 @@ import IConfig from './IConfig.js';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import CollabVMServer from './CollabVMServer.js';
|
import CollabVMServer from './CollabVMServer.js';
|
||||||
|
|
||||||
import { QemuVM, QemuVmDefinition } from '@computernewb/superqemu';
|
import { QemuVmDefinition } from '@computernewb/superqemu';
|
||||||
|
|
||||||
import AuthManager from './AuthManager.js';
|
import AuthManager from './AuthManager.js';
|
||||||
import WSServer from './WebSocket/WSServer.js';
|
import WSServer from './WebSocket/WSServer.js';
|
||||||
import { User } from './User.js';
|
import { User } from './User.js';
|
||||||
import TCPServer from './TCP/TCPServer.js';
|
import TCPServer from './TCP/TCPServer.js';
|
||||||
import VM from './VM.js';
|
import VM from './vm/interface.js';
|
||||||
import VNCVM from './VNCVM/VNCVM.js';
|
import VNCVM from './vm/vnc/VNCVM.js';
|
||||||
import GeoIPDownloader from './GeoIPDownloader.js';
|
import GeoIPDownloader from './GeoIPDownloader.js';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { Database } from './Database.js';
|
import { Database } from './Database.js';
|
||||||
import { BanManager } from './BanManager.js';
|
import { BanManager } from './BanManager.js';
|
||||||
|
import { QemuVMShim } from './vm/qemu.js';
|
||||||
|
|
||||||
let logger = pino();
|
let logger = pino();
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ async function start() {
|
|||||||
vncPort: Config.qemu.vncPort,
|
vncPort: Config.qemu.vncPort,
|
||||||
};
|
};
|
||||||
|
|
||||||
VM = new QemuVM(def);
|
VM = new QemuVMShim(def);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'vncvm': {
|
case 'vncvm': {
|
||||||
|
|||||||
34
cvmts/src/vm/interface.ts
Normal file
34
cvmts/src/vm/interface.ts
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
// Stops the VM.
|
||||||
|
Stop(): Promise<void>;
|
||||||
|
|
||||||
|
// Reboots the VM.
|
||||||
|
Reboot(): Promise<void>;
|
||||||
|
|
||||||
|
// Resets the VM.
|
||||||
|
Reset(): Promise<void>;
|
||||||
|
|
||||||
|
// Monitor command
|
||||||
|
MonitorCommand(command: string): Promise<any>;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
89
cvmts/src/vm/qemu.ts
Normal file
89
cvmts/src/vm/qemu.ts
Normal file
@@ -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<void> {
|
||||||
|
return this.vm.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Stop(): Promise<void> {
|
||||||
|
await this.vm.Stop();
|
||||||
|
|
||||||
|
this.display?.Disconnect();
|
||||||
|
this.display = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Reboot(): Promise<void> {
|
||||||
|
return this.vm.Reboot();
|
||||||
|
}
|
||||||
|
|
||||||
|
Reset(): Promise<void> {
|
||||||
|
return this.vm.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
MonitorCommand(command: string): Promise<any> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
cvmts/src/vm/vnc/VNCVM.ts
Normal file
111
cvmts/src/vm/vnc/VNCVM.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (this.def.startCmd) await execaCommand(this.def.startCmd, { shell: true });
|
||||||
|
this.SetState(VMState.Started);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Stop(): Promise<void> {
|
||||||
|
this.logger.info('Disconnecting');
|
||||||
|
this.Disconnect();
|
||||||
|
if (this.def.stopCmd) await execaCommand(this.def.stopCmd, { shell: true });
|
||||||
|
this.SetState(VMState.Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Reboot(): Promise<void> {
|
||||||
|
if (this.def.rebootCmd) await execaCommand(this.def.rebootCmd, { shell: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async MonitorCommand(command: string): Promise<any> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
yarn.lock
11
yarn.lock
@@ -41,14 +41,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@computernewb/superqemu@npm:^0.2.3":
|
"@computernewb/superqemu@npm:0.2.4-alpha0":
|
||||||
version: 0.2.3
|
version: 0.2.4-alpha0
|
||||||
resolution: "@computernewb/superqemu@npm:0.2.3"
|
resolution: "@computernewb/superqemu@npm:0.2.4-alpha0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@computernewb/nodejs-rfb": "npm:^0.3.0"
|
|
||||||
execa: "npm:^8.0.1"
|
execa: "npm:^8.0.1"
|
||||||
pino: "npm:^9.3.1"
|
pino: "npm:^9.3.1"
|
||||||
checksum: 10c0/70d63278f4cdd6e5521a9bf62b9492380c96a94dcbb2e719e7396a4139c4238560ad7deea86ea163af6fc8c526b9f658e7f5e7586391fe4b57f5257467a16eb1
|
checksum: 10c0/ac002c2da734db0fc8823a4ae6c7361ef9cf2aa15fd0345376163ddd1f124654fd8d7576c3e9831ec57f6c41263683c3684e31db8066c31cff9abfc7a55a7346
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -76,7 +75,7 @@ __metadata:
|
|||||||
resolution: "@cvmts/cvmts@workspace:cvmts"
|
resolution: "@cvmts/cvmts@workspace:cvmts"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@computernewb/nodejs-rfb": "npm:^0.3.0"
|
"@computernewb/nodejs-rfb": "npm:^0.3.0"
|
||||||
"@computernewb/superqemu": "npm:^0.2.3"
|
"@computernewb/superqemu": "npm:0.2.4-alpha0"
|
||||||
"@cvmts/cvm-rs": "npm:*"
|
"@cvmts/cvm-rs": "npm:*"
|
||||||
"@maxmind/geoip2-node": "npm:^5.0.0"
|
"@maxmind/geoip2-node": "npm:^5.0.0"
|
||||||
"@types/node": "npm:^20.12.5"
|
"@types/node": "npm:^20.12.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user