initial commit

This commit is contained in:
Lily Tsuru 2024-04-02 07:43:54 -04:00
commit 071b531679
45 changed files with 6874 additions and 0 deletions

8
.editorconfig Normal file
View file

@ -0,0 +1,8 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
# if this is changed please change it in the .clang-format so that nothing explodes
indent_size = 4

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
**/dist
/package-lock.json
# why don't you put this in the webapp/ project root?
/.parcel-cache
# nvm it does now ok
/webapp/.parcel-cache

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
dist
*.md
*.json

20
.prettierrc.json Normal file
View file

@ -0,0 +1,20 @@
{
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": true,
"printWidth": 200,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "none",
"useTabs": true,
"vueIndentScriptAndStyle": false
}

20
LICENSE Normal file
View file

@ -0,0 +1,20 @@
Copyright 2023 Lily Tsuru/modeco80 <lily.modeco80@protonmail.ch>
Please see qemu/src/rfb/LICENSE for additional terms.
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.

29
README.md Normal file
View file

@ -0,0 +1,29 @@
# Socket.Computer
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 CrustTest webapp (TODO)
## Building
```bash
$ yarn
$ yarn build:service # Build the service
$ yarn build:frontend # Build the webapp
```
## Hosting
TODO
- Edit `webapp/src/index.ts` to point the websocket URL to an appopiate place
- Build the service and the webapp (tip, see the above section)
- copy `webapp/dist` (excl. `.map` files) to an applicable webroot
- Run the backend, optionally with systemd service things (MAKE SURE TO SET NODE_ENV TO PRODUCTION.) (also proxy it for wss please)
... profit?

24
backend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "@socketcomputer/backend",
"version": "1.0.0",
"private": "true",
"description": "socket 2.0 backend",
"type": "module",
"scripts": {
"build": "tsc"
},
"author": "modeco80",
"license": "MIT",
"dependencies": {
"@fastify/websocket": "^10.0.1",
"@julusian/jpeg-turbo": "^2.1.0",
"@socketcomputer/qemu": "*",
"@socketcomputer/shared": "*",
"fastify": "^4.26.2",
"mnemonist": "^0.39.8",
"canvas": "^2.11.2"
},
"devDependencies": {
"@types/ws": "^8.5.10"
}
}

View file

@ -0,0 +1,46 @@
import { EventEmitter } from 'node:events';
// TODO: Second granualarity. (nvm this is fine for socket2..)
const kMinute = 60 * 1000;
export class ExtendableTimer extends EventEmitter {
private timeout: NodeJS.Timeout;
private iterationcount: number = 0;
private time: number;
constructor(baseTimeMin: number) {
super();
this.time = baseTimeMin;
}
private Arm() {
this.timeout = setTimeout(() => {
this.iterationcount--;
if (this.iterationcount == 1) {
this.emit('expiry-near');
} else if (this.iterationcount == 0) {
return this.emit('expired');
}
this.Arm();
}, kMinute);
}
Start() {
this.iterationcount = this.time;
clearTimeout(this.timeout);
this.Arm();
}
Stop() {
this.iterationcount = 0;
clearTimeout(this.timeout);
this.emit('expired');
}
Extend() {
this.iterationcount = this.time;
clearTimeout(this.timeout);
this.Arm();
}
}

View file

