backend: Port to superqemu

This commit is contained in:
Lily Tsuru 2024-07-24 04:22:31 -04:00
parent 7d5b7bb9d4
commit 203e8c258c
30 changed files with 4465 additions and 5297 deletions

BIN
.yarn/install-state.gz Normal file

Binary file not shown.

1
.yarnrc.yml Normal file
View file

@ -0,0 +1 @@
nodeLinker: node-modules

View file

@ -3,12 +3,9 @@
socket.computer, except not powered by socket.io anymore, and with many less bugs. This monorepo builds
- The backend
- A QEMU VM runner package (feel free to steal it)
- Shared components
- The webapp (TODO)
## Building
```bash

View file

@ -1,35 +1,29 @@
{
"name": "@socketcomputer/backend",
"version": "1.0.0",
"private": "true",
"description": "socket 2.0 backend",
"type": "module",
"scripts": {
"build": "parcel build src/index.ts --target node"
},
"author": "modeco80",
"license": "MIT",
"targets": {
"node":{
"context": "node",
"outputFormat": "esmodule"
}
"node": {
"context": "node",
"outputFormat": "esmodule"
}
},
"dependencies": {
"@computernewb/superqemu": "^0.1.0",
"@fastify/websocket": "^10.0.1",
"@socketcomputer/qemu": "*",
"@socketcomputer/shared": "*",
"canvas": "^2.11.2",
"fastify": "^4.26.2",
"mnemonist": "^0.39.8",
"canvas": "^2.11.2"
"mnemonist": "^0.39.8"
},
"devDependencies": {
"parcel": "^2.12.0",
"@types/ws": "^8.5.10"
"@types/ws": "^8.5.10",
"parcel": "^2.12.0"
}
}

View file

@ -2,7 +2,7 @@
// which are standardized across all crusttest slots.
// (This file has been bastardized for socket2)
import { QemuVmDefinition } from '@socketcomputer/qemu';
import { QemuVmDefinition } from '@computernewb/superqemu';
const kQemuPath = '/srv/collabvm/qemu/bin/qemu-system-x86_64';
@ -63,6 +63,7 @@ export function Slot_PCDef(
return {
id: 'socketvm1',
command: qCommand
command: qCommand.join(' '),
snapshot: true
};
}

View file

@ -1,4 +1,4 @@
import { QemuVmDefinition, QemuVM, setSnapshot } from '@socketcomputer/qemu';
import { QemuVmDefinition, QemuVM } from '@computernewb/superqemu';
import { Slot_PCDef } from './SlotQemuDefs.js';
import { FastifyInstance, fastify, FastifyRequest } from 'fastify';
@ -6,6 +6,7 @@ import * as fastifyWebsocket from '@fastify/websocket';
import { SocketVM } from './SocketVM.js';
import { VMUser } from './VMUser.js';
import pino from 'pino';
// CONFIG types (not used yet)
export type SocketComputerConfig_VM = {
@ -26,6 +27,10 @@ export class SocketComputerServer {
exposeHeadRoutes: false
});
private logger = pino({
name: "Sc2Server"
});
Init() {
this.fastify.register(fastifyWebsocket.default);
this.fastify.register(async (app, _) => this.Routes(app), {});
@ -33,7 +38,7 @@ export class SocketComputerServer {
async Listen() {
try {
console.log('Backend starting...');
this.logger.info('Backend starting...');
// create and start teh VMxorz!!!!
await this.InitVM();
@ -43,6 +48,7 @@ export class SocketComputerServer {
port: 4050
});
} catch (err) {
this.logger.error(err, 'Error listening');
return;
}
}
@ -56,7 +62,6 @@ export class SocketComputerServer {
this.vm = new SocketVM(new QemuVM(slotDef));
// Boot it up
setSnapshot(true);
await this.vm.Start();
}
@ -75,6 +80,7 @@ export class SocketComputerServer {
return;
}
self.vm?.AddUser(new VMUser(connection, address));
});
}

View file

@ -1,13 +1,14 @@
import { EventEmitter } from 'node:events';
import { TurnQueue, UserTimeTuple, kTurnTimeSeconds } from './TurnQueue.js';
import { VMUser } from './VMUser.js';
import { QemuDisplay, QemuVM, VMState } from '@socketcomputer/qemu';
import { QemuDisplay, QemuVM, VMState } from '@computernewb/superqemu';
import { ExtendableTimer } from './ExtendableTimer.js';
import { kMaxUserNameLength } from '@socketcomputer/shared';
import * as Shared from '@socketcomputer/shared';
import { Canvas } from 'canvas';
import pino from 'pino';
// for the maximum socket.io experience
const kCanvasJpegQuality = 0.25;
@ -24,18 +25,22 @@ export class SocketVM extends EventEmitter {
private users: Array<VMUser> = [];
private queue: TurnQueue = new TurnQueue();
private logger = pino({
name: 'Sc2VM'
});
constructor(vm: QemuVM) {
super();
this.vm = vm;
this.timer.on('expired', async () => {
// bye bye!
console.log(`[SocketVM] VM timer expired, resetting..`);
this.logger.info(`[SocketVM] VM timer expired, resetting..`);
await this.vm.Stop();
});
this.timer.on('expiry-near', async () => {
console.log(`[SocketVM] VM timer expires in 1 minute.`);
this.logger.info(`[SocketVM] VM timer expires in 1 minute.`);
});
this.queue.on('turnQueue', (arr: Array<UserTimeTuple>) => {
@ -97,7 +102,7 @@ export class SocketVM extends EventEmitter {
user.username = VMUser.GenerateName();
user.vm = this;
console.log(`[SocketVM] ${user.username} (IP ${user.address}) joined`);
this.logger.info({user: user.username, ip: user.address}, 'User joined');
await this.SendInitialScreen(user);
@ -129,7 +134,7 @@ export class SocketVM extends EventEmitter {
}
async RemUser(user: VMUser) {
console.log(`[SocketVM] ${user.username} (IP ${user.address}) left`);
this.logger.info({user: user.username, ip: user.address}, 'User left');
this.users.splice(this.users.indexOf(user), 1);
this.queue.TryRemove(user);
@ -148,7 +153,7 @@ export class SocketVM extends EventEmitter {
this.OnMessage(user, await Shared.MessageDecoder.ReadMessage(messageBuffer, false));
} catch (err) {
// Log the error and close the connection
console.log(`Error decoding message, closing connection: (user ${user.username}, ip ${user.address})`, err);
this.logger.error({ err: err, user: user.username, ip: user.address }, `Error decoding message, closing connection`);
user.connection.close();
return;
}
@ -203,8 +208,7 @@ export class SocketVM extends EventEmitter {
let buffers = this.MakeFullScreenData();
for (let buffer of buffers)
await self.BroadcastBuffer(buffer);
for (let buffer of buffers) await self.BroadcastBuffer(buffer);
});
this.display?.on('rect', async (x: number, y: number, rect: ImageData) => {

View file

@ -1,28 +1,28 @@
{
"name": "socketcomputer-repo",
"private": "true",
"workspaces": [
"shared",
"backend",
"qemu",
"webapp"
],
"scripts": {
"build": "yarn build:service && yarn build:frontend",
"build:service": "npm -w shared run build && npm -w qemu run build && npm -w backend run build",
"build": "yarn build:service && yarn build:frontend",
"build:service": "npm -w shared run build && npm -w backend run build",
"build:frontend": "npm -w shared run build && npm -w webapp run build"
},
"dependencies": {
"canvas": "^2.11.2"
"canvas": "^2.11.2",
"pino": "^9.3.1"
},
"devDependencies": {
"parcel": "^2.12.0",
"@parcel/packager-ts": "2.12.0",
"@parcel/transformer-sass": "^2.12.0",
"@parcel/transformer-sass": "^2.12.0",
"@parcel/transformer-typescript-types": "2.12.0",
"@types/node": "^20.12.2",
"parcel": "^2.12.0",
"prettier": "^3.2.5",
"stream-http": "^3.1.0",
"typescript": "^5.4.3"
}
},
"packageManager": "yarn@4.3.1"
}

View file

@ -1,32 +0,0 @@
{
"name": "@socketcomputer/qemu",
"version": "1.0.0",
"private": "true",
"description": "QEMU runtime for socketcomputer backend",
"exports": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module",
"scripts": {
"build": "parcel build src/index.ts --target node --target types"
},
"author": "",
"license": "MIT",
"targets": {
"types": {},
"node": {
"context": "node",
"isLibrary": true,
"outputFormat": "esmodule"
}
},
"dependencies": {
"canvas": "^2.11.2",
"execa": "^8.0.1",
"split": "^1.0.1"
},
"devDependencies": {
"parcel": "^2.12.0",
"@types/node": "^20.12.2",
"@types/split": "^1.0.5"
}
}

View file

@ -1,148 +0,0 @@
import { VncClient } from './rfb/client.js';
import { EventEmitter } from 'node:events';
import { Canvas, CanvasRenderingContext2D, createImageData } from 'canvas';
import { BatchRects, Size, Rect } from './QemuUtil.js';
const kQemuFps = 30;
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
export class QemuDisplay extends EventEmitter {
private displayVnc = new VncClient({
debug: false,
fps: kQemuFps,
encodings: [
VncClient.consts.encodings.raw,
//VncClient.consts.encodings.pseudoQemuAudio,
VncClient.consts.encodings.pseudoDesktopSize
// For now?
//VncClient.consts.encodings.pseudoCursor
]
});
private displayCanvas: Canvas = new Canvas(640, 480);
private displayCtx: CanvasRenderingContext2D = this.displayCanvas.getContext('2d');
private vncShouldReconnect: boolean = false;
private vncSocketPath: string;
constructor(socketPath: string) {
super();
this.vncSocketPath = socketPath;
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(kQemuFps);
this.emit('connected');
this.displayCanvas.width = this.displayVnc.clientWidth;
this.displayCanvas.height = this.displayVnc.clientHeight;
this.emit('resize', this.displayVnc.clientWidth, this.displayVnc.clientHeight);
this.emit('rect', 0, 0, this.displayCtx.getImageData(0, 0, this.displayVnc.clientWidth, this.displayVnc.clientHeight));
this.emit('frame');
});
this.displayVnc.on('desktopSizeChanged', ({ width, height }) => {
this.emit('resize', width, height);
this.displayCanvas.width = width;
this.displayCanvas.height = height;
});
let rects: Rect[] = [];
this.displayVnc.on('rectUpdateProcessed', (rect) => {
rects.push(rect);
});
this.displayVnc.on('frameUpdated', (fb) => {
this.displayCtx.putImageData(createImageData(new Uint8ClampedArray(fb.buffer), this.displayVnc.clientWidth, this.displayVnc.clientHeight), 0, 0);
// TODO: optimize the rects a bit. using guacamole's cheap method
// of just flushing the whole screen if the area of all the updated rects gets too big
// might just work.
//for (const rect of rects) {
// this.emit('rect', rect.x, rect.y, this.displayCtx.getImageData(rect.x, rect.y, rect.width, rect.height));
//}
// cvmts batcher
let batched = BatchRects(this.Size(), rects);
this.emit('rect', batched.x, batched.y, this.displayCtx.getImageData(batched.x, batched.y, batched.width, batched.height));
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({
path: this.vncSocketPath
});
}
Connect() {
this.vncShouldReconnect = true;
this.Reconnect();
}
Disconnect() {
this.vncShouldReconnect = false;
this.displayVnc.disconnect();
}
GetCanvas() {
return this.displayCanvas;
}
Size(): Size {
return {
width: this.displayVnc.clientWidth,
height: this.displayVnc.clientHeight
};
}
MouseEvent(x: number, y: number, buttons: number) {
this.displayVnc.sendPointerEvent(x, y, buttons);
}
KeyboardEvent(keysym: number, pressed: boolean) {
this.displayVnc.sendKeyEvent(keysym, pressed);
}
}

View file

@ -1,73 +0,0 @@
// QEMU utility functions
// most of these are just for randomly generated/temporary files
import { execa } from 'execa';
import * as crypto from 'node:crypto';
export type Size = { width: number, height: number };
export type Rect = {height:number,width:number,x:number,y:number};
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
};
}
// Generates a random unicast/local MAC address.
export async function GenMacAddress(): Promise<string> {
return new Promise((res, rej) => {
crypto.randomBytes(6, (err, buf) => {
if (err) rej(err);
// Modify byte 0 to make this MAC address proper
let rawByte0 = buf.readUInt8(0);
rawByte0 &= ~0b00000011; // keep most of the bits set from what we got, except for the Unicast and Local bits
rawByte0 |= 1 << 1; // Always set the Local bit. Leave the Unicast bit unset.
buf.writeUInt8(rawByte0);
// this makes me wanna cry but it seems to working
res(
buf
.toString('hex')
.split(/(.{2})/)
.filter((o) => o)
.join(':')
);
});
});
}

View file

@ -1,287 +0,0 @@
import { execa, ExecaChildProcess } from 'execa';
import { EventEmitter } from 'events';
import QmpClient from './QmpClient.js';
import { QemuDisplay } from './QemuDisplay.js';
import { unlink } from 'node:fs/promises';
export enum VMState {
Stopped,
Starting,
Started,
Stopping
}
export type QemuVmDefinition = {
id: string;
command: string[];
};
/// Temporary path base (for UNIX sockets/etc.)
const kVmTmpPathBase = `/tmp`;
/// The max amount of times QMP connection is allowed to fail before
/// the VM is forcefully stopped.
const kMaxFailCount = 5;
let gVMShouldSnapshot = false;
async function Sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function setSnapshot(val: boolean) {
gVMShouldSnapshot = val;
}
export class QemuVM extends EventEmitter {
private state = VMState.Stopped;
private qmpInstance: QmpClient | null = null;
private qmpConnected = false;
private qmpFailCount = 0;
private qemuProcess: ExecaChildProcess | null = null;
private qemuRunning = false;
private display: QemuDisplay | null = null;
private definition: QemuVmDefinition;
private addedCommandShit = false;
constructor(def: QemuVmDefinition) {
super();
this.definition = def;
}
async Start() {
// Don't start while either trying to start or starting.
if (this.state == VMState.Started || this.state == VMState.Starting) return;
var cmd = this.definition.command;
// build additional command line statements to enable qmp/vnc over unix sockets
if(!this.addedCommandShit) {
cmd.push('-no-shutdown');
if(gVMShouldSnapshot)
cmd.push('-snapshot');
cmd.push(`-qmp`);
cmd.push(`unix:${this.GetQmpPath()},server,nowait`);
cmd.push(`-vnc`);
cmd.push(`unix:${this.GetVncPath()}`);
this.addedCommandShit = true;
}
this.VMLog(`Starting QEMU with command \"${cmd.join(' ')}\"`);
await this.StartQemu(cmd);
}
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');
// Start indicating we're stopping, so we don't
// erroneously start trying to restart everything
// we're going to tear down in this function call.
this.SetState(VMState.Stopping);
// Kill the QEMU process and QMP/display connections if they are running.
await this.DisconnectQmp();
this.DisconnectDisplay();
await this.StopQemu();
}
async Reset() {
this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM');
// let code know the VM is going to reset
// N.B: In the crusttest world, a reset simply amounts to a
// mean cold reboot of the qemu process basically
this.emit('reset');
await this.Stop();
await Sleep(500);
await this.Start();
}
async QmpCommand(command: string, args: any | null): Promise<any> {
return await this.qmpInstance?.Execute(command, args);
}
async MonitorCommand(command: string) {
this.AssertState(VMState.Started, 'cannot use QemuVM#MonitorCommand on a non-started VM');
return await this.QmpCommand('human-monitor-command', {
'command-line': command
});
}
async ChangeRemovableMedia(deviceName: string, imagePath: string): Promise<void> {
this.AssertState(VMState.Started, 'cannot use QemuVM#ChangeRemovableMedia on a non-started VM');
// N.B: if this throws, the code which called this should handle the error accordingly
await this.QmpCommand('blockdev-change-medium', {
device: deviceName, // techinically deprecated, but I don't feel like figuring out QOM path just for a simple function
filename: imagePath
});
}
async EjectRemovableMedia(deviceName: string) {
this.AssertState(VMState.Started, 'cannot use QemuVM#EjectRemovableMedia on a non-started VM');
await this.QmpCommand('eject', {
device: deviceName
});
}
GetDisplay() {
return this.display;
}
/// Private fun bits :)
private VMLog(...args: any[]) {
// TODO: hook this into a logger of some sort
console.log(`[QemuVM] [${this.definition.id}] ${args.join('')}`);
}
private AssertState(stateShouldBe: VMState, message: string) {
if (this.state !== stateShouldBe) throw new Error(message);
}
private SetState(state: VMState) {
this.state = state;
this.emit('statechange', this.state);
}
private GetQmpPath() {
return `${kVmTmpPathBase}/socket2-${this.definition.id}-ctrl`;
}
private GetVncPath() {
return `${kVmTmpPathBase}/socket2-${this.definition.id}-vnc`;
}
private async StartQemu(split: Array<string>) {
let self = this;
this.SetState(VMState.Starting);
// Start QEMU
this.qemuProcess = execa(split[0], split.slice(1));
this.qemuProcess.on('spawn', async () => {
self.qemuRunning = true;
await Sleep(500);
await self.ConnectQmp();
});
this.qemuProcess.on('exit', async (code) => {
self.qemuRunning = false;
console.log("qemu process go boom")
// ?
if (self.qmpConnected) {
await self.DisconnectQmp();
}
self.DisconnectDisplay();
if (self.state != VMState.Stopping) {
if (code == 0) {
await Sleep(500);
await self.StartQemu(split);
} else {
self.VMLog('QEMU exited with a non-zero exit code. This usually means an error in the command line. Stopping VM.');
await self.Stop();
}
} else {
this.SetState(VMState.Stopped);
}
});
}
private async StopQemu() {
if (this.qemuRunning == true) this.qemuProcess?.kill('SIGKILL');
}
private async ConnectQmp() {
let self = this;
if (!this.qmpConnected) {
self.qmpInstance = new QmpClient();
self.qmpInstance.on('close', async () => {
self.qmpConnected = false;
// If we aren't stopping, then we do actually need to care QMP disconnected
if (self.state != VMState.Stopping) {
if (self.qmpFailCount++ < kMaxFailCount) {
this.VMLog(`Failed to connect to QMP ${self.qmpFailCount} times`);
await Sleep(500);
await self.ConnectQmp();
} else {
this.VMLog(`Failed to connect to QMP ${self.qmpFailCount} times, giving up`);
await self.Stop();
}
}
});
self.qmpInstance.on('event', async (ev) => {
switch (ev.event) {
// Handle the STOP event sent when using -no-shutdown
case 'STOP':
await self.qmpInstance?.Execute('system_reset');
break;
case 'RESET':
self.VMLog('got a reset event!');
await self.qmpInstance?.Execute('cont');
break;
}
});
self.qmpInstance.on('qmp-ready', async (hadError) => {
self.VMLog('QMP ready');
self.display = new QemuDisplay(self.GetVncPath());
self.display.Connect();
// QMP has been connected so the VM is ready to be considered started
self.qmpFailCount = 0;
self.qmpConnected = true;
self.SetState(VMState.Started);
});
try {
await Sleep(500);
this.qmpInstance?.ConnectUNIX(this.GetQmpPath());
} catch (err) {
// just try again
await Sleep(500);
await this.ConnectQmp();
}
}
}
private async DisconnectDisplay() {
try {
this.display?.Disconnect();
this.display = null; // disassociate with that display object.
await unlink(this.GetVncPath());
} catch (err) {
// oh well lol
}
}
private async DisconnectQmp() {
if (this.qmpConnected) return;
if(this.qmpInstance == null)
return;
this.qmpConnected = false;
this.qmpInstance.end();
this.qmpInstance = null;
try {
await unlink(this.GetQmpPath());
} catch(err) {
}
}
}

