Giant refactoring (or at least the start)

In short:
- cvmts is now bundled/built via parcel and inside of a npm/yarn workspace with multiple nodejs projects
- cvmts now uses the crusttest QEMU management and RFB library (or a fork, if you so prefer).
- cvmts does NOT use node-canvas anymore, instead we opt for the same route crusttest took and just encode jpegs ourselves from the RFB provoded framebuffer via jpeg-turbo. this means funnily enough sharp is back for more for thumbnails, but actually seems to WORK this time
- IPData is now managed in a very similar way to the original cvm 1.2 implementation where a central manager and reference count exist. tbh it wouldn't be that hard to implement multinode either, but for now, I'm not going to take much time on doing that.

this refactor is still incomplete. please do not treat it as generally available while it's not on the default branch. if you want to use it (and report bugs or send fixes) feel free to, but while it may "just work" in certain situations it may be very broken in others.

(yes, I know windows support is partially totaled by this; it's something that can and will be fixed)
This commit is contained in:
modeco80 2024-04-23 09:57:02 -04:00
parent 28dddfc363
commit cb297e15c4
46 changed files with 5661 additions and 1011 deletions

13
.gitignore vendored
View file

@ -1,3 +1,10 @@
node_modules/
build/
config.toml
.parcel-cache/
.yarn/
**/node_modules/
config.toml
# for now
cvmts/attic
/dist
**/dist/

6
.gitmodules vendored Normal file
View file

@ -0,0 +1,6 @@
[submodule "nodejs-rfb"]
path = nodejs-rfb
url = https://github.com/computernewb/nodejs-rfb
[submodule "jpeg-turbo"]
path = jpeg-turbo
url = https://github.com/computernewb/jpeg-turbo

1
.npmrc
View file

@ -1 +0,0 @@
package-lock=false

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
dist
*.md
**/package.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
}

1
.yarnrc.yml Normal file
View file

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

View file

@ -3,6 +3,9 @@
This is a drop-in replacement for the dying CollabVM 1.2.11. Currently in beta
## Running
**TODO**: These instructions are not finished for the refactor branch.
1. Copy config.example.toml to config.toml, and fill out fields
2. Install dependencies: `npm i`
3. Build it: `npm run build`

35
cvmts/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "@cvmts/cvmts",
"version": "1.0.0",
"description": "replacement for collabvm 1.2.11 because the old one :boom:",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "parcel build src/index.ts --target node",
"serve": "node dist/index.js"
},
"author": "Elijah R, modeco80",
"license": "GPL-3.0",
"targets": {
"node": {
"context": "node",
"outputFormat": "esmodule"
}
},
"dependencies": {
"@computernewb/jpeg-turbo": "*",
"@cvmts/qemu": "*",
"execa": "^8.0.1",
"mnemonist": "^0.39.5",
"sharp": "^0.33.3",
"toml": "^3.0.0",
"ws": "^8.14.1"
},
"devDependencies": {
"@types/node": "^20.12.5",
"@types/ws": "^8.5.5",
"parcel": "^2.12.0",
"prettier": "^3.2.5",
"typescript": "^5.4.4"
}
}

46
cvmts/src/AuthManager.ts Normal file
View file

@ -0,0 +1,46 @@
import { Logger } from '@cvmts/shared';
import { Rank, User } from './User.js';
export default class AuthManager {
apiEndpoint: string;
secretKey: string;
private logger = new Logger("CVMTS.AuthMan");
constructor(apiEndpoint: string, secretKey: string) {
this.apiEndpoint = apiEndpoint;
this.secretKey = secretKey;
}
async Authenticate(token: string, user: User): Promise<JoinResponse> {
var response = await fetch(this.apiEndpoint + '/api/v1/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
secretKey: this.secretKey,
sessionToken: token,
ip: user.IP.address
})
});
var json = (await response.json()) as JoinResponse;
if (!json.success) {
this.logger.Error(`Failed to query auth server: ${json.error}`);
process.exit(1);
}
return json;
}
}
interface JoinResponse {
success: boolean;
clientSuccess: boolean;
error: string | undefined;
username: string | undefined;
rank: Rank;
}

69
cvmts/src/IConfig.ts Normal file
View file

@ -0,0 +1,69 @@
export default interface IConfig {
http: {
host: string;
port: number;
proxying: boolean;
proxyAllowedIps: string[];
origin: boolean;
originAllowedDomains: string[];
maxConnections: number;
};
auth: {
enabled: boolean;
apiEndpoint: string;
secretKey: string;
guestPermissions: {
chat: boolean;
turn: boolean;
};
};
vm: {
qemuArgs: string;
vncPort: number;
snapshots: boolean;
qmpHost: string | null;
qmpPort: number | null;
qmpSockDir: string | null;
};
collabvm: {
node: string;
displayname: string;
motd: string;
bancmd: string | string[];
moderatorEnabled: boolean;
usernameblacklist: string[];
maxChatLength: number;
maxChatHistoryLength: number;
turnlimit: {
enabled: boolean;
maximum: number;
};
automute: {
enabled: boolean;
seconds: number;
messages: number;
};
tempMuteTime: number;
turnTime: number;
voteTime: number;
voteCooldown: number;
adminpass: string;
modpass: string;
turnwhitelist: boolean;
turnpass: string;
moderatorPermissions: Permissions;
};
}
export interface Permissions {
restore: boolean;
reboot: boolean;
ban: boolean;
forcevote: boolean;
mute: boolean;
kick: boolean;
bypassturn: boolean;
rename: boolean;
grabip: boolean;
xss: boolean;
}

62
cvmts/src/IPData.ts Normal file
View file

@ -0,0 +1,62 @@
import { Logger } from "@cvmts/shared";
export class IPData {
tempMuteExpireTimeout?: NodeJS.Timeout;
muted: Boolean;
vote: boolean | null;
address: string;
refCount: number = 0;
constructor(address: string) {
this.address = address;
this.muted = false;
this.vote = null;
}
// Call when a connection is closed to "release" the ip data
Unref() {
if(this.refCount - 1 < 0)
this.refCount = 0;
this.refCount--;
}
}
export class IPDataManager {
static ipDatas = new Map<string, IPData>();
static logger = new Logger("CVMTS.IPDataManager");
static GetIPData(address: string) {
if(IPDataManager.ipDatas.has(address)) {
// Note: We already check for if it exists, so we use ! here
// because TypeScript can't exactly tell that in this case,
// only in explicit null or undefined checks
let ref = IPDataManager.ipDatas.get(address)!;
ref.refCount++;
return ref;
}
let data = new IPData(address);
data.refCount++;
IPDataManager.ipDatas.set(address, data);
return data;
}
static ForEachIPData(callback: (d: IPData) => void) {
for(let tuple of IPDataManager.ipDatas)
callback(tuple[1]);
}
}
// Garbage collect unreferenced IPDatas every 15 seconds.
// Strictly speaking this will just allow the v8 GC to finally
// delete the objects, but same difference.
setInterval(() => {
for(let tuple of IPDataManager.ipDatas) {
if(tuple[1].refCount == 0) {
IPDataManager.logger.Info("Deleted ipdata for IP {0}", tuple[0]);
IPDataManager.ipDatas.delete(tuple[0]);
}
}
}, 15000);

161
cvmts/src/User.ts Normal file
View file