@ -0,0 +1,70 @@
// QEMU definitions. These define the base QEMU command lines,
// which are standardized across all crusttest slots.
// (This file has been bastardized for socket2)
import { QemuVmDefinition } from '@socketcomputer/qemu';
const kQemuPath = '/srv/collabvm/qemu/bin/qemu-system-x86_64';
// the aio model qemu will use. if possible, leave this at 'io_uring'.
const kQemuAio = 'io_uring';
/// Creates a base definition for a VM with PC chipset.
export function Slot_PCDef(
// RAM
ramSize: string,
// Network
netdevOption: string = '-netdev user,id=vm.wan',
netAdapterModel: string = 'rtl8139',
netMac: string = 'c7:4e:c0:5f:2c:7c',
// HDA
hdaIsSsd: boolean = true,
hdImagePath: string,
hdImageFormat: string
): QemuVmDefinition {
let qCommand = [kQemuPath];
let pushOption = (opt: string) => {
qCommand.push(opt.substring(0, opt.indexOf(' ')));
qCommand.push(opt.substring(opt.indexOf(' ') + 1));
};
// boilerplate/chipset
qCommand.push('-nodefaults');
pushOption('-machine pc-i440fx-2.10,accel=kvm,kernel_irqchip=on,hpet=off,acpi=on,usb=on');
pushOption('-rtc base=localtime,clock=vm');
// CPU/RAM
pushOption(`-cpu pentium3`);
pushOption(`-m ${ramSize}`); // unlike LVM we don't really want to prealloc per se..
pushOption(`${netdevOption}`);
pushOption(`-device ${netAdapterModel},id=vm.netadp,netdev=vm.wan,mac=${netMac}`);
pushOption(
`-drive if=none,file=${hdImagePath},cache=writeback,discard=unmap,format=${hdImageFormat},aio=${kQemuAio},id=vm.hda_drive,bps=65000000,bps_max=65000000,iops=1500,iops_max=2000`
);
// prettier-ignore
if (hdaIsSsd)
pushOption(`-device ide-hd,id=vm.hda,rotation_rate=1,drive=vm.hda_drive`);
else
pushOption(`-device ide-hd,id=vm.hda,drive=vm.hda_drive`);
// CD drive
pushOption(`-drive if=none,media=cdrom,aio=${kQemuAio},id=vm.cd`);
pushOption(`-device ide-cd,drive=vm.cd,id=vm.cd_drive`);
pushOption('-audio driver=none,model=ac97');
// VGA + usb tablet
pushOption('-device VGA,id=vm.vga');
pushOption('-device usb-tablet');
return {
id: "socketvm1",
command: qCommand
};
}

View file