View file

@ -1,135 +0,0 @@
// This was originally based off the contents of the node-qemu-qmp package,
// but I've modified it possibly to the point where it could be treated as my own creation.
import split from 'split';
import { Socket } from 'net';
export type QmpCallback = (err: Error | null, res: any | null) => void;
type QmpCommandEntry = {
callback: QmpCallback | null;
id: number;
};
// TODO: Instead of the client "Is-A"ing a Socket, this should instead contain/store a Socket,
// (preferrably) passed by the user, to use for QMP communications.
// The client shouldn't have to know or care about the protocol, and it effectively hackily uses the fact
// Socket extends EventEmitter.
export default class QmpClient extends Socket {
public qmpHandshakeData: any;
private commandEntries: QmpCommandEntry[] = [];
private lastID = 0;
private ExecuteSync(command: string, args: any | null, callback: QmpCallback | null) {
let cmd: QmpCommandEntry = {
callback: callback,
id: ++this.lastID
};
let qmpOut: any = {
execute: command,
id: cmd.id
};
if (args) qmpOut['arguments'] = args;
// Add stuff
this.commandEntries.push(cmd);
this.write(JSON.stringify(qmpOut));
}
// TODO: Make this function a bit more ergonomic?
async Execute(command: string, args: any | null = null): Promise<any> {
return new Promise((res, rej) => {
this.ExecuteSync(command, args, (err, result) => {
if (err) rej(err);
res(result);
});
});
}
private Handshake(callback: () => void) {
this.write(
JSON.stringify({
execute: 'qmp_capabilities'
})
);
this.once('data', (data) => {
// Once QEMU replies to us, the handshake is done.
// We do not negotiate anything special.
callback();
});
}
// this can probably be made async
private ConnectImpl() {
let self = this;
this.once('connect', () => {
this.removeAllListeners('error');
});
this.once('error', (err) => {
// just rethrow lol
//throw err;
console.log("you have pants: rules,", err);
});
this.once('data', (data) => {
// Handshake QMP with the server.
self.qmpHandshakeData = JSON.parse(data.toString('utf8')).QMP;
self.Handshake(() => {
// Now ready to parse QMP responses/events.
self.pipe(split(JSON.parse))
.on('data', (json: any) => {
if (json == null) return self.end();
if (json.return || json.error) {
// Our handshake has a spurious return because we never assign it an ID,
// and it is gathered by this pipe for some reason I'm not quite sure about.
// So, just for safety's sake, don't process any return objects which don't have an ID attached to them.
if (json.id == null) return;
let callbackEntry = this.commandEntries.find((entry) => entry.id === json.id);
let error: Error | null = json.error ? new Error(json.error.desc) : null;
// we somehow didn't find a callback entry for this response.
// I don't know how. Techinically not an error..., but I guess you're not getting a reponse to whatever causes this to happen
if (callbackEntry == null) return;
if (callbackEntry?.callback) callbackEntry.callback(error, json.return);
// Remove the completed callback entry.
this.commandEntries.slice(this.commandEntries.indexOf(callbackEntry));
} else if (json.event) {
this.emit('event', json);
}
})
.on('error', () => {
// Give up.
return self.end();
});
this.emit('qmp-ready');
});
});
this.once('close', () => {
this.end();
this.removeAllListeners('data'); // wow. good job bud. cool memory leak
});
}
Connect(host: string, port: number) {
super.connect(port, host);
this.ConnectImpl();
}
ConnectUNIX(path: string) {
super.connect(path);
this.ConnectImpl();
}
}