@ -0,0 +1,161 @@
import * as Utilities from './Utilities.js';
import * as guacutils from './guacutils.js';
import { WebSocket } from 'ws';
import { IPData } from './IPData.js';
import IConfig from './IConfig.js';
import RateLimiter from './RateLimiter.js';
import { execa, execaCommand, ExecaSyncError } from 'execa';
import { Logger } from '@cvmts/shared';
export class User {
socket: WebSocket;
nopSendInterval: NodeJS.Timeout;
msgRecieveInterval: NodeJS.Timeout;
nopRecieveTimeout?: NodeJS.Timeout;
username?: string;
connectedToNode: boolean;
viewMode: number;
rank: Rank;
msgsSent: number;
Config: IConfig;
IP: IPData;
// Rate limiters
ChatRateLimit: RateLimiter;
LoginRateLimit: RateLimiter;
RenameRateLimit: RateLimiter;
TurnRateLimit: RateLimiter;
VoteRateLimit: RateLimiter;
private logger = new Logger("CVMTS.User");
constructor(ws: WebSocket, ip: IPData, config: IConfig, username?: string, node?: string) {
this.IP = ip;
this.connectedToNode = false;
this.viewMode = -1;
this.Config = config;
this.socket = ws;
this.msgsSent = 0;
this.socket.on('close', () => {
clearInterval(this.nopSendInterval);
});
this.socket.on('message', (e) => {
clearTimeout(this.nopRecieveTimeout);
clearInterval(this.msgRecieveInterval);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
});
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
this.sendNop();
if (username) this.username = username;
this.rank = 0;
this.ChatRateLimit = new RateLimiter(this.Config.collabvm.automute.messages, this.Config.collabvm.automute.seconds);
this.ChatRateLimit.on('limit', () => this.mute(false));
this.RenameRateLimit = new RateLimiter(3, 60);
this.RenameRateLimit.on('limit', () => this.closeConnection());
this.LoginRateLimit = new RateLimiter(4, 3);
this.LoginRateLimit.on('limit', () => this.closeConnection());
this.TurnRateLimit = new RateLimiter(5, 3);
this.TurnRateLimit.on('limit', () => this.closeConnection());
this.VoteRateLimit = new RateLimiter(3, 3);
this.VoteRateLimit.on('limit', () => this.closeConnection());
}
assignGuestName(existingUsers: string[]): string {
var username;
do {
username = 'guest' + Utilities.Randint(10000, 99999);
} while (existingUsers.indexOf(username) !== -1);
this.username = username;
return username;
}
sendNop() {
this.socket.send('3.nop;');
}
sendMsg(msg: string | Buffer) {
if (this.socket.readyState !== this.socket.OPEN) return;
clearInterval(this.nopSendInterval);
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.socket.send(msg);
}
private onNoMsg() {
this.sendNop();
this.nopRecieveTimeout = setTimeout(() => {
this.closeConnection();
}, 3000);
}
closeConnection() {
this.socket.send(guacutils.encode('disconnect'));
this.socket.close();
}
onMsgSent() {
if (!this.Config.collabvm.automute.enabled) return;
// rate limit guest and unregistered chat messages, but not staff ones
switch (this.rank) {
case Rank.Moderator:
case Rank.Admin:
break;
default:
this.ChatRateLimit.request();
break;
}
}
mute(permanent: boolean) {
this.IP.muted = true;
this.sendMsg(guacutils.encode('chat', '', `You have been muted${permanent ? '' : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`));
if (!permanent) {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000);
}
}
unmute() {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.muted = false;
this.sendMsg(guacutils.encode('chat', '', 'You are no longer muted.'));
}
private banCmdArgs(arg: string): string {
return arg.replace(/\$IP/g, this.IP.address).replace(/\$NAME/g, this.username || '');
}
async ban() {
// Prevent the user from taking turns or chatting, in case the ban command takes a while
this.IP.muted = true;
try {
if (Array.isArray(this.Config.collabvm.bancmd)) {
let args: string[] = this.Config.collabvm.bancmd.map((a: string) => this.banCmdArgs(a));
if (args.length || args[0].length) {
await execa(args.shift()!, args, { stdout: process.stdout, stderr: process.stderr });
this.kick();
} else {
this.logger.Error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
} else if (typeof this.Config.collabvm.bancmd == 'string') {
let cmd: string = this.banCmdArgs(this.Config.collabvm.bancmd);
if (cmd.length) {
await execaCommand(cmd, { stdout: process.stdout, stderr: process.stderr });
this.kick();
} else {
this.logger.Error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
}
} catch (e) {
this.logger.Error(`Failed to ban ${this.IP.address} (${this.username}): ${(e as ExecaSyncError).shortMessage}`);
}
}
async kick() {
this.sendMsg('10.disconnect;');
this.socket.close();
}
}
export enum Rank {
Unregistered = 0,
// After all these years
Registered = 1,
Admin = 2,
Moderator = 3,
// Giving a good gap between server only internal ranks just in case
Turn = 10
}

View file

@ -9,63 +9,113 @@ import * as guacutils from './guacutils.js';
import CircularBuffer from 'mnemonist/circular-buffer.js';
import Queue from 'mnemonist/queue.js';
import { createHash } from 'crypto';
import { isIP } from 'net';
import QEMUVM from './QEMUVM.js';
import { Canvas, createCanvas } from 'canvas';
import { IPData } from './IPData.js';
import { readFileSync } from 'fs';
import log from './log.js';
import VM from './VM.js';
import { isIP } from 'node:net';
import { QemuVM, QemuVmDefinition } from '@cvmts/qemu';
import { IPData, IPDataManager } from './IPData.js';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'url';
import path from 'path';
import AuthManager from './AuthManager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { Size, Rect, Logger } from '@cvmts/shared';
import jpegTurbo from "@computernewb/jpeg-turbo";
import sharp from 'sharp';
// probably better
const __dirname = process.cwd();
// ejla this exist. Useing it.
type ChatHistory = {
user: string,
msg: string
};
// A good balance. TODO: Configurable?
const kJpegQuality = 35;
// this returns appropiate Sharp options to deal with the framebuffer
function GetRawSharpOptions(size: Size): sharp.CreateRaw {
return {
width: size.width,
height: size.height,
channels: 4
}
}
async function EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promise<Buffer> {
let offset = (rect.y * displaySize.width + rect.x) * 4;
//console.log('encoding rect', rect, 'with byteoffset', offset, '(size ', displaySize, ')');
return jpegTurbo.compress(canvas.subarray(offset), {
format: jpegTurbo.FORMAT_RGBA,
width: rect.width,
height: rect.height,
subsampling: jpegTurbo.SAMP_422,
stride: displaySize.width,
quality: kJpegQuality
});
}
export default class WSServer {
private Config : IConfig;
private server : http.Server;
private socket : WebSocketServer;
private httpServer : http.Server;
private wsServer : WebSocketServer;
private clients : User[];
private ips : IPData[];
private ChatHistory : CircularBuffer<{user:string,msg:string}>
private ChatHistory : CircularBuffer<ChatHistory>
private TurnQueue : Queue<User>;
// Time remaining on the current turn
private TurnTime : number;
// Interval to keep track of the current turn time
private TurnInterval? : NodeJS.Timeout;
// If a reset vote is in progress
private voteInProgress : boolean;
// Interval to keep track of vote resets
private voteInterval? : NodeJS.Timeout;
// How much time is left on the vote
private voteTime : number;
// How much time until another reset vote can be cast
private voteCooldown : number;
// Interval to keep track
private voteCooldownInterval? : NodeJS.Timeout;
// Completely disable turns
private turnsAllowed : boolean;
// Hide the screen
private screenHidden : boolean;
// base64 image to show when the screen is hidden
private screenHiddenImg : string;
private screenHiddenThumb : string;
// Indefinite turn
private indefiniteTurn : User | null;
private ModPerms : number;
private VM : VM;
private VM : QemuVM;
// Authentication manager
private auth : AuthManager | null;
constructor(config : IConfig, vm : VM, auth : AuthManager | null) {
private logger = new Logger("CVMTS.Server");
constructor(config : IConfig, vm : QemuVM, auth : AuthManager | null) {
this.Config = config;
this.ChatHistory = new CircularBuffer<{user:string,msg:string}>(Array, this.Config.collabvm.maxChatHistoryLength);
this.ChatHistory = new CircularBuffer<ChatHistory>(Array, this.Config.collabvm.maxChatHistoryLength);
this.TurnQueue = new Queue<User>();
this.TurnTime = 0;
this.clients = [];
this.ips = [];
this.voteInProgress = false;
this.voteTime = 0;
this.voteCooldown = 0;
@ -76,25 +126,33 @@ export default class WSServer {
this.indefiniteTurn = null;
this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions);
this.server = http.createServer();
this.socket = new WebSocketServer({noServer: true});
this.server.on('upgrade', (req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) => this.httpOnUpgrade(req, socket, head));
this.server.on('request', (req, res) => {
this.httpServer = http.createServer();
this.wsServer = new WebSocketServer({noServer: true});
this.httpServer.on('upgrade', (req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) => this.httpOnUpgrade(req, socket, head));
this.httpServer.on('request', (req, res) => {
res.writeHead(426);
res.write("This server only accepts WebSocket connections.");
res.end();
});
var initSize = vm.getSize();
this.newsize(initSize);
let initSize = vm.GetDisplay().Size() || {
width: 0,
height: 0
};
this.OnDisplayResized(initSize);
vm.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size));
vm.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect));
this.VM = vm;
this.VM.on("dirtyrect", (j, x, y) => this.newrect(j, x, y));
this.VM.on("size", (s) => this.newsize(s));
// authentication manager
this.auth = auth;
}
listen() {
this.server.listen(this.Config.http.port, this.Config.http.host);
this.httpServer.listen(this.Config.http.port, this.Config.http.host);
}
private httpOnUpgrade(req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) {
@ -182,58 +240,62 @@ export default class WSServer {
socket.destroy();
}
this.socket.handleUpgrade(req, socket, head, (ws: WebSocket) => {
this.socket.emit('connection', ws, req);
this.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => {
this.wsServer.emit('connection', ws, req);
this.onConnection(ws, req, ip);
});
}
private onConnection(ws : WebSocket, req: http.IncomingMessage, ip : string) {
var _ipdata = this.ips.filter(data => data.address == ip);
var ipdata;
if(_ipdata.length > 0) {
ipdata = _ipdata[0];
}else{
ipdata = new IPData(ip);
this.ips.push(ipdata);
}
var user = new User(ws, ipdata, this.Config);
let user = new User(ws, IPDataManager.GetIPData(ip), this.Config);
this.clients.push(user);
ws.on('error', (e) => {
log("ERROR", `${e} (caused by connection ${ip})`);
ws.on('error', (e) => {
this.logger.Error(`${e} (caused by connection ${ip})`);
ws.close();
});
ws.on('close', () => this.connectionClosed(user));
ws.on('message', (e) => {
ws.on('message', (buf: Buffer, isBinary: boolean) => {
var msg;
try {msg = e.toString()}
catch {
// Close the user's connection if they send a non-string message
// Close the user's connection if they send a non-string message
if(isBinary) {
user.closeConnection();
return;
}
this.onMessage(user, msg);
try {
this.onMessage(user, buf.toString());
} catch {
}
});
if (this.Config.auth.enabled) {
user.sendMsg(guacutils.encode("auth", this.Config.auth.apiEndpoint));
}
user.sendMsg(this.getAdduserMsg());
log("INFO", `Connect from ${user.IP.address}`);
this.logger.Info(`Connect from ${user.IP.address}`);
};
private connectionClosed(user : User) {
if (this.clients.indexOf(user) === -1) return;
let clientIndex = this.clients.indexOf(user)
if (clientIndex === -1) return;
if(user.IP.vote != null) {
user.IP.vote = null;
this.sendVoteUpdate();
};
}
// Unreference the IP data.
user.IP.Unref();
if (this.indefiniteTurn === user) this.indefiniteTurn = null;
this.clients.splice(this.clients.indexOf(user), 1);
log("INFO", `Disconnect From ${user.IP.address}${user.username ? ` with username ${user.username}` : ""}`);
this.clients.splice(clientIndex, 1);
this.logger.Info(`Disconnect From ${user.IP.address}${user.username ? ` with username ${user.username}` : ""}`);
if (!user.username) return;
if (this.TurnQueue.toArray().indexOf(user) !== -1) {
var hadturn = (this.TurnQueue.peek() === user);
@ -243,6 +305,8 @@ export default class WSServer {
this.clients.forEach((c) => c.sendMsg(guacutils.encode("remuser", "1", user.username!)));
}
private async onMessage(client : User, message : string) {
var msgArr = guacutils.decode(message);
if (msgArr.length < 1) return;
@ -255,7 +319,7 @@ export default class WSServer {
}
var res = await this.auth!.Authenticate(msgArr[1], client);
if (res.clientSuccess) {
log("INFO", `${client.IP.address} logged in as ${res.username}`);
this.logger.Info(`${client.IP.address} logged in as ${res.username}`);
client.sendMsg(guacutils.encode("login", "1"));
var old = this.clients.find(c=>c.username === res.username);
if (old) {
@ -297,10 +361,7 @@ export default class WSServer {
client.sendMsg(guacutils.encode("size", "0", "1024", "768"));
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg));
} else {
client.sendMsg(guacutils.encode("size", "0", this.VM.framebuffer.width.toString(), this.VM.framebuffer.height.toString()));
var jpg = this.VM.framebuffer.toBuffer("image/jpeg");
var jpg64 = jpg.toString("base64");
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64));
await this.SendFullScreenWithSize(client);
}
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
if (this.voteInProgress) this.sendVoteUpdate(client);
@ -335,10 +396,7 @@ export default class WSServer {
client.sendMsg(guacutils.encode("size", "0", "1024", "768"));
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg));
} else {
client.sendMsg(guacutils.encode("size", "0", this.VM.framebuffer.width.toString(), this.VM.framebuffer.height.toString()));
var jpg = this.VM.framebuffer.toBuffer("image/jpeg");
var jpg64 = jpg.toString("base64");
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64));
await this.SendFullScreenWithSize(client);
}
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
}
@ -428,20 +486,18 @@ export default class WSServer {
break;
case "mouse":
if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return;
if (!this.VM.acceptingInput()) return;
var x = parseInt(msgArr[1]);
var y = parseInt(msgArr[2]);
var mask = parseInt(msgArr[3]);
if (x === undefined || y === undefined || mask === undefined) return;
this.VM.pointerEvent(x, y, mask);
this.VM.GetDisplay()!.MouseEvent(x, y, mask);
break;
case "key":
if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return;
if (!this.VM.acceptingInput()) return;
var keysym = parseInt(msgArr[1]);
var down = parseInt(msgArr[2]);
if (keysym === undefined || (down !== 0 && down !== 1)) return;
this.VM.keyEvent(keysym, down === 1 ? true : false);
this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false);
break;
case "vote":
if (!this.Config.vm.snapshots) return;
@ -501,10 +557,8 @@ export default class WSServer {
return;
}
if (this.screenHidden) {
client.sendMsg(guacutils.encode("size", "0", this.VM.framebuffer.width.toString(), this.VM.framebuffer.height.toString()));
var jpg = this.VM.framebuffer.toBuffer("image/jpeg");
var jpg64 = jpg.toString("base64");
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64));
await this.SendFullScreenWithSize(client);
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
}
@ -513,24 +567,26 @@ export default class WSServer {
case "5":
// QEMU Monitor
if (client.rank !== Rank.Admin) return;
/* Surely there could be rudimentary processing to convert some qemu monitor syntax to [XYZ hypervisor] if possible
if (!(this.VM instanceof QEMUVM)) {
client.sendMsg(guacutils.encode("admin", "2", "This is not a QEMU VM and therefore QEMU monitor commands cannot be run."));
return;
}
*/
if (msgArr.length !== 4 || msgArr[2] !== this.Config.collabvm.node) return;
var output = await this.VM.qmpClient.runMonitorCmd(msgArr[3]);
var output = await this.VM.MonitorCommand(msgArr[3]);
client.sendMsg(guacutils.encode("admin", "2", String(output)));
break;
case "8":
// Restore
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.restore)) return;
this.VM.Restore();
this.VM.Reset();
break;
case "10":
// Reboot
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.reboot)) return;
if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return;
this.VM.Reboot();
this.VM.MonitorCommand("system_reset");
break;
case "12":
// Ban
@ -671,11 +727,19 @@ export default class WSServer {
break;
case "1":
this.screenHidden = false;
this.clients.forEach(client => {
client.sendMsg(guacutils.encode("size", "0", this.VM.framebuffer.width.toString(), this.VM.framebuffer.height.toString()));
var jpg = this.VM.framebuffer.toBuffer("image/jpeg");
var jpg64 = jpg.toString("base64");
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64));
let displaySize = this.VM.GetDisplay().Size();
let encoded = await this.MakeRectData({
x: 0,
y: 0,
width: displaySize.width,
height: displaySize.height
});
this.clients.forEach(async client => {
client.sendMsg(guacutils.encode("size", "0", displaySize.width.toString(), displaySize.height.toString()));
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", encoded));
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
});
break;
@ -733,12 +797,12 @@ export default class WSServer {
client.sendMsg(guacutils.encode("rename", "0", status, client.username!, client.rank.toString()));
if (hadName) {
log("INFO", `Rename ${client.IP.address} from ${oldname} to ${client.username}`);
this.logger.Info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`);
this.clients.forEach((c) =>
c.sendMsg(guacutils.encode("rename", "1", oldname, client.username!, client.rank.toString())));
} else {
log("INFO", `Rename ${client.IP.address} to ${client.username}`);
this.logger.Info(`Rename ${client.IP.address} to ${client.username}`);
this.clients.forEach((c) =>
c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString())));
@ -820,31 +884,61 @@ export default class WSServer {
}
}
private async newrect(rect : Canvas, x : number, y : number) {
var jpg = rect.toBuffer("image/jpeg", {quality: 0.5, progressive: true, chromaSubsampling: true});
var jpg64 = jpg.toString("base64");
private async OnDisplayRectangle(rect: Rect) {
let encodedb64 = await this.MakeRectData(rect);
this.clients.filter(c => c.connectedToNode || c.viewMode == 1).forEach(c => {
if (this.screenHidden && c.rank == Rank.Unregistered) return;
c.sendMsg(guacutils.encode("png", "0", "0", x.toString(), y.toString(), jpg64));
c.sendMsg(guacutils.encode("png", "0", "0", rect.x.toString(), rect.y.toString(), encodedb64));
c.sendMsg(guacutils.encode("sync", Date.now().toString()));
});
}
private newsize(size : {height:number,width:number}) {
private OnDisplayResized(size : Size) {
this.clients.filter(c => c.connectedToNode || c.viewMode == 1).forEach(c => {
if (this.screenHidden && c.rank == Rank.Unregistered) return;
c.sendMsg(guacutils.encode("size", "0", size.width.toString(), size.height.toString()))
});
}
private async SendFullScreenWithSize(client: User) {
let display = this.VM.GetDisplay();
let displaySize = display.Size();
let encoded = await this.MakeRectData({
x: 0,
y: 0,
width: displaySize.width,
height: displaySize.height
});
client.sendMsg(guacutils.encode("size", "0", displaySize.width.toString(), displaySize.height.toString()));
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", encoded));
}
private async MakeRectData(rect: Rect) {
let display = this.VM.GetDisplay();
let displaySize = display.Size();
let encoded = await EncodeJpeg(display.Buffer(), displaySize, rect);
return encoded.toString('base64');
}
getThumbnail() : Promise<string> {
return new Promise(async (res, rej) => {
var cnv = createCanvas(400, 300);
var ctx = cnv.getContext("2d");
ctx.drawImage(this.VM.framebuffer, 0, 0, 400, 300);
var jpg = cnv.toBuffer("image/jpeg");
res(jpg.toString("base64"));
})
let display = this.VM.GetDisplay();
if(display == null)
return;
// TODO: pass custom options to Sharp.resize() probably
let out = await sharp(display.Buffer(), {raw: GetRawSharpOptions(display.Size())})
.resize(400, 300)
.toFormat('jpeg')
.toBuffer();
res(out.toString('base64'));
});
}
startVote() {
@ -868,7 +962,7 @@ export default class WSServer {
this.clients.forEach((c) => c.sendMsg(guacutils.encode("vote", "2")));
if (result === true || (result === undefined && count.yes >= count.no)) {
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has won.")));
this.VM.Restore();
this.VM.Reset();
} else {
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has lost.")));
}
@ -896,7 +990,7 @@ export default class WSServer {
getVoteCounts() : {yes:number,no:number} {
var yes = 0;
var no = 0;
this.ips.forEach((c) => {
IPDataManager.ForEachIPData((c) => {
if (c.vote === true) yes++;
if (c.vote === false) no++;
});

View file

@ -2,25 +2,29 @@ import * as toml from 'toml';
import IConfig from './IConfig.js';
import * as fs from "fs";
import WSServer from './WSServer.js';
import QEMUVM from './QEMUVM.js';
import log from './log.js';
import { QemuVM, QemuVmDefinition } from '@cvmts/qemu';
import * as Shared from '@cvmts/shared';
import AuthManager from './AuthManager.js';
log("INFO", "CollabVM Server starting up");
let logger = new Shared.Logger("CVMTS.Init");
logger.Info("CollabVM Server starting up");
// Parse the config file
var Config : IConfig;
if (!fs.existsSync("config.toml")) {
log("FATAL", "Config.toml not found. Please copy config.example.toml and fill out fields")
logger.Error("Fatal error: Config.toml not found. Please copy config.example.toml and fill out fields")
process.exit(1);
}
try {
var configRaw = fs.readFileSync("config.toml").toString();
Config = toml.parse(configRaw);
} catch (e) {
log("FATAL", `Failed to read or parse the config file: ${e}`);
logger.Error("Fatal error: Failed to read or parse the config file: {0}", (e as Error).message);
process.exit(1);
}
@ -30,16 +34,21 @@ async function start() {
// and the host OS is Windows, as this
// configuration will very likely not work.
if(process.platform === "win32" && Config.vm.qmpSockDir) {
log("WARN", "You appear to have the option 'qmpSockDir' enabled in the config.")
log("WARN", "This is not supported on Windows, and you will likely run into issues.");
log("WARN", "To remove this warning, use the qmpHost and qmpPort options instead.");
logger.Warning("You appear to have the option 'qmpSockDir' enabled in the config.")
logger.Warning("This is not supported on Windows, and you will likely run into issues.");
logger.Warning("To remove this warning, use the qmpHost and qmpPort options instead.");
}
// Init the auth manager if enabled
var auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null;
let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null;
// Fire up the VM
var VM = new QEMUVM(Config);
let def: QemuVmDefinition = {
id: Config.collabvm.node,
command: Config.vm.qemuArgs
}
var VM = new QemuVM(def);
await VM.Start();
// Start up the websocket server

1
cvmts/tsconfig.json Symbolic link
View file

@ -0,0 +1 @@
../tsconfig.json

1
jpeg-turbo Submodule

@ -0,0 +1 @@
Subproject commit 6718ec1fc12aeccdb1b1490a7a258f24e8f83164

1
nodejs-rfb Submodule

@ -0,0 +1 @@
Subproject commit c94369b4447e574e3f62a18e93b08124f7dc96e5

View file

@ -1,28 +1,20 @@
{
"name": "collabvm1.ts",
"version": "1.0.0",
"description": "replacement for collabvm 1.2.11 because the old one :boom:",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"serve": "node build/index.js"
},
"author": "Elijah R",
"license": "GPL-3.0",
"dependencies": {
"@types/node": "^20.6.0",
"@types/sharp": "^0.31.1",
"@types/ws": "^8.5.5",
"async-mutex": "^0.4.0",
"canvas": "^2.11.2",
"execa": "^8.0.1",
"fs": "^0.0.1-security",
"jimp": "^0.22.10",
"mnemonist": "^0.39.5",
"rfb2": "github:elijahr2411/node-rfb2",
"toml": "^3.0.0",
"typescript": "^5.2.2",
"ws": "^8.14.1"
},
"type": "module"
"name": "cvmts-repo",
"workspaces": [
"shared",
"jpeg-turbo",
"nodejs-rfb",
"qemu",
"cvmts"
],
"devDependencies": {
"@parcel/packager-ts": "2.12.0",
"@parcel/transformer-sass": "2.12.0",
"@parcel/transformer-typescript-types": "2.12.0",
"@types/node": "^20.12.5",
"parcel": "^2.12.0",
"prettier": "^3.2.5",
"typescript": "^5.4.4"
},
"packageManager": "yarn@4.1.1"
}

31
qemu/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "@cvmts/qemu",
"version": "1.0.0",
"description": "QEMU runtime for crusttest 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": {
"@computernewb/nodejs-rfb": "*",
"@cvmts/shared": "*",
"execa": "^8.0.1",
"split": "^1.0.1"
},
"devDependencies": {
"@types/split": "^1.0.5",
"parcel": "^2.12.0"
}
}

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

@ -0,0 +1,143 @@
import { VncClient } from '@computernewb/nodejs-rfb';
import { EventEmitter } from 'node:events';
import { BatchRects } from './QemuUtil.js';
import { Size, Rect, Clamp } from '@cvmts/shared';
const kQemuFps = 60;
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 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.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
//this.emit('rect', { x: 0, y: 0, width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
this.emit('frame');
});
this.displayVnc.on('desktopSizeChanged', (size: Size) => {
this.emit('resize', size);
});
let rects: Rect[] = [];
this.displayVnc.on('rectUpdateProcessed', (rect: Rect) => {
rects.push(rect);
});
this.displayVnc.on('frameUpdated', (fb: Buffer) => {
// use the cvmts batcher
let batched = BatchRects(this.Size(), rects);
this.emit('rect', batched);
// unbatched (watch the performace go now)
//for(let rect of rects)
// this.emit('rect', rect);
rects = [];
this.emit('frame');
});
}
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();
}
Buffer(): Buffer {
return this.displayVnc.fb;
}
Size(): Size {
if (!this.displayVnc.connected)
return {
width: 0,
height: 0
};
return {
width: this.displayVnc.clientWidth,
height: this.displayVnc.clientHeight
};
}
MouseEvent(x: number, y: number, buttons: number) {
if (this.displayVnc.connected) this.displayVnc.sendPointerEvent(Clamp(x, 0, this.displayVnc.clientWidth), Clamp(y, 0, this.displayVnc.clientHeight), buttons);
}
KeyboardEvent(keysym: number, pressed: boolean) {
if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed);
}
}

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

@ -0,0 +1,41 @@
import { Size, Rect } from "@cvmts/shared";
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
};
}

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

@ -0,0 +1,290 @@
import { execa, execaCommand, ExecaChildProcess } from 'execa';
import { EventEmitter } from 'events';
import QmpClient from './QmpClient.js';
import { QemuDisplay } from './QemuDisplay.js';
import { unlink } from 'node:fs/promises';
import * as Shared from '@cvmts/shared';
export enum VMState {
Stopped,
Starting,
Started,
Stopping
}
// TODO: Add bits to this to allow usage (optionally)
// of VNC/QMP port. This will be needed to fix up Windows support.
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;
// TODO: This should be added to QemuVmDefinition and the below export removed
let gVMShouldSnapshot = true;
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;
private definition: QemuVmDefinition;
private addedAdditionalArguments = false;
private logger: Shared.Logger;
constructor(def: QemuVmDefinition) {
super();
this.definition = def;
this.logger = new Shared.Logger(`CVMTS.QEMU.QemuVM/${this.definition.id}`);
this.display = new QemuDisplay(this.GetVncPath());
}
async Start() {
// Don't start while either trying to start or starting.
if (this.state == VMState.Started || this.state == VMState.Starting) return;
let cmd = this.definition.command;
// build additional command line statements to enable qmp/vnc over unix sockets
// FIXME: Still use TCP if on Windows.
if(!this.addedAdditionalArguments) {
cmd += ' -no-shutdown';
if(gVMShouldSnapshot)
cmd += ' -snapshot';
cmd += ` -qmp unix:${this.GetQmpPath()},server,wait -vnc unix:${this.GetVncPath()}`;
this.definition.command = cmd;
this.addedAdditionalArguments = true;
}
this.VMLog().Info(`Starting QEMU with command \"${cmd}\"`);
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 Shared.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() {
return this.logger;
}
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}/cvmts-${this.definition.id}-mon`;
}
private GetVncPath() {
return `${kVmTmpPathBase}/cvmts-${this.definition.id}-vnc`;
}
private async StartQemu(split: string) {
let self = this;
this.SetState(VMState.Starting);
// Start QEMU
this.qemuProcess = execaCommand(split);
this.qemuProcess.on('spawn', async () => {
self.qemuRunning = true;
await Shared.Sleep(500);
await self.ConnectQmp();
});
this.qemuProcess.on('exit', async (code) => {
self.qemuRunning = false;
// ?
if (self.qmpConnected) {
await self.DisconnectQmp();
}
self.DisconnectDisplay();
if (self.state != VMState.Stopping) {
if (code == 0) {
await Shared.Sleep(500);
await self.StartQemu(split);
} else {
self.VMLog().Error('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('SIGTERM');
}
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().Error(`Failed to connect to QMP ${self.qmpFailCount} times`);
await Shared.Sleep(500);
await self.ConnectQmp();
} else {
this.VMLog().Error(`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':
await self.qmpInstance?.Execute('cont');
break;
}
});
self.qmpInstance.on('qmp-ready', async (hadError) => {
self.VMLog().Info('QMP ready');
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 Shared.Sleep(500);
this.qmpInstance?.ConnectUNIX(this.GetQmpPath());
} catch (err) {
// just try again
await Shared.Sleep(500);
await this.ConnectQmp();
}
}
}
private async DisconnectDisplay() {
try {
this.display?.Disconnect();
//this.display = null; // disassociate with that display object.
await unlink(this.GetVncPath());
// qemu *should* do this on its own but it really doesn't like doing so sometimes
await unlink(this.GetQmpPath());
} 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: string, port: number) {
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';

1
qemu/tsconfig.json Symbolic link
View file

@ -0,0 +1 @@
../tsconfig.json

28
shared/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "@cvmts/shared",
"version": "1.0.0",
"description": "cvmts shared util bits",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"targets": {
"types": {},
"shared": {
"context": "browser",
"isLibrary": true,
"outputFormat": "esmodule"
}
},
"devDependencies": {
"@protobuf-ts/plugin": "^2.9.4",
"parcel": "^2.12.0"
},
"scripts": {
"build": "parcel build src/index.ts --target shared --target types"
},
"author": "",
"license": "ISC"
}