@ -0,0 +1,395 @@
import { QemuVmDefinition, QemuDisplay, QemuVM, VMState, setSnapshot, GenMacAddress } from '@socketcomputer/qemu';
import { Slot_PCDef } from './SlotQemuDefs.js';
import { ExtendableTimer } from './ExtendableTimer.js';
import { EventEmitter } from 'node:events';
import * as Shared from '@socketcomputer/shared';
import { Canvas } from 'canvas';
import { FastifyInstance, fastify } from 'fastify';
import * as fastifyWebsocket from '@fastify/websocket';
import { WebSocket } from 'ws';
import Queue from 'mnemonist/queue.js';
class VMUser {
public connection: WebSocket;
public username: string;
private vm: VirtualMachine;
constructor(connection: WebSocket, slot: VirtualMachine) {
this.connection = connection;
this.vm = slot;
this.vm.AddUser(this);
this.connection.on('message', async (data, isBinary) => {
if (!isBinary) this.connection.close(1000);
await this.vm.OnWSMessage(this, data as Buffer);
});
this.connection.on('close', async () => {
console.log('closed');
await this.vm.RemUser(this);
});
}
async SendMessage(messageGenerator: (encoder: Shared.MessageEncoder) => ArrayBuffer) {
await this.SendBuffer(messageGenerator(new Shared.MessageEncoder()));
}
async SendBuffer(buffer: ArrayBuffer): Promise<void> {
return new Promise((res, rej) => {
if (this.connection.readyState !== WebSocket.CLOSED) {
this.connection.send(buffer, (err) => {
res();
});
}
});
}
static GenerateName() {
return `guest${Math.floor(Math.random() * (99999 - 10000) + 10000)}`;
}
}
const kTurnTimeSeconds = 18;
type userAndTime = {
user: VMUser;
// waiting time if this user is not the front.
time: number;
};
// the turn queue. yes this is mostly stolen from cvmts but I make it cleaner by Seperate!!!!!!!
class TurnQueue extends EventEmitter {
private queue: Queue<VMUser> = new Queue<VMUser>();
private turnTime = kTurnTimeSeconds;
private interval: NodeJS.Timeout = null;
constructor() {
super();
}
public CurrentUser(): VMUser {
return this.queue.peek();
}
public TryEnqueue(user: VMUser) {
// Already the current user
if (this.CurrentUser() == user) return;
// Already in the queue
if (this.queue.toArray().indexOf(user) !== -1) return;
this.queue.enqueue(user);
if (this.queue.size == 1) this.nextTurn();
}
private turnInterval() {
this.turnTime--;
if (this.turnTime < 1) {
this.queue.dequeue();
this.nextTurn();
}
}
private nextTurn() {
clearInterval(this.interval);
if (this.queue.size === 0) {
} else {
this.turnTime = kTurnTimeSeconds;
this.interval = setInterval(() => this.turnInterval(), 1000);
}
if (this.queue.size == 1) this.emit('turnQueue', [{ user: this.CurrentUser(), time: kTurnTimeSeconds * 1000 }]);
// removes the front of the quuee
let arr = this.queue.toArray().slice(1);
let arr2: Array<userAndTime> = arr.map((u, index) => {
return {
user: u,
time: this.turnTime * 1000 + (index - 1) * (kTurnTimeSeconds * 1000)
};
}, this);
this.emit('turnQueue', arr2);
}
}
// A slot.
class VirtualMachine extends EventEmitter {
private vm: QemuVM;
private display: QemuDisplay;
private timer: ExtendableTimer = null;
private users: Array<VMUser> = [];
private queue: TurnQueue = new TurnQueue();
constructor(vm: QemuVM) {
super();
this.vm = vm;
this.timer = new ExtendableTimer(2);
this.timer.on('expired', async () => {
// bye bye!
console.log(`[VM] VM expired, resetting..`);
await this.vm.Stop();
});
this.timer.on('expiry-near', async () => {
console.log(`[VM] about to expire!`);
});
this.vm.on('statechange', async (state: VMState) => {
if (state == VMState.Started) {
this.display = this.vm.GetDisplay();
await this.VMRunning();
} else if (state == VMState.Stopped) {
this.display = null;
await this.VMStopped();
}
});
this.queue.on('turnQueue', (arr: Array<userAndTime>) => {
// TODO! SERIALIZE TURN QUEUE!
console.log("Turn queue", arr);
for (let entry of arr) {
entry.user.SendMessage((encoder: Shared.MessageEncoder) => {
// painnnnnnnnnnnnnnnnnnn fuck i should just make a dynamic buffer system lol
encoder.Init(4 + arr.length * Shared.kMaxUserNameLength);
// pain ?
encoder.SetTurnSrvMessage(
entry.time,
arr.map((entry: userAndTime) => {
return entry.user.username;
})
);
return encoder.Finish();
});
}
});
}
async Start() {
await this.vm.Start();
}
async AddUser(user: VMUser) {
user.username = VMUser.GenerateName();
console.log(user.username, 'joined.');
// send bullshit
await this.sendFullScreen(user);
// send an adduser for all users
for (let user of this.users) {
user.SendMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(4 + Shared.kMaxUserNameLength);
encoder.SetAddUserMessage(user.username);
return encoder.Finish();
});
}
// officially add the user
this.users.push(user);
// hello!
await this.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(4 + Shared.kMaxUserNameLength);
encoder.SetAddUserMessage(user.username);
return encoder.Finish();
});
}
async RemUser(user: VMUser) {
// TODO: erase from turn queue (once we have it) wired up
this.users.splice(this.users.indexOf(user), 1);
// bye-bye!
await this.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(4 + Shared.kMaxUserNameLength);
encoder.SetRemUserMessage(user.username);
return encoder.Finish();
});
}
async OnWSMessage(user: VMUser, message: Buffer) {
try {
this.OnDecodedMessage(user, await Shared.MessageDecoder.ReadMessage(message, false));
} catch (err) {
// get out
user.connection.close();
return;
}
}
private async OnDecodedMessage(user: VMUser, message: Shared.DeserializedMessage) {
switch (message.type) {
case Shared.MessageType.Chat:
console.log(`${user.username} > ${(message as Shared.ChatMessage).message}`);
break;
case Shared.MessageType.Turn:
this.queue.TryEnqueue(user);
break;
case Shared.MessageType.Mouse:
if(user != this.queue.CurrentUser())
return;
if(this.display == null) return;
this.display.MouseEvent((message as Shared.MouseMessage).x, (message as Shared.MouseMessage).y, (message as Shared.MouseMessage).buttons);
break;
case Shared.MessageType.Key:
if(user != this.queue.CurrentUser())
return;
if(this.display == null) return;
break;
// ignore unhandlable messages (we won't get any invalid ones because they will cause a throw)
default:
break;
}
}
private async BroadcastMessage(messageGenerator: (encoder: Shared.MessageEncoder) => ArrayBuffer) {
let buffer = messageGenerator(new Shared.MessageEncoder());
for (let user of this.users) {
await user.SendBuffer(buffer);
}
}
private async InsertCD(isoPath: string) {
await this.vm.ChangeRemovableMedia('vm.cd', isoPath);
}
private async VMRunning() {
let self = this;
// Hook up the display
this.display.on('resize', async (width, height) => {
if(self.display == null)
return;
await self.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(4);
encoder.SetDisplaySizeMessage(width, height);
return encoder.Finish();
});
// sexy cream!
let canvas = self.display.GetCanvas();
if(canvas == null)
return;
let buffer = canvas.toBuffer('image/jpeg', { quality: 0.75 });
await this.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(buffer.length + 256);
encoder.SetDisplayRectMessage(0, 0, buffer);
return encoder.Finish();
});
});
this.display.on('rect', async (x, y, rect: ImageData) => {
let canvas = new Canvas(rect.width, rect.height);
canvas.getContext('2d').putImageData(rect, 0, 0);
let buffer = canvas.toBuffer('image/jpeg', { quality: 0.75 });
await this.BroadcastMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(buffer.length + 256);
encoder.SetDisplayRectMessage(x, y, buffer);
return encoder.Finish();
});
});
this.timer.Start();
}
private async sendFullScreen(user: VMUser) {
if (this.display == null) return;
let buffer = this.display.GetCanvas().toBuffer('image/jpeg', { quality: 0.75 });
await user.SendMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(8);
encoder.SetDisplaySizeMessage(this.display.Size().width, this.display.Size().height);
return encoder.Finish();
});
await user.SendMessage((encoder: Shared.MessageEncoder) => {
encoder.Init(buffer.length + 256);
encoder.SetDisplayRectMessage(0, 0, buffer);
return encoder.Finish();
});
}
private async VMStopped() {
await this.vm.Start();
}
}
export class SocketComputerServer {
private vm: VirtualMachine = null;
private fastify: FastifyInstance = fastify({
exposeHeadRoutes: false
});
Init() {
this.fastify.register(fastifyWebsocket.default);
this.fastify.register(async (app, _) => this.CTRoutes(app), {});
}
async Listen() {
try {
console.log('Backend starting...');
// create teh VM!!!!
await this.CreateVM();
await this.fastify.listen({
host: '127.0.0.1',
port: 4050
});
} catch (err) {
return;
}
}
async CreateVM() {
let diskpath = '/srv/collabvm/vms/socket1/socket1.qcow2';
let slotDef: QemuVmDefinition = Slot_PCDef('2G', '-netdev user,id=vm.wan', 'rtl8139', await GenMacAddress(), true, diskpath, 'qcow2');
setSnapshot(true);
// create the slot for real!
this.vm = new VirtualMachine(new QemuVM(slotDef));
await this.vm.Start(); // boot it up
}
CTRoutes(app: FastifyInstance) {
let self = this;
app.get('/', { websocket: true }, (connection: fastifyWebsocket.WebSocket) => {
new VMUser(connection, self.vm);
});
}
}