View file

@ -1,3 +0,0 @@
export * from './QemuDisplay.js';
export * from './QemuUtil.js';
export * from './QemuVM.js';

View file

@ -1,21 +0,0 @@
Copyright 2021 Filipe Calaça Barbosa
Copyright 2022 dither
Copyright 2023-2024 Lily Tsuru/modeco80 <lily.modeco80@protonmail.ch>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,11 +0,0 @@
# Notice
The source here was originally taken from a fork of [vnc-rfb-client](https://github.com/ayunami2000/vnc-rfb-client) made for the LucidVM project, available [here](https://github.com/lucidvm/rfb).
It has been grossly modified for the usecases for the socket2 backend, but is more than likely usable to other clients.
- converted to (strict) TypeScript; my initial port beforehand was not strict and it Sucked
- Also, actually use interfaces, and reduce the amount of code duplicated significantly in the rect decoders.
- all modules rewritten to use ESM
- some noisy debug prints removed
- (some, very tiny) code cleanup

View file

@ -1,919 +0,0 @@
import { IRectDecoder } from './decoders/decoder.js';
import { HextileDecoder } from './decoders/hextile.js';
import { RawDecoder } from './decoders/raw.js';
import { ZrleDecoder } from './decoders/zrle.js';
// import { TightDecoder } from "./decoders/tight.js";
import { CopyRectDecoder } from './decoders/copyrect.js';
import { EventEmitter } from 'node:events';
import { consts } from './constants.js';
import * as net from 'node:net';
import * as crypto from 'node:crypto';
import { SocketBuffer } from './socketbuffer.js';
import { VncRectangle, Color3, PixelFormat, Cursor } from './types.js';
export class VncClient extends EventEmitter {
// These are in no particular order.
public debug: boolean = false;
private _connected: boolean = false;
private _authenticated: boolean = false;
private _version: string = "";
private _password: string = "";
private _audioChannels: number = 2;
private _audioFrequency: number = 22050;
private _rects: number = 0;
private _decoders: Array<IRectDecoder> = [];
private _fps: number;
private _timerInterval: number;
private _timerPointer : NodeJS.Timeout|null = null;
public fb: Buffer = Buffer.from([]);
private _handshaked: boolean = false;
private _waitingServerInit: boolean = false;
private _expectingChallenge: boolean = false;
private _challengeResponseSent: boolean = false;
private _set8BitColor: boolean = false;
private _frameBufferReady = false;
private _firstFrameReceived = false;
private _processingFrame = false;
private _relativePointer: boolean = false;
public bigEndianFlag: boolean = false;
public clientWidth: number = 0;
public clientHeight: number = 0;
public clientName: string = "";
public pixelFormat: PixelFormat = {
bitsPerPixel: 0,
depth: 0,
bigEndianFlag: 0,
trueColorFlag: 0,
redMax: 0,
greenMax: 0,
blueMax: 0,
redShift: 0,
greenShift: 0,
blueShift: 0
};
private _colorMap: Color3[] = [];
private _audioData: Buffer = Buffer.from([]);
private _cursor: Cursor = {
width: 0,
height: 0,
x: 0,
y: 0,
cursorPixels: null,
bitmask: null,
posX: 0,
posY: 0
};
public encodings: number[];
private _connection: net.Socket|null = null;
private _socketBuffer: SocketBuffer;
static get consts() {
return {
encodings: consts.encodings
};
}
/**
* Return if client is connected
* @returns {boolean}
*/
get connected() {
return this._connected;
}
/**
* Return if client is authenticated
* @returns {boolean}
*/
get authenticated() {
return this._authenticated;
}
/**
* Return negotiated protocol version
* @returns {string}
*/
get protocolVersion() {
return this._version;
}
/**
* Return the local port used by the client
* @returns {number}
*/
get localPort() {
return this._connection ? this._connection?.localPort : 0;
}
constructor(options: any = { debug: false, fps: 0, encodings: [] }) {
super();
this._socketBuffer = new SocketBuffer();
this.resetState();
this.debug = options.debug || false;
this._fps = Number(options.fps) || 0;
// Calculate interval to meet configured FPS
this._timerInterval = this._fps > 0 ? 1000 / this._fps : 0;
// Default encodings
this.encodings =
options.encodings && options.encodings.length
? options.encodings
: [consts.encodings.copyRect, consts.encodings.zrle, consts.encodings.hextile, consts.encodings.raw, consts.encodings.pseudoDesktopSize];
this._audioChannels = options.audioChannels || 2;
this._audioFrequency = options.audioFrequency || 22050;
this._rects = 0;
this._decoders[consts.encodings.raw] = new RawDecoder();
// TODO: Implement tight encoding
// this._decoders[encodings.tight] = new tightDecoder();
this._decoders[consts.encodings.zrle] = new ZrleDecoder();
this._decoders[consts.encodings.copyRect] = new CopyRectDecoder();
this._decoders[consts.encodings.hextile] = new HextileDecoder();
if (this._timerInterval) {
this._fbTimer();
}
}
/**
* Timer used to limit the rate of frame update requests according to configured FPS
* @private
*/
_fbTimer() {
this._timerPointer = setTimeout(() => {
this._fbTimer();
if (this._firstFrameReceived && !this._processingFrame && this._fps > 0) {
this.requestFrameUpdate();
}
}, this._timerInterval);
}
/**
* Adjuste the configured FPS
* @param fps {number} - Number of update requests send by second
*/
changeFps(fps: number) {
if (!Number.isNaN(fps)) {
this._fps = Number(fps);
this._timerInterval = this._fps > 0 ? 1000 / this._fps : 0;
if (this._timerPointer && !this._fps) {
// If FPS was zeroed stop the timer
clearTimeout(this._timerPointer);
this._timerPointer = null;
} else if (this._fps && !this._timerPointer) {
// If FPS was zero and is now set, start the timer
this._fbTimer();
}
} else {
throw new Error('Invalid FPS. Must be a number.');
}
}
/**
* Starts the connection with the VNC server
* @param options
*/
connect(
options: any /* = {
host: '',
password: '',
path: '',
set8BitColor: false,
port: 5900
} */
) {
if (options.password) {
this._password = options.password;
}
this._set8BitColor = options.set8BitColor || false;
if (options.path === null) {
if (!options.host) {
throw new Error('Host missing.');
}
this._connection = net.connect(options.port || 5900, options.host);
// disable nagle's algorithm for TCP
this._connection?.setNoDelay();
} else {
// unix socket. bodged in but oh well
this._connection = net.connect(options.path);
}
this._connection?.on('connect', () => {
this._connected = true;
this.emit('connected');
});
this._connection?.on('close', () => {
this.resetState();
this.emit('closed');
});
this._connection?.on('timeout', () => {
this.emit('connectTimeout');
});
this._connection?.on('error', (err) => {
this.emit('connectError', err);
});
this._connection?.on('data', async (data) => {
this._socketBuffer.pushData(data);
if (!this._handshaked) {
this._handleHandshake();
} else if (this._expectingChallenge) {
this._handleAuthChallenge();
} else if (this._waitingServerInit) {
await this._handleServerInit();
} else {
await this._handleData();
}
});
}
/**
* Disconnect the client
*/
disconnect() {
if (this._connection) {
this._connection?.end();
this.resetState();
this.emit('disconnected');
}
}
/**
* Request the server a frame update
* @param full - If the server should send all the frame buffer or just the last changes
* @param incremental - Incremental number for not full requests
* @param x - X position of the update area desired, usually 0
* @param y - Y position of the update area desired, usually 0
* @param width - Width of the update area desired, usually client width
* @param height - Height of the update area desired, usually client height
*/
requestFrameUpdate(full = false, incremental = 1, x = 0, y = 0, width = this.clientWidth, height = this.clientHeight) {
if ((this._frameBufferReady || full) && this._connection && !this._rects) {
// Request data
const message = Buffer.alloc(10);
message.writeUInt8(3); // Message type
message.writeUInt8(full ? 0 : incremental, 1); // Incremental
message.writeUInt16BE(x, 2); // X-Position
message.writeUInt16BE(y, 4); // Y-Position
message.writeUInt16BE(width, 6); // Width
message.writeUInt16BE(height, 8); // Height
this._connection?.write(message);
this._frameBufferReady = true;
}
}
/**
* Handle handshake msg
* @private
*/
_handleHandshake() {
// Handshake, negotiating protocol version
if (this._socketBuffer.toString() === consts.versionString.V3_003) {
this._log('Sending 3.3', true);
this._connection?.write(consts.versionString.V3_003);
this._version = '3.3';
} else if (this._socketBuffer.toString() === consts.versionString.V3_007) {
this._log('Sending 3.7', true);
this._connection?.write(consts.versionString.V3_007);
this._version = '3.7';
} else if (this._socketBuffer.toString() === consts.versionString.V3_008) {
this._log('Sending 3.8', true);
this._connection?.write(consts.versionString.V3_008);
this._version = '3.8';
} else {
// Negotiating auth mechanism
this._handshaked = true;
if (this._socketBuffer.includes(0x02) && this._password) {
this._log('Password provided and server support VNC auth. Choosing VNC auth.', true);
this._expectingChallenge = true;
this._connection?.write(Buffer.from([0x02]));
} else if (this._socketBuffer.includes(1)) {
this._log('Password not provided or server does not support VNC auth. Trying none.', true);
this._connection?.write(Buffer.from([0x01]));
if (this._version === '3.7') {
this._waitingServerInit = true;
} else {
this._expectingChallenge = true;
this._challengeResponseSent = true;
}
} else {
this._log('Connection error. Msg: ' + this._socketBuffer.toString());
this.disconnect();
}
}
this._socketBuffer?.flush(false);
}
/**
* Handle VNC auth challenge
* @private
*/
_handleAuthChallenge() {
if (this._challengeResponseSent) {
// Challenge response already sent. Checking result.
if (this._socketBuffer.buffer[3] === 0) {
// Auth success
this._authenticated = true;
this.emit('authenticated');
this._expectingChallenge = false;
this._sendClientInit();
} else {
// Auth fail
this.emit('authError');
this.resetState();
}
} else {
const key = Buffer.alloc(8);
key.fill(0);
key.write(this._password.slice(0, 8));
this.reverseBits(key);
const des1 = crypto.createCipheriv('des', key, Buffer.alloc(8));
const des2 = crypto.createCipheriv('des', key, Buffer.alloc(8));
const response = Buffer.alloc(16);
response.fill(des1.update(this._socketBuffer.buffer.slice(0, 8)), 0, 8);
response.fill(des2.update(this._socketBuffer.buffer.slice(8, 16)), 8, 16);
this._connection?.write(response);
this._challengeResponseSent = true;
}
this._socketBuffer.flush(false);
}
/**
* Reverse bits order of a byte
* @param buf - Buffer to be flipped
*/
reverseBits(buf: Buffer) {
for (let x = 0; x < buf.length; x++) {
let newByte = 0;
newByte += buf[x] & 128 ? 1 : 0;
newByte += buf[x] & 64 ? 2 : 0;
newByte += buf[x] & 32 ? 4 : 0;
newByte += buf[x] & 16 ? 8 : 0;
newByte += buf[x] & 8 ? 16 : 0;
newByte += buf[x] & 4 ? 32 : 0;
newByte += buf[x] & 2 ? 64 : 0;
newByte += buf[x] & 1 ? 128 : 0;
buf[x] = newByte;
}
}
/**
* Handle server init msg
* @returns {Promise<void>}
* @private
*/
async _handleServerInit() {
this._waitingServerInit = false;
await this._socketBuffer.waitBytes(18);
this.clientWidth = this._socketBuffer.readUInt16BE();
this.clientHeight = this._socketBuffer.readUInt16BE();
this.pixelFormat.bitsPerPixel = this._socketBuffer.readUInt8();
this.pixelFormat.depth = this._socketBuffer.readUInt8();
this.pixelFormat.bigEndianFlag = this._socketBuffer.readUInt8();
this.pixelFormat.trueColorFlag = this._socketBuffer.readUInt8();
this.pixelFormat.redMax = this.bigEndianFlag ? this._socketBuffer.readUInt16BE() : this._socketBuffer.readUInt16LE();
this.pixelFormat.greenMax = this.bigEndianFlag ? this._socketBuffer.readUInt16BE() : this._socketBuffer.readUInt16LE();
this.pixelFormat.blueMax = this.bigEndianFlag ? this._socketBuffer.readUInt16BE() : this._socketBuffer.readUInt16LE();
this.pixelFormat.redShift = this._socketBuffer.readInt8();
this.pixelFormat.greenShift = this._socketBuffer.readInt8();
this.pixelFormat.blueShift = this._socketBuffer.readInt8();
this.updateFbSize();
this.clientName = this._socketBuffer.buffer.slice(24).toString();
this._socketBuffer.flush(false);
// FIXME: Removed because these are noise
//this._log(`Screen size: ${this.clientWidth}x${this.clientHeight}`);
//this._log(`Client name: ${this.clientName}`);
//this._log(`pixelFormat: ${JSON.stringify(this.pixelFormat)}`);
if (this._set8BitColor) {
//this._log(`8 bit color format requested, only raw encoding is supported.`);
this._setPixelFormatToColorMap();
}
this._sendEncodings();
setTimeout(() => {