50
shared/src/Logger.ts Normal file
View file

@ -0,0 +1,50 @@
import { Format } from "./format";
import { StringLike } from "./StringLike";
export enum LogLevel {
VERBOSE = 0,
INFO,
WARNING,
ERROR
};
let gLogLevel = LogLevel.INFO;
export function SetLogLevel(level: LogLevel) {
gLogLevel = level;
}
export class Logger {
private _component: string;
constructor(component: string) {
this._component = component;
}
// TODO: use js argments stuff.
Verbose(pattern: string, ...args: Array<StringLike>) {
if(gLogLevel <= LogLevel.VERBOSE)
console.log(`[${this._component}] [VERBOSE] ${Format(pattern, ...args)}`);
}
Info(pattern: string, ...args: Array<StringLike>) {
if(gLogLevel <= LogLevel.INFO)
console.log(`[${this._component}] [INFO] ${Format(pattern, ...args)}`);
}
Warning(pattern: string, ...args: Array<StringLike>) {
if(gLogLevel <= LogLevel.WARNING)
console.warn(`[${this._component}] [WARNING] ${Format(pattern, ...args)}`);
}
Error(pattern: string, ...args: Array<StringLike>) {
if(gLogLevel <= LogLevel.ERROR)
console.error(`[${this._component}] [ERROR] ${Format(pattern, ...args)}`);
}
}