8
backend/src/index.ts Normal file
View file

@ -0,0 +1,8 @@
import { SocketComputerServer } from './SocketComputerServer.js';
(async () => {
let backend = new SocketComputerServer();
backend.Init();
await backend.Listen();
})();

11
backend/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "."
},
"references": [
{ "path": "../shared" }
]
}

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "socketcomputer-repo",
"private": "true",
"workspaces": [
"shared",
"backend",
"qemu",
"webapp"
],
"scripts": {
"build:frontend": "npm -w shared run build && npm -w webapp run build",
"build:service": "npm -w shared run build && npm -w qemu run build && npm -w backend run build"
},
"dependencies": {
"canvas": "^2.11.2"
},
"devDependencies": {
"@types/node": "^20.12.2",
"prettier": "^3.2.5",
"typescript": "^5.4.3"
}
}

19
qemu/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "@socketcomputer/qemu",
"version": "1.0.0",
"private": "true",
"description": "QEMU runtime for socketcomputer backend",
"main": "dist/src/index.js",
"type": "module",
"scripts": {
"build": "tsc"
},
"author": "",
"license": "MIT",
"dependencies": {
"canvas": "^2.11.2",
"execa": "^8.0.1",
"split": "^1.0.1"
}
}

143
qemu/src/QemuDisplay.ts Normal file
View file

