initial commit
This commit is contained in:
commit
071b531679
8
.editorconfig
Normal file
8
.editorconfig
Normal 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
7
.gitignore
vendored
Normal 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
3
.prettierignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
dist
|
||||
*.md
|
||||
*.json
|
20
.prettierrc.json
Normal file
20
.prettierrc.json
Normal 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
20
LICENSE
Normal 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
29
README.md
Normal 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
24
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
46
backend/src/ExtendableTimer.ts
Normal file
46
backend/src/ExtendableTimer.ts
Normal 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();
|
||||
}
|
||||
}
|
70
backend/src/SlotQemuDefs.ts
Normal file
70
backend/src/SlotQemuDefs.ts
Normal 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
|
||||
};
|
||||
}
|
395
backend/src/SocketComputerServer.ts
Normal file
395
backend/src/SocketComputerServer.ts
Normal 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
8
backend/src/index.ts
Normal 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
11
backend/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../shared" }
|
||||
]
|
||||
}
|
22
package.json
Normal file
22
package.json
Normal 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
19
qemu/package.json
Normal 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
143
qemu/src/QemuDisplay.ts
Normal 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
33
qemu/src/QemuUtil.ts
Normal 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
286
qemu/src/QemuVM.ts
Normal 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
135
qemu/src/QmpClient.ts
Normal 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
3
qemu/src/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './QemuDisplay.js';
|
||||
export * from './QemuUtil.js';
|
||||
export * from './QemuVM.js';
|
21
qemu/src/rfb/LICENSE
Normal file
21
qemu/src/rfb/LICENSE
Normal 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
10
qemu/src/rfb/README.md
Normal 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
885
qemu/src/rfb/client.ts
Normal 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();
|
||||