9
shared/src/StringLike.ts Normal file
View file

@ -0,0 +1,9 @@
// TODO: `Object` has a toString(), but we should probably gate that off
/// Interface for things that can be turned into strings
export interface ToStringable {
toString(): string;
}
/// A type for strings, or things that can (in a valid manner) be turned into strings
export type StringLike = string | ToStringable;

77
shared/src/format.ts Normal file
View file

@ -0,0 +1,77 @@
import { StringLike } from './StringLike';
function isalpha(char: number) {
return RegExp(/^\p{L}/, 'u').test(String.fromCharCode(char));
}
/// A simple function for formatting strings in a more expressive manner.
/// While JavaScript *does* have string interpolation, it's not a total replacement
/// for just formatting strings, and a method like this is better for data independent formatting.
///
/// ## Example usage
///
/// ```typescript
/// let hello = Format("Hello, {0}!", "World");
/// ```
export function Format(pattern: string, ...args: Array<StringLike>) {
let argumentsAsStrings: Array<string> = [...args].map((el) => {
// This catches cases where the thing already is a string
if (typeof el == 'string') return el as string;
return el.toString();
});
let pat = pattern;
// Handle pattern ("{0} {1} {2} {3} {4} {5}") syntax if found
for (let i = 0; i < pat.length; ++i) {
if (pat[i] == '{') {
let replacementStart = i;
let foundSpecifierEnd = false;
// Make sure the specifier is not cut off (the last character of the string)
if (i + 3 > pat.length) {
throw new Error(`Error in format pattern "${pat}": Cutoff/invalid format specifier`);
}
// Try and find the specifier end ('}').
// Whitespace and a '{' are considered errors.
for (let j = i + 1; j < pat.length; ++j) {
switch (pat[j]) {
case '}':
foundSpecifierEnd = true;
i = j;
break;
case '{':
throw new Error(`Error in format pattern "${pat}": Cannot start a format specifier in an existing replacement`);
case ' ':
throw new Error(`Error in format pattern "${pat}": Whitespace inside format specifier`);
case '-':
throw new Error(`Error in format pattern "${pat}": Malformed format specifier`);
default:
if (isalpha(pat.charCodeAt(j))) throw new Error(`Error in format pattern "${pat}": Malformed format specifier`);
break;
}
if (foundSpecifierEnd) break;
}
if (!foundSpecifierEnd) throw new Error(`Error in format pattern "${pat}": No terminating "}" character found`);
// Get the beginning and trailer
let beginning = pat.substring(0, replacementStart);
let trailer = pat.substring(replacementStart + 3);
let argumentIndex = parseInt(pat.substring(replacementStart + 1, i));
if (Number.isNaN(argumentIndex) || argumentIndex > argumentsAsStrings.length) throw new Error(`Error in format pattern "${pat}": Argument index out of bounds`);
// This is seriously the only decent way to do this in javascript
// thanks brendan eich (replace this thanking with more choice words in your head)
pat = beginning + argumentsAsStrings[argumentIndex] + trailer;
}
}
return pat;
}