@ -0,0 +1,143 @@
import { VncClient } from './rfb/client.js';
import { EventEmitter } from 'node:events';
import { Canvas, CanvasRenderingContext2D, createImageData } from 'canvas';
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: VncRect[] = [];
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));
}
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() {
return {
width: this.displayVnc.clientWidth,
height: this.displayVnc.clientHeight
};
}
MouseEvent(x, y, buttons) {
this.displayVnc.sendPointerEvent(x, y, buttons);
}
KeyboardEvent(keysym, pressed) {
this.displayVnc.sendKeyEvent(keysym, pressed);
}
}

33
qemu/src/QemuUtil.ts Normal file
View file

@ -0,0 +1,33 @@
// QEMU utility functions
// most of these are just for randomly generated/temporary files
import { execa } from 'execa';
import * as crypto from 'node:crypto';
/// Temporary path base for hard drive images.
const kVmHdaTmpPathBase = `/mnt/vmi/tmp/crusttest-hda`;
// 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(':')
);
});
});
}

286
qemu/src/QemuVM.ts Normal file
View file

@ -0,0 +1,286 @@
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) {
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();
this.SetState(VMState.Stopped);
}
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) {
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) {
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();
}
}
});
}
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) {
}
}
}

135
qemu/src/QmpClient.ts Normal file
View file

@ -0,0 +1,135 @@
// 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, port) {
super.connect(port, host);
this.ConnectImpl();
}
ConnectUNIX(path: string) {
super.connect(path);
this.ConnectImpl();
}
}

3
qemu/src/index.ts Normal file
View file

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

21
qemu/src/rfb/LICENSE Normal file
View file

@ -0,0 +1,21 @@
Copyright 2021 Filipe Calaça Barbosa
Copyright 2022 dither
Copyright 2023 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.

10
qemu/src/rfb/README.md Normal file
View file

@ -0,0 +1,10 @@
# 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 `@socketcomputer/qemu` package:
- converted to TypeScript
- all modules rewritten to use ESM
- some noisy debug prints removed
- (some, very tiny) code cleanup

885
qemu/src/rfb/client.ts Normal file
View file

@ -0,0 +1,885 @@
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';
export class VncClient extends EventEmitter {
// These are in no particular order.
public debug: Boolean;
private _connected: Boolean;
private _authenticated: Boolean;
private _version: string;
private _password: string;
private _audioChannels: number;
private _audioFrequency: number;
private _rects: number;
private _decoders: any; // no real good way to type this yet. will do it later
private _fps: number;
private _timerInterval: number;
private _timerPointer;
public fb: Buffer;
private _handshaked: Boolean;
private _waitingServerInit: Boolean;
private _expectingChallenge: Boolean;
private _challengeResponseSent: Boolean;
private _set8BitColor: Boolean;
private _frameBufferReady = false;
private _firstFrameReceived = false;
private _processingFrame = false;
private _relativePointer: Boolean;
public bigEndianFlag: Boolean;
public clientWidth: number;
public clientHeight: number;
public clientName: string;
public pixelFormat: any;
private _colorMap: any[];
private _audioData: Buffer;
private _cursor: any;
public encodings: number[];
private _connection: net.Socket;
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 = {};
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();