Merge pull request #30 from computernewb/dev/better_display
merge better display branch
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",
|
||||||
"@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,32 @@ export default class CollabVMServer {
|
|||||||
|
|
||||||
this.VM = vm;
|
this.VM = vm;
|
||||||
|
|
||||||
// this probably should be made general at some point,
|
|
||||||
// and the VM interface allowed to return a nullable display
|
|
||||||
// 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 +680,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 +888,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 +941,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;
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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, VMDisplay {
|
|
||||||
def: VNCVMDef;
|
|
||||||
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;
|
|
||||||
// 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.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<void> {
|
|
||||||
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<void> {
|
|
||||||
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.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,13 @@
|
|||||||
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 = {
|
// events:
|
||||||
x: number;
|
//
|
||||||
y: number;
|
// 'connected' -> () -> on successful connection
|
||||||
width: number;
|
// 'resize' -> (w, h) -> done when resize occurs
|
||||||
height: number;
|
// 'rect' -> (x, y, Buffer) -> framebuffer rect (RGBA)
|
||||||
};
|
// 'frame' -> () -> done at end of frame
|
||||||
|
|
||||||
export interface VMDisplay extends EventEmitter {
|
export interface VMDisplay extends EventEmitter {
|
||||||
Connect(): void;
|
Connect(): void;
|
||||||
150
cvmts/src/display/vnc.ts
Normal file
150
cvmts/src/display/vnc.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { VncClient } from '@computernewb/nodejs-rfb';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { Clamp } from '../Utilities.js';
|
||||||
|
import { BatchRects } from './batch.js';
|
||||||
|
import { VMDisplay } from './interface.js';
|
||||||
|
|
||||||
|
import { Size, Rect } from '../Utilities.js';
|
||||||
|
|
||||||
|
// the FPS to run the VNC client at
|
||||||
|
// This only affects internal polling,
|
||||||
|
// if the VNC itself is sending updates at a slower rate
|
||||||
|
// the display will be at that slower rate
|
||||||
|
const kVncBaseFramerate = 60;
|
||||||
|
|
||||||
|
export type VncRect = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: replace with a non-asshole VNC client (prefably one implemented
|
||||||
|
// as a part of cvm-rs)
|
||||||
|
export class VncDisplay extends EventEmitter implements VMDisplay {
|
||||||
|
private displayVnc = new VncClient({
|
||||||
|
debug: false,
|
||||||
|
fps: kVncBaseFramerate,
|
||||||
|
|
||||||
|
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.vncConnectOpts = vncConnectOpts;
|
||||||
|
|
||||||
|
this.displayVnc.on('connectTimeout', () => {
|
||||||
|
this.Reconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.displayVnc.on('authError', () => {
|
||||||
|
this.Reconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.displayVnc.on('disconnect', () => {
|
||||||
|
this.Reconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.displayVnc.on('closed', () => {
|
||||||
|
this.Reconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.displayVnc.on('firstFrameUpdate', () => {
|
||||||
|
// apparently this library is this good.
|
||||||
|
// at least it's better than the two others which exist.
|
||||||
|
this.displayVnc.changeFps(kVncBaseFramerate);
|
||||||
|
this.emit('connected');
|
||||||
|
this.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(this.vncConnectOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
Connect() {
|
||||||
|
this.vncShouldReconnect = true;
|
||||||
|
this.Reconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Disconnect() {
|
||||||
|
this.vncShouldReconnect = false;
|
||||||
|
this.displayVnc.disconnect();
|
||||||
|
|
||||||
|
// bye bye!
|
||||||
|
this.displayVnc.removeAllListeners();
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Connected() {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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":
|
||||||
version: 0.2.3
|
version: 0.2.4
|
||||||
resolution: "@computernewb/superqemu@npm:0.2.3"
|
resolution: "@computernewb/superqemu@npm:0.2.4"
|
||||||
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/9ed3190bd95c60a6f74fbb6d29cbd9909ff18b04d64b5a09c02dec91169304f439d7b0ac91848b69621066810cdfef4a0dbf97075938ee40b3aebd74376b4440
|
||||||
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"
|
||||||
"@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