24
shared/src/index.ts Normal file
View file

@ -0,0 +1,24 @@
// public modules
export * from './StringLike.js';
export * from './Logger.js';
export * from './format.js';
export function Clamp(input: number, min: number, max: number) {
return Math.min(Math.max(input, min), max);
}
export async function Sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type Size = {
width: number;
height: number;
};
export type Rect = {
x: number,
y: number,
width: number,
height: number
};

1
shared/tsconfig.json Symbolic link
View file

@ -0,0 +1 @@
../tsconfig.json

View file

@ -1,41 +0,0 @@
import { Rank, User } from "./User.js";
import log from "./log.js";
export default class AuthManager {
apiEndpoint : string;
secretKey : string;
constructor(apiEndpoint : string, secretKey : string) {
this.apiEndpoint = apiEndpoint;
this.secretKey = secretKey;
}
Authenticate(token : string, user : User) {
return new Promise<JoinResponse>(async res => {
var response = await fetch(this.apiEndpoint + "/api/v1/join", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
secretKey: this.secretKey,
sessionToken: token,
ip: user.IP.address
})
});
var json = await response.json() as JoinResponse;
if (!json.success) {
log("FATAL", `Failed to query auth server: ${json.error}`);
process.exit(1);
}
res(json);
});
}
}
interface JoinResponse {
success : boolean;
clientSuccess : boolean;
error : string | undefined;
username : string | undefined;
rank : Rank;
}

View file

@ -1,44 +0,0 @@
import { Mutex } from "async-mutex";
export default class Framebuffer {
fb : Buffer;
private writemutex : Mutex;
size : {height : number, width : number};
constructor() {
this.fb = Buffer.alloc(1);
this.size = {height: 0, width: 0};
this.writemutex = new Mutex();
}
setSize(w : number, h : number) {
var size = h * w * 4;
this.size.height = h;
this.size.width = w;
this.fb = Buffer.alloc(size);
}
loadDirtyRect(rect : Buffer, x : number, y : number, width : number, height : number) : Promise<void> {
if (this.fb.length < rect.length)
throw new Error("Dirty rect larger than framebuffer (did you forget to set the size?)");
return this.writemutex.runExclusive(() => {
return new Promise<void>((res, rej) => {
var byteswritten = 0;
for (var i = 0; i < height; i++) {
byteswritten += rect.copy(this.fb, 4 * ((y + i) * this.size.width + x), byteswritten, byteswritten + (width * 4));
}
res();
})
});
}
getFb() : Promise<Buffer> {
return new Promise<Buffer>(async (res, rej) => {
var v = await this.writemutex.runExclusive(() => {
return new Promise<Buffer>((reso, reje) => {
var buff = Buffer.alloc(this.fb.length);
this.fb.copy(buff);
reso(buff);
});
});
res(v);
})
}
}

View file

@ -1,69 +0,0 @@
export default interface IConfig {
http : {
host : string;
port : number;
proxying : boolean;
proxyAllowedIps : string[];
origin : boolean;
originAllowedDomains : string[];
maxConnections: number;
};
auth : {
enabled : boolean;
apiEndpoint : string;
secretKey : string;
guestPermissions : {
chat : boolean;
turn : boolean;
}
}
vm : {
qemuArgs : string;
vncPort : number;
snapshots : boolean;
qmpHost : string | null;
qmpPort : number | null;
qmpSockDir : string | null;
};
collabvm : {
node : string;
displayname : string;
motd : string;
bancmd : string | string[];
moderatorEnabled : boolean;
usernameblacklist : string[];
maxChatLength : number;
maxChatHistoryLength : number;
turnlimit : {
enabled: boolean,
maximum: number;
};
automute : {
enabled: boolean;
seconds: number;
messages: number;
};
tempMuteTime : number;
turnTime : number;
voteTime : number;
voteCooldown: number;
adminpass : string;
modpass : string;
turnwhitelist : boolean;
turnpass : string;
moderatorPermissions : Permissions;
};
};
export interface Permissions {
restore : boolean;
reboot : boolean;
ban : boolean;
forcevote : boolean;
mute : boolean;
kick : boolean;
bypassturn : boolean;
rename : boolean;
grabip : boolean;
xss : boolean;
}

View file

@ -1,12 +0,0 @@
export class IPData {
tempMuteExpireTimeout? : NodeJS.Timeout;
muted: Boolean;
vote: boolean | null;
address: string;
constructor(address: string) {
this.address = address;
this.muted = false;
this.vote = null;
}
}

View file

@ -1,254 +0,0 @@
import IConfig from "./IConfig.js";
import * as rfb from 'rfb2';
import * as fs from 'fs';
import { ExecaChildProcess, execaCommand } from "execa";
import QMPClient from "./QMPClient.js";
import BatchRects from "./RectBatcher.js";
import { createCanvas, Canvas, CanvasRenderingContext2D, createImageData } from "canvas";
import { Mutex } from "async-mutex";
import log from "./log.js";
import VM from "./VM.js";
export default class QEMUVM extends VM {
vnc? : rfb.RfbClient;
vncPort : number;
framebuffer : Canvas;
framebufferCtx : CanvasRenderingContext2D;
qmpSock : string;
qmpType: string;
qmpClient : QMPClient;
qemuCmd : string;
qemuProcess? : ExecaChildProcess;
qmpErrorLevel : number;
vncErrorLevel : number;
processRestartErrorLevel : number;
expectedExit : boolean;
vncOpen : boolean;
vncUpdateInterval? : NodeJS.Timeout;
rects : {height:number,width:number,x:number,y:number,data:Buffer}[];
rectMutex : Mutex;
vncReconnectTimeout? : NodeJS.Timeout;
qmpReconnectTimeout? : NodeJS.Timeout;
qemuRestartTimeout? : NodeJS.Timeout;
constructor(Config : IConfig) {
super();
if (Config.vm.vncPort < 5900) {
log("FATAL", "VNC port must be 5900 or higher")
process.exit(1);
}
Config.vm.qmpSockDir == null ? this.qmpType = "tcp:" : this.qmpType = "unix:";
if(this.qmpType == "tcp:") {
this.qmpSock = `${Config.vm.qmpHost}:${Config.vm.qmpPort}`;
}else{
this.qmpSock = `${Config.vm.qmpSockDir}collab-vm-qmp-${Config.collabvm.node}.sock`;
}
this.vncPort = Config.vm.vncPort;
this.qemuCmd = `${Config.vm.qemuArgs} -no-shutdown -vnc 127.0.0.1:${this.vncPort - 5900} -qmp ${this.qmpType}${this.qmpSock},server,nowait`;
if (Config.vm.snapshots) this.qemuCmd += " -snapshot"
this.qmpErrorLevel = 0;
this.vncErrorLevel = 0;
this.vncOpen = true;
this.rects = [];
this.rectMutex = new Mutex();
this.framebuffer = createCanvas(1, 1);
this.framebufferCtx = this.framebuffer.getContext("2d");
this.processRestartErrorLevel = 0;
this.expectedExit = false;
this.qmpClient = new QMPClient(this.qmpSock, this.qmpType);
this.qmpClient.on('connected', () => this.qmpConnected());
this.qmpClient.on('close', () => this.qmpClosed());
}
Start() : Promise<void> {
return new Promise<void>(async (res, rej) => {
if (fs.existsSync(this.qmpSock))
try {
fs.unlinkSync(this.qmpSock);
} catch (e) {
log("ERROR", `Failed to delete existing socket: ${e}`);
process.exit(-1);
}
this.qemuProcess = execaCommand(this.qemuCmd);
this.qemuProcess.catch(() => false);
this.qemuProcess.stderr?.on('data', (d) => log("ERROR", `QEMU sent to stderr: ${d.toString()}`));
this.qemuProcess.once('spawn', () => {
setTimeout(async () => {
await this.qmpClient.connect();
}, 2000)
});
this.qemuProcess.once('exit', () => {
if (this.expectedExit) return;
clearTimeout(this.qmpReconnectTimeout);
clearTimeout(this.vncReconnectTimeout);
this.processRestartErrorLevel++;
if (this.processRestartErrorLevel > 4) {
log("FATAL", "QEMU failed to launch 5 times.");
process.exit(-1);
}
log("WARN", "QEMU exited unexpectedly, retrying in 3 seconds");
this.qmpClient.disconnect();
this.vnc?.end();
this.qemuRestartTimeout = setTimeout(() => this.Start(), 3000);
});
this.qemuProcess.on('error', () => false);
this.once('vncconnect', () => res());
});
}
private qmpConnected() {
this.qmpErrorLevel = 0;
this.processRestartErrorLevel = 0;
log("INFO", "QMP Connected");
setTimeout(() => this.startVNC(), 1000);
}
private startVNC() {
this.vnc = rfb.createConnection({
host: "127.0.0.1",
port: this.vncPort,
});
this.vnc.on("close", () => this.vncClosed());
this.vnc.on("connect", () => this.vncConnected());
this.vnc.on("rect", (r) => this.onVNCRect(r));
this.vnc.on("resize", (s) => this.onVNCSize(s));
}
public getSize() {
if (!this.vnc) return {height:0,width:0};
return {height: this.vnc.height, width: this.vnc.width}
}
private qmpClosed() {
if (this.expectedExit) return;
this.qmpErrorLevel++;
if (this.qmpErrorLevel > 4) {
log("FATAL", "Failed to connect to QMP after 5 attempts");
process.exit(1);
}
log("ERROR", "Failed to connect to QMP, retrying in 3 seconds.");
this.qmpReconnectTimeout = setTimeout(() => this.qmpClient.connect(), 3000);
}
private vncClosed() {
this.vncOpen = false;
if (this.expectedExit) return;
this.vncErrorLevel++;
if (this.vncErrorLevel > 4) {
log("FATAL", "Failed to connect to VNC after 5 attempts.")
process.exit(1);
}
try {
this.vnc?.end();
} catch {};
log("ERROR", "Failed to connect to VNC, retrying in 3 seconds");
this.vncReconnectTimeout = setTimeout(() => this.startVNC(), 3000);
}
private vncConnected() {
this.vncOpen = true;
this.emit('vncconnect');
log("INFO", "VNC Connected");
this.vncErrorLevel = 0;
this.onVNCSize({height: this.vnc!.height, width: this.vnc!.width});
this.vncUpdateInterval = setInterval(() => this.SendRects(), 33);
}
private onVNCRect(rect : any) {
return this.rectMutex.runExclusive(async () => {
return new Promise<void>(async (res, rej) => {
var buff = Buffer.alloc(rect.height * rect.width * 4)
var offset = 0;
for (var i = 0; i < rect.data.length; i += 4) {
buff[offset++] = rect.data[i + 2];
buff[offset++] = rect.data[i + 1];
buff[offset++] = rect.data[i];
buff[offset++] = 255;
}
var imgdata = createImageData(Uint8ClampedArray.from(buff), rect.width, rect.height);
this.framebufferCtx.putImageData(imgdata, rect.x, rect.y);
this.rects.push({
x: rect.x,
y: rect.y,
height: rect.height,
width: rect.width,
data: buff,
});
if (!this.vnc) throw new Error();
if (this.vncOpen)
this.vnc.requestUpdate(true, 0, 0, this.vnc.height, this.vnc.width);
res();
})
});
}
SendRects() {
if (!this.vnc || this.rects.length < 1) return;
return this.rectMutex.runExclusive(() => {
return new Promise<void>(async (res, rej) => {
var rect = await BatchRects(this.framebuffer, [...this.rects]);
this.rects = [];
this.emit('dirtyrect', rect.data, rect.x, rect.y);
res();
});
})
}
private onVNCSize(size : any) {
if (this.framebuffer.height !== size.height) this.framebuffer.height = size.height;
if (this.framebuffer.width !== size.width) this.framebuffer.width = size.width;
this.emit("size", {height: size.height, width: size.width});
}
Reboot() : Promise<void> {
return new Promise(async (res, rej) => {
if (this.expectedExit) {res(); return;}
res(await this.qmpClient.reboot());
});
}
async Restore() {
if (this.expectedExit) return;
await this.Stop();
this.expectedExit = false;
this.Start();
}
Stop() : Promise<void> {
return new Promise<void>(async (res, rej) => {
if (this.expectedExit) {res(); return;}
if (!this.qemuProcess) throw new Error("VM was not running");
this.expectedExit = true;
this.vncOpen = false;
this.vnc?.end();
clearInterval(this.vncUpdateInterval);
var killTimeout = setTimeout(() => {
log("WARN", "Force killing QEMU after 10 seconds of waiting for shutdown");
this.qemuProcess?.kill(9);
}, 10000);
var closep = new Promise<void>(async (reso, reje) => {
this.qemuProcess?.once('exit', () => reso());
await this.qmpClient.execute({ "execute": "quit" });
});
var qmpclosep = new Promise<void>((reso, rej) => {
this.qmpClient.once('close', () => reso());
});
await Promise.all([closep, qmpclosep]);
clearTimeout(killTimeout);
res();
})
}
public pointerEvent(x: number, y: number, mask: number) {
if (!this.vnc) throw new Error("VNC was not instantiated.");
this.vnc.pointerEvent(x, y, mask);
}
public acceptingInput(): boolean {
return this.vncOpen;
}
public keyEvent(keysym: number, down: boolean): void {
if (!this.vnc) throw new Error("VNC was not instantiated.");
this.vnc.keyEvent(keysym, down ? 1 : 0);
}
}

View file

@ -1,152 +0,0 @@
import EventEmitter from "events";
import { Socket } from "net";
import { Mutex } from "async-mutex";
import log from "./log.js";
import { EOL } from "os";
export default class QMPClient extends EventEmitter {
socketfile : string;
sockettype: string;
socket : Socket;
connected : boolean;
sentConnected : boolean;
cmdMutex : Mutex; // So command outputs don't get mixed up
constructor(socketfile : string, sockettype: string) {
super();
this.sockettype = sockettype;
this.socketfile = socketfile;
this.socket = new Socket();
this.connected = false;
this.sentConnected = false;
this.cmdMutex = new Mutex();
}
connect() : Promise<void> {
return new Promise((res, rej) => {
if (this.connected) {res(); return;}
try {
if(this.sockettype == "tcp:") {
let _sock = this.socketfile.split(':');
this.socket.connect(parseInt(_sock[1]), _sock[0]);
}else{
this.socket.connect(this.socketfile);
}
} catch (e) {
this.onClose();
}
this.connected = true;
this.socket.on('error', () => false); // Disable throwing if QMP errors
this.socket.on('data', (data) => {
data.toString().split(EOL).forEach(instr => this.onData(instr));
});
this.socket.on('close', () => this.onClose());
this.once('connected', () => {res();});
})
}
disconnect() {
this.connected = false;
this.socket.destroy();
}
private async onData(data : string) {
let msg;
try {
msg = JSON.parse(data);
} catch {
return;
}
if (msg.QMP !== undefined) {
if (this.sentConnected)
return;
await this.execute({ execute: "qmp_capabilities" });
this.emit('connected');
this.sentConnected = true;
}
if (msg.return !== undefined && Object.keys(msg.return).length)
this.emit("qmpreturn", msg.return);
else if(msg.event !== undefined) {
switch(msg.event) {
case "STOP":
{
log("INFO", "The VM was shut down, restarting...");
this.reboot();
break;
}
case "RESET":
{
log("INFO", "QEMU reset event occured");
this.resume();
break;
};
default: break;
}
}else
// for now just return an empty string.
// This is a giant hack but avoids a deadlock
this.emit("qmpreturn", '');
}
private onClose() {
this.connected = false;
this.sentConnected = false;
if (this.socket.readyState === 'open')
this.socket.destroy();
this.cmdMutex.cancel();
this.cmdMutex.release();
this.socket = new Socket();
this.emit('close');
}
async reboot() {
if (!this.connected)
return;
await this.execute({"execute": "system_reset"});
}
async resume() {
if (!this.connected)
return;
await this.execute({"execute": "cont"});
}
async ExitQEMU() {
if (!this.connected)
return;
await this.execute({"execute": "quit"});
}
execute(args : object) {
return new Promise(async (res, rej) => {
var result:any;
try {
result = await this.cmdMutex.runExclusive(() => {
// I kinda hate having two promises but IDK how else to do it /shrug
return new Promise((reso, reje) => {
this.once('qmpreturn', (e) => {
reso(e);
});
this.socket.write(JSON.stringify(args));
});
});
} catch {
res({});
}
res(result);
});
}
runMonitorCmd(command : string) {
return new Promise(async (res, rej) => {
res(await this.execute({execute: "human-monitor-command", arguments: {"command-line": command}}));
});
}
}

View file

@ -1,28 +0,0 @@
import { Canvas, createCanvas, createImageData } from "canvas";
export default async function BatchRects(fb : Canvas, rects : {height:number,width:number,x:number,y:number,data:Buffer}[]) : Promise<{x:number,y:number,data:Canvas}> {
var mergedX = fb.width;
var mergedY = fb.height;
var mergedHeight = 0;
var mergedWidth = 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;
});
var rect = createCanvas(mergedWidth, mergedHeight);
var ctx = rect.getContext("2d");
ctx.drawImage(fb, mergedX, mergedY, mergedWidth, mergedHeight, 0, 0, mergedWidth, mergedHeight);
for (const r of rects) {
var id = createImageData(Uint8ClampedArray.from(r.data), r.width, r.height);
ctx.putImageData(id, r.x - mergedX, r.y - mergedY);
}
return {
data: rect,
x: mergedX,
y: mergedY,
}
}

View file

@ -1,158 +0,0 @@
import * as Utilities from './Utilities.js';
import * as guacutils from './guacutils.js';
import {WebSocket} from 'ws';
import {IPData} from './IPData.js';
import IConfig from './IConfig.js';
import RateLimiter from './RateLimiter.js';
import { execa, execaCommand, ExecaSyncError } from 'execa';
import log from './log.js';
export class User {
socket : WebSocket;
nopSendInterval : NodeJS.Timeout;
msgRecieveInterval : NodeJS.Timeout;
nopRecieveTimeout? : NodeJS.Timeout;
username? : string;
connectedToNode : boolean;
viewMode : number;
rank : Rank;
msgsSent : number;
Config : IConfig;
IP : IPData;
// Rate limiters
ChatRateLimit : RateLimiter;
LoginRateLimit : RateLimiter;
RenameRateLimit : RateLimiter;
TurnRateLimit : RateLimiter;
VoteRateLimit : RateLimiter;
constructor(ws : WebSocket, ip : IPData, config : IConfig, username? : string, node? : string) {
this.IP = ip;
this.connectedToNode = false;
this.viewMode = -1;
this.Config = config;
this.socket = ws;
this.msgsSent = 0;
this.socket.on('close', () => {
clearInterval(this.nopSendInterval);
});
this.socket.on('message', (e) => {
clearTimeout(this.nopRecieveTimeout);
clearInterval(this.msgRecieveInterval);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
})
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
this.sendNop();
if (username) this.username = username;
this.rank = 0;
this.ChatRateLimit = new RateLimiter(this.Config.collabvm.automute.messages, this.Config.collabvm.automute.seconds);
this.ChatRateLimit.on('limit', () => this.mute(false));
this.RenameRateLimit = new RateLimiter(3, 60);
this.RenameRateLimit.on('limit', () => this.closeConnection());
this.LoginRateLimit = new RateLimiter(4, 3);
this.LoginRateLimit.on('limit', () => this.closeConnection());
this.TurnRateLimit = new RateLimiter(5, 3);
this.TurnRateLimit.on('limit', () => this.closeConnection());
this.VoteRateLimit = new RateLimiter(3, 3);
this.VoteRateLimit.on('limit', () => this.closeConnection());
}
assignGuestName(existingUsers : string[]) : string {
var username;
do {
username = "guest" + Utilities.Randint(10000, 99999);
} while (existingUsers.indexOf(username) !== -1);
this.username = username;
return username;
}
sendNop() {
this.socket.send("3.nop;");
}
sendMsg(msg : string | Buffer) {
if (this.socket.readyState !== this.socket.OPEN) return;
clearInterval(this.nopSendInterval);
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.socket.send(msg);
}
private onNoMsg() {
this.sendNop();
this.nopRecieveTimeout = setTimeout(() => {
this.closeConnection();
}, 3000);
}
closeConnection() {
this.socket.send(guacutils.encode("disconnect"));
this.socket.close();
}
onMsgSent() {
if (!this.Config.collabvm.automute.enabled) return;
// rate limit guest and unregistered chat messages, but not staff ones
switch(this.rank) {
case Rank.Moderator:
case Rank.Admin:
break;
default:
this.ChatRateLimit.request();
break;
}
}
mute(permanent : boolean) {
this.IP.muted = true;
this.sendMsg(guacutils.encode("chat", "", `You have been muted${permanent ? "" : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`));
if (!permanent) {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000);
}
}
unmute() {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.muted = false;
this.sendMsg(guacutils.encode("chat", "", "You are no longer muted."));
}
private banCmdArgs(arg: string) : string {
return arg.replace(/\$IP/g, this.IP.address).replace(/\$NAME/g, this.username || "");
}
async ban() {
// Prevent the user from taking turns or chatting, in case the ban command takes a while
this.IP.muted = true;
try {
if (Array.isArray(this.Config.collabvm.bancmd)) {
let args: string[] = this.Config.collabvm.bancmd.map((a: string) => this.banCmdArgs(a));
if (args.length || args[0].length) {
await execa(args.shift()!, args, {stdout: process.stdout, stderr: process.stderr});
this.kick();
} else {
log("ERROR", `Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
} else if (typeof this.Config.collabvm.bancmd == "string") {
let cmd: string = this.banCmdArgs(this.Config.collabvm.bancmd);
if (cmd.length) {
await execaCommand(cmd, {stdout: process.stdout, stderr: process.stderr});
this.kick();
} else {
log("ERROR", `Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
}
} catch (e) {
log("ERROR", `Failed to ban ${this.IP.address} (${this.username}): ${(e as ExecaSyncError).shortMessage}`);
}
}
async kick() {
this.sendMsg("10.disconnect;");
this.socket.close();
}
}
export enum Rank {
Unregistered = 0,
// After all these years
Registered = 1,
Admin = 2,
Moderator = 3,
// Giving a good gap between server only internal ranks just in case
Turn = 10,
}

View file

@ -1,12 +0,0 @@
import { Canvas } from "canvas";
import EventEmitter from "events";
export default abstract class VM extends EventEmitter {
public abstract getSize() : {height:number;width:number;};
public abstract get framebuffer() : Canvas;
public abstract pointerEvent(x : number, y : number, mask : number) : void;
public abstract acceptingInput() : boolean;
public abstract keyEvent(keysym : number, down : boolean) : void;
public abstract Restore() : void;
public abstract Reboot() : Promise<void>;
}

View file

@ -1,7 +0,0 @@
export default function log(loglevel : string, ...message : string[]) {
console[
(loglevel === "ERROR" || loglevel === "FATAL") ? "error" :
(loglevel === "WARN") ? "warn" :
"log"
](`[${new Date().toLocaleString()}] [${loglevel}]`, ...message);
}

View file

@ -1,103 +1,10 @@
// This is the base tsconfig the entire cvmts project uses
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2022", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [".js", ".d.ts", ".ts", ".mjs", ".cjs", ".json"], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"strict": true,
}
}

4177
yarn.lock Normal file

File diff suppressed because it is too large Load diff