From cb297e15c47bc5a5d4dc04f5ad9c3452858140dc Mon Sep 17 00:00:00 2001 From: modeco80 Date: Tue, 23 Apr 2024 09:57:02 -0400 Subject: [PATCH 01/60] 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) --- .gitignore | 13 +- .gitmodules | 6 + .npmrc | 1 - .prettierignore | 3 + .prettierrc.json | 20 + .yarnrc.yml | 1 + README.md | 3 + cvmts/package.json | 35 + cvmts/src/AuthManager.ts | 46 + cvmts/src/IConfig.ts | 69 + cvmts/src/IPData.ts | 62 + {src => cvmts/src}/RateLimiter.ts | 0 cvmts/src/User.ts | 161 ++ {src => cvmts/src}/Utilities.ts | 0 {src => cvmts/src}/WSServer.ts | 280 +- {src => cvmts/src}/guacutils.ts | 0 {src => cvmts/src}/index.ts | 29 +- cvmts/tsconfig.json | 1 + jpeg-turbo | 1 + nodejs-rfb | 1 + package.json | 44 +- qemu/package.json | 31 + qemu/src/QemuDisplay.ts | 143 + qemu/src/QemuUtil.ts | 41 + qemu/src/QemuVM.ts | 290 ++ qemu/src/QmpClient.ts | 135 + qemu/src/index.ts | 3 + qemu/tsconfig.json | 1 + shared/package.json | 28 + shared/src/Logger.ts | 50 + shared/src/StringLike.ts | 9 + shared/src/format.ts | 77 + shared/src/index.ts | 24 + shared/tsconfig.json | 1 + src/AuthManager.ts | 41 - src/Framebuffer.ts | 44 - src/IConfig.ts | 69 - src/IPData.ts | 12 - src/QEMUVM.ts | 254 -- src/QMPClient.ts | 152 -- src/RectBatcher.ts | 28 - src/User.ts | 158 -- src/VM.ts | 12 - src/log.ts | 7 - tsconfig.json | 109 +- yarn.lock | 4177 +++++++++++++++++++++++++++++ 46 files changed, 5661 insertions(+), 1011 deletions(-) create mode 100644 .gitmodules delete mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 .yarnrc.yml create mode 100644 cvmts/package.json create mode 100644 cvmts/src/AuthManager.ts create mode 100644 cvmts/src/IConfig.ts create mode 100644 cvmts/src/IPData.ts rename {src => cvmts/src}/RateLimiter.ts (100%) create mode 100644 cvmts/src/User.ts rename {src => cvmts/src}/Utilities.ts (100%) rename {src => cvmts/src}/WSServer.ts (85%) rename {src => cvmts/src}/guacutils.ts (100%) rename {src => cvmts/src}/index.ts (50%) create mode 120000 cvmts/tsconfig.json create mode 160000 jpeg-turbo create mode 160000 nodejs-rfb create mode 100644 qemu/package.json create mode 100644 qemu/src/QemuDisplay.ts create mode 100644 qemu/src/QemuUtil.ts create mode 100644 qemu/src/QemuVM.ts create mode 100644 qemu/src/QmpClient.ts create mode 100644 qemu/src/index.ts create mode 120000 qemu/tsconfig.json create mode 100644 shared/package.json create mode 100644 shared/src/Logger.ts create mode 100644 shared/src/StringLike.ts create mode 100644 shared/src/format.ts create mode 100644 shared/src/index.ts create mode 120000 shared/tsconfig.json delete mode 100644 src/AuthManager.ts delete mode 100644 src/Framebuffer.ts delete mode 100644 src/IConfig.ts delete mode 100644 src/IPData.ts delete mode 100644 src/QEMUVM.ts delete mode 100644 src/QMPClient.ts delete mode 100644 src/RectBatcher.ts delete mode 100644 src/User.ts delete mode 100644 src/VM.ts delete mode 100644 src/log.ts create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore index 8924c30..3a1de6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ -node_modules/ -build/ -config.toml \ No newline at end of file +.parcel-cache/ +.yarn/ +**/node_modules/ +config.toml + +# for now +cvmts/attic + +/dist +**/dist/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..76ac5b5 --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 9cf9495..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock=false \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1ba211f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +dist +*.md +**/package.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..d41a549 --- /dev/null +++ b/.prettierrc.json @@ -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 +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/README.md b/README.md index e3d2740..e723f55 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/cvmts/package.json b/cvmts/package.json new file mode 100644 index 0000000..fd626fc --- /dev/null +++ b/cvmts/package.json @@ -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" + } +} diff --git a/cvmts/src/AuthManager.ts b/cvmts/src/AuthManager.ts new file mode 100644 index 0000000..0b46f90 --- /dev/null +++ b/cvmts/src/AuthManager.ts @@ -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 { + 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; +} diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts new file mode 100644 index 0000000..5390386 --- /dev/null +++ b/cvmts/src/IConfig.ts @@ -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; +} diff --git a/cvmts/src/IPData.ts b/cvmts/src/IPData.ts new file mode 100644 index 0000000..c535ba4 --- /dev/null +++ b/cvmts/src/IPData.ts @@ -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(); + 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); + diff --git a/src/RateLimiter.ts b/cvmts/src/RateLimiter.ts similarity index 100% rename from src/RateLimiter.ts rename to cvmts/src/RateLimiter.ts diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts new file mode 100644 index 0000000..cc316a5 --- /dev/null +++ b/cvmts/src/User.ts @@ -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 +} diff --git a/src/Utilities.ts b/cvmts/src/Utilities.ts similarity index 100% rename from src/Utilities.ts rename to cvmts/src/Utilities.ts diff --git a/src/WSServer.ts b/cvmts/src/WSServer.ts similarity index 85% rename from src/WSServer.ts rename to cvmts/src/WSServer.ts index dde946a..2c54010 100644 --- a/src/WSServer.ts +++ b/cvmts/src/WSServer.ts @@ -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 { + 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 + private TurnQueue : Queue; + // 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(Array, this.Config.collabvm.maxChatHistoryLength); this.TurnQueue = new Queue(); 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 { 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++; }); diff --git a/src/guacutils.ts b/cvmts/src/guacutils.ts similarity index 100% rename from src/guacutils.ts rename to cvmts/src/guacutils.ts diff --git a/src/index.ts b/cvmts/src/index.ts similarity index 50% rename from src/index.ts rename to cvmts/src/index.ts index b2d0f71..88c1843 100644 --- a/src/index.ts +++ b/cvmts/src/index.ts @@ -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 diff --git a/cvmts/tsconfig.json b/cvmts/tsconfig.json new file mode 120000 index 0000000..4ec6ff6 --- /dev/null +++ b/cvmts/tsconfig.json @@ -0,0 +1 @@ +../tsconfig.json \ No newline at end of file diff --git a/jpeg-turbo b/jpeg-turbo new file mode 160000 index 0000000..6718ec1 --- /dev/null +++ b/jpeg-turbo @@ -0,0 +1 @@ +Subproject commit 6718ec1fc12aeccdb1b1490a7a258f24e8f83164 diff --git a/nodejs-rfb b/nodejs-rfb new file mode 160000 index 0000000..c94369b --- /dev/null +++ b/nodejs-rfb @@ -0,0 +1 @@ +Subproject commit c94369b4447e574e3f62a18e93b08124f7dc96e5 diff --git a/package.json b/package.json index 1114024..731a7e4 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/qemu/package.json b/qemu/package.json new file mode 100644 index 0000000..19e1788 --- /dev/null +++ b/qemu/package.json @@ -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" + } +} diff --git a/qemu/src/QemuDisplay.ts b/qemu/src/QemuDisplay.ts new file mode 100644 index 0000000..cb4e9d4 --- /dev/null +++ b/qemu/src/QemuDisplay.ts @@ -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); + } +} diff --git a/qemu/src/QemuUtil.ts b/qemu/src/QemuUtil.ts new file mode 100644 index 0000000..47eceff --- /dev/null +++ b/qemu/src/QemuUtil.ts @@ -0,0 +1,41 @@ +import { Size, Rect } from "@cvmts/shared"; + +export function BatchRects(size: Size, rects: Array): 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 + }; +} \ No newline at end of file diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts new file mode 100644 index 0000000..87e160d --- /dev/null +++ b/qemu/src/QemuVM.ts @@ -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 { + 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 { + 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) { + + } + } +} diff --git a/qemu/src/QmpClient.ts b/qemu/src/QmpClient.ts new file mode 100644 index 0000000..df20613 --- /dev/null +++ b/qemu/src/QmpClient.ts @@ -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 { + 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(); + } +} diff --git a/qemu/src/index.ts b/qemu/src/index.ts new file mode 100644 index 0000000..274f400 --- /dev/null +++ b/qemu/src/index.ts @@ -0,0 +1,3 @@ +export * from './QemuDisplay.js'; +export * from './QemuUtil.js'; +export * from './QemuVM.js'; diff --git a/qemu/tsconfig.json b/qemu/tsconfig.json new file mode 120000 index 0000000..4ec6ff6 --- /dev/null +++ b/qemu/tsconfig.json @@ -0,0 +1 @@ +../tsconfig.json \ No newline at end of file diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..1733d65 --- /dev/null +++ b/shared/package.json @@ -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" +} diff --git a/shared/src/Logger.ts b/shared/src/Logger.ts new file mode 100644 index 0000000..15cd6be --- /dev/null +++ b/shared/src/Logger.ts @@ -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) { + if(gLogLevel <= LogLevel.VERBOSE) + console.log(`[${this._component}] [VERBOSE] ${Format(pattern, ...args)}`); + } + + Info(pattern: string, ...args: Array) { + if(gLogLevel <= LogLevel.INFO) + console.log(`[${this._component}] [INFO] ${Format(pattern, ...args)}`); + } + + Warning(pattern: string, ...args: Array) { + if(gLogLevel <= LogLevel.WARNING) + console.warn(`[${this._component}] [WARNING] ${Format(pattern, ...args)}`); + } + + Error(pattern: string, ...args: Array) { + if(gLogLevel <= LogLevel.ERROR) + console.error(`[${this._component}] [ERROR] ${Format(pattern, ...args)}`); + } + + + +} diff --git a/shared/src/StringLike.ts b/shared/src/StringLike.ts new file mode 100644 index 0000000..2ed941b --- /dev/null +++ b/shared/src/StringLike.ts @@ -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; \ No newline at end of file diff --git a/shared/src/format.ts b/shared/src/format.ts new file mode 100644 index 0000000..ae95f9d --- /dev/null +++ b/shared/src/format.ts @@ -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) { + let argumentsAsStrings: Array = [...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; +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..8b744b7 --- /dev/null +++ b/shared/src/index.ts @@ -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 +}; \ No newline at end of file diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 120000 index 0000000..4ec6ff6 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1 @@ +../tsconfig.json \ No newline at end of file diff --git a/src/AuthManager.ts b/src/AuthManager.ts deleted file mode 100644 index 69fc01e..0000000 --- a/src/AuthManager.ts +++ /dev/null @@ -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(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; -} \ No newline at end of file diff --git a/src/Framebuffer.ts b/src/Framebuffer.ts deleted file mode 100644 index 33b1dda..0000000 --- a/src/Framebuffer.ts +++ /dev/null @@ -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 { - 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((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 { - return new Promise(async (res, rej) => { - var v = await this.writemutex.runExclusive(() => { - return new Promise((reso, reje) => { - var buff = Buffer.alloc(this.fb.length); - this.fb.copy(buff); - reso(buff); - }); - }); - res(v); - }) - } - -} \ No newline at end of file diff --git a/src/IConfig.ts b/src/IConfig.ts deleted file mode 100644 index 2d465b4..0000000 --- a/src/IConfig.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/IPData.ts b/src/IPData.ts deleted file mode 100644 index 9a7c329..0000000 --- a/src/IPData.ts +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/src/QEMUVM.ts b/src/QEMUVM.ts deleted file mode 100644 index 88dc398..0000000 --- a/src/QEMUVM.ts +++ /dev/null @@ -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 { - return new Promise(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(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(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 { - 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 { - return new Promise(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(async (reso, reje) => { - this.qemuProcess?.once('exit', () => reso()); - await this.qmpClient.execute({ "execute": "quit" }); - }); - var qmpclosep = new Promise((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); - } -} diff --git a/src/QMPClient.ts b/src/QMPClient.ts deleted file mode 100644 index 9c4a357..0000000 --- a/src/QMPClient.ts +++ /dev/null @@ -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 { - 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}})); - }); - } -} diff --git a/src/RectBatcher.ts b/src/RectBatcher.ts deleted file mode 100644 index 46242d4..0000000 --- a/src/RectBatcher.ts +++ /dev/null @@ -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, - } -} \ No newline at end of file diff --git a/src/User.ts b/src/User.ts deleted file mode 100644 index 85acb8c..0000000 --- a/src/User.ts +++ /dev/null @@ -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, -} diff --git a/src/VM.ts b/src/VM.ts deleted file mode 100644 index b06113c..0000000 --- a/src/VM.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/log.ts b/src/log.ts deleted file mode 100644 index ee1928c..0000000 --- a/src/log.ts +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index af23762..1cad5cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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 ''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, + } } diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..fe2b342 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4177 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@babel/code-frame@npm:^7.0.0": + version: 7.24.2 + resolution: "@babel/code-frame@npm:7.24.2" + dependencies: + "@babel/highlight": "npm:^7.24.2" + picocolors: "npm:^1.0.0" + checksum: 10c0/d1d4cba89475ab6aab7a88242e1fd73b15ecb9f30c109b69752956434d10a26a52cbd37727c4eca104b6d45227bd1dfce39a6a6f4a14c9b2f07f871e968cf406 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.22.20": + version: 7.22.20 + resolution: "@babel/helper-validator-identifier@npm:7.22.20" + checksum: 10c0/dcad63db345fb110e032de46c3688384b0008a42a4845180ce7cd62b1a9c0507a1bed727c4d1060ed1a03ae57b4d918570259f81724aaac1a5b776056f37504e + languageName: node + linkType: hard + +"@babel/highlight@npm:^7.24.2": + version: 7.24.2 + resolution: "@babel/highlight@npm:7.24.2" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.22.20" + chalk: "npm:^2.4.2" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.0.0" + checksum: 10c0/98ce00321daedeed33a4ed9362dc089a70375ff1b3b91228b9f05e6591d387a81a8cba68886e207861b8871efa0bc997ceabdd9c90f6cce3ee1b2f7f941b42db + languageName: node + linkType: hard + +"@computernewb/jpeg-turbo@npm:*, @computernewb/jpeg-turbo@workspace:jpeg-turbo": + version: 0.0.0-use.local + resolution: "@computernewb/jpeg-turbo@workspace:jpeg-turbo" + dependencies: + cmake-js: "npm:^7.2.0" + node-addon-api: "npm:^6.0.0" + languageName: unknown + linkType: soft + +"@computernewb/nodejs-rfb@npm:*, @computernewb/nodejs-rfb@workspace:nodejs-rfb": + version: 0.0.0-use.local + resolution: "@computernewb/nodejs-rfb@workspace:nodejs-rfb" + dependencies: + "@parcel/packager-ts": "npm:2.12.0" + "@parcel/transformer-typescript-types": "npm:2.12.0" + "@types/node": "npm:^20.12.7" + parcel: "npm:^2.12.0" + prettier: "npm:^3.2.5" + typescript: "npm:>=3.0.0" + languageName: unknown + linkType: soft + +"@cvmts/cvmts@workspace:cvmts": + version: 0.0.0-use.local + resolution: "@cvmts/cvmts@workspace:cvmts" + dependencies: + "@computernewb/jpeg-turbo": "npm:*" + "@cvmts/qemu": "npm:*" + "@types/node": "npm:^20.12.5" + "@types/ws": "npm:^8.5.5" + execa: "npm:^8.0.1" + mnemonist: "npm:^0.39.5" + parcel: "npm:^2.12.0" + prettier: "npm:^3.2.5" + sharp: "npm:^0.33.3" + toml: "npm:^3.0.0" + typescript: "npm:^5.4.4" + ws: "npm:^8.14.1" + languageName: unknown + linkType: soft + +"@cvmts/qemu@npm:*, @cvmts/qemu@workspace:qemu": + version: 0.0.0-use.local + resolution: "@cvmts/qemu@workspace:qemu" + dependencies: + "@computernewb/nodejs-rfb": "npm:*" + "@cvmts/shared": "npm:*" + "@types/split": "npm:^1.0.5" + execa: "npm:^8.0.1" + parcel: "npm:^2.12.0" + split: "npm:^1.0.1" + languageName: unknown + linkType: soft + +"@cvmts/shared@npm:*, @cvmts/shared@workspace:shared": + version: 0.0.0-use.local + resolution: "@cvmts/shared@workspace:shared" + dependencies: + "@protobuf-ts/plugin": "npm:^2.9.4" + parcel: "npm:^2.12.0" + languageName: unknown + linkType: soft + +"@emnapi/runtime@npm:^1.1.0": + version: 1.1.1 + resolution: "@emnapi/runtime@npm:1.1.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/c11ee57abf0ec643e64ccdace4b4fcc0b0c7b1117a191f969e84ae3669841aa90d2c17fa35b73f5a66fc0c843c8caca7bf11187faaeaa526bcfb7dbfb9b85de9 + languageName: node + linkType: hard + +"@img/sharp-darwin-arm64@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-darwin-arm64@npm:0.33.3" + dependencies: + "@img/sharp-libvips-darwin-arm64": "npm:1.0.2" + dependenciesMeta: + "@img/sharp-libvips-darwin-arm64": + optional: true + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-darwin-x64@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-darwin-x64@npm:0.33.3" + dependencies: + "@img/sharp-libvips-darwin-x64": "npm:1.0.2" + dependenciesMeta: + "@img/sharp-libvips-darwin-x64": + optional: true + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.0.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-x64@npm:1.0.2": + version: 1.0.2 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.0.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.0.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm@npm:1.0.2": + version: 1.0.2 + resolution: "@img/sharp-libvips-linux-arm@npm:1.0.2" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-s390x@npm:1.0.2": + version: 1.0.2 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.0.2" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-x64@npm:1.0.2": + version: 1.0.2 + resolution: "@img/sharp-libvips-linux-x64@npm:1.0.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.0.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-x64@npm:1.0.2": + version: 1.0.2 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.0.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linux-arm64@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-linux-arm64@npm:0.33.3" + dependencies: + "@img/sharp-libvips-linux-arm64": "npm:1.0.2" + dependenciesMeta: + "@img/sharp-libvips-linux-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-arm@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-linux-arm@npm:0.33.3" + dependencies: + "@img/sharp-libvips-linux-arm": "npm:1.0.2" + dependenciesMeta: + "@img/sharp-libvips-linux-arm": + optional: true + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-s390x@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-linux-s390x@npm:0.33.3" + dependencies: + "@img/sharp-libvips-linux-s390x": "npm:1.0.2" + dependenciesMeta: + "@img/sharp-libvips-linux-s390x": + optional: true + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-x64@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-linux-x64@npm:0.33.3" + dependencies: + "@img/sharp-libvips-linux-x64": "npm:1.0.2" + dependenciesMeta: + "@img/sharp-libvips-linux-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-arm64@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.33.3" + dependencies: + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.0.2" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-x64@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-linuxmusl-x64@npm:0.33.3" + dependencies: + "@img/sharp-libvips-linuxmusl-x64": "npm:1.0.2" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-wasm32@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-wasm32@npm:0.33.3" + dependencies: + "@emnapi/runtime": "npm:^1.1.0" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@img/sharp-win32-ia32@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-win32-ia32@npm:0.33.3" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@img/sharp-win32-x64@npm:0.33.3": + version: 0.33.3 + resolution: "@img/sharp-win32-x64@npm:0.33.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@isaacs/cliui@npm:^8.0.2": + version: 8.0.2 + resolution: "@isaacs/cliui@npm:8.0.2" + dependencies: + string-width: "npm:^5.1.2" + string-width-cjs: "npm:string-width@^4.2.0" + strip-ansi: "npm:^7.0.1" + strip-ansi-cjs: "npm:strip-ansi@^6.0.1" + wrap-ansi: "npm:^8.1.0" + wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" + checksum: 10c0/b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e + languageName: node + linkType: hard + +"@lezer/common@npm:^1.0.0": + version: 1.2.1 + resolution: "@lezer/common@npm:1.2.1" + checksum: 10c0/af61436dc026f8deebaded13d8e1beea2ae307cbbfb270116cdedadb8208f0674da9c3b5963128a2b1cd4072b4e90bc8128133f4feaf31b6e801e4568f1a15a6 + languageName: node + linkType: hard + +"@lezer/lr@npm:^1.0.0": + version: 1.4.0 + resolution: "@lezer/lr@npm:1.4.0" + dependencies: + "@lezer/common": "npm:^1.0.0" + checksum: 10c0/1e3af297cc142bb6676cb3c4e1bd310da2e93b53273cf745dc85d839a08e1d3cbbd67e0fc0322a480cf25ee215fefe967c53bc2af3ddf5d9b1bf267081dfa164 + languageName: node + linkType: hard + +"@lmdb/lmdb-darwin-arm64@npm:2.8.5": + version: 2.8.5 + resolution: "@lmdb/lmdb-darwin-arm64@npm:2.8.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@lmdb/lmdb-darwin-x64@npm:2.8.5": + version: 2.8.5 + resolution: "@lmdb/lmdb-darwin-x64@npm:2.8.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@lmdb/lmdb-linux-arm64@npm:2.8.5": + version: 2.8.5 + resolution: "@lmdb/lmdb-linux-arm64@npm:2.8.5" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@lmdb/lmdb-linux-arm@npm:2.8.5": + version: 2.8.5 + resolution: "@lmdb/lmdb-linux-arm@npm:2.8.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@lmdb/lmdb-linux-x64@npm:2.8.5": + version: 2.8.5 + resolution: "@lmdb/lmdb-linux-x64@npm:2.8.5" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@lmdb/lmdb-win32-x64@npm:2.8.5": + version: 2.8.5 + resolution: "@lmdb/lmdb-win32-x64@npm:2.8.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@mischnic/json-sourcemap@npm:^0.1.0": + version: 0.1.1 + resolution: "@mischnic/json-sourcemap@npm:0.1.1" + dependencies: + "@lezer/common": "npm:^1.0.0" + "@lezer/lr": "npm:^1.0.0" + json5: "npm:^2.2.1" + checksum: 10c0/e2e314fc048a16baedb10ec4d517c2622e464b8a9f8481cd4c008ebdabed1e5167a8f1407e06a14bb89f035addbb13851c1c5b6672ef8e089205f7f6d300cdd8 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.2": + version: 3.0.2 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.2": + version: 3.0.2 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.2": + version: 3.0.2 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.2": + version: 3.0.2 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.2": + version: 3.0.2 + resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.2": + version: 3.0.2 + resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@npmcli/agent@npm:^2.0.0": + version: 2.2.2 + resolution: "@npmcli/agent@npm:2.2.2" + dependencies: + agent-base: "npm:^7.1.0" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.1" + lru-cache: "npm:^10.0.1" + socks-proxy-agent: "npm:^8.0.3" + checksum: 10c0/325e0db7b287d4154ecd164c0815c08007abfb07653cc57bceded17bb7fd240998a3cbdbe87d700e30bef494885eccc725ab73b668020811d56623d145b524ae + languageName: node + linkType: hard + +"@npmcli/fs@npm:^3.1.0": + version: 3.1.0 + resolution: "@npmcli/fs@npm:3.1.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/162b4a0b8705cd6f5c2470b851d1dc6cd228c86d2170e1769d738c1fbb69a87160901411c3c035331e9e99db72f1f1099a8b734bf1637cc32b9a5be1660e4e1e + languageName: node + linkType: hard + +"@parcel/bundler-default@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/bundler-default@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/graph": "npm:3.2.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + checksum: 10c0/797e7494c82f2669a8d8d409b2efa2c956d2ac4edd5cd1b85560bbd7696483edb8ec220f66cdd88f7a3e47cfb346f33b21818c96f5a2bac098d5eef5085475d8 + languageName: node + linkType: hard + +"@parcel/cache@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/cache@npm:2.12.0" + dependencies: + "@parcel/fs": "npm:2.12.0" + "@parcel/logger": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + lmdb: "npm:2.8.5" + peerDependencies: + "@parcel/core": ^2.12.0 + checksum: 10c0/ef80c88a754d2e1c9161eb8e518f4a4b03c186001384100d037e333a1c00b4a701b0f6c1743a1663c6bb7e20d09c8582584f44ebea0fc6d81c81b4a81a1d0b6b + languageName: node + linkType: hard + +"@parcel/codeframe@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/codeframe@npm:2.12.0" + dependencies: + chalk: "npm:^4.1.0" + checksum: 10c0/23a73d8a5b6a7612ab6a5918ad52631f58d3529758730517a0ce151f0c533e5b4b1788278dd521d4863dd0e0b972afb590af69cb8523b14e809279825da549a1 + languageName: node + linkType: hard + +"@parcel/compressor-raw@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/compressor-raw@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + checksum: 10c0/e057b38d3cae862048f3777ea97544e60465e8efc16ecab0b8602d9c2787c80a09ac3bb338f773af5c17a6b4356caf103986951b47022fdf02b21c5e0b600033 + languageName: node + linkType: hard + +"@parcel/config-default@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/config-default@npm:2.12.0" + dependencies: + "@parcel/bundler-default": "npm:2.12.0" + "@parcel/compressor-raw": "npm:2.12.0" + "@parcel/namer-default": "npm:2.12.0" + "@parcel/optimizer-css": "npm:2.12.0" + "@parcel/optimizer-htmlnano": "npm:2.12.0" + "@parcel/optimizer-image": "npm:2.12.0" + "@parcel/optimizer-svgo": "npm:2.12.0" + "@parcel/optimizer-swc": "npm:2.12.0" + "@parcel/packager-css": "npm:2.12.0" + "@parcel/packager-html": "npm:2.12.0" + "@parcel/packager-js": "npm:2.12.0" + "@parcel/packager-raw": "npm:2.12.0" + "@parcel/packager-svg": "npm:2.12.0" + "@parcel/packager-wasm": "npm:2.12.0" + "@parcel/reporter-dev-server": "npm:2.12.0" + "@parcel/resolver-default": "npm:2.12.0" + "@parcel/runtime-browser-hmr": "npm:2.12.0" + "@parcel/runtime-js": "npm:2.12.0" + "@parcel/runtime-react-refresh": "npm:2.12.0" + "@parcel/runtime-service-worker": "npm:2.12.0" + "@parcel/transformer-babel": "npm:2.12.0" + "@parcel/transformer-css": "npm:2.12.0" + "@parcel/transformer-html": "npm:2.12.0" + "@parcel/transformer-image": "npm:2.12.0" + "@parcel/transformer-js": "npm:2.12.0" + "@parcel/transformer-json": "npm:2.12.0" + "@parcel/transformer-postcss": "npm:2.12.0" + "@parcel/transformer-posthtml": "npm:2.12.0" + "@parcel/transformer-raw": "npm:2.12.0" + "@parcel/transformer-react-refresh-wrap": "npm:2.12.0" + "@parcel/transformer-svg": "npm:2.12.0" + peerDependencies: + "@parcel/core": ^2.12.0 + checksum: 10c0/c3fec515c14479f1a0041db79a70198f04bea94a03a7f331257f057de178d4e0061b68853c2e83d45f891d09fadb8b4361f38421832b6e116edd46f8e0ee51a9 + languageName: node + linkType: hard + +"@parcel/core@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/core@npm:2.12.0" + dependencies: + "@mischnic/json-sourcemap": "npm:^0.1.0" + "@parcel/cache": "npm:2.12.0" + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/events": "npm:2.12.0" + "@parcel/fs": "npm:2.12.0" + "@parcel/graph": "npm:3.2.0" + "@parcel/logger": "npm:2.12.0" + "@parcel/package-manager": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/profiler": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/types": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + "@parcel/workers": "npm:2.12.0" + abortcontroller-polyfill: "npm:^1.1.9" + base-x: "npm:^3.0.8" + browserslist: "npm:^4.6.6" + clone: "npm:^2.1.1" + dotenv: "npm:^7.0.0" + dotenv-expand: "npm:^5.1.0" + json5: "npm:^2.2.0" + msgpackr: "npm:^1.9.9" + nullthrows: "npm:^1.1.1" + semver: "npm:^7.5.2" + checksum: 10c0/ab6b4bc1e95b0aaee23c5aec8479cf6681cf84a0c422e1001a3a0f3957aa28756851eb201a89d8b55ce84912c8987a76597f77193ade771f034c1c33a07ece44 + languageName: node + linkType: hard + +"@parcel/diagnostic@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/diagnostic@npm:2.12.0" + dependencies: + "@mischnic/json-sourcemap": "npm:^0.1.0" + nullthrows: "npm:^1.1.1" + checksum: 10c0/61c2fce32a1abdf343a4d2e3a109779dc2a9c255059e4dd70ad9b4b3bd5b11b676d0c42bc77e4b32e886ef471be018b25b952baa9da137c066410642d2d0507f + languageName: node + linkType: hard + +"@parcel/events@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/events@npm:2.12.0" + checksum: 10c0/0f0a0b02086b81d68cf8f239414e9e09b5a6eca6dddfd22d2e922979b2d85b03e6f68bcafa2c6434c46867c908e25f2002f47f0ed5551f2674a75f4d6c5731ff + languageName: node + linkType: hard + +"@parcel/fs@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/fs@npm:2.12.0" + dependencies: + "@parcel/rust": "npm:2.12.0" + "@parcel/types": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + "@parcel/watcher": "npm:^2.0.7" + "@parcel/workers": "npm:2.12.0" + peerDependencies: + "@parcel/core": ^2.12.0 + checksum: 10c0/5d9ebf62e80dc3781fcd1eb763da46188115e254d285690383539a085aeaf9d864a54655046223ea42815b9b308ecba80d9af53cff6390c6bbb37d2b29df8e35 + languageName: node + linkType: hard + +"@parcel/graph@npm:3.2.0": + version: 3.2.0 + resolution: "@parcel/graph@npm:3.2.0" + dependencies: + nullthrows: "npm:^1.1.1" + checksum: 10c0/acb98a9c44dbabaa38e2a7b6b07aa489d75dc207ed6107ea43575d3c68ebf388a65a982d85677c7d00cd2d7bb6f8a6f75df9618a53389e9f640aa9346fb75c3b + languageName: node + linkType: hard + +"@parcel/logger@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/logger@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/events": "npm:2.12.0" + checksum: 10c0/b33782bbf0cfff30169a4ee8dd3a1d14c9b2c0d4781715e26b5dc6f2321ddff8ca84eca8de40bccb1a8c5d3ce847494408f5db63bbeddcdaaf9b82b1cc376a17 + languageName: node + linkType: hard + +"@parcel/markdown-ansi@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/markdown-ansi@npm:2.12.0" + dependencies: + chalk: "npm:^4.1.0" + checksum: 10c0/0c203c70ab1eb12f4976c32b086b2abf5dc841b42310610e70e1e713fe915acfd0942b56a78456811a9ee150226bb44052910a3f98ea56289aafa36b6ce89e27 + languageName: node + linkType: hard + +"@parcel/namer-default@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/namer-default@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + checksum: 10c0/5baffe07af2329315b9d2b897565b915038246afaa3269d81bcd5eb4bcc7a21771bf1171918d68a67c099584b006167beeefa4716fb4557aae4bc112ebaf4159 + languageName: node + linkType: hard + +"@parcel/node-resolver-core@npm:3.3.0": + version: 3.3.0 + resolution: "@parcel/node-resolver-core@npm:3.3.0" + dependencies: + "@mischnic/json-sourcemap": "npm:^0.1.0" + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/fs": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + semver: "npm:^7.5.2" + checksum: 10c0/9a2731763514c0a54da9710e1131b5960b928900cbc33faf67d07a892cf9ed9f1b11ed2653e574e8363c4527d16e008365917b7b09eb3b9ee727fd244a5f51ee + languageName: node + linkType: hard + +"@parcel/optimizer-css@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/optimizer-css@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/utils": "npm:2.12.0" + browserslist: "npm:^4.6.6" + lightningcss: "npm:^1.22.1" + nullthrows: "npm:^1.1.1" + checksum: 10c0/537e84a85fda7a2f73acd2a55842ffe9846abb02d18a7518baf8ae140fc6140a26bb1988285dbccb49a883fdc8597eabbb6d4882500bf160b97d6d93e3664677 + languageName: node + linkType: hard + +"@parcel/optimizer-htmlnano@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/optimizer-htmlnano@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + htmlnano: "npm:^2.0.0" + nullthrows: "npm:^1.1.1" + posthtml: "npm:^0.16.5" + svgo: "npm:^2.4.0" + checksum: 10c0/487e0fa99e975e6f9add2759e4ad412c0595d7b80d5dde9e186700fa54a9ecb9d1cb611fbd5a0d3392fda3a01050d95e3ded53ca8b50ede3203fe77af489cd0b + languageName: node + linkType: hard + +"@parcel/optimizer-image@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/optimizer-image@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + "@parcel/workers": "npm:2.12.0" + peerDependencies: + "@parcel/core": ^2.12.0 + checksum: 10c0/f050c569548ec8330c65d0e9b6f6b15d5761e14e704ef16b950db19ae0d6b5a30fd42a38bb04841561244e8ab8f7fb781d9e9f1418ae84858fe7ad325a4be494 + languageName: node + linkType: hard + +"@parcel/optimizer-svgo@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/optimizer-svgo@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + svgo: "npm:^2.4.0" + checksum: 10c0/dc49c565d8f15b4f78ee70910a9c527f25316f0440e9cba6c5b8af1562d34708e5276b35f1e1ea26e7911d6d5c60fa82be6627517fe818df6f69eba5f0f6813f + languageName: node + linkType: hard + +"@parcel/optimizer-swc@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/optimizer-swc@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/utils": "npm:2.12.0" + "@swc/core": "npm:^1.3.36" + nullthrows: "npm:^1.1.1" + checksum: 10c0/52f52182769ebb76248deab85893dacf183e6ff9a87a56c3589331cb0e37debb7ae8fa819386fe23f69b15e6b39823879e20816b10fbab3d316018a94b0c653c + languageName: node + linkType: hard + +"@parcel/package-manager@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/package-manager@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/fs": "npm:2.12.0" + "@parcel/logger": "npm:2.12.0" + "@parcel/node-resolver-core": "npm:3.3.0" + "@parcel/types": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + "@parcel/workers": "npm:2.12.0" + "@swc/core": "npm:^1.3.36" + semver: "npm:^7.5.2" + peerDependencies: + "@parcel/core": ^2.12.0 + checksum: 10c0/3ebffe05b293332f69c34479ea0b51a9fa3449ab56eef1b0ec9487c4feacf45df6dec9d8dcb67203398249093370f7d884dc0cb6b6ee15ee8c5db9768579060c + languageName: node + linkType: hard + +"@parcel/packager-css@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/packager-css@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/utils": "npm:2.12.0" + lightningcss: "npm:^1.22.1" + nullthrows: "npm:^1.1.1" + checksum: 10c0/a7293c84c67b9e07b8b8cdc48d96037e05bc50daa8a2aba64b23797fea87e259bf7046a5b969917531db33b8f2387463c817e569a34f42d791bbfacb074268ea + languageName: node + linkType: hard + +"@parcel/packager-html@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/packager-html@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/types": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + posthtml: "npm:^0.16.5" + checksum: 10c0/099eccde796af61cb6f153fcd69c49d22b4acc430d3652a4f2e5d4124c1cf2d6782213048436fd8e9e5521a52b1219e7bc02d38be89ce97e6f70899d3be31d7f + languageName: node + linkType: hard + +"@parcel/packager-js@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/packager-js@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/types": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + globals: "npm:^13.2.0" + nullthrows: "npm:^1.1.1" + checksum: 10c0/89214e8d35f6dc35c2fd0c2b11ec608703dbc52435a7a6141e0b8fc676610fa09c2210cc93490ea4b3581ae0bc13f307dd5515402c939980e1c6bf90148d34e2 + languageName: node + linkType: hard + +"@parcel/packager-raw@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/packager-raw@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + checksum: 10c0/c1539179a62674460fea65c9fd1b150aedd596723e79d4e949bf5bd667defd6a72ed73552033e4cdd2b854aa6d5022201797b746e5deb633b41f1de716716af9 + languageName: node + linkType: hard + +"@parcel/packager-svg@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/packager-svg@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/types": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + posthtml: "npm:^0.16.4" + checksum: 10c0/58f877d470e5b50adb7eca837f571cbd221cf6681bc83d08146e4aeae4e1430a2e3363beb4a62cfc6952f4f8ded1746889545b4c946300258268a11b298047fd + languageName: node + linkType: hard + +"@parcel/packager-ts@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/packager-ts@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + checksum: 10c0/244f94c0d33cfb76e429196bf826e73f508ee4e4af7aa736b02ff3de65ace8ea12a0d7bf8d7d1e6c2ee4d0d5f1db8564db01d58379b08cf8bd4c90d0476f053b + languageName: node + linkType: hard + +"@parcel/packager-wasm@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/packager-wasm@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + checksum: 10c0/bd3ccd6f9a0506b26b0d708ded6cea3ac53df5c49426086b556ba7f9f1351aba010da3e0795a1f6944cdc306cffc08eed249bb8444aa4f44d9de0e3d1592810d + languageName: node + linkType: hard + +"@parcel/plugin@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/plugin@npm:2.12.0" + dependencies: + "@parcel/types": "npm:2.12.0" + checksum: 10c0/2030a3e1ee6b8cdfdf07935b085f7731e286651d7455b84a7f635016c580af715deffb893c5bc9fb3e0126db4511d3f2b592ee17b61108d001339d51ef56f9bf + languageName: node + linkType: hard + +"@parcel/profiler@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/profiler@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/events": "npm:2.12.0" + chrome-trace-event: "npm:^1.0.2" + checksum: 10c0/3caa9014da88f7435c43396fd1bb413c35134801699943717079a92fcd3ab0a0974c98b98473c5bc1ef434ce8203483fb96af642c1d64e20266625499ca4b4fe + languageName: node + linkType: hard + +"@parcel/reporter-cli@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/reporter-cli@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/types": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + chalk: "npm:^4.1.0" + term-size: "npm:^2.2.1" + checksum: 10c0/0fee616377d540e11e61fd827a8886d8b8fc4985f87da694945b5a7f3da821bcbb0c5d7a31d72cdf12546c7bf555f7ef5c15d75b71ab157d93cacf0972b29006 + languageName: node + linkType: hard + +"@parcel/reporter-dev-server@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/reporter-dev-server@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + checksum: 10c0/bd875c937214aa877805413dbfce89d95dc2578098693991cce26624366cc19807a678c2779edbc620f9618db244807a2271027fb5e328318618a4666b33e512 + languageName: node + linkType: hard + +"@parcel/reporter-tracer@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/reporter-tracer@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + chrome-trace-event: "npm:^1.0.3" + nullthrows: "npm:^1.1.1" + checksum: 10c0/5ab33196ce4a62681d5017d908da354e25a6d367cdf0a849cd408c673bac61d3674316438a4c4c7eebb26f865e5ee3c1b8cda897c92dfa7211c0107c48d04388 + languageName: node + linkType: hard + +"@parcel/resolver-default@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/resolver-default@npm:2.12.0" + dependencies: + "@parcel/node-resolver-core": "npm:3.3.0" + "@parcel/plugin": "npm:2.12.0" + checksum: 10c0/22b1e4223070c962570928390c6cb77e866d4a3ede1a7019ad3ed2fed75604a2d78c335d65aa646dd753f05916397b56416aef52009cace9b56fd39bf6362457 + languageName: node + linkType: hard + +"@parcel/runtime-browser-hmr@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/runtime-browser-hmr@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + checksum: 10c0/126babc8dbd7937e94a38bed1527190a203c20bcba7b66f85b1ddbce81ec54b3fb0579f371284cb7290b70fc46b88eaaa1ee6c9d9e3b739b6267d6902dc82f93 + languageName: node + linkType: hard + +"@parcel/runtime-js@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/runtime-js@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + checksum: 10c0/01cb236c0ab6f6a170ead43d519ba02092d9b1805f2b8e8cce6f0fec4cb2c37e885c8ce0ff8ae4c7025499d1e36d1ff755f5e8018172c4245c01e97c7a3e9a21 + languageName: node + linkType: hard + +"@parcel/runtime-react-refresh@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/runtime-react-refresh@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + react-error-overlay: "npm:6.0.9" + react-refresh: "npm:^0.9.0" + checksum: 10c0/9efd3903118169f1eb4c176afbc4b8ee38d8b516a72dd189fec4d05c5b216e105aa6a77dd87aa5966923a648ed2c227e83feaed6c706a6fd5ebe0cdf255d5d46 + languageName: node + linkType: hard + +"@parcel/runtime-service-worker@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/runtime-service-worker@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + checksum: 10c0/014e44aa15bcc81002713af1cfc88a1d010f3ba6565ec5ea560231540a79cb76fecb11336ac019fb4c9c21a59477a1ce2d9f1a67f85e07be6b7da4498cfa17b3 + languageName: node + linkType: hard + +"@parcel/rust@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/rust@npm:2.12.0" + checksum: 10c0/38d8e5c69b31b3f7eb431f479c250f7a4e37f7814ce0aa16d42c300fffa25659da0ea8ca8e22534fa2b935dc8559507829d0cdebb588756aa4c3619565dcd3e3 + languageName: node + linkType: hard + +"@parcel/source-map@npm:^2.1.1": + version: 2.1.1 + resolution: "@parcel/source-map@npm:2.1.1" + dependencies: + detect-libc: "npm:^1.0.3" + checksum: 10c0/cea8450e152666be413556f0d100f125e81646bffc497e7c792bd9fc5067d052f1a008c8404ce1cd3a587d58b9ef57207ada89149cf2c705e71b1978308045f6 + languageName: node + linkType: hard + +"@parcel/transformer-babel@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-babel@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/utils": "npm:2.12.0" + browserslist: "npm:^4.6.6" + json5: "npm:^2.2.0" + nullthrows: "npm:^1.1.1" + semver: "npm:^7.5.2" + checksum: 10c0/b7398cc2ef02fd76010bb522fc72e562ce835643365a37ccfc56368121e5c9d890bef14fffa40a8c69e4a26f13ee7d6da8d8e8590957bd4f363b5aa1c4f7d43d + languageName: node + linkType: hard + +"@parcel/transformer-css@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-css@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/utils": "npm:2.12.0" + browserslist: "npm:^4.6.6" + lightningcss: "npm:^1.22.1" + nullthrows: "npm:^1.1.1" + checksum: 10c0/b3ad2591bca09a5696791b9a50bfb8efb825e92313740d6e3988ae1345d70965e92f9d42d58ae5571749e422d9018681aa49bddeafa939f3948a6993cc1cb4c8 + languageName: node + linkType: hard + +"@parcel/transformer-html@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-html@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + posthtml: "npm:^0.16.5" + posthtml-parser: "npm:^0.10.1" + posthtml-render: "npm:^3.0.0" + semver: "npm:^7.5.2" + srcset: "npm:4" + checksum: 10c0/1e73c1afe87b8db36e358752fe1b89d466cd9bfe66dda34fca58ad28ab10931553b16ba82096eeb266a0d90e62d6c9e455e3b32dbdf550f4212193898d4c45fd + languageName: node + linkType: hard + +"@parcel/transformer-image@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-image@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + "@parcel/workers": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + peerDependencies: + "@parcel/core": ^2.12.0 + checksum: 10c0/e361fa97d81b3dc2dfe011342321f1d2afd4fd41a9c2791522d8f39e2dc94714a2a0b9d291eb73437b2023fd1493ad37046d6b1ee925ec80daa18261cd5767a4 + languageName: node + linkType: hard + +"@parcel/transformer-js@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-js@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/utils": "npm:2.12.0" + "@parcel/workers": "npm:2.12.0" + "@swc/helpers": "npm:^0.5.0" + browserslist: "npm:^4.6.6" + nullthrows: "npm:^1.1.1" + regenerator-runtime: "npm:^0.13.7" + semver: "npm:^7.5.2" + peerDependencies: + "@parcel/core": ^2.12.0 + checksum: 10c0/8a438f0ae93539338ac3f2e2666377e75fb8a5a5386c84485d6cf5f0ad5e52862a80da89c35ca01fae10184ccc7567f1347679fd3b514f7b86643dc83dbce6a6 + languageName: node + linkType: hard + +"@parcel/transformer-json@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-json@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + json5: "npm:^2.2.0" + checksum: 10c0/41f931eacf89b5a792ca906594eeafa75d9fe5d0af85af7cf42e77f04e1d31de5bd64d3da9fcf0bdf745f3af252dd727ac318b12cc1c3a1345d19c5096ad98d8 + languageName: node + linkType: hard + +"@parcel/transformer-postcss@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-postcss@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + clone: "npm:^2.1.1" + nullthrows: "npm:^1.1.1" + postcss-value-parser: "npm:^4.2.0" + semver: "npm:^7.5.2" + checksum: 10c0/24c3a7eedd741ec1df43bed64b7e02e0132e1c85b9a93322fc994fd2a7f457c4a45f624edf3c064630f947749eb1eb89cb5a502db3f6a39286880afe09020e5a + languageName: node + linkType: hard + +"@parcel/transformer-posthtml@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-posthtml@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + posthtml: "npm:^0.16.5" + posthtml-parser: "npm:^0.10.1" + posthtml-render: "npm:^3.0.0" + semver: "npm:^7.5.2" + checksum: 10c0/ae626c15d5dda547850511a8aed41ba35e9496861dbba24efcb904693ced003a74f25c454b0f4bb96500725dd7e09ed4d09becccc48c0c8cdf8fde3ba02aa3f0 + languageName: node + linkType: hard + +"@parcel/transformer-raw@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-raw@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + checksum: 10c0/3a23729c6f91ef22c106995f730483dd375f81c11f8bb37ff485d6f3c111f64445d437796d470b42bdd2ee75cc3c4a142911fbcddd1676c8659dfc5e886917d2 + languageName: node + linkType: hard + +"@parcel/transformer-react-refresh-wrap@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-react-refresh-wrap@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + react-refresh: "npm:^0.9.0" + checksum: 10c0/37dd835182bf71fcee5858f0ab16d5683d2827b4930095ed9fffbd496e431a7f1c53de598f294220b7ff27cd5264d5f1fa750d974a1ee02fb39342fd867b6f9c + languageName: node + linkType: hard + +"@parcel/transformer-sass@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-sass@npm:2.12.0" + dependencies: + "@parcel/plugin": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + sass: "npm:^1.38.0" + checksum: 10c0/d82ee90605f9a8f04a978e2d72faf9693a843a0875961b6e7aa40659197356f1be45d738840c52863607c7823f70630f819aebd843606af8ea00772b75ba7574 + languageName: node + linkType: hard + +"@parcel/transformer-svg@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-svg@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + posthtml: "npm:^0.16.5" + posthtml-parser: "npm:^0.10.1" + posthtml-render: "npm:^3.0.0" + semver: "npm:^7.5.2" + checksum: 10c0/8916bdc0b36c60b32963e015c43a8bcd8cc2b15cc11b7611c49af6a4e4d63c2aabea0aa0fde31a78278eec25f88b52b3e56d8382dc2db5f3a401e63312115f3a + languageName: node + linkType: hard + +"@parcel/transformer-typescript-types@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/transformer-typescript-types@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/plugin": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/ts-utils": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + peerDependencies: + typescript: ">=3.0.0" + checksum: 10c0/6b0e89b64f2cfc5e56b972b02a6a1b97c50e60b6ab935389ebf9479d05d164fe4ff9dc297b0d78ed3d3578f80049425b056512546d45877f549a6713d54efd03 + languageName: node + linkType: hard + +"@parcel/ts-utils@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/ts-utils@npm:2.12.0" + dependencies: + nullthrows: "npm:^1.1.1" + peerDependencies: + typescript: ">=3.0.0" + checksum: 10c0/be53613cb3d950f5a6f0690af4bdda1ec9aaf0e97a035c66271230bd322ae29568d10831e61ded899188ca36d80add8ccbfcb494ebd4b0d9795cb51ccb7425cc + languageName: node + linkType: hard + +"@parcel/types@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/types@npm:2.12.0" + dependencies: + "@parcel/cache": "npm:2.12.0" + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/fs": "npm:2.12.0" + "@parcel/package-manager": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + "@parcel/workers": "npm:2.12.0" + utility-types: "npm:^3.10.0" + checksum: 10c0/a8aa61ad7cc8218a41fe27c206031b30c55eab59cd4affdfac7d15ddcfb80a1969c22086760b7d4fbdd63016dbfe3278d462e04b9c12e474780fe154caf08150 + languageName: node + linkType: hard + +"@parcel/utils@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/utils@npm:2.12.0" + dependencies: + "@parcel/codeframe": "npm:2.12.0" + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/logger": "npm:2.12.0" + "@parcel/markdown-ansi": "npm:2.12.0" + "@parcel/rust": "npm:2.12.0" + "@parcel/source-map": "npm:^2.1.1" + chalk: "npm:^4.1.0" + nullthrows: "npm:^1.1.1" + checksum: 10c0/888e2352d056ceff4e81d0cf4ae4eb8f322b0a8c4eb9e6f6aa5f916adc3f27c90369d5580b4f316029bf5160294a607795181a6bb368741524c177a14b2aa7c7 + languageName: node + linkType: hard + +"@parcel/watcher-android-arm64@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-android-arm64@npm:2.4.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-arm64@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-darwin-arm64@npm:2.4.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-x64@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-darwin-x64@npm:2.4.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-freebsd-x64@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-freebsd-x64@npm:2.4.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-glibc@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-linux-arm-glibc@npm:2.4.1" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-glibc@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.4.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-musl@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-linux-arm64-musl@npm:2.4.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-glibc@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-linux-x64-glibc@npm:2.4.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-musl@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-linux-x64-musl@npm:2.4.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-win32-arm64@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-win32-arm64@npm:2.4.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-win32-ia32@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-win32-ia32@npm:2.4.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@parcel/watcher-win32-x64@npm:2.4.1": + version: 2.4.1 + resolution: "@parcel/watcher-win32-x64@npm:2.4.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher@npm:^2.0.7": + version: 2.4.1 + resolution: "@parcel/watcher@npm:2.4.1" + dependencies: + "@parcel/watcher-android-arm64": "npm:2.4.1" + "@parcel/watcher-darwin-arm64": "npm:2.4.1" + "@parcel/watcher-darwin-x64": "npm:2.4.1" + "@parcel/watcher-freebsd-x64": "npm:2.4.1" + "@parcel/watcher-linux-arm-glibc": "npm:2.4.1" + "@parcel/watcher-linux-arm64-glibc": "npm:2.4.1" + "@parcel/watcher-linux-arm64-musl": "npm:2.4.1" + "@parcel/watcher-linux-x64-glibc": "npm:2.4.1" + "@parcel/watcher-linux-x64-musl": "npm:2.4.1" + "@parcel/watcher-win32-arm64": "npm:2.4.1" + "@parcel/watcher-win32-ia32": "npm:2.4.1" + "@parcel/watcher-win32-x64": "npm:2.4.1" + detect-libc: "npm:^1.0.3" + is-glob: "npm:^4.0.3" + micromatch: "npm:^4.0.5" + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + dependenciesMeta: + "@parcel/watcher-android-arm64": + optional: true + "@parcel/watcher-darwin-arm64": + optional: true + "@parcel/watcher-darwin-x64": + optional: true + "@parcel/watcher-freebsd-x64": + optional: true + "@parcel/watcher-linux-arm-glibc": + optional: true + "@parcel/watcher-linux-arm64-glibc": + optional: true + "@parcel/watcher-linux-arm64-musl": + optional: true + "@parcel/watcher-linux-x64-glibc": + optional: true + "@parcel/watcher-linux-x64-musl": + optional: true + "@parcel/watcher-win32-arm64": + optional: true + "@parcel/watcher-win32-ia32": + optional: true + "@parcel/watcher-win32-x64": + optional: true + checksum: 10c0/33b7112094b9eb46c234d824953967435b628d3d93a0553255e9910829b84cab3da870153c3a870c31db186dc58f3b2db81382fcaee3451438aeec4d786a6211 + languageName: node + linkType: hard + +"@parcel/workers@npm:2.12.0": + version: 2.12.0 + resolution: "@parcel/workers@npm:2.12.0" + dependencies: + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/logger": "npm:2.12.0" + "@parcel/profiler": "npm:2.12.0" + "@parcel/types": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + nullthrows: "npm:^1.1.1" + peerDependencies: + "@parcel/core": ^2.12.0 + checksum: 10c0/0f5e12e7997d806d6694e91a6c5968c34e1967f50bab3c09296589b2b279ffcd1c8de735845448de350e510a5657ba0aeb4b2c5c04cab81c4c7a57f70d567f5e + languageName: node + linkType: hard + +"@pkgjs/parseargs@npm:^0.11.0": + version: 0.11.0 + resolution: "@pkgjs/parseargs@npm:0.11.0" + checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd + languageName: node + linkType: hard + +"@protobuf-ts/plugin-framework@npm:^2.9.4": + version: 2.9.4 + resolution: "@protobuf-ts/plugin-framework@npm:2.9.4" + dependencies: + "@protobuf-ts/runtime": "npm:^2.9.4" + typescript: "npm:^3.9" + checksum: 10c0/2923852ab1d2d46090245a858fd362fffccd4f556963b01d153d4c5568dfa33d34101dacca0b1f38f23516e5a4d1f765e14be1d885dc1159d5ec37d25000f065 + languageName: node + linkType: hard + +"@protobuf-ts/plugin@npm:^2.9.4": + version: 2.9.4 + resolution: "@protobuf-ts/plugin@npm:2.9.4" + dependencies: + "@protobuf-ts/plugin-framework": "npm:^2.9.4" + "@protobuf-ts/protoc": "npm:^2.9.4" + "@protobuf-ts/runtime": "npm:^2.9.4" + "@protobuf-ts/runtime-rpc": "npm:^2.9.4" + typescript: "npm:^3.9" + bin: + protoc-gen-dump: bin/protoc-gen-dump + protoc-gen-ts: bin/protoc-gen-ts + checksum: 10c0/dbf1506e656d4d8ca91ace656cf3e238aed93d6539747c72c140fb0be29af61ccafae4e8c9f1e6f8369ac20508263d718ccb411dcf2d15276672c8ad7ba8194c + languageName: node + linkType: hard + +"@protobuf-ts/protoc@npm:^2.9.4": + version: 2.9.4 + resolution: "@protobuf-ts/protoc@npm:2.9.4" + bin: + protoc: protoc.js + checksum: 10c0/4ce4380cdab5560d13dd3b8d3538e6aee508a10b6b43dbd649d2ffe0a774129d59bd0e270ce7f643a95b9703e19088a5c725f68939913f2187fdeb1d6b42d4b5 + languageName: node + linkType: hard + +"@protobuf-ts/runtime-rpc@npm:^2.9.4": + version: 2.9.4 + resolution: "@protobuf-ts/runtime-rpc@npm:2.9.4" + dependencies: + "@protobuf-ts/runtime": "npm:^2.9.4" + checksum: 10c0/91fa7037b669dc92073d393dbe6bb109307d7b884506f6e5a310c6bde43b3920154b1176c826e9739c81ecd108090516b826e94354d58e454df2eef7f50f3a12 + languageName: node + linkType: hard + +"@protobuf-ts/runtime@npm:^2.9.4": + version: 2.9.4 + resolution: "@protobuf-ts/runtime@npm:2.9.4" + checksum: 10c0/78a10c0e2ee33fe98b3e30d15f8a52fe1a9505de3a8c056339bc01a0a076d4108a4efe93b578dc034c91c1b8c85996643b3f4d45f95c7e2bd5c151455b4fd23f + languageName: node + linkType: hard + +"@swc/core-darwin-arm64@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-darwin-arm64@npm:1.4.17" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@swc/core-darwin-x64@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-darwin-x64@npm:1.4.17" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@swc/core-linux-arm-gnueabihf@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.4.17" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@swc/core-linux-arm64-gnu@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-linux-arm64-gnu@npm:1.4.17" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@swc/core-linux-arm64-musl@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-linux-arm64-musl@npm:1.4.17" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@swc/core-linux-x64-gnu@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-linux-x64-gnu@npm:1.4.17" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@swc/core-linux-x64-musl@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-linux-x64-musl@npm:1.4.17" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@swc/core-win32-arm64-msvc@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-win32-arm64-msvc@npm:1.4.17" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@swc/core-win32-ia32-msvc@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-win32-ia32-msvc@npm:1.4.17" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@swc/core-win32-x64-msvc@npm:1.4.17": + version: 1.4.17 + resolution: "@swc/core-win32-x64-msvc@npm:1.4.17" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@swc/core@npm:^1.3.36": + version: 1.4.17 + resolution: "@swc/core@npm:1.4.17" + dependencies: + "@swc/core-darwin-arm64": "npm:1.4.17" + "@swc/core-darwin-x64": "npm:1.4.17" + "@swc/core-linux-arm-gnueabihf": "npm:1.4.17" + "@swc/core-linux-arm64-gnu": "npm:1.4.17" + "@swc/core-linux-arm64-musl": "npm:1.4.17" + "@swc/core-linux-x64-gnu": "npm:1.4.17" + "@swc/core-linux-x64-musl": "npm:1.4.17" + "@swc/core-win32-arm64-msvc": "npm:1.4.17" + "@swc/core-win32-ia32-msvc": "npm:1.4.17" + "@swc/core-win32-x64-msvc": "npm:1.4.17" + "@swc/counter": "npm:^0.1.2" + "@swc/types": "npm:^0.1.5" + peerDependencies: + "@swc/helpers": ^0.5.0 + dependenciesMeta: + "@swc/core-darwin-arm64": + optional: true + "@swc/core-darwin-x64": + optional: true + "@swc/core-linux-arm-gnueabihf": + optional: true + "@swc/core-linux-arm64-gnu": + optional: true + "@swc/core-linux-arm64-musl": + optional: true + "@swc/core-linux-x64-gnu": + optional: true + "@swc/core-linux-x64-musl": + optional: true + "@swc/core-win32-arm64-msvc": + optional: true + "@swc/core-win32-ia32-msvc": + optional: true + "@swc/core-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@swc/helpers": + optional: true + checksum: 10c0/385b1ced6ed3b282c717f422d7fb70a8529f81b004dacb6fd49b3cc3693f33047d806870fae868ea71b586628aaf6879870afacd495c61103fe4f46bda8a83e3 + languageName: node + linkType: hard + +"@swc/counter@npm:^0.1.2, @swc/counter@npm:^0.1.3": + version: 0.1.3 + resolution: "@swc/counter@npm:0.1.3" + checksum: 10c0/8424f60f6bf8694cfd2a9bca45845bce29f26105cda8cf19cdb9fd3e78dc6338699e4db77a89ae449260bafa1cc6bec307e81e7fb96dbf7dcfce0eea55151356 + languageName: node + linkType: hard + +"@swc/helpers@npm:^0.5.0": + version: 0.5.10 + resolution: "@swc/helpers@npm:0.5.10" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/db7d82cf1301d01a92777795abe6846fd0a0af15bf52c37f1f2945cdafd96ebc612276820fc4c04e0b875b7109b5f4087e796afe7ddba36c1137d895144db2e2 + languageName: node + linkType: hard + +"@swc/types@npm:^0.1.5": + version: 0.1.6 + resolution: "@swc/types@npm:0.1.6" + dependencies: + "@swc/counter": "npm:^0.1.3" + checksum: 10c0/043a0e56d69db8733827ad69db55d0ffbd6976fd24ef629a488e57040067ac84d057a57e08bc5a3db545d44b01d6aa43c22df1152c637af450d366e57cde6e22 + languageName: node + linkType: hard + +"@trysound/sax@npm:0.2.0": + version: 0.2.0 + resolution: "@trysound/sax@npm:0.2.0" + checksum: 10c0/44907308549ce775a41c38a815f747009ac45929a45d642b836aa6b0a536e4978d30b8d7d680bbd116e9dd73b7dbe2ef0d1369dcfc2d09e83ba381e485ecbe12 + languageName: node + linkType: hard + +"@types/node@npm:*, @types/node@npm:^20.12.5, @types/node@npm:^20.12.7": + version: 20.12.7 + resolution: "@types/node@npm:20.12.7" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10c0/dce80d63a3b91892b321af823d624995c61e39c6a223cc0ac481a44d337640cc46931d33efb3beeed75f5c85c3bda1d97cef4c5cd4ec333caf5dee59cff6eca0 + languageName: node + linkType: hard + +"@types/split@npm:^1.0.5": + version: 1.0.5 + resolution: "@types/split@npm:1.0.5" + dependencies: + "@types/node": "npm:*" + "@types/through": "npm:*" + checksum: 10c0/eb187a3b07e5064928e49bffd5c45ad1f1109135fee52344bb7623cdb55e2ebb16bd6ca009a30a0a6e2b262f7ebb7bf18030ff873819e80fafd4cbb51dba1a74 + languageName: node + linkType: hard + +"@types/through@npm:*": + version: 0.0.33 + resolution: "@types/through@npm:0.0.33" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/6a8edd7f40cd7e197318e86310a40e568cddd380609dde59b30d5cc6c5f8276ddc698905eac4b3b429eb39f2e8ee326bc20dc6e95a2cdc41c4d3fc9a1ebd4929 + languageName: node + linkType: hard + +"@types/ws@npm:^8.5.5": + version: 8.5.10 + resolution: "@types/ws@npm:8.5.10" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/e9af279b984c4a04ab53295a40aa95c3e9685f04888df5c6920860d1dd073fcc57c7bd33578a04b285b2c655a0b52258d34bee0a20569dca8defb8393e1e5d29 + languageName: node + linkType: hard + +"abbrev@npm:^2.0.0": + version: 2.0.0 + resolution: "abbrev@npm:2.0.0" + checksum: 10c0/f742a5a107473946f426c691c08daba61a1d15942616f300b5d32fd735be88fef5cba24201757b6c407fd564555fb48c751cfa33519b2605c8a7aadd22baf372 + languageName: node + linkType: hard + +"abortcontroller-polyfill@npm:^1.1.9": + version: 1.7.5 + resolution: "abortcontroller-polyfill@npm:1.7.5" + checksum: 10c0/d7a5ab6fda4f9a54f22ddeb233a2564d2f4f857ec17be25fee21a91bb5090bee57c630c454634b5c4b93fc06bd90d592d1f2fc69f77cd28791ac0fe361feb7d2 + languageName: node + linkType: hard + +"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": + version: 7.1.1 + resolution: "agent-base@npm:7.1.1" + dependencies: + debug: "npm:^4.3.4" + checksum: 10c0/e59ce7bed9c63bf071a30cc471f2933862044c97fd9958967bfe22521d7a0f601ce4ed5a8c011799d0c726ca70312142ae193bbebb60f576b52be19d4a363b50 + languageName: node + linkType: hard + +"aggregate-error@npm:^3.0.0": + version: 3.1.0 + resolution: "aggregate-error@npm:3.1.0" + dependencies: + clean-stack: "npm:^2.0.0" + indent-string: "npm:^4.0.0" + checksum: 10c0/a42f67faa79e3e6687a4923050e7c9807db3848a037076f791d10e092677d65c1d2d863b7848560699f40fc0502c19f40963fb1cd1fb3d338a7423df8e45e039 + languageName: node + linkType: hard + +"ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 + languageName: node + linkType: hard + +"ansi-regex@npm:^6.0.1": + version: 6.0.1 + resolution: "ansi-regex@npm:6.0.1" + checksum: 10c0/cbe16dbd2c6b2735d1df7976a7070dd277326434f0212f43abf6d87674095d247968209babdaad31bb00882fa68807256ba9be340eec2f1004de14ca75f52a08 + languageName: node + linkType: hard + +"ansi-styles@npm:^3.2.1": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10c0/ece5a8ef069fcc5298f67e3f4771a663129abd174ea2dfa87923a2be2abf6cd367ef72ac87942da00ce85bd1d651d4cd8595aebdb1b385889b89b205860e977b + languageName: node + linkType: hard + +"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: "npm:^2.0.1" + checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 + languageName: node + linkType: hard + +"ansi-styles@npm:^6.1.0": + version: 6.2.1 + resolution: "ansi-styles@npm:6.2.1" + checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c + languageName: node + linkType: hard + +"anymatch@npm:~3.1.2": + version: 3.1.3 + resolution: "anymatch@npm:3.1.3" + dependencies: + normalize-path: "npm:^3.0.0" + picomatch: "npm:^2.0.4" + checksum: 10c0/57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac + languageName: node + linkType: hard + +"aproba@npm:^1.0.3 || ^2.0.0": + version: 2.0.0 + resolution: "aproba@npm:2.0.0" + checksum: 10c0/d06e26384a8f6245d8c8896e138c0388824e259a329e0c9f196b4fa533c82502a6fd449586e3604950a0c42921832a458bb3aa0aa9f0ba449cfd4f50fd0d09b5 + languageName: node + linkType: hard + +"are-we-there-yet@npm:^3.0.0": + version: 3.0.1 + resolution: "are-we-there-yet@npm:3.0.1" + dependencies: + delegates: "npm:^1.0.0" + readable-stream: "npm:^3.6.0" + checksum: 10c0/8373f289ba42e4b5ec713bb585acdac14b5702c75f2a458dc985b9e4fa5762bc5b46b40a21b72418a3ed0cfb5e35bdc317ef1ae132f3035f633d581dd03168c3 + languageName: node + linkType: hard + +"argparse@npm:^2.0.1": + version: 2.0.1 + resolution: "argparse@npm:2.0.1" + checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"axios@npm:^1.6.5": + version: 1.6.8 + resolution: "axios@npm:1.6.8" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/0f22da6f490335479a89878bc7d5a1419484fbb437b564a80c34888fc36759ae4f56ea28d55a191695e5ed327f0bad56e7ff60fb6770c14d1be6501505d47ab9 + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee + languageName: node + linkType: hard + +"base-x@npm:^3.0.8": + version: 3.0.9 + resolution: "base-x@npm:3.0.9" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/e6bbeae30b24f748b546005affb710c5fbc8b11a83f6cd0ca999bd1ab7ad3a22e42888addc40cd145adc4edfe62fcfab4ebc91da22e4259aae441f95a77aee1a + languageName: node + linkType: hard + +"binary-extensions@npm:^2.0.0": + version: 2.3.0 + resolution: "binary-extensions@npm:2.3.0" + checksum: 10c0/75a59cafc10fb12a11d510e77110c6c7ae3f4ca22463d52487709ca7f18f69d886aa387557cc9864fbdb10153d0bdb4caacabf11541f55e89ed6e18d12ece2b5 + languageName: node + linkType: hard + +"boolbase@npm:^1.0.0": + version: 1.0.0 + resolution: "boolbase@npm:1.0.0" + checksum: 10c0/e4b53deb4f2b85c52be0e21a273f2045c7b6a6ea002b0e139c744cb6f95e9ec044439a52883b0d74dedd1ff3da55ed140cfdddfed7fb0cccbed373de5dce1bcf + languageName: node + linkType: hard + +"brace-expansion@npm:^2.0.1": + version: 2.0.1 + resolution: "brace-expansion@npm:2.0.1" + dependencies: + balanced-match: "npm:^1.0.0" + checksum: 10c0/b358f2fe060e2d7a87aa015979ecea07f3c37d4018f8d6deb5bd4c229ad3a0384fe6029bb76cd8be63c81e516ee52d1a0673edbe2023d53a5191732ae3c3e49f + languageName: node + linkType: hard + +"braces@npm:^3.0.2, braces@npm:~3.0.2": + version: 3.0.2 + resolution: "braces@npm:3.0.2" + dependencies: + fill-range: "npm:^7.0.1" + checksum: 10c0/321b4d675791479293264019156ca322163f02dc06e3c4cab33bb15cd43d80b51efef69b0930cfde3acd63d126ebca24cd0544fa6f261e093a0fb41ab9dda381 + languageName: node + linkType: hard + +"browserslist@npm:^4.6.6": + version: 4.23.0 + resolution: "browserslist@npm:4.23.0" + dependencies: + caniuse-lite: "npm:^1.0.30001587" + electron-to-chromium: "npm:^1.4.668" + node-releases: "npm:^2.0.14" + update-browserslist-db: "npm:^1.0.13" + bin: + browserslist: cli.js + checksum: 10c0/8e9cc154529062128d02a7af4d8adeead83ca1df8cd9ee65a88e2161039f3d68a4d40fea7353cab6bae4c16182dec2fdd9a1cf7dc2a2935498cee1af0e998943 + languageName: node + linkType: hard + +"cacache@npm:^18.0.0": + version: 18.0.2 + resolution: "cacache@npm:18.0.2" + dependencies: + "@npmcli/fs": "npm:^3.1.0" + fs-minipass: "npm:^3.0.0" + glob: "npm:^10.2.2" + lru-cache: "npm:^10.0.1" + minipass: "npm:^7.0.3" + minipass-collect: "npm:^2.0.1" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + p-map: "npm:^4.0.0" + ssri: "npm:^10.0.0" + tar: "npm:^6.1.11" + unique-filename: "npm:^3.0.0" + checksum: 10c0/7992665305cc251a984f4fdbab1449d50e88c635bc43bf2785530c61d239c61b349e5734461baa461caaee65f040ab14e2d58e694f479c0810cffd181ba5eabc + languageName: node + linkType: hard + +"callsites@npm:^3.0.0": + version: 3.1.0 + resolution: "callsites@npm:3.1.0" + checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001587": + version: 1.0.30001612 + resolution: "caniuse-lite@npm:1.0.30001612" + checksum: 10c0/d6b405ff06f4e913bc779f9183fa68001c9d6b8526a7dd1b99c60587dd21a01aa8def3d8462cf6214f0181f1c21b9245611ff65241cf9c967fc742e86ece5065 + languageName: node + linkType: hard + +"chalk@npm:^2.4.2": + version: 2.4.2 + resolution: "chalk@npm:2.4.2" + dependencies: + ansi-styles: "npm:^3.2.1" + escape-string-regexp: "npm:^1.0.5" + supports-color: "npm:^5.3.0" + checksum: 10c0/e6543f02ec877732e3a2d1c3c3323ddb4d39fbab687c23f526e25bd4c6a9bf3b83a696e8c769d078e04e5754921648f7821b2a2acfd16c550435fd630026e073 + languageName: node + linkType: hard + +"chalk@npm:^4.1.0": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 + languageName: node + linkType: hard + +"chokidar@npm:>=3.0.0 <4.0.0": + version: 3.6.0 + resolution: "chokidar@npm:3.6.0" + dependencies: + anymatch: "npm:~3.1.2" + braces: "npm:~3.0.2" + fsevents: "npm:~2.3.2" + glob-parent: "npm:~5.1.2" + is-binary-path: "npm:~2.1.0" + is-glob: "npm:~4.0.1" + normalize-path: "npm:~3.0.0" + readdirp: "npm:~3.6.0" + dependenciesMeta: + fsevents: + optional: true + checksum: 10c0/8361dcd013f2ddbe260eacb1f3cb2f2c6f2b0ad118708a343a5ed8158941a39cb8fb1d272e0f389712e74ee90ce8ba864eece9e0e62b9705cb468a2f6d917462 + languageName: node + linkType: hard + +"chownr@npm:^2.0.0": + version: 2.0.0 + resolution: "chownr@npm:2.0.0" + checksum: 10c0/594754e1303672171cc04e50f6c398ae16128eb134a88f801bf5354fd96f205320f23536a045d9abd8b51024a149696e51231565891d4efdab8846021ecf88e6 + languageName: node + linkType: hard + +"chrome-trace-event@npm:^1.0.2, chrome-trace-event@npm:^1.0.3": + version: 1.0.3 + resolution: "chrome-trace-event@npm:1.0.3" + checksum: 10c0/080ce2d20c2b9e0f8461a380e9585686caa768b1c834a464470c9dc74cda07f27611c7b727a2cd768a9cecd033297fdec4ce01f1e58b62227882c1059dec321c + languageName: node + linkType: hard + +"clean-stack@npm:^2.0.0": + version: 2.2.0 + resolution: "clean-stack@npm:2.2.0" + checksum: 10c0/1f90262d5f6230a17e27d0c190b09d47ebe7efdd76a03b5a1127863f7b3c9aec4c3e6c8bb3a7bbf81d553d56a1fd35728f5a8ef4c63f867ac8d690109742a8c1 + languageName: node + linkType: hard + +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 + languageName: node + linkType: hard + +"clone@npm:^2.1.1": + version: 2.1.2 + resolution: "clone@npm:2.1.2" + checksum: 10c0/ed0601cd0b1606bc7d82ee7175b97e68d1dd9b91fd1250a3617b38d34a095f8ee0431d40a1a611122dcccb4f93295b4fdb94942aa763392b5fe44effa50c2d5e + languageName: node + linkType: hard + +"cmake-js@npm:^7.2.0": + version: 7.3.0 + resolution: "cmake-js@npm:7.3.0" + dependencies: + axios: "npm:^1.6.5" + debug: "npm:^4" + fs-extra: "npm:^11.2.0" + lodash.isplainobject: "npm:^4.0.6" + memory-stream: "npm:^1.0.0" + node-api-headers: "npm:^1.1.0" + npmlog: "npm:^6.0.2" + rc: "npm:^1.2.7" + semver: "npm:^7.5.4" + tar: "npm:^6.2.0" + url-join: "npm:^4.0.1" + which: "npm:^2.0.2" + yargs: "npm:^17.7.2" + bin: + cmake-js: bin/cmake-js + checksum: 10c0/8aa3839603578e3f40bff55a3b7cb962682223ab91bab82501289d6ee0b84246c22ecdc01381b38079a0f20fd5c6ca01068e928bf399f8054a19a7cdaab1060c + languageName: node + linkType: hard + +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10c0/5ad3c534949a8c68fca8fbc6f09068f435f0ad290ab8b2f76841b9e6af7e0bb57b98cb05b0e19fe33f5d91e5a8611ad457e5f69e0a484caad1f7487fd0e8253c + languageName: node + linkType: hard + +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: "npm:~1.1.4" + checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 + languageName: node + linkType: hard + +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10c0/566a3d42cca25b9b3cd5528cd7754b8e89c0eb646b7f214e8e2eaddb69994ac5f0557d9c175eb5d8f0ad73531140d9c47525085ee752a91a2ab15ab459caf6d6 + languageName: node + linkType: hard + +"color-name@npm:^1.0.0, color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 + languageName: node + linkType: hard + +"color-string@npm:^1.9.0": + version: 1.9.1 + resolution: "color-string@npm:1.9.1" + dependencies: + color-name: "npm:^1.0.0" + simple-swizzle: "npm:^0.2.2" + checksum: 10c0/b0bfd74c03b1f837f543898b512f5ea353f71630ccdd0d66f83028d1f0924a7d4272deb278b9aef376cacf1289b522ac3fb175e99895283645a2dc3a33af2404 + languageName: node + linkType: hard + +"color-support@npm:^1.1.3": + version: 1.1.3 + resolution: "color-support@npm:1.1.3" + bin: + color-support: bin.js + checksum: 10c0/8ffeaa270a784dc382f62d9be0a98581db43e11eee301af14734a6d089bd456478b1a8b3e7db7ca7dc5b18a75f828f775c44074020b51c05fc00e6d0992b1cc6 + languageName: node + linkType: hard + +"color@npm:^4.2.3": + version: 4.2.3 + resolution: "color@npm:4.2.3" + dependencies: + color-convert: "npm:^2.0.1" + color-string: "npm:^1.9.0" + checksum: 10c0/7fbe7cfb811054c808349de19fb380252e5e34e61d7d168ec3353e9e9aacb1802674bddc657682e4e9730c2786592a4de6f8283e7e0d3870b829bb0b7b2f6118 + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"commander@npm:^7.0.0, commander@npm:^7.2.0": + version: 7.2.0 + resolution: "commander@npm:7.2.0" + checksum: 10c0/8d690ff13b0356df7e0ebbe6c59b4712f754f4b724d4f473d3cc5b3fdcf978e3a5dc3078717858a2ceb50b0f84d0660a7f22a96cdc50fb877d0c9bb31593d23a + languageName: node + linkType: hard + +"console-control-strings@npm:^1.1.0": + version: 1.1.0 + resolution: "console-control-strings@npm:1.1.0" + checksum: 10c0/7ab51d30b52d461412cd467721bb82afe695da78fff8f29fe6f6b9cbaac9a2328e27a22a966014df9532100f6dd85370460be8130b9c677891ba36d96a343f50 + languageName: node + linkType: hard + +"cosmiconfig@npm:^8.0.0": + version: 8.3.6 + resolution: "cosmiconfig@npm:8.3.6" + dependencies: + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" + path-type: "npm:^4.0.0" + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/0382a9ed13208f8bfc22ca2f62b364855207dffdb73dc26e150ade78c3093f1cf56172df2dd460c8caf2afa91c0ed4ec8a88c62f8f9cd1cf423d26506aa8797a + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 + languageName: node + linkType: hard + +"css-select@npm:^4.1.3": + version: 4.3.0 + resolution: "css-select@npm:4.3.0" + dependencies: + boolbase: "npm:^1.0.0" + css-what: "npm:^6.0.1" + domhandler: "npm:^4.3.1" + domutils: "npm:^2.8.0" + nth-check: "npm:^2.0.1" + checksum: 10c0/a489d8e5628e61063d5a8fe0fa1cc7ae2478cb334a388a354e91cf2908154be97eac9fa7ed4dffe87a3e06cf6fcaa6016553115335c4fd3377e13dac7bd5a8e1 + languageName: node + linkType: hard + +"css-tree@npm:^1.1.2, css-tree@npm:^1.1.3": + version: 1.1.3 + resolution: "css-tree@npm:1.1.3" + dependencies: + mdn-data: "npm:2.0.14" + source-map: "npm:^0.6.1" + checksum: 10c0/499a507bfa39b8b2128f49736882c0dd636b0cd3370f2c69f4558ec86d269113286b7df469afc955de6a68b0dba00bc533e40022a73698081d600072d5d83c1c + languageName: node + linkType: hard + +"css-what@npm:^6.0.1": + version: 6.1.0 + resolution: "css-what@npm:6.1.0" + checksum: 10c0/a09f5a6b14ba8dcf57ae9a59474722e80f20406c53a61e9aedb0eedc693b135113ffe2983f4efc4b5065ae639442e9ae88df24941ef159c218b231011d733746 + languageName: node + linkType: hard + +"csso@npm:^4.2.0": + version: 4.2.0 + resolution: "csso@npm:4.2.0" + dependencies: + css-tree: "npm:^1.1.2" + checksum: 10c0/f8c6b1300efaa0f8855a7905ae3794a29c6496e7f16a71dec31eb6ca7cfb1f058a4b03fd39b66c4deac6cb06bf6b4ba86da7b67d7320389cb9994d52b924b903 + languageName: node + linkType: hard + +"cvmts-repo@workspace:.": + version: 0.0.0-use.local + resolution: "cvmts-repo@workspace:." + dependencies: + "@parcel/packager-ts": "npm:2.12.0" + "@parcel/transformer-sass": "npm:2.12.0" + "@parcel/transformer-typescript-types": "npm:2.12.0" + "@types/node": "npm:^20.12.5" + parcel: "npm:^2.12.0" + prettier: "npm:^3.2.5" + typescript: "npm:^5.4.4" + languageName: unknown + linkType: soft + +"debug@npm:4, debug@npm:^4, debug@npm:^4.3.4": + version: 4.3.4 + resolution: "debug@npm:4.3.4" + dependencies: + ms: "npm:2.1.2" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/cedbec45298dd5c501d01b92b119cd3faebe5438c3917ff11ae1bff86a6c722930ac9c8659792824013168ba6db7c4668225d845c633fbdafbbf902a6389f736 + languageName: node + linkType: hard + +"deep-extend@npm:^0.6.0": + version: 0.6.0 + resolution: "deep-extend@npm:0.6.0" + checksum: 10c0/1c6b0abcdb901e13a44c7d699116d3d4279fdb261983122a3783e7273844d5f2537dc2e1c454a23fcf645917f93fbf8d07101c1d03c015a87faa662755212566 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + +"delegates@npm:^1.0.0": + version: 1.0.0 + resolution: "delegates@npm:1.0.0" + checksum: 10c0/ba05874b91148e1db4bf254750c042bf2215febd23a6d3cda2e64896aef79745fbd4b9996488bd3cafb39ce19dbce0fd6e3b6665275638befffe1c9b312b91b5 + languageName: node + linkType: hard + +"detect-libc@npm:^1.0.3": + version: 1.0.3 + resolution: "detect-libc@npm:1.0.3" + bin: + detect-libc: ./bin/detect-libc.js + checksum: 10c0/4da0deae9f69e13bc37a0902d78bf7169480004b1fed3c19722d56cff578d16f0e11633b7fbf5fb6249181236c72e90024cbd68f0b9558ae06e281f47326d50d + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.1, detect-libc@npm:^2.0.3": + version: 2.0.3 + resolution: "detect-libc@npm:2.0.3" + checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7 + languageName: node + linkType: hard + +"dom-serializer@npm:^1.0.1": + version: 1.4.1 + resolution: "dom-serializer@npm:1.4.1" + dependencies: + domelementtype: "npm:^2.0.1" + domhandler: "npm:^4.2.0" + entities: "npm:^2.0.0" + checksum: 10c0/67d775fa1ea3de52035c98168ddcd59418356943b5eccb80e3c8b3da53adb8e37edb2cc2f885802b7b1765bf5022aec21dfc32910d7f9e6de4c3148f095ab5e0 + languageName: node + linkType: hard + +"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0": + version: 2.3.0 + resolution: "domelementtype@npm:2.3.0" + checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9 + languageName: node + linkType: hard + +"domhandler@npm:^4.2.0, domhandler@npm:^4.2.2, domhandler@npm:^4.3.1": + version: 4.3.1 + resolution: "domhandler@npm:4.3.1" + dependencies: + domelementtype: "npm:^2.2.0" + checksum: 10c0/5c199c7468cb052a8b5ab80b13528f0db3d794c64fc050ba793b574e158e67c93f8336e87fd81e9d5ee43b0e04aea4d8b93ed7be4899cb726a1601b3ba18538b + languageName: node + linkType: hard + +"domutils@npm:^2.8.0": + version: 2.8.0 + resolution: "domutils@npm:2.8.0" + dependencies: + dom-serializer: "npm:^1.0.1" + domelementtype: "npm:^2.2.0" + domhandler: "npm:^4.2.0" + checksum: 10c0/d58e2ae01922f0dd55894e61d18119924d88091837887bf1438f2327f32c65eb76426bd9384f81e7d6dcfb048e0f83c19b222ad7101176ad68cdc9c695b563db + languageName: node + linkType: hard + +"dotenv-expand@npm:^5.1.0": + version: 5.1.0 + resolution: "dotenv-expand@npm:5.1.0" + checksum: 10c0/24ac633de853ef474d0421cc639328b7134109c8dc2baaa5e3afb7495af5e9237136d7e6971e55668e4dce915487eb140967cdd2b3e99aa439e0f6bf8b56faeb + languageName: node + linkType: hard + +"dotenv@npm:^7.0.0": + version: 7.0.0 + resolution: "dotenv@npm:7.0.0" + checksum: 10c0/4d834d09d23ebd284e701c4204172659a7dcd51116f11c29c575ae6d918ccd4760a3383bdfd83cfbed42f061266b787f8e56452b952638867ea5476be875eb27 + languageName: node + linkType: hard + +"eastasianwidth@npm:^0.2.0": + version: 0.2.0 + resolution: "eastasianwidth@npm:0.2.0" + checksum: 10c0/26f364ebcdb6395f95124fda411f63137a4bfb5d3a06453f7f23dfe52502905bd84e0488172e0f9ec295fdc45f05c23d5d91baf16bd26f0fe9acd777a188dc39 + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.4.668": + version: 1.4.746 + resolution: "electron-to-chromium@npm:1.4.746" + checksum: 10c0/1ff47105510e9a6dbc542d7165b88e030c8f2c815b30683ca05d8bc1a24e8f03e57caa0dc03959b08860b0465f9645edea5c682400da5b79b71ce9ddbb89a3d6 + languageName: node + linkType: hard + +"emoji-regex@npm:^8.0.0": + version: 8.0.0 + resolution: "emoji-regex@npm:8.0.0" + checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010 + languageName: node + linkType: hard + +"emoji-regex@npm:^9.2.2": + version: 9.2.2 + resolution: "emoji-regex@npm:9.2.2" + checksum: 10c0/af014e759a72064cf66e6e694a7fc6b0ed3d8db680427b021a89727689671cefe9d04151b2cad51dbaf85d5ba790d061cd167f1cf32eb7b281f6368b3c181639 + languageName: node + linkType: hard + +"encoding@npm:^0.1.13": + version: 0.1.13 + resolution: "encoding@npm:0.1.13" + dependencies: + iconv-lite: "npm:^0.6.2" + checksum: 10c0/36d938712ff00fe1f4bac88b43bcffb5930c1efa57bbcdca9d67e1d9d6c57cfb1200fb01efe0f3109b2ce99b231f90779532814a81370a1bd3274a0f58585039 + languageName: node + linkType: hard + +"entities@npm:^2.0.0": + version: 2.2.0 + resolution: "entities@npm:2.2.0" + checksum: 10c0/7fba6af1f116300d2ba1c5673fc218af1961b20908638391b4e1e6d5850314ee2ac3ec22d741b3a8060479911c99305164aed19b6254bde75e7e6b1b2c3f3aa3 + languageName: node + linkType: hard + +"entities@npm:^3.0.1": + version: 3.0.1 + resolution: "entities@npm:3.0.1" + checksum: 10c0/2d93f48fd86de0b0ed8ee34456aa47b4e74a916a5e663cfcc7048302e2c7e932002926daf5a00ad6d5691e3c90673a15d413704d86d7e1b9532f9bc00d975590 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"err-code@npm:^2.0.2": + version: 2.0.3 + resolution: "err-code@npm:2.0.3" + checksum: 10c0/b642f7b4dd4a376e954947550a3065a9ece6733ab8e51ad80db727aaae0817c2e99b02a97a3d6cecc648a97848305e728289cf312d09af395403a90c9d4d8a66 + languageName: node + linkType: hard + +"error-ex@npm:^1.3.1": + version: 1.3.2 + resolution: "error-ex@npm:1.3.2" + dependencies: + is-arrayish: "npm:^0.2.1" + checksum: 10c0/ba827f89369b4c93382cfca5a264d059dfefdaa56ecc5e338ffa58a6471f5ed93b71a20add1d52290a4873d92381174382658c885ac1a2305f7baca363ce9cce + languageName: node + linkType: hard + +"escalade@npm:^3.1.1": + version: 3.1.2 + resolution: "escalade@npm:3.1.2" + checksum: 10c0/6b4adafecd0682f3aa1cd1106b8fff30e492c7015b178bc81b2d2f75106dabea6c6d6e8508fc491bd58e597c74abb0e8e2368f943ecb9393d4162e3c2f3cf287 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 10c0/a968ad453dd0c2724e14a4f20e177aaf32bb384ab41b674a8454afe9a41c5e6fe8903323e0a1052f56289d04bd600f81278edf140b0fcc02f5cac98d0f5b5371 + languageName: node + linkType: hard + +"execa@npm:^8.0.1": + version: 8.0.1 + resolution: "execa@npm:8.0.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^8.0.1" + human-signals: "npm:^5.0.0" + is-stream: "npm:^3.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^5.1.0" + onetime: "npm:^6.0.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^3.0.0" + checksum: 10c0/2c52d8775f5bf103ce8eec9c7ab3059909ba350a5164744e9947ed14a53f51687c040a250bda833f906d1283aa8803975b84e6c8f7a7c42f99dc8ef80250d1af + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.1 + resolution: "exponential-backoff@npm:3.1.1" + checksum: 10c0/160456d2d647e6019640bd07111634d8c353038d9fa40176afb7cd49b0548bdae83b56d05e907c2cce2300b81cae35d800ef92fefb9d0208e190fa3b7d6bb579 + languageName: node + linkType: hard + +"fill-range@npm:^7.0.1": + version: 7.0.1 + resolution: "fill-range@npm:7.0.1" + dependencies: + to-regex-range: "npm:^5.0.1" + checksum: 10c0/7cdad7d426ffbaadf45aeb5d15ec675bbd77f7597ad5399e3d2766987ed20bda24d5fac64b3ee79d93276f5865608bb22344a26b9b1ae6c4d00bd94bf611623f + languageName: node + linkType: hard + +"follow-redirects@npm:^1.15.6": + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/9ff767f0d7be6aa6870c82ac79cf0368cd73e01bbc00e9eb1c2a16fbb198ec105e3c9b6628bb98e9f3ac66fe29a957b9645bcb9a490bb7aa0d35f908b6b85071 + languageName: node + linkType: hard + +"foreground-child@npm:^3.1.0": + version: 3.1.1 + resolution: "foreground-child@npm:3.1.1" + dependencies: + cross-spawn: "npm:^7.0.0" + signal-exit: "npm:^4.0.1" + checksum: 10c0/9700a0285628abaeb37007c9a4d92bd49f67210f09067638774338e146c8e9c825c5c877f072b2f75f41dc6a2d0be8664f79ffc03f6576649f54a84fb9b47de0 + languageName: node + linkType: hard + +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + mime-types: "npm:^2.1.12" + checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e + languageName: node + linkType: hard + +"fs-extra@npm:^11.2.0": + version: 11.2.0 + resolution: "fs-extra@npm:11.2.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10c0/d77a9a9efe60532d2e790e938c81a02c1b24904ef7a3efb3990b835514465ba720e99a6ea56fd5e2db53b4695319b644d76d5a0e9988a2beef80aa7b1da63398 + languageName: node + linkType: hard + +"fs-minipass@npm:^2.0.0": + version: 2.1.0 + resolution: "fs-minipass@npm:2.1.0" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/703d16522b8282d7299337539c3ed6edddd1afe82435e4f5b76e34a79cd74e488a8a0e26a636afc2440e1a23b03878e2122e3a2cfe375a5cf63c37d92b86a004 + languageName: node + linkType: hard + +"fs-minipass@npm:^3.0.0": + version: 3.0.3 + resolution: "fs-minipass@npm:3.0.3" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94 + languageName: node + linkType: hard + +"fsevents@npm:~2.3.2": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"gauge@npm:^4.0.3": + version: 4.0.4 + resolution: "gauge@npm:4.0.4" + dependencies: + aproba: "npm:^1.0.3 || ^2.0.0" + color-support: "npm:^1.1.3" + console-control-strings: "npm:^1.1.0" + has-unicode: "npm:^2.0.1" + signal-exit: "npm:^3.0.7" + string-width: "npm:^4.2.3" + strip-ansi: "npm:^6.0.1" + wide-align: "npm:^1.1.5" + checksum: 10c0/ef10d7981113d69225135f994c9f8c4369d945e64a8fc721d655a3a38421b738c9fe899951721d1b47b73c41fdb5404ac87cc8903b2ecbed95d2800363e7e58c + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde + languageName: node + linkType: hard + +"get-port@npm:^4.2.0": + version: 4.2.0 + resolution: "get-port@npm:4.2.0" + checksum: 10c0/ecce4233b720e7c6612aedc334ee8bb62b7d44db7ad6a55e58f7b3a17993ecfcb1bb218b8bb1ee197d0971c12e420aad2b3f95a93e4a117f2186f926ebcd2d42 + languageName: node + linkType: hard + +"get-stream@npm:^8.0.1": + version: 8.0.1 + resolution: "get-stream@npm:8.0.1" + checksum: 10c0/5c2181e98202b9dae0bb4a849979291043e5892eb40312b47f0c22b9414fc9b28a3b6063d2375705eb24abc41ecf97894d9a51f64ff021511b504477b27b4290 + languageName: node + linkType: hard + +"glob-parent@npm:~5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: "npm:^4.0.1" + checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee + languageName: node + linkType: hard + +"glob@npm:^10.2.2, glob@npm:^10.3.10": + version: 10.3.12 + resolution: "glob@npm:10.3.12" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^2.3.6" + minimatch: "npm:^9.0.1" + minipass: "npm:^7.0.4" + path-scurry: "npm:^1.10.2" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/f60cefdc1cf3f958b2bb5823e1b233727f04916d489dc4641d76914f016e6704421e06a83cbb68b0cb1cb9382298b7a88075b844ad2127fc9727ea22b18b0711 + languageName: node + linkType: hard + +"globals@npm:^13.2.0": + version: 13.24.0 + resolution: "globals@npm:13.24.0" + dependencies: + type-fest: "npm:^0.20.2" + checksum: 10c0/d3c11aeea898eb83d5ec7a99508600fbe8f83d2cf00cbb77f873dbf2bcb39428eff1b538e4915c993d8a3b3473fa71eeebfe22c9bb3a3003d1e26b1f2c8a42cd + languageName: node + linkType: hard + +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"has-flag@npm:^3.0.0": + version: 3.0.0 + resolution: "has-flag@npm:3.0.0" + checksum: 10c0/1c6c83b14b8b1b3c25b0727b8ba3e3b647f99e9e6e13eb7322107261de07a4c1be56fc0d45678fc376e09772a3a1642ccdaf8fc69bdf123b6c086598397ce473 + languageName: node + linkType: hard + +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 + languageName: node + linkType: hard + +"has-unicode@npm:^2.0.1": + version: 2.0.1 + resolution: "has-unicode@npm:2.0.1" + checksum: 10c0/ebdb2f4895c26bb08a8a100b62d362e49b2190bcfd84b76bc4be1a3bd4d254ec52d0dd9f2fbcc093fc5eb878b20c52146f9dfd33e2686ed28982187be593b47c + languageName: node + linkType: hard + +"htmlnano@npm:^2.0.0": + version: 2.1.0 + resolution: "htmlnano@npm:2.1.0" + dependencies: + cosmiconfig: "npm:^8.0.0" + posthtml: "npm:^0.16.5" + timsort: "npm:^0.3.0" + peerDependencies: + cssnano: ^6.0.0 + postcss: ^8.3.11 + purgecss: ^5.0.0 + relateurl: ^0.2.7 + srcset: 4.0.0 + svgo: ^3.0.2 + terser: ^5.10.0 + uncss: ^0.17.3 + peerDependenciesMeta: + cssnano: + optional: true + postcss: + optional: true + purgecss: + optional: true + relateurl: + optional: true + srcset: + optional: true + svgo: + optional: true + terser: + optional: true + uncss: + optional: true + checksum: 10c0/33e78a18e044c6db671626babfdab60bd483c432164e6e38ef70c895a5698a91215972ebf2dbd7f7f8c05fbac80fa169ee1dde4bc0f1427d7dc3c162e0300610 + languageName: node + linkType: hard + +"htmlparser2@npm:^7.1.1": + version: 7.2.0 + resolution: "htmlparser2@npm:7.2.0" + dependencies: + domelementtype: "npm:^2.0.1" + domhandler: "npm:^4.2.2" + domutils: "npm:^2.8.0" + entities: "npm:^3.0.1" + checksum: 10c0/7e1fa7f3b2635f2a1c5272765e25aab33b241d84a43e9d27f28a0b7166b51a8025dec40a6a29af38d6a698a2f1d2983cb43e5c61d4e07ec5aa9df672a7460e16 + languageName: node + linkType: hard + +"http-cache-semantics@npm:^4.1.1": + version: 4.1.1 + resolution: "http-cache-semantics@npm:4.1.1" + checksum: 10c0/ce1319b8a382eb3cbb4a37c19f6bfe14e5bb5be3d09079e885e8c513ab2d3cd9214902f8a31c9dc4e37022633ceabfc2d697405deeaf1b8f3552bb4ed996fdfc + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.1": + version: 7.0.4 + resolution: "https-proxy-agent@npm:7.0.4" + dependencies: + agent-base: "npm:^7.0.2" + debug: "npm:4" + checksum: 10c0/bc4f7c38da32a5fc622450b6cb49a24ff596f9bd48dcedb52d2da3fa1c1a80e100fb506bd59b326c012f21c863c69b275c23de1a01d0b84db396822fdf25e52b + languageName: node + linkType: hard + +"human-signals@npm:^5.0.0": + version: 5.0.0 + resolution: "human-signals@npm:5.0.0" + checksum: 10c0/5a9359073fe17a8b58e5a085e9a39a950366d9f00217c4ff5878bd312e09d80f460536ea6a3f260b5943a01fe55c158d1cea3fc7bee3d0520aeef04f6d915c82 + languageName: node + linkType: hard + +"iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 + languageName: node + linkType: hard + +"immutable@npm:^4.0.0": + version: 4.3.5 + resolution: "immutable@npm:4.3.5" + checksum: 10c0/63d2d7908241a955d18c7822fd2215b6e89ff5a1a33cc72cd475b013cbbdef7a705aa5170a51ce9f84a57f62fdddfaa34e7b5a14b33d8a43c65cc6a881d6e894 + languageName: node + linkType: hard + +"import-fresh@npm:^3.3.0": + version: 3.3.0 + resolution: "import-fresh@npm:3.3.0" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/7f882953aa6b740d1f0e384d0547158bc86efbf2eea0f1483b8900a6f65c5a5123c2cf09b0d542cc419d0b98a759ecaeb394237e97ea427f2da221dc3cd80cc3 + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 + languageName: node + linkType: hard + +"indent-string@npm:^4.0.0": + version: 4.0.0 + resolution: "indent-string@npm:4.0.0" + checksum: 10c0/1e1904ddb0cb3d6cce7cd09e27a90184908b7a5d5c21b92e232c93579d314f0b83c246ffb035493d0504b1e9147ba2c9b21df0030f48673fba0496ecd698161f + languageName: node + linkType: hard + +"inherits@npm:^2.0.3": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"ini@npm:~1.3.0": + version: 1.3.8 + resolution: "ini@npm:1.3.8" + checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a + languageName: node + linkType: hard + +"ip-address@npm:^9.0.5": + version: 9.0.5 + resolution: "ip-address@npm:9.0.5" + dependencies: + jsbn: "npm:1.1.0" + sprintf-js: "npm:^1.1.3" + checksum: 10c0/331cd07fafcb3b24100613e4b53e1a2b4feab11e671e655d46dc09ee233da5011284d09ca40c4ecbdfe1d0004f462958675c224a804259f2f78d2465a87824bc + languageName: node + linkType: hard + +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: 10c0/e7fb686a739068bb70f860b39b67afc62acc62e36bb61c5f965768abce1873b379c563e61dd2adad96ebb7edf6651111b385e490cf508378959b0ed4cac4e729 + languageName: node + linkType: hard + +"is-arrayish@npm:^0.3.1": + version: 0.3.2 + resolution: "is-arrayish@npm:0.3.2" + checksum: 10c0/f59b43dc1d129edb6f0e282595e56477f98c40278a2acdc8b0a5c57097c9eff8fe55470493df5775478cf32a4dc8eaf6d3a749f07ceee5bc263a78b2434f6a54 + languageName: node + linkType: hard + +"is-binary-path@npm:~2.1.0": + version: 2.1.0 + resolution: "is-binary-path@npm:2.1.0" + dependencies: + binary-extensions: "npm:^2.0.0" + checksum: 10c0/a16eaee59ae2b315ba36fad5c5dcaf8e49c3e27318f8ab8fa3cdb8772bf559c8d1ba750a589c2ccb096113bb64497084361a25960899cb6172a6925ab6123d38 + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 + resolution: "is-fullwidth-code-point@npm:3.0.0" + checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc + languageName: node + linkType: hard + +"is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: "npm:^2.1.1" + checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a + languageName: node + linkType: hard + +"is-json@npm:^2.0.1": + version: 2.0.1 + resolution: "is-json@npm:2.0.1" + checksum: 10c0/49233aa560396e6365186be2f3a4618bf8b8067c1a97f2a25b8de09a9d7f326985f0163508067abeae5a21c69594a2a537f0147a5c4050ef097c15964e994cb4 + languageName: node + linkType: hard + +"is-lambda@npm:^1.0.1": + version: 1.0.1 + resolution: "is-lambda@npm:1.0.1" + checksum: 10c0/85fee098ae62ba6f1e24cf22678805473c7afd0fb3978a3aa260e354cb7bcb3a5806cf0a98403188465efedec41ab4348e8e4e79305d409601323855b3839d4d + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 + languageName: node + linkType: hard + +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 10c0/eb2f7127af02ee9aa2a0237b730e47ac2de0d4e76a4a905a50a11557f2339df5765eaea4ceb8029f1efa978586abe776908720bfcb1900c20c6ec5145f6f29d8 + languageName: node + linkType: hard + +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d + languageName: node + linkType: hard + +"isexe@npm:^3.1.1": + version: 3.1.1 + resolution: "isexe@npm:3.1.1" + checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7 + languageName: node + linkType: hard + +"jackspeak@npm:^2.3.6": + version: 2.3.6 + resolution: "jackspeak@npm:2.3.6" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 10c0/f01d8f972d894cd7638bc338e9ef5ddb86f7b208ce177a36d718eac96ec86638a6efa17d0221b10073e64b45edc2ce15340db9380b1f5d5c5d000cbc517dc111 + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + +"js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" + dependencies: + argparse: "npm:^2.0.1" + bin: + js-yaml: bin/js-yaml.js + checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + languageName: node + linkType: hard + +"jsbn@npm:1.1.0": + version: 1.1.0 + resolution: "jsbn@npm:1.1.0" + checksum: 10c0/4f907fb78d7b712e11dea8c165fe0921f81a657d3443dde75359ed52eb2b5d33ce6773d97985a089f09a65edd80b11cb75c767b57ba47391fee4c969f7215c96 + languageName: node + linkType: hard + +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3 + languageName: node + linkType: hard + +"json5@npm:^2.2.0, json5@npm:^2.2.1": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c + languageName: node + linkType: hard + +"jsonfile@npm:^6.0.1": + version: 6.1.0 + resolution: "jsonfile@npm:6.1.0" + dependencies: + graceful-fs: "npm:^4.1.6" + universalify: "npm:^2.0.0" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10c0/4f95b5e8a5622b1e9e8f33c96b7ef3158122f595998114d1e7f03985649ea99cb3cd99ce1ed1831ae94c8c8543ab45ebd044207612f31a56fd08462140e46865 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-darwin-arm64@npm:1.24.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-darwin-x64@npm:1.24.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-freebsd-x64@npm:1.24.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.24.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-linux-arm64-gnu@npm:1.24.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-linux-arm64-musl@npm:1.24.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-linux-x64-gnu@npm:1.24.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-linux-x64-musl@npm:1.24.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.24.1": + version: 1.24.1 + resolution: "lightningcss-win32-x64-msvc@npm:1.24.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:^1.22.1": + version: 1.24.1 + resolution: "lightningcss@npm:1.24.1" + dependencies: + detect-libc: "npm:^1.0.3" + lightningcss-darwin-arm64: "npm:1.24.1" + lightningcss-darwin-x64: "npm:1.24.1" + lightningcss-freebsd-x64: "npm:1.24.1" + lightningcss-linux-arm-gnueabihf: "npm:1.24.1" + lightningcss-linux-arm64-gnu: "npm:1.24.1" + lightningcss-linux-arm64-musl: "npm:1.24.1" + lightningcss-linux-x64-gnu: "npm:1.24.1" + lightningcss-linux-x64-musl: "npm:1.24.1" + lightningcss-win32-x64-msvc: "npm:1.24.1" + dependenciesMeta: + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/6fe2cd1bc92d431195ecb8bb9ebb98fc69010c04436354e0493b0a955d81823e6a2b114a4518ab46ad4eefc10606b51ca157adce2909e09e63b21002ccca93d3 + languageName: node + linkType: hard + +"lines-and-columns@npm:^1.1.6": + version: 1.2.4 + resolution: "lines-and-columns@npm:1.2.4" + checksum: 10c0/3da6ee62d4cd9f03f5dc90b4df2540fb85b352081bee77fe4bbcd12c9000ead7f35e0a38b8d09a9bb99b13223446dd8689ff3c4959807620726d788701a83d2d + languageName: node + linkType: hard + +"lmdb@npm:2.8.5": + version: 2.8.5 + resolution: "lmdb@npm:2.8.5" + dependencies: + "@lmdb/lmdb-darwin-arm64": "npm:2.8.5" + "@lmdb/lmdb-darwin-x64": "npm:2.8.5" + "@lmdb/lmdb-linux-arm": "npm:2.8.5" + "@lmdb/lmdb-linux-arm64": "npm:2.8.5" + "@lmdb/lmdb-linux-x64": "npm:2.8.5" + "@lmdb/lmdb-win32-x64": "npm:2.8.5" + msgpackr: "npm:^1.9.5" + node-addon-api: "npm:^6.1.0" + node-gyp: "npm:latest" + node-gyp-build-optional-packages: "npm:5.1.1" + ordered-binary: "npm:^1.4.1" + weak-lru-cache: "npm:^1.2.2" + dependenciesMeta: + "@lmdb/lmdb-darwin-arm64": + optional: true + "@lmdb/lmdb-darwin-x64": + optional: true + "@lmdb/lmdb-linux-arm": + optional: true + "@lmdb/lmdb-linux-arm64": + optional: true + "@lmdb/lmdb-linux-x64": + optional: true + "@lmdb/lmdb-win32-x64": + optional: true + bin: + download-lmdb-prebuilds: bin/download-prebuilds.js + checksum: 10c0/5c95ae636611f32d3583b26bca0d4b0dc236378f785b5735420edda62f88ddacc17c7586d586779a49f3377422c85c3e0b416c4a47f1c21945f76f001551afc9 + languageName: node + linkType: hard + +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb + languageName: node + linkType: hard + +"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": + version: 10.2.0 + resolution: "lru-cache@npm:10.2.0" + checksum: 10c0/c9847612aa2daaef102d30542a8d6d9b2c2bb36581c1bf0dc3ebf5e5f3352c772a749e604afae2e46873b930a9e9523743faac4e5b937c576ab29196774712ee + languageName: node + linkType: hard + +"lru-cache@npm:^6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9 + languageName: node + linkType: hard + +"make-fetch-happen@npm:^13.0.0": + version: 13.0.0 + resolution: "make-fetch-happen@npm:13.0.0" + dependencies: + "@npmcli/agent": "npm:^2.0.0" + cacache: "npm:^18.0.0" + http-cache-semantics: "npm:^4.1.1" + is-lambda: "npm:^1.0.1" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^3.0.0" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^0.6.3" + promise-retry: "npm:^2.0.1" + ssri: "npm:^10.0.0" + checksum: 10c0/43b9f6dcbc6fe8b8604cb6396957c3698857a15ba4dbc38284f7f0e61f248300585ef1eb8cc62df54e9c724af977e45b5cdfd88320ef7f53e45070ed3488da55 + languageName: node + linkType: hard + +"mdn-data@npm:2.0.14": + version: 2.0.14 + resolution: "mdn-data@npm:2.0.14" + checksum: 10c0/67241f8708c1e665a061d2b042d2d243366e93e5bf1f917693007f6d55111588b952dcbfd3ea9c2d0969fb754aad81b30fdcfdcc24546495fc3b24336b28d4bd + languageName: node + linkType: hard + +"memory-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "memory-stream@npm:1.0.0" + dependencies: + readable-stream: "npm:^3.4.0" + checksum: 10c0/a2d9abd35845b228055ce5424dbdd8478711ba41325d02e6c8ef9baeba557287d4493a6e74d3db5c9849c58ea13fdc1dd445c96f469cbd02f47d22cfba930306 + languageName: node + linkType: hard + +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + languageName: node + linkType: hard + +"micromatch@npm:^4.0.5": + version: 4.0.5 + resolution: "micromatch@npm:4.0.5" + dependencies: + braces: "npm:^3.0.2" + picomatch: "npm:^2.3.1" + checksum: 10c0/3d6505b20f9fa804af5d8c596cb1c5e475b9b0cd05f652c5b56141cf941bd72adaeb7a436fda344235cef93a7f29b7472efc779fcdb83b478eab0867b95cdeff + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 10c0/de9cc32be9996fd941e512248338e43407f63f6d497abe8441fa33447d922e927de54d4cc3c1a3c6d652857acd770389d5a3823f311a744132760ce2be15ccbf + languageName: node + linkType: hard + +"minimatch@npm:^9.0.1": + version: 9.0.4 + resolution: "minimatch@npm:9.0.4" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10c0/2c16f21f50e64922864e560ff97c587d15fd491f65d92a677a344e970fe62aafdbeafe648965fa96d33c061b4d0eabfe0213466203dd793367e7f28658cf6414 + languageName: node + linkType: hard + +"minimist@npm:^1.2.0": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 + languageName: node + linkType: hard + +"minipass-collect@npm:^2.0.1": + version: 2.0.1 + resolution: "minipass-collect@npm:2.0.1" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e + languageName: node + linkType: hard + +"minipass-fetch@npm:^3.0.0": + version: 3.0.4 + resolution: "minipass-fetch@npm:3.0.4" + dependencies: + encoding: "npm:^0.1.13" + minipass: "npm:^7.0.3" + minipass-sized: "npm:^1.0.3" + minizlib: "npm:^2.1.2" + dependenciesMeta: + encoding: + optional: true + checksum: 10c0/1b63c1f3313e88eeac4689f1b71c9f086598db9a189400e3ee960c32ed89e06737fa23976c9305c2d57464fb3fcdc12749d3378805c9d6176f5569b0d0ee8a75 + languageName: node + linkType: hard + +"minipass-flush@npm:^1.0.5": + version: 1.0.5 + resolution: "minipass-flush@npm:1.0.5" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd + languageName: node + linkType: hard + +"minipass-pipeline@npm:^1.2.4": + version: 1.2.4 + resolution: "minipass-pipeline@npm:1.2.4" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2 + languageName: node + linkType: hard + +"minipass-sized@npm:^1.0.3": + version: 1.0.3 + resolution: "minipass-sized@npm:1.0.3" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/298f124753efdc745cfe0f2bdfdd81ba25b9f4e753ca4a2066eb17c821f25d48acea607dfc997633ee5bf7b6dfffb4eee4f2051eb168663f0b99fad2fa4829cb + languageName: node + linkType: hard + +"minipass@npm:^3.0.0": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c + languageName: node + linkType: hard + +"minipass@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass@npm:5.0.0" + checksum: 10c0/a91d8043f691796a8ac88df039da19933ef0f633e3d7f0d35dcd5373af49131cf2399bfc355f41515dc495e3990369c3858cd319e5c2722b4753c90bf3152462 + languageName: node + linkType: hard + +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4": + version: 7.0.4 + resolution: "minipass@npm:7.0.4" + checksum: 10c0/6c7370a6dfd257bf18222da581ba89a5eaedca10e158781232a8b5542a90547540b4b9b7e7f490e4cda43acfbd12e086f0453728ecf8c19e0ef6921bc5958ac5 + languageName: node + linkType: hard + +"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": + version: 2.1.2 + resolution: "minizlib@npm:2.1.2" + dependencies: + minipass: "npm:^3.0.0" + yallist: "npm:^4.0.0" + checksum: 10c0/64fae024e1a7d0346a1102bb670085b17b7f95bf6cfdf5b128772ec8faf9ea211464ea4add406a3a6384a7d87a0cd1a96263692134323477b4fb43659a6cab78 + languageName: node + linkType: hard + +"mkdirp@npm:^1.0.3": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: 10c0/46ea0f3ffa8bc6a5bc0c7081ffc3907777f0ed6516888d40a518c5111f8366d97d2678911ad1a6882bf592fa9de6c784fea32e1687bb94e1f4944170af48a5cf + languageName: node + linkType: hard + +"mnemonist@npm:^0.39.5": + version: 0.39.8 + resolution: "mnemonist@npm:0.39.8" + dependencies: + obliterator: "npm:^2.0.1" + checksum: 10c0/fa810768d290919c4ecd3f8ba5c8458bc45df08d1c72fac8f3897721cd90ab42ee1c642cc5208cfd649d40222998dc011127702117c0ca676f243cc80f42cc11 + languageName: node + linkType: hard + +"ms@npm:2.1.2": + version: 2.1.2 + resolution: "ms@npm:2.1.2" + checksum: 10c0/a437714e2f90dbf881b5191d35a6db792efbca5badf112f87b9e1c712aace4b4b9b742dd6537f3edf90fd6f684de897cec230abde57e87883766712ddda297cc + languageName: node + linkType: hard + +"msgpackr-extract@npm:^3.0.2": + version: 3.0.2 + resolution: "msgpackr-extract@npm:3.0.2" + dependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.2" + "@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.2" + "@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.2" + "@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.2" + "@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.2" + "@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.2" + node-gyp: "npm:latest" + node-gyp-build-optional-packages: "npm:5.0.7" + dependenciesMeta: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": + optional: true + "@msgpackr-extract/msgpackr-extract-darwin-x64": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-arm": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-arm64": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-x64": + optional: true + "@msgpackr-extract/msgpackr-extract-win32-x64": + optional: true + bin: + download-msgpackr-prebuilds: bin/download-prebuilds.js + checksum: 10c0/f14727e0121c241a11cf75824f87822c0a08d65e6b8eba8a3fbf26c7d7287ea3f8ca3ab76887fda781a203bd16e51705207d82593ba6f06abca3181c743a352d + languageName: node + linkType: hard + +"msgpackr@npm:^1.9.5, msgpackr@npm:^1.9.9": + version: 1.10.1 + resolution: "msgpackr@npm:1.10.1" + dependencies: + msgpackr-extract: "npm:^3.0.2" + dependenciesMeta: + msgpackr-extract: + optional: true + checksum: 10c0/2e6ed91af89ec15d1e5595c5b837a4adcbb185b0fbd4773d728ced89ab4abbdd3401f6777b193d487d9807e1cb0cf3da1ba9a0bd2d5a553e22355cea84a36bab + languageName: node + linkType: hard + +"negotiator@npm:^0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 + languageName: node + linkType: hard + +"node-addon-api@npm:^6.0.0, node-addon-api@npm:^6.1.0": + version: 6.1.0 + resolution: "node-addon-api@npm:6.1.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/d2699c4ad15740fd31482a3b6fca789af7723ab9d393adc6ac45250faaee72edad8f0b10b2b9d087df0de93f1bdc16d97afdd179b26b9ebc9ed68b569faa4bac + languageName: node + linkType: hard + +"node-addon-api@npm:^7.0.0": + version: 7.1.0 + resolution: "node-addon-api@npm:7.1.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/2e096ab079e3c46d33b0e252386e9c239c352f7cc6d75363d9a3c00bdff34c1a5da170da861917512843f213c32d024ced9dc9552b968029786480d18727ec66 + languageName: node + linkType: hard + +"node-api-headers@npm:^1.1.0": + version: 1.1.0 + resolution: "node-api-headers@npm:1.1.0" + checksum: 10c0/7806d71077348ea199034e8c90a9147038d37fcccc1b85717e48c095fe31783a4f909f5daced4506e6cbce93fba91220bb3fc8626ee0640d26de9860f6500174 + languageName: node + linkType: hard + +"node-gyp-build-optional-packages@npm:5.0.7": + version: 5.0.7 + resolution: "node-gyp-build-optional-packages@npm:5.0.7" + bin: + node-gyp-build-optional-packages: bin.js + node-gyp-build-optional-packages-optional: optional.js + node-gyp-build-optional-packages-test: build-test.js + checksum: 10c0/e0edb57358dfa8e31c26b38310ddc5ae81d19fd13b3bf095c41215dfd6a033b1269b510c3ce5e73f7a4ed3d36f101ea47716ec75be38f5e31916d185e7f18905 + languageName: node + linkType: hard + +"node-gyp-build-optional-packages@npm:5.1.1": + version: 5.1.1 + resolution: "node-gyp-build-optional-packages@npm:5.1.1" + dependencies: + detect-libc: "npm:^2.0.1" + bin: + node-gyp-build-optional-packages: bin.js + node-gyp-build-optional-packages-optional: optional.js + node-gyp-build-optional-packages-test: build-test.js + checksum: 10c0/f9fad2061c48fb0fc90831cd11d6a7670d731d22a5b00c7d3441b43b4003543299ff64ff2729afe2cefd7d14928e560d469336e5bb00f613932ec2cd56b3665b + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 10.1.0 + resolution: "node-gyp@npm:10.1.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.6" + make-fetch-happen: "npm:^13.0.0" + nopt: "npm:^7.0.0" + proc-log: "npm:^3.0.0" + semver: "npm:^7.3.5" + tar: "npm:^6.1.2" + which: "npm:^4.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/9cc821111ca244a01fb7f054db7523ab0a0cd837f665267eb962eb87695d71fb1e681f9e21464cc2fd7c05530dc4c81b810bca1a88f7d7186909b74477491a3c + languageName: node + linkType: hard + +"node-releases@npm:^2.0.14": + version: 2.0.14 + resolution: "node-releases@npm:2.0.14" + checksum: 10c0/199fc93773ae70ec9969bc6d5ac5b2bbd6eb986ed1907d751f411fef3ede0e4bfdb45ceb43711f8078bea237b6036db8b1bf208f6ff2b70c7d615afd157f3ab9 + languageName: node + linkType: hard + +"nopt@npm:^7.0.0": + version: 7.2.0 + resolution: "nopt@npm:7.2.0" + dependencies: + abbrev: "npm:^2.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/9bd7198df6f16eb29ff16892c77bcf7f0cc41f9fb5c26280ac0def2cf8cf319f3b821b3af83eba0e74c85807cc430a16efe0db58fe6ae1f41e69519f585b6aff + languageName: node + linkType: hard + +"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": + version: 3.0.0 + resolution: "normalize-path@npm:3.0.0" + checksum: 10c0/e008c8142bcc335b5e38cf0d63cfd39d6cf2d97480af9abdbe9a439221fd4d749763bab492a8ee708ce7a194bb00c9da6d0a115018672310850489137b3da046 + languageName: node + linkType: hard + +"npm-run-path@npm:^5.1.0": + version: 5.3.0 + resolution: "npm-run-path@npm:5.3.0" + dependencies: + path-key: "npm:^4.0.0" + checksum: 10c0/124df74820c40c2eb9a8612a254ea1d557ddfab1581c3e751f825e3e366d9f00b0d76a3c94ecd8398e7f3eee193018622677e95816e8491f0797b21e30b2deba + languageName: node + linkType: hard + +"npmlog@npm:^6.0.2": + version: 6.0.2 + resolution: "npmlog@npm:6.0.2" + dependencies: + are-we-there-yet: "npm:^3.0.0" + console-control-strings: "npm:^1.1.0" + gauge: "npm:^4.0.3" + set-blocking: "npm:^2.0.0" + checksum: 10c0/0cacedfbc2f6139c746d9cd4a85f62718435ad0ca4a2d6459cd331dd33ae58206e91a0742c1558634efcde3f33f8e8e7fd3adf1bfe7978310cf00bd55cccf890 + languageName: node + linkType: hard + +"nth-check@npm:^2.0.1": + version: 2.1.1 + resolution: "nth-check@npm:2.1.1" + dependencies: + boolbase: "npm:^1.0.0" + checksum: 10c0/5fee7ff309727763689cfad844d979aedd2204a817fbaaf0e1603794a7c20db28548d7b024692f953557df6ce4a0ee4ae46cd8ebd9b36cfb300b9226b567c479 + languageName: node + linkType: hard + +"nullthrows@npm:^1.1.1": + version: 1.1.1 + resolution: "nullthrows@npm:1.1.1" + checksum: 10c0/56f34bd7c3dcb3bd23481a277fa22918120459d3e9d95ca72976c72e9cac33a97483f0b95fc420e2eb546b9fe6db398273aba9a938650cdb8c98ee8f159dcb30 + languageName: node + linkType: hard + +"obliterator@npm:^2.0.1": + version: 2.0.4 + resolution: "obliterator@npm:2.0.4" + checksum: 10c0/ff2c10d4de7d62cd1d588b4d18dfc42f246c9e3a259f60d5716f7f88e5b3a3f79856b3207db96ec9a836a01d0958a21c15afa62a3f4e73a1e0b75f2c2f6bab40 + languageName: node + linkType: hard + +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: "npm:^4.0.0" + checksum: 10c0/4eef7c6abfef697dd4479345a4100c382d73c149d2d56170a54a07418c50816937ad09500e1ed1e79d235989d073a9bade8557122aee24f0576ecde0f392bb6c + languageName: node + linkType: hard + +"ordered-binary@npm:^1.4.1": + version: 1.5.1 + resolution: "ordered-binary@npm:1.5.1" + checksum: 10c0/fb4c74e07436d0bf33d3b537c18dccafb39a60750a64d8b8fbd55f0b0f8eb7dad710f663b9c2edd1d59e9a27e13b638099da901ecf1cc95cd40173f42cf70f9e + languageName: node + linkType: hard + +"p-map@npm:^4.0.0": + version: 4.0.0 + resolution: "p-map@npm:4.0.0" + dependencies: + aggregate-error: "npm:^3.0.0" + checksum: 10c0/592c05bd6262c466ce269ff172bb8de7c6975afca9b50c975135b974e9bdaafbfe80e61aaaf5be6d1200ba08b30ead04b88cfa7e25ff1e3b93ab28c9f62a2c75 + languageName: node + linkType: hard + +"parcel@npm:^2.12.0": + version: 2.12.0 + resolution: "parcel@npm:2.12.0" + dependencies: + "@parcel/config-default": "npm:2.12.0" + "@parcel/core": "npm:2.12.0" + "@parcel/diagnostic": "npm:2.12.0" + "@parcel/events": "npm:2.12.0" + "@parcel/fs": "npm:2.12.0" + "@parcel/logger": "npm:2.12.0" + "@parcel/package-manager": "npm:2.12.0" + "@parcel/reporter-cli": "npm:2.12.0" + "@parcel/reporter-dev-server": "npm:2.12.0" + "@parcel/reporter-tracer": "npm:2.12.0" + "@parcel/utils": "npm:2.12.0" + chalk: "npm:^4.1.0" + commander: "npm:^7.0.0" + get-port: "npm:^4.2.0" + bin: + parcel: lib/bin.js + checksum: 10c0/1853858c22cb728d3e3f524df04fbdc42aa27a0c8a3a0dbe2314d618ac13a3fe81836ce1560cdfce17338f61ec238d9b616073c181ab77af56664a0221af1b2a + languageName: node + linkType: hard + +"parent-module@npm:^1.0.0": + version: 1.0.1 + resolution: "parent-module@npm:1.0.1" + dependencies: + callsites: "npm:^3.0.0" + checksum: 10c0/c63d6e80000d4babd11978e0d3fee386ca7752a02b035fd2435960ffaa7219dc42146f07069fb65e6e8bf1caef89daf9af7535a39bddf354d78bf50d8294f556 + languageName: node + linkType: hard + +"parse-json@npm:^5.2.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": "npm:^7.0.0" + error-ex: "npm:^1.3.1" + json-parse-even-better-errors: "npm:^2.3.0" + lines-and-columns: "npm:^1.1.6" + checksum: 10c0/77947f2253005be7a12d858aedbafa09c9ae39eb4863adf330f7b416ca4f4a08132e453e08de2db46459256fb66afaac5ee758b44fe6541b7cdaf9d252e59585 + languageName: node + linkType: hard + +"path-key@npm:^3.1.0": + version: 3.1.1 + resolution: "path-key@npm:3.1.1" + checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c + languageName: node + linkType: hard + +"path-key@npm:^4.0.0": + version: 4.0.0 + resolution: "path-key@npm:4.0.0" + checksum: 10c0/794efeef32863a65ac312f3c0b0a99f921f3e827ff63afa5cb09a377e202c262b671f7b3832a4e64731003fa94af0263713962d317b9887bd1e0c48a342efba3 + languageName: node + linkType: hard + +"path-scurry@npm:^1.10.2": + version: 1.10.2 + resolution: "path-scurry@npm:1.10.2" + dependencies: + lru-cache: "npm:^10.2.0" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + checksum: 10c0/d723777fbf9627f201e64656680f66ebd940957eebacf780e6cce1c2919c29c116678b2d7dbf8821b3a2caa758d125f4444005ccec886a25c8f324504e48e601 + languageName: node + linkType: hard + +"path-type@npm:^4.0.0": + version: 4.0.0 + resolution: "path-type@npm:4.0.0" + checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c + languageName: node + linkType: hard + +"picocolors@npm:^1.0.0": + version: 1.0.0 + resolution: "picocolors@npm:1.0.0" + checksum: 10c0/20a5b249e331c14479d94ec6817a182fd7a5680debae82705747b2db7ec50009a5f6648d0621c561b0572703f84dbef0858abcbd5856d3c5511426afcb1961f7 + languageName: node + linkType: hard + +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be + languageName: node + linkType: hard + +"postcss-value-parser@npm:^4.2.0": + version: 4.2.0 + resolution: "postcss-value-parser@npm:4.2.0" + checksum: 10c0/f4142a4f56565f77c1831168e04e3effd9ffcc5aebaf0f538eee4b2d465adfd4b85a44257bb48418202a63806a7da7fe9f56c330aebb3cac898e46b4cbf49161 + languageName: node + linkType: hard + +"posthtml-parser@npm:^0.10.1": + version: 0.10.2 + resolution: "posthtml-parser@npm:0.10.2" + dependencies: + htmlparser2: "npm:^7.1.1" + checksum: 10c0/90c7c2e0892c18577a56a5dd60a54c40feb0be7c712a79f711e1730b5eea468f8d521d387af9f08d78e6bca9df613286c3ff8a95ac9426671cbe9021d7ec2ae5 + languageName: node + linkType: hard + +"posthtml-parser@npm:^0.11.0": + version: 0.11.0 + resolution: "posthtml-parser@npm:0.11.0" + dependencies: + htmlparser2: "npm:^7.1.1" + checksum: 10c0/89bf980a60124790f776a9f21aec0f154eba5412d16f0f3a95de7a53d31b9acb9264bf317ab40c080413e3018a8e65c86278e6e8c0731c8e0363418982ed4296 + languageName: node + linkType: hard + +"posthtml-render@npm:^3.0.0": + version: 3.0.0 + resolution: "posthtml-render@npm:3.0.0" + dependencies: + is-json: "npm:^2.0.1" + checksum: 10c0/7adb9c20d0908663019c3c2dede3f6cc8bd19c17c81a1f42a1d8772195be4e5252aeb72a764e92d3424aebfa8c5d35c7ef1ec25243a802d35897aa928858505b + languageName: node + linkType: hard + +"posthtml@npm:^0.16.4, posthtml@npm:^0.16.5": + version: 0.16.6 + resolution: "posthtml@npm:0.16.6" + dependencies: + posthtml-parser: "npm:^0.11.0" + posthtml-render: "npm:^3.0.0" + checksum: 10c0/0505cb70ece051206ffa932394181372be6390a974fd2f50e4e6fdd5d11e41feffba9a5f5e22809ca42899f79bd489d53ceac1d7ad0d782db9521b578e5b7f5a + languageName: node + linkType: hard + +"prettier@npm:^3.2.5": + version: 3.2.5 + resolution: "prettier@npm:3.2.5" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/ea327f37a7d46f2324a34ad35292af2ad4c4c3c3355da07313339d7e554320f66f65f91e856add8530157a733c6c4a897dc41b577056be5c24c40f739f5ee8c6 + languageName: node + linkType: hard + +"proc-log@npm:^3.0.0": + version: 3.0.0 + resolution: "proc-log@npm:3.0.0" + checksum: 10c0/f66430e4ff947dbb996058f6fd22de2c66612ae1a89b097744e17fb18a4e8e7a86db99eda52ccf15e53f00b63f4ec0b0911581ff2aac0355b625c8eac509b0dc + languageName: node + linkType: hard + +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" + dependencies: + err-code: "npm:^2.0.2" + retry: "npm:^0.12.0" + checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96 + languageName: node + linkType: hard + +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b + languageName: node + linkType: hard + +"rc@npm:^1.2.7": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: "npm:^0.6.0" + ini: "npm:~1.3.0" + minimist: "npm:^1.2.0" + strip-json-comments: "npm:~2.0.1" + bin: + rc: ./cli.js + checksum: 10c0/24a07653150f0d9ac7168e52943cc3cb4b7a22c0e43c7dff3219977c2fdca5a2760a304a029c20811a0e79d351f57d46c9bde216193a0f73978496afc2b85b15 + languageName: node + linkType: hard + +"react-error-overlay@npm:6.0.9": + version: 6.0.9 + resolution: "react-error-overlay@npm:6.0.9" + checksum: 10c0/02f51337f34589305f827249acb597446489794cc5b5e721a6260111325b56942a7471b76967cba304e797d7e4ef16dd0bd989c112dd0bb9586270df0d75a4a9 + languageName: node + linkType: hard + +"react-refresh@npm:^0.9.0": + version: 0.9.0 + resolution: "react-refresh@npm:0.9.0" + checksum: 10c0/fa20f605e19dc10342e5cec8dcbb88cd4a473d26a7ff0acf1f0402e78f94ec309837be07a3cc3646f88d19f9ed07fa13a275f4656b5e3ced8fa23ce488984609 + languageName: node + linkType: hard + +"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7 + languageName: node + linkType: hard + +"readdirp@npm:~3.6.0": + version: 3.6.0 + resolution: "readdirp@npm:3.6.0" + dependencies: + picomatch: "npm:^2.2.1" + checksum: 10c0/6fa848cf63d1b82ab4e985f4cf72bd55b7dcfd8e0a376905804e48c3634b7e749170940ba77b32804d5fe93b3cc521aa95a8d7e7d725f830da6d93f3669ce66b + languageName: node + linkType: hard + +"regenerator-runtime@npm:^0.13.7": + version: 0.13.11 + resolution: "regenerator-runtime@npm:0.13.11" + checksum: 10c0/12b069dc774001fbb0014f6a28f11c09ebfe3c0d984d88c9bced77fdb6fedbacbca434d24da9ae9371bfbf23f754869307fb51a4c98a8b8b18e5ef748677ca24 + languageName: node + linkType: hard + +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 + languageName: node + linkType: hard + +"resolve-from@npm:^4.0.0": + version: 4.0.0 + resolution: "resolve-from@npm:4.0.0" + checksum: 10c0/8408eec31a3112ef96e3746c37be7d64020cda07c03a920f5024e77290a218ea758b26ca9529fd7b1ad283947f34b2291c1c0f6aa0ed34acfdda9c6014c8d190 + languageName: node + linkType: hard + +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe + languageName: node + linkType: hard + +"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"sass@npm:^1.38.0": + version: 1.75.0 + resolution: "sass@npm:1.75.0" + dependencies: + chokidar: "npm:>=3.0.0 <4.0.0" + immutable: "npm:^4.0.0" + source-map-js: "npm:>=0.6.2 <2.0.0" + bin: + sass: sass.js + checksum: 10c0/1564ab2c8041c99a330cec93127fe8abcf65ac63eecb471610ed7f3126a2599a58b788a3a98eb8719f7f40b9b04e00c92bc9e11a9c2180ad582b8cba9fb030b0 + languageName: node + linkType: hard + +"semver@npm:^7.3.5, semver@npm:^7.5.2, semver@npm:^7.5.4, semver@npm:^7.6.0": + version: 7.6.0 + resolution: "semver@npm:7.6.0" + dependencies: + lru-cache: "npm:^6.0.0" + bin: + semver: bin/semver.js + checksum: 10c0/fbfe717094ace0aa8d6332d7ef5ce727259815bd8d8815700853f4faf23aacbd7192522f0dc5af6df52ef4fa85a355ebd2f5d39f554bd028200d6cf481ab9b53 + languageName: node + linkType: hard + +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454 + languageName: node + linkType: hard + +"sharp@npm:^0.33.3": + version: 0.33.3 + resolution: "sharp@npm:0.33.3" + dependencies: + "@img/sharp-darwin-arm64": "npm:0.33.3" + "@img/sharp-darwin-x64": "npm:0.33.3" + "@img/sharp-libvips-darwin-arm64": "npm:1.0.2" + "@img/sharp-libvips-darwin-x64": "npm:1.0.2" + "@img/sharp-libvips-linux-arm": "npm:1.0.2" + "@img/sharp-libvips-linux-arm64": "npm:1.0.2" + "@img/sharp-libvips-linux-s390x": "npm:1.0.2" + "@img/sharp-libvips-linux-x64": "npm:1.0.2" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.0.2" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.0.2" + "@img/sharp-linux-arm": "npm:0.33.3" + "@img/sharp-linux-arm64": "npm:0.33.3" + "@img/sharp-linux-s390x": "npm:0.33.3" + "@img/sharp-linux-x64": "npm:0.33.3" + "@img/sharp-linuxmusl-arm64": "npm:0.33.3" + "@img/sharp-linuxmusl-x64": "npm:0.33.3" + "@img/sharp-wasm32": "npm:0.33.3" + "@img/sharp-win32-ia32": "npm:0.33.3" + "@img/sharp-win32-x64": "npm:0.33.3" + color: "npm:^4.2.3" + detect-libc: "npm:^2.0.3" + semver: "npm:^7.6.0" + dependenciesMeta: + "@img/sharp-darwin-arm64": + optional: true + "@img/sharp-darwin-x64": + optional: true + "@img/sharp-libvips-darwin-arm64": + optional: true + "@img/sharp-libvips-darwin-x64": + optional: true + "@img/sharp-libvips-linux-arm": + optional: true + "@img/sharp-libvips-linux-arm64": + optional: true + "@img/sharp-libvips-linux-s390x": + optional: true + "@img/sharp-libvips-linux-x64": + optional: true + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + "@img/sharp-libvips-linuxmusl-x64": + optional: true + "@img/sharp-linux-arm": + optional: true + "@img/sharp-linux-arm64": + optional: true + "@img/sharp-linux-s390x": + optional: true + "@img/sharp-linux-x64": + optional: true + "@img/sharp-linuxmusl-arm64": + optional: true + "@img/sharp-linuxmusl-x64": + optional: true + "@img/sharp-wasm32": + optional: true + "@img/sharp-win32-ia32": + optional: true + "@img/sharp-win32-x64": + optional: true + checksum: 10c0/12f5203426595b4e64c807162a6d52358b591d25fbb414a51fe38861584759fba38485be951ed98d15be3dfe21f2def5336f78ca35bf8bbd22d88cc78ca03f2a + languageName: node + linkType: hard + +"shebang-command@npm:^2.0.0": + version: 2.0.0 + resolution: "shebang-command@npm:2.0.0" + dependencies: + shebang-regex: "npm:^3.0.0" + checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e + languageName: node + linkType: hard + +"shebang-regex@npm:^3.0.0": + version: 3.0.0 + resolution: "shebang-regex@npm:3.0.0" + checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690 + languageName: node + linkType: hard + +"signal-exit@npm:^3.0.7": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 + languageName: node + linkType: hard + +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 + languageName: node + linkType: hard + +"simple-swizzle@npm:^0.2.2": + version: 0.2.2 + resolution: "simple-swizzle@npm:0.2.2" + dependencies: + is-arrayish: "npm:^0.3.1" + checksum: 10c0/df5e4662a8c750bdba69af4e8263c5d96fe4cd0f9fe4bdfa3cbdeb45d2e869dff640beaaeb1ef0e99db4d8d2ec92f85508c269f50c972174851bc1ae5bd64308 + languageName: node + linkType: hard + +"smart-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "smart-buffer@npm:4.2.0" + checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539 + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^8.0.3": + version: 8.0.3 + resolution: "socks-proxy-agent@npm:8.0.3" + dependencies: + agent-base: "npm:^7.1.1" + debug: "npm:^4.3.4" + socks: "npm:^2.7.1" + checksum: 10c0/4950529affd8ccd6951575e21c1b7be8531b24d924aa4df3ee32df506af34b618c4e50d261f4cc603f1bfd8d426915b7d629966c8ce45b05fb5ad8c8b9a6459d + languageName: node + linkType: hard + +"socks@npm:^2.7.1": + version: 2.8.3 + resolution: "socks@npm:2.8.3" + dependencies: + ip-address: "npm:^9.0.5" + smart-buffer: "npm:^4.2.0" + checksum: 10c0/d54a52bf9325165770b674a67241143a3d8b4e4c8884560c4e0e078aace2a728dffc7f70150660f51b85797c4e1a3b82f9b7aa25e0a0ceae1a243365da5c51a7 + languageName: node + linkType: hard + +"source-map-js@npm:>=0.6.2 <2.0.0": + version: 1.2.0 + resolution: "source-map-js@npm:1.2.0" + checksum: 10c0/7e5f896ac10a3a50fe2898e5009c58ff0dc102dcb056ed27a354623a0ece8954d4b2649e1a1b2b52ef2e161d26f8859c7710350930751640e71e374fe2d321a4 + languageName: node + linkType: hard + +"source-map@npm:^0.6.1": + version: 0.6.1 + resolution: "source-map@npm:0.6.1" + checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 + languageName: node + linkType: hard + +"split@npm:^1.0.1": + version: 1.0.1 + resolution: "split@npm:1.0.1" + dependencies: + through: "npm:2" + checksum: 10c0/7f489e7ed5ff8a2e43295f30a5197ffcb2d6202c9cf99357f9690d645b19c812bccf0be3ff336fea5054cda17ac96b91d67147d95dbfc31fbb5804c61962af85 + languageName: node + linkType: hard + +"sprintf-js@npm:^1.1.3": + version: 1.1.3 + resolution: "sprintf-js@npm:1.1.3" + checksum: 10c0/09270dc4f30d479e666aee820eacd9e464215cdff53848b443964202bf4051490538e5dd1b42e1a65cf7296916ca17640aebf63dae9812749c7542ee5f288dec + languageName: node + linkType: hard + +"srcset@npm:4": + version: 4.0.0 + resolution: "srcset@npm:4.0.0" + checksum: 10c0/0685c3bd2423b33831734fb71560cd8784f024895e70ee2ac2c392e30047c27ffd9481e001950fb0503f4906bc3fe963145935604edad77944d09c9800990660 + languageName: node + linkType: hard + +"ssri@npm:^10.0.0": + version: 10.0.5 + resolution: "ssri@npm:10.0.5" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/b091f2ae92474183c7ac5ed3f9811457e1df23df7a7e70c9476eaa9a0c4a0c8fc190fb45acefbf023ca9ee864dd6754237a697dc52a0fb182afe65d8e77443d8 + languageName: node + linkType: hard + +"stable@npm:^0.1.8": + version: 0.1.8 + resolution: "stable@npm:0.1.8" + checksum: 10c0/df74b5883075076e78f8e365e4068ecd977af6c09da510cfc3148a303d4b87bc9aa8f7c48feb67ed4ef970b6140bd9eabba2129e28024aa88df5ea0114cba39d + languageName: node + linkType: hard + +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: "npm:^8.0.0" + is-fullwidth-code-point: "npm:^3.0.0" + strip-ansi: "npm:^6.0.1" + checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b + languageName: node + linkType: hard + +"string-width@npm:^5.0.1, string-width@npm:^5.1.2": + version: 5.1.2 + resolution: "string-width@npm:5.1.2" + dependencies: + eastasianwidth: "npm:^0.2.0" + emoji-regex: "npm:^9.2.2" + strip-ansi: "npm:^7.0.1" + checksum: 10c0/ab9c4264443d35b8b923cbdd513a089a60de339216d3b0ed3be3ba57d6880e1a192b70ae17225f764d7adbf5994e9bb8df253a944736c15a0240eff553c678ca + languageName: node + linkType: hard + +"string_decoder@npm:^1.1.1": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d + languageName: node + linkType: hard + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: "npm:^5.0.1" + checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 + languageName: node + linkType: hard + +"strip-ansi@npm:^7.0.1": + version: 7.1.0 + resolution: "strip-ansi@npm:7.1.0" + dependencies: + ansi-regex: "npm:^6.0.1" + checksum: 10c0/a198c3762e8832505328cbf9e8c8381de14a4fa50a4f9b2160138158ea88c0f5549fb50cb13c651c3088f47e63a108b34622ec18c0499b6c8c3a5ddf6b305ac4 + languageName: node + linkType: hard + +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 10c0/a771a17901427bac6293fd416db7577e2bc1c34a19d38351e9d5478c3c415f523f391003b42ed475f27e33a78233035df183525395f731d3bfb8cdcbd4da08ce + languageName: node + linkType: hard + +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 10c0/b509231cbdee45064ff4f9fd73609e2bcc4e84a4d508e9dd0f31f70356473fde18abfb5838c17d56fb236f5a06b102ef115438de0600b749e818a35fbbc48c43 + languageName: node + linkType: hard + +"supports-color@npm:^5.3.0": + version: 5.5.0 + resolution: "supports-color@npm:5.5.0" + dependencies: + has-flag: "npm:^3.0.0" + checksum: 10c0/6ae5ff319bfbb021f8a86da8ea1f8db52fac8bd4d499492e30ec17095b58af11f0c55f8577390a749b1c4dde691b6a0315dab78f5f54c9b3d83f8fb5905c1c05 + languageName: node + linkType: hard + +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 + languageName: node + linkType: hard + +"svgo@npm:^2.4.0": + version: 2.8.0 + resolution: "svgo@npm:2.8.0" + dependencies: + "@trysound/sax": "npm:0.2.0" + commander: "npm:^7.2.0" + css-select: "npm:^4.1.3" + css-tree: "npm:^1.1.3" + csso: "npm:^4.2.0" + picocolors: "npm:^1.0.0" + stable: "npm:^0.1.8" + bin: + svgo: bin/svgo + checksum: 10c0/0741f5d5cad63111a90a0ce7a1a5a9013f6d293e871b75efe39addb57f29a263e45294e485a4d2ff9cc260a5d142c8b5937b2234b4ef05efdd2706fb2d360ecc + languageName: node + linkType: hard + +"tar@npm:^6.1.11, tar@npm:^6.1.2, tar@npm:^6.2.0": + version: 6.2.1 + resolution: "tar@npm:6.2.1" + dependencies: + chownr: "npm:^2.0.0" + fs-minipass: "npm:^2.0.0" + minipass: "npm:^5.0.0" + minizlib: "npm:^2.1.1" + mkdirp: "npm:^1.0.3" + yallist: "npm:^4.0.0" + checksum: 10c0/a5eca3eb50bc11552d453488344e6507156b9193efd7635e98e867fab275d527af53d8866e2370cd09dfe74378a18111622ace35af6a608e5223a7d27fe99537 + languageName: node + linkType: hard + +"term-size@npm:^2.2.1": + version: 2.2.1 + resolution: "term-size@npm:2.2.1" + checksum: 10c0/89f6bba1d05d425156c0910982f9344d9e4aebf12d64bfa1f460d93c24baa7bc4c4a21d355fbd7153c316433df0538f64d0ae6e336cc4a69fdda4f85d62bc79d + languageName: node + linkType: hard + +"through@npm:2": + version: 2.3.8 + resolution: "through@npm:2.3.8" + checksum: 10c0/4b09f3774099de0d4df26d95c5821a62faee32c7e96fb1f4ebd54a2d7c11c57fe88b0a0d49cf375de5fee5ae6bf4eb56dbbf29d07366864e2ee805349970d3cc + languageName: node + linkType: hard + +"timsort@npm:^0.3.0": + version: 0.3.0 + resolution: "timsort@npm:0.3.0" + checksum: 10c0/571b2054a0db3cf80eb255f8609a1f798cae9176f9ec6e3fbd03d64186c015cc9e1e75b88ba38e1d71aebcc03a931352522c7387dcb90caeb148375c7bc106f4 + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: "npm:^7.0.0" + checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 + languageName: node + linkType: hard + +"toml@npm:^3.0.0": + version: 3.0.0 + resolution: "toml@npm:3.0.0" + checksum: 10c0/8d7ed3e700ca602e5419fca343e1c595eb7aa177745141f0761a5b20874b58ee5c878cd045c408da9d130cb2b611c639912210ba96ce2f78e443569aa8060c18 + languageName: node + linkType: hard + +"tslib@npm:^2.4.0": + version: 2.6.2 + resolution: "tslib@npm:2.6.2" + checksum: 10c0/e03a8a4271152c8b26604ed45535954c0a45296e32445b4b87f8a5abdb2421f40b59b4ca437c4346af0f28179780d604094eb64546bee2019d903d01c6c19bdb + languageName: node + linkType: hard + +"type-fest@npm:^0.20.2": + version: 0.20.2 + resolution: "type-fest@npm:0.20.2" + checksum: 10c0/dea9df45ea1f0aaa4e2d3bed3f9a0bfe9e5b2592bddb92eb1bf06e50bcf98dbb78189668cd8bc31a0511d3fc25539b4cd5c704497e53e93e2d40ca764b10bfc3 + languageName: node + linkType: hard + +"typescript@npm:>=3.0.0, typescript@npm:^5.4.4": + version: 5.4.5 + resolution: "typescript@npm:5.4.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/2954022ada340fd3d6a9e2b8e534f65d57c92d5f3989a263754a78aba549f7e6529acc1921913560a4b816c46dce7df4a4d29f9f11a3dc0d4213bb76d043251e + languageName: node + linkType: hard + +"typescript@npm:^3.9": + version: 3.9.10 + resolution: "typescript@npm:3.9.10" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/863cc06070fa18a0f9c6a83265fb4922a8b51bf6f2c6760fb0b73865305ce617ea4bc6477381f9f4b7c3a8cb4a455b054f5469e6e41307733fe6a2bd9aae82f8 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A>=3.0.0#optional!builtin, typescript@patch:typescript@npm%3A^5.4.4#optional!builtin": + version: 5.4.5 + resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/db2ad2a16ca829f50427eeb1da155e7a45e598eec7b086d8b4e8ba44e5a235f758e606d681c66992230d3fc3b8995865e5fd0b22a2c95486d0b3200f83072ec9 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^3.9#optional!builtin": + version: 3.9.10 + resolution: "typescript@patch:typescript@npm%3A3.9.10#optional!builtin::version=3.9.10&hash=3bd3d3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/9041fb3886e7d6a560f985227b8c941d17a750f2edccb5f9b3a15a2480574654d9be803ad4a14aabcc2f2553c4d272a25fd698a7c42692f03f66b009fb46883c + languageName: node + linkType: hard + +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501 + languageName: node + linkType: hard + +"unique-filename@npm:^3.0.0": + version: 3.0.0 + resolution: "unique-filename@npm:3.0.0" + dependencies: + unique-slug: "npm:^4.0.0" + checksum: 10c0/6363e40b2fa758eb5ec5e21b3c7fb83e5da8dcfbd866cc0c199d5534c42f03b9ea9ab069769cc388e1d7ab93b4eeef28ef506ab5f18d910ef29617715101884f + languageName: node + linkType: hard + +"unique-slug@npm:^4.0.0": + version: 4.0.0 + resolution: "unique-slug@npm:4.0.0" + dependencies: + imurmurhash: "npm:^0.1.4" + checksum: 10c0/cb811d9d54eb5821b81b18205750be84cb015c20a4a44280794e915f5a0a70223ce39066781a354e872df3572e8155c228f43ff0cce94c7cbf4da2cc7cbdd635 + languageName: node + linkType: hard + +"universalify@npm:^2.0.0": + version: 2.0.1 + resolution: "universalify@npm:2.0.1" + checksum: 10c0/73e8ee3809041ca8b818efb141801a1004e3fc0002727f1531f4de613ea281b494a40909596dae4a042a4fb6cd385af5d4db2e137b1362e0e91384b828effd3a + languageName: node + linkType: hard + +"update-browserslist-db@npm:^1.0.13": + version: 1.0.13 + resolution: "update-browserslist-db@npm:1.0.13" + dependencies: + escalade: "npm:^3.1.1" + picocolors: "npm:^1.0.0" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/e52b8b521c78ce1e0c775f356cd16a9c22c70d25f3e01180839c407a5dc787fb05a13f67560cbaf316770d26fa99f78f1acd711b1b54a4f35d4820d4ea7136e6 + languageName: node + linkType: hard + +"url-join@npm:^4.0.1": + version: 4.0.1 + resolution: "url-join@npm:4.0.1" + checksum: 10c0/ac65e2c7c562d7b49b68edddcf55385d3e922bc1dd5d90419ea40b53b6de1607d1e45ceb71efb9d60da02c681d13c6cb3a1aa8b13fc0c989dfc219df97ee992d + languageName: node + linkType: hard + +"util-deprecate@npm:^1.0.1": + version: 1.0.2 + resolution: "util-deprecate@npm:1.0.2" + checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 + languageName: node + linkType: hard + +"utility-types@npm:^3.10.0": + version: 3.11.0 + resolution: "utility-types@npm:3.11.0" + checksum: 10c0/2f1580137b0c3e6cf5405f37aaa8f5249961a76d26f1ca8efc0ff49a2fc0e0b2db56de8e521a174d075758e0c7eb3e590edec0832eb44478b958f09914920f19 + languageName: node + linkType: hard + +"weak-lru-cache@npm:^1.2.2": + version: 1.2.2 + resolution: "weak-lru-cache@npm:1.2.2" + checksum: 10c0/744847bd5b96ca86db1cb40d0aea7e92c02bbdb05f501181bf9c581e82fa2afbda32a327ffbe75749302b8492ab449f1c657ca02410d725f5d412d1e6c607d72 + languageName: node + linkType: hard + +"which@npm:^2.0.1, which@npm:^2.0.2": + version: 2.0.2 + resolution: "which@npm:2.0.2" + dependencies: + isexe: "npm:^2.0.0" + bin: + node-which: ./bin/node-which + checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f + languageName: node + linkType: hard + +"which@npm:^4.0.0": + version: 4.0.0 + resolution: "which@npm:4.0.0" + dependencies: + isexe: "npm:^3.1.1" + bin: + node-which: bin/which.js + checksum: 10c0/449fa5c44ed120ccecfe18c433296a4978a7583bf2391c50abce13f76878d2476defde04d0f79db8165bdf432853c1f8389d0485ca6e8ebce3bbcded513d5e6a + languageName: node + linkType: hard + +"wide-align@npm:^1.1.5": + version: 1.1.5 + resolution: "wide-align@npm:1.1.5" + dependencies: + string-width: "npm:^1.0.2 || 2 || 3 || 4" + checksum: 10c0/1d9c2a3e36dfb09832f38e2e699c367ef190f96b82c71f809bc0822c306f5379df87bab47bed27ea99106d86447e50eb972d3c516c2f95782807a9d082fbea95 + languageName: node + linkType: hard + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da + languageName: node + linkType: hard + +"wrap-ansi@npm:^8.1.0": + version: 8.1.0 + resolution: "wrap-ansi@npm:8.1.0" + dependencies: + ansi-styles: "npm:^6.1.0" + string-width: "npm:^5.0.1" + strip-ansi: "npm:^7.0.1" + checksum: 10c0/138ff58a41d2f877eae87e3282c0630fc2789012fc1af4d6bd626eeb9a2f9a65ca92005e6e69a75c7b85a68479fe7443c7dbe1eb8fbaa681a4491364b7c55c60 + languageName: node + linkType: hard + +"ws@npm:^8.14.1": + version: 8.16.0 + resolution: "ws@npm:8.16.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/a7783bb421c648b1e622b423409cb2a58ac5839521d2f689e84bc9dc41d59379c692dd405b15a997ea1d4c0c2e5314ad707332d0c558f15232d2bc07c0b4618a + languageName: node + linkType: hard + +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 + languageName: node + linkType: hard + +"yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a + languageName: node + linkType: hard + +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + +"yargs@npm:^17.7.2": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 + languageName: node + linkType: hard From 59d5331b6895dce2cdf9cf260322989bd967f658 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Tue, 23 Apr 2024 10:42:36 -0400 Subject: [PATCH 02/60] replace dirname with a hack that should behave like before the '@ts-expect-error' is to silence tsc just in case, since this function does in fact exist during bundling. techinically the real solution would be to declare it but /shrug --- cvmts/src/WSServer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cvmts/src/WSServer.ts b/cvmts/src/WSServer.ts index 2c54010..101d454 100644 --- a/cvmts/src/WSServer.ts +++ b/cvmts/src/WSServer.ts @@ -21,8 +21,12 @@ import { Size, Rect, Logger } from '@cvmts/shared'; import jpegTurbo from "@computernewb/jpeg-turbo"; import sharp from 'sharp'; -// probably better -const __dirname = process.cwd(); +// @ts-expect-error (I know, but this is already ugly) +// really wish I didn't have to do it like this.. +const __dirname = fileURLToPath(new __parcel__URL__('..')); + + +console.log(__dirname); // ejla this exist. Useing it. type ChatHistory = { From db97a62046672c3e88ae5b50fe7a81a8ec566c62 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Tue, 23 Apr 2024 19:43:23 -0400 Subject: [PATCH 03/60] move jpeg encoding to a worker thread pool this also switches cvmts back to building with tsc, mostly because I couldn't get parcel's worker interop to work at all. --- cvmts/package.json | 10 ++----- cvmts/src/JPEGEncoderWorker.ts | 16 +++++++++++ cvmts/src/WSServer.ts | 50 +++++++++++++++++++--------------- cvmts/tsconfig.json | 8 +++++- yarn.lock | 44 ++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 cvmts/src/JPEGEncoderWorker.ts mode change 120000 => 100644 cvmts/tsconfig.json diff --git a/cvmts/package.json b/cvmts/package.json index fd626fc..909254f 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -5,22 +5,17 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build": "parcel build src/index.ts --target node", + "build": "tsc -outDir dist -rootDir src/", "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", + "piscina": "^4.4.0", "sharp": "^0.33.3", "toml": "^3.0.0", "ws": "^8.14.1" @@ -28,7 +23,6 @@ "devDependencies": { "@types/node": "^20.12.5", "@types/ws": "^8.5.5", - "parcel": "^2.12.0", "prettier": "^3.2.5", "typescript": "^5.4.4" } diff --git a/cvmts/src/JPEGEncoderWorker.ts b/cvmts/src/JPEGEncoderWorker.ts new file mode 100644 index 0000000..25bc123 --- /dev/null +++ b/cvmts/src/JPEGEncoderWorker.ts @@ -0,0 +1,16 @@ + +import jpegTurbo from "@computernewb/jpeg-turbo"; +import Piscina from "piscina"; + +export default async (opts: any) => { + let res = await jpegTurbo.compress(opts.buffer, { + format: jpegTurbo.FORMAT_RGBA, + width: opts.width, + height: opts.height, + subsampling: jpegTurbo.SAMP_422, + stride: opts.stride, + quality: opts.quality + }); + + return Piscina.move(res); +} \ No newline at end of file diff --git a/cvmts/src/WSServer.ts b/cvmts/src/WSServer.ts index 101d454..a789dc5 100644 --- a/cvmts/src/WSServer.ts +++ b/cvmts/src/WSServer.ts @@ -13,22 +13,20 @@ 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 path from 'node:path'; import AuthManager from './AuthManager.js'; import { Size, Rect, Logger } from '@cvmts/shared'; -import jpegTurbo from "@computernewb/jpeg-turbo"; import sharp from 'sharp'; +import Piscina from 'piscina'; -// @ts-expect-error (I know, but this is already ugly) -// really wish I didn't have to do it like this.. -const __dirname = fileURLToPath(new __parcel__URL__('..')); +// Instead of strange hacks we can just use nodejs provided +// import.meta properties, which have existed since LTS if not before +const __filename = import.meta.filename; +const __dirname = import.meta.dirname; +const kCVMTSAssetsRoot = path.resolve(__dirname, '../../assets'); -console.log(__dirname); - -// ejla this exist. Useing it. type ChatHistory = { user: string, msg: string @@ -46,19 +44,27 @@ function GetRawSharpOptions(size: Size): sharp.CreateRaw { } } +const kJpegPool = new Piscina({ + filename: path.join(import.meta.dirname + '/JPEGEncoderWorker.js'), + minThreads: 4, + maxThreads: 4 +}); + async function EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promise { let offset = (rect.y * displaySize.width + rect.x) * 4; - //console.log('encoding rect', rect, 'with byteoffset', offset, '(size ', displaySize, ')'); + let res = await kJpegPool.run({ + buffer: canvas.subarray(offset), + width: rect.width, + height: rect.height, + stride: displaySize.width, + quality: kJpegQuality + }); - 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 - }); + + // have to manually turn it back into a buffer because + // Piscina for some reason turns it into a Uint8Array + return Buffer.from(res); } export default class WSServer { @@ -125,8 +131,8 @@ export default class WSServer { this.voteCooldown = 0; this.turnsAllowed = true; this.screenHidden = false; - this.screenHiddenImg = readFileSync(__dirname + "/../assets/screenhidden.jpeg").toString("base64"); - this.screenHiddenThumb = readFileSync(__dirname + "/../assets/screenhiddenthumb.jpeg").toString("base64"); + this.screenHiddenImg = readFileSync(path.join(kCVMTSAssetsRoot, "screenhidden.jpeg")).toString("base64"); + this.screenHiddenThumb = readFileSync(path.join(kCVMTSAssetsRoot, "screenhiddenthumb.jpeg")).toString("base64"); this.indefiniteTurn = null; this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); @@ -937,7 +943,7 @@ export default class WSServer { // TODO: pass custom options to Sharp.resize() probably let out = await sharp(display.Buffer(), {raw: GetRawSharpOptions(display.Size())}) - .resize(400, 300) + .resize(400, 300, { fit: 'fill' }) .toFormat('jpeg') .toBuffer(); @@ -1000,4 +1006,4 @@ export default class WSServer { }); return {yes:yes,no:no}; } -} +} \ No newline at end of file diff --git a/cvmts/tsconfig.json b/cvmts/tsconfig.json deleted file mode 120000 index 4ec6ff6..0000000 --- a/cvmts/tsconfig.json +++ /dev/null @@ -1 +0,0 @@ -../tsconfig.json \ No newline at end of file diff --git a/cvmts/tsconfig.json b/cvmts/tsconfig.json new file mode 100644 index 0000000..baa2339 --- /dev/null +++ b/cvmts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "include": [ "src/**/*" ], + "compilerOptions": { + "resolveJsonModule": true, + } +} diff --git a/yarn.lock b/yarn.lock index fe2b342..b8671e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -67,6 +67,7 @@ __metadata: execa: "npm:^8.0.1" mnemonist: "npm:^0.39.5" parcel: "npm:^2.12.0" + piscina: "npm:^4.4.0" prettier: "npm:^3.2.5" sharp: "npm:^0.33.3" toml: "npm:^3.0.0" @@ -3155,6 +3156,26 @@ __metadata: languageName: node linkType: hard +"nice-napi@npm:^1.0.2": + version: 1.0.2 + resolution: "nice-napi@npm:1.0.2" + dependencies: + node-addon-api: "npm:^3.0.0" + node-gyp: "npm:latest" + node-gyp-build: "npm:^4.2.2" + conditions: "!os=win32" + languageName: node + linkType: hard + +"node-addon-api@npm:^3.0.0": + version: 3.2.1 + resolution: "node-addon-api@npm:3.2.1" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/41f21c9d12318875a2c429befd06070ce367065a3ef02952cfd4ea17ef69fa14012732f510b82b226e99c254da8d671847ea018cad785f839a5366e02dd56302 + languageName: node + linkType: hard + "node-addon-api@npm:^6.0.0, node-addon-api@npm:^6.1.0": version: 6.1.0 resolution: "node-addon-api@npm:6.1.0" @@ -3204,6 +3225,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.2.2": + version: 4.8.0 + resolution: "node-gyp-build@npm:4.8.0" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10c0/85324be16f81f0235cbbc42e3eceaeb1b5ab94c8d8f5236755e1435b4908338c65a4e75f66ee343cbcb44ddf9b52a428755bec16dcd983295be4458d95c8e1ad + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 10.1.0 resolution: "node-gyp@npm:10.1.0" @@ -3408,6 +3440,18 @@ __metadata: languageName: node linkType: hard +"piscina@npm:^4.4.0": + version: 4.4.0 + resolution: "piscina@npm:4.4.0" + dependencies: + nice-napi: "npm:^1.0.2" + dependenciesMeta: + nice-napi: + optional: true + checksum: 10c0/df6c2a2b673b0633a625f8dfc32f4519155e74ee24e31be9e69d2937e76d6cec8640278b4a50195652a943cccf8c634ed406f08598933c57e959d242b5fe5d1d + languageName: node + linkType: hard + "postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" From bcbf7db8d9b756146d08ea9b66d72e217f82ab24 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 24 Apr 2024 03:41:32 -0400 Subject: [PATCH 04/60] misc patches done to get everything to play ball also adds editorconfig --- .editorconfig | 11 +++++++++++ cvmts/src/JPEGEncoderWorker.ts | 29 ++++++++++++++++------------- cvmts/src/WSServer.ts | 11 ++++++++--- yarn.lock | 1 - 4 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0b53eb8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = tab +indent_size = 4 + +# specifically for YAML +[{yml, yaml}] +indent_style = space diff --git a/cvmts/src/JPEGEncoderWorker.ts b/cvmts/src/JPEGEncoderWorker.ts index 25bc123..c5f93cf 100644 --- a/cvmts/src/JPEGEncoderWorker.ts +++ b/cvmts/src/JPEGEncoderWorker.ts @@ -1,16 +1,19 @@ - -import jpegTurbo from "@computernewb/jpeg-turbo"; -import Piscina from "piscina"; +import jpegTurbo from '@computernewb/jpeg-turbo'; +import Piscina from 'piscina'; export default async (opts: any) => { - let res = await jpegTurbo.compress(opts.buffer, { - format: jpegTurbo.FORMAT_RGBA, - width: opts.width, - height: opts.height, - subsampling: jpegTurbo.SAMP_422, - stride: opts.stride, - quality: opts.quality - }); + try { + let res = await jpegTurbo.compress(opts.buffer, { + format: jpegTurbo.FORMAT_RGBA, + width: opts.width, + height: opts.height, + subsampling: jpegTurbo.SAMP_422, + stride: opts.stride, + quality: opts.quality + }); - return Piscina.move(res); -} \ No newline at end of file + return Piscina.move(res); + } catch { + return; + } +}; diff --git a/cvmts/src/WSServer.ts b/cvmts/src/WSServer.ts index a789dc5..51528ba 100644 --- a/cvmts/src/WSServer.ts +++ b/cvmts/src/WSServer.ts @@ -44,7 +44,8 @@ function GetRawSharpOptions(size: Size): sharp.CreateRaw { } } -const kJpegPool = new Piscina({ +// Thread pool for doing JPEG encoding for rects. +const TheJpegEncoderPool = new Piscina({ filename: path.join(import.meta.dirname + '/JPEGEncoderWorker.js'), minThreads: 4, maxThreads: 4 @@ -53,7 +54,7 @@ const kJpegPool = new Piscina({ async function EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promise { let offset = (rect.y * displaySize.width + rect.x) * 4; - let res = await kJpegPool.run({ + let res = await TheJpegEncoderPool.run({ buffer: canvas.subarray(offset), width: rect.width, height: rect.height, @@ -61,6 +62,10 @@ async function EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promis quality: kJpegQuality }); + // TODO: There's probably (definitely) a better way to fix this + if(res == undefined) + return Buffer.from([]); + // have to manually turn it back into a buffer because // Piscina for some reason turns it into a Uint8Array @@ -1006,4 +1011,4 @@ export default class WSServer { }); return {yes:yes,no:no}; } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index b8671e3..bf82089 100644 --- a/yarn.lock +++ b/yarn.lock @@ -66,7 +66,6 @@ __metadata: "@types/ws": "npm:^8.5.5" execa: "npm:^8.0.1" mnemonist: "npm:^0.39.5" - parcel: "npm:^2.12.0" piscina: "npm:^4.4.0" prettier: "npm:^3.2.5" sharp: "npm:^0.33.3" From ddae307874e43961cda559346fcf4ba066761e73 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 24 Apr 2024 03:50:17 -0400 Subject: [PATCH 05/60] chore: reformat all code with prettier --- cvmts/src/AuthManager.ts | 7 +- cvmts/src/IPData.ts | 24 +- cvmts/src/RateLimiter.ts | 66 +- cvmts/src/User.ts | 9 +- cvmts/src/Utilities.ts | 102 +-- cvmts/src/WSServer.ts | 1761 +++++++++++++++++++------------------- cvmts/src/guacutils.ts | 58 +- cvmts/src/index.ts | 65 +- qemu/src/QemuUtil.ts | 4 +- qemu/src/QemuVM.ts | 16 +- qemu/src/QmpClient.ts | 2 +- shared/src/StringLike.ts | 3 +- shared/src/index.ts | 10 +- 13 files changed, 1053 insertions(+), 1074 deletions(-) diff --git a/cvmts/src/AuthManager.ts b/cvmts/src/AuthManager.ts index 0b46f90..4552264 100644 --- a/cvmts/src/AuthManager.ts +++ b/cvmts/src/AuthManager.ts @@ -1,12 +1,11 @@ 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"); + private logger = new Logger('CVMTS.AuthMan'); constructor(apiEndpoint: string, secretKey: string) { this.apiEndpoint = apiEndpoint; @@ -14,7 +13,7 @@ export default class AuthManager { } async Authenticate(token: string, user: User): Promise { - var response = await fetch(this.apiEndpoint + '/api/v1/join', { + let response = await fetch(this.apiEndpoint + '/api/v1/join', { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -26,7 +25,7 @@ export default class AuthManager { }) }); - var json = (await response.json()) as JoinResponse; + let json = (await response.json()) as JoinResponse; if (!json.success) { this.logger.Error(`Failed to query auth server: ${json.error}`); diff --git a/cvmts/src/IPData.ts b/cvmts/src/IPData.ts index c535ba4..ce53011 100644 --- a/cvmts/src/IPData.ts +++ b/cvmts/src/IPData.ts @@ -1,4 +1,4 @@ -import { Logger } from "@cvmts/shared"; +import { Logger } from '@cvmts/shared'; export class IPData { tempMuteExpireTimeout?: NodeJS.Timeout; @@ -15,19 +15,17 @@ export class IPData { // Call when a connection is closed to "release" the ip data Unref() { - if(this.refCount - 1 < 0) - this.refCount = 0; + if (this.refCount - 1 < 0) this.refCount = 0; this.refCount--; } } - export class IPDataManager { static ipDatas = new Map(); - static logger = new Logger("CVMTS.IPDataManager"); + static logger = new Logger('CVMTS.IPDataManager'); static GetIPData(address: string) { - if(IPDataManager.ipDatas.has(address)) { + 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 @@ -35,16 +33,15 @@ export class IPDataManager { 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]); + for (let tuple of IPDataManager.ipDatas) callback(tuple[1]); } } @@ -52,11 +49,10 @@ export class IPDataManager { // 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]); + 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); - diff --git a/cvmts/src/RateLimiter.ts b/cvmts/src/RateLimiter.ts index 32399ea..3df21fb 100644 --- a/cvmts/src/RateLimiter.ts +++ b/cvmts/src/RateLimiter.ts @@ -1,36 +1,36 @@ -import { EventEmitter } from "events"; +import { EventEmitter } from 'events'; // Class to ratelimit a resource (chatting, logging in, etc) export default class RateLimiter extends EventEmitter { - private limit : number; - private interval : number; - private requestCount : number; - private limiter? : NodeJS.Timeout; - private limiterSet : boolean; - constructor(limit : number, interval : number) { - super(); - this.limit = limit; - this.interval = interval; - this.requestCount = 0; - this.limiterSet = false; - } - // Return value is whether or not the action should be continued - request() : boolean { - this.requestCount++; - if (this.requestCount === this.limit) { - this.emit('limit'); - clearTimeout(this.limiter); - this.limiterSet = false; - this.requestCount = 0; - return false; - } - if (!this.limiterSet) { - this.limiter = setTimeout(() => { - this.limiterSet = false; - this.requestCount = 0; - }, this.interval * 1000); - this.limiterSet = true; - } - return true; - } -} \ No newline at end of file + private limit: number; + private interval: number; + private requestCount: number; + private limiter?: NodeJS.Timeout; + private limiterSet: boolean; + constructor(limit: number, interval: number) { + super(); + this.limit = limit; + this.interval = interval; + this.requestCount = 0; + this.limiterSet = false; + } + // Return value is whether or not the action should be continued + request(): boolean { + this.requestCount++; + if (this.requestCount === this.limit) { + this.emit('limit'); + clearTimeout(this.limiter); + this.limiterSet = false; + this.requestCount = 0; + return false; + } + if (!this.limiterSet) { + this.limiter = setTimeout(() => { + this.limiterSet = false; + this.requestCount = 0; + }, this.interval * 1000); + this.limiterSet = true; + } + return true; + } +} diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index cc316a5..81cea6a 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -26,7 +26,7 @@ export class User { TurnRateLimit: RateLimiter; VoteRateLimit: RateLimiter; - private logger = new Logger("CVMTS.User"); + private logger = new Logger('CVMTS.User'); constructor(ws: WebSocket, ip: IPData, config: IConfig, username?: string, node?: string) { this.IP = ip; @@ -59,6 +59,7 @@ export class User { this.VoteRateLimit = new RateLimiter(3, 3); this.VoteRateLimit.on('limit', () => this.closeConnection()); } + assignGuestName(existingUsers: string[]): string { var username; do { @@ -67,25 +68,30 @@ export class User { 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 @@ -99,6 +105,7 @@ export class User { break; } } + mute(permanent: boolean) { this.IP.muted = true; this.sendMsg(guacutils.encode('chat', '', `You have been muted${permanent ? '' : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`)); diff --git a/cvmts/src/Utilities.ts b/cvmts/src/Utilities.ts index ac67fc2..efdcf81 100644 --- a/cvmts/src/Utilities.ts +++ b/cvmts/src/Utilities.ts @@ -1,54 +1,54 @@ -import { Permissions } from "./IConfig"; +import { Permissions } from './IConfig'; -export function Randint(min : number, max : number) { - return Math.floor((Math.random() * (max - min)) + min); -} -export function HTMLSanitize(input : string) : string { - var output = ""; - for (var i = 0; i < input.length; i++) { - switch (input[i]) { - case "<": - output += "<" - break; - case ">": - output += ">" - break; - case "&": - output += "&" - break; - case "\"": - output += """ - break; - case "'": - output += "'"; - break; - case "/": - output += "/"; - break; - case "\n": - output += " "; - break; - default: - var charcode : number = input.charCodeAt(i); - if (charcode >= 32 && charcode <= 126) - output += input[i]; - break; - } - } - return output; +export function Randint(min: number, max: number) { + return Math.floor(Math.random() * (max - min) + min); } -export function MakeModPerms(modperms : Permissions) : number { - var perms = 0; - if (modperms.restore) perms |= 1; - if (modperms.reboot) perms |= 2; - if (modperms.ban) perms |= 4; - if (modperms.forcevote) perms |= 8; - if (modperms.mute) perms |= 16; - if (modperms.kick) perms |= 32; - if (modperms.bypassturn) perms |= 64; - if (modperms.rename) perms |= 128; - if (modperms.grabip) perms |= 256; - if (modperms.xss) perms |= 512; - return perms; -} \ No newline at end of file +export function HTMLSanitize(input: string): string { + var output = ''; + for (var i = 0; i < input.length; i++) { + switch (input[i]) { + case '<': + output += '<'; + break; + case '>': + output += '>'; + break; + case '&': + output += '&'; + break; + case '"': + output += '"'; + break; + case "'": + output += '''; + break; + case '/': + output += '/'; + break; + case '\n': + output += ' '; + break; + default: + var charcode: number = input.charCodeAt(i); + if (charcode >= 32 && charcode <= 126) output += input[i]; + break; + } + } + return output; +} + +export function MakeModPerms(modperms: Permissions): number { + var perms = 0; + if (modperms.restore) perms |= 1; + if (modperms.reboot) perms |= 2; + if (modperms.ban) perms |= 4; + if (modperms.forcevote) perms |= 8; + if (modperms.mute) perms |= 16; + if (modperms.kick) perms |= 32; + if (modperms.bypassturn) perms |= 64; + if (modperms.rename) perms |= 128; + if (modperms.grabip) perms |= 256; + if (modperms.xss) perms |= 512; + return perms; +} diff --git a/cvmts/src/WSServer.ts b/cvmts/src/WSServer.ts index 51528ba..ddc3105 100644 --- a/cvmts/src/WSServer.ts +++ b/cvmts/src/WSServer.ts @@ -1,4 +1,4 @@ -import {WebSocketServer, WebSocket} from 'ws'; +import { WebSocketServer, WebSocket } from 'ws'; import * as http from 'http'; import IConfig from './IConfig.js'; import internal from 'stream'; @@ -28,8 +28,13 @@ const __dirname = import.meta.dirname; const kCVMTSAssetsRoot = path.resolve(__dirname, '../../assets'); type ChatHistory = { - user: string, - msg: string + user: string; + msg: string; +}; + +type VoteTally = { + yes: number; + no: number; }; // A good balance. TODO: Configurable? @@ -37,978 +42,964 @@ 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 - } + return { + width: size.width, + height: size.height, + channels: 4 + }; } // Thread pool for doing JPEG encoding for rects. const TheJpegEncoderPool = new Piscina({ - filename: path.join(import.meta.dirname + '/JPEGEncoderWorker.js'), - minThreads: 4, - maxThreads: 4 + filename: path.join(import.meta.dirname + '/JPEGEncoderWorker.js'), + minThreads: 4, + maxThreads: 4 }); async function EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promise { let offset = (rect.y * displaySize.width + rect.x) * 4; - let res = await TheJpegEncoderPool.run({ - buffer: canvas.subarray(offset), - width: rect.width, - height: rect.height, - stride: displaySize.width, - quality: kJpegQuality - }); + let res = await TheJpegEncoderPool.run({ + buffer: canvas.subarray(offset), + width: rect.width, + height: rect.height, + stride: displaySize.width, + quality: kJpegQuality + }); // TODO: There's probably (definitely) a better way to fix this - if(res == undefined) - return Buffer.from([]); + if (res == undefined) return Buffer.from([]); - - // have to manually turn it back into a buffer because - // Piscina for some reason turns it into a Uint8Array - return Buffer.from(res); + // have to manually turn it back into a buffer because + // Piscina for some reason turns it into a Uint8Array + return Buffer.from(res); } export default class WSServer { - private Config : IConfig; + private Config: IConfig; - private httpServer : http.Server; - private wsServer : WebSocketServer; + private httpServer: http.Server; + private wsServer: WebSocketServer; - private clients : User[]; + private clients: User[]; - private ChatHistory : CircularBuffer + private ChatHistory: CircularBuffer; - private TurnQueue : Queue; - - // Time remaining on the current turn - private TurnTime : number; + private TurnQueue: Queue; - // Interval to keep track of the current turn time - private TurnInterval? : NodeJS.Timeout; + // Time remaining on the current turn + private TurnTime: number; - // If a reset vote is in progress - private voteInProgress : boolean; + // Interval to keep track of the current turn time + private TurnInterval?: NodeJS.Timeout; - // Interval to keep track of vote resets - private voteInterval? : NodeJS.Timeout; + // If a reset vote is in progress + private voteInProgress: boolean; - // How much time is left on the vote - private voteTime : number; + // Interval to keep track of vote resets + private voteInterval?: NodeJS.Timeout; - // How much time until another reset vote can be cast - private voteCooldown : number; + // How much time is left on the vote + private voteTime: number; - // Interval to keep track - private voteCooldownInterval? : NodeJS.Timeout; + // How much time until another reset vote can be cast + private voteCooldown: number; - // Completely disable turns - private turnsAllowed : boolean; + // Interval to keep track + private voteCooldownInterval?: NodeJS.Timeout; - // Hide the screen - private screenHidden : boolean; + // Completely disable turns + private turnsAllowed: boolean; - // base64 image to show when the screen is hidden - private screenHiddenImg : string; - private screenHiddenThumb : string; + // Hide the screen + private screenHidden: boolean; - // Indefinite turn - private indefiniteTurn : User | null; - private ModPerms : number; - private VM : QemuVM; + // base64 image to show when the screen is hidden + private screenHiddenImg: string; + private screenHiddenThumb: string; - // Authentication manager - private auth : AuthManager | null; + // Indefinite turn + private indefiniteTurn: User | null; + private ModPerms: number; + private VM: QemuVM; - private logger = new Logger("CVMTS.Server"); + // Authentication manager + private auth: AuthManager | null; - constructor(config : IConfig, vm : QemuVM, auth : AuthManager | null) { - this.Config = config; - this.ChatHistory = new CircularBuffer(Array, this.Config.collabvm.maxChatHistoryLength); - this.TurnQueue = new Queue(); - this.TurnTime = 0; - this.clients = []; - this.voteInProgress = false; - this.voteTime = 0; - this.voteCooldown = 0; - this.turnsAllowed = true; - this.screenHidden = false; - this.screenHiddenImg = readFileSync(path.join(kCVMTSAssetsRoot, "screenhidden.jpeg")).toString("base64"); - this.screenHiddenThumb = readFileSync(path.join(kCVMTSAssetsRoot, "screenhiddenthumb.jpeg")).toString("base64"); + private logger = new Logger('CVMTS.Server'); - this.indefiniteTurn = null; - this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); - 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(); - }); + constructor(config: IConfig, vm: QemuVM, auth: AuthManager | null) { + this.Config = config; + this.ChatHistory = new CircularBuffer(Array, this.Config.collabvm.maxChatHistoryLength); + this.TurnQueue = new Queue(); + this.TurnTime = 0; + this.clients = []; + this.voteInProgress = false; + this.voteTime = 0; + this.voteCooldown = 0; + this.turnsAllowed = true; + this.screenHidden = false; + this.screenHiddenImg = readFileSync(path.join(kCVMTSAssetsRoot, 'screenhidden.jpeg')).toString('base64'); + this.screenHiddenThumb = readFileSync(path.join(kCVMTSAssetsRoot, 'screenhiddenthumb.jpeg')).toString('base64'); - let initSize = vm.GetDisplay().Size() || { - width: 0, - height: 0 - }; + this.indefiniteTurn = null; + this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); + 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(); + }); - this.OnDisplayResized(initSize); + let initSize = vm.GetDisplay().Size() || { + width: 0, + height: 0 + }; - vm.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size)); - vm.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect)); + this.OnDisplayResized(initSize); - this.VM = vm; - - // authentication manager - this.auth = auth; - } + vm.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size)); + vm.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect)); - listen() { - this.httpServer.listen(this.Config.http.port, this.Config.http.host); - } + this.VM = vm; - private httpOnUpgrade(req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) { - var killConnection = () => { - socket.write("HTTP/1.1 400 Bad Request\n\n400 Bad Request"); - socket.destroy(); - } + // authentication manager + this.auth = auth; + } - if (req.headers['sec-websocket-protocol'] !== "guacamole") { - killConnection(); - return; - } + listen() { + this.httpServer.listen(this.Config.http.port, this.Config.http.host); + } - if (this.Config.http.origin) { - // If the client is not sending an Origin header, kill the connection. - if(!req.headers.origin) { - killConnection(); - return; - } + private httpOnUpgrade(req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) { + var killConnection = () => { + socket.write('HTTP/1.1 400 Bad Request\n\n400 Bad Request'); + socket.destroy(); + }; - // Try to parse the Origin header sent by the client, if it fails, kill the connection. - var _uri; - var _host; - try { - _uri = new URL(req.headers.origin.toLowerCase()); - _host = _uri.host; - } catch { - killConnection(); - return; - } + if (req.headers['sec-websocket-protocol'] !== 'guacamole') { + killConnection(); + return; + } - // detect fake origin headers - if (_uri.pathname !== "/" || _uri.search !== "") { - killConnection(); - return; - } - - // If the domain name is not in the list of allowed origins, kill the connection. - if(!this.Config.http.originAllowedDomains.includes(_host)) { - killConnection(); - return; - } - } + if (this.Config.http.origin) { + // If the client is not sending an Origin header, kill the connection. + if (!req.headers.origin) { + killConnection(); + return; + } - let ip: string; - if (this.Config.http.proxying) { - // If the requesting IP isn't allowed to proxy, kill it - if (this.Config.http.proxyAllowedIps.indexOf(req.socket.remoteAddress!) === -1) { - killConnection(); - return; - } - // Make sure x-forwarded-for is set - if (req.headers["x-forwarded-for"] === undefined) { - killConnection(); - return; - } - try { - // Get the first IP from the X-Forwarded-For variable - ip = req.headers["x-forwarded-for"]?.toString().replace(/\ /g, "").split(",")[0]; - } catch { - // If we can't get the IP, kill the connection - killConnection(); - return; - } - // If for some reason the IP isn't defined, kill it - if (!ip) { - killConnection(); - return; - } - // Make sure the IP is valid. If not, kill the connection. - if (!isIP(ip)) { - killConnection(); - return; - } - } else { - if (!req.socket.remoteAddress) return; - ip = req.socket.remoteAddress; - } + // Try to parse the Origin header sent by the client, if it fails, kill the connection. + var _uri; + var _host; + try { + _uri = new URL(req.headers.origin.toLowerCase()); + _host = _uri.host; + } catch { + killConnection(); + return; + } - // Get the amount of active connections coming from the requesting IP. - let connections = this.clients.filter(client => client.IP.address == ip); - // If it exceeds the limit set in the config, reject the connection with a 429. - if(connections.length + 1 > this.Config.http.maxConnections) { - socket.write("HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests"); - socket.destroy(); - } + // detect fake origin headers + if (_uri.pathname !== '/' || _uri.search !== '') { + killConnection(); + return; + } - this.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => { - this.wsServer.emit('connection', ws, req); - this.onConnection(ws, req, ip); - }); - } + // If the domain name is not in the list of allowed origins, kill the connection. + if (!this.Config.http.originAllowedDomains.includes(_host)) { + killConnection(); + return; + } + } - private onConnection(ws : WebSocket, req: http.IncomingMessage, ip : string) { - let user = new User(ws, IPDataManager.GetIPData(ip), this.Config); - this.clients.push(user); + let ip: string; + if (this.Config.http.proxying) { + // If the requesting IP isn't allowed to proxy, kill it + if (this.Config.http.proxyAllowedIps.indexOf(req.socket.remoteAddress!) === -1) { + killConnection(); + return; + } + // Make sure x-forwarded-for is set + if (req.headers['x-forwarded-for'] === undefined) { + killConnection(); + return; + } + try { + // Get the first IP from the X-Forwarded-For variable + ip = req.headers['x-forwarded-for']?.toString().replace(/\ /g, '').split(',')[0]; + } catch { + // If we can't get the IP, kill the connection + killConnection(); + return; + } + // If for some reason the IP isn't defined, kill it + if (!ip) { + killConnection(); + return; + } + // Make sure the IP is valid. If not, kill the connection. + if (!isIP(ip)) { + killConnection(); + return; + } + } else { + if (!req.socket.remoteAddress) return; + ip = req.socket.remoteAddress; + } - ws.on('error', (e) => { - this.logger.Error(`${e} (caused by connection ${ip})`); - ws.close(); - }); + // Get the amount of active connections coming from the requesting IP. + let connections = this.clients.filter((client) => client.IP.address == ip); + // If it exceeds the limit set in the config, reject the connection with a 429. + if (connections.length + 1 > this.Config.http.maxConnections) { + socket.write('HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests'); + socket.destroy(); + } - ws.on('close', () => this.connectionClosed(user)); + this.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => { + this.wsServer.emit('connection', ws, req); + this.onConnection(ws, req, ip); + }); + } - ws.on('message', (buf: Buffer, isBinary: boolean) => { - var msg; + private onConnection(ws: WebSocket, req: http.IncomingMessage, ip: string) { + let user = new User(ws, IPDataManager.GetIPData(ip), this.Config); + this.clients.push(user); - // Close the user's connection if they send a non-string message - if(isBinary) { - user.closeConnection(); - return; - } + ws.on('error', (e) => { + this.logger.Error(`${e} (caused by connection ${ip})`); + ws.close(); + }); - try { - this.onMessage(user, buf.toString()); - } catch { - } - }); + ws.on('close', () => this.connectionClosed(user)); - if (this.Config.auth.enabled) { - user.sendMsg(guacutils.encode("auth", this.Config.auth.apiEndpoint)); - } - user.sendMsg(this.getAdduserMsg()); - this.logger.Info(`Connect from ${user.IP.address}`); - }; + ws.on('message', (buf: Buffer, isBinary: boolean) => { + var msg; - private connectionClosed(user : User) { - let clientIndex = this.clients.indexOf(user) - if (clientIndex === -1) return; + // Close the user's connection if they send a non-string message + if (isBinary) { + user.closeConnection(); + return; + } - if(user.IP.vote != null) { - user.IP.vote = null; - this.sendVoteUpdate(); - } + try { + this.onMessage(user, buf.toString()); + } catch {} + }); - // Unreference the IP data. - user.IP.Unref(); + if (this.Config.auth.enabled) { + user.sendMsg(guacutils.encode('auth', this.Config.auth.apiEndpoint)); + } + user.sendMsg(this.getAdduserMsg()); + this.logger.Info(`Connect from ${user.IP.address}`); + } - if (this.indefiniteTurn === user) this.indefiniteTurn = null; + private connectionClosed(user: User) { + let clientIndex = this.clients.indexOf(user); + if (clientIndex === -1) return; - this.clients.splice(clientIndex, 1); + if (user.IP.vote != null) { + user.IP.vote = null; + this.sendVoteUpdate(); + } - 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); - this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(u => u !== user)); - if (hadturn) this.nextTurn(); - } - - this.clients.forEach((c) => c.sendMsg(guacutils.encode("remuser", "1", user.username!))); - } + // Unreference the IP data. + user.IP.Unref(); + if (this.indefiniteTurn === user) this.indefiniteTurn = null; - private async onMessage(client : User, message : string) { - var msgArr = guacutils.decode(message); - if (msgArr.length < 1) return; - switch (msgArr[0]) { - case "login": - if (msgArr.length !== 2 || !this.Config.auth.enabled) return; - if (!client.connectedToNode) { - client.sendMsg(guacutils.encode("login", "0", "You must connect to the VM before logging in.")); - return; - } - var res = await this.auth!.Authenticate(msgArr[1], client); - if (res.clientSuccess) { - 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) { - // kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that - // so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list - this.connectionClosed(old); - await old.kick(); - } - // Set username - this.renameUser(client, res.username); - // Set rank - client.rank = res.rank; - if (client.rank === Rank.Admin) { - client.sendMsg(guacutils.encode("admin", "0", "1")); - } else if (client.rank === Rank.Moderator) { - client.sendMsg(guacutils.encode("admin", "0", "3", this.ModPerms.toString())); - } - this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString()))); - } else { - client.sendMsg(guacutils.encode("login", "0", res.error!)); - if (res.error === "You are banned") { - client.kick(); - } - } - break; - case "list": - client.sendMsg(guacutils.encode("list", this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); - break; - case "connect": - if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { - client.sendMsg(guacutils.encode("connect", "0")); - return; - } - client.connectedToNode = true; - client.sendMsg(guacutils.encode("connect", "1", "1", this.Config.vm.snapshots ? "1" : "0", "0")); - if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd)); - if (this.screenHidden) { - client.sendMsg(guacutils.encode("size", "0", "1024", "768")); - client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg)); - } else { - await this.SendFullScreenWithSize(client); - } - client.sendMsg(guacutils.encode("sync", Date.now().toString())); - if (this.voteInProgress) this.sendVoteUpdate(client); - this.sendTurnUpdate(client); - break; - case "view": - if(client.connectedToNode) return; - if(client.username || msgArr.length !== 3 || msgArr[1] !== this.Config.collabvm.node) { - // The use of connect here is intentional. - client.sendMsg(guacutils.encode("connect", "0")); - return; - } + this.clients.splice(clientIndex, 1); - switch(msgArr[2]) { - case "0": - client.viewMode = 0; - break; - case "1": - client.viewMode = 1; - break; - default: - client.sendMsg(guacutils.encode("connect", "0")); - return; - } - - client.sendMsg(guacutils.encode("connect", "1", "1", this.Config.vm.snapshots ? "1" : "0", "0")); - if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd)); - - if(client.viewMode == 1) { - if (this.screenHidden) { - client.sendMsg(guacutils.encode("size", "0", "1024", "768")); - client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg)); - } else { - await this.SendFullScreenWithSize(client); - } - client.sendMsg(guacutils.encode("sync", Date.now().toString())); - } - - if (this.voteInProgress) this.sendVoteUpdate(client); - this.sendTurnUpdate(client); - break; - case "rename": - if (!client.RenameRateLimit.request()) return; - if (client.connectedToNode && client.IP.muted) return; - if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) { - client.sendMsg(guacutils.encode("chat", "", "Go to your account settings to change your username.")); - return; - } - if (this.Config.auth.enabled && msgArr[1] !== undefined) { - // Don't send system message to a user without a username since it was likely an automated attempt by the webapp - if (client.username) client.sendMsg(guacutils.encode("chat", "", "You need to log in to do that.")); - if (client.rank !== Rank.Unregistered) return; - this.renameUser(client, undefined); - return; - } - this.renameUser(client, msgArr[1]); - break; - case "chat": - if (!client.username) return; - if (client.IP.muted) return; - if (msgArr.length !== 2) return; - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) { - client.sendMsg(guacutils.encode("chat", "", "You need to login to do that.")); - return; - } - var msg = Utilities.HTMLSanitize(msgArr[1]); - // One of the things I hated most about the old server is it completely discarded your message if it was too long - if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength); - if (msg.trim().length < 1) return; - - this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, msg))); - this.ChatHistory.push({user: client.username, msg: msg}); - client.onMsgSent(); - break; - case "turn": - if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) { - client.sendMsg(guacutils.encode("chat", "", "You need to login to do that.")); - return; - } - if (!client.TurnRateLimit.request()) return; - if (!client.connectedToNode) return; - if (msgArr.length > 2) return; - var takingTurn : boolean; - if (msgArr.length === 1) takingTurn = true; - else switch (msgArr[1]) { - case "0": - if (this.indefiniteTurn === client) { - this.indefiniteTurn = null; - } - takingTurn = false; - break; - case "1": - takingTurn = true; - break; - default: - return; - break; - } - if (takingTurn) { - var currentQueue = this.TurnQueue.toArray(); - // If the user is already in the turn queue, ignore the turn request. - if (currentQueue.indexOf(client) !== -1) return; - // If they're muted, also ignore the turn request. - // Send them the turn queue to prevent client glitches - if (client.IP.muted) return; - if(this.Config.collabvm.turnlimit.enabled) { - // Get the amount of users in the turn queue with the same IP as the user requesting a turn. - let turns = currentQueue.filter(user => user.IP.address == client.IP.address); - // If it exceeds the limit set in the config, ignore the turn request. - if(turns.length + 1 > this.Config.collabvm.turnlimit.maximum) return; - } - this.TurnQueue.enqueue(client); - if (this.TurnQueue.size === 1) this.nextTurn(); - } else { - var hadturn = (this.TurnQueue.peek() === client); - this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(u => u !== client)); - if (hadturn) this.nextTurn(); - } - this.sendTurnUpdate(); - break; - case "mouse": - if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) 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.GetDisplay()!.MouseEvent(x, y, mask); - break; - case "key": - if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; - var keysym = parseInt(msgArr[1]); - var down = parseInt(msgArr[2]); - if (keysym === undefined || (down !== 0 && down !== 1)) return; - this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false); - break; - case "vote": - if (!this.Config.vm.snapshots) return; - if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; - if (!client.connectedToNode) return; - if (msgArr.length !== 2) return; - if (!client.VoteRateLimit.request()) return; - switch (msgArr[1]) { - case "1": - if (!this.voteInProgress) { - if (this.voteCooldown !== 0) { - client.sendMsg(guacutils.encode("vote", "3", this.voteCooldown.toString())); - return; - } - this.startVote(); - this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has started a vote to reset the VM.`))); - } - else if (client.IP.vote !== true) - this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted yes.`))); - client.IP.vote = true; - break; - case "0": - if (!this.voteInProgress) return; - if (client.IP.vote !== false) - this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted no.`))); - client.IP.vote = false; - break; - } - this.sendVoteUpdate(); - break; - case "admin": - if (msgArr.length < 2) return; - switch (msgArr[1]) { - case "2": - // Login - if (this.Config.auth.enabled) { - client.sendMsg(guacutils.encode("chat", "", "This server does not support staff passwords. Please log in to become staff.")); - return; - } - if (!client.LoginRateLimit.request() || !client.username) return; - if (msgArr.length !== 3) return; - var sha256 = createHash("sha256"); - sha256.update(msgArr[2]); - var pwdHash = sha256.digest('hex'); - sha256.destroy(); - if (pwdHash === this.Config.collabvm.adminpass) { - client.rank = Rank.Admin; - client.sendMsg(guacutils.encode("admin", "0", "1")); - } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { - client.rank = Rank.Moderator; - client.sendMsg(guacutils.encode("admin", "0", "3", this.ModPerms.toString())); - } else if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) { - client.rank = Rank.Turn; - client.sendMsg(guacutils.encode("chat", "", "You may now take turns.")); - } else { - client.sendMsg(guacutils.encode("admin", "0", "0")); - return; - } - if (this.screenHidden) { - await this.SendFullScreenWithSize(client); + 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; + this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((u) => u !== user)); + if (hadturn) this.nextTurn(); + } - client.sendMsg(guacutils.encode("sync", Date.now().toString())); - } - - this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString()))); - break; - 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 + 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; + switch (msgArr[0]) { + case 'login': + if (msgArr.length !== 2 || !this.Config.auth.enabled) return; + if (!client.connectedToNode) { + client.sendMsg(guacutils.encode('login', '0', 'You must connect to the VM before logging in.')); + return; + } + var res = await this.auth!.Authenticate(msgArr[1], client); + if (res.clientSuccess) { + 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) { + // kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that + // so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list + this.connectionClosed(old); + await old.kick(); + } + // Set username + this.renameUser(client, res.username); + // Set rank + client.rank = res.rank; + if (client.rank === Rank.Admin) { + client.sendMsg(guacutils.encode('admin', '0', '1')); + } else if (client.rank === Rank.Moderator) { + client.sendMsg(guacutils.encode('admin', '0', '3', this.ModPerms.toString())); + } + this.clients.forEach((c) => c.sendMsg(guacutils.encode('adduser', '1', client.username!, client.rank.toString()))); + } else { + client.sendMsg(guacutils.encode('login', '0', res.error!)); + if (res.error === 'You are banned') { + client.kick(); + } + } + break; + case 'list': + client.sendMsg(guacutils.encode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); + break; + case 'connect': + if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { + client.sendMsg(guacutils.encode('connect', '0')); + return; + } + client.connectedToNode = true; + client.sendMsg(guacutils.encode('connect', '1', '1', this.Config.vm.snapshots ? '1' : '0', '0')); + if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); + if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode('chat', '', this.Config.collabvm.motd)); + if (this.screenHidden) { + client.sendMsg(guacutils.encode('size', '0', '1024', '768')); + client.sendMsg(guacutils.encode('png', '0', '0', '0', '0', this.screenHiddenImg)); + } else { + await this.SendFullScreenWithSize(client); + } + client.sendMsg(guacutils.encode('sync', Date.now().toString())); + if (this.voteInProgress) this.sendVoteUpdate(client); + this.sendTurnUpdate(client); + break; + case 'view': + if (client.connectedToNode) return; + if (client.username || msgArr.length !== 3 || msgArr[1] !== this.Config.collabvm.node) { + // The use of connect here is intentional. + client.sendMsg(guacutils.encode('connect', '0')); + return; + } + + switch (msgArr[2]) { + case '0': + client.viewMode = 0; + break; + case '1': + client.viewMode = 1; + break; + default: + client.sendMsg(guacutils.encode('connect', '0')); + return; + } + + client.sendMsg(guacutils.encode('connect', '1', '1', this.Config.vm.snapshots ? '1' : '0', '0')); + if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); + if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode('chat', '', this.Config.collabvm.motd)); + + if (client.viewMode == 1) { + if (this.screenHidden) { + client.sendMsg(guacutils.encode('size', '0', '1024', '768')); + client.sendMsg(guacutils.encode('png', '0', '0', '0', '0', this.screenHiddenImg)); + } else { + await this.SendFullScreenWithSize(client); + } + client.sendMsg(guacutils.encode('sync', Date.now().toString())); + } + + if (this.voteInProgress) this.sendVoteUpdate(client); + this.sendTurnUpdate(client); + break; + case 'rename': + if (!client.RenameRateLimit.request()) return; + if (client.connectedToNode && client.IP.muted) return; + if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) { + client.sendMsg(guacutils.encode('chat', '', 'Go to your account settings to change your username.')); + return; + } + if (this.Config.auth.enabled && msgArr[1] !== undefined) { + // Don't send system message to a user without a username since it was likely an automated attempt by the webapp + if (client.username) client.sendMsg(guacutils.encode('chat', '', 'You need to log in to do that.')); + if (client.rank !== Rank.Unregistered) return; + this.renameUser(client, undefined); + return; + } + this.renameUser(client, msgArr[1]); + break; + case 'chat': + if (!client.username) return; + if (client.IP.muted) return; + if (msgArr.length !== 2) return; + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) { + client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); + return; + } + var msg = Utilities.HTMLSanitize(msgArr[1]); + // One of the things I hated most about the old server is it completely discarded your message if it was too long + if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength); + if (msg.trim().length < 1) return; + + this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', client.username!, msg))); + this.ChatHistory.push({ user: client.username, msg: msg }); + client.onMsgSent(); + break; + case 'turn': + if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) { + client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); + return; + } + if (!client.TurnRateLimit.request()) return; + if (!client.connectedToNode) return; + if (msgArr.length > 2) return; + var takingTurn: boolean; + if (msgArr.length === 1) takingTurn = true; + else + switch (msgArr[1]) { + case '0': + if (this.indefiniteTurn === client) { + this.indefiniteTurn = null; + } + takingTurn = false; + break; + case '1': + takingTurn = true; + break; + default: + return; + break; + } + if (takingTurn) { + var currentQueue = this.TurnQueue.toArray(); + // If the user is already in the turn queue, ignore the turn request. + if (currentQueue.indexOf(client) !== -1) return; + // If they're muted, also ignore the turn request. + // Send them the turn queue to prevent client glitches + if (client.IP.muted) return; + if (this.Config.collabvm.turnlimit.enabled) { + // Get the amount of users in the turn queue with the same IP as the user requesting a turn. + let turns = currentQueue.filter((user) => user.IP.address == client.IP.address); + // If it exceeds the limit set in the config, ignore the turn request. + if (turns.length + 1 > this.Config.collabvm.turnlimit.maximum) return; + } + this.TurnQueue.enqueue(client); + if (this.TurnQueue.size === 1) this.nextTurn(); + } else { + var hadturn = this.TurnQueue.peek() === client; + this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((u) => u !== client)); + if (hadturn) this.nextTurn(); + } + this.sendTurnUpdate(); + break; + case 'mouse': + if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) 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.GetDisplay()!.MouseEvent(x, y, mask); + break; + case 'key': + if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; + var keysym = parseInt(msgArr[1]); + var down = parseInt(msgArr[2]); + if (keysym === undefined || (down !== 0 && down !== 1)) return; + this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false); + break; + case 'vote': + if (!this.Config.vm.snapshots) return; + if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; + if (!client.connectedToNode) return; + if (msgArr.length !== 2) return; + if (!client.VoteRateLimit.request()) return; + switch (msgArr[1]) { + case '1': + if (!this.voteInProgress) { + if (this.voteCooldown !== 0) { + client.sendMsg(guacutils.encode('vote', '3', this.voteCooldown.toString())); + return; + } + this.startVote(); + this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has started a vote to reset the VM.`))); + } else if (client.IP.vote !== true) this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has voted yes.`))); + client.IP.vote = true; + break; + case '0': + if (!this.voteInProgress) return; + if (client.IP.vote !== false) this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has voted no.`))); + client.IP.vote = false; + break; + } + this.sendVoteUpdate(); + break; + case 'admin': + if (msgArr.length < 2) return; + switch (msgArr[1]) { + case '2': + // Login + if (this.Config.auth.enabled) { + client.sendMsg(guacutils.encode('chat', '', 'This server does not support staff passwords. Please log in to become staff.')); + return; + } + if (!client.LoginRateLimit.request() || !client.username) return; + if (msgArr.length !== 3) return; + var sha256 = createHash('sha256'); + sha256.update(msgArr[2]); + var pwdHash = sha256.digest('hex'); + sha256.destroy(); + if (pwdHash === this.Config.collabvm.adminpass) { + client.rank = Rank.Admin; + client.sendMsg(guacutils.encode('admin', '0', '1')); + } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { + client.rank = Rank.Moderator; + client.sendMsg(guacutils.encode('admin', '0', '3', this.ModPerms.toString())); + } else if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) { + client.rank = Rank.Turn; + client.sendMsg(guacutils.encode('chat', '', 'You may now take turns.')); + } else { + client.sendMsg(guacutils.encode('admin', '0', '0')); + return; + } + if (this.screenHidden) { + await this.SendFullScreenWithSize(client); + + client.sendMsg(guacutils.encode('sync', Date.now().toString())); + } + + this.clients.forEach((c) => c.sendMsg(guacutils.encode('adduser', '1', client.username!, client.rank.toString()))); + break; + 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.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.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.MonitorCommand("system_reset"); - break; - case "12": - // Ban - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return; - var user = this.clients.find(c => c.username === msgArr[2]); - if (!user) return; - user.ban(); - case "13": - // Force Vote - if (msgArr.length !== 3) return; - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return; - if (!this.voteInProgress) return; - switch (msgArr[2]) { - case "1": - this.endVote(true); - break; - case "0": - this.endVote(false); - break; - } - break; - case "14": - // Mute - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return; - if (msgArr.length !== 4) return; - var user = this.clients.find(c => c.username === msgArr[2]); - if (!user) return; - var permamute; - switch (msgArr[3]) { - case "0": - permamute = false; - break; - case "1": - permamute = true; - break; - default: - return; - } - user.mute(permamute); - break; - case "15": - // Kick - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return; - var user = this.clients.find(c => c.username === msgArr[2]); - if (!user) return; - user.kick(); - break; - case "16": - // End turn - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; - if (msgArr.length !== 3) return; - var user = this.clients.find(c => c.username === msgArr[2]); - if (!user) return; - this.endTurn(user); - break; - case "17": - // Clear turn queue - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; - if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return; - this.clearTurns(); - break; - case "18": - // Rename user - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return; - if (this.Config.auth.enabled) { - client.sendMsg(guacutils.encode("chat", "", "Cannot rename users on a server that uses authentication.")); - } - if (msgArr.length !== 4) return; - var user = this.clients.find(c => c.username === msgArr[2]); - if (!user) return; - this.renameUser(user, msgArr[3]); - break; - case "19": - // Get IP - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return; - if (msgArr.length !== 3) return; - var user = this.clients.find(c => c.username === msgArr[2]); - if (!user) return; - client.sendMsg(guacutils.encode("admin", "19", msgArr[2], user.IP.address)); - break; - case "20": - // Steal turn - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; - this.bypassTurn(client); - break; - case "21": - // XSS - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return; - if (msgArr.length !== 3) return; - switch (client.rank) { - case Rank.Admin: - - this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, msgArr[2]))); - - this.ChatHistory.push({user: client.username!, msg: msgArr[2]}); - break; - case Rank.Moderator: - - this.clients.filter(c => c.rank !== Rank.Admin).forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, msgArr[2]))); - - this.clients.filter(c => c.rank === Rank.Admin).forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, Utilities.HTMLSanitize(msgArr[2])))); - break; - } - break; - case "22": - // Toggle turns - if (client.rank !== Rank.Admin) return; - if (msgArr.length !== 3) return; - switch (msgArr[2]) { - case "0": - this.clearTurns(); - this.turnsAllowed = false; - break; - case "1": - this.turnsAllowed = true; - break; - } - break; - case "23": - // Indefinite turn - if (client.rank !== Rank.Admin) return; - this.indefiniteTurn = client; - this.TurnQueue = Queue.from([client, ...this.TurnQueue.toArray().filter(c=>c!==client)]); - this.sendTurnUpdate(); - break; - case "24": - // Hide screen - if (client.rank !== Rank.Admin) return; - if (msgArr.length !== 3) return; - switch (msgArr[2]) { - case "0": - this.screenHidden = true; - this.clients.filter(c => c.rank == Rank.Unregistered).forEach(client => { - client.sendMsg(guacutils.encode("size", "0", "1024", "768")); - client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg)); - client.sendMsg(guacutils.encode("sync", Date.now().toString())); - }); - break; - case "1": - this.screenHidden = false; - let displaySize = this.VM.GetDisplay().Size(); - - let encoded = await this.MakeRectData({ - x: 0, - y: 0, - width: displaySize.width, - height: displaySize.height - }); - + if (msgArr.length !== 4 || msgArr[2] !== this.Config.collabvm.node) return; + 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.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.MonitorCommand('system_reset'); + break; + case '12': + // Ban + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + user.ban(); + case '13': + // Force Vote + if (msgArr.length !== 3) return; + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return; + if (!this.voteInProgress) return; + switch (msgArr[2]) { + case '1': + this.endVote(true); + break; + case '0': + this.endVote(false); + break; + } + break; + case '14': + // Mute + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return; + if (msgArr.length !== 4) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + var permamute; + switch (msgArr[3]) { + case '0': + permamute = false; + break; + case '1': + permamute = true; + break; + default: + return; + } + user.mute(permamute); + break; + case '15': + // Kick + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + user.kick(); + break; + case '16': + // End turn + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + if (msgArr.length !== 3) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + this.endTurn(user); + break; + case '17': + // Clear turn queue + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return; + this.clearTurns(); + break; + case '18': + // Rename user + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return; + if (this.Config.auth.enabled) { + client.sendMsg(guacutils.encode('chat', '', 'Cannot rename users on a server that uses authentication.')); + } + if (msgArr.length !== 4) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + this.renameUser(user, msgArr[3]); + break; + case '19': + // Get IP + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return; + if (msgArr.length !== 3) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + client.sendMsg(guacutils.encode('admin', '19', msgArr[2], user.IP.address)); + break; + case '20': + // Steal turn + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + this.bypassTurn(client); + break; + case '21': + // XSS + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return; + if (msgArr.length !== 3) return; + switch (client.rank) { + case Rank.Admin: + this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', client.username!, msgArr[2]))); - 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; - } - break; - case "25": - if (client.rank !== Rank.Admin || msgArr.length !== 3) - return; - this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", msgArr[2]))); - break; - - } - break; + this.ChatHistory.push({ user: client.username!, msg: msgArr[2] }); + break; + case Rank.Moderator: + this.clients.filter((c) => c.rank !== Rank.Admin).forEach((c) => c.sendMsg(guacutils.encode('chat', client.username!, msgArr[2]))); - } - } + this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.sendMsg(guacutils.encode('chat', client.username!, Utilities.HTMLSanitize(msgArr[2])))); + break; + } + break; + case '22': + // Toggle turns + if (client.rank !== Rank.Admin) return; + if (msgArr.length !== 3) return; + switch (msgArr[2]) { + case '0': + this.clearTurns(); + this.turnsAllowed = false; + break; + case '1': + this.turnsAllowed = true; + break; + } + break; + case '23': + // Indefinite turn + if (client.rank !== Rank.Admin) return; + this.indefiniteTurn = client; + this.TurnQueue = Queue.from([client, ...this.TurnQueue.toArray().filter((c) => c !== client)]); + this.sendTurnUpdate(); + break; + case '24': + // Hide screen + if (client.rank !== Rank.Admin) return; + if (msgArr.length !== 3) return; + switch (msgArr[2]) { + case '0': + this.screenHidden = true; + this.clients + .filter((c) => c.rank == Rank.Unregistered) + .forEach((client) => { + client.sendMsg(guacutils.encode('size', '0', '1024', '768')); + client.sendMsg(guacutils.encode('png', '0', '0', '0', '0', this.screenHiddenImg)); + client.sendMsg(guacutils.encode('sync', Date.now().toString())); + }); + break; + case '1': + this.screenHidden = false; + let displaySize = this.VM.GetDisplay().Size(); - getUsernameList() : string[] { - var arr : string[] = []; - - this.clients.filter(c => c.username).forEach((c) => arr.push(c.username!)); - return arr; - } + let encoded = await this.MakeRectData({ + x: 0, + y: 0, + width: displaySize.width, + height: displaySize.height + }); - renameUser(client : User, newName? : string) { - // This shouldn't need a ternary but it does for some reason - var hadName : boolean = client.username ? true : false; - var oldname : any; - if (hadName) oldname = client.username; - var status = "0"; - if (!newName) { - client.assignGuestName(this.getUsernameList()); - } else { - newName = newName.trim(); - if (hadName && newName === oldname) { - - client.sendMsg(guacutils.encode("rename", "0", "0", client.username!, client.rank.toString())); - return; - } - if (this.getUsernameList().indexOf(newName) !== -1) { - client.assignGuestName(this.getUsernameList()); - if(client.connectedToNode) { - status = "1"; - } - } else - if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) { - client.assignGuestName(this.getUsernameList()); - status = "2"; - } else - if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) { - client.assignGuestName(this.getUsernameList()); - status = "3"; - } else client.username = newName; - } - - client.sendMsg(guacutils.encode("rename", "0", status, client.username!, client.rank.toString())); - if (hadName) { - 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 { - 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()))); - } - } + 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; + } + break; + case '25': + if (client.rank !== Rank.Admin || msgArr.length !== 3) return; + this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', msgArr[2]))); + break; + } + break; + } + } - getAdduserMsg() : string { - var arr : string[] = ["adduser", this.clients.filter(c=>c.username).length.toString()]; - - this.clients.filter(c=>c.username).forEach((c) => arr.push(c.username!, c.rank.toString())); - return guacutils.encode(...arr); - } - getChatHistoryMsg() : string { - var arr : string[] = ["chat"]; - this.ChatHistory.forEach(c => arr.push(c.user, c.msg)); - return guacutils.encode(...arr); - } - private sendTurnUpdate(client? : User) { - var turnQueueArr = this.TurnQueue.toArray(); - var turntime; - if (this.indefiniteTurn === null) turntime = (this.TurnTime * 1000); - else turntime = 9999999999; - var arr = ["turn", turntime.toString(), this.TurnQueue.size.toString()]; - // @ts-ignore - this.TurnQueue.forEach((c) => arr.push(c.username)); - var currentTurningUser = this.TurnQueue.peek(); - if (client) { - client.sendMsg(guacutils.encode(...arr)); - return; - } - this.clients.filter(c => (c !== currentTurningUser && c.connectedToNode)).forEach((c) => { - if (turnQueueArr.indexOf(c) !== -1) { - var time; - if (this.indefiniteTurn === null) time = ((this.TurnTime * 1000) + ((turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000)); - else time = 9999999999; - c.sendMsg(guacutils.encode(...arr, time.toString())); - } else { - c.sendMsg(guacutils.encode(...arr)); - } - }); - if (currentTurningUser) - currentTurningUser.sendMsg(guacutils.encode(...arr)); - } - private nextTurn() { - clearInterval(this.TurnInterval); - if (this.TurnQueue.size === 0) { - } else { - this.TurnTime = this.Config.collabvm.turnTime; - this.TurnInterval = setInterval(() => this.turnInterval(), 1000); - } - this.sendTurnUpdate(); - } + getUsernameList(): string[] { + var arr: string[] = []; - clearTurns() { - clearInterval(this.TurnInterval); - this.TurnQueue.clear(); - this.sendTurnUpdate(); - } + this.clients.filter((c) => c.username).forEach((c) => arr.push(c.username!)); + return arr; + } - bypassTurn(client : User) { - var a = this.TurnQueue.toArray().filter(c => c !== client); - this.TurnQueue = Queue.from([client, ...a]); - this.nextTurn(); - } + renameUser(client: User, newName?: string) { + // This shouldn't need a ternary but it does for some reason + var hadName: boolean = client.username ? true : false; + var oldname: any; + if (hadName) oldname = client.username; + var status = '0'; + if (!newName) { + client.assignGuestName(this.getUsernameList()); + } else { + newName = newName.trim(); + if (hadName && newName === oldname) { + client.sendMsg(guacutils.encode('rename', '0', '0', client.username!, client.rank.toString())); + return; + } + if (this.getUsernameList().indexOf(newName) !== -1) { + client.assignGuestName(this.getUsernameList()); + if (client.connectedToNode) { + status = '1'; + } + } else if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) { + client.assignGuestName(this.getUsernameList()); + status = '2'; + } else if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) { + client.assignGuestName(this.getUsernameList()); + status = '3'; + } else client.username = newName; + } - endTurn(client : User) { - var hasTurn = (this.TurnQueue.peek() === client); - this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(c => c !== client)); - if (hasTurn) this.nextTurn(); - else this.sendTurnUpdate(); - } + client.sendMsg(guacutils.encode('rename', '0', status, client.username!, client.rank.toString())); + if (hadName) { + 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 { + 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()))); + } + } - private turnInterval() { - if (this.indefiniteTurn !== null) return; - this.TurnTime--; - if (this.TurnTime < 1) { - this.TurnQueue.dequeue(); - this.nextTurn(); - } - } + getAdduserMsg(): string { + var arr: string[] = ['adduser', this.clients.filter((c) => c.username).length.toString()]; - 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", rect.x.toString(), rect.y.toString(), encodedb64)); - c.sendMsg(guacutils.encode("sync", Date.now().toString())); - }); - } + this.clients.filter((c) => c.username).forEach((c) => arr.push(c.username!, c.rank.toString())); + return guacutils.encode(...arr); + } - 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())) - }); - } + getChatHistoryMsg(): string { + var arr: string[] = ['chat']; + this.ChatHistory.forEach((c) => arr.push(c.user, c.msg)); + return guacutils.encode(...arr); + } - private async SendFullScreenWithSize(client: User) { - let display = this.VM.GetDisplay(); - let displaySize = display.Size(); + private sendTurnUpdate(client?: User) { + var turnQueueArr = this.TurnQueue.toArray(); + var turntime; + if (this.indefiniteTurn === null) turntime = this.TurnTime * 1000; + else turntime = 9999999999; + var arr = ['turn', turntime.toString(), this.TurnQueue.size.toString()]; + // @ts-ignore + this.TurnQueue.forEach((c) => arr.push(c.username)); + var currentTurningUser = this.TurnQueue.peek(); + if (client) { + client.sendMsg(guacutils.encode(...arr)); + return; + } + this.clients + .filter((c) => c !== currentTurningUser && c.connectedToNode) + .forEach((c) => { + if (turnQueueArr.indexOf(c) !== -1) { + var time; + if (this.indefiniteTurn === null) time = this.TurnTime * 1000 + (turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000; + else time = 9999999999; + c.sendMsg(guacutils.encode(...arr, time.toString())); + } else { + c.sendMsg(guacutils.encode(...arr)); + } + }); + if (currentTurningUser) currentTurningUser.sendMsg(guacutils.encode(...arr)); + } + private nextTurn() { + clearInterval(this.TurnInterval); + if (this.TurnQueue.size === 0) { + } else { + this.TurnTime = this.Config.collabvm.turnTime; + this.TurnInterval = setInterval(() => this.turnInterval(), 1000); + } + this.sendTurnUpdate(); + } - let encoded = await this.MakeRectData({ - x: 0, - y: 0, - width: displaySize.width, - height: displaySize.height - }); + clearTurns() { + clearInterval(this.TurnInterval); + this.TurnQueue.clear(); + this.sendTurnUpdate(); + } - client.sendMsg(guacutils.encode("size", "0", displaySize.width.toString(), displaySize.height.toString())); - client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", encoded)); - } + bypassTurn(client: User) { + var a = this.TurnQueue.toArray().filter((c) => c !== client); + this.TurnQueue = Queue.from([client, ...a]); + this.nextTurn(); + } - private async MakeRectData(rect: Rect) { - let display = this.VM.GetDisplay(); - let displaySize = display.Size(); + endTurn(client: User) { + var hasTurn = this.TurnQueue.peek() === client; + this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((c) => c !== client)); + if (hasTurn) this.nextTurn(); + else this.sendTurnUpdate(); + } - let encoded = await EncodeJpeg(display.Buffer(), displaySize, rect); + private turnInterval() { + if (this.indefiniteTurn !== null) return; + this.TurnTime--; + if (this.TurnTime < 1) { + this.TurnQueue.dequeue(); + this.nextTurn(); + } + } - return encoded.toString('base64'); - } + private async OnDisplayRectangle(rect: Rect) { + let encodedb64 = await this.MakeRectData(rect); - getThumbnail() : Promise { - return new Promise(async (res, rej) => { - let display = this.VM.GetDisplay(); - if(display == null) - return; + 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', rect.x.toString(), rect.y.toString(), encodedb64)); + c.sendMsg(guacutils.encode('sync', Date.now().toString())); + }); + } - // TODO: pass custom options to Sharp.resize() probably - let out = await sharp(display.Buffer(), {raw: GetRawSharpOptions(display.Size())}) - .resize(400, 300, { fit: 'fill' }) - .toFormat('jpeg') - .toBuffer(); + 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())); + }); + } - res(out.toString('base64')); - }); - } + private async SendFullScreenWithSize(client: User) { + let display = this.VM.GetDisplay(); + let displaySize = display.Size(); - startVote() { - if (this.voteInProgress) return; - this.voteInProgress = true; - this.clients.forEach(c => c.sendMsg(guacutils.encode("vote", "0"))); - this.voteTime = this.Config.collabvm.voteTime; - this.voteInterval = setInterval(() => { - this.voteTime--; - if (this.voteTime < 1) { - this.endVote(); - } - }, 1000); - } + let encoded = await this.MakeRectData({ + x: 0, + y: 0, + width: displaySize.width, + height: displaySize.height + }); - endVote(result? : boolean) { - if (!this.voteInProgress) return; - this.voteInProgress = false; - clearInterval(this.voteInterval); - var count = this.getVoteCounts(); - 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.Reset(); - } else { - this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has lost."))); - } - this.clients.forEach(c => { - c.IP.vote = null; - }); - this.voteCooldown = this.Config.collabvm.voteCooldown; - this.voteCooldownInterval = setInterval(() => { - this.voteCooldown--; - if (this.voteCooldown < 1) - clearInterval(this.voteCooldownInterval); - }, 1000); - } + client.sendMsg(guacutils.encode('size', '0', displaySize.width.toString(), displaySize.height.toString())); + client.sendMsg(guacutils.encode('png', '0', '0', '0', '0', encoded)); + } - sendVoteUpdate(client? : User) { - if (!this.voteInProgress) return; - var count = this.getVoteCounts(); - var msg = guacutils.encode("vote", "1", (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString()); - if (client) - client.sendMsg(msg); - else - this.clients.forEach((c) => c.sendMsg(msg)); - } + private async MakeRectData(rect: Rect) { + let display = this.VM.GetDisplay(); + let displaySize = display.Size(); - getVoteCounts() : {yes:number,no:number} { - var yes = 0; - var no = 0; - IPDataManager.ForEachIPData((c) => { - if (c.vote === true) yes++; - if (c.vote === false) no++; - }); - return {yes:yes,no:no}; - } + let encoded = await EncodeJpeg(display.Buffer(), displaySize, rect); + + return encoded.toString('base64'); + } + + getThumbnail(): Promise { + return new Promise(async (res, rej) => { + 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, { fit: 'fill' }) + .toFormat('jpeg') + .toBuffer(); + + res(out.toString('base64')); + }); + } + + startVote() { + if (this.voteInProgress) return; + this.voteInProgress = true; + this.clients.forEach((c) => c.sendMsg(guacutils.encode('vote', '0'))); + this.voteTime = this.Config.collabvm.voteTime; + this.voteInterval = setInterval(() => { + this.voteTime--; + if (this.voteTime < 1) { + this.endVote(); + } + }, 1000); + } + + endVote(result?: boolean) { + if (!this.voteInProgress) return; + this.voteInProgress = false; + clearInterval(this.voteInterval); + var count = this.getVoteCounts(); + 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.Reset(); + } else { + this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', 'The vote to reset the VM has lost.'))); + } + this.clients.forEach((c) => { + c.IP.vote = null; + }); + this.voteCooldown = this.Config.collabvm.voteCooldown; + this.voteCooldownInterval = setInterval(() => { + this.voteCooldown--; + if (this.voteCooldown < 1) clearInterval(this.voteCooldownInterval); + }, 1000); + } + + sendVoteUpdate(client?: User) { + if (!this.voteInProgress) return; + var count = this.getVoteCounts(); + var msg = guacutils.encode('vote', '1', (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString()); + if (client) client.sendMsg(msg); + else this.clients.forEach((c) => c.sendMsg(msg)); + } + + getVoteCounts(): VoteTally { + let yes = 0; + let no = 0; + IPDataManager.ForEachIPData((c) => { + if (c.vote === true) yes++; + if (c.vote === false) no++; + }); + return { yes: yes, no: no }; + } } diff --git a/cvmts/src/guacutils.ts b/cvmts/src/guacutils.ts index 0e0d41c..0647f70 100644 --- a/cvmts/src/guacutils.ts +++ b/cvmts/src/guacutils.ts @@ -1,43 +1,37 @@ -export function decode(string : string) : string[] { - let pos = -1; - let sections = []; +export function decode(string: string): string[] { + let pos = -1; + let sections = []; - for(;;) { - let len = string.indexOf('.', pos + 1); + for (;;) { + let len = string.indexOf('.', pos + 1); - if(len === -1) - break; + if (len === -1) break; - pos = parseInt(string.slice(pos + 1, len)) + len + 1; + pos = parseInt(string.slice(pos + 1, len)) + len + 1; - // don't allow funky protocol length - if(pos > string.length) - return []; + // don't allow funky protocol length + if (pos > string.length) return []; - sections.push(string.slice(len + 1, pos)); + sections.push(string.slice(len + 1, pos)); + const sep = string.slice(pos, pos + 1); - const sep = string.slice(pos, pos + 1); + if (sep === ',') continue; + else if (sep === ';') break; + // Invalid data. + else return []; + } - if(sep === ',') - continue; - else if(sep === ';') - break; - else - // Invalid data. - return []; - } - - return sections; + return sections; } -export function encode(...string : string[]) : string { - let command = ''; +export function encode(...string: string[]): string { + let command = ''; - for(var i = 0; i < string.length; i++) { - let current = string[i]; - command += current.toString().length + '.' + current; - command += ( i < string.length - 1 ? ',' : ';'); - } - return command; -} \ No newline at end of file + for (var i = 0; i < string.length; i++) { + let current = string[i]; + command += current.toString().length + '.' + current; + command += i < string.length - 1 ? ',' : ';'; + } + return command; +} diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index 88c1843..89cd218 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -1,6 +1,6 @@ import * as toml from 'toml'; import IConfig from './IConfig.js'; -import * as fs from "fs"; +import * as fs from 'fs'; import WSServer from './WSServer.js'; import { QemuVM, QemuVmDefinition } from '@cvmts/qemu'; @@ -8,51 +8,50 @@ import { QemuVM, QemuVmDefinition } from '@cvmts/qemu'; import * as Shared from '@cvmts/shared'; import AuthManager from './AuthManager.js'; -let logger = new Shared.Logger("CVMTS.Init"); +let logger = new Shared.Logger('CVMTS.Init'); -logger.Info("CollabVM Server starting up"); +logger.Info('CollabVM Server starting up'); // Parse the config file -var Config : IConfig; +let Config: IConfig; -if (!fs.existsSync("config.toml")) { - logger.Error("Fatal error: Config.toml not found. Please copy config.example.toml and fill out fields") - process.exit(1); +if (!fs.existsSync('config.toml')) { + 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); + var configRaw = fs.readFileSync('config.toml').toString(); + Config = toml.parse(configRaw); } catch (e) { - logger.Error("Fatal error: Failed to read or parse the config file: {0}", (e as Error).message); - process.exit(1); + logger.Error('Fatal error: Failed to read or parse the config file: {0}', (e as Error).message); + process.exit(1); } - async function start() { - // Print a warning if qmpSockDir is set - // and the host OS is Windows, as this - // configuration will very likely not work. - if(process.platform === "win32" && Config.vm.qmpSockDir) { - 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."); - } + // Print a warning if qmpSockDir is set + // and the host OS is Windows, as this + // configuration will very likely not work. + if (process.platform === 'win32' && Config.vm.qmpSockDir) { + 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 - let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; + // Init the auth manager if enabled + let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; - // Fire up the VM - let def: QemuVmDefinition = { - id: Config.collabvm.node, - command: Config.vm.qemuArgs - } + // Fire up the VM + let def: QemuVmDefinition = { + id: Config.collabvm.node, + command: Config.vm.qemuArgs + }; - var VM = new QemuVM(def); - await VM.Start(); + var VM = new QemuVM(def); + await VM.Start(); - // Start up the websocket server - var WS = new WSServer(Config, VM, auth); - WS.listen(); + // Start up the websocket server + var WS = new WSServer(Config, VM, auth); + WS.listen(); } -start(); \ No newline at end of file +start(); diff --git a/qemu/src/QemuUtil.ts b/qemu/src/QemuUtil.ts index 47eceff..b6b4715 100644 --- a/qemu/src/QemuUtil.ts +++ b/qemu/src/QemuUtil.ts @@ -1,4 +1,4 @@ -import { Size, Rect } from "@cvmts/shared"; +import { Size, Rect } from '@cvmts/shared'; export function BatchRects(size: Size, rects: Array): Rect { var mergedX = size.width; @@ -38,4 +38,4 @@ export function BatchRects(size: Size, rects: Array): Rect { width: mergedWidth, height: mergedHeight }; -} \ No newline at end of file +} diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 87e160d..243fb94 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -30,8 +30,6 @@ 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; } @@ -68,10 +66,9 @@ export class QemuVM extends EventEmitter { // build additional command line statements to enable qmp/vnc over unix sockets // FIXME: Still use TCP if on Windows. - if(!this.addedAdditionalArguments) { + if (!this.addedAdditionalArguments) { cmd += ' -no-shutdown'; - if(gVMShouldSnapshot) - cmd += ' -snapshot'; + if (gVMShouldSnapshot) cmd += ' -snapshot'; cmd += ` -qmp unix:${this.GetQmpPath()},server,wait -vnc unix:${this.GetVncPath()}`; this.definition.command = cmd; this.addedAdditionalArguments = true; @@ -183,7 +180,7 @@ export class QemuVM extends EventEmitter { if (self.qmpConnected) { await self.DisconnectQmp(); } - + self.DisconnectDisplay(); if (self.state != VMState.Stopping) { @@ -275,16 +272,13 @@ export class QemuVM extends EventEmitter { private async DisconnectQmp() { if (this.qmpConnected) return; - if(this.qmpInstance == null) - return; + if (this.qmpInstance == null) return; this.qmpConnected = false; this.qmpInstance.end(); this.qmpInstance = null; try { await unlink(this.GetQmpPath()); - } catch(err) { - - } + } catch (err) {} } } diff --git a/qemu/src/QmpClient.ts b/qemu/src/QmpClient.ts index df20613..3cfda5e 100644 --- a/qemu/src/QmpClient.ts +++ b/qemu/src/QmpClient.ts @@ -76,7 +76,7 @@ export default class QmpClient extends Socket { // just rethrow lol //throw err; - console.log("you have pants: rules,", err); + console.log('you have pants: rules,', err); }); this.once('data', (data) => { diff --git a/shared/src/StringLike.ts b/shared/src/StringLike.ts index 2ed941b..ecc6989 100644 --- a/shared/src/StringLike.ts +++ b/shared/src/StringLike.ts @@ -1,4 +1,3 @@ - // TODO: `Object` has a toString(), but we should probably gate that off /// Interface for things that can be turned into strings export interface ToStringable { @@ -6,4 +5,4 @@ export interface ToStringable { } /// A type for strings, or things that can (in a valid manner) be turned into strings -export type StringLike = string | ToStringable; \ No newline at end of file +export type StringLike = string | ToStringable; diff --git a/shared/src/index.ts b/shared/src/index.ts index 8b744b7..82d7877 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -17,8 +17,8 @@ export type Size = { }; export type Rect = { - x: number, - y: number, - width: number, - height: number -}; \ No newline at end of file + x: number; + y: number; + width: number; + height: number; +}; From a904f26961ead220969bdb5a326c3f3dbf8d0b8a Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 24 Apr 2024 04:18:05 -0400 Subject: [PATCH 06/60] abstract jpeg encoding away from "WSServer" Additionally make thumbnail encoding threadpooled as well, just so it (probably) doesn't block as much. --- cvmts/src/JPEGEncoder.ts | 59 +++++++++++++++++++++++++ cvmts/src/JPEGEncoderWorker.ts | 2 +- cvmts/src/ThumbnailJPEGEncoderWorker.ts | 31 +++++++++++++ cvmts/src/WSServer.ts | 57 +++--------------------- qemu/src/QemuDisplay.ts | 4 ++ 5 files changed, 102 insertions(+), 51 deletions(-) create mode 100644 cvmts/src/JPEGEncoder.ts create mode 100644 cvmts/src/ThumbnailJPEGEncoderWorker.ts diff --git a/cvmts/src/JPEGEncoder.ts b/cvmts/src/JPEGEncoder.ts new file mode 100644 index 0000000..2cc3a6c --- /dev/null +++ b/cvmts/src/JPEGEncoder.ts @@ -0,0 +1,59 @@ +import path from 'node:path'; +import Piscina from 'piscina'; + +import { Size, Rect } from '@cvmts/shared'; + +const kMaxJpegThreads = 4; +const kIdleTimeout = 25000; + +// Thread pool for doing JPEG encoding for rects. +const TheJpegEncoderPool = new Piscina({ + filename: path.join(import.meta.dirname + '/JPEGEncoderWorker.js'), + idleTimeout: kIdleTimeout, + maxThreads: kMaxJpegThreads +}); + +const TheThumbnailEncoderPool = new Piscina({ + filename: path.join(import.meta.dirname + '/ThumbnailJPEGEncoderWorker.js'), + idleTimeout: kIdleTimeout, + maxThreads: kMaxJpegThreads +}); + +// A good balance. TODO: Configurable? +let gJpegQuality = 35; + +export class JPEGEncoder { + + static SetQuality(quality: number) { + gJpegQuality = quality; + } + + static async EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promise { + let offset = (rect.y * displaySize.width + rect.x) * 4; + + let res = await TheJpegEncoderPool.run({ + buffer: canvas.subarray(offset), + width: rect.width, + height: rect.height, + stride: displaySize.width, + quality: gJpegQuality + }); + + // TODO: There's probably (definitely) a better way to fix this + if (res == undefined) return Buffer.from([]); + + // have to manually turn it back into a buffer because + // Piscina for some reason turns it into a Uint8Array + return Buffer.from(res); + } + + static async EncodeThumbnail(buffer: Buffer, size: Size) : Promise { + let res = await TheThumbnailEncoderPool.run({ + buffer: buffer, + size: size, + quality: gJpegQuality + }); + + return Buffer.from(res) + } +} diff --git a/cvmts/src/JPEGEncoderWorker.ts b/cvmts/src/JPEGEncoderWorker.ts index c5f93cf..124914b 100644 --- a/cvmts/src/JPEGEncoderWorker.ts +++ b/cvmts/src/JPEGEncoderWorker.ts @@ -9,7 +9,7 @@ export default async (opts: any) => { height: opts.height, subsampling: jpegTurbo.SAMP_422, stride: opts.stride, - quality: opts.quality + quality: opts.quality || 75 }); return Piscina.move(res); diff --git a/cvmts/src/ThumbnailJPEGEncoderWorker.ts b/cvmts/src/ThumbnailJPEGEncoderWorker.ts new file mode 100644 index 0000000..535bfd9 --- /dev/null +++ b/cvmts/src/ThumbnailJPEGEncoderWorker.ts @@ -0,0 +1,31 @@ +import { Size } from '@cvmts/shared'; +import Piscina from 'piscina'; +import sharp from 'sharp'; + +const kThumbnailSize: Size = { + width: 400, + height: 300 +}; + +// this returns appropiate Sharp options to deal with CVMTS raw framebuffers +// (which are RGBA bitmaps, essentially. We probably should abstract that out but +// that'd mean having to introduce that to rfb and oihwekjtgferklds;./tghnredsltg;erhds) +function GetRawSharpOptions(size: Size): sharp.CreateRaw { + return { + width: size.width, + height: size.height, + channels: 4 + }; +} + +export default async (opts: any) => { + let out = await sharp(opts.buffer, { raw: GetRawSharpOptions(opts.size) }) + .resize(kThumbnailSize.width, kThumbnailSize.height, { fit: 'fill' }) + .jpeg({ + quality: opts.quality || 75 + }) + .toFormat('jpeg') + .toBuffer(); + + return Piscina.move(out); +}; diff --git a/cvmts/src/WSServer.ts b/cvmts/src/WSServer.ts index ddc3105..78c6d3d 100644 --- a/cvmts/src/WSServer.ts +++ b/cvmts/src/WSServer.ts @@ -17,8 +17,7 @@ import path from 'node:path'; import AuthManager from './AuthManager.js'; import { Size, Rect, Logger } from '@cvmts/shared'; -import sharp from 'sharp'; -import Piscina from 'piscina'; +import { JPEGEncoder } from './JPEGEncoder.js'; // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before @@ -37,43 +36,8 @@ type VoteTally = { no: number; }; -// 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 - }; -} -// Thread pool for doing JPEG encoding for rects. -const TheJpegEncoderPool = new Piscina({ - filename: path.join(import.meta.dirname + '/JPEGEncoderWorker.js'), - minThreads: 4, - maxThreads: 4 -}); - -async function EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promise { - let offset = (rect.y * displaySize.width + rect.x) * 4; - - let res = await TheJpegEncoderPool.run({ - buffer: canvas.subarray(offset), - width: rect.width, - height: rect.height, - stride: displaySize.width, - quality: kJpegQuality - }); - - // TODO: There's probably (definitely) a better way to fix this - if (res == undefined) return Buffer.from([]); - - // have to manually turn it back into a buffer because - // Piscina for some reason turns it into a Uint8Array - return Buffer.from(res); -} export default class WSServer { private Config: IConfig; @@ -930,24 +894,17 @@ export default class WSServer { let display = this.VM.GetDisplay(); let displaySize = display.Size(); - let encoded = await EncodeJpeg(display.Buffer(), displaySize, rect); + let encoded = await JPEGEncoder.EncodeJpeg(display.Buffer(), displaySize, rect); return encoded.toString('base64'); } - getThumbnail(): Promise { - return new Promise(async (res, rej) => { - let display = this.VM.GetDisplay(); - if (display == null) return; + async getThumbnail(): Promise { + let display = this.VM.GetDisplay(); + if (!display.Connected()) throw new Error('VM display is not connected'); - // TODO: pass custom options to Sharp.resize() probably - let out = await sharp(display.Buffer(), { raw: GetRawSharpOptions(display.Size()) }) - .resize(400, 300, { fit: 'fill' }) - .toFormat('jpeg') - .toBuffer(); - - res(out.toString('base64')); - }); + let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size()); + return buf.toString('base64'); } startVote() { diff --git a/qemu/src/QemuDisplay.ts b/qemu/src/QemuDisplay.ts index cb4e9d4..efcfd47 100644 --- a/qemu/src/QemuDisplay.ts +++ b/qemu/src/QemuDisplay.ts @@ -116,6 +116,10 @@ export class QemuDisplay extends EventEmitter { this.displayVnc.disconnect(); } + Connected() { + return this.displayVnc.connected; + } + Buffer(): Buffer { return this.displayVnc.fb; } From e03bf57ede40397782d06a55980f2cf4f1495264 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 24 Apr 2024 04:38:47 -0400 Subject: [PATCH 07/60] .. ok, i guess one node buffer can't be moved but the other magically can. --- cvmts/src/ThumbnailJPEGEncoderWorker.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/cvmts/src/ThumbnailJPEGEncoderWorker.ts b/cvmts/src/ThumbnailJPEGEncoderWorker.ts index 535bfd9..2b96658 100644 --- a/cvmts/src/ThumbnailJPEGEncoderWorker.ts +++ b/cvmts/src/ThumbnailJPEGEncoderWorker.ts @@ -19,13 +19,19 @@ function GetRawSharpOptions(size: Size): sharp.CreateRaw { } export default async (opts: any) => { - let out = await sharp(opts.buffer, { raw: GetRawSharpOptions(opts.size) }) - .resize(kThumbnailSize.width, kThumbnailSize.height, { fit: 'fill' }) - .jpeg({ - quality: opts.quality || 75 - }) - .toFormat('jpeg') - .toBuffer(); + try { + console.log(opts) + let out = await sharp(opts.buffer, { raw: GetRawSharpOptions(opts.size) }) + .resize(kThumbnailSize.width, kThumbnailSize.height, { fit: 'fill' }) + .jpeg({ + quality: opts.quality || 75 + }) + .toFormat('jpeg') + .toBuffer(); + + return out; + } catch { + return; + } - return Piscina.move(out); }; From 2e05504e4a2a5d246799c2a8886e49d678ef13f0 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 1 May 2024 08:08:43 -0400 Subject: [PATCH 08/60] hastily hand merge yellowcode vote patch thing Co-Authored-By: yellows111 --- config.example.toml | 3 +++ cvmts/src/IConfig.ts | 2 ++ cvmts/src/WSServer.ts | 24 +++++++++++++++++++----- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/config.example.toml b/config.example.toml index c37c86d..ab7d5a6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -21,6 +21,9 @@ secretKey = "hunter2" [auth.guestPermissions] chat = true turn = false +callForReset = false +vote = true + [vm] qemuArgs = "qemu-system-x86_64" diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index 5390386..5d0f98a 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -15,6 +15,8 @@ export default interface IConfig { guestPermissions: { chat: boolean; turn: boolean; + callForReset: boolean; + vote: boolean; }; }; vm: { diff --git a/cvmts/src/WSServer.ts b/cvmts/src/WSServer.ts index 78c6d3d..1194ac5 100644 --- a/cvmts/src/WSServer.ts +++ b/cvmts/src/WSServer.ts @@ -36,9 +36,6 @@ type VoteTally = { no: number; }; - - - export default class WSServer { private Config: IConfig; @@ -489,18 +486,35 @@ export default class WSServer { switch (msgArr[1]) { case '1': if (!this.voteInProgress) { + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) { + client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); + return; + } + if (this.voteCooldown !== 0) { client.sendMsg(guacutils.encode('vote', '3', this.voteCooldown.toString())); return; } this.startVote(); this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has started a vote to reset the VM.`))); - } else if (client.IP.vote !== true) this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has voted yes.`))); + } + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { + client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); + return; + } else if (client.IP.vote !== true) { + this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has voted yes.`))); + } client.IP.vote = true; break; case '0': if (!this.voteInProgress) return; - if (client.IP.vote !== false) this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has voted no.`))); + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { + client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); + return; + } + if (client.IP.vote !== false) { + this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has voted no.`))); + } client.IP.vote = false; break; } From e184bfb085f67b563f5825a6b01699356c790a80 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 22 May 2024 17:56:04 -0400 Subject: [PATCH 09/60] qemu: fix qmp disconnection semi properly this is actually something i need to push to crusttest as well, because this will affect it as well, though not as badly because it will only break certain buttons --- qemu/src/QemuVM.ts | 13 +++++-- qemu/src/QmpClient.ts | 87 ++++++++++++++++++++----------------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 243fb94..ca1e396 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -207,21 +207,26 @@ export class QemuVM extends EventEmitter { if (!this.qmpConnected) { self.qmpInstance = new QmpClient(); - self.qmpInstance.on('close', async () => { + let onQmpError = async (err: Error|undefined) => { self.qmpConnected = false; // If we aren't stopping, then we do actually need to care QMP disconnected if (self.state != VMState.Stopping) { + //if(err !== undefined) // This doesn't show anything useful or maybe I'm just stupid idk + // self.VMLog().Error(`Error: ${err!}`) if (self.qmpFailCount++ < kMaxFailCount) { - this.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times`); + self.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`); + self.VMLog().Error(`Reached max retries, giving up.`); await self.Stop(); } } - }); + }; + + self.qmpInstance.on('close', onQmpError); + self.qmpInstance.on('error', onQmpError); self.qmpInstance.on('event', async (ev) => { switch (ev.event) { diff --git a/qemu/src/QmpClient.ts b/qemu/src/QmpClient.ts index 3cfda5e..fe93d68 100644 --- a/qemu/src/QmpClient.ts +++ b/qemu/src/QmpClient.ts @@ -22,6 +22,12 @@ export default class QmpClient extends Socket { private commandEntries: QmpCommandEntry[] = []; private lastID = 0; + constructor() { + super(); + + this.assignHandlers(); + } + private ExecuteSync(command: string, args: any | null, callback: QmpCallback | null) { let cmd: QmpCommandEntry = { callback: callback, @@ -65,71 +71,60 @@ export default class QmpClient extends Socket { } // this can probably be made async - private ConnectImpl() { + private assignHandlers() { let self = this; - this.once('connect', () => { - this.removeAllListeners('error'); - }); + this.on('connect', () => { + // this should be more correct? + 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(); - this.once('error', (err) => { - // just rethrow lol - //throw err; + 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; - console.log('you have pants: rules,', err); - }); + let callbackEntry = this.commandEntries.find((entry) => entry.id === json.id); + let error: Error | null = json.error ? new Error(json.error.desc) : null; - 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(); + // 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 (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; + if (callbackEntry?.callback) callbackEntry.callback(error, json.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'); + // 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.on('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(); } } From 173ee8149f61ab026eb3e01095d9ac1c88f04e06 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sun, 26 May 2024 16:33:35 -0400 Subject: [PATCH 10/60] auth: Make more resilant to backend failures --- cvmts/src/AuthManager.ts | 10 ++++--- cvmts/src/WSServer.ts | 61 +++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/cvmts/src/AuthManager.ts b/cvmts/src/AuthManager.ts index 4552264..081390b 100644 --- a/cvmts/src/AuthManager.ts +++ b/cvmts/src/AuthManager.ts @@ -25,12 +25,14 @@ export default class AuthManager { }) }); + // Make sure the fetch returned okay + if(!response.ok) + throw new Error(`Failed to query quth server: ${response.statusText}`) + let json = (await response.json()) as JoinResponse; - if (!json.success) { - this.logger.Error(`Failed to query auth server: ${json.error}`); - process.exit(1); - } + if (!json.success) + throw new Error(json.error); return json; } diff --git a/cvmts/src/WSServer.ts b/cvmts/src/WSServer.ts index 1194ac5..6e3164f 100644 --- a/cvmts/src/WSServer.ts +++ b/cvmts/src/WSServer.ts @@ -294,32 +294,38 @@ export default class WSServer { client.sendMsg(guacutils.encode('login', '0', 'You must connect to the VM before logging in.')); return; } - var res = await this.auth!.Authenticate(msgArr[1], client); - if (res.clientSuccess) { - 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) { - // kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that - // so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list - this.connectionClosed(old); - await old.kick(); - } - // Set username - this.renameUser(client, res.username); - // Set rank - client.rank = res.rank; - if (client.rank === Rank.Admin) { - client.sendMsg(guacutils.encode('admin', '0', '1')); - } else if (client.rank === Rank.Moderator) { - client.sendMsg(guacutils.encode('admin', '0', '3', this.ModPerms.toString())); - } - this.clients.forEach((c) => c.sendMsg(guacutils.encode('adduser', '1', client.username!, client.rank.toString()))); - } else { - client.sendMsg(guacutils.encode('login', '0', res.error!)); - if (res.error === 'You are banned') { - client.kick(); + try { + let res = await this.auth!.Authenticate(msgArr[1], client); + if (res.clientSuccess) { + this.logger.Info(`${client.IP.address} logged in as ${res.username}`); + client.sendMsg(guacutils.encode('login', '1')); + let old = this.clients.find((c) => c.username === res.username); + if (old) { + // kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that + // so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list + this.connectionClosed(old); + await old.kick(); + } + // Set username + this.renameUser(client, res.username); + // Set rank + client.rank = res.rank; + if (client.rank === Rank.Admin) { + client.sendMsg(guacutils.encode('admin', '0', '1')); + } else if (client.rank === Rank.Moderator) { + client.sendMsg(guacutils.encode('admin', '0', '3', this.ModPerms.toString())); + } + this.clients.forEach((c) => c.sendMsg(guacutils.encode('adduser', '1', client.username!, client.rank.toString()))); + } else { + client.sendMsg(guacutils.encode('login', '0', res.error!)); + if (res.error === 'You are banned') { + client.kick(); + } } + } catch(err) { + this.logger.Error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`); + // for now? + client.sendMsg(guacutils.encode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')) } break; case 'list': @@ -915,7 +921,10 @@ export default class WSServer { async getThumbnail(): Promise { let display = this.VM.GetDisplay(); - if (!display.Connected()) throw new Error('VM display is not connected'); + + // oh well + if (!display.Connected()) + return ""; let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size()); return buf.toString('base64'); From 7053973205784a79a89afbbcc4664b8036341530 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sun, 26 May 2024 23:19:55 -0400 Subject: [PATCH 11/60] abstract websocket to allow additional transport layers --- cvmts/src/{WSServer.ts => CollabVMServer.ts} | 137 +---------------- cvmts/src/NetworkClient.ts | 8 + cvmts/src/NetworkServer.ts | 6 + cvmts/src/User.ts | 17 ++- cvmts/src/WebSocket/WSClient.ts | 55 +++++++ cvmts/src/WebSocket/WSServer.ts | 152 +++++++++++++++++++ cvmts/src/index.ts | 13 +- 7 files changed, 243 insertions(+), 145 deletions(-) rename cvmts/src/{WSServer.ts => CollabVMServer.ts} (89%) create mode 100644 cvmts/src/NetworkClient.ts create mode 100644 cvmts/src/NetworkServer.ts create mode 100644 cvmts/src/WebSocket/WSClient.ts create mode 100644 cvmts/src/WebSocket/WSServer.ts diff --git a/cvmts/src/WSServer.ts b/cvmts/src/CollabVMServer.ts similarity index 89% rename from cvmts/src/WSServer.ts rename to cvmts/src/CollabVMServer.ts index 6e3164f..d3dab83 100644 --- a/cvmts/src/WSServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -1,7 +1,6 @@ import { WebSocketServer, WebSocket } from 'ws'; import * as http from 'http'; import IConfig from './IConfig.js'; -import internal from 'stream'; import * as Utilities from './Utilities.js'; import { User, Rank } from './User.js'; import * as guacutils from './guacutils.js'; @@ -36,12 +35,9 @@ type VoteTally = { no: number; }; -export default class WSServer { +export default class CollabVMServer { private Config: IConfig; - private httpServer: http.Server; - private wsServer: WebSocketServer; - private clients: User[]; private ChatHistory: CircularBuffer; @@ -105,14 +101,6 @@ export default class WSServer { this.indefiniteTurn = null; this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); - 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(); - }); let initSize = vm.GetDisplay().Size() || { width: 0, @@ -130,131 +118,14 @@ export default class WSServer { this.auth = auth; } - listen() { - this.httpServer.listen(this.Config.http.port, this.Config.http.host); - } - - private httpOnUpgrade(req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) { - var killConnection = () => { - socket.write('HTTP/1.1 400 Bad Request\n\n400 Bad Request'); - socket.destroy(); - }; - - if (req.headers['sec-websocket-protocol'] !== 'guacamole') { - killConnection(); - return; - } - - if (this.Config.http.origin) { - // If the client is not sending an Origin header, kill the connection. - if (!req.headers.origin) { - killConnection(); - return; - } - - // Try to parse the Origin header sent by the client, if it fails, kill the connection. - var _uri; - var _host; - try { - _uri = new URL(req.headers.origin.toLowerCase()); - _host = _uri.host; - } catch { - killConnection(); - return; - } - - // detect fake origin headers - if (_uri.pathname !== '/' || _uri.search !== '') { - killConnection(); - return; - } - - // If the domain name is not in the list of allowed origins, kill the connection. - if (!this.Config.http.originAllowedDomains.includes(_host)) { - killConnection(); - return; - } - } - - let ip: string; - if (this.Config.http.proxying) { - // If the requesting IP isn't allowed to proxy, kill it - if (this.Config.http.proxyAllowedIps.indexOf(req.socket.remoteAddress!) === -1) { - killConnection(); - return; - } - // Make sure x-forwarded-for is set - if (req.headers['x-forwarded-for'] === undefined) { - killConnection(); - return; - } - try { - // Get the first IP from the X-Forwarded-For variable - ip = req.headers['x-forwarded-for']?.toString().replace(/\ /g, '').split(',')[0]; - } catch { - // If we can't get the IP, kill the connection - killConnection(); - return; - } - // If for some reason the IP isn't defined, kill it - if (!ip) { - killConnection(); - return; - } - // Make sure the IP is valid. If not, kill the connection. - if (!isIP(ip)) { - killConnection(); - return; - } - } else { - if (!req.socket.remoteAddress) return; - ip = req.socket.remoteAddress; - } - - // Get the amount of active connections coming from the requesting IP. - let connections = this.clients.filter((client) => client.IP.address == ip); - // If it exceeds the limit set in the config, reject the connection with a 429. - if (connections.length + 1 > this.Config.http.maxConnections) { - socket.write('HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests'); - socket.destroy(); - } - - 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) { - let user = new User(ws, IPDataManager.GetIPData(ip), this.Config); + public addUser(user: User) { this.clients.push(user); - - ws.on('error', (e) => { - this.logger.Error(`${e} (caused by connection ${ip})`); - ws.close(); - }); - - ws.on('close', () => this.connectionClosed(user)); - - ws.on('message', (buf: Buffer, isBinary: boolean) => { - var msg; - - // Close the user's connection if they send a non-string message - if (isBinary) { - user.closeConnection(); - return; - } - - try { - this.onMessage(user, buf.toString()); - } catch {} - }); - + user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); + user.socket.on('disconnect', () => this.connectionClosed(user)); if (this.Config.auth.enabled) { user.sendMsg(guacutils.encode('auth', this.Config.auth.apiEndpoint)); } user.sendMsg(this.getAdduserMsg()); - this.logger.Info(`Connect from ${user.IP.address}`); } private connectionClosed(user: User) { diff --git a/cvmts/src/NetworkClient.ts b/cvmts/src/NetworkClient.ts new file mode 100644 index 0000000..ae1c36e --- /dev/null +++ b/cvmts/src/NetworkClient.ts @@ -0,0 +1,8 @@ +export default interface NetworkClient { + getIP() : string; + send(msg: string) : Promise; + close() : void; + on(event: string, listener: (...args: any[]) => void) : void; + off(event: string, listener: (...args: any[]) => void) : void; + isOpen() : boolean; +} \ No newline at end of file diff --git a/cvmts/src/NetworkServer.ts b/cvmts/src/NetworkServer.ts new file mode 100644 index 0000000..fd3ec24 --- /dev/null +++ b/cvmts/src/NetworkServer.ts @@ -0,0 +1,6 @@ +export default interface NetworkServer { + start() : void; + stop() : void; + on(event: string, listener: (...args: any[]) => void) : void; + off(event: string, listener: (...args: any[]) => void) : void; +} \ No newline at end of file diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 81cea6a..187efa2 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -1,14 +1,14 @@ 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'; +import NetworkClient from './NetworkClient.js'; export class User { - socket: WebSocket; + socket: NetworkClient; nopSendInterval: NodeJS.Timeout; msgRecieveInterval: NodeJS.Timeout; nopRecieveTimeout?: NodeJS.Timeout; @@ -28,17 +28,18 @@ export class User { private logger = new Logger('CVMTS.User'); - constructor(ws: WebSocket, ip: IPData, config: IConfig, username?: string, node?: string) { + constructor(socket: NetworkClient, ip: IPData, config: IConfig, username?: string, node?: string) { this.IP = ip; this.connectedToNode = false; this.viewMode = -1; this.Config = config; - this.socket = ws; + this.socket = socket; this.msgsSent = 0; - this.socket.on('close', () => { + this.socket.on('disconnect', () => { clearInterval(this.nopSendInterval); + clearInterval(this.msgRecieveInterval); }); - this.socket.on('message', (e) => { + this.socket.on('msg', (e) => { clearTimeout(this.nopRecieveTimeout); clearInterval(this.msgRecieveInterval); this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000); @@ -73,8 +74,8 @@ export class User { this.socket.send('3.nop;'); } - sendMsg(msg: string | Buffer) { - if (this.socket.readyState !== this.socket.OPEN) return; + sendMsg(msg: string) { + if (!this.socket.isOpen()) return; clearInterval(this.nopSendInterval); this.nopSendInterval = setInterval(() => this.sendNop(), 5000); this.socket.send(msg); diff --git a/cvmts/src/WebSocket/WSClient.ts b/cvmts/src/WebSocket/WSClient.ts new file mode 100644 index 0000000..96b9a36 --- /dev/null +++ b/cvmts/src/WebSocket/WSClient.ts @@ -0,0 +1,55 @@ +import { WebSocket } from "ws"; +import NetworkClient from "../NetworkClient.js"; +import EventEmitter from "events"; +import { Logger } from "@cvmts/shared"; + +export default class WSClient extends EventEmitter implements NetworkClient { + socket: WebSocket; + ip: string; + logger: Logger; + + constructor(ws: WebSocket, ip: string) { + super(); + this.socket = ws; + this.ip = ip; + this.logger = new Logger("CVMTS.WSClient"); + this.socket.on('message', (buf: Buffer, isBinary: boolean) => { + // Close the user's connection if they send a non-string message + if (isBinary) { + this.close(); + return; + } + + this.emit('msg', buf.toString("utf-8")); + }); + + this.socket.on('close', () => { + this.emit('disconnect'); + + }); + } + + isOpen(): boolean { + return this.socket.readyState === WebSocket.OPEN; + } + + getIP(): string { + return this.ip; + } + send(msg: string): Promise { + return new Promise((res,rej) => { + this.socket.send(msg, (err) => { + if (err) { + rej(err); + return; + } + res(); + }); + }); + } + + close(): void { + this.socket.close(); + } + +} \ No newline at end of file diff --git a/cvmts/src/WebSocket/WSServer.ts b/cvmts/src/WebSocket/WSServer.ts new file mode 100644 index 0000000..dd37da5 --- /dev/null +++ b/cvmts/src/WebSocket/WSServer.ts @@ -0,0 +1,152 @@ +import * as http from 'http'; +import NetworkServer from '../NetworkServer.js'; +import EventEmitter from 'events'; +import { WebSocketServer, WebSocket } from 'ws'; +import internal from 'stream'; +import IConfig from '../IConfig.js'; +import { isIP } from 'net'; +import { IPDataManager } from '../IPData.js'; +import WSClient from './WSClient.js'; +import { User } from '../User.js'; +import { Logger } from '@cvmts/shared'; + +export default class WSServer extends EventEmitter implements NetworkServer { + private httpServer: http.Server; + private wsServer: WebSocketServer; + private clients: WSClient[]; + private Config: IConfig; + private logger: Logger; + + constructor(config : IConfig) { + super(); + this.Config = config; + this.clients = []; + this.logger = new Logger("CVMTS.WSServer"); + 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(); + }); + } + + start(): void { + this.httpServer.listen(this.Config.http.port, this.Config.http.host, () => { + this.logger.Info(`WebSocket server listening on ${this.Config.http.host}:${this.Config.http.port}`); + }); + } + + stop(): void { + this.httpServer.close(); + } + + private httpOnUpgrade(req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) { + var killConnection = () => { + socket.write('HTTP/1.1 400 Bad Request\n\n400 Bad Request'); + socket.destroy(); + }; + + if (req.headers['sec-websocket-protocol'] !== 'guacamole') { + killConnection(); + return; + } + + if (this.Config.http.origin) { + // If the client is not sending an Origin header, kill the connection. + if (!req.headers.origin) { + killConnection(); + return; + } + + // Try to parse the Origin header sent by the client, if it fails, kill the connection. + var _uri; + var _host; + try { + _uri = new URL(req.headers.origin.toLowerCase()); + _host = _uri.host; + } catch { + killConnection(); + return; + } + + // detect fake origin headers + if (_uri.pathname !== '/' || _uri.search !== '') { + killConnection(); + return; + } + + // If the domain name is not in the list of allowed origins, kill the connection. + if (!this.Config.http.originAllowedDomains.includes(_host)) { + killConnection(); + return; + } + } + + let ip: string; + if (this.Config.http.proxying) { + // If the requesting IP isn't allowed to proxy, kill it + if (this.Config.http.proxyAllowedIps.indexOf(req.socket.remoteAddress!) === -1) { + killConnection(); + return; + } + // Make sure x-forwarded-for is set + if (req.headers['x-forwarded-for'] === undefined) { + killConnection(); + return; + } + try { + // Get the first IP from the X-Forwarded-For variable + ip = req.headers['x-forwarded-for']?.toString().replace(/\ /g, '').split(',')[0]; + } catch { + // If we can't get the IP, kill the connection + killConnection(); + return; + } + // If for some reason the IP isn't defined, kill it + if (!ip) { + killConnection(); + return; + } + // Make sure the IP is valid. If not, kill the connection. + if (!isIP(ip)) { + killConnection(); + return; + } + } else { + if (!req.socket.remoteAddress) return; + ip = req.socket.remoteAddress; + } + + // TODO: Implement + + // Get the amount of active connections coming from the requesting IP. + //let connections = this.clients.filter((client) => client.IP.address == ip); + // If it exceeds the limit set in the config, reject the connection with a 429. + //if (connections.length + 1 > this.Config.http.maxConnections) { + // socket.write('HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests'); + // socket.destroy(); + //} + + 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) { + let client = new WSClient(ws, ip); + this.clients.push(client); + let user = new User(client, IPDataManager.GetIPData(ip), this.Config); + + this.emit('connect', user); + + ws.on('error', (e) => { + this.logger.Error(`${e} (caused by connection ${ip})`); + ws.close(); + }); + + this.logger.Info(`Connect from ${user.IP.address}`); + } +} \ No newline at end of file diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index 89cd218..980c984 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -1,12 +1,14 @@ import * as toml from 'toml'; import IConfig from './IConfig.js'; import * as fs from 'fs'; -import WSServer from './WSServer.js'; +import CollabVMServer from './CollabVMServer.js'; import { QemuVM, QemuVmDefinition } from '@cvmts/qemu'; import * as Shared from '@cvmts/shared'; import AuthManager from './AuthManager.js'; +import WSServer from './WebSocket/WSServer.js'; +import { User } from './User.js'; let logger = new Shared.Logger('CVMTS.Init'); @@ -50,8 +52,11 @@ async function start() { var VM = new QemuVM(def); await VM.Start(); - // Start up the websocket server - var WS = new WSServer(Config, VM, auth); - WS.listen(); + // Start up the server + var CVM = new CollabVMServer(Config, VM, auth); + + var WS = new WSServer(Config); + WS.on('connect', (client: User) => CVM.addUser(client)); + WS.start(); } start(); From 8add016b605c1e147d5b2561741f701d0d7e1640 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Mon, 27 May 2024 00:06:05 -0400 Subject: [PATCH 12/60] implement TCP server, reimplement maxConnections except it now kicks the oldest connection --- config.example.toml | 4 +-- cvmts/src/CollabVMServer.ts | 6 ++++ cvmts/src/IConfig.ts | 7 ++++- cvmts/src/TCP/TCPClient.ts | 55 +++++++++++++++++++++++++++++++++ cvmts/src/TCP/TCPServer.ts | 39 +++++++++++++++++++++++ cvmts/src/WebSocket/WSClient.ts | 3 -- cvmts/src/index.ts | 7 +++++ 7 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 cvmts/src/TCP/TCPClient.ts create mode 100644 cvmts/src/TCP/TCPServer.ts diff --git a/config.example.toml b/config.example.toml index ab7d5a6..0ba3885 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,8 +10,6 @@ proxyAllowedIps = ["127.0.0.1"] origin = false # Origins to accept connections from. originAllowedDomains = ["computernewb.com"] -# Maximum amount of active connections allowed from the same IP. -maxConnections = 3 [auth] enabled = false @@ -39,6 +37,8 @@ qmpSockDir = "/tmp/" node = "acoolvm" displayname = "A Really Cool CollabVM Instance" motd = "welcome!" +# Maximum amount of active connections allowed from the same IP. +maxConnections = 3 # Command used to ban an IP. # Use $IP to specify an ip and (optionally) use $NAME to specify a username bancmd = "iptables -A INPUT -s $IP -j REJECT" diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index d3dab83..b75287d 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -119,6 +119,12 @@ export default class CollabVMServer { } public addUser(user: User) { + let sameip = this.clients.filter(c => c.IP.address === user.IP.address); + if (sameip.length >= this.Config.collabvm.maxConnections) { + // Kick the oldest client + // I think this is a better solution than just rejecting the connection + sameip[0].kick(); + } this.clients.push(user); user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); user.socket.on('disconnect', () => this.connectionClosed(user)); diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index 5d0f98a..d438e56 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -6,8 +6,12 @@ export default interface IConfig { proxyAllowedIps: string[]; origin: boolean; originAllowedDomains: string[]; - maxConnections: number; }; + tcp: { + enabled: boolean; + host: string; + port: number; + } auth: { enabled: boolean; apiEndpoint: string; @@ -31,6 +35,7 @@ export default interface IConfig { node: string; displayname: string; motd: string; + maxConnections: number; bancmd: string | string[]; moderatorEnabled: boolean; usernameblacklist: string[]; diff --git a/cvmts/src/TCP/TCPClient.ts b/cvmts/src/TCP/TCPClient.ts new file mode 100644 index 0000000..806e4e1 --- /dev/null +++ b/cvmts/src/TCP/TCPClient.ts @@ -0,0 +1,55 @@ +import EventEmitter from "events"; +import NetworkClient from "../NetworkClient.js"; +import { Socket } from "net"; + +export default class TCPClient extends EventEmitter implements NetworkClient { + private socket: Socket; + private cache: string; + + constructor(socket: Socket) { + super(); + this.socket = socket; + this.cache = ''; + this.socket.on('end', () => { + this.emit('disconnect'); + }) + this.socket.on('data', (data) => { + var msg = data.toString('utf-8'); + if (msg[msg.length - 1] === '\n') msg = msg.slice(0, -1); + this.cache += msg; + this.readCache(); + }); + } + + private readCache() { + for (var index = this.cache.indexOf(';'); index !== -1; index = this.cache.indexOf(';')) { + this.emit('msg', this.cache.slice(0, index + 1)); + this.cache = this.cache.slice(index + 1); + } + } + + getIP(): string { + return this.socket.remoteAddress!; + } + + send(msg: string): Promise { + return new Promise((res, rej) => { + this.socket.write(msg, (err) => { + if (err) { + rej(err); + return; + } + res(); + }); + }); + } + + close(): void { + this.emit('disconnect'); + this.socket.end(); + } + + isOpen(): boolean { + return this.socket.writable; + } +} \ No newline at end of file diff --git a/cvmts/src/TCP/TCPServer.ts b/cvmts/src/TCP/TCPServer.ts new file mode 100644 index 0000000..61d4250 --- /dev/null +++ b/cvmts/src/TCP/TCPServer.ts @@ -0,0 +1,39 @@ +import EventEmitter from "events"; +import NetworkServer from "../NetworkServer.js"; +import { Server, Socket } from "net"; +import IConfig from "../IConfig.js"; +import { Logger } from "@cvmts/shared"; +import TCPClient from "./TCPClient.js"; +import { IPDataManager } from "../IPData.js"; +import { User } from "../User.js"; + +export default class TCPServer extends EventEmitter implements NetworkServer { + listener: Server; + Config: IConfig; + logger: Logger; + clients: TCPClient[]; + + constructor(config: IConfig) { + super(); + this.logger = new Logger("CVMTS.TCPServer"); + this.Config = config; + this.listener = new Server(); + this.clients = []; + this.listener.on('connection', socket => this.onConnection(socket)); + } + + private onConnection(socket: Socket) { + var client = new TCPClient(socket); + this.clients.push(client); + this.emit('connect', new User(client, IPDataManager.GetIPData(client.getIP()), this.Config)); + } + + start(): void { + this.listener.listen(this.Config.tcp.port, this.Config.tcp.host, () => { + this.logger.Info(`TCP server listening on ${this.Config.tcp.host}:${this.Config.tcp.port}`); + }) + } + stop(): void { + this.listener.close(); + } +} \ No newline at end of file diff --git a/cvmts/src/WebSocket/WSClient.ts b/cvmts/src/WebSocket/WSClient.ts index 96b9a36..69b6468 100644 --- a/cvmts/src/WebSocket/WSClient.ts +++ b/cvmts/src/WebSocket/WSClient.ts @@ -6,13 +6,11 @@ import { Logger } from "@cvmts/shared"; export default class WSClient extends EventEmitter implements NetworkClient { socket: WebSocket; ip: string; - logger: Logger; constructor(ws: WebSocket, ip: string) { super(); this.socket = ws; this.ip = ip; - this.logger = new Logger("CVMTS.WSClient"); this.socket.on('message', (buf: Buffer, isBinary: boolean) => { // Close the user's connection if they send a non-string message if (isBinary) { @@ -25,7 +23,6 @@ export default class WSClient extends EventEmitter implements NetworkClient { this.socket.on('close', () => { this.emit('disconnect'); - }); } diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index 980c984..37f7ee6 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -9,6 +9,7 @@ import * as Shared from '@cvmts/shared'; import AuthManager from './AuthManager.js'; import WSServer from './WebSocket/WSServer.js'; import { User } from './User.js'; +import TCPServer from './TCP/TCPServer.js'; let logger = new Shared.Logger('CVMTS.Init'); @@ -58,5 +59,11 @@ async function start() { var WS = new WSServer(Config); WS.on('connect', (client: User) => CVM.addUser(client)); WS.start(); + + if (Config.tcp.enabled) { + var TCP = new TCPServer(Config); + TCP.on('connect', (client: User) => CVM.addUser(client)); + TCP.start(); + } } start(); From 565bf7d9b5780a4d3d6e206f07a9ec0437027694 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Mon, 27 May 2024 00:10:56 -0400 Subject: [PATCH 13/60] improve on connection logging --- cvmts/src/TCP/TCPServer.ts | 1 + cvmts/src/WebSocket/WSServer.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cvmts/src/TCP/TCPServer.ts b/cvmts/src/TCP/TCPServer.ts index 61d4250..db46372 100644 --- a/cvmts/src/TCP/TCPServer.ts +++ b/cvmts/src/TCP/TCPServer.ts @@ -23,6 +23,7 @@ export default class TCPServer extends EventEmitter implements NetworkServer { } private onConnection(socket: Socket) { + this.logger.Info(`New TCP connection from ${socket.remoteAddress}`); var client = new TCPClient(socket); this.clients.push(client); this.emit('connect', new User(client, IPDataManager.GetIPData(client.getIP()), this.Config)); diff --git a/cvmts/src/WebSocket/WSServer.ts b/cvmts/src/WebSocket/WSServer.ts index dd37da5..71f4fdd 100644 --- a/cvmts/src/WebSocket/WSServer.ts +++ b/cvmts/src/WebSocket/WSServer.ts @@ -147,6 +147,6 @@ export default class WSServer extends EventEmitter implements NetworkServer { ws.close(); }); - this.logger.Info(`Connect from ${user.IP.address}`); + this.logger.Info(`New WebSocket connection from ${user.IP.address}`); } } \ No newline at end of file From 1c0ee235ddd25c99376060c251471998755c486f Mon Sep 17 00:00:00 2001 From: Elijah R Date: Tue, 11 Jun 2024 12:43:54 -0400 Subject: [PATCH 14/60] add global build script --- package.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 731a7e4..06e4e28 100644 --- a/package.json +++ b/package.json @@ -16,5 +16,9 @@ "prettier": "^3.2.5", "typescript": "^5.4.4" }, - "packageManager": "yarn@4.1.1" + "packageManager": "yarn@4.1.1", + "scripts": { + "build": "yarn && cd nodejs-rfb && yarn && yarn build && cd ../shared && yarn build && cd ../qemu && yarn build && cd ../cvmts && yarn build", + "serve": "node cvmts/dist/index.js" + } } From 794b801628346c6d76ba6c77a891fc62e5df456b Mon Sep 17 00:00:00 2001 From: Elijah R Date: Tue, 11 Jun 2024 13:46:24 -0400 Subject: [PATCH 15/60] add VNCVM --- config.example.toml | 16 ++++ cvmts/src/CollabVMServer.ts | 20 ++--- cvmts/src/IConfig.ts | 6 ++ cvmts/src/VM.ts | 11 +++ cvmts/src/VMDisplay.ts | 12 +++ cvmts/src/VNCVM/VNCVM.ts | 174 ++++++++++++++++++++++++++++++++++++ cvmts/src/VNCVM/VNCVMDef.ts | 8 ++ cvmts/src/index.ts | 59 ++++++++---- qemu/src/QemuVM.ts | 8 ++ 9 files changed, 286 insertions(+), 28 deletions(-) create mode 100644 cvmts/src/VM.ts create mode 100644 cvmts/src/VMDisplay.ts create mode 100644 cvmts/src/VNCVM/VNCVM.ts create mode 100644 cvmts/src/VNCVM/VNCVMDef.ts diff --git a/config.example.toml b/config.example.toml index 0ba3885..66e3412 100644 --- a/config.example.toml +++ b/config.example.toml @@ -11,6 +11,11 @@ origin = false # Origins to accept connections from. originAllowedDomains = ["computernewb.com"] +[tcp] +enabled = false +host = "0.0.0.0" +port = 6014 + [auth] enabled = false apiEndpoint = "" @@ -24,6 +29,9 @@ vote = true [vm] +type = "qemu" + +[qemu] qemuArgs = "qemu-system-x86_64" vncPort = 5900 snapshots = true @@ -33,6 +41,14 @@ snapshots = true # Comment out qmpSockDir if you're using Windows. qmpSockDir = "/tmp/" +[vncvm] +vncHost = "127.0.0.1" +vncPort = 5900 +# startCmd = "" +# stopCmd = "" +# rebootCmd = "" +# restoreCmd = "" + [collabvm] node = "acoolvm" displayname = "A Really Cool CollabVM Instance" diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index b75287d..48b5bb2 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -1,5 +1,3 @@ -import { WebSocketServer, WebSocket } from 'ws'; -import * as http from 'http'; import IConfig from './IConfig.js'; import * as Utilities from './Utilities.js'; import { User, Rank } from './User.js'; @@ -8,19 +6,17 @@ 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 'node:net'; import { QemuVM, QemuVmDefinition } from '@cvmts/qemu'; -import { IPData, IPDataManager } from './IPData.js'; +import { IPDataManager } from './IPData.js'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import AuthManager from './AuthManager.js'; import { Size, Rect, Logger } from '@cvmts/shared'; - import { JPEGEncoder } from './JPEGEncoder.js'; +import VM from './VM.js'; // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before -const __filename = import.meta.filename; const __dirname = import.meta.dirname; const kCVMTSAssetsRoot = path.resolve(__dirname, '../../assets'); @@ -78,14 +74,14 @@ export default class CollabVMServer { // Indefinite turn private indefiniteTurn: User | null; private ModPerms: number; - private VM: QemuVM; + private VM: VM; // Authentication manager private auth: AuthManager | null; private logger = new Logger('CVMTS.Server'); - constructor(config: IConfig, vm: QemuVM, auth: AuthManager | null) { + constructor(config: IConfig, vm: VM, auth: AuthManager | null) { this.Config = config; this.ChatHistory = new CircularBuffer(Array, this.Config.collabvm.maxChatHistoryLength); this.TurnQueue = new Queue(); @@ -214,7 +210,7 @@ export default class CollabVMServer { return; } client.connectedToNode = true; - client.sendMsg(guacutils.encode('connect', '1', '1', this.Config.vm.snapshots ? '1' : '0', '0')); + client.sendMsg(guacutils.encode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode('chat', '', this.Config.collabvm.motd)); if (this.screenHidden) { @@ -247,7 +243,7 @@ export default class CollabVMServer { return; } - client.sendMsg(guacutils.encode('connect', '1', '1', this.Config.vm.snapshots ? '1' : '0', '0')); + client.sendMsg(guacutils.encode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode('chat', '', this.Config.collabvm.motd)); @@ -361,7 +357,7 @@ export default class CollabVMServer { this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false); break; case 'vote': - if (!this.Config.vm.snapshots) return; + if (!this.VM.SnapshotsSupported()) return; if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; if (!client.connectedToNode) return; if (msgArr.length !== 2) return; @@ -461,7 +457,7 @@ export default class CollabVMServer { // 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.MonitorCommand('system_reset'); + await this.VM.Reboot(); break; case '12': // Ban diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index d438e56..6c7f10d 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -1,3 +1,5 @@ +import VNCVMDef from "./VNCVM/VNCVMDef"; + export default interface IConfig { http: { host: string; @@ -24,6 +26,9 @@ export default interface IConfig { }; }; vm: { + type: "qemu" | "vncvm"; + }; + qemu: { qemuArgs: string; vncPort: number; snapshots: boolean; @@ -31,6 +36,7 @@ export default interface IConfig { qmpPort: number | null; qmpSockDir: string | null; }; + vncvm: VNCVMDef; collabvm: { node: string; displayname: string; diff --git a/cvmts/src/VM.ts b/cvmts/src/VM.ts new file mode 100644 index 0000000..11b8a06 --- /dev/null +++ b/cvmts/src/VM.ts @@ -0,0 +1,11 @@ +import VMDisplay from "./VMDisplay.js"; + +export default interface VM { + Start(): Promise; + Stop(): Promise; + Reboot(): Promise; + Reset(): Promise; + MonitorCommand(command: string): Promise; + GetDisplay(): VMDisplay; + SnapshotsSupported(): boolean; +} \ No newline at end of file diff --git a/cvmts/src/VMDisplay.ts b/cvmts/src/VMDisplay.ts new file mode 100644 index 0000000..adee89f --- /dev/null +++ b/cvmts/src/VMDisplay.ts @@ -0,0 +1,12 @@ +import { Size } from "@cvmts/shared"; +import EventEmitter from "node:events"; + +export default interface VMDisplay extends EventEmitter { + Connect(): void; + Disconnect(): void; + Connected(): boolean; + Buffer(): Buffer; + Size(): Size; + MouseEvent(x: number, y: number, buttons: number): void; + KeyboardEvent(keysym: number, pressed: boolean): void; +} \ No newline at end of file diff --git a/cvmts/src/VNCVM/VNCVM.ts b/cvmts/src/VNCVM/VNCVM.ts new file mode 100644 index 0000000..52d1cee --- /dev/null +++ b/cvmts/src/VNCVM/VNCVM.ts @@ -0,0 +1,174 @@ +import EventEmitter from "events"; +import VNCVMDef from "./VNCVMDef"; +import VM from "../VM"; +import VMDisplay from "../VMDisplay"; +import { Clamp, Logger, Rect, Size, Sleep } from "@cvmts/shared"; +import { VncClient } from '@computernewb/nodejs-rfb'; +import { BatchRects } from "@cvmts/qemu"; +import { execaCommand } from "execa"; + +export default class VNCVM extends EventEmitter implements VM, VMDisplay { + def : VNCVMDef; + logger: Logger; + private displayVnc = new VncClient({ + debug: false, + fps: 60, + encodings: [ + VncClient.consts.encodings.raw, + VncClient.consts.encodings.pseudoDesktopSize + ] + }); + private vncShouldReconnect: boolean = false; + + constructor(def : VNCVMDef) { + super(); + this.def = def; + this.logger = new Logger(`CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}`); + + this.displayVnc.on('connectTimeout', () => { + this.Reconnect(); + }); + + this.displayVnc.on('authError', () => { + this.Reconnect(); + }); + + this.displayVnc.on('disconnect', () => { + this.logger.Info('Disconnected'); + this.Reconnect(); + }); + + this.displayVnc.on('closed', () => { + this.Reconnect(); + }); + + this.displayVnc.on('firstFrameUpdate', () => { + this.logger.Info('Connected'); + // apparently this library is this good. + // at least it's better than the two others which exist. + this.displayVnc.changeFps(60); + this.emit('connected'); + + this.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight }); + //this.emit('rect', { x: 0, y: 0, width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight }); + this.emit('frame'); + }); + + this.displayVnc.on('desktopSizeChanged', (size: Size) => { + this.emit('resize', size); + }); + + let rects: Rect[] = []; + + this.displayVnc.on('rectUpdateProcessed', (rect: Rect) => { + rects.push(rect); + }); + + this.displayVnc.on('frameUpdated', (fb: Buffer) => { + // use the cvmts batcher + let batched = BatchRects(this.Size(), rects); + this.emit('rect', batched); + + // unbatched (watch the performace go now) + //for(let rect of rects) + // this.emit('rect', rect); + + rects = []; + + this.emit('frame'); + }); + } + + + async Reset(): Promise { + if (this.def.restoreCmd) await execaCommand(this.def.restoreCmd, {shell: true}); + else { + await this.Stop(); + await Sleep(1000); + await this.Start(); + } + } + + private Reconnect() { + if (this.displayVnc.connected) return; + + if (!this.vncShouldReconnect) return; + + // TODO: this should also give up after a max tries count + // if we fail after max tries, emit a event + + this.displayVnc.connect({ + host: this.def.vncHost, + port: this.def.vncPort, + path: null, + }); + } + + async Start(): Promise { + this.logger.Info('Connecting'); + if (this.def.startCmd) await execaCommand(this.def.startCmd, {shell: true}); + this.Connect(); + } + + async Stop(): Promise { + this.logger.Info('Disconnecting'); + this.Disconnect(); + if (this.def.stopCmd) await execaCommand(this.def.stopCmd, {shell: true}); + } + + async Reboot(): Promise { + if (this.def.rebootCmd) await execaCommand(this.def.rebootCmd, {shell: true}); + } + + async MonitorCommand(command: string): Promise { + // TODO: This can maybe run a specified command? + return "This VM does not support monitor commands."; + } + + GetDisplay(): VMDisplay { + return this; + } + + SnapshotsSupported(): boolean { + return true; + } + + Connect(): void { + this.vncShouldReconnect = true; + this.Reconnect(); + } + + Disconnect(): void { + this.vncShouldReconnect = false; + this.displayVnc.disconnect(); + } + + Connected(): boolean { + return this.displayVnc.connected; + } + + Buffer(): Buffer { + return this.displayVnc.fb; + } + + Size(): Size { + if (!this.displayVnc.connected) + return { + width: 0, + height: 0 + }; + + return { + width: this.displayVnc.clientWidth, + height: this.displayVnc.clientHeight + }; + } + + MouseEvent(x: number, y: number, buttons: number): void { + if (this.displayVnc.connected) this.displayVnc.sendPointerEvent(Clamp(x, 0, this.displayVnc.clientWidth), Clamp(y, 0, this.displayVnc.clientHeight), buttons); + } + + KeyboardEvent(keysym: number, pressed: boolean): void { + if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed); + } +} \ No newline at end of file diff --git a/cvmts/src/VNCVM/VNCVMDef.ts b/cvmts/src/VNCVM/VNCVMDef.ts new file mode 100644 index 0000000..b6d34d9 --- /dev/null +++ b/cvmts/src/VNCVM/VNCVMDef.ts @@ -0,0 +1,8 @@ +export default interface VNCVMDef { + vncHost : string; + vncPort : number; + startCmd : string | null; + stopCmd : string | null; + rebootCmd : string | null; + restoreCmd : string | null; +} \ No newline at end of file diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index 37f7ee6..3ead48a 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -10,6 +10,8 @@ import AuthManager from './AuthManager.js'; import WSServer from './WebSocket/WSServer.js'; import { User } from './User.js'; import TCPServer from './TCP/TCPServer.js'; +import VM from './VM.js'; +import VNCVM from './VNCVM/VNCVM.js'; let logger = new Shared.Logger('CVMTS.Init'); @@ -31,28 +33,53 @@ try { process.exit(1); } -async function start() { - // Print a warning if qmpSockDir is set - // and the host OS is Windows, as this - // configuration will very likely not work. - if (process.platform === 'win32' && Config.vm.qmpSockDir) { - 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.'); - } +let exiting = false; +let VM : VM; +async function stop() { + if (exiting) return; + exiting = true; + await VM.Stop(); + process.exit(0); +} + +async function start() { // Init the auth manager if enabled let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; + switch (Config.vm.type) { + case "qemu": { + // Print a warning if qmpSockDir is set + // and the host OS is Windows, as this + // configuration will very likely not work. + if (process.platform === 'win32' && Config.qemu.qmpSockDir !== null) { + 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.'); + } - // Fire up the VM - let def: QemuVmDefinition = { - id: Config.collabvm.node, - command: Config.vm.qemuArgs - }; + // Fire up the VM + let def: QemuVmDefinition = { + id: Config.collabvm.node, + command: Config.qemu.qemuArgs + }; + + VM = new QemuVM(def); + break; + } + case "vncvm": { + VM = new VNCVM(Config.vncvm); + break; + } + default: { + logger.Error('Invalid VM type in config: {0}', Config.vm.type); + process.exit(1); + return; + } + } + process.on('SIGINT', async () => await stop()); + process.on('SIGTERM', async () => await stop()); - var VM = new QemuVM(def); await VM.Start(); - // Start up the server var CVM = new CollabVMServer(Config, VM, auth); diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index ca1e396..b1ab475 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -78,6 +78,14 @@ export class QemuVM extends EventEmitter { await this.StartQemu(cmd); } + SnapshotsSupported() : boolean { + return gVMShouldSnapshot; + } + + async Reboot() : Promise { + await this.MonitorCommand('system_reset'); + } + 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'); From 4e501065856c5296692fea89c73224732d406070 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 01:36:07 -0400 Subject: [PATCH 16/60] cvmts: replace guacamole decoder with a node native module written in rust --- .gitignore | 6 +- cvmts/package.json | 1 + cvmts/src/CollabVMServer.ts | 923 ++++++++++++++++++------------------ cvmts/src/User.ts | 8 +- cvmts/src/guacutils.ts | 37 -- guac-rs/Cargo.lock | 209 ++++++++ guac-rs/Cargo.toml | 13 + guac-rs/index.d.ts | 3 + guac-rs/index.js | 6 + guac-rs/package.json | 15 + guac-rs/src/guac.rs | 193 ++++++++ guac-rs/src/lib.rs | 80 ++++ package.json | 1 + yarn.lock | 17 + 14 files changed, 1011 insertions(+), 501 deletions(-) delete mode 100644 cvmts/src/guacutils.ts create mode 100644 guac-rs/Cargo.lock create mode 100644 guac-rs/Cargo.toml create mode 100644 guac-rs/index.d.ts create mode 100644 guac-rs/index.js create mode 100644 guac-rs/package.json create mode 100644 guac-rs/src/guac.rs create mode 100644 guac-rs/src/lib.rs diff --git a/.gitignore b/.gitignore index 3a1de6b..0215abd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ config.toml cvmts/attic /dist -**/dist/ \ No newline at end of file +**/dist/ + +# Guac-rs +guac-rs/target +guac-rs/index.node diff --git a/cvmts/package.json b/cvmts/package.json index 909254f..fbf2c87 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -12,6 +12,7 @@ "license": "GPL-3.0", "dependencies": { "@computernewb/jpeg-turbo": "*", + "@cvmts/guac-rs": "*", "@cvmts/qemu": "*", "execa": "^8.0.1", "mnemonist": "^0.39.5", diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 48b5bb2..be084b2 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -1,7 +1,7 @@ import IConfig from './IConfig.js'; import * as Utilities from './Utilities.js'; import { User, Rank } from './User.js'; -import * as guacutils from './guacutils.js'; +import * as guac from '@cvmts/guac-rs'; // I hate that you have to do it like this import CircularBuffer from 'mnemonist/circular-buffer.js'; import Queue from 'mnemonist/queue.js'; @@ -115,7 +115,7 @@ export default class CollabVMServer { } public addUser(user: User) { - let sameip = this.clients.filter(c => c.IP.address === user.IP.address); + let sameip = this.clients.filter((c) => c.IP.address === user.IP.address); if (sameip.length >= this.Config.collabvm.maxConnections) { // Kick the oldest client // I think this is a better solution than just rejecting the connection @@ -125,7 +125,7 @@ export default class CollabVMServer { user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); user.socket.on('disconnect', () => this.connectionClosed(user)); if (this.Config.auth.enabled) { - user.sendMsg(guacutils.encode('auth', this.Config.auth.apiEndpoint)); + user.sendMsg(guac.guacEncode('auth', this.Config.auth.apiEndpoint)); } user.sendMsg(this.getAdduserMsg()); } @@ -154,473 +154,479 @@ export default class CollabVMServer { if (hadturn) this.nextTurn(); } - this.clients.forEach((c) => c.sendMsg(guacutils.encode('remuser', '1', user.username!))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('remuser', '1', user.username!))); } private async onMessage(client: User, message: string) { - var msgArr = guacutils.decode(message); - if (msgArr.length < 1) return; - switch (msgArr[0]) { - case 'login': - if (msgArr.length !== 2 || !this.Config.auth.enabled) return; - if (!client.connectedToNode) { - client.sendMsg(guacutils.encode('login', '0', 'You must connect to the VM before logging in.')); - return; - } - try { - let res = await this.auth!.Authenticate(msgArr[1], client); - if (res.clientSuccess) { - this.logger.Info(`${client.IP.address} logged in as ${res.username}`); - client.sendMsg(guacutils.encode('login', '1')); - let old = this.clients.find((c) => c.username === res.username); - if (old) { - // kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that - // so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list - this.connectionClosed(old); - await old.kick(); - } - // Set username - this.renameUser(client, res.username); - // Set rank - client.rank = res.rank; - if (client.rank === Rank.Admin) { - client.sendMsg(guacutils.encode('admin', '0', '1')); - } else if (client.rank === Rank.Moderator) { - client.sendMsg(guacutils.encode('admin', '0', '3', this.ModPerms.toString())); - } - this.clients.forEach((c) => c.sendMsg(guacutils.encode('adduser', '1', client.username!, client.rank.toString()))); - } else { - client.sendMsg(guacutils.encode('login', '0', res.error!)); - if (res.error === 'You are banned') { - client.kick(); - } - } - } catch(err) { - this.logger.Error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`); - // for now? - client.sendMsg(guacutils.encode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')) - } - break; - case 'list': - client.sendMsg(guacutils.encode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); - break; - case 'connect': - if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { - client.sendMsg(guacutils.encode('connect', '0')); - return; - } - client.connectedToNode = true; - client.sendMsg(guacutils.encode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); - if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode('chat', '', this.Config.collabvm.motd)); - if (this.screenHidden) { - client.sendMsg(guacutils.encode('size', '0', '1024', '768')); - client.sendMsg(guacutils.encode('png', '0', '0', '0', '0', this.screenHiddenImg)); - } else { - await this.SendFullScreenWithSize(client); - } - client.sendMsg(guacutils.encode('sync', Date.now().toString())); - if (this.voteInProgress) this.sendVoteUpdate(client); - this.sendTurnUpdate(client); - break; - case 'view': - if (client.connectedToNode) return; - if (client.username || msgArr.length !== 3 || msgArr[1] !== this.Config.collabvm.node) { - // The use of connect here is intentional. - client.sendMsg(guacutils.encode('connect', '0')); - return; - } - - switch (msgArr[2]) { - case '0': - client.viewMode = 0; - break; - case '1': - client.viewMode = 1; - break; - default: - client.sendMsg(guacutils.encode('connect', '0')); + try { + var msgArr = guac.guacDecode(message); + if (msgArr.length < 1) return; + switch (msgArr[0]) { + case 'login': + if (msgArr.length !== 2 || !this.Config.auth.enabled) return; + if (!client.connectedToNode) { + client.sendMsg(guac.guacEncode('login', '0', 'You must connect to the VM before logging in.')); return; - } - - client.sendMsg(guacutils.encode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); - if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode('chat', '', this.Config.collabvm.motd)); - - if (client.viewMode == 1) { + } + try { + let res = await this.auth!.Authenticate(msgArr[1], client); + if (res.clientSuccess) { + this.logger.Info(`${client.IP.address} logged in as ${res.username}`); + client.sendMsg(guac.guacEncode('login', '1')); + let old = this.clients.find((c) => c.username === res.username); + if (old) { + // kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that + // so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list + this.connectionClosed(old); + await old.kick(); + } + // Set username + this.renameUser(client, res.username); + // Set rank + client.rank = res.rank; + if (client.rank === Rank.Admin) { + client.sendMsg(guac.guacEncode('admin', '0', '1')); + } else if (client.rank === Rank.Moderator) { + client.sendMsg(guac.guacEncode('admin', '0', '3', this.ModPerms.toString())); + } + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('adduser', '1', client.username!, client.rank.toString()))); + } else { + client.sendMsg(guac.guacEncode('login', '0', res.error!)); + if (res.error === 'You are banned') { + client.kick(); + } + } + } catch (err) { + this.logger.Error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`); + // for now? + client.sendMsg(guac.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')); + } + break; + case 'list': + client.sendMsg(guac.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); + break; + case 'connect': + if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { + client.sendMsg(guac.guacEncode('connect', '0')); + return; + } + client.connectedToNode = true; + client.sendMsg(guac.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); + if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); + if (this.Config.collabvm.motd) client.sendMsg(guac.guacEncode('chat', '', this.Config.collabvm.motd)); if (this.screenHidden) { - client.sendMsg(guacutils.encode('size', '0', '1024', '768')); - client.sendMsg(guacutils.encode('png', '0', '0', '0', '0', this.screenHiddenImg)); + client.sendMsg(guac.guacEncode('size', '0', '1024', '768')); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); } else { await this.SendFullScreenWithSize(client); } - client.sendMsg(guacutils.encode('sync', Date.now().toString())); - } + client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + if (this.voteInProgress) this.sendVoteUpdate(client); + this.sendTurnUpdate(client); + break; + case 'view': + if (client.connectedToNode) return; + if (client.username || msgArr.length !== 3 || msgArr[1] !== this.Config.collabvm.node) { + // The use of connect here is intentional. + client.sendMsg(guac.guacEncode('connect', '0')); + return; + } - if (this.voteInProgress) this.sendVoteUpdate(client); - this.sendTurnUpdate(client); - break; - case 'rename': - if (!client.RenameRateLimit.request()) return; - if (client.connectedToNode && client.IP.muted) return; - if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) { - client.sendMsg(guacutils.encode('chat', '', 'Go to your account settings to change your username.')); - return; - } - if (this.Config.auth.enabled && msgArr[1] !== undefined) { - // Don't send system message to a user without a username since it was likely an automated attempt by the webapp - if (client.username) client.sendMsg(guacutils.encode('chat', '', 'You need to log in to do that.')); - if (client.rank !== Rank.Unregistered) return; - this.renameUser(client, undefined); - return; - } - this.renameUser(client, msgArr[1]); - break; - case 'chat': - if (!client.username) return; - if (client.IP.muted) return; - if (msgArr.length !== 2) return; - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) { - client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); - return; - } - var msg = Utilities.HTMLSanitize(msgArr[1]); - // One of the things I hated most about the old server is it completely discarded your message if it was too long - if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength); - if (msg.trim().length < 1) return; - - this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', client.username!, msg))); - this.ChatHistory.push({ user: client.username, msg: msg }); - client.onMsgSent(); - break; - case 'turn': - if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) { - client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); - return; - } - if (!client.TurnRateLimit.request()) return; - if (!client.connectedToNode) return; - if (msgArr.length > 2) return; - var takingTurn: boolean; - if (msgArr.length === 1) takingTurn = true; - else - switch (msgArr[1]) { + switch (msgArr[2]) { case '0': - if (this.indefiniteTurn === client) { - this.indefiniteTurn = null; - } - takingTurn = false; + client.viewMode = 0; break; case '1': - takingTurn = true; + client.viewMode = 1; break; default: + client.sendMsg(guac.guacEncode('connect', '0')); return; - break; } - if (takingTurn) { - var currentQueue = this.TurnQueue.toArray(); - // If the user is already in the turn queue, ignore the turn request. - if (currentQueue.indexOf(client) !== -1) return; - // If they're muted, also ignore the turn request. - // Send them the turn queue to prevent client glitches - if (client.IP.muted) return; - if (this.Config.collabvm.turnlimit.enabled) { - // Get the amount of users in the turn queue with the same IP as the user requesting a turn. - let turns = currentQueue.filter((user) => user.IP.address == client.IP.address); - // If it exceeds the limit set in the config, ignore the turn request. - if (turns.length + 1 > this.Config.collabvm.turnlimit.maximum) return; - } - this.TurnQueue.enqueue(client); - if (this.TurnQueue.size === 1) this.nextTurn(); - } else { - var hadturn = this.TurnQueue.peek() === client; - this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((u) => u !== client)); - if (hadturn) this.nextTurn(); - } - this.sendTurnUpdate(); - break; - case 'mouse': - if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) 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.GetDisplay()!.MouseEvent(x, y, mask); - break; - case 'key': - if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; - var keysym = parseInt(msgArr[1]); - var down = parseInt(msgArr[2]); - if (keysym === undefined || (down !== 0 && down !== 1)) return; - this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false); - break; - case 'vote': - if (!this.VM.SnapshotsSupported()) return; - if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; - if (!client.connectedToNode) return; - if (msgArr.length !== 2) return; - if (!client.VoteRateLimit.request()) return; - switch (msgArr[1]) { - case '1': - if (!this.voteInProgress) { - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) { - client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); - return; - } - if (this.voteCooldown !== 0) { - client.sendMsg(guacutils.encode('vote', '3', this.voteCooldown.toString())); - return; - } - this.startVote(); - this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has started a vote to reset the VM.`))); - } - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { - client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); - return; - } else if (client.IP.vote !== true) { - this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has voted yes.`))); - } - client.IP.vote = true; - break; - case '0': - if (!this.voteInProgress) return; - if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { - client.sendMsg(guacutils.encode('chat', '', 'You need to login to do that.')); - return; - } - if (client.IP.vote !== false) { - this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', `${client.username} has voted no.`))); - } - client.IP.vote = false; - break; - } - this.sendVoteUpdate(); - break; - case 'admin': - if (msgArr.length < 2) return; - switch (msgArr[1]) { - case '2': - // Login - if (this.Config.auth.enabled) { - client.sendMsg(guacutils.encode('chat', '', 'This server does not support staff passwords. Please log in to become staff.')); - return; - } - if (!client.LoginRateLimit.request() || !client.username) return; - if (msgArr.length !== 3) return; - var sha256 = createHash('sha256'); - sha256.update(msgArr[2]); - var pwdHash = sha256.digest('hex'); - sha256.destroy(); - if (pwdHash === this.Config.collabvm.adminpass) { - client.rank = Rank.Admin; - client.sendMsg(guacutils.encode('admin', '0', '1')); - } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { - client.rank = Rank.Moderator; - client.sendMsg(guacutils.encode('admin', '0', '3', this.ModPerms.toString())); - } else if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) { - client.rank = Rank.Turn; - client.sendMsg(guacutils.encode('chat', '', 'You may now take turns.')); - } else { - client.sendMsg(guacutils.encode('admin', '0', '0')); - return; - } + client.sendMsg(guac.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); + if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); + if (this.Config.collabvm.motd) client.sendMsg(guac.guacEncode('chat', '', this.Config.collabvm.motd)); + + if (client.viewMode == 1) { if (this.screenHidden) { + client.sendMsg(guac.guacEncode('size', '0', '1024', '768')); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); + } else { await this.SendFullScreenWithSize(client); - - client.sendMsg(guacutils.encode('sync', Date.now().toString())); } + client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + } - this.clients.forEach((c) => c.sendMsg(guacutils.encode('adduser', '1', client.username!, client.rank.toString()))); - break; - 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.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.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; - await this.VM.Reboot(); - break; - case '12': - // Ban - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return; - var user = this.clients.find((c) => c.username === msgArr[2]); - if (!user) return; - user.ban(); - case '13': - // Force Vote - if (msgArr.length !== 3) return; - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return; - if (!this.voteInProgress) return; - switch (msgArr[2]) { - case '1': - this.endVote(true); - break; + if (this.voteInProgress) this.sendVoteUpdate(client); + this.sendTurnUpdate(client); + break; + case 'rename': + if (!client.RenameRateLimit.request()) return; + if (client.connectedToNode && client.IP.muted) return; + if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) { + client.sendMsg(guac.guacEncode('chat', '', 'Go to your account settings to change your username.')); + return; + } + if (this.Config.auth.enabled && msgArr[1] !== undefined) { + // Don't send system message to a user without a username since it was likely an automated attempt by the webapp + if (client.username) client.sendMsg(guac.guacEncode('chat', '', 'You need to log in to do that.')); + if (client.rank !== Rank.Unregistered) return; + this.renameUser(client, undefined); + return; + } + this.renameUser(client, msgArr[1]); + break; + case 'chat': + if (!client.username) return; + if (client.IP.muted) return; + if (msgArr.length !== 2) return; + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) { + client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + return; + } + var msg = Utilities.HTMLSanitize(msgArr[1]); + // One of the things I hated most about the old server is it completely discarded your message if it was too long + if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength); + if (msg.trim().length < 1) return; + + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', client.username!, msg))); + this.ChatHistory.push({ user: client.username, msg: msg }); + client.onMsgSent(); + break; + case 'turn': + if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) { + client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + return; + } + if (!client.TurnRateLimit.request()) return; + if (!client.connectedToNode) return; + if (msgArr.length > 2) return; + var takingTurn: boolean; + if (msgArr.length === 1) takingTurn = true; + else + switch (msgArr[1]) { case '0': - this.endVote(false); - break; - } - break; - case '14': - // Mute - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return; - if (msgArr.length !== 4) return; - var user = this.clients.find((c) => c.username === msgArr[2]); - if (!user) return; - var permamute; - switch (msgArr[3]) { - case '0': - permamute = false; + if (this.indefiniteTurn === client) { + this.indefiniteTurn = null; + } + takingTurn = false; break; case '1': - permamute = true; + takingTurn = true; break; default: return; + break; } - user.mute(permamute); - break; - case '15': - // Kick - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return; - var user = this.clients.find((c) => c.username === msgArr[2]); - if (!user) return; - user.kick(); - break; - case '16': - // End turn - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; - if (msgArr.length !== 3) return; - var user = this.clients.find((c) => c.username === msgArr[2]); - if (!user) return; - this.endTurn(user); - break; - case '17': - // Clear turn queue - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; - if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return; - this.clearTurns(); - break; - case '18': - // Rename user - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return; - if (this.Config.auth.enabled) { - client.sendMsg(guacutils.encode('chat', '', 'Cannot rename users on a server that uses authentication.')); + if (takingTurn) { + var currentQueue = this.TurnQueue.toArray(); + // If the user is already in the turn queue, ignore the turn request. + if (currentQueue.indexOf(client) !== -1) return; + // If they're muted, also ignore the turn request. + // Send them the turn queue to prevent client glitches + if (client.IP.muted) return; + if (this.Config.collabvm.turnlimit.enabled) { + // Get the amount of users in the turn queue with the same IP as the user requesting a turn. + let turns = currentQueue.filter((user) => user.IP.address == client.IP.address); + // If it exceeds the limit set in the config, ignore the turn request. + if (turns.length + 1 > this.Config.collabvm.turnlimit.maximum) return; } - if (msgArr.length !== 4) return; - var user = this.clients.find((c) => c.username === msgArr[2]); - if (!user) return; - this.renameUser(user, msgArr[3]); - break; - case '19': - // Get IP - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return; - if (msgArr.length !== 3) return; - var user = this.clients.find((c) => c.username === msgArr[2]); - if (!user) return; - client.sendMsg(guacutils.encode('admin', '19', msgArr[2], user.IP.address)); - break; - case '20': - // Steal turn - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; - this.bypassTurn(client); - break; - case '21': - // XSS - if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return; - if (msgArr.length !== 3) return; - switch (client.rank) { - case Rank.Admin: - this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', client.username!, msgArr[2]))); + this.TurnQueue.enqueue(client); + if (this.TurnQueue.size === 1) this.nextTurn(); + } else { + var hadturn = this.TurnQueue.peek() === client; + this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((u) => u !== client)); + if (hadturn) this.nextTurn(); + } + this.sendTurnUpdate(); + break; + case 'mouse': + if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) 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.GetDisplay()!.MouseEvent(x, y, mask); + break; + case 'key': + if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; + var keysym = parseInt(msgArr[1]); + var down = parseInt(msgArr[2]); + if (keysym === undefined || (down !== 0 && down !== 1)) return; + this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false); + break; + case 'vote': + if (!this.VM.SnapshotsSupported()) return; + if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; + if (!client.connectedToNode) return; + if (msgArr.length !== 2) return; + if (!client.VoteRateLimit.request()) return; + switch (msgArr[1]) { + case '1': + if (!this.voteInProgress) { + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) { + client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + return; + } - this.ChatHistory.push({ user: client.username!, msg: msgArr[2] }); - break; - case Rank.Moderator: - this.clients.filter((c) => c.rank !== Rank.Admin).forEach((c) => c.sendMsg(guacutils.encode('chat', client.username!, msgArr[2]))); + if (this.voteCooldown !== 0) { + client.sendMsg(guac.guacEncode('vote', '3', this.voteCooldown.toString())); + return; + } + this.startVote(); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', `${client.username} has started a vote to reset the VM.`))); + } + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { + client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + return; + } else if (client.IP.vote !== true) { + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', `${client.username} has voted yes.`))); + } + client.IP.vote = true; + break; + case '0': + if (!this.voteInProgress) return; + if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { + client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + return; + } + if (client.IP.vote !== false) { + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', `${client.username} has voted no.`))); + } + client.IP.vote = false; + break; + } + this.sendVoteUpdate(); + break; + case 'admin': + if (msgArr.length < 2) return; + switch (msgArr[1]) { + case '2': + // Login + if (this.Config.auth.enabled) { + client.sendMsg(guac.guacEncode('chat', '', 'This server does not support staff passwords. Please log in to become staff.')); + return; + } + if (!client.LoginRateLimit.request() || !client.username) return; + if (msgArr.length !== 3) return; + var sha256 = createHash('sha256'); + sha256.update(msgArr[2]); + var pwdHash = sha256.digest('hex'); + sha256.destroy(); + if (pwdHash === this.Config.collabvm.adminpass) { + client.rank = Rank.Admin; + client.sendMsg(guac.guacEncode('admin', '0', '1')); + } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { + client.rank = Rank.Moderator; + client.sendMsg(guac.guacEncode('admin', '0', '3', this.ModPerms.toString())); + } else if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) { + client.rank = Rank.Turn; + client.sendMsg(guac.guacEncode('chat', '', 'You may now take turns.')); + } else { + client.sendMsg(guac.guacEncode('admin', '0', '0')); + return; + } + if (this.screenHidden) { + await this.SendFullScreenWithSize(client); - this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.sendMsg(guacutils.encode('chat', client.username!, Utilities.HTMLSanitize(msgArr[2])))); - break; - } - break; - case '22': - // Toggle turns - if (client.rank !== Rank.Admin) return; - if (msgArr.length !== 3) return; - switch (msgArr[2]) { - case '0': - this.clearTurns(); - this.turnsAllowed = false; - break; - case '1': - this.turnsAllowed = true; - break; - } - break; - case '23': - // Indefinite turn - if (client.rank !== Rank.Admin) return; - this.indefiniteTurn = client; - this.TurnQueue = Queue.from([client, ...this.TurnQueue.toArray().filter((c) => c !== client)]); - this.sendTurnUpdate(); - break; - case '24': - // Hide screen - if (client.rank !== Rank.Admin) return; - if (msgArr.length !== 3) return; - switch (msgArr[2]) { - case '0': - this.screenHidden = true; - this.clients - .filter((c) => c.rank == Rank.Unregistered) - .forEach((client) => { - client.sendMsg(guacutils.encode('size', '0', '1024', '768')); - client.sendMsg(guacutils.encode('png', '0', '0', '0', '0', this.screenHiddenImg)); - client.sendMsg(guacutils.encode('sync', Date.now().toString())); + client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + } + + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('adduser', '1', client.username!, client.rank.toString()))); + break; + 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(guac.guacEncode("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.MonitorCommand(msgArr[3]); + client.sendMsg(guac.guacEncode('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.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; + await this.VM.Reboot(); + break; + case '12': + // Ban + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + user.ban(); + case '13': + // Force Vote + if (msgArr.length !== 3) return; + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return; + if (!this.voteInProgress) return; + switch (msgArr[2]) { + case '1': + this.endVote(true); + break; + case '0': + this.endVote(false); + break; + } + break; + case '14': + // Mute + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return; + if (msgArr.length !== 4) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + var permamute; + switch (msgArr[3]) { + case '0': + permamute = false; + break; + case '1': + permamute = true; + break; + default: + return; + } + user.mute(permamute); + break; + case '15': + // Kick + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + user.kick(); + break; + case '16': + // End turn + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + if (msgArr.length !== 3) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + this.endTurn(user); + break; + case '17': + // Clear turn queue + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return; + this.clearTurns(); + break; + case '18': + // Rename user + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return; + if (this.Config.auth.enabled) { + client.sendMsg(guac.guacEncode('chat', '', 'Cannot rename users on a server that uses authentication.')); + } + if (msgArr.length !== 4) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + this.renameUser(user, msgArr[3]); + break; + case '19': + // Get IP + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return; + if (msgArr.length !== 3) return; + var user = this.clients.find((c) => c.username === msgArr[2]); + if (!user) return; + client.sendMsg(guac.guacEncode('admin', '19', msgArr[2], user.IP.address)); + break; + case '20': + // Steal turn + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + this.bypassTurn(client); + break; + case '21': + // XSS + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return; + if (msgArr.length !== 3) return; + switch (client.rank) { + case Rank.Admin: + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', client.username!, msgArr[2]))); + + this.ChatHistory.push({ user: client.username!, msg: msgArr[2] }); + break; + case Rank.Moderator: + this.clients.filter((c) => c.rank !== Rank.Admin).forEach((c) => c.sendMsg(guac.guacEncode('chat', client.username!, msgArr[2]))); + + this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.sendMsg(guac.guacEncode('chat', client.username!, Utilities.HTMLSanitize(msgArr[2])))); + break; + } + break; + case '22': + // Toggle turns + if (client.rank !== Rank.Admin) return; + if (msgArr.length !== 3) return; + switch (msgArr[2]) { + case '0': + this.clearTurns(); + this.turnsAllowed = false; + break; + case '1': + this.turnsAllowed = true; + break; + } + break; + case '23': + // Indefinite turn + if (client.rank !== Rank.Admin) return; + this.indefiniteTurn = client; + this.TurnQueue = Queue.from([client, ...this.TurnQueue.toArray().filter((c) => c !== client)]); + this.sendTurnUpdate(); + break; + case '24': + // Hide screen + if (client.rank !== Rank.Admin) return; + if (msgArr.length !== 3) return; + switch (msgArr[2]) { + case '0': + this.screenHidden = true; + this.clients + .filter((c) => c.rank == Rank.Unregistered) + .forEach((client) => { + client.sendMsg(guac.guacEncode('size', '0', '1024', '768')); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); + client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + }); + break; + case '1': + this.screenHidden = false; + let displaySize = this.VM.GetDisplay().Size(); + + let encoded = await this.MakeRectData({ + x: 0, + y: 0, + width: displaySize.width, + height: displaySize.height }); - break; - case '1': - this.screenHidden = false; - 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; - } - break; - case '25': - if (client.rank !== Rank.Admin || msgArr.length !== 3) return; - this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', msgArr[2]))); - break; - } - break; + this.clients.forEach(async (client) => { + client.sendMsg(guac.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', encoded)); + client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + }); + break; + } + break; + case '25': + if (client.rank !== Rank.Admin || msgArr.length !== 3) return; + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', msgArr[2]))); + break; + } + break; + } + } catch (err) { + // No + this.logger.Error(`User ${user?.IP.address} ${user?.username ? `with username ${user?.username}` : ''} sent broken Guacamole: ${(err as Error)}`); + user?.kick(); } } @@ -642,7 +648,7 @@ export default class CollabVMServer { } else { newName = newName.trim(); if (hadName && newName === oldname) { - client.sendMsg(guacutils.encode('rename', '0', '0', client.username!, client.rank.toString())); + client.sendMsg(guac.guacEncode('rename', '0', '0', client.username!, client.rank.toString())); return; } if (this.getUsernameList().indexOf(newName) !== -1) { @@ -659,13 +665,13 @@ export default class CollabVMServer { } else client.username = newName; } - client.sendMsg(guacutils.encode('rename', '0', status, client.username!, client.rank.toString())); + client.sendMsg(guac.guacEncode('rename', '0', status, client.username!, client.rank.toString())); if (hadName) { 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()))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); } else { 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()))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('adduser', '1', client.username!, client.rank.toString()))); } } @@ -673,13 +679,13 @@ export default class CollabVMServer { var arr: string[] = ['adduser', this.clients.filter((c) => c.username).length.toString()]; this.clients.filter((c) => c.username).forEach((c) => arr.push(c.username!, c.rank.toString())); - return guacutils.encode(...arr); + return guac.guacEncode(...arr); } getChatHistoryMsg(): string { var arr: string[] = ['chat']; this.ChatHistory.forEach((c) => arr.push(c.user, c.msg)); - return guacutils.encode(...arr); + return guac.guacEncode(...arr); } private sendTurnUpdate(client?: User) { @@ -692,7 +698,7 @@ export default class CollabVMServer { this.TurnQueue.forEach((c) => arr.push(c.username)); var currentTurningUser = this.TurnQueue.peek(); if (client) { - client.sendMsg(guacutils.encode(...arr)); + client.sendMsg(guac.guacEncode(...arr)); return; } this.clients @@ -702,12 +708,12 @@ export default class CollabVMServer { var time; if (this.indefiniteTurn === null) time = this.TurnTime * 1000 + (turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000; else time = 9999999999; - c.sendMsg(guacutils.encode(...arr, time.toString())); + c.sendMsg(guac.guacEncode(...arr, time.toString())); } else { - c.sendMsg(guacutils.encode(...arr)); + c.sendMsg(guac.guacEncode(...arr)); } }); - if (currentTurningUser) currentTurningUser.sendMsg(guacutils.encode(...arr)); + if (currentTurningUser) currentTurningUser.sendMsg(guac.guacEncode(...arr)); } private nextTurn() { clearInterval(this.TurnInterval); @@ -754,8 +760,8 @@ export default class CollabVMServer { .filter((c) => c.connectedToNode || c.viewMode == 1) .forEach((c) => { if (this.screenHidden && c.rank == Rank.Unregistered) return; - c.sendMsg(guacutils.encode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64)); - c.sendMsg(guacutils.encode('sync', Date.now().toString())); + c.sendMsg(guac.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64)); + c.sendMsg(guac.guacEncode('sync', Date.now().toString())); }); } @@ -764,7 +770,7 @@ export default class CollabVMServer { .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())); + c.sendMsg(guac.guacEncode('size', '0', size.width.toString(), size.height.toString())); }); } @@ -779,8 +785,8 @@ export default class CollabVMServer { 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)); + client.sendMsg(guac.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); + client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', encoded)); } private async MakeRectData(rect: Rect) { @@ -796,8 +802,7 @@ export default class CollabVMServer { let display = this.VM.GetDisplay(); // oh well - if (!display.Connected()) - return ""; + if (!display.Connected()) return ''; let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size()); return buf.toString('base64'); @@ -806,7 +811,7 @@ export default class CollabVMServer { startVote() { if (this.voteInProgress) return; this.voteInProgress = true; - this.clients.forEach((c) => c.sendMsg(guacutils.encode('vote', '0'))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('vote', '0'))); this.voteTime = this.Config.collabvm.voteTime; this.voteInterval = setInterval(() => { this.voteTime--; @@ -821,12 +826,12 @@ export default class CollabVMServer { this.voteInProgress = false; clearInterval(this.voteInterval); var count = this.getVoteCounts(); - this.clients.forEach((c) => c.sendMsg(guacutils.encode('vote', '2'))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('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.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', 'The vote to reset the VM has won.'))); this.VM.Reset(); } else { - this.clients.forEach((c) => c.sendMsg(guacutils.encode('chat', '', 'The vote to reset the VM has lost.'))); + this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', 'The vote to reset the VM has lost.'))); } this.clients.forEach((c) => { c.IP.vote = null; @@ -841,7 +846,7 @@ export default class CollabVMServer { sendVoteUpdate(client?: User) { if (!this.voteInProgress) return; var count = this.getVoteCounts(); - var msg = guacutils.encode('vote', '1', (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString()); + var msg = guac.guacEncode('vote', '1', (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString()); if (client) client.sendMsg(msg); else this.clients.forEach((c) => c.sendMsg(msg)); } diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 187efa2..cbc2a68 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -1,5 +1,5 @@ import * as Utilities from './Utilities.js'; -import * as guacutils from './guacutils.js'; +import * as guac from '@cvmts/guac-rs'; import { IPData } from './IPData.js'; import IConfig from './IConfig.js'; import RateLimiter from './RateLimiter.js'; @@ -89,7 +89,7 @@ export class User { } closeConnection() { - this.socket.send(guacutils.encode('disconnect')); + this.socket.send(guac.guacEncode('disconnect')); this.socket.close(); } @@ -109,7 +109,7 @@ export class User { mute(permanent: boolean) { this.IP.muted = true; - this.sendMsg(guacutils.encode('chat', '', `You have been muted${permanent ? '' : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`)); + this.sendMsg(guac.guacEncode('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); @@ -118,7 +118,7 @@ export class User { unmute() { clearTimeout(this.IP.tempMuteExpireTimeout); this.IP.muted = false; - this.sendMsg(guacutils.encode('chat', '', 'You are no longer muted.')); + this.sendMsg(guac.guacEncode('chat', '', 'You are no longer muted.')); } private banCmdArgs(arg: string): string { diff --git a/cvmts/src/guacutils.ts b/cvmts/src/guacutils.ts deleted file mode 100644 index 0647f70..0000000 --- a/cvmts/src/guacutils.ts +++ /dev/null @@ -1,37 +0,0 @@ -export function decode(string: string): string[] { - let pos = -1; - let sections = []; - - for (;;) { - let len = string.indexOf('.', pos + 1); - - if (len === -1) break; - - pos = parseInt(string.slice(pos + 1, len)) + len + 1; - - // don't allow funky protocol length - if (pos > string.length) return []; - - sections.push(string.slice(len + 1, pos)); - - const sep = string.slice(pos, pos + 1); - - if (sep === ',') continue; - else if (sep === ';') break; - // Invalid data. - else return []; - } - - return sections; -} - -export function encode(...string: string[]): string { - let command = ''; - - for (var i = 0; i < string.length; i++) { - let current = string[i]; - command += current.toString().length + '.' + current; - command += i < string.length - 1 ? ',' : ';'; - } - return command; -} diff --git a/guac-rs/Cargo.lock b/guac-rs/Cargo.lock new file mode 100644 index 0000000..8f6bdab --- /dev/null +++ b/guac-rs/Cargo.lock @@ -0,0 +1,209 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "guac-rs" +version = "0.1.0" +dependencies = [ + "neon", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "libloading" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "neon" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d75440242411c87dc39847b0e33e961ec1f10326a9d8ecf9c1ea64a3b3c13dc" +dependencies = [ + "getrandom", + "libloading", + "neon-macros", + "once_cell", + "semver", + "send_wrapper", + "smallvec", +] + +[[package]] +name = "neon-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6813fde79b646e47e7ad75f480aa80ef76a5d9599e2717407961531169ee38b" +dependencies = [ + "quote", + "syn", + "syn-mid", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "proc-macro2" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-mid" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5dc35bb08dd1ca3dfb09dce91fd2d13294d6711c88897d9a9d60acf39bce049" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/guac-rs/Cargo.toml b/guac-rs/Cargo.toml new file mode 100644 index 0000000..917285f --- /dev/null +++ b/guac-rs/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "guac-rs" +description = "Rust guacamole decoding :)" +version = "0.1.0" +license = "MIT" +edition = "2021" +exclude = ["index.node"] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +neon = "1" diff --git a/guac-rs/index.d.ts b/guac-rs/index.d.ts new file mode 100644 index 0000000..8edbc8e --- /dev/null +++ b/guac-rs/index.d.ts @@ -0,0 +1,3 @@ + +export function guacDecode(input: string): string[]; +export function guacEncode(...items: string[]): string; diff --git a/guac-rs/index.js b/guac-rs/index.js new file mode 100644 index 0000000..3d87ceb --- /dev/null +++ b/guac-rs/index.js @@ -0,0 +1,6 @@ +// *sigh* +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +export let {guacDecode, guacEncode} = require('./index.node'); + diff --git a/guac-rs/package.json b/guac-rs/package.json new file mode 100644 index 0000000..493934b --- /dev/null +++ b/guac-rs/package.json @@ -0,0 +1,15 @@ +{ + "name": "@cvmts/guac-rs", + "packageManager": "yarn@4.1.1", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "build": "cargo-cp-artifact -nc index.node -- cargo build --release --message-format=json-render-diagnostics", + "install": "yarn build", + "test": "cargo test" + }, + "devDependencies": { + "cargo-cp-artifact": "^0.1" + } +} diff --git a/guac-rs/src/guac.rs b/guac-rs/src/guac.rs new file mode 100644 index 0000000..b92f289 --- /dev/null +++ b/guac-rs/src/guac.rs @@ -0,0 +1,193 @@ +use std::fmt; + +// type of a guac message +pub type Elements = Vec; + +// FIXME: thiserror, please. + +/// Errors during decoding +#[derive(Debug, Clone)] +pub enum DecodeError { + /// Invalid guacamole instruction format + InvalidFormat, + + /// Instruction is too long for the current decode policy. + InstructionTooLong, + + /// Element is too long for the current decode policy. + ElementTooLong, + + /// Invalid element size. + ElementSizeInvalid, +} + +pub type DecodeResult = std::result::Result; + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => write!(f, "Invalid Guacamole instruction while decoding"), + Self::InstructionTooLong => write!(f, "Instruction too long for current decode policy"), + Self::ElementTooLong => write!(f, "Element too long for current decode policy"), + Self::ElementSizeInvalid => write!(f, "Element size is invalid") + } + } +} + +// this decode policy abstraction would in theory be useful, +// but idk how to do this kind of thing in rust very well + +pub struct StaticDecodePolicy(); + +impl StaticDecodePolicy { + fn max_instruction_size(&self) -> usize { + INST_SIZE + } + + fn max_element_size(&self) -> usize { + ELEM_SIZE + } +} + +/// The default decode policy. +pub type DefaultDecodePolicy = StaticDecodePolicy<12288, 4096>; + + +/// Encodes elements into a Guacamole instruction +pub fn encode_instruction(elements: &Elements) -> String { + let mut str = String::new(); + + for elem in elements.iter() { + str.push_str(&format!("{}.{},", elem.len(), elem)); + } + + // hacky, but whatever + str.pop(); + str.push(';'); + + str +} + +/// Decodes a Guacamole instruction to individual elements +pub fn decode_instruction(element_string: &String) -> DecodeResult { + let policy = DefaultDecodePolicy {}; + + let mut vec: Elements = Vec::new(); + let mut current_position: usize = 0; + + // Instruction is too long. Don't even bother + if policy.max_instruction_size() < element_string.len() { + return Err(DecodeError::InstructionTooLong); + } + + let chars = element_string.chars().collect::>(); + + loop { + let mut element_size: usize = 0; + + // Scan the integer value in by hand. This is mostly because + // I'm stupid, and the Rust integer parsing routines (seemingly) + // require a substring (or a slice, but, if you can generate a slice, + // you can also just scan the value in by hand.) + // + // We bound this anyways and do quite the checks, so even though it's not great, + // it should be generally fine (TM). + loop { + let c = chars[current_position]; + + if c >= '0' && c <= '9' { + element_size = element_size * 10 + (c as usize) - ('0' as usize); + } else { + if c == '.' { + break; + } + + return Err(DecodeError::InvalidFormat); + } + current_position += 1; + } + + // Eat the '.' seperating the size and the element data; + // our integer scanning ensures we only get here in the case that this is actually the '.' + // character. + current_position += 1; + + // Make sure the element size doesn't overflow the decode policy + // or the size of the whole instruction. + + if element_size >= policy.max_element_size() { + return Err(DecodeError::ElementTooLong); + } + + if element_size >= element_string.len() { + return Err(DecodeError::ElementSizeInvalid); + } + + // cutoff elements or something + if current_position + element_size > chars.len()-1 { + //println!("? {current_position} a {}", chars.len()); + return Err(DecodeError::InvalidFormat); + } + + let element = chars + .iter() + .skip(current_position) + .take(element_size) + .collect::(); + + current_position += element_size; + + vec.push(element); + + // make sure seperator is proper + match chars[current_position] { + ',' => {} + ';' => break, + _ => return Err(DecodeError::InvalidFormat), + } + + // eat the ',' + current_position += 1; + } + + Ok(vec) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_basic() { + let test = String::from("7.connect,3.vm1;"); + let res = decode_instruction(&test); + + assert!(res.is_ok()); + assert_eq!(res.unwrap(), vec!["connect", "vm1"]); + } + + #[test] + fn decode_errors() { + let test = String::from("700.connect,3.vm1;"); + let res = decode_instruction(&test); + + eprintln!("Error for: {}", res.clone().unwrap_err()); + + assert!(res.is_err()) + } + + // generally just test that the codec even works + // (we can decode a instruction we created) + #[test] + fn general_codec_works() { + let vec = vec![String::from("connect"), String::from("vm1")]; + let test = encode_instruction(&vec); + + assert_eq!(test, "7.connect,3.vm1;"); + + let res = decode_instruction(&test); + + assert!(res.is_ok()); + assert_eq!(res.unwrap(), vec); + } +} diff --git a/guac-rs/src/lib.rs b/guac-rs/src/lib.rs new file mode 100644 index 0000000..dc90d35 --- /dev/null +++ b/guac-rs/src/lib.rs @@ -0,0 +1,80 @@ +mod guac; + +use neon::prelude::*; + +fn guac_decode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsArray> { + let input = cx.argument::(0)?.value(cx); + + match guac::decode_instruction(&input) { + Ok(data) => { + let array = JsArray::new(cx, data.len()); + + let conv = data.iter() + .map(|v| { + cx.string(v) + }) + .collect::>>(); + + for (i, str) in conv.iter().enumerate() { + array.set(cx, i as u32, *str)?; + } + + return Ok(array); + } + + Err(e) => { + let err = cx.string(format!("Error decoding guacamole: {}", e)); + return cx.throw(err); + } + } +} + +fn guac_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsString> { + + let mut elements: Vec = Vec::with_capacity(cx.len()); + + // Capture varadic arguments + for i in 0..cx.len() { + let input = cx.argument::(i)?.value(cx); + elements.push(input); + } + + // old array stuff + /* + let input = cx.argument::(0)?; + let raw_elements = input.to_vec(cx)?; + + // bleh + let vecres: Result, _> = raw_elements + .iter() + .map(|item| match item.to_string(cx) { + Ok(s) => { + return Ok(s.value(cx)); + } + + Err(e) => { + return Err(e); + } + }) + .collect(); + + let vec = vecres?; + */ + + Ok(cx.string(guac::encode_instruction(&elements))) +} + +fn guac_decode(mut cx: FunctionContext) -> JsResult { + guac_decode_impl(&mut cx) +} + +fn guac_encode(mut cx: FunctionContext) -> JsResult { + guac_encode_impl(&mut cx) +} + +#[neon::main] +fn main(mut cx: ModuleContext) -> NeonResult<()> { + cx.export_function("guacDecode", guac_decode)?; + cx.export_function("guacEncode", guac_encode)?; + Ok(()) +} diff --git a/package.json b/package.json index 06e4e28..7c9552a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "cvmts-repo", "workspaces": [ "shared", + "guac-rs", "jpeg-turbo", "nodejs-rfb", "qemu", diff --git a/yarn.lock b/yarn.lock index bf82089..6635007 100644 --- a/yarn.lock +++ b/yarn.lock @@ -75,6 +75,14 @@ __metadata: languageName: unknown linkType: soft +"@cvmts/guac-rs@workspace:guac-rs": + version: 0.0.0-use.local + resolution: "@cvmts/guac-rs@workspace:guac-rs" + dependencies: + cargo-cp-artifact: "npm:^0.1" + languageName: unknown + linkType: soft + "@cvmts/qemu@npm:*, @cvmts/qemu@workspace:qemu": version: 0.0.0-use.local resolution: "@cvmts/qemu@workspace:qemu" @@ -1772,6 +1780,15 @@ __metadata: languageName: node linkType: hard +"cargo-cp-artifact@npm:^0.1": + version: 0.1.9 + resolution: "cargo-cp-artifact@npm:0.1.9" + bin: + cargo-cp-artifact: bin/cargo-cp-artifact.js + checksum: 10c0/60eb1845917cfb021920fcf600a72379890b385396f9c69107face3b16b347960b66cd3d82cc169c6ac8b1212cf0706584125bc36fbc08353b033310c17ca0a6 + languageName: node + linkType: hard + "chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" From d9ee611bc5548f66ca82543012a3125d9a2079b1 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 01:37:17 -0400 Subject: [PATCH 17/60] guac-rs: reformat im stupid --- guac-rs/src/lib.rs | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/guac-rs/src/lib.rs b/guac-rs/src/lib.rs index dc90d35..95063e4 100644 --- a/guac-rs/src/lib.rs +++ b/guac-rs/src/lib.rs @@ -9,11 +9,10 @@ fn guac_decode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsArray> { Ok(data) => { let array = JsArray::new(cx, data.len()); - let conv = data.iter() - .map(|v| { - cx.string(v) - }) - .collect::>>(); + let conv = data + .iter() + .map(|v| cx.string(v)) + .collect::>>(); for (i, str) in conv.iter().enumerate() { array.set(cx, i as u32, *str)?; @@ -30,17 +29,16 @@ fn guac_decode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsArray> { } fn guac_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsString> { + let mut elements: Vec = Vec::with_capacity(cx.len()); - let mut elements: Vec = Vec::with_capacity(cx.len()); + // Capture varadic arguments + for i in 0..cx.len() { + let input = cx.argument::(i)?.value(cx); + elements.push(input); + } - // Capture varadic arguments - for i in 0..cx.len() { - let input = cx.argument::(i)?.value(cx); - elements.push(input); - } - - // old array stuff - /* + // old array stuff + /* let input = cx.argument::(0)?; let raw_elements = input.to_vec(cx)?; @@ -59,7 +57,7 @@ fn guac_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsString> .collect(); let vec = vecres?; - */ + */ Ok(cx.string(guac::encode_instruction(&elements))) } @@ -69,7 +67,7 @@ fn guac_decode(mut cx: FunctionContext) -> JsResult { } fn guac_encode(mut cx: FunctionContext) -> JsResult { - guac_encode_impl(&mut cx) + guac_encode_impl(&mut cx) } #[neon::main] From eefde464b4531a3a8fc59b1fec75cf5e8012b3ea Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 01:49:12 -0400 Subject: [PATCH 18/60] why is yarn so picky --- guac-rs/package.json | 1 + yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/guac-rs/package.json b/guac-rs/package.json index 493934b..c939fc5 100644 --- a/guac-rs/package.json +++ b/guac-rs/package.json @@ -1,5 +1,6 @@ { "name": "@cvmts/guac-rs", + "version": "0.1.0", "packageManager": "yarn@4.1.1", "type": "module", "main": "index.js", diff --git a/yarn.lock b/yarn.lock index 6635007..72e6c52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,6 +61,7 @@ __metadata: resolution: "@cvmts/cvmts@workspace:cvmts" dependencies: "@computernewb/jpeg-turbo": "npm:*" + "@cvmts/guac-rs": "npm:*" "@cvmts/qemu": "npm:*" "@types/node": "npm:^20.12.5" "@types/ws": "npm:^8.5.5" @@ -75,7 +76,7 @@ __metadata: languageName: unknown linkType: soft -"@cvmts/guac-rs@workspace:guac-rs": +"@cvmts/guac-rs@npm:*, @cvmts/guac-rs@workspace:guac-rs": version: 0.0.0-use.local resolution: "@cvmts/guac-rs@workspace:guac-rs" dependencies: From b342d4874f24f07fd6f6dd2e378240613e684400 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 02:14:57 -0400 Subject: [PATCH 19/60] remove license cargo key --- guac-rs/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/guac-rs/Cargo.toml b/guac-rs/Cargo.toml index 917285f..7fec5d6 100644 --- a/guac-rs/Cargo.toml +++ b/guac-rs/Cargo.toml @@ -2,7 +2,6 @@ name = "guac-rs" description = "Rust guacamole decoding :)" version = "0.1.0" -license = "MIT" edition = "2021" exclude = ["index.node"] From ba8743f461b09c35863754a6182530048adc37b6 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 02:34:38 -0400 Subject: [PATCH 20/60] guac-rs: remove commented dead code gits a SCM. --- guac-rs/src/lib.rs | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/guac-rs/src/lib.rs b/guac-rs/src/lib.rs index 95063e4..2d7f623 100644 --- a/guac-rs/src/lib.rs +++ b/guac-rs/src/lib.rs @@ -22,7 +22,7 @@ fn guac_decode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsArray> { } Err(e) => { - let err = cx.string(format!("Error decoding guacamole: {}", e)); + let err = cx.string(format!("{}", e)); return cx.throw(err); } } @@ -37,28 +37,6 @@ fn guac_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsString> elements.push(input); } - // old array stuff - /* - let input = cx.argument::(0)?; - let raw_elements = input.to_vec(cx)?; - - // bleh - let vecres: Result, _> = raw_elements - .iter() - .map(|item| match item.to_string(cx) { - Ok(s) => { - return Ok(s.value(cx)); - } - - Err(e) => { - return Err(e); - } - }) - .collect(); - - let vec = vecres?; - */ - Ok(cx.string(guac::encode_instruction(&elements))) } From b485e7f689c46816239114ab2d8e6be1ce24edd7 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 17:56:55 -0400 Subject: [PATCH 21/60] cvmts: reimplement connection limit using ipdata --- cvmts/src/IPData.ts | 13 +++++++++++++ cvmts/src/WebSocket/WSServer.ts | 18 +++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/cvmts/src/IPData.ts b/cvmts/src/IPData.ts index ce53011..676b9e8 100644 --- a/cvmts/src/IPData.ts +++ b/cvmts/src/IPData.ts @@ -40,6 +40,19 @@ export class IPDataManager { return data; } + static GetIPDataMaybe(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; + } + + return null; + } + static ForEachIPData(callback: (d: IPData) => void) { for (let tuple of IPDataManager.ipDatas) callback(tuple[1]); } diff --git a/cvmts/src/WebSocket/WSServer.ts b/cvmts/src/WebSocket/WSServer.ts index 71f4fdd..918cfe6 100644 --- a/cvmts/src/WebSocket/WSServer.ts +++ b/cvmts/src/WebSocket/WSServer.ts @@ -119,15 +119,15 @@ export default class WSServer extends EventEmitter implements NetworkServer { ip = req.socket.remoteAddress; } - // TODO: Implement + let ipdata = IPDataManager.GetIPDataMaybe(ip); - // Get the amount of active connections coming from the requesting IP. - //let connections = this.clients.filter((client) => client.IP.address == ip); - // If it exceeds the limit set in the config, reject the connection with a 429. - //if (connections.length + 1 > this.Config.http.maxConnections) { - // socket.write('HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests'); - // socket.destroy(); - //} + if(ipdata != null) { + let connections = ipdata.refCount; + if (connections + 1 > this.Config.collabvm.maxConnections) { + socket.write('HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests'); + socket.destroy(); + } + } this.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => { this.wsServer.emit('connection', ws, req); @@ -149,4 +149,4 @@ export default class WSServer extends EventEmitter implements NetworkServer { this.logger.Info(`New WebSocket connection from ${user.IP.address}`); } -} \ No newline at end of file +} From 0d34bb1c8e720960b3fc5f3cfafe384ccf155fdb Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 18:03:10 -0400 Subject: [PATCH 22/60] cvmts/qemu: support snapshots properly --- cvmts/src/index.ts | 3 ++- qemu/src/QemuVM.ts | 15 +++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index 3ead48a..df45e80 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -60,7 +60,8 @@ async function start() { // Fire up the VM let def: QemuVmDefinition = { id: Config.collabvm.node, - command: Config.qemu.qemuArgs + command: Config.qemu.qemuArgs, + snapshot: Config.qemu.snapshots }; VM = new QemuVM(def); diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index b1ab475..1378601 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -16,8 +16,9 @@ export enum VMState { // 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; + id: string, + command: string, + snapshot: boolean }; /// Temporary path base (for UNIX sockets/etc.) @@ -27,12 +28,6 @@ const kVmTmpPathBase = `/tmp`; /// 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; @@ -68,7 +63,7 @@ export class QemuVM extends EventEmitter { // FIXME: Still use TCP if on Windows. if (!this.addedAdditionalArguments) { cmd += ' -no-shutdown'; - if (gVMShouldSnapshot) cmd += ' -snapshot'; + if (this.definition.snapshot) cmd += ' -snapshot'; cmd += ` -qmp unix:${this.GetQmpPath()},server,wait -vnc unix:${this.GetVncPath()}`; this.definition.command = cmd; this.addedAdditionalArguments = true; @@ -79,7 +74,7 @@ export class QemuVM extends EventEmitter { } SnapshotsSupported() : boolean { - return gVMShouldSnapshot; + return this.definition.snapshot; } async Reboot() : Promise { From e798ff5c86d2d728f6f141849f66749ecdf2ece6 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 18:16:16 -0400 Subject: [PATCH 23/60] bandaid fix time --- cvmts/src/User.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index cbc2a68..5578b37 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -78,7 +78,10 @@ export class User { if (!this.socket.isOpen()) return; clearInterval(this.nopSendInterval); this.nopSendInterval = setInterval(() => this.sendNop(), 5000); - this.socket.send(msg); + this.socket.send(msg) + .catch((err: Error) => { + this.logger.Error(`bandaid fix: ${err.message}`); + }); } private onNoMsg() { From fa23aa74326a96fa51710b422f56635d221a062e Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 18:20:41 -0400 Subject: [PATCH 24/60] cvmts: nope I have to fix it properly --- cvmts/src/User.ts | 5 +---- cvmts/src/WebSocket/WSClient.ts | 5 ++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 5578b37..cbc2a68 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -78,10 +78,7 @@ export class User { if (!this.socket.isOpen()) return; clearInterval(this.nopSendInterval); this.nopSendInterval = setInterval(() => this.sendNop(), 5000); - this.socket.send(msg) - .catch((err: Error) => { - this.logger.Error(`bandaid fix: ${err.message}`); - }); + this.socket.send(msg); } private onNoMsg() { diff --git a/cvmts/src/WebSocket/WSClient.ts b/cvmts/src/WebSocket/WSClient.ts index 69b6468..cf5e2b0 100644 --- a/cvmts/src/WebSocket/WSClient.ts +++ b/cvmts/src/WebSocket/WSClient.ts @@ -35,6 +35,9 @@ export default class WSClient extends EventEmitter implements NetworkClient { } send(msg: string): Promise { return new Promise((res,rej) => { + if(!this.isOpen()) + res(); + this.socket.send(msg, (err) => { if (err) { rej(err); @@ -49,4 +52,4 @@ export default class WSClient extends EventEmitter implements NetworkClient { this.socket.close(); } -} \ No newline at end of file +} From 97de887518d1d331d3134d2993f1b62f5a7bf3be Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 18:26:27 -0400 Subject: [PATCH 25/60] cvmts: Actually unref ipdata on disconnect --- cvmts/src/User.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index cbc2a68..05d26b4 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -35,15 +35,21 @@ export class User { this.Config = config; this.socket = socket; this.msgsSent = 0; + this.socket.on('disconnect', () => { + // Unref the ip data for this connection + this.IP.Unref(); + clearInterval(this.nopSendInterval); clearInterval(this.msgRecieveInterval); }); + this.socket.on('msg', (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(); From 39521a4b1dc0eeaef1626a571812e4c1902fb5fc Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 19 Jun 2024 23:30:29 -0400 Subject: [PATCH 26/60] misc stuff from production (also refactors qemu a bit) --- cvmts/src/CollabVMServer.ts | 19 +++++- cvmts/src/ThumbnailJPEGEncoderWorker.ts | 1 - qemu/package.json | 2 +- qemu/src/QemuVM.ts | 81 +++++++++++++++---------- 4 files changed, 69 insertions(+), 34 deletions(-) diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index be084b2..fc5f7d3 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -6,7 +6,7 @@ import * as guac from '@cvmts/guac-rs'; import CircularBuffer from 'mnemonist/circular-buffer.js'; import Queue from 'mnemonist/queue.js'; import { createHash } from 'crypto'; -import { QemuVM, QemuVmDefinition } from '@cvmts/qemu'; +import { VMState, QemuVM, QemuVmDefinition } from '@cvmts/qemu'; import { IPDataManager } from './IPData.js'; import { readFileSync } from 'node:fs'; import path from 'node:path'; @@ -21,6 +21,10 @@ const __dirname = import.meta.dirname; const kCVMTSAssetsRoot = path.resolve(__dirname, '../../assets'); +const kRestartTimeout = 5000; + + + type ChatHistory = { user: string; msg: string; @@ -110,6 +114,19 @@ export default class CollabVMServer { this.VM = vm; + // hack but whatever (TODO: less rickity) + if(config.vm.type == "qemu") { + (vm as QemuVM).on('statechange', (newState: VMState) => { + if(newState == VMState.Stopped) { + this.logger.Info("stopped ?"); + setTimeout(async () => { + this.logger.Info("restarting VM"); + await this.VM.Start(); + }, kRestartTimeout) + } + }); + } + // authentication manager this.auth = auth; } diff --git a/cvmts/src/ThumbnailJPEGEncoderWorker.ts b/cvmts/src/ThumbnailJPEGEncoderWorker.ts index 2b96658..b957188 100644 --- a/cvmts/src/ThumbnailJPEGEncoderWorker.ts +++ b/cvmts/src/ThumbnailJPEGEncoderWorker.ts @@ -20,7 +20,6 @@ function GetRawSharpOptions(size: Size): sharp.CreateRaw { export default async (opts: any) => { try { - console.log(opts) let out = await sharp(opts.buffer, { raw: GetRawSharpOptions(opts.size) }) .resize(kThumbnailSize.width, kThumbnailSize.height, { fit: 'fill' }) .jpeg({ diff --git a/qemu/package.json b/qemu/package.json index 19e1788..ce9d2e0 100644 --- a/qemu/package.json +++ b/qemu/package.json @@ -1,7 +1,7 @@ { "name": "@cvmts/qemu", "version": "1.0.0", - "description": "QEMU runtime for crusttest backend", + "description": "A simple and easy to use QEMU supervision runtime", "exports": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 1378601..6afdadc 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -28,7 +28,6 @@ const kVmTmpPathBase = `/tmp`; /// the VM is forcefully stopped. const kMaxFailCount = 5; - export class QemuVM extends EventEmitter { private state = VMState.Stopped; @@ -69,7 +68,6 @@ export class QemuVM extends EventEmitter { this.addedAdditionalArguments = true; } - this.VMLog().Info(`Starting QEMU with command \"${cmd}\"`); await this.StartQemu(cmd); } @@ -84,28 +82,27 @@ export class QemuVM extends EventEmitter { 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 + + // Indicate we're stopping, so we don't // erroneously start trying to restart everything - // we're going to tear down in this function call. + // we're going to tear down. this.SetState(VMState.Stopping); // Kill the QEMU process and QMP/display connections if they are running. await this.DisconnectQmp(); - this.DisconnectDisplay(); + await this.DisconnectDisplay(); await this.StopQemu(); + } async Reset() { - this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM'); + //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(); + + // Do magic. + await this.StopQemu(); } async QmpCommand(command: string, args: any | null): Promise { @@ -152,6 +149,12 @@ export class QemuVM extends EventEmitter { private SetState(state: VMState) { this.state = state; this.emit('statechange', this.state); + + // reset some state when starting the vm back up + // to avoid potentional issues. + if(this.state == VMState.Starting) { + this.qmpFailCount = 0; + } } private GetQmpPath() { @@ -167,27 +170,44 @@ export class QemuVM extends EventEmitter { this.SetState(VMState.Starting); + this.VMLog().Info(`Starting QEMU with command \"${split}\"`); + // Start QEMU this.qemuProcess = execaCommand(split); this.qemuProcess.on('spawn', async () => { + self.VMLog().Info("QEMU started"); self.qemuRunning = true; await Shared.Sleep(500); await self.ConnectQmp(); }); this.qemuProcess.on('exit', async (code) => { + self.VMLog().Info("QEMU process exited"); self.qemuRunning = false; + + // this should be being done anways but it's very clearly not sometimes so + // fuck it, let's just force it here + try { + await unlink(this.GetVncPath()); + } catch(_) {} + + try { + await unlink(this.GetQmpPath()); + } catch(_) {} + // ? if (self.qmpConnected) { await self.DisconnectQmp(); } - self.DisconnectDisplay(); + await self.DisconnectDisplay(); + if (self.state != VMState.Stopping) { if (code == 0) { + // Wait a bit and restart QEMU. await Shared.Sleep(500); await self.StartQemu(split); } else { @@ -195,6 +215,7 @@ export class QemuVM extends EventEmitter { await self.Stop(); } } else { + // Indicate we have stopped. this.SetState(VMState.Stopped); } }); @@ -210,26 +231,28 @@ export class QemuVM extends EventEmitter { if (!this.qmpConnected) { self.qmpInstance = new QmpClient(); - let onQmpError = async (err: Error|undefined) => { - self.qmpConnected = false; + let onQmpError = async () => { + if(self.qmpConnected) { + self.qmpConnected = false; - // If we aren't stopping, then we do actually need to care QMP disconnected - if (self.state != VMState.Stopping) { - //if(err !== undefined) // This doesn't show anything useful or maybe I'm just stupid idk - // self.VMLog().Error(`Error: ${err!}`) - if (self.qmpFailCount++ < kMaxFailCount) { - self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); - await Shared.Sleep(500); - await self.ConnectQmp(); - } else { - self.VMLog().Error(`Reached max retries, giving up.`); - await self.Stop(); + // If we aren't stopping, then we should care QMP disconnected + if (self.state != VMState.Stopping) { + if (self.qmpFailCount++ < kMaxFailCount) { + self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); + await Shared.Sleep(500); + await self.ConnectQmp(); + } else { + self.VMLog().Error(`Reached max retries, giving up.`); + await self.Stop(); + } } } }; self.qmpInstance.on('close', onQmpError); - self.qmpInstance.on('error', onQmpError); + self.qmpInstance.on('error', (e: Error) => { + self.VMLog().Error("QMP Error: {0}", e.message); + }); self.qmpInstance.on('event', async (ev) => { switch (ev.event) { @@ -269,10 +292,6 @@ export class QemuVM extends EventEmitter { 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 } From 87a377a10f317a755275237e26e30b4d9c06a973 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Thu, 20 Jun 2024 03:20:56 -0400 Subject: [PATCH 27/60] cvmts: replace jpeg-turbo native module with new rust module This module also does threadpooling internally, so we don't need Piscina anymore (which I'm pretty sure was actually bottlenecking.) --- .gitignore | 4 + .gitmodules | 3 - cvmts/package.json | 3 +- cvmts/src/CollabVMServer.ts | 2 +- cvmts/src/JPEGEncoder.ts | 71 ++-- cvmts/src/JPEGEncoderWorker.ts | 19 - cvmts/src/ThumbnailJPEGEncoderWorker.ts | 36 -- jpeg-turbo | 1 - jpegturbo-rs/Cargo.lock | 350 ++++++++++++++++++ jpegturbo-rs/Cargo.toml | 15 + jpegturbo-rs/index.d.ts | 15 + jpegturbo-rs/index.js | 6 + jpegturbo-rs/package.json | 16 + jpegturbo-rs/src/jpeg_compressor.rs | 82 +++++ jpegturbo-rs/src/lib.rs | 91 +++++ package.json | 2 +- yarn.lock | 471 +----------------------- 17 files changed, 632 insertions(+), 555 deletions(-) delete mode 100644 cvmts/src/JPEGEncoderWorker.ts delete mode 100644 cvmts/src/ThumbnailJPEGEncoderWorker.ts delete mode 160000 jpeg-turbo create mode 100644 jpegturbo-rs/Cargo.lock create mode 100644 jpegturbo-rs/Cargo.toml create mode 100644 jpegturbo-rs/index.d.ts create mode 100644 jpegturbo-rs/index.js create mode 100644 jpegturbo-rs/package.json create mode 100644 jpegturbo-rs/src/jpeg_compressor.rs create mode 100644 jpegturbo-rs/src/lib.rs diff --git a/.gitignore b/.gitignore index 0215abd..869f36b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ cvmts/attic # Guac-rs guac-rs/target guac-rs/index.node + +# jpegturbo-rs +jpegturbo-rs/target +jpegturbo-rs/index.node diff --git a/.gitmodules b/.gitmodules index 76ac5b5..24e582d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [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 diff --git a/cvmts/package.json b/cvmts/package.json index fbf2c87..94754d5 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -11,12 +11,11 @@ "author": "Elijah R, modeco80", "license": "GPL-3.0", "dependencies": { - "@computernewb/jpeg-turbo": "*", "@cvmts/guac-rs": "*", + "@cvmts/jpegturbo-rs": "*", "@cvmts/qemu": "*", "execa": "^8.0.1", "mnemonist": "^0.39.5", - "piscina": "^4.4.0", "sharp": "^0.33.3", "toml": "^3.0.0", "ws": "^8.14.1" diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index fc5f7d3..5cb6f9b 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -810,7 +810,7 @@ export default class CollabVMServer { let display = this.VM.GetDisplay(); let displaySize = display.Size(); - let encoded = await JPEGEncoder.EncodeJpeg(display.Buffer(), displaySize, rect); + let encoded = await JPEGEncoder.Encode(display.Buffer(), displaySize, rect); return encoded.toString('base64'); } diff --git a/cvmts/src/JPEGEncoder.ts b/cvmts/src/JPEGEncoder.ts index 2cc3a6c..7e73243 100644 --- a/cvmts/src/JPEGEncoder.ts +++ b/cvmts/src/JPEGEncoder.ts @@ -1,59 +1,52 @@ -import path from 'node:path'; -import Piscina from 'piscina'; - import { Size, Rect } from '@cvmts/shared'; - -const kMaxJpegThreads = 4; -const kIdleTimeout = 25000; - -// Thread pool for doing JPEG encoding for rects. -const TheJpegEncoderPool = new Piscina({ - filename: path.join(import.meta.dirname + '/JPEGEncoderWorker.js'), - idleTimeout: kIdleTimeout, - maxThreads: kMaxJpegThreads -}); - -const TheThumbnailEncoderPool = new Piscina({ - filename: path.join(import.meta.dirname + '/ThumbnailJPEGEncoderWorker.js'), - idleTimeout: kIdleTimeout, - maxThreads: kMaxJpegThreads -}); +import sharp from 'sharp'; +import * as jpeg from '@cvmts/jpegturbo-rs'; // A good balance. TODO: Configurable? let gJpegQuality = 35; -export class JPEGEncoder { +const kThumbnailSize: Size = { + width: 400, + height: 300 +}; +// this returns appropiate Sharp options to deal with CVMTS raw framebuffers +// (which are RGBA bitmaps, essentially. We probably should abstract that out but +// that'd mean having to introduce that to rfb and oihwekjtgferklds;./tghnredsltg;erhds) +function GetRawSharpOptions(size: Size): sharp.CreateRaw { + return { + width: size.width, + height: size.height, + channels: 4 + }; +} + +export class JPEGEncoder { static SetQuality(quality: number) { gJpegQuality = quality; } - static async EncodeJpeg(canvas: Buffer, displaySize: Size, rect: Rect): Promise { + static async Encode(canvas: Buffer, displaySize: Size, rect: Rect): Promise { let offset = (rect.y * displaySize.width + rect.x) * 4; - - let res = await TheJpegEncoderPool.run({ - buffer: canvas.subarray(offset), + return jpeg.jpegEncode({ width: rect.width, height: rect.height, stride: displaySize.width, - quality: gJpegQuality + buffer: canvas.subarray(offset) }); - - // TODO: There's probably (definitely) a better way to fix this - if (res == undefined) return Buffer.from([]); - - // have to manually turn it back into a buffer because - // Piscina for some reason turns it into a Uint8Array - return Buffer.from(res); } - static async EncodeThumbnail(buffer: Buffer, size: Size) : Promise { - let res = await TheThumbnailEncoderPool.run({ - buffer: buffer, - size: size, - quality: gJpegQuality - }); + static async EncodeThumbnail(buffer: Buffer, size: Size): Promise { + let { data, info } = await sharp(buffer, { raw: GetRawSharpOptions(size) }) + .resize(kThumbnailSize.width, kThumbnailSize.height, { fit: 'fill' }) + .raw() + .toBuffer({ resolveWithObject: true }); - return Buffer.from(res) + return jpeg.jpegEncode({ + width: kThumbnailSize.width, + height: kThumbnailSize.height, + stride: kThumbnailSize.width, + buffer: data + }); } } diff --git a/cvmts/src/JPEGEncoderWorker.ts b/cvmts/src/JPEGEncoderWorker.ts deleted file mode 100644 index 124914b..0000000 --- a/cvmts/src/JPEGEncoderWorker.ts +++ /dev/null @@ -1,19 +0,0 @@ -import jpegTurbo from '@computernewb/jpeg-turbo'; -import Piscina from 'piscina'; - -export default async (opts: any) => { - try { - let res = await jpegTurbo.compress(opts.buffer, { - format: jpegTurbo.FORMAT_RGBA, - width: opts.width, - height: opts.height, - subsampling: jpegTurbo.SAMP_422, - stride: opts.stride, - quality: opts.quality || 75 - }); - - return Piscina.move(res); - } catch { - return; - } -}; diff --git a/cvmts/src/ThumbnailJPEGEncoderWorker.ts b/cvmts/src/ThumbnailJPEGEncoderWorker.ts deleted file mode 100644 index b957188..0000000 --- a/cvmts/src/ThumbnailJPEGEncoderWorker.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Size } from '@cvmts/shared'; -import Piscina from 'piscina'; -import sharp from 'sharp'; - -const kThumbnailSize: Size = { - width: 400, - height: 300 -}; - -// this returns appropiate Sharp options to deal with CVMTS raw framebuffers -// (which are RGBA bitmaps, essentially. We probably should abstract that out but -// that'd mean having to introduce that to rfb and oihwekjtgferklds;./tghnredsltg;erhds) -function GetRawSharpOptions(size: Size): sharp.CreateRaw { - return { - width: size.width, - height: size.height, - channels: 4 - }; -} - -export default async (opts: any) => { - try { - let out = await sharp(opts.buffer, { raw: GetRawSharpOptions(opts.size) }) - .resize(kThumbnailSize.width, kThumbnailSize.height, { fit: 'fill' }) - .jpeg({ - quality: opts.quality || 75 - }) - .toFormat('jpeg') - .toBuffer(); - - return out; - } catch { - return; - } - -}; diff --git a/jpeg-turbo b/jpeg-turbo deleted file mode 160000 index 6718ec1..0000000 --- a/jpeg-turbo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6718ec1fc12aeccdb1b1490a7a258f24e8f83164 diff --git a/jpegturbo-rs/Cargo.lock b/jpegturbo-rs/Cargo.lock new file mode 100644 index 0000000..60574c6 --- /dev/null +++ b/jpegturbo-rs/Cargo.lock @@ -0,0 +1,350 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "backtrace" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "cc" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cmake" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +dependencies = [ + "cc", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "jpegturbo-rs" +version = "0.1.0" +dependencies = [ + "neon", + "once_cell", + "tokio", + "turbojpeg-sys", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "libloading" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + +[[package]] +name = "neon" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d75440242411c87dc39847b0e33e961ec1f10326a9d8ecf9c1ea64a3b3c13dc" +dependencies = [ + "getrandom", + "libloading", + "neon-macros", + "once_cell", + "semver", + "send_wrapper", + "smallvec", +] + +[[package]] +name = "neon-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6813fde79b646e47e7ad75f480aa80ef76a5d9599e2717407961531169ee38b" +dependencies = [ + "quote", + "syn", + "syn-mid", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "proc-macro2" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-mid" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5dc35bb08dd1ca3dfb09dce91fd2d13294d6711c88897d9a9d60acf39bce049" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +dependencies = [ + "backtrace", + "num_cpus", + "pin-project-lite", +] + +[[package]] +name = "turbojpeg-sys" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa6daade3b979fb7454cce5ebcb9772ce7a1cf476ea27ed20ed06e13d9bc983" +dependencies = [ + "anyhow", + "cmake", + "libc", + "pkg-config", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/jpegturbo-rs/Cargo.toml b/jpegturbo-rs/Cargo.toml new file mode 100644 index 0000000..5fc50ae --- /dev/null +++ b/jpegturbo-rs/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "jpegturbo-rs" +description = "Rust powered JPEGTurbo sex" +version = "0.1.0" +edition = "2021" +exclude = ["index.node"] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +neon = "1" +once_cell = "1.19.0" +tokio = { version = "1.38.0", features = [ "rt", "rt-multi-thread" ] } +turbojpeg-sys = "1.0.0" diff --git a/jpegturbo-rs/index.d.ts b/jpegturbo-rs/index.d.ts new file mode 100644 index 0000000..9c653e8 --- /dev/null +++ b/jpegturbo-rs/index.d.ts @@ -0,0 +1,15 @@ + +interface JpegInputArgs { + width: number, + height: number, + stride: number, // The width of your input framebuffer OR your image width (if encoding a full image) + buffer: Buffer + + // TODO: Allow different formats, or export a boxed ffi object which can store a format + // (i.e: new JpegEncoder(FORMAT_xxx)). +} + +/// Performs JPEG encoding. +export function jpegEncode(input: JpegInputArgs) : Promise; + +// TODO: Version that can downscale? diff --git a/jpegturbo-rs/index.js b/jpegturbo-rs/index.js new file mode 100644 index 0000000..5519fa2 --- /dev/null +++ b/jpegturbo-rs/index.js @@ -0,0 +1,6 @@ +// *sigh* +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +export let {jpegEncode} = require('./index.node'); + diff --git a/jpegturbo-rs/package.json b/jpegturbo-rs/package.json new file mode 100644 index 0000000..58be5d6 --- /dev/null +++ b/jpegturbo-rs/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cvmts/jpegturbo-rs", + "version": "0.1.0", + "packageManager": "yarn@4.1.1", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "build": "cargo-cp-artifact -nc index.node -- cargo build --release --message-format=json-render-diagnostics", + "install": "yarn build", + "test": "cargo test" + }, + "devDependencies": { + "cargo-cp-artifact": "^0.1" + } +} diff --git a/jpegturbo-rs/src/jpeg_compressor.rs b/jpegturbo-rs/src/jpeg_compressor.rs new file mode 100644 index 0000000..56248c4 --- /dev/null +++ b/jpegturbo-rs/src/jpeg_compressor.rs @@ -0,0 +1,82 @@ +use turbojpeg_sys::*; + +pub struct Image<'a> { + pub buffer: &'a [u8], + pub width: u32, + pub height: u32, + pub stride: u32, + pub format: TJPF, +} + +pub struct JpegCompressor { + handle: tjhandle, + subsamp: TJSAMP, + quality: u32, +} + +unsafe impl Send for JpegCompressor {} + +impl JpegCompressor { + pub fn new() -> Self { + unsafe { + let init = Self { + handle: tjInitCompress(), + subsamp: TJSAMP_TJSAMP_422, + quality: 95, + }; + return init; + } + } + + pub fn set_quality(&mut self, quality: u32) { + self.quality = quality; + } + + pub fn set_subsamp(&mut self, samp: TJSAMP) { + self.subsamp = samp; + } + + pub fn compress_buffer<'a>(&self, image: &Image<'a>) -> Vec { + unsafe { + let size: usize = + tjBufSize(image.width as i32, image.height as i32, self.subsamp) as usize; + let mut vec = Vec::with_capacity(size); + + vec.resize(size, 0); + + let mut ptr: *mut u8 = vec.as_mut_ptr(); + let mut size: u64 = 0; + + let res = tjCompress2( + self.handle, + image.buffer.as_ptr(), + image.width as i32, + image.stride as i32, + image.height as i32, + image.format, + std::ptr::addr_of_mut!(ptr), + std::ptr::addr_of_mut!(size), + self.subsamp, + self.quality as i32, + (TJFLAG_NOREALLOC) as i32, + ); + + // TODO: Result sex so we can actually notify failure + if res == -1 { + return Vec::new(); + } + + // Truncate down to the size we're given back + vec.truncate(size as usize); + return vec; + } + } +} + +impl Drop for JpegCompressor { + fn drop(&mut self) { + unsafe { + tjDestroy(self.handle); + } + } +} diff --git a/jpegturbo-rs/src/lib.rs b/jpegturbo-rs/src/lib.rs new file mode 100644 index 0000000..dc3f498 --- /dev/null +++ b/jpegturbo-rs/src/lib.rs @@ -0,0 +1,91 @@ +use std::sync::{Arc, Mutex}; + +use neon::prelude::*; +use neon::types::buffer::TypedArray; + +use once_cell::sync::OnceCell; +use tokio::runtime::Runtime; + +use std::cell::RefCell; + +mod jpeg_compressor; + +fn runtime<'a, C: Context<'a>>(cx: &mut C) -> NeonResult<&'static Runtime> { + static RUNTIME: OnceCell = OnceCell::new(); + + RUNTIME + .get_or_try_init(Runtime::new) + .or_else(|err| cx.throw_error(&err.to_string())) +} + +thread_local! { + static COMPRESSOR: RefCell = RefCell::new(jpeg_compressor::JpegCompressor::new()); +} + +fn jpeg_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsPromise> { + let input = cx.argument::(0)?; + + // Get our input arguments here + let width: u64 = input.get::(cx, "width")?.value(cx) as u64; + let height: u64 = input.get::(cx, "height")?.value(cx) as u64; + let stride: u64 = input.get::(cx, "stride")?.value(cx) as u64; + let buffer: Handle = input.get(cx, "buffer")?; + + let (deferred, promise) = cx.promise(); + let channel = cx.channel(); + let runtime = runtime(cx)?; + + let buf = buffer.as_slice(cx); + + let copy: Arc>> = Arc::new(Mutex::new(Vec::with_capacity(buf.len()))); + + // Copy from the node buffer to our temporary buffer + { + let mut locked = copy.lock().unwrap(); + let cap = locked.capacity(); + locked.resize(cap, 0); + locked.copy_from_slice(buf); + } + + // Spawn off a tokio blocking pool thread that will do the work for us + runtime.spawn_blocking(move || { + let clone = Arc::clone(©); + let locked = clone.lock().unwrap(); + + let image: jpeg_compressor::Image = jpeg_compressor::Image { + buffer: locked.as_slice(), + width: width as u32, + height: height as u32, + + stride: (stride * 4u64) as u32, // I think? + format: turbojpeg_sys::TJPF_TJPF_RGBA, + }; + + let vec = COMPRESSOR.with(|lazy| { + let mut b = lazy.borrow_mut(); + b.set_quality(35); + b.set_subsamp(turbojpeg_sys::TJSAMP_TJSAMP_420); + b.compress_buffer(&image) + }); + + // Fulfill the Javascript promise with our encoded buffer + deferred.settle_with(&channel, move |mut cx| { + let mut buf = cx.buffer(vec.len())?; + let slice = buf.as_mut_slice(&mut cx); + slice.copy_from_slice(vec.as_slice()); + Ok(buf) + }); + }); + + Ok(promise) +} + +fn jpeg_encode(mut cx: FunctionContext) -> JsResult { + jpeg_encode_impl(&mut cx) +} + +#[neon::main] +fn main(mut cx: ModuleContext) -> NeonResult<()> { + cx.export_function("jpegEncode", jpeg_encode)?; + Ok(()) +} diff --git a/package.json b/package.json index 7c9552a..82387f8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "workspaces": [ "shared", "guac-rs", - "jpeg-turbo", + "jpegturbo-rs", "nodejs-rfb", "qemu", "cvmts" diff --git a/yarn.lock b/yarn.lock index 72e6c52..2c8b671 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,15 +34,6 @@ __metadata: languageName: node linkType: hard -"@computernewb/jpeg-turbo@npm:*, @computernewb/jpeg-turbo@workspace:jpeg-turbo": - version: 0.0.0-use.local - resolution: "@computernewb/jpeg-turbo@workspace:jpeg-turbo" - dependencies: - cmake-js: "npm:^7.2.0" - node-addon-api: "npm:^6.0.0" - languageName: unknown - linkType: soft - "@computernewb/nodejs-rfb@npm:*, @computernewb/nodejs-rfb@workspace:nodejs-rfb": version: 0.0.0-use.local resolution: "@computernewb/nodejs-rfb@workspace:nodejs-rfb" @@ -60,14 +51,13 @@ __metadata: version: 0.0.0-use.local resolution: "@cvmts/cvmts@workspace:cvmts" dependencies: - "@computernewb/jpeg-turbo": "npm:*" "@cvmts/guac-rs": "npm:*" + "@cvmts/jpegturbo-rs": "npm:*" "@cvmts/qemu": "npm:*" "@types/node": "npm:^20.12.5" "@types/ws": "npm:^8.5.5" execa: "npm:^8.0.1" mnemonist: "npm:^0.39.5" - piscina: "npm:^4.4.0" prettier: "npm:^3.2.5" sharp: "npm:^0.33.3" toml: "npm:^3.0.0" @@ -84,6 +74,14 @@ __metadata: languageName: unknown linkType: soft +"@cvmts/jpegturbo-rs@npm:*, @cvmts/jpegturbo-rs@workspace:jpegturbo-rs": + version: 0.0.0-use.local + resolution: "@cvmts/jpegturbo-rs@workspace:jpegturbo-rs" + dependencies: + cargo-cp-artifact: "npm:^0.1" + languageName: unknown + linkType: soft + "@cvmts/qemu@npm:*, @cvmts/qemu@workspace:qemu": version: 0.0.0-use.local resolution: "@cvmts/qemu@workspace:qemu" @@ -1643,23 +1641,6 @@ __metadata: languageName: node linkType: hard -"aproba@npm:^1.0.3 || ^2.0.0": - version: 2.0.0 - resolution: "aproba@npm:2.0.0" - checksum: 10c0/d06e26384a8f6245d8c8896e138c0388824e259a329e0c9f196b4fa533c82502a6fd449586e3604950a0c42921832a458bb3aa0aa9f0ba449cfd4f50fd0d09b5 - languageName: node - linkType: hard - -"are-we-there-yet@npm:^3.0.0": - version: 3.0.1 - resolution: "are-we-there-yet@npm:3.0.1" - dependencies: - delegates: "npm:^1.0.0" - readable-stream: "npm:^3.6.0" - checksum: 10c0/8373f289ba42e4b5ec713bb585acdac14b5702c75f2a458dc985b9e4fa5762bc5b46b40a21b72418a3ed0cfb5e35bdc317ef1ae132f3035f633d581dd03168c3 - languageName: node - linkType: hard - "argparse@npm:^2.0.1": version: 2.0.1 resolution: "argparse@npm:2.0.1" @@ -1667,24 +1648,6 @@ __metadata: languageName: node linkType: hard -"asynckit@npm:^0.4.0": - version: 0.4.0 - resolution: "asynckit@npm:0.4.0" - checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d - languageName: node - linkType: hard - -"axios@npm:^1.6.5": - version: 1.6.8 - resolution: "axios@npm:1.6.8" - dependencies: - follow-redirects: "npm:^1.15.6" - form-data: "npm:^4.0.0" - proxy-from-env: "npm:^1.1.0" - checksum: 10c0/0f22da6f490335479a89878bc7d5a1419484fbb437b564a80c34888fc36759ae4f56ea28d55a191695e5ed327f0bad56e7ff60fb6770c14d1be6501505d47ab9 - languageName: node - linkType: hard - "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1851,17 +1814,6 @@ __metadata: languageName: node linkType: hard -"cliui@npm:^8.0.1": - version: 8.0.1 - resolution: "cliui@npm:8.0.1" - dependencies: - string-width: "npm:^4.2.0" - strip-ansi: "npm:^6.0.1" - wrap-ansi: "npm:^7.0.0" - checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 - languageName: node - linkType: hard - "clone@npm:^2.1.1": version: 2.1.2 resolution: "clone@npm:2.1.2" @@ -1869,29 +1821,6 @@ __metadata: languageName: node linkType: hard -"cmake-js@npm:^7.2.0": - version: 7.3.0 - resolution: "cmake-js@npm:7.3.0" - dependencies: - axios: "npm:^1.6.5" - debug: "npm:^4" - fs-extra: "npm:^11.2.0" - lodash.isplainobject: "npm:^4.0.6" - memory-stream: "npm:^1.0.0" - node-api-headers: "npm:^1.1.0" - npmlog: "npm:^6.0.2" - rc: "npm:^1.2.7" - semver: "npm:^7.5.4" - tar: "npm:^6.2.0" - url-join: "npm:^4.0.1" - which: "npm:^2.0.2" - yargs: "npm:^17.7.2" - bin: - cmake-js: bin/cmake-js - checksum: 10c0/8aa3839603578e3f40bff55a3b7cb962682223ab91bab82501289d6ee0b84246c22ecdc01381b38079a0f20fd5c6ca01068e928bf399f8054a19a7cdaab1060c - languageName: node - linkType: hard - "color-convert@npm:^1.9.0": version: 1.9.3 resolution: "color-convert@npm:1.9.3" @@ -1934,15 +1863,6 @@ __metadata: languageName: node linkType: hard -"color-support@npm:^1.1.3": - version: 1.1.3 - resolution: "color-support@npm:1.1.3" - bin: - color-support: bin.js - checksum: 10c0/8ffeaa270a784dc382f62d9be0a98581db43e11eee301af14734a6d089bd456478b1a8b3e7db7ca7dc5b18a75f828f775c44074020b51c05fc00e6d0992b1cc6 - languageName: node - linkType: hard - "color@npm:^4.2.3": version: 4.2.3 resolution: "color@npm:4.2.3" @@ -1953,15 +1873,6 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.8": - version: 1.0.8 - resolution: "combined-stream@npm:1.0.8" - dependencies: - delayed-stream: "npm:~1.0.0" - checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 - languageName: node - linkType: hard - "commander@npm:^7.0.0, commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -1969,13 +1880,6 @@ __metadata: languageName: node linkType: hard -"console-control-strings@npm:^1.1.0": - version: 1.1.0 - resolution: "console-control-strings@npm:1.1.0" - checksum: 10c0/7ab51d30b52d461412cd467721bb82afe695da78fff8f29fe6f6b9cbaac9a2328e27a22a966014df9532100f6dd85370460be8130b9c677891ba36d96a343f50 - languageName: node - linkType: hard - "cosmiconfig@npm:^8.0.0": version: 8.3.6 resolution: "cosmiconfig@npm:8.3.6" @@ -2057,7 +1961,7 @@ __metadata: languageName: unknown linkType: soft -"debug@npm:4, debug@npm:^4, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -2069,27 +1973,6 @@ __metadata: languageName: node linkType: hard -"deep-extend@npm:^0.6.0": - version: 0.6.0 - resolution: "deep-extend@npm:0.6.0" - checksum: 10c0/1c6b0abcdb901e13a44c7d699116d3d4279fdb261983122a3783e7273844d5f2537dc2e1c454a23fcf645917f93fbf8d07101c1d03c015a87faa662755212566 - languageName: node - linkType: hard - -"delayed-stream@npm:~1.0.0": - version: 1.0.0 - resolution: "delayed-stream@npm:1.0.0" - checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 - languageName: node - linkType: hard - -"delegates@npm:^1.0.0": - version: 1.0.0 - resolution: "delegates@npm:1.0.0" - checksum: 10c0/ba05874b91148e1db4bf254750c042bf2215febd23a6d3cda2e64896aef79745fbd4b9996488bd3cafb39ce19dbce0fd6e3b6665275638befffe1c9b312b91b5 - languageName: node - linkType: hard - "detect-libc@npm:^1.0.3": version: 1.0.3 resolution: "detect-libc@npm:1.0.3" @@ -2279,16 +2162,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.6": - version: 1.15.6 - resolution: "follow-redirects@npm:1.15.6" - peerDependenciesMeta: - debug: - optional: true - checksum: 10c0/9ff767f0d7be6aa6870c82ac79cf0368cd73e01bbc00e9eb1c2a16fbb198ec105e3c9b6628bb98e9f3ac66fe29a957b9645bcb9a490bb7aa0d35f908b6b85071 - languageName: node - linkType: hard - "foreground-child@npm:^3.1.0": version: 3.1.1 resolution: "foreground-child@npm:3.1.1" @@ -2299,28 +2172,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.0": - version: 4.0.0 - resolution: "form-data@npm:4.0.0" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - mime-types: "npm:^2.1.12" - checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e - languageName: node - linkType: hard - -"fs-extra@npm:^11.2.0": - version: 11.2.0 - resolution: "fs-extra@npm:11.2.0" - dependencies: - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^6.0.1" - universalify: "npm:^2.0.0" - checksum: 10c0/d77a9a9efe60532d2e790e938c81a02c1b24904ef7a3efb3990b835514465ba720e99a6ea56fd5e2db53b4695319b644d76d5a0e9988a2beef80aa7b1da63398 - languageName: node - linkType: hard - "fs-minipass@npm:^2.0.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -2358,29 +2209,6 @@ __metadata: languageName: node linkType: hard -"gauge@npm:^4.0.3": - version: 4.0.4 - resolution: "gauge@npm:4.0.4" - dependencies: - aproba: "npm:^1.0.3 || ^2.0.0" - color-support: "npm:^1.1.3" - console-control-strings: "npm:^1.1.0" - has-unicode: "npm:^2.0.1" - signal-exit: "npm:^3.0.7" - string-width: "npm:^4.2.3" - strip-ansi: "npm:^6.0.1" - wide-align: "npm:^1.1.5" - checksum: 10c0/ef10d7981113d69225135f994c9f8c4369d945e64a8fc721d655a3a38421b738c9fe899951721d1b47b73c41fdb5404ac87cc8903b2ecbed95d2800363e7e58c - languageName: node - linkType: hard - -"get-caller-file@npm:^2.0.5": - version: 2.0.5 - resolution: "get-caller-file@npm:2.0.5" - checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde - languageName: node - linkType: hard - "get-port@npm:^4.2.0": version: 4.2.0 resolution: "get-port@npm:4.2.0" @@ -2428,7 +2256,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -2449,13 +2277,6 @@ __metadata: languageName: node linkType: hard -"has-unicode@npm:^2.0.1": - version: 2.0.1 - resolution: "has-unicode@npm:2.0.1" - checksum: 10c0/ebdb2f4895c26bb08a8a100b62d362e49b2190bcfd84b76bc4be1a3bd4d254ec52d0dd9f2fbcc093fc5eb878b20c52146f9dfd33e2686ed28982187be593b47c - languageName: node - linkType: hard - "htmlnano@npm:^2.0.0": version: 2.1.0 resolution: "htmlnano@npm:2.1.0" @@ -2579,20 +2400,6 @@ __metadata: languageName: node linkType: hard -"inherits@npm:^2.0.3": - version: 2.0.4 - resolution: "inherits@npm:2.0.4" - checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 - languageName: node - linkType: hard - -"ini@npm:~1.3.0": - version: 1.3.8 - resolution: "ini@npm:1.3.8" - checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a - languageName: node - linkType: hard - "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -2745,19 +2552,6 @@ __metadata: languageName: node linkType: hard -"jsonfile@npm:^6.0.1": - version: 6.1.0 - resolution: "jsonfile@npm:6.1.0" - dependencies: - graceful-fs: "npm:^4.1.6" - universalify: "npm:^2.0.0" - dependenciesMeta: - graceful-fs: - optional: true - checksum: 10c0/4f95b5e8a5622b1e9e8f33c96b7ef3158122f595998114d1e7f03985649ea99cb3cd99ce1ed1831ae94c8c8543ab45ebd044207612f31a56fd08462140e46865 - languageName: node - linkType: hard - "lightningcss-darwin-arm64@npm:1.24.1": version: 1.24.1 resolution: "lightningcss-darwin-arm64@npm:1.24.1" @@ -2900,13 +2694,6 @@ __metadata: languageName: node linkType: hard -"lodash.isplainobject@npm:^4.0.6": - version: 4.0.6 - resolution: "lodash.isplainobject@npm:4.0.6" - checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb - languageName: node - linkType: hard - "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.2.0 resolution: "lru-cache@npm:10.2.0" @@ -2949,15 +2736,6 @@ __metadata: languageName: node linkType: hard -"memory-stream@npm:^1.0.0": - version: 1.0.0 - resolution: "memory-stream@npm:1.0.0" - dependencies: - readable-stream: "npm:^3.4.0" - checksum: 10c0/a2d9abd35845b228055ce5424dbdd8478711ba41325d02e6c8ef9baeba557287d4493a6e74d3db5c9849c58ea13fdc1dd445c96f469cbd02f47d22cfba930306 - languageName: node - linkType: hard - "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -2975,22 +2753,6 @@ __metadata: languageName: node linkType: hard -"mime-db@npm:1.52.0": - version: 1.52.0 - resolution: "mime-db@npm:1.52.0" - checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa - languageName: node - linkType: hard - -"mime-types@npm:^2.1.12": - version: 2.1.35 - resolution: "mime-types@npm:2.1.35" - dependencies: - mime-db: "npm:1.52.0" - checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 - languageName: node - linkType: hard - "mimic-fn@npm:^4.0.0": version: 4.0.0 resolution: "mimic-fn@npm:4.0.0" @@ -3007,13 +2769,6 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0": - version: 1.2.8 - resolution: "minimist@npm:1.2.8" - checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 - languageName: node - linkType: hard - "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -3173,27 +2928,7 @@ __metadata: languageName: node linkType: hard -"nice-napi@npm:^1.0.2": - version: 1.0.2 - resolution: "nice-napi@npm:1.0.2" - dependencies: - node-addon-api: "npm:^3.0.0" - node-gyp: "npm:latest" - node-gyp-build: "npm:^4.2.2" - conditions: "!os=win32" - languageName: node - linkType: hard - -"node-addon-api@npm:^3.0.0": - version: 3.2.1 - resolution: "node-addon-api@npm:3.2.1" - dependencies: - node-gyp: "npm:latest" - checksum: 10c0/41f21c9d12318875a2c429befd06070ce367065a3ef02952cfd4ea17ef69fa14012732f510b82b226e99c254da8d671847ea018cad785f839a5366e02dd56302 - languageName: node - linkType: hard - -"node-addon-api@npm:^6.0.0, node-addon-api@npm:^6.1.0": +"node-addon-api@npm:^6.1.0": version: 6.1.0 resolution: "node-addon-api@npm:6.1.0" dependencies: @@ -3211,13 +2946,6 @@ __metadata: languageName: node linkType: hard -"node-api-headers@npm:^1.1.0": - version: 1.1.0 - resolution: "node-api-headers@npm:1.1.0" - checksum: 10c0/7806d71077348ea199034e8c90a9147038d37fcccc1b85717e48c095fe31783a4f909f5daced4506e6cbce93fba91220bb3fc8626ee0640d26de9860f6500174 - languageName: node - linkType: hard - "node-gyp-build-optional-packages@npm:5.0.7": version: 5.0.7 resolution: "node-gyp-build-optional-packages@npm:5.0.7" @@ -3242,17 +2970,6 @@ __metadata: languageName: node linkType: hard -"node-gyp-build@npm:^4.2.2": - version: 4.8.0 - resolution: "node-gyp-build@npm:4.8.0" - bin: - node-gyp-build: bin.js - node-gyp-build-optional: optional.js - node-gyp-build-test: build-test.js - checksum: 10c0/85324be16f81f0235cbbc42e3eceaeb1b5ab94c8d8f5236755e1435b4908338c65a4e75f66ee343cbcb44ddf9b52a428755bec16dcd983295be4458d95c8e1ad - languageName: node - linkType: hard - "node-gyp@npm:latest": version: 10.1.0 resolution: "node-gyp@npm:10.1.0" @@ -3307,18 +3024,6 @@ __metadata: languageName: node linkType: hard -"npmlog@npm:^6.0.2": - version: 6.0.2 - resolution: "npmlog@npm:6.0.2" - dependencies: - are-we-there-yet: "npm:^3.0.0" - console-control-strings: "npm:^1.1.0" - gauge: "npm:^4.0.3" - set-blocking: "npm:^2.0.0" - checksum: 10c0/0cacedfbc2f6139c746d9cd4a85f62718435ad0ca4a2d6459cd331dd33ae58206e91a0742c1558634efcde3f33f8e8e7fd3adf1bfe7978310cf00bd55cccf890 - languageName: node - linkType: hard - "nth-check@npm:^2.0.1": version: 2.1.1 resolution: "nth-check@npm:2.1.1" @@ -3457,18 +3162,6 @@ __metadata: languageName: node linkType: hard -"piscina@npm:^4.4.0": - version: 4.4.0 - resolution: "piscina@npm:4.4.0" - dependencies: - nice-napi: "npm:^1.0.2" - dependenciesMeta: - nice-napi: - optional: true - checksum: 10c0/df6c2a2b673b0633a625f8dfc32f4519155e74ee24e31be9e69d2937e76d6cec8640278b4a50195652a943cccf8c634ed406f08598933c57e959d242b5fe5d1d - languageName: node - linkType: hard - "postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" @@ -3539,27 +3232,6 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^1.1.0": - version: 1.1.0 - resolution: "proxy-from-env@npm:1.1.0" - checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b - languageName: node - linkType: hard - -"rc@npm:^1.2.7": - version: 1.2.8 - resolution: "rc@npm:1.2.8" - dependencies: - deep-extend: "npm:^0.6.0" - ini: "npm:~1.3.0" - minimist: "npm:^1.2.0" - strip-json-comments: "npm:~2.0.1" - bin: - rc: ./cli.js - checksum: 10c0/24a07653150f0d9ac7168e52943cc3cb4b7a22c0e43c7dff3219977c2fdca5a2760a304a029c20811a0e79d351f57d46c9bde216193a0f73978496afc2b85b15 - languageName: node - linkType: hard - "react-error-overlay@npm:6.0.9": version: 6.0.9 resolution: "react-error-overlay@npm:6.0.9" @@ -3574,17 +3246,6 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": - version: 3.6.2 - resolution: "readable-stream@npm:3.6.2" - dependencies: - inherits: "npm:^2.0.3" - string_decoder: "npm:^1.1.1" - util-deprecate: "npm:^1.0.1" - checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7 - languageName: node - linkType: hard - "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -3601,13 +3262,6 @@ __metadata: languageName: node linkType: hard -"require-directory@npm:^2.1.1": - version: 2.1.1 - resolution: "require-directory@npm:2.1.1" - checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 - languageName: node - linkType: hard - "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -3622,7 +3276,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:^5.0.1": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -3649,7 +3303,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5, semver@npm:^7.5.2, semver@npm:^7.5.4, semver@npm:^7.6.0": +"semver@npm:^7.3.5, semver@npm:^7.5.2, semver@npm:^7.6.0": version: 7.6.0 resolution: "semver@npm:7.6.0" dependencies: @@ -3660,13 +3314,6 @@ __metadata: languageName: node linkType: hard -"set-blocking@npm:^2.0.0": - version: 2.0.0 - resolution: "set-blocking@npm:2.0.0" - checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454 - languageName: node - linkType: hard - "sharp@npm:^0.33.3": version: 0.33.3 resolution: "sharp@npm:0.33.3" @@ -3752,13 +3399,6 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.7": - version: 3.0.7 - resolution: "signal-exit@npm:3.0.7" - checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 - languageName: node - linkType: hard - "signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" @@ -3856,7 +3496,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -3878,15 +3518,6 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": - version: 1.3.0 - resolution: "string_decoder@npm:1.3.0" - dependencies: - safe-buffer: "npm:~5.2.0" - checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d - languageName: node - linkType: hard - "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -3912,13 +3543,6 @@ __metadata: languageName: node linkType: hard -"strip-json-comments@npm:~2.0.1": - version: 2.0.1 - resolution: "strip-json-comments@npm:2.0.1" - checksum: 10c0/b509231cbdee45064ff4f9fd73609e2bcc4e84a4d508e9dd0f31f70356473fde18abfb5838c17d56fb236f5a06b102ef115438de0600b749e818a35fbbc48c43 - languageName: node - linkType: hard - "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -3954,7 +3578,7 @@ __metadata: languageName: node linkType: hard -"tar@npm:^6.1.11, tar@npm:^6.1.2, tar@npm:^6.2.0": +"tar@npm:^6.1.11, tar@npm:^6.1.2": version: 6.2.1 resolution: "tar@npm:6.2.1" dependencies: @@ -4084,13 +3708,6 @@ __metadata: languageName: node linkType: hard -"universalify@npm:^2.0.0": - version: 2.0.1 - resolution: "universalify@npm:2.0.1" - checksum: 10c0/73e8ee3809041ca8b818efb141801a1004e3fc0002727f1531f4de613ea281b494a40909596dae4a042a4fb6cd385af5d4db2e137b1362e0e91384b828effd3a - languageName: node - linkType: hard - "update-browserslist-db@npm:^1.0.13": version: 1.0.13 resolution: "update-browserslist-db@npm:1.0.13" @@ -4105,20 +3722,6 @@ __metadata: languageName: node linkType: hard -"url-join@npm:^4.0.1": - version: 4.0.1 - resolution: "url-join@npm:4.0.1" - checksum: 10c0/ac65e2c7c562d7b49b68edddcf55385d3e922bc1dd5d90419ea40b53b6de1607d1e45ceb71efb9d60da02c681d13c6cb3a1aa8b13fc0c989dfc219df97ee992d - languageName: node - linkType: hard - -"util-deprecate@npm:^1.0.1": - version: 1.0.2 - resolution: "util-deprecate@npm:1.0.2" - checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 - languageName: node - linkType: hard - "utility-types@npm:^3.10.0": version: 3.11.0 resolution: "utility-types@npm:3.11.0" @@ -4133,7 +3736,7 @@ __metadata: languageName: node linkType: hard -"which@npm:^2.0.1, which@npm:^2.0.2": +"which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2" dependencies: @@ -4155,16 +3758,7 @@ __metadata: languageName: node linkType: hard -"wide-align@npm:^1.1.5": - version: 1.1.5 - resolution: "wide-align@npm:1.1.5" - dependencies: - string-width: "npm:^1.0.2 || 2 || 3 || 4" - checksum: 10c0/1d9c2a3e36dfb09832f38e2e699c367ef190f96b82c71f809bc0822c306f5379df87bab47bed27ea99106d86447e50eb972d3c516c2f95782807a9d082fbea95 - languageName: node - linkType: hard - -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" dependencies: @@ -4201,38 +3795,9 @@ __metadata: languageName: node linkType: hard -"y18n@npm:^5.0.5": - version: 5.0.8 - resolution: "y18n@npm:5.0.8" - checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 - languageName: node - linkType: hard - "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a languageName: node linkType: hard - -"yargs-parser@npm:^21.1.1": - version: 21.1.1 - resolution: "yargs-parser@npm:21.1.1" - checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 - languageName: node - linkType: hard - -"yargs@npm:^17.7.2": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" - dependencies: - cliui: "npm:^8.0.1" - escalade: "npm:^3.1.1" - get-caller-file: "npm:^2.0.5" - require-directory: "npm:^2.1.1" - string-width: "npm:^4.2.3" - y18n: "npm:^5.0.5" - yargs-parser: "npm:^21.1.1" - checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 - languageName: node - linkType: hard From b8ed1778855ba82e868187becba429f9911f516e Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sat, 22 Jun 2024 21:14:05 -0400 Subject: [PATCH 28/60] cvm-rs: merge guac and jpeg libs together into one doesn't really need to be two seperate libraries. also preperation for other funnies the build script has been replaced with a much saner justfile which uses much saner "yarn workspace" invocations instead of blindly cding all over the place --- .gitignore | 8 +- Justfile | 8 + README.md | 6 +- {jpegturbo-rs => cvm-rs}/Cargo.lock | 20 +- {jpegturbo-rs => cvm-rs}/Cargo.toml | 6 +- cvm-rs/index.d.ts | 84 +++++++ {jpegturbo-rs => cvm-rs}/index.js | 2 +- {guac-rs => cvm-rs}/package.json | 2 +- {guac-rs => cvm-rs}/src/guac.rs | 0 guac-rs/src/lib.rs => cvm-rs/src/guac_js.rs | 14 +- .../src/jpeg_compressor.rs | 0 .../src/lib.rs => cvm-rs/src/jpeg_js.rs | 16 +- cvm-rs/src/lib.rs | 19 ++ cvmts/package.json | 3 +- cvmts/src/CollabVMServer.ts | 156 ++++++------- cvmts/src/JPEGEncoder.ts | 6 +- cvmts/src/User.ts | 8 +- cvmts/src/WebSocket/WSClient.ts | 9 +- guac-rs/Cargo.lock | 209 ------------------ guac-rs/Cargo.toml | 12 - guac-rs/index.d.ts | 3 - guac-rs/index.js | 6 - jpegturbo-rs/index.d.ts | 15 -- jpegturbo-rs/package.json | 16 -- package.json | 5 +- yarn.lock | 27 +-- 26 files changed, 246 insertions(+), 414 deletions(-) create mode 100644 Justfile rename {jpegturbo-rs => cvm-rs}/Cargo.lock (99%) rename {jpegturbo-rs => cvm-rs}/Cargo.toml (67%) create mode 100644 cvm-rs/index.d.ts rename {jpegturbo-rs => cvm-rs}/index.js (57%) rename {guac-rs => cvm-rs}/package.json (93%) rename {guac-rs => cvm-rs}/src/guac.rs (100%) rename guac-rs/src/lib.rs => cvm-rs/src/guac_js.rs (77%) rename {jpegturbo-rs => cvm-rs}/src/jpeg_compressor.rs (100%) rename jpegturbo-rs/src/lib.rs => cvm-rs/src/jpeg_js.rs (85%) create mode 100644 cvm-rs/src/lib.rs delete mode 100644 guac-rs/Cargo.lock delete mode 100644 guac-rs/Cargo.toml delete mode 100644 guac-rs/index.d.ts delete mode 100644 guac-rs/index.js delete mode 100644 jpegturbo-rs/index.d.ts delete mode 100644 jpegturbo-rs/package.json diff --git a/.gitignore b/.gitignore index 869f36b..d5db0bf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,5 @@ cvmts/attic **/dist/ # Guac-rs -guac-rs/target -guac-rs/index.node - -# jpegturbo-rs -jpegturbo-rs/target -jpegturbo-rs/index.node +cvm-rs/target +cvm-rs/index.node diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..2333d22 --- /dev/null +++ b/Justfile @@ -0,0 +1,8 @@ +all: + yarn workspace @cvmts/cvm-rs run build + yarn workspace @cvmts/shared run build + yarn workspace @cvmts/qemu run build + yarn workspace @cvmts/cvmts run build + +pkg: + yarn diff --git a/README.md b/README.md index e723f55..d304e2b 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ This is a drop-in replacement for the dying CollabVM 1.2.11. Currently in beta **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` -4. Run it: `npm run serve` +2. Install dependencies: `yarn` +3. Build it: `yarn build` +4. Run it: `yarn serve` ## FAQ ### When I try to access the admin panel, the server crashes! diff --git a/jpegturbo-rs/Cargo.lock b/cvm-rs/Cargo.lock similarity index 99% rename from jpegturbo-rs/Cargo.lock rename to cvm-rs/Cargo.lock index 60574c6..00d2143 100644 --- a/jpegturbo-rs/Cargo.lock +++ b/cvm-rs/Cargo.lock @@ -59,6 +59,16 @@ dependencies = [ "cc", ] +[[package]] +name = "cvm-rs" +version = "0.1.0" +dependencies = [ + "neon", + "once_cell", + "tokio", + "turbojpeg-sys", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -82,16 +92,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" -[[package]] -name = "jpegturbo-rs" -version = "0.1.0" -dependencies = [ - "neon", - "once_cell", - "tokio", - "turbojpeg-sys", -] - [[package]] name = "libc" version = "0.2.155" diff --git a/jpegturbo-rs/Cargo.toml b/cvm-rs/Cargo.toml similarity index 67% rename from jpegturbo-rs/Cargo.toml rename to cvm-rs/Cargo.toml index 5fc50ae..3b78185 100644 --- a/jpegturbo-rs/Cargo.toml +++ b/cvm-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "jpegturbo-rs" -description = "Rust powered JPEGTurbo sex" +name = "cvm-rs" +description = "Rust utility library for cvmts. Runs all the high performance code" version = "0.1.0" edition = "2021" exclude = ["index.node"] @@ -10,6 +10,8 @@ crate-type = ["cdylib"] [dependencies] neon = "1" + +# Required for JPEG once_cell = "1.19.0" tokio = { version = "1.38.0", features = [ "rt", "rt-multi-thread" ] } turbojpeg-sys = "1.0.0" diff --git a/cvm-rs/index.d.ts b/cvm-rs/index.d.ts new file mode 100644 index 0000000..6032ed6 --- /dev/null +++ b/cvm-rs/index.d.ts @@ -0,0 +1,84 @@ +// + +// Guacamole Codec +export function guacDecode(input: string): string[]; +export function guacEncode(...items: string[]): string; + +interface JpegInputArgs { + width: number, + height: number, + stride: number, // The width of your input framebuffer OR your image width (if encoding a full image) + buffer: Buffer + + // TODO: Allow different formats, or export a boxed ffi object which can store a format + // (i.e: new JpegEncoder(FORMAT_xxx)). +} + +/// Performs JPEG encoding. +export function jpegEncode(input: JpegInputArgs) : Promise; + +// TODO: Version that can downscale? + + +/* remoting API? + +js side api: + + class RemotingClient extends EventEmitter { + constructor(uri: string) + + Connect(): Promise - connects to server. + + Disconnect(): void - disconnects from a server. + + get FullScreen(): Buffer - gets the full screen JPEG at a specific moment. This should only be called once + during some user-specific setup (for example: when a new user connects) + + get Thumbnail(): Buffer - gets JPEG thumbnail. + + KeyEvent(key: number, pressed: boolean) - sends a key event to the server. + MouseEvent(x: number, y: number, buttons: MouseButtonMask) - sends a mouse event (the button mask is semi-standardized for remoting, + the mask can be converted if not applicable for a given protocol) + + // explicit property setter APIs, maybe expose the semi-internal remotingSetProperty API if required? + set JpegQuality(q: number) - sets JPEG quality + + // events: + + on('open', cb: () => void) - on open + + //on('firstupdate', cb: (rect: RectWithJpeg) => void) - the first update of a resize is given here + // doesn't really matter + + on('resize', cb: (size: Size) => void) - when the server resizes we do too. + + on('update', cb: (rects: Array) => void) - gives screen frame update as jpeg rects + (pre-batched using existing batcher or a new invention or something) + on('close', cb: () => void) - on close + + on('cursor', cb: (b: CursorBitmap) => void) - cursor bitmap changed (always rgba8888) + + } + + binding side API: + + remotingNew("vnc://abc.def:1234") - creates a new remoting client which will use the given protocol in the URI + xxx for callbacks (they will get migrated to eventemitter or something on the JS side so it's more "idiomatic", depending on performance. + In all honesty however, remoting will take care of all the performance sensitive tasks, so it probably won't matter at all) + + remotingConnect(client) -> promise (throws rejection) - disconnects + remotingDisconnect(client) - disconnects + remotingGetBuffer(client) -> Buffer - gets the buffer used for the screen + + remotingSetProperty(client, propertyId, propertyValue) - sets property (e.g: jpeg quality) + e.g: server uri could be set after client creation + with remotingSetProperty(boxedClient, remoting.propertyServerUri, "vnc://another-server.org::2920") + + remotingGetThumbnail(client) - gets thumbnail, this is updated by remoting at about 5 fps + + remotingKeyEvent(client, key, pressed) - key event + remotingMouseEvent(client, x, y, buttons) - mouse event + + on the rust side a boxed client will contain an inner boxed `dyn RemotingProtocolClient` which will contain protocol specific dispatch, + upon parsing a remoting URI we will create a given client (e.g, for `vnc://` we'd make the VNC one) +*/ diff --git a/jpegturbo-rs/index.js b/cvm-rs/index.js similarity index 57% rename from jpegturbo-rs/index.js rename to cvm-rs/index.js index 5519fa2..fc02f40 100644 --- a/jpegturbo-rs/index.js +++ b/cvm-rs/index.js @@ -2,5 +2,5 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); -export let {jpegEncode} = require('./index.node'); +export let {guacDecode, guacEncode, jpegEncode} = require('./index.node'); diff --git a/guac-rs/package.json b/cvm-rs/package.json similarity index 93% rename from guac-rs/package.json rename to cvm-rs/package.json index c939fc5..41d9dcb 100644 --- a/guac-rs/package.json +++ b/cvm-rs/package.json @@ -1,5 +1,5 @@ { - "name": "@cvmts/guac-rs", + "name": "@cvmts/cvm-rs", "version": "0.1.0", "packageManager": "yarn@4.1.1", "type": "module", diff --git a/guac-rs/src/guac.rs b/cvm-rs/src/guac.rs similarity index 100% rename from guac-rs/src/guac.rs rename to cvm-rs/src/guac.rs diff --git a/guac-rs/src/lib.rs b/cvm-rs/src/guac_js.rs similarity index 77% rename from guac-rs/src/lib.rs rename to cvm-rs/src/guac_js.rs index 2d7f623..9077333 100644 --- a/guac-rs/src/lib.rs +++ b/cvm-rs/src/guac_js.rs @@ -1,6 +1,5 @@ -mod guac; - use neon::prelude::*; +use crate::guac; fn guac_decode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsArray> { let input = cx.argument::(0)?.value(cx); @@ -40,17 +39,10 @@ fn guac_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsString> Ok(cx.string(guac::encode_instruction(&elements))) } -fn guac_decode(mut cx: FunctionContext) -> JsResult { +pub fn guac_decode(mut cx: FunctionContext) -> JsResult { guac_decode_impl(&mut cx) } -fn guac_encode(mut cx: FunctionContext) -> JsResult { +pub fn guac_encode(mut cx: FunctionContext) -> JsResult { guac_encode_impl(&mut cx) } - -#[neon::main] -fn main(mut cx: ModuleContext) -> NeonResult<()> { - cx.export_function("guacDecode", guac_decode)?; - cx.export_function("guacEncode", guac_encode)?; - Ok(()) -} diff --git a/jpegturbo-rs/src/jpeg_compressor.rs b/cvm-rs/src/jpeg_compressor.rs similarity index 100% rename from jpegturbo-rs/src/jpeg_compressor.rs rename to cvm-rs/src/jpeg_compressor.rs diff --git a/jpegturbo-rs/src/lib.rs b/cvm-rs/src/jpeg_js.rs similarity index 85% rename from jpegturbo-rs/src/lib.rs rename to cvm-rs/src/jpeg_js.rs index dc3f498..de87f9b 100644 --- a/jpegturbo-rs/src/lib.rs +++ b/cvm-rs/src/jpeg_js.rs @@ -8,8 +8,10 @@ use tokio::runtime::Runtime; use std::cell::RefCell; -mod jpeg_compressor; +use crate::jpeg_compressor::*; +/// Gives a static Tokio runtime. We should replace this with +/// rayon or something, but for now tokio works. fn runtime<'a, C: Context<'a>>(cx: &mut C) -> NeonResult<&'static Runtime> { static RUNTIME: OnceCell = OnceCell::new(); @@ -19,7 +21,7 @@ fn runtime<'a, C: Context<'a>>(cx: &mut C) -> NeonResult<&'static Runtime> { } thread_local! { - static COMPRESSOR: RefCell = RefCell::new(jpeg_compressor::JpegCompressor::new()); + static COMPRESSOR: RefCell = RefCell::new(JpegCompressor::new()); } fn jpeg_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsPromise> { @@ -52,7 +54,7 @@ fn jpeg_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsPromise> let clone = Arc::clone(©); let locked = clone.lock().unwrap(); - let image: jpeg_compressor::Image = jpeg_compressor::Image { + let image: Image = Image { buffer: locked.as_slice(), width: width as u32, height: height as u32, @@ -80,12 +82,6 @@ fn jpeg_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsPromise> Ok(promise) } -fn jpeg_encode(mut cx: FunctionContext) -> JsResult { +pub fn jpeg_encode(mut cx: FunctionContext) -> JsResult { jpeg_encode_impl(&mut cx) } - -#[neon::main] -fn main(mut cx: ModuleContext) -> NeonResult<()> { - cx.export_function("jpegEncode", jpeg_encode)?; - Ok(()) -} diff --git a/cvm-rs/src/lib.rs b/cvm-rs/src/lib.rs new file mode 100644 index 0000000..e6c6291 --- /dev/null +++ b/cvm-rs/src/lib.rs @@ -0,0 +1,19 @@ +mod guac; +mod guac_js; + +mod jpeg_compressor; +mod jpeg_js; + + +use neon::prelude::*; + + +#[neon::main] +fn main(mut cx: ModuleContext) -> NeonResult<()> { + // Mostly transitionary, later on API should change + cx.export_function("jpegEncode", jpeg_js::jpeg_encode)?; + + cx.export_function("guacDecode", guac_js::guac_decode)?; + cx.export_function("guacEncode", guac_js::guac_encode)?; + Ok(()) +} diff --git a/cvmts/package.json b/cvmts/package.json index 94754d5..496ede2 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -11,8 +11,7 @@ "author": "Elijah R, modeco80", "license": "GPL-3.0", "dependencies": { - "@cvmts/guac-rs": "*", - "@cvmts/jpegturbo-rs": "*", + "@cvmts/cvm-rs": "*", "@cvmts/qemu": "*", "execa": "^8.0.1", "mnemonist": "^0.39.5", diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 5cb6f9b..e986557 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -1,7 +1,7 @@ import IConfig from './IConfig.js'; import * as Utilities from './Utilities.js'; import { User, Rank } from './User.js'; -import * as guac from '@cvmts/guac-rs'; +import * as cvm from '@cvmts/cvm-rs'; // I hate that you have to do it like this import CircularBuffer from 'mnemonist/circular-buffer.js'; import Queue from 'mnemonist/queue.js'; @@ -142,7 +142,7 @@ export default class CollabVMServer { user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); user.socket.on('disconnect', () => this.connectionClosed(user)); if (this.Config.auth.enabled) { - user.sendMsg(guac.guacEncode('auth', this.Config.auth.apiEndpoint)); + user.sendMsg(cvm.guacEncode('auth', this.Config.auth.apiEndpoint)); } user.sendMsg(this.getAdduserMsg()); } @@ -171,25 +171,25 @@ export default class CollabVMServer { if (hadturn) this.nextTurn(); } - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('remuser', '1', user.username!))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('remuser', '1', user.username!))); } private async onMessage(client: User, message: string) { try { - var msgArr = guac.guacDecode(message); + var msgArr = cvm.guacDecode(message); if (msgArr.length < 1) return; switch (msgArr[0]) { case 'login': if (msgArr.length !== 2 || !this.Config.auth.enabled) return; if (!client.connectedToNode) { - client.sendMsg(guac.guacEncode('login', '0', 'You must connect to the VM before logging in.')); + client.sendMsg(cvm.guacEncode('login', '0', 'You must connect to the VM before logging in.')); return; } try { let res = await this.auth!.Authenticate(msgArr[1], client); if (res.clientSuccess) { this.logger.Info(`${client.IP.address} logged in as ${res.username}`); - client.sendMsg(guac.guacEncode('login', '1')); + client.sendMsg(cvm.guacEncode('login', '1')); let old = this.clients.find((c) => c.username === res.username); if (old) { // kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that @@ -202,13 +202,13 @@ export default class CollabVMServer { // Set rank client.rank = res.rank; if (client.rank === Rank.Admin) { - client.sendMsg(guac.guacEncode('admin', '0', '1')); + client.sendMsg(cvm.guacEncode('admin', '0', '1')); } else if (client.rank === Rank.Moderator) { - client.sendMsg(guac.guacEncode('admin', '0', '3', this.ModPerms.toString())); + client.sendMsg(cvm.guacEncode('admin', '0', '3', this.ModPerms.toString())); } - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('adduser', '1', client.username!, client.rank.toString()))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString()))); } else { - client.sendMsg(guac.guacEncode('login', '0', res.error!)); + client.sendMsg(cvm.guacEncode('login', '0', res.error!)); if (res.error === 'You are banned') { client.kick(); } @@ -216,28 +216,28 @@ export default class CollabVMServer { } catch (err) { this.logger.Error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`); // for now? - client.sendMsg(guac.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')); + client.sendMsg(cvm.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')); } break; case 'list': - client.sendMsg(guac.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); + client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); break; case 'connect': if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { - client.sendMsg(guac.guacEncode('connect', '0')); + client.sendMsg(cvm.guacEncode('connect', '0')); return; } client.connectedToNode = true; - client.sendMsg(guac.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); + client.sendMsg(cvm.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - if (this.Config.collabvm.motd) client.sendMsg(guac.guacEncode('chat', '', this.Config.collabvm.motd)); + if (this.Config.collabvm.motd) client.sendMsg(cvm.guacEncode('chat', '', this.Config.collabvm.motd)); if (this.screenHidden) { - client.sendMsg(guac.guacEncode('size', '0', '1024', '768')); - client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); + client.sendMsg(cvm.guacEncode('size', '0', '1024', '768')); + client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); } else { await this.SendFullScreenWithSize(client); } - client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + client.sendMsg(cvm.guacEncode('sync', Date.now().toString())); if (this.voteInProgress) this.sendVoteUpdate(client); this.sendTurnUpdate(client); break; @@ -245,7 +245,7 @@ export default class CollabVMServer { if (client.connectedToNode) return; if (client.username || msgArr.length !== 3 || msgArr[1] !== this.Config.collabvm.node) { // The use of connect here is intentional. - client.sendMsg(guac.guacEncode('connect', '0')); + client.sendMsg(cvm.guacEncode('connect', '0')); return; } @@ -257,22 +257,22 @@ export default class CollabVMServer { client.viewMode = 1; break; default: - client.sendMsg(guac.guacEncode('connect', '0')); + client.sendMsg(cvm.guacEncode('connect', '0')); return; } - client.sendMsg(guac.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); + client.sendMsg(cvm.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); - if (this.Config.collabvm.motd) client.sendMsg(guac.guacEncode('chat', '', this.Config.collabvm.motd)); + if (this.Config.collabvm.motd) client.sendMsg(cvm.guacEncode('chat', '', this.Config.collabvm.motd)); if (client.viewMode == 1) { if (this.screenHidden) { - client.sendMsg(guac.guacEncode('size', '0', '1024', '768')); - client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); + client.sendMsg(cvm.guacEncode('size', '0', '1024', '768')); + client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); } else { await this.SendFullScreenWithSize(client); } - client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + client.sendMsg(cvm.guacEncode('sync', Date.now().toString())); } if (this.voteInProgress) this.sendVoteUpdate(client); @@ -282,12 +282,12 @@ export default class CollabVMServer { if (!client.RenameRateLimit.request()) return; if (client.connectedToNode && client.IP.muted) return; if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) { - client.sendMsg(guac.guacEncode('chat', '', 'Go to your account settings to change your username.')); + client.sendMsg(cvm.guacEncode('chat', '', 'Go to your account settings to change your username.')); return; } if (this.Config.auth.enabled && msgArr[1] !== undefined) { // Don't send system message to a user without a username since it was likely an automated attempt by the webapp - if (client.username) client.sendMsg(guac.guacEncode('chat', '', 'You need to log in to do that.')); + if (client.username) client.sendMsg(cvm.guacEncode('chat', '', 'You need to log in to do that.')); if (client.rank !== Rank.Unregistered) return; this.renameUser(client, undefined); return; @@ -299,7 +299,7 @@ export default class CollabVMServer { if (client.IP.muted) return; if (msgArr.length !== 2) return; if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) { - client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.')); return; } var msg = Utilities.HTMLSanitize(msgArr[1]); @@ -307,14 +307,14 @@ export default class CollabVMServer { if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength); if (msg.trim().length < 1) return; - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', client.username!, msg))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', client.username!, msg))); this.ChatHistory.push({ user: client.username, msg: msg }); client.onMsgSent(); break; case 'turn': if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return; if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) { - client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.')); return; } if (!client.TurnRateLimit.request()) return; @@ -384,33 +384,33 @@ export default class CollabVMServer { case '1': if (!this.voteInProgress) { if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) { - client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.')); return; } if (this.voteCooldown !== 0) { - client.sendMsg(guac.guacEncode('vote', '3', this.voteCooldown.toString())); + client.sendMsg(cvm.guacEncode('vote', '3', this.voteCooldown.toString())); return; } this.startVote(); - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', `${client.username} has started a vote to reset the VM.`))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has started a vote to reset the VM.`))); } if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { - client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.')); return; } else if (client.IP.vote !== true) { - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', `${client.username} has voted yes.`))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has voted yes.`))); } client.IP.vote = true; break; case '0': if (!this.voteInProgress) return; if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) { - client.sendMsg(guac.guacEncode('chat', '', 'You need to login to do that.')); + client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.')); return; } if (client.IP.vote !== false) { - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', `${client.username} has voted no.`))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has voted no.`))); } client.IP.vote = false; break; @@ -423,7 +423,7 @@ export default class CollabVMServer { case '2': // Login if (this.Config.auth.enabled) { - client.sendMsg(guac.guacEncode('chat', '', 'This server does not support staff passwords. Please log in to become staff.')); + client.sendMsg(cvm.guacEncode('chat', '', 'This server does not support staff passwords. Please log in to become staff.')); return; } if (!client.LoginRateLimit.request() || !client.username) return; @@ -434,37 +434,37 @@ export default class CollabVMServer { sha256.destroy(); if (pwdHash === this.Config.collabvm.adminpass) { client.rank = Rank.Admin; - client.sendMsg(guac.guacEncode('admin', '0', '1')); + client.sendMsg(cvm.guacEncode('admin', '0', '1')); } else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) { client.rank = Rank.Moderator; - client.sendMsg(guac.guacEncode('admin', '0', '3', this.ModPerms.toString())); + client.sendMsg(cvm.guacEncode('admin', '0', '3', this.ModPerms.toString())); } else if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) { client.rank = Rank.Turn; - client.sendMsg(guac.guacEncode('chat', '', 'You may now take turns.')); + client.sendMsg(cvm.guacEncode('chat', '', 'You may now take turns.')); } else { - client.sendMsg(guac.guacEncode('admin', '0', '0')); + client.sendMsg(cvm.guacEncode('admin', '0', '0')); return; } if (this.screenHidden) { await this.SendFullScreenWithSize(client); - client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + client.sendMsg(cvm.guacEncode('sync', Date.now().toString())); } - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('adduser', '1', client.username!, client.rank.toString()))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString()))); break; 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(guac.guacEncode("admin", "2", "This is not a QEMU VM and therefore QEMU monitor commands cannot be run.")); + client.sendMsg(cvm.guacEncode("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.MonitorCommand(msgArr[3]); - client.sendMsg(guac.guacEncode('admin', '2', String(output))); + client.sendMsg(cvm.guacEncode('admin', '2', String(output))); break; case '8': // Restore @@ -541,7 +541,7 @@ export default class CollabVMServer { // Rename user if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return; if (this.Config.auth.enabled) { - client.sendMsg(guac.guacEncode('chat', '', 'Cannot rename users on a server that uses authentication.')); + client.sendMsg(cvm.guacEncode('chat', '', 'Cannot rename users on a server that uses authentication.')); } if (msgArr.length !== 4) return; var user = this.clients.find((c) => c.username === msgArr[2]); @@ -554,7 +554,7 @@ export default class CollabVMServer { if (msgArr.length !== 3) return; var user = this.clients.find((c) => c.username === msgArr[2]); if (!user) return; - client.sendMsg(guac.guacEncode('admin', '19', msgArr[2], user.IP.address)); + client.sendMsg(cvm.guacEncode('admin', '19', msgArr[2], user.IP.address)); break; case '20': // Steal turn @@ -567,14 +567,14 @@ export default class CollabVMServer { if (msgArr.length !== 3) return; switch (client.rank) { case Rank.Admin: - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', client.username!, msgArr[2]))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', client.username!, msgArr[2]))); this.ChatHistory.push({ user: client.username!, msg: msgArr[2] }); break; case Rank.Moderator: - this.clients.filter((c) => c.rank !== Rank.Admin).forEach((c) => c.sendMsg(guac.guacEncode('chat', client.username!, msgArr[2]))); + this.clients.filter((c) => c.rank !== Rank.Admin).forEach((c) => c.sendMsg(cvm.guacEncode('chat', client.username!, msgArr[2]))); - this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.sendMsg(guac.guacEncode('chat', client.username!, Utilities.HTMLSanitize(msgArr[2])))); + this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.sendMsg(cvm.guacEncode('chat', client.username!, Utilities.HTMLSanitize(msgArr[2])))); break; } break; @@ -609,9 +609,9 @@ export default class CollabVMServer { this.clients .filter((c) => c.rank == Rank.Unregistered) .forEach((client) => { - client.sendMsg(guac.guacEncode('size', '0', '1024', '768')); - client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); - client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + client.sendMsg(cvm.guacEncode('size', '0', '1024', '768')); + client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg)); + client.sendMsg(cvm.guacEncode('sync', Date.now().toString())); }); break; case '1': @@ -626,16 +626,16 @@ export default class CollabVMServer { }); this.clients.forEach(async (client) => { - client.sendMsg(guac.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); - client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', encoded)); - client.sendMsg(guac.guacEncode('sync', Date.now().toString())); + client.sendMsg(cvm.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); + client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded)); + client.sendMsg(cvm.guacEncode('sync', Date.now().toString())); }); break; } break; case '25': if (client.rank !== Rank.Admin || msgArr.length !== 3) return; - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', msgArr[2]))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', msgArr[2]))); break; } break; @@ -665,7 +665,7 @@ export default class CollabVMServer { } else { newName = newName.trim(); if (hadName && newName === oldname) { - client.sendMsg(guac.guacEncode('rename', '0', '0', client.username!, client.rank.toString())); + client.sendMsg(cvm.guacEncode('rename', '0', '0', client.username!, client.rank.toString())); return; } if (this.getUsernameList().indexOf(newName) !== -1) { @@ -682,13 +682,13 @@ export default class CollabVMServer { } else client.username = newName; } - client.sendMsg(guac.guacEncode('rename', '0', status, client.username!, client.rank.toString())); + client.sendMsg(cvm.guacEncode('rename', '0', status, client.username!, client.rank.toString())); if (hadName) { this.logger.Info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`); - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); } else { this.logger.Info(`Rename ${client.IP.address} to ${client.username}`); - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('adduser', '1', client.username!, client.rank.toString()))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString()))); } } @@ -696,13 +696,13 @@ export default class CollabVMServer { var arr: string[] = ['adduser', this.clients.filter((c) => c.username).length.toString()]; this.clients.filter((c) => c.username).forEach((c) => arr.push(c.username!, c.rank.toString())); - return guac.guacEncode(...arr); + return cvm.guacEncode(...arr); } getChatHistoryMsg(): string { var arr: string[] = ['chat']; this.ChatHistory.forEach((c) => arr.push(c.user, c.msg)); - return guac.guacEncode(...arr); + return cvm.guacEncode(...arr); } private sendTurnUpdate(client?: User) { @@ -715,7 +715,7 @@ export default class CollabVMServer { this.TurnQueue.forEach((c) => arr.push(c.username)); var currentTurningUser = this.TurnQueue.peek(); if (client) { - client.sendMsg(guac.guacEncode(...arr)); + client.sendMsg(cvm.guacEncode(...arr)); return; } this.clients @@ -725,12 +725,12 @@ export default class CollabVMServer { var time; if (this.indefiniteTurn === null) time = this.TurnTime * 1000 + (turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000; else time = 9999999999; - c.sendMsg(guac.guacEncode(...arr, time.toString())); + c.sendMsg(cvm.guacEncode(...arr, time.toString())); } else { - c.sendMsg(guac.guacEncode(...arr)); + c.sendMsg(cvm.guacEncode(...arr)); } }); - if (currentTurningUser) currentTurningUser.sendMsg(guac.guacEncode(...arr)); + if (currentTurningUser) currentTurningUser.sendMsg(cvm.guacEncode(...arr)); } private nextTurn() { clearInterval(this.TurnInterval); @@ -777,8 +777,8 @@ export default class CollabVMServer { .filter((c) => c.connectedToNode || c.viewMode == 1) .forEach((c) => { if (this.screenHidden && c.rank == Rank.Unregistered) return; - c.sendMsg(guac.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64)); - c.sendMsg(guac.guacEncode('sync', Date.now().toString())); + c.sendMsg(cvm.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64)); + c.sendMsg(cvm.guacEncode('sync', Date.now().toString())); }); } @@ -787,7 +787,7 @@ export default class CollabVMServer { .filter((c) => c.connectedToNode || c.viewMode == 1) .forEach((c) => { if (this.screenHidden && c.rank == Rank.Unregistered) return; - c.sendMsg(guac.guacEncode('size', '0', size.width.toString(), size.height.toString())); + c.sendMsg(cvm.guacEncode('size', '0', size.width.toString(), size.height.toString())); }); } @@ -802,8 +802,8 @@ export default class CollabVMServer { height: displaySize.height }); - client.sendMsg(guac.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); - client.sendMsg(guac.guacEncode('png', '0', '0', '0', '0', encoded)); + client.sendMsg(cvm.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); + client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded)); } private async MakeRectData(rect: Rect) { @@ -828,7 +828,7 @@ export default class CollabVMServer { startVote() { if (this.voteInProgress) return; this.voteInProgress = true; - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('vote', '0'))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('vote', '0'))); this.voteTime = this.Config.collabvm.voteTime; this.voteInterval = setInterval(() => { this.voteTime--; @@ -843,12 +843,12 @@ export default class CollabVMServer { this.voteInProgress = false; clearInterval(this.voteInterval); var count = this.getVoteCounts(); - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('vote', '2'))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('vote', '2'))); if (result === true || (result === undefined && count.yes >= count.no)) { - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', 'The vote to reset the VM has won.'))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', 'The vote to reset the VM has won.'))); this.VM.Reset(); } else { - this.clients.forEach((c) => c.sendMsg(guac.guacEncode('chat', '', 'The vote to reset the VM has lost.'))); + this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', 'The vote to reset the VM has lost.'))); } this.clients.forEach((c) => { c.IP.vote = null; @@ -863,7 +863,7 @@ export default class CollabVMServer { sendVoteUpdate(client?: User) { if (!this.voteInProgress) return; var count = this.getVoteCounts(); - var msg = guac.guacEncode('vote', '1', (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString()); + var msg = cvm.guacEncode('vote', '1', (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString()); if (client) client.sendMsg(msg); else this.clients.forEach((c) => c.sendMsg(msg)); } diff --git a/cvmts/src/JPEGEncoder.ts b/cvmts/src/JPEGEncoder.ts index 7e73243..a9a6f7a 100644 --- a/cvmts/src/JPEGEncoder.ts +++ b/cvmts/src/JPEGEncoder.ts @@ -1,6 +1,6 @@ import { Size, Rect } from '@cvmts/shared'; import sharp from 'sharp'; -import * as jpeg from '@cvmts/jpegturbo-rs'; +import * as cvm from '@cvmts/cvm-rs'; // A good balance. TODO: Configurable? let gJpegQuality = 35; @@ -28,7 +28,7 @@ export class JPEGEncoder { static async Encode(canvas: Buffer, displaySize: Size, rect: Rect): Promise { let offset = (rect.y * displaySize.width + rect.x) * 4; - return jpeg.jpegEncode({ + return cvm.jpegEncode({ width: rect.width, height: rect.height, stride: displaySize.width, @@ -42,7 +42,7 @@ export class JPEGEncoder { .raw() .toBuffer({ resolveWithObject: true }); - return jpeg.jpegEncode({ + return cvm.jpegEncode({ width: kThumbnailSize.width, height: kThumbnailSize.height, stride: kThumbnailSize.width, diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 05d26b4..a434c2f 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -1,5 +1,5 @@ import * as Utilities from './Utilities.js'; -import * as guac from '@cvmts/guac-rs'; +import * as cvm from '@cvmts/cvm-rs'; import { IPData } from './IPData.js'; import IConfig from './IConfig.js'; import RateLimiter from './RateLimiter.js'; @@ -95,7 +95,7 @@ export class User { } closeConnection() { - this.socket.send(guac.guacEncode('disconnect')); + this.socket.send(cvm.guacEncode('disconnect')); this.socket.close(); } @@ -115,7 +115,7 @@ export class User { mute(permanent: boolean) { this.IP.muted = true; - this.sendMsg(guac.guacEncode('chat', '', `You have been muted${permanent ? '' : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`)); + this.sendMsg(cvm.guacEncode('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); @@ -124,7 +124,7 @@ export class User { unmute() { clearTimeout(this.IP.tempMuteExpireTimeout); this.IP.muted = false; - this.sendMsg(guac.guacEncode('chat', '', 'You are no longer muted.')); + this.sendMsg(cvm.guacEncode('chat', '', 'You are no longer muted.')); } private banCmdArgs(arg: string): string { diff --git a/cvmts/src/WebSocket/WSClient.ts b/cvmts/src/WebSocket/WSClient.ts index cf5e2b0..bf7daa4 100644 --- a/cvmts/src/WebSocket/WSClient.ts +++ b/cvmts/src/WebSocket/WSClient.ts @@ -49,7 +49,14 @@ export default class WSClient extends EventEmitter implements NetworkClient { } close(): void { - this.socket.close(); + if(this.isOpen()) { + // While this seems counterintutive, do note that the WebSocket protocol + // *sends* a data frame whilist closing a connection. Therefore, if the other end + // has forcibly hung up (closed) their connection, the best way to handle that + // is to just let the inner TCP socket propegate that, which `ws` will do for us. + // Otherwise, we'll try to send data to a closed client then SIGPIPE. + this.socket.close(); + } } } diff --git a/guac-rs/Cargo.lock b/guac-rs/Cargo.lock deleted file mode 100644 index 8f6bdab..0000000 --- a/guac-rs/Cargo.lock +++ /dev/null @@ -1,209 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "getrandom" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "guac-rs" -version = "0.1.0" -dependencies = [ - "neon", -] - -[[package]] -name = "libc" -version = "0.2.155" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" - -[[package]] -name = "libloading" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" -dependencies = [ - "cfg-if", - "windows-targets", -] - -[[package]] -name = "neon" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d75440242411c87dc39847b0e33e961ec1f10326a9d8ecf9c1ea64a3b3c13dc" -dependencies = [ - "getrandom", - "libloading", - "neon-macros", - "once_cell", - "semver", - "send_wrapper", - "smallvec", -] - -[[package]] -name = "neon-macros" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6813fde79b646e47e7ad75f480aa80ef76a5d9599e2717407961531169ee38b" -dependencies = [ - "quote", - "syn", - "syn-mid", -] - -[[package]] -name = "once_cell" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" - -[[package]] -name = "proc-macro2" -version = "1.0.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "semver" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" - -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - -[[package]] -name = "smallvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "syn" -version = "2.0.66" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn-mid" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5dc35bb08dd1ca3dfb09dce91fd2d13294d6711c88897d9a9d60acf39bce049" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "windows-targets" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/guac-rs/Cargo.toml b/guac-rs/Cargo.toml deleted file mode 100644 index 7fec5d6..0000000 --- a/guac-rs/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "guac-rs" -description = "Rust guacamole decoding :)" -version = "0.1.0" -edition = "2021" -exclude = ["index.node"] - -[lib] -crate-type = ["cdylib"] - -[dependencies] -neon = "1" diff --git a/guac-rs/index.d.ts b/guac-rs/index.d.ts deleted file mode 100644 index 8edbc8e..0000000 --- a/guac-rs/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ - -export function guacDecode(input: string): string[]; -export function guacEncode(...items: string[]): string; diff --git a/guac-rs/index.js b/guac-rs/index.js deleted file mode 100644 index 3d87ceb..0000000 --- a/guac-rs/index.js +++ /dev/null @@ -1,6 +0,0 @@ -// *sigh* -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); - -export let {guacDecode, guacEncode} = require('./index.node'); - diff --git a/jpegturbo-rs/index.d.ts b/jpegturbo-rs/index.d.ts deleted file mode 100644 index 9c653e8..0000000 --- a/jpegturbo-rs/index.d.ts +++ /dev/null @@ -1,15 +0,0 @@ - -interface JpegInputArgs { - width: number, - height: number, - stride: number, // The width of your input framebuffer OR your image width (if encoding a full image) - buffer: Buffer - - // TODO: Allow different formats, or export a boxed ffi object which can store a format - // (i.e: new JpegEncoder(FORMAT_xxx)). -} - -/// Performs JPEG encoding. -export function jpegEncode(input: JpegInputArgs) : Promise; - -// TODO: Version that can downscale? diff --git a/jpegturbo-rs/package.json b/jpegturbo-rs/package.json deleted file mode 100644 index 58be5d6..0000000 --- a/jpegturbo-rs/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@cvmts/jpegturbo-rs", - "version": "0.1.0", - "packageManager": "yarn@4.1.1", - "type": "module", - "main": "index.js", - "types": "index.d.ts", - "scripts": { - "build": "cargo-cp-artifact -nc index.node -- cargo build --release --message-format=json-render-diagnostics", - "install": "yarn build", - "test": "cargo test" - }, - "devDependencies": { - "cargo-cp-artifact": "^0.1" - } -} diff --git a/package.json b/package.json index 82387f8..76cfb71 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,7 @@ "name": "cvmts-repo", "workspaces": [ "shared", - "guac-rs", - "jpegturbo-rs", + "cvm-rs", "nodejs-rfb", "qemu", "cvmts" @@ -19,7 +18,7 @@ }, "packageManager": "yarn@4.1.1", "scripts": { - "build": "yarn && cd nodejs-rfb && yarn && yarn build && cd ../shared && yarn build && cd ../qemu && yarn build && cd ../cvmts && yarn build", + "build": "just", "serve": "node cvmts/dist/index.js" } } diff --git a/yarn.lock b/yarn.lock index 2c8b671..6e41293 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,12 +47,19 @@ __metadata: languageName: unknown linkType: soft +"@cvmts/cvm-rs@npm:*, @cvmts/cvm-rs@workspace:cvm-rs": + version: 0.0.0-use.local + resolution: "@cvmts/cvm-rs@workspace:cvm-rs" + dependencies: + cargo-cp-artifact: "npm:^0.1" + languageName: unknown + linkType: soft + "@cvmts/cvmts@workspace:cvmts": version: 0.0.0-use.local resolution: "@cvmts/cvmts@workspace:cvmts" dependencies: - "@cvmts/guac-rs": "npm:*" - "@cvmts/jpegturbo-rs": "npm:*" + "@cvmts/cvm-rs": "npm:*" "@cvmts/qemu": "npm:*" "@types/node": "npm:^20.12.5" "@types/ws": "npm:^8.5.5" @@ -66,22 +73,6 @@ __metadata: languageName: unknown linkType: soft -"@cvmts/guac-rs@npm:*, @cvmts/guac-rs@workspace:guac-rs": - version: 0.0.0-use.local - resolution: "@cvmts/guac-rs@workspace:guac-rs" - dependencies: - cargo-cp-artifact: "npm:^0.1" - languageName: unknown - linkType: soft - -"@cvmts/jpegturbo-rs@npm:*, @cvmts/jpegturbo-rs@workspace:jpegturbo-rs": - version: 0.0.0-use.local - resolution: "@cvmts/jpegturbo-rs@workspace:jpegturbo-rs" - dependencies: - cargo-cp-artifact: "npm:^0.1" - languageName: unknown - linkType: soft - "@cvmts/qemu@npm:*, @cvmts/qemu@workspace:qemu": version: 0.0.0-use.local resolution: "@cvmts/qemu@workspace:qemu" From b0829d5bcffabf17256e6e77ef9ad85440b762a2 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sat, 22 Jun 2024 21:24:41 -0400 Subject: [PATCH 29/60] add just-install dev dependency --- jpeg-turbo | 1 + package.json | 1 + 2 files changed, 2 insertions(+) create mode 160000 jpeg-turbo diff --git a/jpeg-turbo b/jpeg-turbo new file mode 160000 index 0000000..6718ec1 --- /dev/null +++ b/jpeg-turbo @@ -0,0 +1 @@ +Subproject commit 6718ec1fc12aeccdb1b1490a7a258f24e8f83164 diff --git a/package.json b/package.json index 76cfb71..0f51c82 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@parcel/transformer-sass": "2.12.0", "@parcel/transformer-typescript-types": "2.12.0", "@types/node": "^20.12.5", + "just-install": "^2.0.1", "parcel": "^2.12.0", "prettier": "^3.2.5", "typescript": "^5.4.4" From 09d41617ed4cd6176406da6d3290eb5462df26ce Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sat, 22 Jun 2024 21:26:49 -0400 Subject: [PATCH 30/60] prettier reformat for merge (and remove jpeg-turbo Again) --- cvmts/src/AuthManager.ts | 6 +- cvmts/src/CollabVMServer.ts | 14 ++- cvmts/src/IConfig.ts | 6 +- cvmts/src/NetworkClient.ts | 14 +-- cvmts/src/NetworkServer.ts | 10 +- cvmts/src/TCP/TCPClient.ts | 96 +++++++++---------- cvmts/src/TCP/TCPServer.ts | 70 +++++++------- cvmts/src/User.ts | 4 +- cvmts/src/VM.ts | 18 ++-- cvmts/src/VMDisplay.ts | 20 ++-- cvmts/src/VNCVM/VNCVM.ts | 160 ++++++++++++++++---------------- cvmts/src/VNCVM/VNCVMDef.ts | 14 +-- cvmts/src/WebSocket/WSClient.ts | 78 ++++++++-------- cvmts/src/WebSocket/WSServer.ts | 50 +++++----- cvmts/src/index.ts | 6 +- jpeg-turbo | 1 - 16 files changed, 278 insertions(+), 289 deletions(-) delete mode 160000 jpeg-turbo diff --git a/cvmts/src/AuthManager.ts b/cvmts/src/AuthManager.ts index 081390b..de8217b 100644 --- a/cvmts/src/AuthManager.ts +++ b/cvmts/src/AuthManager.ts @@ -26,13 +26,11 @@ export default class AuthManager { }); // Make sure the fetch returned okay - if(!response.ok) - throw new Error(`Failed to query quth server: ${response.statusText}`) + if (!response.ok) throw new Error(`Failed to query quth server: ${response.statusText}`); let json = (await response.json()) as JoinResponse; - if (!json.success) - throw new Error(json.error); + if (!json.success) throw new Error(json.error); return json; } diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index e986557..39e3f10 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -23,8 +23,6 @@ const kCVMTSAssetsRoot = path.resolve(__dirname, '../../assets'); const kRestartTimeout = 5000; - - type ChatHistory = { user: string; msg: string; @@ -115,14 +113,14 @@ export default class CollabVMServer { this.VM = vm; // hack but whatever (TODO: less rickity) - if(config.vm.type == "qemu") { + if (config.vm.type == 'qemu') { (vm as QemuVM).on('statechange', (newState: VMState) => { - if(newState == VMState.Stopped) { - this.logger.Info("stopped ?"); + if (newState == VMState.Stopped) { + this.logger.Info('stopped ?'); setTimeout(async () => { - this.logger.Info("restarting VM"); + this.logger.Info('restarting VM'); await this.VM.Start(); - }, kRestartTimeout) + }, kRestartTimeout); } }); } @@ -642,7 +640,7 @@ export default class CollabVMServer { } } catch (err) { // No - this.logger.Error(`User ${user?.IP.address} ${user?.username ? `with username ${user?.username}` : ''} sent broken Guacamole: ${(err as Error)}`); + this.logger.Error(`User ${user?.IP.address} ${user?.username ? `with username ${user?.username}` : ''} sent broken Guacamole: ${err as Error}`); user?.kick(); } } diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index 6c7f10d..c049ce6 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -1,4 +1,4 @@ -import VNCVMDef from "./VNCVM/VNCVMDef"; +import VNCVMDef from './VNCVM/VNCVMDef'; export default interface IConfig { http: { @@ -13,7 +13,7 @@ export default interface IConfig { enabled: boolean; host: string; port: number; - } + }; auth: { enabled: boolean; apiEndpoint: string; @@ -26,7 +26,7 @@ export default interface IConfig { }; }; vm: { - type: "qemu" | "vncvm"; + type: 'qemu' | 'vncvm'; }; qemu: { qemuArgs: string; diff --git a/cvmts/src/NetworkClient.ts b/cvmts/src/NetworkClient.ts index ae1c36e..501da5b 100644 --- a/cvmts/src/NetworkClient.ts +++ b/cvmts/src/NetworkClient.ts @@ -1,8 +1,8 @@ export default interface NetworkClient { - getIP() : string; - send(msg: string) : Promise; - close() : void; - on(event: string, listener: (...args: any[]) => void) : void; - off(event: string, listener: (...args: any[]) => void) : void; - isOpen() : boolean; -} \ No newline at end of file + getIP(): string; + send(msg: string): Promise; + close(): void; + on(event: string, listener: (...args: any[]) => void): void; + off(event: string, listener: (...args: any[]) => void): void; + isOpen(): boolean; +} diff --git a/cvmts/src/NetworkServer.ts b/cvmts/src/NetworkServer.ts index fd3ec24..5fce6f6 100644 --- a/cvmts/src/NetworkServer.ts +++ b/cvmts/src/NetworkServer.ts @@ -1,6 +1,6 @@ export default interface NetworkServer { - start() : void; - stop() : void; - on(event: string, listener: (...args: any[]) => void) : void; - off(event: string, listener: (...args: any[]) => void) : void; -} \ No newline at end of file + start(): void; + stop(): void; + on(event: string, listener: (...args: any[]) => void): void; + off(event: string, listener: (...args: any[]) => void): void; +} diff --git a/cvmts/src/TCP/TCPClient.ts b/cvmts/src/TCP/TCPClient.ts index 806e4e1..83e65db 100644 --- a/cvmts/src/TCP/TCPClient.ts +++ b/cvmts/src/TCP/TCPClient.ts @@ -1,55 +1,55 @@ -import EventEmitter from "events"; -import NetworkClient from "../NetworkClient.js"; -import { Socket } from "net"; +import EventEmitter from 'events'; +import NetworkClient from '../NetworkClient.js'; +import { Socket } from 'net'; export default class TCPClient extends EventEmitter implements NetworkClient { - private socket: Socket; - private cache: string; - - constructor(socket: Socket) { - super(); - this.socket = socket; - this.cache = ''; - this.socket.on('end', () => { - this.emit('disconnect'); - }) - this.socket.on('data', (data) => { - var msg = data.toString('utf-8'); - if (msg[msg.length - 1] === '\n') msg = msg.slice(0, -1); - this.cache += msg; - this.readCache(); - }); - } + private socket: Socket; + private cache: string; - private readCache() { - for (var index = this.cache.indexOf(';'); index !== -1; index = this.cache.indexOf(';')) { - this.emit('msg', this.cache.slice(0, index + 1)); - this.cache = this.cache.slice(index + 1); - } - } + constructor(socket: Socket) { + super(); + this.socket = socket; + this.cache = ''; + this.socket.on('end', () => { + this.emit('disconnect'); + }); + this.socket.on('data', (data) => { + var msg = data.toString('utf-8'); + if (msg[msg.length - 1] === '\n') msg = msg.slice(0, -1); + this.cache += msg; + this.readCache(); + }); + } - getIP(): string { - return this.socket.remoteAddress!; - } + private readCache() { + for (var index = this.cache.indexOf(';'); index !== -1; index = this.cache.indexOf(';')) { + this.emit('msg', this.cache.slice(0, index + 1)); + this.cache = this.cache.slice(index + 1); + } + } - send(msg: string): Promise { - return new Promise((res, rej) => { - this.socket.write(msg, (err) => { - if (err) { - rej(err); - return; - } - res(); - }); - }); - } + getIP(): string { + return this.socket.remoteAddress!; + } - close(): void { - this.emit('disconnect'); - this.socket.end(); - } + send(msg: string): Promise { + return new Promise((res, rej) => { + this.socket.write(msg, (err) => { + if (err) { + rej(err); + return; + } + res(); + }); + }); + } - isOpen(): boolean { - return this.socket.writable; - } -} \ No newline at end of file + close(): void { + this.emit('disconnect'); + this.socket.end(); + } + + isOpen(): boolean { + return this.socket.writable; + } +} diff --git a/cvmts/src/TCP/TCPServer.ts b/cvmts/src/TCP/TCPServer.ts index db46372..c32c694 100644 --- a/cvmts/src/TCP/TCPServer.ts +++ b/cvmts/src/TCP/TCPServer.ts @@ -1,40 +1,40 @@ -import EventEmitter from "events"; -import NetworkServer from "../NetworkServer.js"; -import { Server, Socket } from "net"; -import IConfig from "../IConfig.js"; -import { Logger } from "@cvmts/shared"; -import TCPClient from "./TCPClient.js"; -import { IPDataManager } from "../IPData.js"; -import { User } from "../User.js"; +import EventEmitter from 'events'; +import NetworkServer from '../NetworkServer.js'; +import { Server, Socket } from 'net'; +import IConfig from '../IConfig.js'; +import { Logger } from '@cvmts/shared'; +import TCPClient from './TCPClient.js'; +import { IPDataManager } from '../IPData.js'; +import { User } from '../User.js'; export default class TCPServer extends EventEmitter implements NetworkServer { - listener: Server; - Config: IConfig; - logger: Logger; - clients: TCPClient[]; + listener: Server; + Config: IConfig; + logger: Logger; + clients: TCPClient[]; - constructor(config: IConfig) { - super(); - this.logger = new Logger("CVMTS.TCPServer"); - this.Config = config; - this.listener = new Server(); - this.clients = []; - this.listener.on('connection', socket => this.onConnection(socket)); - } + constructor(config: IConfig) { + super(); + this.logger = new Logger('CVMTS.TCPServer'); + this.Config = config; + this.listener = new Server(); + this.clients = []; + this.listener.on('connection', (socket) => this.onConnection(socket)); + } - private onConnection(socket: Socket) { - this.logger.Info(`New TCP connection from ${socket.remoteAddress}`); - var client = new TCPClient(socket); - this.clients.push(client); - this.emit('connect', new User(client, IPDataManager.GetIPData(client.getIP()), this.Config)); - } + private onConnection(socket: Socket) { + this.logger.Info(`New TCP connection from ${socket.remoteAddress}`); + var client = new TCPClient(socket); + this.clients.push(client); + this.emit('connect', new User(client, IPDataManager.GetIPData(client.getIP()), this.Config)); + } - start(): void { - this.listener.listen(this.Config.tcp.port, this.Config.tcp.host, () => { - this.logger.Info(`TCP server listening on ${this.Config.tcp.host}:${this.Config.tcp.port}`); - }) - } - stop(): void { - this.listener.close(); - } -} \ No newline at end of file + start(): void { + this.listener.listen(this.Config.tcp.port, this.Config.tcp.host, () => { + this.logger.Info(`TCP server listening on ${this.Config.tcp.host}:${this.Config.tcp.port}`); + }); + } + stop(): void { + this.listener.close(); + } +} diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index a434c2f..4b2b9d3 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -49,7 +49,7 @@ export class User { clearInterval(this.msgRecieveInterval); this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000); }); - + this.nopSendInterval = setInterval(() => this.sendNop(), 5000); this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000); this.sendNop(); @@ -66,7 +66,7 @@ export class User { this.VoteRateLimit = new RateLimiter(3, 3); this.VoteRateLimit.on('limit', () => this.closeConnection()); } - + assignGuestName(existingUsers: string[]): string { var username; do { diff --git a/cvmts/src/VM.ts b/cvmts/src/VM.ts index 11b8a06..dbdf4bb 100644 --- a/cvmts/src/VM.ts +++ b/cvmts/src/VM.ts @@ -1,11 +1,11 @@ -import VMDisplay from "./VMDisplay.js"; +import VMDisplay from './VMDisplay.js'; export default interface VM { - Start(): Promise; - Stop(): Promise; - Reboot(): Promise; - Reset(): Promise; - MonitorCommand(command: string): Promise; - GetDisplay(): VMDisplay; - SnapshotsSupported(): boolean; -} \ No newline at end of file + Start(): Promise; + Stop(): Promise; + Reboot(): Promise; + Reset(): Promise; + MonitorCommand(command: string): Promise; + GetDisplay(): VMDisplay; + SnapshotsSupported(): boolean; +} diff --git a/cvmts/src/VMDisplay.ts b/cvmts/src/VMDisplay.ts index adee89f..b06a6d7 100644 --- a/cvmts/src/VMDisplay.ts +++ b/cvmts/src/VMDisplay.ts @@ -1,12 +1,12 @@ -import { Size } from "@cvmts/shared"; -import EventEmitter from "node:events"; +import { Size } from '@cvmts/shared'; +import EventEmitter from 'node:events'; export default interface VMDisplay extends EventEmitter { - Connect(): void; - Disconnect(): void; - Connected(): boolean; - Buffer(): Buffer; - Size(): Size; - MouseEvent(x: number, y: number, buttons: number): void; - KeyboardEvent(keysym: number, pressed: boolean): void; -} \ No newline at end of file + Connect(): void; + Disconnect(): void; + Connected(): boolean; + Buffer(): Buffer; + Size(): Size; + MouseEvent(x: number, y: number, buttons: number): void; + KeyboardEvent(keysym: number, pressed: boolean): void; +} diff --git a/cvmts/src/VNCVM/VNCVM.ts b/cvmts/src/VNCVM/VNCVM.ts index 52d1cee..c89ec61 100644 --- a/cvmts/src/VNCVM/VNCVM.ts +++ b/cvmts/src/VNCVM/VNCVM.ts @@ -1,31 +1,28 @@ -import EventEmitter from "events"; -import VNCVMDef from "./VNCVMDef"; -import VM from "../VM"; -import VMDisplay from "../VMDisplay"; -import { Clamp, Logger, Rect, Size, Sleep } from "@cvmts/shared"; +import EventEmitter from 'events'; +import VNCVMDef from './VNCVMDef'; +import VM from '../VM'; +import VMDisplay from '../VMDisplay'; +import { Clamp, Logger, Rect, Size, Sleep } from '@cvmts/shared'; import { VncClient } from '@computernewb/nodejs-rfb'; -import { BatchRects } from "@cvmts/qemu"; -import { execaCommand } from "execa"; +import { BatchRects } from '@cvmts/qemu'; +import { execaCommand } from 'execa'; export default class VNCVM extends EventEmitter implements VM, VMDisplay { - def : VNCVMDef; - logger: Logger; - private displayVnc = new VncClient({ - debug: false, - fps: 60, - encodings: [ - VncClient.consts.encodings.raw, - VncClient.consts.encodings.pseudoDesktopSize - ] - }); - private vncShouldReconnect: boolean = false; + def: VNCVMDef; + logger: Logger; + private displayVnc = new VncClient({ + debug: false, + fps: 60, + encodings: [VncClient.consts.encodings.raw, VncClient.consts.encodings.pseudoDesktopSize] + }); + private vncShouldReconnect: boolean = false; - constructor(def : VNCVMDef) { - super(); - this.def = def; - this.logger = new Logger(`CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}`); + constructor(def: VNCVMDef) { + super(); + this.def = def; + this.logger = new Logger(`CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}`); - this.displayVnc.on('connectTimeout', () => { + this.displayVnc.on('connectTimeout', () => { this.Reconnect(); }); @@ -34,7 +31,7 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { }); this.displayVnc.on('disconnect', () => { - this.logger.Info('Disconnected'); + this.logger.Info('Disconnected'); this.Reconnect(); }); @@ -43,7 +40,7 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { }); this.displayVnc.on('firstFrameUpdate', () => { - this.logger.Info('Connected'); + this.logger.Info('Connected'); // apparently this library is this good. // at least it's better than the two others which exist. this.displayVnc.changeFps(60); @@ -77,17 +74,16 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { this.emit('frame'); }); - } + } - - async Reset(): Promise { - if (this.def.restoreCmd) await execaCommand(this.def.restoreCmd, {shell: true}); - else { - await this.Stop(); - await Sleep(1000); - await this.Start(); - } - } + async Reset(): Promise { + if (this.def.restoreCmd) await execaCommand(this.def.restoreCmd, { shell: true }); + else { + await this.Stop(); + await Sleep(1000); + await this.Start(); + } + } private Reconnect() { if (this.displayVnc.connected) return; @@ -98,60 +94,60 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { // if we fail after max tries, emit a event this.displayVnc.connect({ - host: this.def.vncHost, - port: this.def.vncPort, - path: null, + host: this.def.vncHost, + port: this.def.vncPort, + path: null }); } - async Start(): Promise { - this.logger.Info('Connecting'); - if (this.def.startCmd) await execaCommand(this.def.startCmd, {shell: true}); - this.Connect(); - } + async Start(): Promise { + this.logger.Info('Connecting'); + if (this.def.startCmd) await execaCommand(this.def.startCmd, { shell: true }); + this.Connect(); + } - async Stop(): Promise { - this.logger.Info('Disconnecting'); - this.Disconnect(); - if (this.def.stopCmd) await execaCommand(this.def.stopCmd, {shell: true}); - } + async Stop(): Promise { + this.logger.Info('Disconnecting'); + this.Disconnect(); + if (this.def.stopCmd) await execaCommand(this.def.stopCmd, { shell: true }); + } - async Reboot(): Promise { - if (this.def.rebootCmd) await execaCommand(this.def.rebootCmd, {shell: true}); - } + async Reboot(): Promise { + if (this.def.rebootCmd) await execaCommand(this.def.rebootCmd, { shell: true }); + } - async MonitorCommand(command: string): Promise { - // TODO: This can maybe run a specified command? - return "This VM does not support monitor commands."; - } + async MonitorCommand(command: string): Promise { + // TODO: This can maybe run a specified command? + return 'This VM does not support monitor commands.'; + } - GetDisplay(): VMDisplay { - return this; - } + GetDisplay(): VMDisplay { + return this; + } - SnapshotsSupported(): boolean { - return true; - } + SnapshotsSupported(): boolean { + return true; + } - Connect(): void { - this.vncShouldReconnect = true; - this.Reconnect(); - } + Connect(): void { + this.vncShouldReconnect = true; + this.Reconnect(); + } - Disconnect(): void { - this.vncShouldReconnect = false; - this.displayVnc.disconnect(); - } + Disconnect(): void { + this.vncShouldReconnect = false; + this.displayVnc.disconnect(); + } - Connected(): boolean { - return this.displayVnc.connected; - } + Connected(): boolean { + return this.displayVnc.connected; + } - Buffer(): Buffer { - return this.displayVnc.fb; - } + Buffer(): Buffer { + return this.displayVnc.fb; + } - Size(): Size { + Size(): Size { if (!this.displayVnc.connected) return { width: 0, @@ -162,13 +158,13 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight }; - } + } - MouseEvent(x: number, y: number, buttons: number): void { + MouseEvent(x: number, y: number, buttons: number): void { if (this.displayVnc.connected) this.displayVnc.sendPointerEvent(Clamp(x, 0, this.displayVnc.clientWidth), Clamp(y, 0, this.displayVnc.clientHeight), buttons); - } + } - KeyboardEvent(keysym: number, pressed: boolean): void { + KeyboardEvent(keysym: number, pressed: boolean): void { if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed); - } -} \ No newline at end of file + } +} diff --git a/cvmts/src/VNCVM/VNCVMDef.ts b/cvmts/src/VNCVM/VNCVMDef.ts index b6d34d9..395977d 100644 --- a/cvmts/src/VNCVM/VNCVMDef.ts +++ b/cvmts/src/VNCVM/VNCVMDef.ts @@ -1,8 +1,8 @@ export default interface VNCVMDef { - vncHost : string; - vncPort : number; - startCmd : string | null; - stopCmd : string | null; - rebootCmd : string | null; - restoreCmd : string | null; -} \ No newline at end of file + vncHost: string; + vncPort: number; + startCmd: string | null; + stopCmd: string | null; + rebootCmd: string | null; + restoreCmd: string | null; +} diff --git a/cvmts/src/WebSocket/WSClient.ts b/cvmts/src/WebSocket/WSClient.ts index bf7daa4..cbb84ed 100644 --- a/cvmts/src/WebSocket/WSClient.ts +++ b/cvmts/src/WebSocket/WSClient.ts @@ -1,62 +1,60 @@ -import { WebSocket } from "ws"; -import NetworkClient from "../NetworkClient.js"; -import EventEmitter from "events"; -import { Logger } from "@cvmts/shared"; +import { WebSocket } from 'ws'; +import NetworkClient from '../NetworkClient.js'; +import EventEmitter from 'events'; +import { Logger } from '@cvmts/shared'; export default class WSClient extends EventEmitter implements NetworkClient { - socket: WebSocket; - ip: string; + socket: WebSocket; + ip: string; - constructor(ws: WebSocket, ip: string) { - super(); - this.socket = ws; - this.ip = ip; - this.socket.on('message', (buf: Buffer, isBinary: boolean) => { + constructor(ws: WebSocket, ip: string) { + super(); + this.socket = ws; + this.ip = ip; + this.socket.on('message', (buf: Buffer, isBinary: boolean) => { // Close the user's connection if they send a non-string message if (isBinary) { this.close(); return; } - this.emit('msg', buf.toString("utf-8")); + this.emit('msg', buf.toString('utf-8')); }); - this.socket.on('close', () => { - this.emit('disconnect'); - }); - } + this.socket.on('close', () => { + this.emit('disconnect'); + }); + } - isOpen(): boolean { - return this.socket.readyState === WebSocket.OPEN; - } + isOpen(): boolean { + return this.socket.readyState === WebSocket.OPEN; + } - getIP(): string { - return this.ip; - } - send(msg: string): Promise { - return new Promise((res,rej) => { - if(!this.isOpen()) + getIP(): string { + return this.ip; + } + send(msg: string): Promise { + return new Promise((res, rej) => { + if (!this.isOpen()) res(); + + this.socket.send(msg, (err) => { + if (err) { + rej(err); + return; + } res(); - - this.socket.send(msg, (err) => { - if (err) { - rej(err); - return; - } - res(); - }); - }); - } + }); + }); + } - close(): void { - if(this.isOpen()) { + close(): void { + if (this.isOpen()) { // While this seems counterintutive, do note that the WebSocket protocol // *sends* a data frame whilist closing a connection. Therefore, if the other end // has forcibly hung up (closed) their connection, the best way to handle that // is to just let the inner TCP socket propegate that, which `ws` will do for us. // Otherwise, we'll try to send data to a closed client then SIGPIPE. - this.socket.close(); + this.socket.close(); } - } - + } } diff --git a/cvmts/src/WebSocket/WSServer.ts b/cvmts/src/WebSocket/WSServer.ts index 918cfe6..76580a9 100644 --- a/cvmts/src/WebSocket/WSServer.ts +++ b/cvmts/src/WebSocket/WSServer.ts @@ -11,18 +11,18 @@ import { User } from '../User.js'; import { Logger } from '@cvmts/shared'; export default class WSServer extends EventEmitter implements NetworkServer { - private httpServer: http.Server; - private wsServer: WebSocketServer; - private clients: WSClient[]; - private Config: IConfig; - private logger: Logger; + private httpServer: http.Server; + private wsServer: WebSocketServer; + private clients: WSClient[]; + private Config: IConfig; + private logger: Logger; - constructor(config : IConfig) { - super(); - this.Config = config; - this.clients = []; - this.logger = new Logger("CVMTS.WSServer"); - this.httpServer = http.createServer(); + constructor(config: IConfig) { + super(); + this.Config = config; + this.clients = []; + this.logger = new Logger('CVMTS.WSServer'); + 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) => { @@ -30,19 +30,19 @@ export default class WSServer extends EventEmitter implements NetworkServer { res.write('This server only accepts WebSocket connections.'); res.end(); }); - } + } - start(): void { - this.httpServer.listen(this.Config.http.port, this.Config.http.host, () => { - this.logger.Info(`WebSocket server listening on ${this.Config.http.host}:${this.Config.http.port}`); - }); - } + start(): void { + this.httpServer.listen(this.Config.http.port, this.Config.http.host, () => { + this.logger.Info(`WebSocket server listening on ${this.Config.http.host}:${this.Config.http.port}`); + }); + } - stop(): void { - this.httpServer.close(); - } + stop(): void { + this.httpServer.close(); + } - private httpOnUpgrade(req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) { + private httpOnUpgrade(req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) { var killConnection = () => { socket.write('HTTP/1.1 400 Bad Request\n\n400 Bad Request'); socket.destroy(); @@ -121,7 +121,7 @@ export default class WSServer extends EventEmitter implements NetworkServer { let ipdata = IPDataManager.GetIPDataMaybe(ip); - if(ipdata != null) { + if (ipdata != null) { let connections = ipdata.refCount; if (connections + 1 > this.Config.collabvm.maxConnections) { socket.write('HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests'); @@ -136,11 +136,11 @@ export default class WSServer extends EventEmitter implements NetworkServer { } private onConnection(ws: WebSocket, req: http.IncomingMessage, ip: string) { - let client = new WSClient(ws, ip); - this.clients.push(client); + let client = new WSClient(ws, ip); + this.clients.push(client); let user = new User(client, IPDataManager.GetIPData(ip), this.Config); - this.emit('connect', user); + this.emit('connect', user); ws.on('error', (e) => { this.logger.Error(`${e} (caused by connection ${ip})`); diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index df45e80..fa85223 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -34,7 +34,7 @@ try { } let exiting = false; -let VM : VM; +let VM: VM; async function stop() { if (exiting) return; @@ -47,7 +47,7 @@ async function start() { // Init the auth manager if enabled let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; switch (Config.vm.type) { - case "qemu": { + case 'qemu': { // Print a warning if qmpSockDir is set // and the host OS is Windows, as this // configuration will very likely not work. @@ -67,7 +67,7 @@ async function start() { VM = new QemuVM(def); break; } - case "vncvm": { + case 'vncvm': { VM = new VNCVM(Config.vncvm); break; } diff --git a/jpeg-turbo b/jpeg-turbo deleted file mode 160000 index 6718ec1..0000000 --- a/jpeg-turbo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6718ec1fc12aeccdb1b1490a7a258f24e8f83164 From dda72cad91fdaea6d8e4a670ebf42a75752110ac Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sat, 22 Jun 2024 21:27:28 -0400 Subject: [PATCH 31/60] cvmts: quth => auth --- cvmts/src/AuthManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cvmts/src/AuthManager.ts b/cvmts/src/AuthManager.ts index de8217b..8cfe96e 100644 --- a/cvmts/src/AuthManager.ts +++ b/cvmts/src/AuthManager.ts @@ -26,7 +26,7 @@ export default class AuthManager { }); // Make sure the fetch returned okay - if (!response.ok) throw new Error(`Failed to query quth server: ${response.statusText}`); + if (!response.ok) throw new Error(`Failed to query auth server: ${response.statusText}`); let json = (await response.json()) as JoinResponse; From 47dae01d3ee6ce68e05bc506cc228c036f1e8c6c Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sat, 22 Jun 2024 21:38:26 -0400 Subject: [PATCH 32/60] add dependencies to README, remove not-so-frequently-asked questions --- README.md | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d304e2b..b5c576a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,30 @@ # CollabVM1.ts This is a drop-in replacement for the dying CollabVM 1.2.11. Currently in beta +## Compatibility + +The CollabVM server will run on any Operating System that can run Node.JS and Rust. This means modern linux distributions and Windows versions. + +We do not recommend or support running CollabVM Server on Windows due to very poor support for QEMU on that platform. + +## Dependencies + +The CollabVM server requires the following to be installed on your server: + +1. Node.js (obviously) +2. QEMU (Unless you just want to use a VNC Connection as your VM) +3. Rust and Cargo +4. NASM assembler + +### Installing dependencies on Arch + +1. Install dependencies: `sudo pacman --noconfirm -S nodejs npm nasm rust` +2. Enable corepack: `sudo corepack enable` + +### Installing dependencies on Debian + +TODO + ## Running **TODO**: These instructions are not finished for the refactor branch. @@ -10,13 +34,3 @@ This is a drop-in replacement for the dying CollabVM 1.2.11. Currently in beta 2. Install dependencies: `yarn` 3. Build it: `yarn build` 4. Run it: `yarn serve` - -## FAQ -### When I try to access the admin panel, the server crashes! -The server does not support the admin panel. Instead, there is a configuration file you can edit named config.toml. -### Why only QEMU? Why not VMWare, VirtualBox, etc.? -This server was written very quickly to replace CollabVM Server 1.2.11, and so only QEMU support exists. There are plans to support VMWare when CollabVM Server 3 releases. -### What platforms can this be run on? -If it can run a relatively new version of Node and QEMU, then you can run this. This means modern Linux distributions, modern macOS versions and Windows 10 and above. -### When the VM shuts off, instead of restarting, it freezes. -This has been fixed already, you are running a copy of the code before February 11th, 2023. From 1079a847a8d2006565b36d3e4a2cdab662a70dd3 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sat, 22 Jun 2024 21:43:46 -0400 Subject: [PATCH 33/60] remove npm from arch deps (thought corepack was provided by npm, it's actually provided by nodejs itself) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b5c576a..e0a5474 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The CollabVM server requires the following to be installed on your server: ### Installing dependencies on Arch -1. Install dependencies: `sudo pacman --noconfirm -S nodejs npm nasm rust` +1. Install dependencies: `sudo pacman --noconfirm -S nodejs nasm rust` 2. Enable corepack: `sudo corepack enable` ### Installing dependencies on Debian From 5a67deb59be6e593bcab8937fbd3eeceebe95813 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sat, 22 Jun 2024 21:44:22 -0400 Subject: [PATCH 34/60] add --needed and -y flag to pacman command --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e0a5474..e889fee 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The CollabVM server requires the following to be installed on your server: ### Installing dependencies on Arch -1. Install dependencies: `sudo pacman --noconfirm -S nodejs nasm rust` +1. Install dependencies: `sudo pacman --needed --noconfirm -Sy nodejs nasm rust` 2. Enable corepack: `sudo corepack enable` ### Installing dependencies on Debian From 183b17194e611d7fc7a6987c27c1ea757b17ed2b Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sat, 22 Jun 2024 21:46:37 -0400 Subject: [PATCH 35/60] chore(README): cargo is usually a default part of a complete Rust toolchain so just specify "A Rust toolchain" --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e889fee..aa29238 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The CollabVM server requires the following to be installed on your server: 1. Node.js (obviously) 2. QEMU (Unless you just want to use a VNC Connection as your VM) -3. Rust and Cargo +3. A Rust toolchain (e.g: [rustup](https://rustup.rs)) 4. NASM assembler ### Installing dependencies on Arch From d100721a644cff99cd753b3327cd339e3c6ba104 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sat, 22 Jun 2024 21:50:18 -0400 Subject: [PATCH 36/60] update yarn.lock --- yarn.lock | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/yarn.lock b/yarn.lock index 6e41293..f0a0c55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1550,6 +1550,15 @@ __metadata: languageName: node linkType: hard +"@types/yauzl@npm:^2.9.1": + version: 2.10.3 + resolution: "@types/yauzl@npm:2.10.3" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/f1b7c1b99fef9f2fe7f1985ef7426d0cebe48cd031f1780fcdc7451eec7e31ac97028f16f50121a59bcf53086a1fc8c856fd5b7d3e00970e43d92ae27d6b43dc + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -1701,6 +1710,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:~0.2.3": + version: 0.2.13 + resolution: "buffer-crc32@npm:0.2.13" + checksum: 10c0/cb0a8ddf5cf4f766466db63279e47761eb825693eeba6a5a95ee4ec8cb8f81ede70aa7f9d8aeec083e781d47154290eb5d4d26b3f7a465ec57fb9e7d59c47150 + languageName: node + linkType: hard + "cacache@npm:^18.0.0": version: 18.0.2 resolution: "cacache@npm:18.0.2" @@ -1946,12 +1962,20 @@ __metadata: "@parcel/transformer-sass": "npm:2.12.0" "@parcel/transformer-typescript-types": "npm:2.12.0" "@types/node": "npm:^20.12.5" + just-install: "npm:^2.0.1" parcel: "npm:^2.12.0" prettier: "npm:^3.2.5" typescript: "npm:^5.4.4" languageName: unknown linkType: soft +"data-uri-to-buffer@npm:^4.0.0": + version: 4.0.1 + resolution: "data-uri-to-buffer@npm:4.0.1" + checksum: 10c0/20a6b93107597530d71d4cb285acee17f66bcdfc03fd81040921a81252f19db27588d87fc8fc69e1950c55cfb0bf8ae40d0e5e21d907230813eb5d5a7f9eb45b + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -1964,6 +1988,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.1.1": + version: 4.3.5 + resolution: "debug@npm:4.3.5" + dependencies: + ms: "npm:2.1.2" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/082c375a2bdc4f4469c99f325ff458adad62a3fc2c482d59923c260cb08152f34e2659f72b3767db8bb2f21ca81a60a42d1019605a412132d7b9f59363a005cc + languageName: node + linkType: hard + "detect-libc@npm:^1.0.3": version: 1.0.3 resolution: "detect-libc@npm:1.0.3" @@ -2069,6 +2105,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0": + version: 1.4.4 + resolution: "end-of-stream@npm:1.4.4" + dependencies: + once: "npm:^1.4.0" + checksum: 10c0/870b423afb2d54bb8d243c63e07c170409d41e20b47eeef0727547aea5740bd6717aca45597a9f2745525667a6b804c1e7bede41f856818faee5806dd9ff3975 + languageName: node + linkType: hard + "entities@npm:^2.0.0": version: 2.2.0 resolution: "entities@npm:2.2.0" @@ -2144,6 +2189,42 @@ __metadata: languageName: node linkType: hard +"extract-zip@npm:^2.0.1": + version: 2.0.1 + resolution: "extract-zip@npm:2.0.1" + dependencies: + "@types/yauzl": "npm:^2.9.1" + debug: "npm:^4.1.1" + get-stream: "npm:^5.1.0" + yauzl: "npm:^2.10.0" + dependenciesMeta: + "@types/yauzl": + optional: true + bin: + extract-zip: cli.js + checksum: 10c0/9afbd46854aa15a857ae0341a63a92743a7b89c8779102c3b4ffc207516b2019337353962309f85c66ee3d9092202a83cdc26dbf449a11981272038443974aee + languageName: node + linkType: hard + +"fd-slicer@npm:~1.1.0": + version: 1.1.0 + resolution: "fd-slicer@npm:1.1.0" + dependencies: + pend: "npm:~1.2.0" + checksum: 10c0/304dd70270298e3ffe3bcc05e6f7ade2511acc278bc52d025f8918b48b6aa3b77f10361bddfadfe2a28163f7af7adbdce96f4d22c31b2f648ba2901f0c5fc20e + languageName: node + linkType: hard + +"fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": + version: 3.2.0 + resolution: "fetch-blob@npm:3.2.0" + dependencies: + node-domexception: "npm:^1.0.0" + web-streams-polyfill: "npm:^3.0.3" + checksum: 10c0/60054bf47bfa10fb0ba6cb7742acec2f37c1f56344f79a70bb8b1c48d77675927c720ff3191fa546410a0442c998d27ab05e9144c32d530d8a52fbe68f843b69 + languageName: node + linkType: hard + "fill-range@npm:^7.0.1": version: 7.0.1 resolution: "fill-range@npm:7.0.1" @@ -2163,6 +2244,15 @@ __metadata: languageName: node linkType: hard +"formdata-polyfill@npm:^4.0.10": + version: 4.0.10 + resolution: "formdata-polyfill@npm:4.0.10" + dependencies: + fetch-blob: "npm:^3.1.2" + checksum: 10c0/5392ec484f9ce0d5e0d52fb5a78e7486637d516179b0eb84d81389d7eccf9ca2f663079da56f761355c0a65792810e3b345dc24db9a8bbbcf24ef3c8c88570c6 + languageName: node + linkType: hard + "fs-minipass@npm:^2.0.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -2207,6 +2297,15 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^5.1.0": + version: 5.2.0 + resolution: "get-stream@npm:5.2.0" + dependencies: + pump: "npm:^3.0.0" + checksum: 10c0/43797ffd815fbb26685bf188c8cfebecb8af87b3925091dd7b9a9c915993293d78e3c9e1bce125928ff92f2d0796f3889b92b5ec6d58d1041b574682132e0a80 + languageName: node + linkType: hard + "get-stream@npm:^8.0.1": version: 8.0.1 resolution: "get-stream@npm:8.0.1" @@ -2543,6 +2642,19 @@ __metadata: languageName: node linkType: hard +"just-install@npm:^2.0.1": + version: 2.0.1 + resolution: "just-install@npm:2.0.1" + dependencies: + extract-zip: "npm:^2.0.1" + node-fetch: "npm:^3.3.2" + bin: + just: bin/just.js + just-install: install.js + checksum: 10c0/dd7ef462b498c7289d223da1e9e54f24b957e18d760b903e1b85cc964265c5130deb82fc77ea5bd5b08fda1330c397aa9863f448b70b3403283ff3351c1e6792 + languageName: node + linkType: hard + "lightningcss-darwin-arm64@npm:1.24.1": version: 1.24.1 resolution: "lightningcss-darwin-arm64@npm:1.24.1" @@ -2937,6 +3049,24 @@ __metadata: languageName: node linkType: hard +"node-domexception@npm:^1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: 10c0/5e5d63cda29856402df9472335af4bb13875e1927ad3be861dc5ebde38917aecbf9ae337923777af52a48c426b70148815e890a5d72760f1b4d758cc671b1a2b + languageName: node + linkType: hard + +"node-fetch@npm:^3.3.2": + version: 3.3.2 + resolution: "node-fetch@npm:3.3.2" + dependencies: + data-uri-to-buffer: "npm:^4.0.0" + fetch-blob: "npm:^3.1.4" + formdata-polyfill: "npm:^4.0.10" + checksum: 10c0/f3d5e56190562221398c9f5750198b34cf6113aa304e34ee97c94fd300ec578b25b2c2906edba922050fce983338fde0d5d34fcb0fc3336ade5bd0e429ad7538 + languageName: node + linkType: hard + "node-gyp-build-optional-packages@npm:5.0.7": version: 5.0.7 resolution: "node-gyp-build-optional-packages@npm:5.0.7" @@ -3038,6 +3168,15 @@ __metadata: languageName: node linkType: hard +"once@npm:^1.3.1, once@npm:^1.4.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 + languageName: node + linkType: hard + "onetime@npm:^6.0.0": version: 6.0.0 resolution: "onetime@npm:6.0.0" @@ -3139,6 +3278,13 @@ __metadata: languageName: node linkType: hard +"pend@npm:~1.2.0": + version: 1.2.0 + resolution: "pend@npm:1.2.0" + checksum: 10c0/8a87e63f7a4afcfb0f9f77b39bb92374afc723418b9cb716ee4257689224171002e07768eeade4ecd0e86f1fa3d8f022994219fb45634f2dbd78c6803e452458 + languageName: node + linkType: hard + "picocolors@npm:^1.0.0": version: 1.0.0 resolution: "picocolors@npm:1.0.0" @@ -3223,6 +3369,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.0 + resolution: "pump@npm:3.0.0" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10c0/bbdeda4f747cdf47db97428f3a135728669e56a0ae5f354a9ac5b74556556f5446a46f720a8f14ca2ece5be9b4d5d23c346db02b555f46739934cc6c093a5478 + languageName: node + linkType: hard + "react-error-overlay@npm:6.0.9": version: 6.0.9 resolution: "react-error-overlay@npm:6.0.9" @@ -3727,6 +3883,13 @@ __metadata: languageName: node linkType: hard +"web-streams-polyfill@npm:^3.0.3": + version: 3.3.3 + resolution: "web-streams-polyfill@npm:3.3.3" + checksum: 10c0/64e855c47f6c8330b5436147db1c75cb7e7474d924166800e8e2aab5eb6c76aac4981a84261dd2982b3e754490900b99791c80ae1407a9fa0dcff74f82ea3a7f + languageName: node + linkType: hard + "which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2" @@ -3771,6 +3934,13 @@ __metadata: languageName: node linkType: hard +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 + languageName: node + linkType: hard + "ws@npm:^8.14.1": version: 8.16.0 resolution: "ws@npm:8.16.0" @@ -3792,3 +3962,13 @@ __metadata: checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a languageName: node linkType: hard + +"yauzl@npm:^2.10.0": + version: 2.10.0 + resolution: "yauzl@npm:2.10.0" + dependencies: + buffer-crc32: "npm:~0.2.3" + fd-slicer: "npm:~1.1.0" + checksum: 10c0/f265002af7541b9ec3589a27f5fb8f11cf348b53cc15e2751272e3c062cd73f3e715bc72d43257de71bbaecae446c3f1b14af7559e8ab0261625375541816422 + languageName: node + linkType: hard From 014650991ca13a6ad5e5969dbcbbf526cf6e6258 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sat, 22 Jun 2024 21:52:20 -0400 Subject: [PATCH 37/60] bump rfb --- nodejs-rfb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodejs-rfb b/nodejs-rfb index c94369b..ca99bcc 160000 --- a/nodejs-rfb +++ b/nodejs-rfb @@ -1 +1 @@ -Subproject commit c94369b4447e574e3f62a18e93b08124f7dc96e5 +Subproject commit ca99bccda86a39d7cbe80ce5ac45bb6ce4f5e757 From 020c6310ec4322d409c03784a8d44a996f061d2b Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sat, 22 Jun 2024 22:57:01 -0400 Subject: [PATCH 38/60] some fixes and improvements to build - change .yarn/ gitignore to include subprojects - add nodejs-rfb to build (and bump it) - add clean script --- .gitignore | 2 +- Justfile | 1 + nodejs-rfb | 2 +- package.json | 3 ++- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index d5db0bf..8c9ed6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .parcel-cache/ -.yarn/ +**/.yarn/ **/node_modules/ config.toml diff --git a/Justfile b/Justfile index 2333d22..53bef25 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,6 @@ all: yarn workspace @cvmts/cvm-rs run build + yarn workspace @computernewb/nodejs-rfb run build yarn workspace @cvmts/shared run build yarn workspace @cvmts/qemu run build yarn workspace @cvmts/cvmts run build diff --git a/nodejs-rfb b/nodejs-rfb index ca99bcc..55e9e6c 160000 --- a/nodejs-rfb +++ b/nodejs-rfb @@ -1 +1 @@ -Subproject commit ca99bccda86a39d7cbe80ce5ac45bb6ce4f5e757 +Subproject commit 55e9e6cd65988dce59f53a3ea8701a90073b55a4 diff --git a/package.json b/package.json index 0f51c82..63a02be 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "packageManager": "yarn@4.1.1", "scripts": { "build": "just", - "serve": "node cvmts/dist/index.js" + "serve": "node cvmts/dist/index.js", + "clean": "rm -rf .parcel-cache .yarn **/node_modules **/dist cvm-rs/target cvm-rs/index.node" } } From 286f3eec62a9d51762b8764558ea856736b57bd2 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sun, 23 Jun 2024 02:11:23 -0400 Subject: [PATCH 39/60] cvm-rs: Actually throw `Error` on guac decode errors we were throwing String or something before.. --- cvm-rs/src/guac_js.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cvm-rs/src/guac_js.rs b/cvm-rs/src/guac_js.rs index 9077333..1a8dd01 100644 --- a/cvm-rs/src/guac_js.rs +++ b/cvm-rs/src/guac_js.rs @@ -21,8 +21,7 @@ fn guac_decode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsArray> { } Err(e) => { - let err = cx.string(format!("{}", e)); - return cx.throw(err); + return cx.throw_error(format!("{}", e)); } } } From 85a86327f4a3e702277bc16f3aee4221aa198aeb Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sun, 23 Jun 2024 02:23:50 -0400 Subject: [PATCH 40/60] add missing XFF warning, remove ipdata check from WSServer because CollabVMServer already does that --- cvmts/src/WebSocket/WSServer.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/cvmts/src/WebSocket/WSServer.ts b/cvmts/src/WebSocket/WSServer.ts index 76580a9..807c51e 100644 --- a/cvmts/src/WebSocket/WSServer.ts +++ b/cvmts/src/WebSocket/WSServer.ts @@ -94,6 +94,7 @@ export default class WSServer extends EventEmitter implements NetworkServer { // Make sure x-forwarded-for is set if (req.headers['x-forwarded-for'] === undefined) { killConnection(); + this.logger.Error('X-Forwarded-For header not set. This is most likely a misconfiguration of your reverse proxy.'); return; } try { @@ -101,6 +102,7 @@ export default class WSServer extends EventEmitter implements NetworkServer { ip = req.headers['x-forwarded-for']?.toString().replace(/\ /g, '').split(',')[0]; } catch { // If we can't get the IP, kill the connection + this.logger.Error('Invalid X-Forwarded-For header. This is most likely a misconfiguration of your reverse proxy.'); killConnection(); return; } @@ -119,16 +121,6 @@ export default class WSServer extends EventEmitter implements NetworkServer { ip = req.socket.remoteAddress; } - let ipdata = IPDataManager.GetIPDataMaybe(ip); - - if (ipdata != null) { - let connections = ipdata.refCount; - if (connections + 1 > this.Config.collabvm.maxConnections) { - socket.write('HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests'); - socket.destroy(); - } - } - this.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => { this.wsServer.emit('connection', ws, req); this.onConnection(ws, req, ip); From 1a5a0cd4071262f7019b89168d46783f76ce8c5d Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sun, 23 Jun 2024 02:23:59 -0400 Subject: [PATCH 41/60] add geoip country flag support --- .gitignore | 3 + config.example.toml | 9 +++ cvmts/package.json | 1 + cvmts/src/CollabVMServer.ts | 47 +++++++++++++-- cvmts/src/GeoIPDownloader.ts | 107 +++++++++++++++++++++++++++++++++++ cvmts/src/IConfig.ts | 6 ++ cvmts/src/User.ts | 3 + cvmts/src/index.ts | 8 ++- yarn.lock | 103 +++++++++++++++++++++++++++++++++ 9 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 cvmts/src/GeoIPDownloader.ts diff --git a/.gitignore b/.gitignore index 8c9ed6f..6fbbf6a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ cvmts/attic # Guac-rs cvm-rs/target cvm-rs/index.node + +# geolite shit +**/geoip/ \ No newline at end of file diff --git a/config.example.toml b/config.example.toml index 4129359..e7f4f6c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -11,6 +11,15 @@ origin = false # Origins to accept connections from. originAllowedDomains = ["computernewb.com"] +[geoip] +# Enables support for showing country flags next to usernames. +enabled = false +# Directory to store and load GeoIP databases from. +directory = "geoip/" +# MaxMind license key and account ID (https://www.maxmind.com/en/accounts/current/license-key) +accountID = "" +licenseKey = "" + [tcp] enabled = false host = "0.0.0.0" diff --git a/cvmts/package.json b/cvmts/package.json index 496ede2..773823f 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -13,6 +13,7 @@ "dependencies": { "@cvmts/cvm-rs": "*", "@cvmts/qemu": "*", + "@maxmind/geoip2-node": "^5.0.0", "execa": "^8.0.1", "mnemonist": "^0.39.5", "sharp": "^0.33.3", diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 39e3f10..538507a 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -14,6 +14,7 @@ import AuthManager from './AuthManager.js'; import { Size, Rect, Logger } from '@cvmts/shared'; import { JPEGEncoder } from './JPEGEncoder.js'; import VM from './VM.js'; +import { ReaderModel } from '@maxmind/geoip2-node'; // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before @@ -81,9 +82,12 @@ export default class CollabVMServer { // Authentication manager private auth: AuthManager | null; + // Geoip + private geoipReader: ReaderModel | null; + private logger = new Logger('CVMTS.Server'); - constructor(config: IConfig, vm: VM, auth: AuthManager | null) { + constructor(config: IConfig, vm: VM, auth: AuthManager | null, geoipReader: ReaderModel | null) { this.Config = config; this.ChatHistory = new CircularBuffer(Array, this.Config.collabvm.maxChatHistoryLength); this.TurnQueue = new Queue(); @@ -127,6 +131,8 @@ export default class CollabVMServer { // authentication manager this.auth = auth; + + this.geoipReader = geoipReader; } public addUser(user: User) { @@ -137,12 +143,20 @@ export default class CollabVMServer { sameip[0].kick(); } this.clients.push(user); + if (this.Config.geoip.enabled) { + try { + user.countryCode = this.geoipReader!.country(user.IP.address).country!.isoCode; + } catch (error) { + this.logger.Warning(`Failed to get country code for ${user.IP.address}: ${(error as Error).message}`); + } + } user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); user.socket.on('disconnect', () => this.connectionClosed(user)); if (this.Config.auth.enabled) { user.sendMsg(cvm.guacEncode('auth', this.Config.auth.apiEndpoint)); } user.sendMsg(this.getAdduserMsg()); + if (this.Config.geoip.enabled) user.sendMsg(this.getFlagMsg()); } private connectionClosed(user: User) { @@ -196,7 +210,14 @@ export default class CollabVMServer { await old.kick(); } // Set username - this.renameUser(client, res.username); + if (client.countryCode !== null && client.noFlag) { + // privacy + for (let cl of this.clients.filter(c => c !== client)) { + cl.sendMsg(cvm.guacEncode('remuser', '1', client.username!)); + } + this.renameUser(client, res.username, false); + } + else this.renameUser(client, res.username, true); // Set rank client.rank = res.rank; if (client.rank === Rank.Admin) { @@ -217,6 +238,11 @@ export default class CollabVMServer { client.sendMsg(cvm.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')); } break; + case 'noflag': { + if (client.connectedToNode) // too late + return; + client.noFlag = true; + } case 'list': client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); break; @@ -652,7 +678,7 @@ export default class CollabVMServer { return arr; } - renameUser(client: User, newName?: string) { + renameUser(client: User, newName?: string, announce: boolean = true) { // This shouldn't need a ternary but it does for some reason var hadName: boolean = client.username ? true : false; var oldname: any; @@ -683,10 +709,13 @@ export default class CollabVMServer { client.sendMsg(cvm.guacEncode('rename', '0', status, client.username!, client.rank.toString())); if (hadName) { this.logger.Info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`); - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); + if (announce) this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); } else { this.logger.Info(`Rename ${client.IP.address} to ${client.username}`); - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString()))); + if (announce) this.clients.forEach((c) => { + c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())); + if (client.countryCode !== null) c.sendMsg(cvm.guacEncode('flag', client.username!, client.countryCode)); + }); } } @@ -697,6 +726,14 @@ export default class CollabVMServer { return cvm.guacEncode(...arr); } + getFlagMsg() : string { + var arr = ['flag']; + for (let c of this.clients.filter(cl => cl.countryCode !== null && cl.username && (!cl.noFlag || cl.rank === Rank.Unregistered))) { + arr.push(c.username!, c.countryCode!); + } + return cvm.guacEncode(...arr); + } + getChatHistoryMsg(): string { var arr: string[] = ['chat']; this.ChatHistory.forEach((c) => arr.push(c.user, c.msg)); diff --git a/cvmts/src/GeoIPDownloader.ts b/cvmts/src/GeoIPDownloader.ts new file mode 100644 index 0000000..ef297cf --- /dev/null +++ b/cvmts/src/GeoIPDownloader.ts @@ -0,0 +1,107 @@ +import { Logger } from '@cvmts/shared'; +import { Reader, ReaderModel } from '@maxmind/geoip2-node'; +import * as fs from 'fs/promises'; +import * as path from 'node:path'; +import { Readable } from 'node:stream'; +import { ReadableStream } from 'node:stream/web'; +import { finished } from 'node:stream/promises'; +import { execa } from 'execa'; + +export default class GeoIPDownloader { + private directory: string; + private accountID: string; + private licenseKey: string; + private logger: Logger + constructor(filename: string, accountID: string, licenseKey: string) { + this.directory = filename; + if (!this.directory.endsWith('/')) this.directory += '/'; + this.accountID = accountID; + this.licenseKey = licenseKey; + this.logger = new Logger('CVMTS.GeoIPDownloader'); + } + + private genAuthHeader(): string { + return `Basic ${Buffer.from(`${this.accountID}:${this.licenseKey}`).toString('base64')}`; + } + + private async ensureDirectoryExists(): Promise { + let stat; + try { + stat = await fs.stat(this.directory); + } + catch (e) { + var error = e as NodeJS.ErrnoException; + if (error.code === 'ENOTDIR') { + this.logger.Warning('File exists at GeoIP directory path, unlinking...'); + await fs.unlink(this.directory.substring(0, this.directory.length - 1)); + } else if (error.code !== 'ENOENT') { + this.logger.Error('Failed to access GeoIP directory: {0}', error.message); + process.exit(1); + } + this.logger.Info('Creating GeoIP directory: {0}', this.directory); + await fs.mkdir(this.directory, { recursive: true }); + return; + } + } + + async getGeoIPReader(): Promise { + await this.ensureDirectoryExists(); + let dbpath = path.join(this.directory, (await this.getLatestVersion()).replace('.tar.gz', ''), 'GeoLite2-Country.mmdb'); + try { + await fs.access(dbpath, fs.constants.F_OK | fs.constants.R_OK); + this.logger.Info('Loading cached GeoIP database: {0}', dbpath); + } catch (ex) { + var error = ex as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + await this.downloadLatestDatabase(); + } else { + this.logger.Error('Failed to access GeoIP database: {0}', error.message); + process.exit(1); + } + } + return await Reader.open(dbpath); + } + + async getLatestVersion(): Promise { + let res = await fetch('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz', { + redirect: 'follow', + method: "HEAD", + headers: { + "Authorization": this.genAuthHeader() + } + }); + let disposition = res.headers.get('Content-Disposition'); + if (!disposition) { + this.logger.Error('Failed to get latest version of GeoIP database: No Content-Disposition header'); + process.exit(1); + } + let filename = disposition.match(/filename=(.*)$/); + if (!filename) { + this.logger.Error('Failed to get latest version of GeoIP database: Could not parse version from Content-Disposition header'); + process.exit(1); + } + return filename[1]; + } + + async downloadLatestDatabase(): Promise { + let filename = await this.getLatestVersion(); + this.logger.Info('Downloading latest GeoIP database: {0}', filename); + let dbpath = path.join(this.directory, filename); + let file = await fs.open(dbpath, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY); + let stream = file.createWriteStream(); + let res = await fetch('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz', { + redirect: 'follow', + headers: { + "Authorization": this.genAuthHeader() + } + }); + await finished(Readable.fromWeb(res.body as ReadableStream).pipe(stream)); + await file.close(); + this.logger.Info('Finished downloading latest GeoIP database: {0}', filename); + this.logger.Info('Extracting GeoIP database: {0}', filename); + // yeah whatever + await execa('tar', ['xzf', filename], {cwd: this.directory}); + this.logger.Info('Unlinking GeoIP tarball'); + await fs.unlink(dbpath); + } +} \ No newline at end of file diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index c049ce6..6b72adc 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -9,6 +9,12 @@ export default interface IConfig { origin: boolean; originAllowedDomains: string[]; }; + geoip: { + enabled: boolean; + directory: string; + accountID: string; + licenseKey: string; + } tcp: { enabled: boolean; host: string; diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 4b2b9d3..6f5bc2b 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -19,6 +19,9 @@ export class User { msgsSent: number; Config: IConfig; IP: IPData; + // Hide flag. Only takes effect if the user is logged in. + noFlag: boolean = false; + countryCode: string | null = null; // Rate limiters ChatRateLimit: RateLimiter; LoginRateLimit: RateLimiter; diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index fa85223..4aefa80 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -12,6 +12,7 @@ import { User } from './User.js'; import TCPServer from './TCP/TCPServer.js'; import VM from './VM.js'; import VNCVM from './VNCVM/VNCVM.js'; +import GeoIPDownloader from './GeoIPDownloader.js'; let logger = new Shared.Logger('CVMTS.Init'); @@ -44,6 +45,11 @@ async function stop() { } async function start() { + let geoipReader = null; + if (Config.geoip.enabled) { + let downloader = new GeoIPDownloader(Config.geoip.directory, Config.geoip.accountID, Config.geoip.licenseKey); + geoipReader = await downloader.getGeoIPReader(); + } // Init the auth manager if enabled let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; switch (Config.vm.type) { @@ -82,7 +88,7 @@ async function start() { await VM.Start(); // Start up the server - var CVM = new CollabVMServer(Config, VM, auth); + var CVM = new CollabVMServer(Config, VM, auth, geoipReader); var WS = new WSServer(Config); WS.on('connect', (client: User) => CVM.addUser(client)); diff --git a/yarn.lock b/yarn.lock index f0a0c55..1181e92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,6 +61,7 @@ __metadata: dependencies: "@cvmts/cvm-rs": "npm:*" "@cvmts/qemu": "npm:*" + "@maxmind/geoip2-node": "npm:^5.0.0" "@types/node": "npm:^20.12.5" "@types/ws": "npm:^8.5.5" execa: "npm:^8.0.1" @@ -351,6 +352,16 @@ __metadata: languageName: node linkType: hard +"@maxmind/geoip2-node@npm:^5.0.0": + version: 5.0.0 + resolution: "@maxmind/geoip2-node@npm:5.0.0" + dependencies: + ip6addr: "npm:^0.2.5" + maxmind: "npm:^4.2.0" + checksum: 10c0/10f6c936b45632210210750b839578c610a3ceba06aff5db2a3d9da68b51b986caa7e700c78ab2ea02524b3793e4f21daee7ecfde1dc242241291e43833b7087 + languageName: node + linkType: hard + "@mischnic/json-sourcemap@npm:^0.1.0": version: 0.1.1 resolution: "@mischnic/json-sourcemap@npm:0.1.1" @@ -1648,6 +1659,13 @@ __metadata: languageName: node linkType: hard +"assert-plus@npm:1.0.0, assert-plus@npm:^1.0.0": + version: 1.0.0 + resolution: "assert-plus@npm:1.0.0" + checksum: 10c0/b194b9d50c3a8f872ee85ab110784911e696a4d49f7ee6fc5fb63216dedbefd2c55999c70cb2eaeb4cf4a0e0338b44e9ace3627117b5bf0d42460e9132f21b91 + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1887,6 +1905,13 @@ __metadata: languageName: node linkType: hard +"core-util-is@npm:1.0.2": + version: 1.0.2 + resolution: "core-util-is@npm:1.0.2" + checksum: 10c0/980a37a93956d0de8a828ce508f9b9e3317039d68922ca79995421944146700e4aaf490a6dbfebcb1c5292a7184600c7710b957d724be1e37b8254c6bc0fe246 + languageName: node + linkType: hard + "cosmiconfig@npm:^8.0.0": version: 8.3.6 resolution: "cosmiconfig@npm:8.3.6" @@ -2206,6 +2231,20 @@ __metadata: languageName: node linkType: hard +"extsprintf@npm:1.3.0": + version: 1.3.0 + resolution: "extsprintf@npm:1.3.0" + checksum: 10c0/f75114a8388f0cbce68e277b6495dc3930db4dde1611072e4a140c24e204affd77320d004b947a132e9a3b97b8253017b2b62dce661975fb0adced707abf1ab5 + languageName: node + linkType: hard + +"extsprintf@npm:^1.2.0": + version: 1.4.1 + resolution: "extsprintf@npm:1.4.1" + checksum: 10c0/e10e2769985d0e9b6c7199b053a9957589d02e84de42832c295798cb422a025e6d4a92e0259c1fb4d07090f5bfde6b55fd9f880ac5855bd61d775f8ab75a7ab0 + languageName: node + linkType: hard + "fd-slicer@npm:~1.1.0": version: 1.1.0 resolution: "fd-slicer@npm:1.1.0" @@ -2500,6 +2539,16 @@ __metadata: languageName: node linkType: hard +"ip6addr@npm:^0.2.5": + version: 0.2.5 + resolution: "ip6addr@npm:0.2.5" + dependencies: + assert-plus: "npm:^1.0.0" + jsprim: "npm:^2.0.2" + checksum: 10c0/aaa16f844d57d2c8afca375dabb42a62e6990ea044e397bf50e18bea8b445ae0978df6fae5898c898edfd6b58cc3d3c557f405a34792739be912cd303563a916 + languageName: node + linkType: hard + "is-arrayish@npm:^0.2.1": version: 0.2.1 resolution: "is-arrayish@npm:0.2.1" @@ -2633,6 +2682,13 @@ __metadata: languageName: node linkType: hard +"json-schema@npm:0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 10c0/d4a637ec1d83544857c1c163232f3da46912e971d5bf054ba44fdb88f07d8d359a462b4aec46f2745efbc57053365608d88bc1d7b1729f7b4fc3369765639ed3 + languageName: node + linkType: hard + "json5@npm:^2.2.0, json5@npm:^2.2.1": version: 2.2.3 resolution: "json5@npm:2.2.3" @@ -2642,6 +2698,18 @@ __metadata: languageName: node linkType: hard +"jsprim@npm:^2.0.2": + version: 2.0.2 + resolution: "jsprim@npm:2.0.2" + dependencies: + assert-plus: "npm:1.0.0" + extsprintf: "npm:1.3.0" + json-schema: "npm:0.4.0" + verror: "npm:1.10.0" + checksum: 10c0/677be2d41df536c92c6d0114a492ef197084018cfbb1a3e10b1fa1aad889564b2e3a7baa6af7949cc2d73678f42368b0be165a26bd4e4de6883a30dd6a24e98d + languageName: node + linkType: hard + "just-install@npm:^2.0.1": version: 2.0.1 resolution: "just-install@npm:2.0.1" @@ -2832,6 +2900,16 @@ __metadata: languageName: node linkType: hard +"maxmind@npm:^4.2.0": + version: 4.3.20 + resolution: "maxmind@npm:4.3.20" + dependencies: + mmdb-lib: "npm:2.1.1" + tiny-lru: "npm:11.2.6" + checksum: 10c0/f21b366f7c2bf7f6853eeea52478e53dd1052ad75f6f45c270258d5ff023c5f4a85c577d6b1ebdb0a8734073e24df5ed66375cdac0c3159a8f8ae30c6535149d + languageName: node + linkType: hard + "mdn-data@npm:2.0.14": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" @@ -2965,6 +3043,13 @@ __metadata: languageName: node linkType: hard +"mmdb-lib@npm:2.1.1": + version: 2.1.1 + resolution: "mmdb-lib@npm:2.1.1" + checksum: 10c0/675817303af64c21be02e9550ce885b6ffcc6fbbeae7959a189493ccf68c6b7bac74afa00376fd7a421ff2acd8f74f44fc7fd25aeed0675fc21dbc1a9d5df9f9 + languageName: node + linkType: hard + "mnemonist@npm:^0.39.5": version: 0.39.8 resolution: "mnemonist@npm:0.39.8" @@ -3760,6 +3845,13 @@ __metadata: languageName: node linkType: hard +"tiny-lru@npm:11.2.6": + version: 11.2.6 + resolution: "tiny-lru@npm:11.2.6" + checksum: 10c0/d59b2047edae1b4b79708070463ed27ddb1daa64563b74eedaa571e555c47f8de3a7cc19171f47dc46c01f1b7283d9afd2c682dddb4832552ed747d52cd297a6 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -3876,6 +3968,17 @@ __metadata: languageName: node linkType: hard +"verror@npm:1.10.0": + version: 1.10.0 + resolution: "verror@npm:1.10.0" + dependencies: + assert-plus: "npm:^1.0.0" + core-util-is: "npm:1.0.2" + extsprintf: "npm:^1.2.0" + checksum: 10c0/37ccdf8542b5863c525128908ac80f2b476eed36a32cb944de930ca1e2e78584cc435c4b9b4c68d0fc13a47b45ff364b4be43aa74f8804f9050140f660fb660d + languageName: node + linkType: hard + "weak-lru-cache@npm:^1.2.2": version: 1.2.2 resolution: "weak-lru-cache@npm:1.2.2" From 0df56cb5a43d8a7faa43d2fdef0b72fa424ebe43 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sun, 23 Jun 2024 02:40:13 -0400 Subject: [PATCH 42/60] qemu: cleanup/fix reset --- qemu/src/QemuVM.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 6afdadc..27c6704 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -36,7 +36,6 @@ export class QemuVM extends EventEmitter { private qmpFailCount = 0; private qemuProcess: ExecaChildProcess | null = null; - private qemuRunning = false; private display: QemuDisplay; private definition: QemuVmDefinition; @@ -54,12 +53,12 @@ export class QemuVM extends EventEmitter { async Start() { // Don't start while either trying to start or starting. - if (this.state == VMState.Started || this.state == VMState.Starting) return; + //if (this.state == VMState.Started || this.state == VMState.Starting) return; + if(this.qemuProcess) 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. + // Build additional command line statements to enable qmp/vnc over unix sockets if (!this.addedAdditionalArguments) { cmd += ' -no-shutdown'; if (this.definition.snapshot) cmd += ' -snapshot'; @@ -101,8 +100,13 @@ export class QemuVM extends EventEmitter { // let code know the VM is going to reset this.emit('reset'); - // Do magic. - await this.StopQemu(); + if(this.qemuProcess !== null) { + // Do magic. + await this.StopQemu(); + } else { + // N.B we always get here when addl. arguments are added + await this.StartQemu(this.definition.command); + } } async QmpCommand(command: string, args: any | null): Promise { @@ -150,9 +154,8 @@ export class QemuVM extends EventEmitter { this.state = state; this.emit('statechange', this.state); - // reset some state when starting the vm back up - // to avoid potentional issues. - if(this.state == VMState.Starting) { + // reset QMP fail count when the VM is (re)starting or stopped + if(this.state == VMState.Stopped || this.state == VMState.Starting) { this.qmpFailCount = 0; } } @@ -177,14 +180,12 @@ export class QemuVM extends EventEmitter { this.qemuProcess.on('spawn', async () => { self.VMLog().Info("QEMU started"); - self.qemuRunning = true; await Shared.Sleep(500); await self.ConnectQmp(); }); this.qemuProcess.on('exit', async (code) => { self.VMLog().Info("QEMU process exited"); - self.qemuRunning = false; // this should be being done anways but it's very clearly not sometimes so @@ -222,7 +223,10 @@ export class QemuVM extends EventEmitter { } private async StopQemu() { - if (this.qemuRunning == true) this.qemuProcess?.kill('SIGTERM'); + if (this.qemuProcess) { + this.qemuProcess?.kill('SIGTERM'); + this.qemuProcess = null; + } } private async ConnectQmp() { @@ -291,7 +295,6 @@ export class QemuVM extends EventEmitter { private async DisconnectDisplay() { try { this.display?.Disconnect(); - //this.display = null; // disassociate with that display object. } catch (err) { // oh well lol } From 2cc2c6ddf24a0e10534904cdb7d5769a4c4c4f5a Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sun, 23 Jun 2024 02:55:05 -0400 Subject: [PATCH 43/60] cvmts: "fix" panics in cvm-rs --- cvmts/src/CollabVMServer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 538507a..82d4588 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -845,6 +845,10 @@ export default class CollabVMServer { let display = this.VM.GetDisplay(); let displaySize = display.Size(); + // TODO: actually throw an error here + if(displaySize.width == 0 && displaySize.height == 0) + return "no"; + let encoded = await JPEGEncoder.Encode(display.Buffer(), displaySize, rect); return encoded.toString('base64'); From 8369de53ba23d4b34e473455edeb27033ee627da Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sun, 23 Jun 2024 02:56:17 -0400 Subject: [PATCH 44/60] qemu: log QEMU stderr as logger messages Mostly for nicity, but also to make debugging start errors an actual possibility. --- qemu/src/QemuVM.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 27c6704..032f018 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -178,6 +178,10 @@ export class QemuVM extends EventEmitter { // Start QEMU this.qemuProcess = execaCommand(split); + this.qemuProcess.stderr?.on('data', (data) => { + self.VMLog().Error("QEMU stderr: {0}", data.toString('utf8')); + }) + this.qemuProcess.on('spawn', async () => { self.VMLog().Info("QEMU started"); await Shared.Sleep(500); From fe830afdeb83b48b919250e3634bcfa12a6d22c6 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Tue, 25 Jun 2024 19:56:28 -0400 Subject: [PATCH 45/60] add support for binary JPEG (server) --- cvmts/package.json | 1 + cvmts/src/CollabVMServer.ts | 63 +++++++++++++++---- cvmts/src/NetworkClient.ts | 1 + cvmts/src/TCP/TCPClient.ts | 19 +++++- cvmts/src/User.ts | 3 + cvmts/src/WebSocket/WSClient.ts | 15 +++++ cvmts/src/protocol/CollabVMCapabilities.ts | 8 +++ cvmts/src/protocol/CollabVMProtocolMessage.ts | 11 ++++ cvmts/src/protocol/CollabVMRectMessage.ts | 5 ++ yarn.lock | 8 +++ 10 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 cvmts/src/protocol/CollabVMCapabilities.ts create mode 100644 cvmts/src/protocol/CollabVMProtocolMessage.ts create mode 100644 cvmts/src/protocol/CollabVMRectMessage.ts diff --git a/cvmts/package.json b/cvmts/package.json index 773823f..81246fe 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -14,6 +14,7 @@ "@cvmts/cvm-rs": "*", "@cvmts/qemu": "*", "@maxmind/geoip2-node": "^5.0.0", + "@ygoe/msgpack": "^1.0.3", "execa": "^8.0.1", "mnemonist": "^0.39.5", "sharp": "^0.33.3", diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 82d4588..fa1ece8 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -15,6 +15,8 @@ import { Size, Rect, Logger } from '@cvmts/shared'; import { JPEGEncoder } from './JPEGEncoder.js'; import VM from './VM.js'; import { ReaderModel } from '@maxmind/geoip2-node'; +import msgpack from "@ygoe/msgpack"; +import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from './protocol/CollabVMProtocolMessage.js'; // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before @@ -441,6 +443,21 @@ export default class CollabVMServer { } this.sendVoteUpdate(); break; + case "cap": { + if (msgArr.length < 2) return; + // Capabilities can only be announced before connecting to the VM + if (client.connectedToNode) return; + var caps = []; + for (const cap of msgArr.slice(1)) switch(cap) { + case "bin": { + if (caps.indexOf("bin") !== -1) break; + client.Capabilities.bin = true; + caps.push("bin"); + break; + } + } + client.sendMsg(cvm.guacEncode("cap", ...caps)); + } case 'admin': if (msgArr.length < 2) return; switch (msgArr[1]) { @@ -649,11 +666,7 @@ export default class CollabVMServer { height: displaySize.height }); - this.clients.forEach(async (client) => { - client.sendMsg(cvm.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); - client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded)); - client.sendMsg(cvm.guacEncode('sync', Date.now().toString())); - }); + this.clients.forEach(async (client) => this.SendFullScreenWithSize(client)); break; } break; @@ -806,14 +819,27 @@ export default class CollabVMServer { } private async OnDisplayRectangle(rect: Rect) { - let encodedb64 = await this.MakeRectData(rect); - + let encoded = await this.MakeRectData(rect); + let encodedb64 = encoded.toString("base64"); + let bmsg : CollabVMProtocolMessage = { + type: CollabVMProtocolMessageType.rect, + rect: { + x: rect.x, + y: rect.y, + data: encoded + }, + }; + var encodedbin = msgpack.encode(bmsg); this.clients .filter((c) => c.connectedToNode || c.viewMode == 1) .forEach((c) => { if (this.screenHidden && c.rank == Rank.Unregistered) return; - c.sendMsg(cvm.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64)); - c.sendMsg(cvm.guacEncode('sync', Date.now().toString())); + if (c.Capabilities.bin) { + c.socket.sendBinary(encodedbin); + } else { + c.sendMsg(cvm.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64)); + c.sendMsg(cvm.guacEncode('sync', Date.now().toString())); + } }); } @@ -838,7 +864,20 @@ export default class CollabVMServer { }); client.sendMsg(cvm.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); - client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded)); + + if (client.Capabilities.bin) { + let msg : CollabVMProtocolMessage = { + type: CollabVMProtocolMessageType.rect, + rect: { + x: 0, + y: 0, + data: encoded + } + }; + client.socket.sendBinary(msgpack.encode(msg)); + } else { + client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded.toString("base64"))); + } } private async MakeRectData(rect: Rect) { @@ -847,11 +886,11 @@ export default class CollabVMServer { // TODO: actually throw an error here if(displaySize.width == 0 && displaySize.height == 0) - return "no"; + return Buffer.from("no") let encoded = await JPEGEncoder.Encode(display.Buffer(), displaySize, rect); - return encoded.toString('base64'); + return encoded; } async getThumbnail(): Promise { diff --git a/cvmts/src/NetworkClient.ts b/cvmts/src/NetworkClient.ts index 501da5b..600356f 100644 --- a/cvmts/src/NetworkClient.ts +++ b/cvmts/src/NetworkClient.ts @@ -1,6 +1,7 @@ export default interface NetworkClient { getIP(): string; send(msg: string): Promise; + sendBinary(msg: Uint8Array): Promise; close(): void; on(event: string, listener: (...args: any[]) => void): void; off(event: string, listener: (...args: any[]) => void): void; diff --git a/cvmts/src/TCP/TCPClient.ts b/cvmts/src/TCP/TCPClient.ts index 83e65db..f20498e 100644 --- a/cvmts/src/TCP/TCPClient.ts +++ b/cvmts/src/TCP/TCPClient.ts @@ -2,6 +2,9 @@ import EventEmitter from 'events'; import NetworkClient from '../NetworkClient.js'; import { Socket } from 'net'; +const TextHeader = 0; +const BinaryHeader = 1; + export default class TCPClient extends EventEmitter implements NetworkClient { private socket: Socket; private cache: string; @@ -34,7 +37,21 @@ export default class TCPClient extends EventEmitter implements NetworkClient { send(msg: string): Promise { return new Promise((res, rej) => { - this.socket.write(msg, (err) => { + let _msg = new Uint32Array([TextHeader, ...Buffer.from(msg, "utf-8")]); + this.socket.write(Buffer.from(_msg), (err) => { + if (err) { + rej(err); + return; + } + res(); + }); + }); + } + + sendBinary(msg: Uint8Array): Promise { + return new Promise((res, rej) => { + let _msg = new Uint32Array([BinaryHeader, msg.length, ...msg]); + this.socket.write(Buffer.from(_msg), (err) => { if (err) { rej(err); return; diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 6f5bc2b..1590f2e 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -6,6 +6,7 @@ import RateLimiter from './RateLimiter.js'; import { execa, execaCommand, ExecaSyncError } from 'execa'; import { Logger } from '@cvmts/shared'; import NetworkClient from './NetworkClient.js'; +import CollabVMCapabilities from './protocol/CollabVMCapabilities.js'; export class User { socket: NetworkClient; @@ -19,6 +20,7 @@ export class User { msgsSent: number; Config: IConfig; IP: IPData; + Capabilities: CollabVMCapabilities; // Hide flag. Only takes effect if the user is logged in. noFlag: boolean = false; countryCode: string | null = null; @@ -38,6 +40,7 @@ export class User { this.Config = config; this.socket = socket; this.msgsSent = 0; + this.Capabilities = new CollabVMCapabilities(); this.socket.on('disconnect', () => { // Unref the ip data for this connection diff --git a/cvmts/src/WebSocket/WSClient.ts b/cvmts/src/WebSocket/WSClient.ts index cbb84ed..5b4db9b 100644 --- a/cvmts/src/WebSocket/WSClient.ts +++ b/cvmts/src/WebSocket/WSClient.ts @@ -33,6 +33,7 @@ export default class WSClient extends EventEmitter implements NetworkClient { getIP(): string { return this.ip; } + send(msg: string): Promise { return new Promise((res, rej) => { if (!this.isOpen()) res(); @@ -47,6 +48,20 @@ export default class WSClient extends EventEmitter implements NetworkClient { }); } + sendBinary(msg: Uint8Array): Promise { + return new Promise((res, rej) => { + if (!this.isOpen()) res(); + + this.socket.send(msg, (err) => { + if (err) { + rej(err); + return; + } + res(); + }); + }); + } + close(): void { if (this.isOpen()) { // While this seems counterintutive, do note that the WebSocket protocol diff --git a/cvmts/src/protocol/CollabVMCapabilities.ts b/cvmts/src/protocol/CollabVMCapabilities.ts new file mode 100644 index 0000000..c94106f --- /dev/null +++ b/cvmts/src/protocol/CollabVMCapabilities.ts @@ -0,0 +1,8 @@ +export default class CollabVMCapabilities { + // Support for JPEG screen rects in binary msgpack format + bin: boolean; + + constructor() { + this.bin = false; + } +} \ No newline at end of file diff --git a/cvmts/src/protocol/CollabVMProtocolMessage.ts b/cvmts/src/protocol/CollabVMProtocolMessage.ts new file mode 100644 index 0000000..544a7e7 --- /dev/null +++ b/cvmts/src/protocol/CollabVMProtocolMessage.ts @@ -0,0 +1,11 @@ +import CollabVMRectMessage from "./CollabVMRectMessage.js"; + +export interface CollabVMProtocolMessage { + type: CollabVMProtocolMessageType; + rect?: CollabVMRectMessage | undefined; +} + +export enum CollabVMProtocolMessageType { + // JPEG Dirty Rectangle + rect = 0, +} \ No newline at end of file diff --git a/cvmts/src/protocol/CollabVMRectMessage.ts b/cvmts/src/protocol/CollabVMRectMessage.ts new file mode 100644 index 0000000..f2a8668 --- /dev/null +++ b/cvmts/src/protocol/CollabVMRectMessage.ts @@ -0,0 +1,5 @@ +export default interface CollabVMRectMessage { + x: number; + y: number; + data: Uint8Array; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 1181e92..81a1c39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -64,6 +64,7 @@ __metadata: "@maxmind/geoip2-node": "npm:^5.0.0" "@types/node": "npm:^20.12.5" "@types/ws": "npm:^8.5.5" + "@ygoe/msgpack": "npm:^1.0.3" execa: "npm:^8.0.1" mnemonist: "npm:^0.39.5" prettier: "npm:^3.2.5" @@ -1570,6 +1571,13 @@ __metadata: languageName: node linkType: hard +"@ygoe/msgpack@npm:^1.0.3": + version: 1.0.3 + resolution: "@ygoe/msgpack@npm:1.0.3" + checksum: 10c0/f4a9adc86f41b6ccc07fd7756adac28b71a38e6fb741ff4943b338f3bb6283154ae9ebf0614fa40e78cd3c09bbd79f4c5e60e136a8136a99a2b8385ba4710ed7 + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" From 3384e47e202ffd7edd72ff9aa18b3549f5533cb0 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Tue, 25 Jun 2024 20:09:34 -0400 Subject: [PATCH 46/60] use msgpackr instead of @ygoe/msgpack --- cvmts/package.json | 2 +- cvmts/src/CollabVMServer.ts | 2 +- yarn.lock | 21 +++++++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/cvmts/package.json b/cvmts/package.json index 81246fe..3602618 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -14,9 +14,9 @@ "@cvmts/cvm-rs": "*", "@cvmts/qemu": "*", "@maxmind/geoip2-node": "^5.0.0", - "@ygoe/msgpack": "^1.0.3", "execa": "^8.0.1", "mnemonist": "^0.39.5", + "msgpackr": "^1.10.2", "sharp": "^0.33.3", "toml": "^3.0.0", "ws": "^8.14.1" diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index fa1ece8..b9f832e 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -15,7 +15,7 @@ import { Size, Rect, Logger } from '@cvmts/shared'; import { JPEGEncoder } from './JPEGEncoder.js'; import VM from './VM.js'; import { ReaderModel } from '@maxmind/geoip2-node'; -import msgpack from "@ygoe/msgpack"; +import * as msgpack from 'msgpackr'; import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from './protocol/CollabVMProtocolMessage.js'; // Instead of strange hacks we can just use nodejs provided diff --git a/yarn.lock b/yarn.lock index 81a1c39..6df216a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -64,9 +64,9 @@ __metadata: "@maxmind/geoip2-node": "npm:^5.0.0" "@types/node": "npm:^20.12.5" "@types/ws": "npm:^8.5.5" - "@ygoe/msgpack": "npm:^1.0.3" execa: "npm:^8.0.1" mnemonist: "npm:^0.39.5" + msgpackr: "npm:^1.10.2" prettier: "npm:^3.2.5" sharp: "npm:^0.33.3" toml: "npm:^3.0.0" @@ -1571,13 +1571,6 @@ __metadata: languageName: node linkType: hard -"@ygoe/msgpack@npm:^1.0.3": - version: 1.0.3 - resolution: "@ygoe/msgpack@npm:1.0.3" - checksum: 10c0/f4a9adc86f41b6ccc07fd7756adac28b71a38e6fb741ff4943b338f3bb6283154ae9ebf0614fa40e78cd3c09bbd79f4c5e60e136a8136a99a2b8385ba4710ed7 - languageName: node - linkType: hard - "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -3105,6 +3098,18 @@ __metadata: languageName: node linkType: hard +"msgpackr@npm:^1.10.2": + version: 1.10.2 + resolution: "msgpackr@npm:1.10.2" + dependencies: + msgpackr-extract: "npm:^3.0.2" + dependenciesMeta: + msgpackr-extract: + optional: true + checksum: 10c0/eb0a47b3d32a3be92f7a5b1182a67e5d9bfd5668d1aed63d3df03480a06798311eea339319b442ffafe83de19d9f3c9c6ac4d9081af0c9f896599d766a53db20 + languageName: node + linkType: hard + "msgpackr@npm:^1.9.5, msgpackr@npm:^1.9.9": version: 1.10.1 resolution: "msgpackr@npm:1.10.1" From a2f450b37432ce43ebde75f2ea611085118e457c Mon Sep 17 00:00:00 2001 From: Elijah R Date: Tue, 25 Jun 2024 21:24:08 -0400 Subject: [PATCH 47/60] move binprotocol to submodule (server) --- .gitmodules | 3 +++ Justfile | 1 + collab-vm-1.2-binary-protocol | 1 + cvmts/src/CollabVMServer.ts | 2 +- cvmts/src/User.ts | 2 +- cvmts/src/protocol/CollabVMCapabilities.ts | 8 -------- cvmts/src/protocol/CollabVMProtocolMessage.ts | 11 ----------- cvmts/src/protocol/CollabVMRectMessage.ts | 5 ----- package.json | 3 ++- yarn.lock | 11 +++++++++++ 10 files changed, 20 insertions(+), 27 deletions(-) create mode 160000 collab-vm-1.2-binary-protocol delete mode 100644 cvmts/src/protocol/CollabVMCapabilities.ts delete mode 100644 cvmts/src/protocol/CollabVMProtocolMessage.ts delete mode 100644 cvmts/src/protocol/CollabVMRectMessage.ts diff --git a/.gitmodules b/.gitmodules index 24e582d..08a9b15 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "nodejs-rfb"] path = nodejs-rfb url = https://github.com/computernewb/nodejs-rfb +[submodule "collab-vm-1.2-binary-protocol"] + path = collab-vm-1.2-binary-protocol + url = https://github.com/computernewb/collab-vm-1.2-binary-protocol diff --git a/Justfile b/Justfile index 53bef25..fe019e4 100644 --- a/Justfile +++ b/Justfile @@ -3,6 +3,7 @@ all: yarn workspace @computernewb/nodejs-rfb run build yarn workspace @cvmts/shared run build yarn workspace @cvmts/qemu run build + yarn workspace @cvmts/collab-vm-1.2-binary-protocol run build yarn workspace @cvmts/cvmts run build pkg: diff --git a/collab-vm-1.2-binary-protocol b/collab-vm-1.2-binary-protocol new file mode 160000 index 0000000..cfe9acc --- /dev/null +++ b/collab-vm-1.2-binary-protocol @@ -0,0 +1 @@ +Subproject commit cfe9acc60b87ab26cf8612398c734c8caad426b8 diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index b9f832e..4e5f21f 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -16,7 +16,7 @@ import { JPEGEncoder } from './JPEGEncoder.js'; import VM from './VM.js'; import { ReaderModel } from '@maxmind/geoip2-node'; import * as msgpack from 'msgpackr'; -import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from './protocol/CollabVMProtocolMessage.js'; +import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/collab-vm-1.2-binary-protocol'; // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 1590f2e..040d74e 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -6,7 +6,7 @@ import RateLimiter from './RateLimiter.js'; import { execa, execaCommand, ExecaSyncError } from 'execa'; import { Logger } from '@cvmts/shared'; import NetworkClient from './NetworkClient.js'; -import CollabVMCapabilities from './protocol/CollabVMCapabilities.js'; +import { CollabVMCapabilities } from '@cvmts/collab-vm-1.2-binary-protocol'; export class User { socket: NetworkClient; diff --git a/cvmts/src/protocol/CollabVMCapabilities.ts b/cvmts/src/protocol/CollabVMCapabilities.ts deleted file mode 100644 index c94106f..0000000 --- a/cvmts/src/protocol/CollabVMCapabilities.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default class CollabVMCapabilities { - // Support for JPEG screen rects in binary msgpack format - bin: boolean; - - constructor() { - this.bin = false; - } -} \ No newline at end of file diff --git a/cvmts/src/protocol/CollabVMProtocolMessage.ts b/cvmts/src/protocol/CollabVMProtocolMessage.ts deleted file mode 100644 index 544a7e7..0000000 --- a/cvmts/src/protocol/CollabVMProtocolMessage.ts +++ /dev/null @@ -1,11 +0,0 @@ -import CollabVMRectMessage from "./CollabVMRectMessage.js"; - -export interface CollabVMProtocolMessage { - type: CollabVMProtocolMessageType; - rect?: CollabVMRectMessage | undefined; -} - -export enum CollabVMProtocolMessageType { - // JPEG Dirty Rectangle - rect = 0, -} \ No newline at end of file diff --git a/cvmts/src/protocol/CollabVMRectMessage.ts b/cvmts/src/protocol/CollabVMRectMessage.ts deleted file mode 100644 index f2a8668..0000000 --- a/cvmts/src/protocol/CollabVMRectMessage.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default interface CollabVMRectMessage { - x: number; - y: number; - data: Uint8Array; -} \ No newline at end of file diff --git a/package.json b/package.json index 63a02be..2d2e53e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "cvm-rs", "nodejs-rfb", "qemu", - "cvmts" + "cvmts", + "collab-vm-1.2-binary-protocol" ], "devDependencies": { "@parcel/packager-ts": "2.12.0", diff --git a/yarn.lock b/yarn.lock index 6df216a..63d43bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,6 +47,17 @@ __metadata: languageName: unknown linkType: soft +"@cvmts/collab-vm-1.2-binary-protocol@workspace:collab-vm-1.2-binary-protocol": + version: 0.0.0-use.local + resolution: "@cvmts/collab-vm-1.2-binary-protocol@workspace:collab-vm-1.2-binary-protocol" + dependencies: + "@parcel/packager-ts": "npm:2.12.0" + "@parcel/transformer-typescript-types": "npm:2.12.0" + parcel: "npm:^2.12.0" + typescript: "npm:>=3.0.0" + languageName: unknown + linkType: soft + "@cvmts/cvm-rs@npm:*, @cvmts/cvm-rs@workspace:cvm-rs": version: 0.0.0-use.local resolution: "@cvmts/cvm-rs@workspace:cvm-rs" From bee25b53812b48bb57d706e3f0f88180a2511f02 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 10 Jul 2024 18:32:05 -0400 Subject: [PATCH 48/60] Fix the ability for IPData refcount to go negative --- cvm-rs/index.d.ts | 2 +- cvmts/src/CollabVMServer.ts | 3 --- cvmts/src/IPData.ts | 4 ++-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cvm-rs/index.d.ts b/cvm-rs/index.d.ts index 6032ed6..9e664f2 100644 --- a/cvm-rs/index.d.ts +++ b/cvm-rs/index.d.ts @@ -80,5 +80,5 @@ js side api: remotingMouseEvent(client, x, y, buttons) - mouse event on the rust side a boxed client will contain an inner boxed `dyn RemotingProtocolClient` which will contain protocol specific dispatch, - upon parsing a remoting URI we will create a given client (e.g, for `vnc://` we'd make the VNC one) + upon parsing a remoting URI we will create a given client (e.g: for `vnc://` we'd make the VNC one) */ diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 4e5f21f..12f220f 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -170,9 +170,6 @@ export default class CollabVMServer { this.sendVoteUpdate(); } - // Unreference the IP data. - user.IP.Unref(); - if (this.indefiniteTurn === user) this.indefiniteTurn = null; this.clients.splice(clientIndex, 1); diff --git a/cvmts/src/IPData.ts b/cvmts/src/IPData.ts index 676b9e8..9de8fdd 100644 --- a/cvmts/src/IPData.ts +++ b/cvmts/src/IPData.ts @@ -16,7 +16,7 @@ export class IPData { // Call when a connection is closed to "release" the ip data Unref() { if (this.refCount - 1 < 0) this.refCount = 0; - this.refCount--; + else this.refCount--; } } @@ -64,7 +64,7 @@ export class IPDataManager { setInterval(() => { for (let tuple of IPDataManager.ipDatas) { if (tuple[1].refCount == 0) { - IPDataManager.logger.Info('Deleted ipdata for IP {0}', tuple[0]); + IPDataManager.logger.Info('Deleted IPData for IP {0}', tuple[0]); IPDataManager.ipDatas.delete(tuple[0]); } } From 048f08b00b70b6f06da2ae9011b292e5cf69c7c9 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 10 Jul 2024 18:43:35 -0400 Subject: [PATCH 49/60] actually make vm swich displays properly or whatever --- cvmts/src/CollabVMServer.ts | 12 ++++++++++-- qemu/src/QemuVM.ts | 10 +++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 12f220f..0607221 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -113,14 +113,22 @@ export default class CollabVMServer { this.OnDisplayResized(initSize); - vm.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size)); - vm.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect)); +// vm.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size)); +// vm.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect)); this.VM = vm; // hack but whatever (TODO: less rickity) if (config.vm.type == 'qemu') { (vm as QemuVM).on('statechange', (newState: VMState) => { + if(newState == VMState.Started) { + this.logger.Info("started!!"); + + // well aware this sucks but whatever + this.VM.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size)); + this.VM.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect)); + } + if (newState == VMState.Stopped) { this.logger.Info('stopped ?'); setTimeout(async () => { diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 032f018..34dffc8 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -37,7 +37,7 @@ export class QemuVM extends EventEmitter { private qemuProcess: ExecaChildProcess | null = null; - private display: QemuDisplay; + private display: QemuDisplay | null; private definition: QemuVmDefinition; private addedAdditionalArguments = false; @@ -137,7 +137,7 @@ export class QemuVM extends EventEmitter { } GetDisplay() { - return this.display; + return this.display!; } /// Private fun bits :) @@ -260,6 +260,7 @@ export class QemuVM extends EventEmitter { self.qmpInstance.on('close', onQmpError); self.qmpInstance.on('error', (e: Error) => { self.VMLog().Error("QMP Error: {0}", e.message); + onQmpError(); }); self.qmpInstance.on('event', async (ev) => { @@ -277,7 +278,7 @@ export class QemuVM extends EventEmitter { self.qmpInstance.on('qmp-ready', async (hadError) => { self.VMLog().Info('QMP ready'); - self.display.Connect(); + self.display?.Connect(); // QMP has been connected so the VM is ready to be considered started self.qmpFailCount = 0; @@ -299,6 +300,9 @@ export class QemuVM extends EventEmitter { private async DisconnectDisplay() { try { this.display?.Disconnect(); + + // create a new display (and gc the old one) + this.display = new QemuDisplay(this.GetVncPath()); } catch (err) { // oh well lol } From 227a17111030b8c26bb407de479f765511fdde4f Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 10 Jul 2024 22:20:12 -0400 Subject: [PATCH 50/60] qemu: Completely rewrite QMP client from scratch It sucked. The new one is using Sans I/O principles, so it does not directly do I/O or talk to a net.Socket directly (instead, QemuVM implements the layer to do I/O). This means in the future this library could actually be tested, but for now, I'm not bothering with that. There's also some other cleanups that were bothering me. --- cvmts/src/CollabVMServer.ts | 13 ++- package.json | 2 +- qemu/package.json | 4 +- qemu/src/QemuDisplay.ts | 4 + qemu/src/QemuVM.ts | 149 ++++++++++++++----------- qemu/src/QmpClient.ts | 215 +++++++++++++++++++----------------- qemu/src/index.ts | 2 + tsconfig.json | 1 + yarn.lock | 40 +------ 9 files changed, 220 insertions(+), 210 deletions(-) diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 0607221..e72fcd3 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -119,21 +119,22 @@ export default class CollabVMServer { this.VM = vm; // hack but whatever (TODO: less rickity) + let self = this; if (config.vm.type == 'qemu') { (vm as QemuVM).on('statechange', (newState: VMState) => { if(newState == VMState.Started) { - this.logger.Info("started!!"); + //self.logger.Info("started!!"); // well aware this sucks but whatever - this.VM.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size)); - this.VM.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect)); + self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size)); + self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect)); } if (newState == VMState.Stopped) { - this.logger.Info('stopped ?'); + //self.logger.Info('stopped ?'); setTimeout(async () => { - this.logger.Info('restarting VM'); - await this.VM.Start(); + self.logger.Info('restarting VM'); + await self.VM.Start(); }, kRestartTimeout); } }); diff --git a/package.json b/package.json index 2d2e53e..0cbe009 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@parcel/packager-ts": "2.12.0", "@parcel/transformer-sass": "2.12.0", "@parcel/transformer-typescript-types": "2.12.0", - "@types/node": "^20.12.5", + "@types/node": "^20.14.10", "just-install": "^2.0.1", "parcel": "^2.12.0", "prettier": "^3.2.5", diff --git a/qemu/package.json b/qemu/package.json index ce9d2e0..a65b021 100644 --- a/qemu/package.json +++ b/qemu/package.json @@ -21,11 +21,9 @@ "dependencies": { "@computernewb/nodejs-rfb": "*", "@cvmts/shared": "*", - "execa": "^8.0.1", - "split": "^1.0.1" + "execa": "^8.0.1" }, "devDependencies": { - "@types/split": "^1.0.5", "parcel": "^2.12.0" } } diff --git a/qemu/src/QemuDisplay.ts b/qemu/src/QemuDisplay.ts index efcfd47..8bfcbaa 100644 --- a/qemu/src/QemuDisplay.ts +++ b/qemu/src/QemuDisplay.ts @@ -114,6 +114,10 @@ export class QemuDisplay extends EventEmitter { Disconnect() { this.vncShouldReconnect = false; this.displayVnc.disconnect(); + + // bye bye! + this.displayVnc.removeAllListeners(); + this.removeAllListeners(); } Connected() { diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 34dffc8..9cd0b4e 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -1,10 +1,11 @@ import { execa, execaCommand, ExecaChildProcess } from 'execa'; import { EventEmitter } from 'events'; -import QmpClient from './QmpClient.js'; +import { QmpClient, IQmpClientWriter, QmpEvent } from './QmpClient.js'; import { QemuDisplay } from './QemuDisplay.js'; import { unlink } from 'node:fs/promises'; import * as Shared from '@cvmts/shared'; +import { Socket, connect } from 'net'; export enum VMState { Stopped, @@ -28,10 +29,31 @@ const kVmTmpPathBase = `/tmp`; /// the VM is forcefully stopped. const kMaxFailCount = 5; +// writer implementation for net.Socket +class SocketWriter implements IQmpClientWriter { + socket; + client; + + constructor(socket: Socket, client: QmpClient) { + this.socket = socket; + this.client = client; + + this.socket.on('data', (data) => { + this.client.feed(data); + }); + } + + writeSome(buffer: Buffer) { + this.socket.write(buffer); + } + } + + export class QemuVM extends EventEmitter { private state = VMState.Stopped; - private qmpInstance: QmpClient | null = null; + private qmpInstance: QmpClient = new QmpClient(); + private qmpSocket: Socket | null = null; private qmpConnected = false; private qmpFailCount = 0; @@ -49,6 +71,30 @@ export class QemuVM extends EventEmitter { this.logger = new Shared.Logger(`CVMTS.QEMU.QemuVM/${this.definition.id}`); this.display = new QemuDisplay(this.GetVncPath()); + + + let self = this; + + // Handle the STOP event sent when using -no-shutdown + this.qmpInstance.on(QmpEvent.Stop, async () => { + await self.qmpInstance.execute('system_reset'); + }) + + this.qmpInstance.on(QmpEvent.Reset, async () => { + await self.qmpInstance.execute('cont'); + }); + + this.qmpInstance.on('connected', async () => { + self.VMLog().Info('QMP ready'); + + this.display = new QemuDisplay(this.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); + }); } async Start() { @@ -110,7 +156,7 @@ export class QemuVM extends EventEmitter { } async QmpCommand(command: string, args: any | null): Promise { - return await this.qmpInstance?.Execute(command, args); + return await this.qmpInstance?.execute(command, args); } async MonitorCommand(command: string) { @@ -191,7 +237,6 @@ export class QemuVM extends EventEmitter { this.qemuProcess.on('exit', async (code) => { self.VMLog().Info("QEMU process exited"); - // this should be being done anways but it's very clearly not sometimes so // fuck it, let's just force it here try { @@ -209,7 +254,6 @@ export class QemuVM extends EventEmitter { await self.DisconnectDisplay(); - if (self.state != VMState.Stopping) { if (code == 0) { // Wait a bit and restart QEMU. @@ -237,62 +281,43 @@ export class QemuVM extends EventEmitter { let self = this; if (!this.qmpConnected) { - self.qmpInstance = new QmpClient(); - - let onQmpError = async () => { - if(self.qmpConnected) { - self.qmpConnected = false; - - // If we aren't stopping, then we should care QMP disconnected - if (self.state != VMState.Stopping) { - if (self.qmpFailCount++ < kMaxFailCount) { - self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); - await Shared.Sleep(500); - await self.ConnectQmp(); - } else { - self.VMLog().Error(`Reached max retries, giving up.`); - await self.Stop(); - } - } - } - }; - - self.qmpInstance.on('close', onQmpError); - self.qmpInstance.on('error', (e: Error) => { - self.VMLog().Error("QMP Error: {0}", e.message); - onQmpError(); - }); - - 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()); + this.qmpSocket = connect(this.GetQmpPath()); + + let onQmpClose = async () => { + if(self.qmpConnected) { + self.qmpConnected = false; + self.qmpSocket = null; + + // If we aren't stopping, then we should care QMP disconnected + if (self.state != VMState.Stopping) { + if (self.qmpFailCount++ < kMaxFailCount) { + self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); + await Shared.Sleep(500); + await self.ConnectQmp(); + } else { + self.VMLog().Error(`Reached max retries, giving up.`); + await self.Stop(); + } + } + } + }; + + this.qmpSocket.on('close', onQmpClose); + + this.qmpSocket.on('error', (e: Error) => { + self.VMLog().Error("QMP Error: {0}", e.message); + }); + + // Setup the QMP client. + let writer = new SocketWriter(this.qmpSocket, this.qmpInstance); + this.qmpInstance.reset(); + this.qmpInstance.setWriter(writer); } catch (err) { // just try again - await Shared.Sleep(500); - await this.ConnectQmp(); + //await Shared.Sleep(500); + //await this.ConnectQmp(); } } } @@ -300,9 +325,7 @@ export class QemuVM extends EventEmitter { private async DisconnectDisplay() { try { this.display?.Disconnect(); - - // create a new display (and gc the old one) - this.display = new QemuDisplay(this.GetVncPath()); + this.display = null; } catch (err) { // oh well lol } @@ -310,11 +333,11 @@ export class QemuVM extends EventEmitter { private async DisconnectQmp() { if (this.qmpConnected) return; - if (this.qmpInstance == null) return; + if (this.qmpSocket == null) return; this.qmpConnected = false; - this.qmpInstance.end(); - this.qmpInstance = null; + this.qmpSocket?.end(); + try { await unlink(this.GetQmpPath()); } catch (err) {} diff --git a/qemu/src/QmpClient.ts b/qemu/src/QmpClient.ts index fe93d68..eaf7c7c 100644 --- a/qemu/src/QmpClient.ts +++ b/qemu/src/QmpClient.ts @@ -1,130 +1,139 @@ -// 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 { EventEmitter } from "node:events"; -import split from 'split'; +enum QmpClientState { + Handshaking, + Connected +} -import { Socket } from 'net'; +function qmpStringify(obj: any) { + return JSON.stringify(obj) + '\r\n'; +} -export type QmpCallback = (err: Error | null, res: any | null) => void; +// this writer interface is used to poll back to a higher level +// I/O layer that we want to write some data. +export interface IQmpClientWriter { + writeSome(data: Buffer) : void; +} -type QmpCommandEntry = { - callback: QmpCallback | null; - id: number; +export type QmpClientCallback = (err: Error | null, res: any | null) => void; + +type QmpClientCallbackEntry = { + id: number, + callback: QmpClientCallback | null }; -// 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 enum QmpEvent { + BlockIOError = 'BLOCK_IO_ERROR', + Reset = 'RESET', + Resume = 'RESUME', + RtcChange = 'RTC_CHANGE', + Shutdown = 'SHUTDOWN', + Stop = 'STOP', + VncConnected = 'VNC_CONNECTED', + VncDisconnected = 'VNC_DISCONNECTED', + VncInitalized = 'VNC_INITALIZED', + Watchdog = 'WATCHDOG' +}; -export default class QmpClient extends Socket { - public qmpHandshakeData: any; - private commandEntries: QmpCommandEntry[] = []; - private lastID = 0; - constructor() { - super(); +// A QMP client +export class QmpClient extends EventEmitter { + private state = QmpClientState.Handshaking; + private capabilities = ""; + private writer: IQmpClientWriter | null = null; - this.assignHandlers(); - } + private lastID = 0; + private callbacks = new Array(); - private ExecuteSync(command: string, args: any | null, callback: QmpCallback | null) { - let cmd: QmpCommandEntry = { - callback: callback, - id: ++this.lastID - }; + constructor() { + super(); + } - let qmpOut: any = { - execute: command, - id: cmd.id - }; + setWriter(writer: IQmpClientWriter) { + this.writer = writer; + } - if (args) qmpOut['arguments'] = args; + feed(data: Buffer) : void { + let str = data.toString(); - // Add stuff - this.commandEntries.push(cmd); - this.write(JSON.stringify(qmpOut)); - } + /* I don't think this is needed but if it is i'm keeping this for now + if(!str.endsWith('\r\n')) { + console.log("incomplete message!"); + return; + } + */ - // TODO: Make this function a bit more ergonomic? - async Execute(command: string, args: any | null = null): Promise { - return new Promise((res, rej) => { - this.ExecuteSync(command, args, (err, result) => { - if (err) rej(err); - res(result); - }); - }); - } + let obj = JSON.parse(str); - private Handshake(callback: () => void) { - this.write( - JSON.stringify({ - execute: 'qmp_capabilities' - }) - ); + switch(this.state) { + case QmpClientState.Handshaking: + if(obj["return"] != undefined) { + this.state = QmpClientState.Connected; + this.emit('connected'); + return; + } - this.once('data', (data) => { - // Once QEMU replies to us, the handshake is done. - // We do not negotiate anything special. - callback(); - }); - } + let capabilities = qmpStringify({ + execute: "qmp_capabilities" + }); - // this can probably be made async - private assignHandlers() { - let self = this; + this.writer?.writeSome(Buffer.from(capabilities, 'utf8')); + break; - this.on('connect', () => { - // this should be more correct? - 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(); + case QmpClientState.Connected: + if(obj["return"] != undefined || obj['error'] != undefined) { + if(obj['id'] == null) + return; - 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 cb = this.callbacks.find((v) => v.id == obj['id']); + if(cb == undefined) + return; - let callbackEntry = this.commandEntries.find((entry) => entry.id === json.id); - let error: Error | null = json.error ? new Error(json.error.desc) : null; + let error: Error | null = obj.error ? new Error(obj.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(cb.callback) + cb.callback(error, obj.return); - if (callbackEntry?.callback) callbackEntry.callback(error, json.return); + this.callbacks.slice(this.callbacks.indexOf(cb)); + } else if (obj['event']) { + this.emit(obj.event, { + timestamp: obj.timestamp, + data: obj.data + }); + } + break; + } + } - // 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'); - }); - }); - }); + executeSync(command: string, args: any | undefined, callback: QmpClientCallback | null) { + let entry = { + callback: callback, + id: ++this.lastID + }; - this.on('close', () => { - this.end(); - }); - } + let qmpOut: any = { + execute: command, + id: entry.id + }; - Connect(host: string, port: number) { - super.connect(port, host); - } + if(args !== undefined) + qmpOut['arguments'] = args; - ConnectUNIX(path: string) { - super.connect(path); - } + this.callbacks.push(entry); + this.writer?.writeSome(Buffer.from(qmpStringify(qmpOut), 'utf8')); + } + + async execute(command: string, args: any | undefined = undefined) : Promise { + return new Promise((res, rej) => { + this.executeSync(command, args, (err, result) => { + if(err) + rej(err); + res(result); + }); + }); + } + + reset() { + this.state = QmpClientState.Handshaking; + } } diff --git a/qemu/src/index.ts b/qemu/src/index.ts index 274f400..a3deb21 100644 --- a/qemu/src/index.ts +++ b/qemu/src/index.ts @@ -1,3 +1,5 @@ +/// + export * from './QemuDisplay.js'; export * from './QemuUtil.js'; export * from './QemuVM.js'; diff --git a/tsconfig.json b/tsconfig.json index 1cad5cb..1579da8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "Node", + "types": ["node"], "allowSyntheticDefaultImports": true, "strict": true, } diff --git a/yarn.lock b/yarn.lock index 63d43bd..2dc7fb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -92,10 +92,8 @@ __metadata: dependencies: "@computernewb/nodejs-rfb": "npm:*" "@cvmts/shared": "npm:*" - "@types/split": "npm:^1.0.5" execa: "npm:^8.0.1" parcel: "npm:^2.12.0" - split: "npm:^1.0.1" languageName: unknown linkType: soft @@ -1545,22 +1543,12 @@ __metadata: languageName: node linkType: hard -"@types/split@npm:^1.0.5": - version: 1.0.5 - resolution: "@types/split@npm:1.0.5" +"@types/node@npm:^20.14.10": + version: 20.14.10 + resolution: "@types/node@npm:20.14.10" dependencies: - "@types/node": "npm:*" - "@types/through": "npm:*" - checksum: 10c0/eb187a3b07e5064928e49bffd5c45ad1f1109135fee52344bb7623cdb55e2ebb16bd6ca009a30a0a6e2b262f7ebb7bf18030ff873819e80fafd4cbb51dba1a74 - languageName: node - linkType: hard - -"@types/through@npm:*": - version: 0.0.33 - resolution: "@types/through@npm:0.0.33" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/6a8edd7f40cd7e197318e86310a40e568cddd380609dde59b30d5cc6c5f8276ddc698905eac4b3b429eb39f2e8ee326bc20dc6e95a2cdc41c4d3fc9a1ebd4929 + undici-types: "npm:~5.26.4" + checksum: 10c0/0b06cff14365c2d0085dc16cc8cbea5c40ec09cfc1fea966be9eeecf35562760bfde8f88e86de6edfaf394501236e229d9c1084fad04fb4dec472ae245d8ae69 languageName: node linkType: hard @@ -1998,7 +1986,7 @@ __metadata: "@parcel/packager-ts": "npm:2.12.0" "@parcel/transformer-sass": "npm:2.12.0" "@parcel/transformer-typescript-types": "npm:2.12.0" - "@types/node": "npm:^20.12.5" + "@types/node": "npm:^20.14.10" just-install: "npm:^2.0.1" parcel: "npm:^2.12.0" prettier: "npm:^3.2.5" @@ -3713,15 +3701,6 @@ __metadata: languageName: node linkType: hard -"split@npm:^1.0.1": - version: 1.0.1 - resolution: "split@npm:1.0.1" - dependencies: - through: "npm:2" - checksum: 10c0/7f489e7ed5ff8a2e43295f30a5197ffcb2d6202c9cf99357f9690d645b19c812bccf0be3ff336fea5054cda17ac96b91d67147d95dbfc31fbb5804c61962af85 - languageName: node - linkType: hard - "sprintf-js@npm:^1.1.3": version: 1.1.3 resolution: "sprintf-js@npm:1.1.3" @@ -3855,13 +3834,6 @@ __metadata: languageName: node linkType: hard -"through@npm:2": - version: 2.3.8 - resolution: "through@npm:2.3.8" - checksum: 10c0/4b09f3774099de0d4df26d95c5821a62faee32c7e96fb1f4ebd54a2d7c11c57fe88b0a0d49cf375de5fee5ae6bf4eb56dbbf29d07366864e2ee805349970d3cc - languageName: node - linkType: hard - "timsort@npm:^0.3.0": version: 0.3.0 resolution: "timsort@npm:0.3.0" From 0b59c6d8be6e26b2dfdfb6c24f804aaf36b821b9 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Thu, 11 Jul 2024 02:29:26 -0400 Subject: [PATCH 51/60] QMP client now buffers lines properly --- qemu/src/QmpClient.ts | 218 +++++++++++++++++++++++------------------- 1 file changed, 122 insertions(+), 96 deletions(-) diff --git a/qemu/src/QmpClient.ts b/qemu/src/QmpClient.ts index eaf7c7c..29ff0ac 100644 --- a/qemu/src/QmpClient.ts +++ b/qemu/src/QmpClient.ts @@ -1,139 +1,165 @@ -import { EventEmitter } from "node:events"; +import { EventEmitter } from 'node:events'; enum QmpClientState { - Handshaking, - Connected + Handshaking, + Connected } function qmpStringify(obj: any) { - return JSON.stringify(obj) + '\r\n'; + return JSON.stringify(obj) + '\r\n'; } // this writer interface is used to poll back to a higher level // I/O layer that we want to write some data. export interface IQmpClientWriter { - writeSome(data: Buffer) : void; + writeSome(data: Buffer): void; } export type QmpClientCallback = (err: Error | null, res: any | null) => void; type QmpClientCallbackEntry = { - id: number, - callback: QmpClientCallback | null + id: number; + callback: QmpClientCallback | null; }; export enum QmpEvent { - BlockIOError = 'BLOCK_IO_ERROR', - Reset = 'RESET', - Resume = 'RESUME', - RtcChange = 'RTC_CHANGE', - Shutdown = 'SHUTDOWN', - Stop = 'STOP', - VncConnected = 'VNC_CONNECTED', - VncDisconnected = 'VNC_DISCONNECTED', - VncInitalized = 'VNC_INITALIZED', - Watchdog = 'WATCHDOG' -}; + BlockIOError = 'BLOCK_IO_ERROR', + Reset = 'RESET', + Resume = 'RESUME', + RtcChange = 'RTC_CHANGE', + Shutdown = 'SHUTDOWN', + Stop = 'STOP', + VncConnected = 'VNC_CONNECTED', + VncDisconnected = 'VNC_DISCONNECTED', + VncInitalized = 'VNC_INITALIZED', + Watchdog = 'WATCHDOG' +} +class LineStream extends EventEmitter { + // The given line seperator for the stream + lineSeperator = '\r\n'; + buffer = ''; + + constructor() { + super(); + } + + push(data: Buffer) { + this.buffer += data.toString('utf-8'); + + let lines = this.buffer.split(this.lineSeperator); + if (lines.length > 1) { + this.buffer = lines.pop()!; + lines = lines.filter((l) => !!l); + + //console.log(lines) + lines.forEach(l => this.emit('line', l)); + } + return []; + } + + reset() { + this.buffer = ''; + } +} // A QMP client export class QmpClient extends EventEmitter { - private state = QmpClientState.Handshaking; - private capabilities = ""; - private writer: IQmpClientWriter | null = null; + private state = QmpClientState.Handshaking; + private writer: IQmpClientWriter | null = null; - private lastID = 0; - private callbacks = new Array(); + private lastID = 0; + private callbacks = new Array(); - constructor() { - super(); - } + private lineStream = new LineStream(); - setWriter(writer: IQmpClientWriter) { - this.writer = writer; - } + constructor() { + super(); - feed(data: Buffer) : void { - let str = data.toString(); + let self = this; + this.lineStream.on('line', (line: string) => { + self.handleQmpLine(line); + }); + } - /* I don't think this is needed but if it is i'm keeping this for now - if(!str.endsWith('\r\n')) { - console.log("incomplete message!"); - return; - } - */ + setWriter(writer: IQmpClientWriter) { + this.writer = writer; + } - let obj = JSON.parse(str); + feed(data: Buffer): void { + // Forward to the line stream. It will generate 'line' events + // as it is able to split out lines automatically. + this.lineStream.push(data); + } - switch(this.state) { - case QmpClientState.Handshaking: - if(obj["return"] != undefined) { - this.state = QmpClientState.Connected; - this.emit('connected'); - return; - } + private handleQmpLine(line: string) { + let obj = JSON.parse(line); - let capabilities = qmpStringify({ - execute: "qmp_capabilities" - }); + switch (this.state) { + case QmpClientState.Handshaking: + if (obj['return'] != undefined) { + this.state = QmpClientState.Connected; + this.emit('connected'); + return; + } - this.writer?.writeSome(Buffer.from(capabilities, 'utf8')); - break; + let capabilities = qmpStringify({ + execute: 'qmp_capabilities' + }); - case QmpClientState.Connected: - if(obj["return"] != undefined || obj['error'] != undefined) { - if(obj['id'] == null) - return; + this.writer?.writeSome(Buffer.from(capabilities, 'utf8')); + break; - let cb = this.callbacks.find((v) => v.id == obj['id']); - if(cb == undefined) - return; + case QmpClientState.Connected: + if (obj['return'] != undefined || obj['error'] != undefined) { + if (obj['id'] == null) return; - let error: Error | null = obj.error ? new Error(obj.error.desc) : null; + let cb = this.callbacks.find((v) => v.id == obj['id']); + if (cb == undefined) return; - if(cb.callback) - cb.callback(error, obj.return); + let error: Error | null = obj.error ? new Error(obj.error.desc) : null; - this.callbacks.slice(this.callbacks.indexOf(cb)); - } else if (obj['event']) { - this.emit(obj.event, { - timestamp: obj.timestamp, - data: obj.data - }); - } - break; - } - } + if (cb.callback) cb.callback(error, obj.return); - executeSync(command: string, args: any | undefined, callback: QmpClientCallback | null) { - let entry = { - callback: callback, - id: ++this.lastID - }; + this.callbacks.slice(this.callbacks.indexOf(cb)); + } else if (obj['event']) { + this.emit(obj.event, { + timestamp: obj.timestamp, + data: obj.data + }); + } + break; + } + } - let qmpOut: any = { - execute: command, - id: entry.id - }; + executeSync(command: string, args: any | undefined, callback: QmpClientCallback | null) { + let entry = { + callback: callback, + id: ++this.lastID + }; - if(args !== undefined) - qmpOut['arguments'] = args; + let qmpOut: any = { + execute: command, + id: entry.id + }; - this.callbacks.push(entry); - this.writer?.writeSome(Buffer.from(qmpStringify(qmpOut), 'utf8')); - } + if (args !== undefined) qmpOut['arguments'] = args; - async execute(command: string, args: any | undefined = undefined) : Promise { - return new Promise((res, rej) => { - this.executeSync(command, args, (err, result) => { - if(err) - rej(err); - res(result); - }); - }); - } + this.callbacks.push(entry); + this.writer?.writeSome(Buffer.from(qmpStringify(qmpOut), 'utf8')); + } - reset() { - this.state = QmpClientState.Handshaking; - } + async execute(command: string, args: any | undefined = undefined): Promise { + return new Promise((res, rej) => { + this.executeSync(command, args, (err, result) => { + if (err) rej(err); + res(result); + }); + }); + } + + reset() { + this.lineStream.reset(); + this.state = QmpClientState.Handshaking; + } } From 25b32b23b7bbdc0b85c5c7779263fee05f8f2bf3 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Thu, 11 Jul 2024 03:24:22 -0400 Subject: [PATCH 52/60] qemu: More fun refactoring The QMP client has been refactored slightly, mostly just to clean up its edges slightly. QemuVM however has seen a big refactor, especially connecting to QMP. Flattening out this logic is something I should have done a long time ago. This seemingly has finally hammered out the bugs, although time will tell. --- cvmts/src/CollabVMServer.ts | 18 +--- qemu/src/QemuVM.ts | 189 ++++++++++++++++-------------------- qemu/src/QmpClient.ts | 29 +++--- 3 files changed, 102 insertions(+), 134 deletions(-) diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index e72fcd3..d9a2aa2 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -113,9 +113,6 @@ export default class CollabVMServer { this.OnDisplayResized(initSize); -// vm.GetDisplay().on('resize', (size: Size) => this.OnDisplayResized(size)); -// vm.GetDisplay().on('rect', (rect: Rect) => this.OnDisplayRectangle(rect)); - this.VM = vm; // hack but whatever (TODO: less rickity) @@ -123,15 +120,12 @@ export default class CollabVMServer { if (config.vm.type == 'qemu') { (vm as QemuVM).on('statechange', (newState: VMState) => { if(newState == VMState.Started) { - //self.logger.Info("started!!"); - // well aware this sucks but whatever self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size)); self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect)); } if (newState == VMState.Stopped) { - //self.logger.Info('stopped ?'); setTimeout(async () => { self.logger.Info('restarting VM'); await self.VM.Start(); @@ -397,14 +391,14 @@ export default class CollabVMServer { var y = parseInt(msgArr[2]); var mask = parseInt(msgArr[3]); if (x === undefined || y === undefined || mask === undefined) return; - this.VM.GetDisplay()!.MouseEvent(x, y, mask); + this.VM.GetDisplay()?.MouseEvent(x, y, mask); break; case 'key': if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; var keysym = parseInt(msgArr[1]); var down = parseInt(msgArr[2]); if (keysym === undefined || (down !== 0 && down !== 1)) return; - this.VM.GetDisplay()!.KeyboardEvent(keysym, down === 1 ? true : false); + this.VM.GetDisplay()?.KeyboardEvent(keysym, down === 1 ? true : false); break; case 'vote': if (!this.VM.SnapshotsSupported()) return; @@ -503,14 +497,8 @@ export default class CollabVMServer { 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(cvm.guacEncode("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.MonitorCommand(msgArr[3]); + let output = await this.VM.MonitorCommand(msgArr[3]); client.sendMsg(cvm.guacEncode('admin', '2', String(output))); break; case '8': diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 9cd0b4e..bcdf79e 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -17,9 +17,9 @@ export enum VMState { // 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, - snapshot: boolean + id: string; + command: string; + snapshot: boolean; }; /// Temporary path base (for UNIX sockets/etc.) @@ -33,21 +33,20 @@ const kMaxFailCount = 5; class SocketWriter implements IQmpClientWriter { socket; client; - + constructor(socket: Socket, client: QmpClient) { - this.socket = socket; - this.client = client; - - this.socket.on('data', (data) => { - this.client.feed(data); - }); + this.socket = socket; + this.client = client; + + this.socket.on('data', (data) => { + this.client.feed(data); + }); } - + writeSome(buffer: Buffer) { - this.socket.write(buffer); + this.socket.write(buffer); } - } - +} export class QemuVM extends EventEmitter { private state = VMState.Stopped; @@ -72,13 +71,12 @@ export class QemuVM extends EventEmitter { this.display = new QemuDisplay(this.GetVncPath()); - let self = this; // Handle the STOP event sent when using -no-shutdown this.qmpInstance.on(QmpEvent.Stop, async () => { await self.qmpInstance.execute('system_reset'); - }) + }); this.qmpInstance.on(QmpEvent.Reset, async () => { await self.qmpInstance.execute('cont'); @@ -100,7 +98,7 @@ export class QemuVM extends EventEmitter { async Start() { // Don't start while either trying to start or starting. //if (this.state == VMState.Started || this.state == VMState.Starting) return; - if(this.qemuProcess) return; + if (this.qemuProcess) return; let cmd = this.definition.command; @@ -116,43 +114,27 @@ export class QemuVM extends EventEmitter { await this.StartQemu(cmd); } - SnapshotsSupported() : boolean { + SnapshotsSupported(): boolean { return this.definition.snapshot; } - async Reboot() : Promise { + async Reboot(): Promise { await this.MonitorCommand('system_reset'); } 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'); - - // Indicate we're stopping, so we don't - // erroneously start trying to restart everything - // we're going to tear down. + this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM'); + + // Indicate we're stopping, so we don't erroneously start trying to restart everything we're going to tear down. this.SetState(VMState.Stopping); - // Kill the QEMU process and QMP/display connections if they are running. - await this.DisconnectQmp(); - await this.DisconnectDisplay(); + // Stop the QEMU process, which will bring down everything else. 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 - this.emit('reset'); - - if(this.qemuProcess !== null) { - // Do magic. - await this.StopQemu(); - } else { - // N.B we always get here when addl. arguments are added - await this.StartQemu(this.definition.command); - } + this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM'); + await this.StopQemu(); } async QmpCommand(command: string, args: any | null): Promise { @@ -161,9 +143,11 @@ export class QemuVM extends EventEmitter { async MonitorCommand(command: string) { this.AssertState(VMState.Started, 'cannot use QemuVM#MonitorCommand on a non-started VM'); - return await this.QmpCommand('human-monitor-command', { + let result = await this.QmpCommand('human-monitor-command', { 'command-line': command }); + if (result == null) result = ''; + return result; } async ChangeRemovableMedia(deviceName: string, imagePath: string): Promise { @@ -201,7 +185,7 @@ export class QemuVM extends EventEmitter { this.emit('statechange', this.state); // reset QMP fail count when the VM is (re)starting or stopped - if(this.state == VMState.Stopped || this.state == VMState.Starting) { + if (this.state == VMState.Stopped || this.state == VMState.Starting) { this.qmpFailCount = 0; } } @@ -225,43 +209,41 @@ export class QemuVM extends EventEmitter { this.qemuProcess = execaCommand(split); this.qemuProcess.stderr?.on('data', (data) => { - self.VMLog().Error("QEMU stderr: {0}", data.toString('utf8')); - }) + self.VMLog().Error('QEMU stderr: {0}', data.toString('utf8')); + }); this.qemuProcess.on('spawn', async () => { - self.VMLog().Info("QEMU started"); - await Shared.Sleep(500); + self.VMLog().Info('QEMU started'); + await Shared.Sleep(250); await self.ConnectQmp(); }); this.qemuProcess.on('exit', async (code) => { - self.VMLog().Info("QEMU process exited"); + self.VMLog().Info('QEMU process exited'); - // this should be being done anways but it's very clearly not sometimes so - // fuck it, let's just force it here + // Disconnect from the display and QMP connections. + await self.DisconnectDisplay(); + await self.DisconnectQmp(); + + // Remove the sockets for VNC and QMP. try { await unlink(this.GetVncPath()); - } catch(_) {} + } catch (_) {} try { await unlink(this.GetQmpPath()); - } catch(_) {} - - // ? - if (self.qmpConnected) { - await self.DisconnectQmp(); - } - - await self.DisconnectDisplay(); + } catch (_) {} if (self.state != VMState.Stopping) { if (code == 0) { // Wait a bit and restart QEMU. - await Shared.Sleep(500); + await Shared.Sleep(250); 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(); + // Note that we've already tore down everything upon entry to this event handler; therefore + // we can simply set the state and move on. + this.SetState(VMState.Stopped); } } else { // Indicate we have stopped. @@ -280,46 +262,42 @@ export class QemuVM extends EventEmitter { private async ConnectQmp() { let self = this; - if (!this.qmpConnected) { - try { - await Shared.Sleep(500); - this.qmpSocket = connect(this.GetQmpPath()); - - let onQmpClose = async () => { - if(self.qmpConnected) { - self.qmpConnected = false; - self.qmpSocket = null; - - // If we aren't stopping, then we should care QMP disconnected - if (self.state != VMState.Stopping) { - if (self.qmpFailCount++ < kMaxFailCount) { - self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); - await Shared.Sleep(500); - await self.ConnectQmp(); - } else { - self.VMLog().Error(`Reached max retries, giving up.`); - await self.Stop(); - } - } - } - }; - - this.qmpSocket.on('close', onQmpClose); - - this.qmpSocket.on('error', (e: Error) => { - self.VMLog().Error("QMP Error: {0}", e.message); - }); - - // Setup the QMP client. - let writer = new SocketWriter(this.qmpSocket, this.qmpInstance); - this.qmpInstance.reset(); - this.qmpInstance.setWriter(writer); - } catch (err) { - // just try again - //await Shared.Sleep(500); - //await this.ConnectQmp(); - } + if (this.qmpConnected) { + this.VMLog().Error('Already connected to QMP!'); + return; } + + this.qmpSocket = connect(this.GetQmpPath()); + + this.qmpSocket.on('close', async () => { + self.qmpSocket?.removeAllListeners(); + + if (self.qmpConnected) { + await self.DisconnectQmp(); + + // If we aren't stopping, then we should care QMP disconnected + if (self.state != VMState.Stopping) { + if (self.qmpFailCount++ < kMaxFailCount) { + self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); + await Shared.Sleep(250); + await self.ConnectQmp(); + } else { + self.VMLog().Error(`Reached max retries, giving up.`); + await self.Stop(); + return; + } + } + } + }); + + this.qmpSocket.on('error', (e: Error) => { + self.VMLog().Error('QMP socket error: {0}', e.message); + }); + + // Setup the QMP client. + let writer = new SocketWriter(this.qmpSocket, this.qmpInstance); + this.qmpInstance.reset(); + this.qmpInstance.setWriter(writer); } private async DisconnectDisplay() { @@ -332,14 +310,11 @@ export class QemuVM extends EventEmitter { } private async DisconnectQmp() { - if (this.qmpConnected) return; - if (this.qmpSocket == null) return; - + if (!this.qmpConnected) return; this.qmpConnected = false; - this.qmpSocket?.end(); - try { - await unlink(this.GetQmpPath()); - } catch (err) {} + if (this.qmpSocket == null) return; + this.qmpSocket?.end(); + this.qmpSocket = null; } } diff --git a/qemu/src/QmpClient.ts b/qemu/src/QmpClient.ts index 29ff0ac..68d7a5f 100644 --- a/qemu/src/QmpClient.ts +++ b/qemu/src/QmpClient.ts @@ -51,11 +51,8 @@ class LineStream extends EventEmitter { if (lines.length > 1) { this.buffer = lines.pop()!; lines = lines.filter((l) => !!l); - - //console.log(lines) lines.forEach(l => this.emit('line', l)); } - return []; } reset() { @@ -98,16 +95,21 @@ export class QmpClient extends EventEmitter { switch (this.state) { case QmpClientState.Handshaking: if (obj['return'] != undefined) { + // Once we get a return from our handshake execution, + // we have exited handshake state. this.state = QmpClientState.Connected; this.emit('connected'); return; + } else if(obj['QMP'] != undefined) { + // Send a `qmp_capabilities` command, to exit handshake state. + // We do not support any of the supported extended QMP capabilities currently, + // and probably never will (due to their relative uselessness.) + let capabilities = qmpStringify({ + execute: 'qmp_capabilities' + }); + + this.writer?.writeSome(Buffer.from(capabilities, 'utf8')); } - - let capabilities = qmpStringify({ - execute: 'qmp_capabilities' - }); - - this.writer?.writeSome(Buffer.from(capabilities, 'utf8')); break; case QmpClientState.Connected: @@ -119,7 +121,7 @@ export class QmpClient extends EventEmitter { let error: Error | null = obj.error ? new Error(obj.error.desc) : null; - if (cb.callback) cb.callback(error, obj.return); + if (cb.callback) cb.callback(error, obj.return || null); this.callbacks.slice(this.callbacks.indexOf(cb)); } else if (obj['event']) { @@ -132,7 +134,8 @@ export class QmpClient extends EventEmitter { } } - executeSync(command: string, args: any | undefined, callback: QmpClientCallback | null) { + // Executes a QMP command, using a user-provided callback for completion notification + executeCallback(command: string, args: any | undefined, callback: QmpClientCallback | null) { let entry = { callback: callback, id: ++this.lastID @@ -149,9 +152,10 @@ export class QmpClient extends EventEmitter { this.writer?.writeSome(Buffer.from(qmpStringify(qmpOut), 'utf8')); } + // Executes a QMP command asynchronously. async execute(command: string, args: any | undefined = undefined): Promise { return new Promise((res, rej) => { - this.executeSync(command, args, (err, result) => { + this.executeCallback(command, args, (err, result) => { if (err) rej(err); res(result); }); @@ -159,6 +163,7 @@ export class QmpClient extends EventEmitter { } reset() { + // Reset the line stream so it doesn't go awry this.lineStream.reset(); this.state = QmpClientState.Handshaking; } From 7423c6295759551dd972498b49a1553330a148c5 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Thu, 11 Jul 2024 03:33:19 -0400 Subject: [PATCH 53/60] re-add magic timeouts also remove a problematic line that broke more than it helped --- qemu/src/QemuVM.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index bcdf79e..d0553fb 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -214,7 +214,7 @@ export class QemuVM extends EventEmitter { this.qemuProcess.on('spawn', async () => { self.VMLog().Info('QEMU started'); - await Shared.Sleep(250); + await Shared.Sleep(500); await self.ConnectQmp(); }); @@ -237,7 +237,7 @@ export class QemuVM extends EventEmitter { if (self.state != VMState.Stopping) { if (code == 0) { // Wait a bit and restart QEMU. - await Shared.Sleep(250); + 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.'); @@ -267,11 +267,10 @@ export class QemuVM extends EventEmitter { return; } + await Shared.Sleep(500); this.qmpSocket = connect(this.GetQmpPath()); this.qmpSocket.on('close', async () => { - self.qmpSocket?.removeAllListeners(); - if (self.qmpConnected) { await self.DisconnectQmp(); @@ -279,7 +278,7 @@ export class QemuVM extends EventEmitter { if (self.state != VMState.Stopping) { if (self.qmpFailCount++ < kMaxFailCount) { self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); - await Shared.Sleep(250); + await Shared.Sleep(500); await self.ConnectQmp(); } else { self.VMLog().Error(`Reached max retries, giving up.`); From ddf4d9751177dbb849892e0a071188cb4444844b Mon Sep 17 00:00:00 2001 From: modeco80 Date: Thu, 11 Jul 2024 20:33:50 -0400 Subject: [PATCH 54/60] qemu: more refactoring qmp client is now much more robust (and doesn't fight over itself as much). this should hopefully completely eliminate the case where display connects but qmp is half connected. i also forgot QemuDisplay actually emits an event on connection, so we can just use that to wait for when the display connects. which allows us to set the started state there instead of praying when the qmp client connects that we are connected to the display roughly at the same time. i also gated some stuff to require vm state in the server. this is a bit rickity, but does seem to work. --- cvmts/src/CollabVMServer.ts | 80 ++++++++++++++++++++++--------------- cvmts/src/VM.ts | 2 + cvmts/src/VNCVM/VNCVM.ts | 7 +++- qemu/src/QemuVM.ts | 77 ++++++++++++++++++----------------- 4 files changed, 96 insertions(+), 70 deletions(-) diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index d9a2aa2..d972b32 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -106,12 +106,11 @@ export default class CollabVMServer { this.indefiniteTurn = null; this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); - let initSize = vm.GetDisplay().Size() || { + // No size initially, since the + this.OnDisplayResized({ width: 0, height: 0 - }; - - this.OnDisplayResized(initSize); + }); this.VM = vm; @@ -119,7 +118,8 @@ export default class CollabVMServer { let self = this; if (config.vm.type == 'qemu') { (vm as QemuVM).on('statechange', (newState: VMState) => { - if(newState == VMState.Started) { + if (newState == VMState.Started) { + self.logger.Info("VM started"); // well aware this sucks but whatever self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size)); self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect)); @@ -214,12 +214,11 @@ export default class CollabVMServer { // Set username if (client.countryCode !== null && client.noFlag) { // privacy - for (let cl of this.clients.filter(c => c !== client)) { + for (let cl of this.clients.filter((c) => c !== client)) { cl.sendMsg(cvm.guacEncode('remuser', '1', client.username!)); } this.renameUser(client, res.username, false); - } - else this.renameUser(client, res.username, true); + } else this.renameUser(client, res.username, true); // Set rank client.rank = res.rank; if (client.rank === Rank.Admin) { @@ -241,18 +240,28 @@ export default class CollabVMServer { } break; case 'noflag': { - if (client.connectedToNode) // too late + if (client.connectedToNode) + // too late return; client.noFlag = true; } case 'list': - client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); + if (this.VM.GetState() == VMState.Started) { + client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); + } break; case 'connect': if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) { client.sendMsg(cvm.guacEncode('connect', '0')); return; } + + // Don't allow connecting if the VM hasn't started + if (this.VM.GetState() != VMState.Started) { + client.sendMsg(cvm.guacEncode('connect', '0')); + return; + } + client.connectedToNode = true; client.sendMsg(cvm.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); @@ -275,6 +284,12 @@ export default class CollabVMServer { return; } + // similar rationale to 'connect' + if (this.VM.GetState() != VMState.Started) { + client.sendMsg(cvm.guacEncode('connect', '0')); + return; + } + switch (msgArr[2]) { case '0': client.viewMode = 0; @@ -443,20 +458,21 @@ export default class CollabVMServer { } this.sendVoteUpdate(); break; - case "cap": { + case 'cap': { if (msgArr.length < 2) return; // Capabilities can only be announced before connecting to the VM if (client.connectedToNode) return; var caps = []; - for (const cap of msgArr.slice(1)) switch(cap) { - case "bin": { - if (caps.indexOf("bin") !== -1) break; - client.Capabilities.bin = true; - caps.push("bin"); - break; + for (const cap of msgArr.slice(1)) + switch (cap) { + case 'bin': { + if (caps.indexOf('bin') !== -1) break; + client.Capabilities.bin = true; + caps.push('bin'); + break; + } } - } - client.sendMsg(cvm.guacEncode("cap", ...caps)); + client.sendMsg(cvm.guacEncode('cap', ...caps)); } case 'admin': if (msgArr.length < 2) return; @@ -719,10 +735,11 @@ export default class CollabVMServer { if (announce) this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); } else { this.logger.Info(`Rename ${client.IP.address} to ${client.username}`); - if (announce) this.clients.forEach((c) => { - c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())); - if (client.countryCode !== null) c.sendMsg(cvm.guacEncode('flag', client.username!, client.countryCode)); - }); + if (announce) + this.clients.forEach((c) => { + c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())); + if (client.countryCode !== null) c.sendMsg(cvm.guacEncode('flag', client.username!, client.countryCode)); + }); } } @@ -733,9 +750,9 @@ export default class CollabVMServer { return cvm.guacEncode(...arr); } - getFlagMsg() : string { + getFlagMsg(): string { var arr = ['flag']; - for (let c of this.clients.filter(cl => cl.countryCode !== null && cl.username && (!cl.noFlag || cl.rank === Rank.Unregistered))) { + for (let c of this.clients.filter((cl) => cl.countryCode !== null && cl.username && (!cl.noFlag || cl.rank === Rank.Unregistered))) { arr.push(c.username!, c.countryCode!); } return cvm.guacEncode(...arr); @@ -814,14 +831,14 @@ export default class CollabVMServer { private async OnDisplayRectangle(rect: Rect) { let encoded = await this.MakeRectData(rect); - let encodedb64 = encoded.toString("base64"); - let bmsg : CollabVMProtocolMessage = { + let encodedb64 = encoded.toString('base64'); + let bmsg: CollabVMProtocolMessage = { type: CollabVMProtocolMessageType.rect, rect: { x: rect.x, y: rect.y, data: encoded - }, + } }; var encodedbin = msgpack.encode(bmsg); this.clients @@ -860,7 +877,7 @@ export default class CollabVMServer { client.sendMsg(cvm.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString())); if (client.Capabilities.bin) { - let msg : CollabVMProtocolMessage = { + let msg: CollabVMProtocolMessage = { type: CollabVMProtocolMessageType.rect, rect: { x: 0, @@ -870,7 +887,7 @@ export default class CollabVMServer { }; client.socket.sendBinary(msgpack.encode(msg)); } else { - client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded.toString("base64"))); + client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded.toString('base64'))); } } @@ -879,8 +896,7 @@ export default class CollabVMServer { let displaySize = display.Size(); // TODO: actually throw an error here - if(displaySize.width == 0 && displaySize.height == 0) - return Buffer.from("no") + if (displaySize.width == 0 && displaySize.height == 0) return Buffer.from('no'); let encoded = await JPEGEncoder.Encode(display.Buffer(), displaySize, rect); diff --git a/cvmts/src/VM.ts b/cvmts/src/VM.ts index dbdf4bb..d2a8657 100644 --- a/cvmts/src/VM.ts +++ b/cvmts/src/VM.ts @@ -1,3 +1,4 @@ +import { VMState } from '@cvmts/qemu'; import VMDisplay from './VMDisplay.js'; export default interface VM { @@ -7,5 +8,6 @@ export default interface VM { Reset(): Promise; MonitorCommand(command: string): Promise; GetDisplay(): VMDisplay; + GetState(): VMState; SnapshotsSupported(): boolean; } diff --git a/cvmts/src/VNCVM/VNCVM.ts b/cvmts/src/VNCVM/VNCVM.ts index c89ec61..6cf2976 100644 --- a/cvmts/src/VNCVM/VNCVM.ts +++ b/cvmts/src/VNCVM/VNCVM.ts @@ -4,7 +4,7 @@ import VM from '../VM'; import VMDisplay from '../VMDisplay'; import { Clamp, Logger, Rect, Size, Sleep } from '@cvmts/shared'; import { VncClient } from '@computernewb/nodejs-rfb'; -import { BatchRects } from '@cvmts/qemu'; +import { BatchRects, VMState } from '@cvmts/qemu'; import { execaCommand } from 'execa'; export default class VNCVM extends EventEmitter implements VM, VMDisplay { @@ -125,6 +125,11 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { return this; } + GetState(): VMState { + // for now! + return VMState.Started; + } + SnapshotsSupported(): boolean { return true; } diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index d0553fb..ab99930 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -51,14 +51,14 @@ class SocketWriter implements IQmpClientWriter { export class QemuVM extends EventEmitter { private state = VMState.Stopped; + // QMP stuff. private qmpInstance: QmpClient = new QmpClient(); private qmpSocket: Socket | null = null; - private qmpConnected = false; private qmpFailCount = 0; private qemuProcess: ExecaChildProcess | null = null; - private display: QemuDisplay | null; + private display: QemuDisplay | null = null; private definition: QemuVmDefinition; private addedAdditionalArguments = false; @@ -69,8 +69,6 @@ export class QemuVM extends EventEmitter { this.definition = def; this.logger = new Shared.Logger(`CVMTS.QEMU.QemuVM/${this.definition.id}`); - this.display = new QemuDisplay(this.GetVncPath()); - let self = this; // Handle the STOP event sent when using -no-shutdown @@ -86,12 +84,16 @@ export class QemuVM extends EventEmitter { self.VMLog().Info('QMP ready'); this.display = new QemuDisplay(this.GetVncPath()); - self.display?.Connect(); - // QMP has been connected so the VM is ready to be considered started + self.display?.on('connected', () => { + // The VM can now be considered started + self.VMLog().Info("Display connected"); + self.SetState(VMState.Started); + }) + + // now that we've connected to VNC, connect to the display self.qmpFailCount = 0; - self.qmpConnected = true; - self.SetState(VMState.Started); + self.display?.Connect(); }); } @@ -170,6 +172,10 @@ export class QemuVM extends EventEmitter { return this.display!; } + GetState() { + return this.state; + } + /// Private fun bits :) private VMLog() { @@ -223,7 +229,6 @@ export class QemuVM extends EventEmitter { // Disconnect from the display and QMP connections. await self.DisconnectDisplay(); - await self.DisconnectQmp(); // Remove the sockets for VNC and QMP. try { @@ -262,8 +267,10 @@ export class QemuVM extends EventEmitter { private async ConnectQmp() { let self = this; - if (this.qmpConnected) { - this.VMLog().Error('Already connected to QMP!'); + if (this.qmpSocket) { + // This isn't really a problem (since we gate it) + // but I'd like to see if i could eliminate this + this.VMLog().Warning('QemuVM.ConnectQmp(): Already connected to QMP socket!'); return; } @@ -271,20 +278,20 @@ export class QemuVM extends EventEmitter { this.qmpSocket = connect(this.GetQmpPath()); this.qmpSocket.on('close', async () => { - if (self.qmpConnected) { - await self.DisconnectQmp(); + self.qmpSocket?.removeAllListeners(); + self.qmpSocket = null; - // If we aren't stopping, then we should care QMP disconnected - if (self.state != VMState.Stopping) { - if (self.qmpFailCount++ < kMaxFailCount) { - self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); - await Shared.Sleep(500); - await self.ConnectQmp(); - } else { - self.VMLog().Error(`Reached max retries, giving up.`); - await self.Stop(); - return; - } + // If we aren't stopping (i.e: disconnection wasn't because we disconnected), + // then we should care QMP disconnected + if (self.state != VMState.Stopping) { + if (self.qmpFailCount++ < kMaxFailCount) { + self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); + await Shared.Sleep(500); + await self.ConnectQmp(); + } else { + self.VMLog().Error(`Reached max retries, giving up.`); + await self.Stop(); + return; } } }); @@ -293,10 +300,15 @@ export class QemuVM extends EventEmitter { self.VMLog().Error('QMP socket error: {0}', e.message); }); - // Setup the QMP client. - let writer = new SocketWriter(this.qmpSocket, this.qmpInstance); - this.qmpInstance.reset(); - this.qmpInstance.setWriter(writer); + this.qmpSocket.on('connect', () => { + self.VMLog().Info("Connected to QMP socket"); + + // Setup the QMP client. + let writer = new SocketWriter(self.qmpSocket!, self.qmpInstance); + self.qmpInstance.reset(); + self.qmpInstance.setWriter(writer); + }) + } private async DisconnectDisplay() { @@ -307,13 +319,4 @@ export class QemuVM extends EventEmitter { // oh well lol } } - - private async DisconnectQmp() { - if (!this.qmpConnected) return; - this.qmpConnected = false; - - if (this.qmpSocket == null) return; - this.qmpSocket?.end(); - this.qmpSocket = null; - } } From e28bb3a9d711fddb69796255075f022645052872 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Thu, 11 Jul 2024 20:49:49 -0400 Subject: [PATCH 55/60] remove connect/view gating (for now) --- cvmts/src/CollabVMServer.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index d972b32..abb2b98 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -106,7 +106,7 @@ export default class CollabVMServer { this.indefiniteTurn = null; this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); - // No size initially, since the + // No size initially, since there usually won't be a display connected at all during initalization this.OnDisplayResized({ width: 0, height: 0 @@ -114,12 +114,14 @@ export default class CollabVMServer { this.VM = vm; - // hack but whatever (TODO: less rickity) + // this probably should be made general at some point, + // and the VM interface allowed to return a nullable display + // but i cba let self = this; if (config.vm.type == 'qemu') { (vm as QemuVM).on('statechange', (newState: VMState) => { if (newState == VMState.Started) { - self.logger.Info("VM started"); + self.logger.Info('VM started'); // well aware this sucks but whatever self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size)); self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect)); @@ -256,12 +258,6 @@ export default class CollabVMServer { return; } - // Don't allow connecting if the VM hasn't started - if (this.VM.GetState() != VMState.Started) { - client.sendMsg(cvm.guacEncode('connect', '0')); - return; - } - client.connectedToNode = true; client.sendMsg(cvm.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0')); if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg()); @@ -284,12 +280,6 @@ export default class CollabVMServer { return; } - // similar rationale to 'connect' - if (this.VM.GetState() != VMState.Started) { - client.sendMsg(cvm.guacEncode('connect', '0')); - return; - } - switch (msgArr[2]) { case '0': client.viewMode = 0; @@ -865,6 +855,8 @@ export default class CollabVMServer { private async SendFullScreenWithSize(client: User) { let display = this.VM.GetDisplay(); + if (display == null) return; + let displaySize = display.Size(); let encoded = await this.MakeRectData({ @@ -893,11 +885,11 @@ export default class CollabVMServer { private async MakeRectData(rect: Rect) { let display = this.VM.GetDisplay(); - let displaySize = display.Size(); // TODO: actually throw an error here - if (displaySize.width == 0 && displaySize.height == 0) return Buffer.from('no'); + if (display == null) return Buffer.from('no'); + let displaySize = display.Size(); let encoded = await JPEGEncoder.Encode(display.Buffer(), displaySize, rect); return encoded; From 741305919372b85ad5c74b184d6f0e92d2ba370d Mon Sep 17 00:00:00 2001 From: modeco80 Date: Sun, 14 Jul 2024 19:04:19 -0400 Subject: [PATCH 56/60] qemu: Switch to QMP over stdio Simply a more convinent pipe. Additionally, because the pipe will only break when the process exits, this means we can now remove QMP reconnection logic entirely. Can't exactly have problems when the problem code is factored out ;) --- qemu/src/QemuVM.ts | 101 ++++++++++++------------------------------ qemu/src/QmpClient.ts | 4 +- 2 files changed, 30 insertions(+), 75 deletions(-) diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index ab99930..ab25a5c 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -6,6 +6,7 @@ import { unlink } from 'node:fs/promises'; import * as Shared from '@cvmts/shared'; import { Socket, connect } from 'net'; +import { Readable, Stream, Writable } from 'stream'; export enum VMState { Stopped, @@ -14,8 +15,6 @@ export enum VMState { 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; @@ -25,26 +24,24 @@ export type QemuVmDefinition = { /// 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; - -// writer implementation for net.Socket -class SocketWriter implements IQmpClientWriter { - socket; +// writer implementation for process standard I/O +class StdioWriter implements IQmpClientWriter { + stdout; + stdin; client; - constructor(socket: Socket, client: QmpClient) { - this.socket = socket; + constructor(stdout: Readable, stdin: Writable, client: QmpClient) { + this.stdout = stdout; + this.stdin = stdin; this.client = client; - this.socket.on('data', (data) => { + this.stdout.on('data', (data) => { this.client.feed(data); }); } writeSome(buffer: Buffer) { - this.socket.write(buffer); + this.stdin.write(buffer); } } @@ -53,8 +50,6 @@ export class QemuVM extends EventEmitter { // QMP stuff. private qmpInstance: QmpClient = new QmpClient(); - private qmpSocket: Socket | null = null; - private qmpFailCount = 0; private qemuProcess: ExecaChildProcess | null = null; @@ -91,8 +86,7 @@ export class QemuVM extends EventEmitter { self.SetState(VMState.Started); }) - // now that we've connected to VNC, connect to the display - self.qmpFailCount = 0; + // now that QMP has connected, connect to the display self.display?.Connect(); }); } @@ -108,7 +102,7 @@ export class QemuVM extends EventEmitter { if (!this.addedAdditionalArguments) { cmd += ' -no-shutdown'; if (this.definition.snapshot) cmd += ' -snapshot'; - cmd += ` -qmp unix:${this.GetQmpPath()},server,wait -vnc unix:${this.GetVncPath()}`; + cmd += ` -qmp stdio -vnc unix:${this.GetVncPath()}`; this.definition.command = cmd; this.addedAdditionalArguments = true; } @@ -189,11 +183,6 @@ export class QemuVM extends EventEmitter { private SetState(state: VMState) { this.state = state; this.emit('statechange', this.state); - - // reset QMP fail count when the VM is (re)starting or stopped - if (this.state == VMState.Stopped || this.state == VMState.Starting) { - this.qmpFailCount = 0; - } } private GetQmpPath() { @@ -212,7 +201,11 @@ export class QemuVM extends EventEmitter { this.VMLog().Info(`Starting QEMU with command \"${split}\"`); // Start QEMU - this.qemuProcess = execaCommand(split); + this.qemuProcess = execaCommand(split, { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe' + }); this.qemuProcess.stderr?.on('data', (data) => { self.VMLog().Error('QEMU stderr: {0}', data.toString('utf8')); @@ -220,8 +213,7 @@ export class QemuVM extends EventEmitter { this.qemuProcess.on('spawn', async () => { self.VMLog().Info('QEMU started'); - await Shared.Sleep(500); - await self.ConnectQmp(); + await self.QmpStdioInit(); }); this.qemuProcess.on('exit', async (code) => { @@ -230,15 +222,14 @@ export class QemuVM extends EventEmitter { // Disconnect from the display and QMP connections. await self.DisconnectDisplay(); - // Remove the sockets for VNC and QMP. + self.qmpInstance.reset(); + self.qmpInstance.setWriter(null); + + // Remove the VNC UDS socket. try { await unlink(this.GetVncPath()); } catch (_) {} - try { - await unlink(this.GetQmpPath()); - } catch (_) {} - if (self.state != VMState.Stopping) { if (code == 0) { // Wait a bit and restart QEMU. @@ -264,51 +255,15 @@ export class QemuVM extends EventEmitter { } } - private async ConnectQmp() { + private async QmpStdioInit() { let self = this; - if (this.qmpSocket) { - // This isn't really a problem (since we gate it) - // but I'd like to see if i could eliminate this - this.VMLog().Warning('QemuVM.ConnectQmp(): Already connected to QMP socket!'); - return; - } - - await Shared.Sleep(500); - this.qmpSocket = connect(this.GetQmpPath()); - - this.qmpSocket.on('close', async () => { - self.qmpSocket?.removeAllListeners(); - self.qmpSocket = null; - - // If we aren't stopping (i.e: disconnection wasn't because we disconnected), - // then we should care QMP disconnected - if (self.state != VMState.Stopping) { - if (self.qmpFailCount++ < kMaxFailCount) { - self.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times.`); - await Shared.Sleep(500); - await self.ConnectQmp(); - } else { - self.VMLog().Error(`Reached max retries, giving up.`); - await self.Stop(); - return; - } - } - }); - - this.qmpSocket.on('error', (e: Error) => { - self.VMLog().Error('QMP socket error: {0}', e.message); - }); - - this.qmpSocket.on('connect', () => { - self.VMLog().Info("Connected to QMP socket"); - - // Setup the QMP client. - let writer = new SocketWriter(self.qmpSocket!, self.qmpInstance); - self.qmpInstance.reset(); - self.qmpInstance.setWriter(writer); - }) + self.VMLog().Info("Initializing QMP over stdio"); + // Setup the QMP client. + let writer = new StdioWriter(this.qemuProcess?.stdout!, this.qemuProcess?.stdin!, self.qmpInstance); + self.qmpInstance.reset(); + self.qmpInstance.setWriter(writer); } private async DisconnectDisplay() { diff --git a/qemu/src/QmpClient.ts b/qemu/src/QmpClient.ts index 68d7a5f..3a31cf0 100644 --- a/qemu/src/QmpClient.ts +++ b/qemu/src/QmpClient.ts @@ -31,7 +31,7 @@ export enum QmpEvent { Stop = 'STOP', VncConnected = 'VNC_CONNECTED', VncDisconnected = 'VNC_DISCONNECTED', - VncInitalized = 'VNC_INITALIZED', + VncInitialized = 'VNC_INITIALIZED', Watchdog = 'WATCHDOG' } @@ -79,7 +79,7 @@ export class QmpClient extends EventEmitter { }); } - setWriter(writer: IQmpClientWriter) { + setWriter(writer: IQmpClientWriter|null) { this.writer = writer; } From 6a4c1e6ac272b3bd07f1939bf7688910ec6e0f59 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Tue, 16 Jul 2024 06:35:58 -0400 Subject: [PATCH 57/60] qemu: Make sure stdin is open before writing oops. Not sure how I didn't think of that. --- qemu/src/QemuVM.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index ab25a5c..2273db8 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -1,12 +1,11 @@ -import { execa, execaCommand, ExecaChildProcess } from 'execa'; +import { execaCommand, ExecaChildProcess } from 'execa'; import { EventEmitter } from 'events'; import { QmpClient, IQmpClientWriter, QmpEvent } from './QmpClient.js'; import { QemuDisplay } from './QemuDisplay.js'; import { unlink } from 'node:fs/promises'; import * as Shared from '@cvmts/shared'; -import { Socket, connect } from 'net'; -import { Readable, Stream, Writable } from 'stream'; +import { Readable, Writable } from 'stream'; export enum VMState { Stopped, @@ -24,6 +23,10 @@ export type QemuVmDefinition = { /// Temporary path base (for UNIX sockets/etc.) const kVmTmpPathBase = `/tmp`; +// Test so I can test removing any (or well, the lone) sleep calls, +// in the qemuvm code. +const kTestDisableSleep = true; + // writer implementation for process standard I/O class StdioWriter implements IQmpClientWriter { stdout; @@ -41,7 +44,8 @@ class StdioWriter implements IQmpClientWriter { } writeSome(buffer: Buffer) { - this.stdin.write(buffer); + if(!this.stdin.closed) + this.stdin.write(buffer); } } @@ -82,9 +86,9 @@ export class QemuVM extends EventEmitter { self.display?.on('connected', () => { // The VM can now be considered started - self.VMLog().Info("Display connected"); + self.VMLog().Info('Display connected'); self.SetState(VMState.Started); - }) + }); // now that QMP has connected, connect to the display self.display?.Connect(); @@ -232,8 +236,9 @@ export class QemuVM extends EventEmitter { if (self.state != VMState.Stopping) { if (code == 0) { - // Wait a bit and restart QEMU. - await Shared.Sleep(500); + if(!kTestDisableSleep) + 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.'); @@ -258,7 +263,7 @@ export class QemuVM extends EventEmitter { private async QmpStdioInit() { let self = this; - self.VMLog().Info("Initializing QMP over stdio"); + self.VMLog().Info('Initializing QMP over stdio'); // Setup the QMP client. let writer = new StdioWriter(this.qemuProcess?.stdout!, this.qemuProcess?.stdin!, self.qmpInstance); From b9b0aa91dfb124e2c618677f03bf584dd2ae957c Mon Sep 17 00:00:00 2001 From: modeco80 Date: Tue, 16 Jul 2024 06:43:20 -0400 Subject: [PATCH 58/60] qemu: remove the last sleep call Finally, no more hacky sleep calls are left. Woohoo. This was already effectively done by the previous commit, but now that I know it works, I've removed the test path entirely --- qemu/src/QemuVM.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 2273db8..08b88ae 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -23,10 +23,6 @@ export type QemuVmDefinition = { /// Temporary path base (for UNIX sockets/etc.) const kVmTmpPathBase = `/tmp`; -// Test so I can test removing any (or well, the lone) sleep calls, -// in the qemuvm code. -const kTestDisableSleep = true; - // writer implementation for process standard I/O class StdioWriter implements IQmpClientWriter { stdout; @@ -236,9 +232,6 @@ export class QemuVM extends EventEmitter { if (self.state != VMState.Stopping) { if (code == 0) { - if(!kTestDisableSleep) - 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.'); From cf9f11819e2dc5bbae37f4892f1aa0ddf087828a Mon Sep 17 00:00:00 2001 From: modeco80 Date: Tue, 16 Jul 2024 07:02:20 -0400 Subject: [PATCH 59/60] qemu: remove more dead code Not sure how I missed this. --- qemu/src/QemuVM.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts index 08b88ae..dc2b01e 100644 --- a/qemu/src/QemuVM.ts +++ b/qemu/src/QemuVM.ts @@ -185,10 +185,6 @@ export class QemuVM extends EventEmitter { this.emit('statechange', this.state); } - private GetQmpPath() { - return `${kVmTmpPathBase}/cvmts-${this.definition.id}-mon`; - } - private GetVncPath() { return `${kVmTmpPathBase}/cvmts-${this.definition.id}-vnc`; } From 432e75d42ab89753e09a7001ddff63497e600c02 Mon Sep 17 00:00:00 2001 From: modeco80 Date: Tue, 16 Jul 2024 08:29:52 -0400 Subject: [PATCH 60/60] cvmts: Use npm versions of superqemu/nodejs-rfb. We publish them now, so let's use them in cvmts! Additionally, this removes the 'shared' module entirely, since it has little purpose anymore. The logger is replaced with pino (because superqemu uses pino for logging itself). --- .gitmodules | 3 - Justfile | 3 - cvmts/package.json | 5 +- cvmts/src/AuthManager.ts | 3 - cvmts/src/CollabVMServer.ts | 27 ++- cvmts/src/GeoIPDownloader.ts | 184 ++++++++------- cvmts/src/IPData.ts | 6 +- cvmts/src/JPEGEncoder.ts | 2 +- cvmts/src/TCP/TCPServer.ts | 9 +- cvmts/src/User.ts | 10 +- cvmts/src/VM.ts | 4 +- cvmts/src/VMDisplay.ts | 17 +- cvmts/src/VNCVM/VNCVM.ts | 27 ++- cvmts/src/WebSocket/WSClient.ts | 1 - cvmts/src/WebSocket/WSServer.ts | 15 +- cvmts/src/index.ts | 23 +- nodejs-rfb | 1 - package.json | 2 - qemu/package.json | 29 --- qemu/src/QemuDisplay.ts | 151 ------------ qemu/src/QemuUtil.ts | 41 ---- qemu/src/QemuVM.ts | 271 --------------------- qemu/src/QmpClient.ts | 170 -------------- qemu/src/index.ts | 5 - qemu/tsconfig.json | 1 - shared/package.json | 28 --- shared/src/Logger.ts | 50 ---- shared/src/StringLike.ts | 8 - shared/src/format.ts | 77 ------ shared/src/index.ts | 24 -- shared/tsconfig.json | 1 - yarn.lock | 402 +++++++++++++++++++++++--------- 32 files changed, 469 insertions(+), 1131 deletions(-) delete mode 160000 nodejs-rfb delete mode 100644 qemu/package.json delete mode 100644 qemu/src/QemuDisplay.ts delete mode 100644 qemu/src/QemuUtil.ts delete mode 100644 qemu/src/QemuVM.ts delete mode 100644 qemu/src/QmpClient.ts delete mode 100644 qemu/src/index.ts delete mode 120000 qemu/tsconfig.json delete mode 100644 shared/package.json delete mode 100644 shared/src/Logger.ts delete mode 100644 shared/src/StringLike.ts delete mode 100644 shared/src/format.ts delete mode 100644 shared/src/index.ts delete mode 120000 shared/tsconfig.json diff --git a/.gitmodules b/.gitmodules index 08a9b15..925776f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "nodejs-rfb"] - path = nodejs-rfb - url = https://github.com/computernewb/nodejs-rfb [submodule "collab-vm-1.2-binary-protocol"] path = collab-vm-1.2-binary-protocol url = https://github.com/computernewb/collab-vm-1.2-binary-protocol diff --git a/Justfile b/Justfile index fe019e4..684a827 100644 --- a/Justfile +++ b/Justfile @@ -1,8 +1,5 @@ all: yarn workspace @cvmts/cvm-rs run build - yarn workspace @computernewb/nodejs-rfb run build - yarn workspace @cvmts/shared run build - yarn workspace @cvmts/qemu run build yarn workspace @cvmts/collab-vm-1.2-binary-protocol run build yarn workspace @cvmts/cvmts run build diff --git a/cvmts/package.json b/cvmts/package.json index 3602618..c998bd0 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -11,12 +11,14 @@ "author": "Elijah R, modeco80", "license": "GPL-3.0", "dependencies": { + "@computernewb/nodejs-rfb": "^0.3.0", + "@computernewb/superqemu": "^0.1.0", "@cvmts/cvm-rs": "*", - "@cvmts/qemu": "*", "@maxmind/geoip2-node": "^5.0.0", "execa": "^8.0.1", "mnemonist": "^0.39.5", "msgpackr": "^1.10.2", + "pino": "^9.3.1", "sharp": "^0.33.3", "toml": "^3.0.0", "ws": "^8.14.1" @@ -24,6 +26,7 @@ "devDependencies": { "@types/node": "^20.12.5", "@types/ws": "^8.5.5", + "pino-pretty": "^11.2.1", "prettier": "^3.2.5", "typescript": "^5.4.4" } diff --git a/cvmts/src/AuthManager.ts b/cvmts/src/AuthManager.ts index 8cfe96e..4a6ff7f 100644 --- a/cvmts/src/AuthManager.ts +++ b/cvmts/src/AuthManager.ts @@ -1,12 +1,9 @@ -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; diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index abb2b98..b61c7af 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -6,18 +6,20 @@ import * as cvm from '@cvmts/cvm-rs'; import CircularBuffer from 'mnemonist/circular-buffer.js'; import Queue from 'mnemonist/queue.js'; import { createHash } from 'crypto'; -import { VMState, QemuVM, QemuVmDefinition } from '@cvmts/qemu'; +import { VMState, QemuVM, QemuVmDefinition } from '@computernewb/superqemu'; import { IPDataManager } from './IPData.js'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import AuthManager from './AuthManager.js'; -import { Size, Rect, Logger } from '@cvmts/shared'; import { JPEGEncoder } from './JPEGEncoder.js'; import VM from './VM.js'; import { ReaderModel } from '@maxmind/geoip2-node'; import * as msgpack from 'msgpackr'; import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/collab-vm-1.2-binary-protocol'; +import { Size, Rect } from './VMDisplay.js'; +import pino from 'pino'; + // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before const __dirname = import.meta.dirname; @@ -36,6 +38,7 @@ type VoteTally = { no: number; }; + export default class CollabVMServer { private Config: IConfig; @@ -87,7 +90,7 @@ export default class CollabVMServer { // Geoip private geoipReader: ReaderModel | null; - private logger = new Logger('CVMTS.Server'); + private logger = pino({ name: 'CVMTS.Server' }); constructor(config: IConfig, vm: VM, auth: AuthManager | null, geoipReader: ReaderModel | null) { this.Config = config; @@ -121,7 +124,7 @@ export default class CollabVMServer { if (config.vm.type == 'qemu') { (vm as QemuVM).on('statechange', (newState: VMState) => { if (newState == VMState.Started) { - self.logger.Info('VM started'); + self.logger.info('VM started'); // well aware this sucks but whatever self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size)); self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect)); @@ -129,7 +132,7 @@ export default class CollabVMServer { if (newState == VMState.Stopped) { setTimeout(async () => { - self.logger.Info('restarting VM'); + self.logger.info('restarting VM'); await self.VM.Start(); }, kRestartTimeout); } @@ -154,7 +157,7 @@ export default class CollabVMServer { try { user.countryCode = this.geoipReader!.country(user.IP.address).country!.isoCode; } catch (error) { - this.logger.Warning(`Failed to get country code for ${user.IP.address}: ${(error as Error).message}`); + this.logger.warn(`Failed to get country code for ${user.IP.address}: ${(error as Error).message}`); } } user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); @@ -179,7 +182,7 @@ export default class CollabVMServer { this.clients.splice(clientIndex, 1); - this.logger.Info(`Disconnect From ${user.IP.address}${user.username ? ` with username ${user.username}` : ''}`); + 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; @@ -204,7 +207,7 @@ export default class CollabVMServer { try { let res = await this.auth!.Authenticate(msgArr[1], client); if (res.clientSuccess) { - this.logger.Info(`${client.IP.address} logged in as ${res.username}`); + this.logger.info(`${client.IP.address} logged in as ${res.username}`); client.sendMsg(cvm.guacEncode('login', '1')); let old = this.clients.find((c) => c.username === res.username); if (old) { @@ -236,7 +239,7 @@ export default class CollabVMServer { } } } catch (err) { - this.logger.Error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`); + this.logger.error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`); // for now? client.sendMsg(cvm.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')); } @@ -679,7 +682,7 @@ export default class CollabVMServer { } } catch (err) { // No - this.logger.Error(`User ${user?.IP.address} ${user?.username ? `with username ${user?.username}` : ''} sent broken Guacamole: ${err as Error}`); + this.logger.error(`User ${user?.IP.address} ${user?.username ? `with username ${user?.username}` : ''} sent broken Guacamole: ${err as Error}`); user?.kick(); } } @@ -721,10 +724,10 @@ export default class CollabVMServer { client.sendMsg(cvm.guacEncode('rename', '0', status, client.username!, client.rank.toString())); if (hadName) { - this.logger.Info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`); + this.logger.info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`); if (announce) this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); } else { - this.logger.Info(`Rename ${client.IP.address} to ${client.username}`); + this.logger.info(`Rename ${client.IP.address} to ${client.username}`); if (announce) this.clients.forEach((c) => { c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())); diff --git a/cvmts/src/GeoIPDownloader.ts b/cvmts/src/GeoIPDownloader.ts index ef297cf..e53a067 100644 --- a/cvmts/src/GeoIPDownloader.ts +++ b/cvmts/src/GeoIPDownloader.ts @@ -1,4 +1,3 @@ -import { Logger } from '@cvmts/shared'; import { Reader, ReaderModel } from '@maxmind/geoip2-node'; import * as fs from 'fs/promises'; import * as path from 'node:path'; @@ -6,102 +5,101 @@ import { Readable } from 'node:stream'; import { ReadableStream } from 'node:stream/web'; import { finished } from 'node:stream/promises'; import { execa } from 'execa'; +import pino from 'pino'; export default class GeoIPDownloader { - private directory: string; - private accountID: string; - private licenseKey: string; - private logger: Logger - constructor(filename: string, accountID: string, licenseKey: string) { - this.directory = filename; - if (!this.directory.endsWith('/')) this.directory += '/'; - this.accountID = accountID; - this.licenseKey = licenseKey; - this.logger = new Logger('CVMTS.GeoIPDownloader'); - } + private directory: string; + private accountID: string; + private licenseKey: string; + private logger = pino({ name: 'CVMTS.GeoIPDownloader' }); + constructor(filename: string, accountID: string, licenseKey: string) { + this.directory = filename; + if (!this.directory.endsWith('/')) this.directory += '/'; + this.accountID = accountID; + this.licenseKey = licenseKey; + } - private genAuthHeader(): string { - return `Basic ${Buffer.from(`${this.accountID}:${this.licenseKey}`).toString('base64')}`; - } + private genAuthHeader(): string { + return `Basic ${Buffer.from(`${this.accountID}:${this.licenseKey}`).toString('base64')}`; + } - private async ensureDirectoryExists(): Promise { - let stat; - try { - stat = await fs.stat(this.directory); - } - catch (e) { - var error = e as NodeJS.ErrnoException; - if (error.code === 'ENOTDIR') { - this.logger.Warning('File exists at GeoIP directory path, unlinking...'); - await fs.unlink(this.directory.substring(0, this.directory.length - 1)); - } else if (error.code !== 'ENOENT') { - this.logger.Error('Failed to access GeoIP directory: {0}', error.message); - process.exit(1); - } - this.logger.Info('Creating GeoIP directory: {0}', this.directory); - await fs.mkdir(this.directory, { recursive: true }); - return; - } - } + private async ensureDirectoryExists(): Promise { + let stat; + try { + stat = await fs.stat(this.directory); + } catch (e) { + var error = e as NodeJS.ErrnoException; + if (error.code === 'ENOTDIR') { + this.logger.warn('File exists at GeoIP directory path, unlinking...'); + await fs.unlink(this.directory.substring(0, this.directory.length - 1)); + } else if (error.code !== 'ENOENT') { + this.logger.error('Failed to access GeoIP directory: {0}', error.message); + process.exit(1); + } + this.logger.info('Creating GeoIP directory: {0}', this.directory); + await fs.mkdir(this.directory, { recursive: true }); + return; + } + } - async getGeoIPReader(): Promise { - await this.ensureDirectoryExists(); - let dbpath = path.join(this.directory, (await this.getLatestVersion()).replace('.tar.gz', ''), 'GeoLite2-Country.mmdb'); - try { - await fs.access(dbpath, fs.constants.F_OK | fs.constants.R_OK); - this.logger.Info('Loading cached GeoIP database: {0}', dbpath); - } catch (ex) { - var error = ex as NodeJS.ErrnoException; - if (error.code === 'ENOENT') { - await this.downloadLatestDatabase(); - } else { - this.logger.Error('Failed to access GeoIP database: {0}', error.message); - process.exit(1); - } - } - return await Reader.open(dbpath); - } + async getGeoIPReader(): Promise { + await this.ensureDirectoryExists(); + let dbpath = path.join(this.directory, (await this.getLatestVersion()).replace('.tar.gz', ''), 'GeoLite2-Country.mmdb'); + try { + await fs.access(dbpath, fs.constants.F_OK | fs.constants.R_OK); + this.logger.info('Loading cached GeoIP database: {0}', dbpath); + } catch (ex) { + var error = ex as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + await this.downloadLatestDatabase(); + } else { + this.logger.error('Failed to access GeoIP database: {0}', error.message); + process.exit(1); + } + } + return await Reader.open(dbpath); + } - async getLatestVersion(): Promise { - let res = await fetch('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz', { - redirect: 'follow', - method: "HEAD", - headers: { - "Authorization": this.genAuthHeader() - } - }); - let disposition = res.headers.get('Content-Disposition'); - if (!disposition) { - this.logger.Error('Failed to get latest version of GeoIP database: No Content-Disposition header'); - process.exit(1); - } - let filename = disposition.match(/filename=(.*)$/); - if (!filename) { - this.logger.Error('Failed to get latest version of GeoIP database: Could not parse version from Content-Disposition header'); - process.exit(1); - } - return filename[1]; - } + async getLatestVersion(): Promise { + let res = await fetch('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz', { + redirect: 'follow', + method: 'HEAD', + headers: { + Authorization: this.genAuthHeader() + } + }); + let disposition = res.headers.get('Content-Disposition'); + if (!disposition) { + this.logger.error('Failed to get latest version of GeoIP database: No Content-Disposition header'); + process.exit(1); + } + let filename = disposition.match(/filename=(.*)$/); + if (!filename) { + this.logger.error('Failed to get latest version of GeoIP database: Could not parse version from Content-Disposition header'); + process.exit(1); + } + return filename[1]; + } - async downloadLatestDatabase(): Promise { - let filename = await this.getLatestVersion(); - this.logger.Info('Downloading latest GeoIP database: {0}', filename); - let dbpath = path.join(this.directory, filename); - let file = await fs.open(dbpath, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY); - let stream = file.createWriteStream(); - let res = await fetch('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz', { - redirect: 'follow', - headers: { - "Authorization": this.genAuthHeader() - } - }); - await finished(Readable.fromWeb(res.body as ReadableStream).pipe(stream)); - await file.close(); - this.logger.Info('Finished downloading latest GeoIP database: {0}', filename); - this.logger.Info('Extracting GeoIP database: {0}', filename); - // yeah whatever - await execa('tar', ['xzf', filename], {cwd: this.directory}); - this.logger.Info('Unlinking GeoIP tarball'); - await fs.unlink(dbpath); - } -} \ No newline at end of file + async downloadLatestDatabase(): Promise { + let filename = await this.getLatestVersion(); + this.logger.info('Downloading latest GeoIP database: {0}', filename); + let dbpath = path.join(this.directory, filename); + let file = await fs.open(dbpath, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY); + let stream = file.createWriteStream(); + let res = await fetch('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz', { + redirect: 'follow', + headers: { + Authorization: this.genAuthHeader() + } + }); + await finished(Readable.fromWeb(res.body as ReadableStream).pipe(stream)); + await file.close(); + this.logger.info('Finished downloading latest GeoIP database: {0}', filename); + this.logger.info('Extracting GeoIP database: {0}', filename); + // yeah whatever + await execa('tar', ['xzf', filename], { cwd: this.directory }); + this.logger.info('Unlinking GeoIP tarball'); + await fs.unlink(dbpath); + } +} diff --git a/cvmts/src/IPData.ts b/cvmts/src/IPData.ts index 9de8fdd..4228fa9 100644 --- a/cvmts/src/IPData.ts +++ b/cvmts/src/IPData.ts @@ -1,4 +1,4 @@ -import { Logger } from '@cvmts/shared'; +import pino from 'pino'; export class IPData { tempMuteExpireTimeout?: NodeJS.Timeout; @@ -22,7 +22,7 @@ export class IPData { export class IPDataManager { static ipDatas = new Map(); - static logger = new Logger('CVMTS.IPDataManager'); + static logger = pino({ name: 'CVMTS.IPDataManager' }); static GetIPData(address: string) { if (IPDataManager.ipDatas.has(address)) { @@ -64,7 +64,7 @@ export class IPDataManager { setInterval(() => { for (let tuple of IPDataManager.ipDatas) { if (tuple[1].refCount == 0) { - IPDataManager.logger.Info('Deleted IPData for IP {0}', tuple[0]); + IPDataManager.logger.info(`Deleted IPData for IP ${tuple[0]}`); IPDataManager.ipDatas.delete(tuple[0]); } } diff --git a/cvmts/src/JPEGEncoder.ts b/cvmts/src/JPEGEncoder.ts index a9a6f7a..763b3c5 100644 --- a/cvmts/src/JPEGEncoder.ts +++ b/cvmts/src/JPEGEncoder.ts @@ -1,4 +1,4 @@ -import { Size, Rect } from '@cvmts/shared'; +import { Size, Rect } from './VMDisplay.js'; import sharp from 'sharp'; import * as cvm from '@cvmts/cvm-rs'; diff --git a/cvmts/src/TCP/TCPServer.ts b/cvmts/src/TCP/TCPServer.ts index c32c694..040a81a 100644 --- a/cvmts/src/TCP/TCPServer.ts +++ b/cvmts/src/TCP/TCPServer.ts @@ -2,20 +2,19 @@ import EventEmitter from 'events'; import NetworkServer from '../NetworkServer.js'; import { Server, Socket } from 'net'; import IConfig from '../IConfig.js'; -import { Logger } from '@cvmts/shared'; import TCPClient from './TCPClient.js'; import { IPDataManager } from '../IPData.js'; import { User } from '../User.js'; +import pino from 'pino'; export default class TCPServer extends EventEmitter implements NetworkServer { listener: Server; Config: IConfig; - logger: Logger; + logger = pino({name: 'CVMTS.TCPServer'}); clients: TCPClient[]; constructor(config: IConfig) { super(); - this.logger = new Logger('CVMTS.TCPServer'); this.Config = config; this.listener = new Server(); this.clients = []; @@ -23,7 +22,7 @@ export default class TCPServer extends EventEmitter implements NetworkServer { } private onConnection(socket: Socket) { - this.logger.Info(`New TCP connection from ${socket.remoteAddress}`); + this.logger.info(`New TCP connection from ${socket.remoteAddress}`); var client = new TCPClient(socket); this.clients.push(client); this.emit('connect', new User(client, IPDataManager.GetIPData(client.getIP()), this.Config)); @@ -31,7 +30,7 @@ export default class TCPServer extends EventEmitter implements NetworkServer { start(): void { this.listener.listen(this.Config.tcp.port, this.Config.tcp.host, () => { - this.logger.Info(`TCP server listening on ${this.Config.tcp.host}:${this.Config.tcp.port}`); + this.logger.info(`TCP server listening on ${this.Config.tcp.host}:${this.Config.tcp.port}`); }); } stop(): void { diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 040d74e..a9cb62b 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -4,9 +4,9 @@ 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'; import NetworkClient from './NetworkClient.js'; import { CollabVMCapabilities } from '@cvmts/collab-vm-1.2-binary-protocol'; +import pino from 'pino'; export class User { socket: NetworkClient; @@ -31,7 +31,7 @@ export class User { TurnRateLimit: RateLimiter; VoteRateLimit: RateLimiter; - private logger = new Logger('CVMTS.User'); + private logger = pino({ name: 'CVMTS.User' }); constructor(socket: NetworkClient, ip: IPData, config: IConfig, username?: string, node?: string) { this.IP = ip; @@ -148,7 +148,7 @@ export class User { 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`); + 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); @@ -156,11 +156,11 @@ export class User { 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`); + 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}`); + this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): ${(e as ExecaSyncError).shortMessage}`); } } diff --git a/cvmts/src/VM.ts b/cvmts/src/VM.ts index d2a8657..0038e9b 100644 --- a/cvmts/src/VM.ts +++ b/cvmts/src/VM.ts @@ -1,5 +1,5 @@ -import { VMState } from '@cvmts/qemu'; -import VMDisplay from './VMDisplay.js'; +import { VMState } from '@computernewb/superqemu'; +import { VMDisplay } from './VMDisplay.js'; export default interface VM { Start(): Promise; diff --git a/cvmts/src/VMDisplay.ts b/cvmts/src/VMDisplay.ts index b06a6d7..559e9b8 100644 --- a/cvmts/src/VMDisplay.ts +++ b/cvmts/src/VMDisplay.ts @@ -1,7 +1,20 @@ -import { Size } from '@cvmts/shared'; import EventEmitter from 'node:events'; -export default interface VMDisplay extends EventEmitter { +// not great but whatever +// nodejs-rfb COULD probably export them though. +export type Size = { + width: number; + height: number; +}; + +export type Rect = { + x: number; + y: number; + width: number; + height: number; +}; + +export interface VMDisplay extends EventEmitter { Connect(): void; Disconnect(): void; Connected(): boolean; diff --git a/cvmts/src/VNCVM/VNCVM.ts b/cvmts/src/VNCVM/VNCVM.ts index 6cf2976..d2106a9 100644 --- a/cvmts/src/VNCVM/VNCVM.ts +++ b/cvmts/src/VNCVM/VNCVM.ts @@ -1,15 +1,23 @@ import EventEmitter from 'events'; import VNCVMDef from './VNCVMDef'; import VM from '../VM'; -import VMDisplay from '../VMDisplay'; -import { Clamp, Logger, Rect, Size, Sleep } from '@cvmts/shared'; +import { Size, Rect, VMDisplay } from '../VMDisplay'; import { VncClient } from '@computernewb/nodejs-rfb'; -import { BatchRects, VMState } from '@cvmts/qemu'; +import { BatchRects, VMState } from '@computernewb/superqemu'; import { execaCommand } from 'execa'; +import pino from 'pino'; + +function Clamp(input: number, min: number, max: number) { + return Math.min(Math.max(input, min), max); +} + +async function Sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} export default class VNCVM extends EventEmitter implements VM, VMDisplay { def: VNCVMDef; - logger: Logger; + logger; private displayVnc = new VncClient({ debug: false, fps: 60, @@ -20,7 +28,8 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { constructor(def: VNCVMDef) { super(); this.def = def; - this.logger = new Logger(`CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}`); + // TODO: Now that we're using an actual structured logger can we please + this.logger = pino({ name: `CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}` }); this.displayVnc.on('connectTimeout', () => { this.Reconnect(); @@ -31,7 +40,7 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { }); this.displayVnc.on('disconnect', () => { - this.logger.Info('Disconnected'); + this.logger.info('Disconnected'); this.Reconnect(); }); @@ -40,7 +49,7 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { }); this.displayVnc.on('firstFrameUpdate', () => { - this.logger.Info('Connected'); + this.logger.info('Connected'); // apparently this library is this good. // at least it's better than the two others which exist. this.displayVnc.changeFps(60); @@ -101,13 +110,13 @@ export default class VNCVM extends EventEmitter implements VM, VMDisplay { } async Start(): Promise { - this.logger.Info('Connecting'); + this.logger.info('Connecting'); if (this.def.startCmd) await execaCommand(this.def.startCmd, { shell: true }); this.Connect(); } async Stop(): Promise { - this.logger.Info('Disconnecting'); + this.logger.info('Disconnecting'); this.Disconnect(); if (this.def.stopCmd) await execaCommand(this.def.stopCmd, { shell: true }); } diff --git a/cvmts/src/WebSocket/WSClient.ts b/cvmts/src/WebSocket/WSClient.ts index 5b4db9b..c2b0cbf 100644 --- a/cvmts/src/WebSocket/WSClient.ts +++ b/cvmts/src/WebSocket/WSClient.ts @@ -1,7 +1,6 @@ import { WebSocket } from 'ws'; import NetworkClient from '../NetworkClient.js'; import EventEmitter from 'events'; -import { Logger } from '@cvmts/shared'; export default class WSClient extends EventEmitter implements NetworkClient { socket: WebSocket; diff --git a/cvmts/src/WebSocket/WSServer.ts b/cvmts/src/WebSocket/WSServer.ts index 807c51e..1808a0d 100644 --- a/cvmts/src/WebSocket/WSServer.ts +++ b/cvmts/src/WebSocket/WSServer.ts @@ -8,20 +8,19 @@ import { isIP } from 'net'; import { IPDataManager } from '../IPData.js'; import WSClient from './WSClient.js'; import { User } from '../User.js'; -import { Logger } from '@cvmts/shared'; +import pino from 'pino'; export default class WSServer extends EventEmitter implements NetworkServer { private httpServer: http.Server; private wsServer: WebSocketServer; private clients: WSClient[]; private Config: IConfig; - private logger: Logger; + private logger = pino({ name: 'CVMTS.WSServer' }); constructor(config: IConfig) { super(); this.Config = config; this.clients = []; - this.logger = new Logger('CVMTS.WSServer'); 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)); @@ -34,7 +33,7 @@ export default class WSServer extends EventEmitter implements NetworkServer { start(): void { this.httpServer.listen(this.Config.http.port, this.Config.http.host, () => { - this.logger.Info(`WebSocket server listening on ${this.Config.http.host}:${this.Config.http.port}`); + this.logger.info(`WebSocket server listening on ${this.Config.http.host}:${this.Config.http.port}`); }); } @@ -94,7 +93,7 @@ export default class WSServer extends EventEmitter implements NetworkServer { // Make sure x-forwarded-for is set if (req.headers['x-forwarded-for'] === undefined) { killConnection(); - this.logger.Error('X-Forwarded-For header not set. This is most likely a misconfiguration of your reverse proxy.'); + this.logger.error('X-Forwarded-For header not set. This is most likely a misconfiguration of your reverse proxy.'); return; } try { @@ -102,7 +101,7 @@ export default class WSServer extends EventEmitter implements NetworkServer { ip = req.headers['x-forwarded-for']?.toString().replace(/\ /g, '').split(',')[0]; } catch { // If we can't get the IP, kill the connection - this.logger.Error('Invalid X-Forwarded-For header. This is most likely a misconfiguration of your reverse proxy.'); + this.logger.error('Invalid X-Forwarded-For header. This is most likely a misconfiguration of your reverse proxy.'); killConnection(); return; } @@ -135,10 +134,10 @@ export default class WSServer extends EventEmitter implements NetworkServer { this.emit('connect', user); ws.on('error', (e) => { - this.logger.Error(`${e} (caused by connection ${ip})`); + this.logger.error(`${e} (caused by connection ${ip})`); ws.close(); }); - this.logger.Info(`New WebSocket connection from ${user.IP.address}`); + this.logger.info(`New WebSocket connection from ${user.IP.address}`); } } diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index 4aefa80..001cf7c 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -3,9 +3,8 @@ import IConfig from './IConfig.js'; import * as fs from 'fs'; import CollabVMServer from './CollabVMServer.js'; -import { QemuVM, QemuVmDefinition } from '@cvmts/qemu'; +import { QemuVM, QemuVmDefinition } from '@computernewb/superqemu'; -import * as Shared from '@cvmts/shared'; import AuthManager from './AuthManager.js'; import WSServer from './WebSocket/WSServer.js'; import { User } from './User.js'; @@ -13,24 +12,25 @@ import TCPServer from './TCP/TCPServer.js'; import VM from './VM.js'; import VNCVM from './VNCVM/VNCVM.js'; import GeoIPDownloader from './GeoIPDownloader.js'; +import pino from 'pino'; -let logger = new Shared.Logger('CVMTS.Init'); +let logger = pino(); -logger.Info('CollabVM Server starting up'); +logger.info('CollabVM Server starting up'); // Parse the config file let Config: IConfig; if (!fs.existsSync('config.toml')) { - logger.Error('Fatal error: 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) { - logger.Error('Fatal error: Failed to read or parse the config file: {0}', (e as Error).message); + logger.error('Fatal error: Failed to read or parse the config file: {0}', (e as Error).message); process.exit(1); } @@ -54,15 +54,6 @@ async function start() { let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; switch (Config.vm.type) { case 'qemu': { - // Print a warning if qmpSockDir is set - // and the host OS is Windows, as this - // configuration will very likely not work. - if (process.platform === 'win32' && Config.qemu.qmpSockDir !== null) { - 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.'); - } - // Fire up the VM let def: QemuVmDefinition = { id: Config.collabvm.node, @@ -78,7 +69,7 @@ async function start() { break; } default: { - logger.Error('Invalid VM type in config: {0}', Config.vm.type); + logger.error(`Invalid VM type in config: ${Config.vm.type}`); process.exit(1); return; } diff --git a/nodejs-rfb b/nodejs-rfb deleted file mode 160000 index 55e9e6c..0000000 --- a/nodejs-rfb +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 55e9e6cd65988dce59f53a3ea8701a90073b55a4 diff --git a/package.json b/package.json index 0cbe009..c1a3c67 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,6 @@ "workspaces": [ "shared", "cvm-rs", - "nodejs-rfb", - "qemu", "cvmts", "collab-vm-1.2-binary-protocol" ], diff --git a/qemu/package.json b/qemu/package.json deleted file mode 100644 index a65b021..0000000 --- a/qemu/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@cvmts/qemu", - "version": "1.0.0", - "description": "A simple and easy to use QEMU supervision runtime", - "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" - }, - "devDependencies": { - "parcel": "^2.12.0" - } -} diff --git a/qemu/src/QemuDisplay.ts b/qemu/src/QemuDisplay.ts deleted file mode 100644 index 8bfcbaa..0000000 --- a/qemu/src/QemuDisplay.ts +++ /dev/null @@ -1,151 +0,0 @@ -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(); - - // bye bye! - this.displayVnc.removeAllListeners(); - this.removeAllListeners(); - } - - Connected() { - return this.displayVnc.connected; - } - - Buffer(): Buffer { - return this.displayVnc.fb; - } - - Size(): Size { - if (!this.displayVnc.connected) - return { - width: 0, - height: 0 - }; - - return { - width: this.displayVnc.clientWidth, - height: this.displayVnc.clientHeight - }; - } - - MouseEvent(x: number, y: number, buttons: number) { - if (this.displayVnc.connected) this.displayVnc.sendPointerEvent(Clamp(x, 0, this.displayVnc.clientWidth), Clamp(y, 0, this.displayVnc.clientHeight), buttons); - } - - KeyboardEvent(keysym: number, pressed: boolean) { - if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed); - } -} diff --git a/qemu/src/QemuUtil.ts b/qemu/src/QemuUtil.ts deleted file mode 100644 index b6b4715..0000000 --- a/qemu/src/QemuUtil.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Size, Rect } from '@cvmts/shared'; - -export function BatchRects(size: Size, rects: Array): 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 - }; -} diff --git a/qemu/src/QemuVM.ts b/qemu/src/QemuVM.ts deleted file mode 100644 index dc2b01e..0000000 --- a/qemu/src/QemuVM.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { execaCommand, ExecaChildProcess } from 'execa'; -import { EventEmitter } from 'events'; -import { QmpClient, IQmpClientWriter, QmpEvent } from './QmpClient.js'; -import { QemuDisplay } from './QemuDisplay.js'; -import { unlink } from 'node:fs/promises'; - -import * as Shared from '@cvmts/shared'; -import { Readable, Writable } from 'stream'; - -export enum VMState { - Stopped, - Starting, - Started, - Stopping -} - -export type QemuVmDefinition = { - id: string; - command: string; - snapshot: boolean; -}; - -/// Temporary path base (for UNIX sockets/etc.) -const kVmTmpPathBase = `/tmp`; - -// writer implementation for process standard I/O -class StdioWriter implements IQmpClientWriter { - stdout; - stdin; - client; - - constructor(stdout: Readable, stdin: Writable, client: QmpClient) { - this.stdout = stdout; - this.stdin = stdin; - this.client = client; - - this.stdout.on('data', (data) => { - this.client.feed(data); - }); - } - - writeSome(buffer: Buffer) { - if(!this.stdin.closed) - this.stdin.write(buffer); - } -} - -export class QemuVM extends EventEmitter { - private state = VMState.Stopped; - - // QMP stuff. - private qmpInstance: QmpClient = new QmpClient(); - - private qemuProcess: ExecaChildProcess | null = null; - - private display: QemuDisplay | null = null; - 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}`); - - let self = this; - - // Handle the STOP event sent when using -no-shutdown - this.qmpInstance.on(QmpEvent.Stop, async () => { - await self.qmpInstance.execute('system_reset'); - }); - - this.qmpInstance.on(QmpEvent.Reset, async () => { - await self.qmpInstance.execute('cont'); - }); - - this.qmpInstance.on('connected', async () => { - self.VMLog().Info('QMP ready'); - - this.display = new QemuDisplay(this.GetVncPath()); - - self.display?.on('connected', () => { - // The VM can now be considered started - self.VMLog().Info('Display connected'); - self.SetState(VMState.Started); - }); - - // now that QMP has connected, connect to the display - self.display?.Connect(); - }); - } - - async Start() { - // Don't start while either trying to start or starting. - //if (this.state == VMState.Started || this.state == VMState.Starting) return; - if (this.qemuProcess) return; - - let cmd = this.definition.command; - - // Build additional command line statements to enable qmp/vnc over unix sockets - if (!this.addedAdditionalArguments) { - cmd += ' -no-shutdown'; - if (this.definition.snapshot) cmd += ' -snapshot'; - cmd += ` -qmp stdio -vnc unix:${this.GetVncPath()}`; - this.definition.command = cmd; - this.addedAdditionalArguments = true; - } - - await this.StartQemu(cmd); - } - - SnapshotsSupported(): boolean { - return this.definition.snapshot; - } - - async Reboot(): Promise { - await this.MonitorCommand('system_reset'); - } - - async Stop() { - this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM'); - - // Indicate we're stopping, so we don't erroneously start trying to restart everything we're going to tear down. - this.SetState(VMState.Stopping); - - // Stop the QEMU process, which will bring down everything else. - await this.StopQemu(); - } - - async Reset() { - this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM'); - await this.StopQemu(); - } - - async QmpCommand(command: string, args: any | null): Promise { - return await this.qmpInstance?.execute(command, args); - } - - async MonitorCommand(command: string) { - this.AssertState(VMState.Started, 'cannot use QemuVM#MonitorCommand on a non-started VM'); - let result = await this.QmpCommand('human-monitor-command', { - 'command-line': command - }); - if (result == null) result = ''; - return result; - } - - async ChangeRemovableMedia(deviceName: string, imagePath: string): Promise { - 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!; - } - - GetState() { - return this.state; - } - - /// 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 GetVncPath() { - return `${kVmTmpPathBase}/cvmts-${this.definition.id}-vnc`; - } - - private async StartQemu(split: string) { - let self = this; - - this.SetState(VMState.Starting); - - this.VMLog().Info(`Starting QEMU with command \"${split}\"`); - - // Start QEMU - this.qemuProcess = execaCommand(split, { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe' - }); - - this.qemuProcess.stderr?.on('data', (data) => { - self.VMLog().Error('QEMU stderr: {0}', data.toString('utf8')); - }); - - this.qemuProcess.on('spawn', async () => { - self.VMLog().Info('QEMU started'); - await self.QmpStdioInit(); - }); - - this.qemuProcess.on('exit', async (code) => { - self.VMLog().Info('QEMU process exited'); - - // Disconnect from the display and QMP connections. - await self.DisconnectDisplay(); - - self.qmpInstance.reset(); - self.qmpInstance.setWriter(null); - - // Remove the VNC UDS socket. - try { - await unlink(this.GetVncPath()); - } catch (_) {} - - if (self.state != VMState.Stopping) { - if (code == 0) { - 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.'); - // Note that we've already tore down everything upon entry to this event handler; therefore - // we can simply set the state and move on. - this.SetState(VMState.Stopped); - } - } else { - // Indicate we have stopped. - this.SetState(VMState.Stopped); - } - }); - } - - private async StopQemu() { - if (this.qemuProcess) { - this.qemuProcess?.kill('SIGTERM'); - this.qemuProcess = null; - } - } - - private async QmpStdioInit() { - let self = this; - - self.VMLog().Info('Initializing QMP over stdio'); - - // Setup the QMP client. - let writer = new StdioWriter(this.qemuProcess?.stdout!, this.qemuProcess?.stdin!, self.qmpInstance); - self.qmpInstance.reset(); - self.qmpInstance.setWriter(writer); - } - - private async DisconnectDisplay() { - try { - this.display?.Disconnect(); - this.display = null; - } catch (err) { - // oh well lol - } - } -} diff --git a/qemu/src/QmpClient.ts b/qemu/src/QmpClient.ts deleted file mode 100644 index 3a31cf0..0000000 --- a/qemu/src/QmpClient.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { EventEmitter } from 'node:events'; - -enum QmpClientState { - Handshaking, - Connected -} - -function qmpStringify(obj: any) { - return JSON.stringify(obj) + '\r\n'; -} - -// this writer interface is used to poll back to a higher level -// I/O layer that we want to write some data. -export interface IQmpClientWriter { - writeSome(data: Buffer): void; -} - -export type QmpClientCallback = (err: Error | null, res: any | null) => void; - -type QmpClientCallbackEntry = { - id: number; - callback: QmpClientCallback | null; -}; - -export enum QmpEvent { - BlockIOError = 'BLOCK_IO_ERROR', - Reset = 'RESET', - Resume = 'RESUME', - RtcChange = 'RTC_CHANGE', - Shutdown = 'SHUTDOWN', - Stop = 'STOP', - VncConnected = 'VNC_CONNECTED', - VncDisconnected = 'VNC_DISCONNECTED', - VncInitialized = 'VNC_INITIALIZED', - Watchdog = 'WATCHDOG' -} - -class LineStream extends EventEmitter { - // The given line seperator for the stream - lineSeperator = '\r\n'; - buffer = ''; - - constructor() { - super(); - } - - push(data: Buffer) { - this.buffer += data.toString('utf-8'); - - let lines = this.buffer.split(this.lineSeperator); - if (lines.length > 1) { - this.buffer = lines.pop()!; - lines = lines.filter((l) => !!l); - lines.forEach(l => this.emit('line', l)); - } - } - - reset() { - this.buffer = ''; - } -} - -// A QMP client -export class QmpClient extends EventEmitter { - private state = QmpClientState.Handshaking; - private writer: IQmpClientWriter | null = null; - - private lastID = 0; - private callbacks = new Array(); - - private lineStream = new LineStream(); - - constructor() { - super(); - - let self = this; - this.lineStream.on('line', (line: string) => { - self.handleQmpLine(line); - }); - } - - setWriter(writer: IQmpClientWriter|null) { - this.writer = writer; - } - - feed(data: Buffer): void { - // Forward to the line stream. It will generate 'line' events - // as it is able to split out lines automatically. - this.lineStream.push(data); - } - - private handleQmpLine(line: string) { - let obj = JSON.parse(line); - - switch (this.state) { - case QmpClientState.Handshaking: - if (obj['return'] != undefined) { - // Once we get a return from our handshake execution, - // we have exited handshake state. - this.state = QmpClientState.Connected; - this.emit('connected'); - return; - } else if(obj['QMP'] != undefined) { - // Send a `qmp_capabilities` command, to exit handshake state. - // We do not support any of the supported extended QMP capabilities currently, - // and probably never will (due to their relative uselessness.) - let capabilities = qmpStringify({ - execute: 'qmp_capabilities' - }); - - this.writer?.writeSome(Buffer.from(capabilities, 'utf8')); - } - break; - - case QmpClientState.Connected: - if (obj['return'] != undefined || obj['error'] != undefined) { - if (obj['id'] == null) return; - - let cb = this.callbacks.find((v) => v.id == obj['id']); - if (cb == undefined) return; - - let error: Error | null = obj.error ? new Error(obj.error.desc) : null; - - if (cb.callback) cb.callback(error, obj.return || null); - - this.callbacks.slice(this.callbacks.indexOf(cb)); - } else if (obj['event']) { - this.emit(obj.event, { - timestamp: obj.timestamp, - data: obj.data - }); - } - break; - } - } - - // Executes a QMP command, using a user-provided callback for completion notification - executeCallback(command: string, args: any | undefined, callback: QmpClientCallback | null) { - let entry = { - callback: callback, - id: ++this.lastID - }; - - let qmpOut: any = { - execute: command, - id: entry.id - }; - - if (args !== undefined) qmpOut['arguments'] = args; - - this.callbacks.push(entry); - this.writer?.writeSome(Buffer.from(qmpStringify(qmpOut), 'utf8')); - } - - // Executes a QMP command asynchronously. - async execute(command: string, args: any | undefined = undefined): Promise { - return new Promise((res, rej) => { - this.executeCallback(command, args, (err, result) => { - if (err) rej(err); - res(result); - }); - }); - } - - reset() { - // Reset the line stream so it doesn't go awry - this.lineStream.reset(); - this.state = QmpClientState.Handshaking; - } -} diff --git a/qemu/src/index.ts b/qemu/src/index.ts deleted file mode 100644 index a3deb21..0000000 --- a/qemu/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// - -export * from './QemuDisplay.js'; -export * from './QemuUtil.js'; -export * from './QemuVM.js'; diff --git a/qemu/tsconfig.json b/qemu/tsconfig.json deleted file mode 120000 index 4ec6ff6..0000000 --- a/qemu/tsconfig.json +++ /dev/null @@ -1 +0,0 @@ -../tsconfig.json \ No newline at end of file diff --git a/shared/package.json b/shared/package.json deleted file mode 100644 index 1733d65..0000000 --- a/shared/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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" -} diff --git a/shared/src/Logger.ts b/shared/src/Logger.ts deleted file mode 100644 index 15cd6be..0000000 --- a/shared/src/Logger.ts +++ /dev/null @@ -1,50 +0,0 @@ -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) { - if(gLogLevel <= LogLevel.VERBOSE) - console.log(`[${this._component}] [VERBOSE] ${Format(pattern, ...args)}`); - } - - Info(pattern: string, ...args: Array) { - if(gLogLevel <= LogLevel.INFO) - console.log(`[${this._component}] [INFO] ${Format(pattern, ...args)}`); - } - - Warning(pattern: string, ...args: Array) { - if(gLogLevel <= LogLevel.WARNING) - console.warn(`[${this._component}] [WARNING] ${Format(pattern, ...args)}`); - } - - Error(pattern: string, ...args: Array) { - if(gLogLevel <= LogLevel.ERROR) - console.error(`[${this._component}] [ERROR] ${Format(pattern, ...args)}`); - } - - - -} diff --git a/shared/src/StringLike.ts b/shared/src/StringLike.ts deleted file mode 100644 index ecc6989..0000000 --- a/shared/src/StringLike.ts +++ /dev/null @@ -1,8 +0,0 @@ -// 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; diff --git a/shared/src/format.ts b/shared/src/format.ts deleted file mode 100644 index ae95f9d..0000000 --- a/shared/src/format.ts +++ /dev/null @@ -1,77 +0,0 @@ -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) { - let argumentsAsStrings: Array = [...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; -} diff --git a/shared/src/index.ts b/shared/src/index.ts deleted file mode 100644 index 82d7877..0000000 --- a/shared/src/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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; -}; diff --git a/shared/tsconfig.json b/shared/tsconfig.json deleted file mode 120000 index 4ec6ff6..0000000 --- a/shared/tsconfig.json +++ /dev/null @@ -1 +0,0 @@ -../tsconfig.json \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 2dc7fb3..087e547 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,18 +34,23 @@ __metadata: languageName: node linkType: hard -"@computernewb/nodejs-rfb@npm:*, @computernewb/nodejs-rfb@workspace:nodejs-rfb": - version: 0.0.0-use.local - resolution: "@computernewb/nodejs-rfb@workspace:nodejs-rfb" +"@computernewb/nodejs-rfb@npm:^0.3.0": + version: 0.3.0 + resolution: "@computernewb/nodejs-rfb@npm:0.3.0" + checksum: 10c0/915a76118011a4a64e28fcc91fafcffe659e5e86db2505554578f1e5dff4883dbaf5fcc013ac377719fe9c798e730b178aa5030027da0f5a9583355ec2ac8af2 + languageName: node + linkType: hard + +"@computernewb/superqemu@npm:^0.1.0": + version: 0.1.0 + resolution: "@computernewb/superqemu@npm:0.1.0" dependencies: - "@parcel/packager-ts": "npm:2.12.0" - "@parcel/transformer-typescript-types": "npm:2.12.0" - "@types/node": "npm:^20.12.7" - parcel: "npm:^2.12.0" - prettier: "npm:^3.2.5" - typescript: "npm:>=3.0.0" - languageName: unknown - linkType: soft + "@computernewb/nodejs-rfb": "npm:^0.3.0" + execa: "npm:^8.0.1" + pino: "npm:^9.3.1" + checksum: 10c0/7177b46c1093345cc3cbcc09450b8b8b09f09eb74ba5abd283aae39e9d1dbc0780f54187da075e44c78a7b683d47367010a473406c2817c36352edd0ddad2c1a + languageName: node + linkType: hard "@cvmts/collab-vm-1.2-binary-protocol@workspace:collab-vm-1.2-binary-protocol": version: 0.0.0-use.local @@ -70,14 +75,17 @@ __metadata: version: 0.0.0-use.local resolution: "@cvmts/cvmts@workspace:cvmts" dependencies: + "@computernewb/nodejs-rfb": "npm:^0.3.0" + "@computernewb/superqemu": "npm:^0.1.0" "@cvmts/cvm-rs": "npm:*" - "@cvmts/qemu": "npm:*" "@maxmind/geoip2-node": "npm:^5.0.0" "@types/node": "npm:^20.12.5" "@types/ws": "npm:^8.5.5" execa: "npm:^8.0.1" mnemonist: "npm:^0.39.5" msgpackr: "npm:^1.10.2" + pino: "npm:^9.3.1" + pino-pretty: "npm:^11.2.1" prettier: "npm:^3.2.5" sharp: "npm:^0.33.3" toml: "npm:^3.0.0" @@ -86,26 +94,6 @@ __metadata: languageName: unknown linkType: soft -"@cvmts/qemu@npm:*, @cvmts/qemu@workspace:qemu": - version: 0.0.0-use.local - resolution: "@cvmts/qemu@workspace:qemu" - dependencies: - "@computernewb/nodejs-rfb": "npm:*" - "@cvmts/shared": "npm:*" - execa: "npm:^8.0.1" - parcel: "npm:^2.12.0" - languageName: unknown - linkType: soft - -"@cvmts/shared@npm:*, @cvmts/shared@workspace:shared": - version: 0.0.0-use.local - resolution: "@cvmts/shared@workspace:shared" - dependencies: - "@protobuf-ts/plugin": "npm:^2.9.4" - parcel: "npm:^2.12.0" - languageName: unknown - linkType: soft - "@emnapi/runtime@npm:^1.1.0": version: 1.1.1 resolution: "@emnapi/runtime@npm:1.1.1" @@ -1335,57 +1323,6 @@ __metadata: languageName: node linkType: hard -"@protobuf-ts/plugin-framework@npm:^2.9.4": - version: 2.9.4 - resolution: "@protobuf-ts/plugin-framework@npm:2.9.4" - dependencies: - "@protobuf-ts/runtime": "npm:^2.9.4" - typescript: "npm:^3.9" - checksum: 10c0/2923852ab1d2d46090245a858fd362fffccd4f556963b01d153d4c5568dfa33d34101dacca0b1f38f23516e5a4d1f765e14be1d885dc1159d5ec37d25000f065 - languageName: node - linkType: hard - -"@protobuf-ts/plugin@npm:^2.9.4": - version: 2.9.4 - resolution: "@protobuf-ts/plugin@npm:2.9.4" - dependencies: - "@protobuf-ts/plugin-framework": "npm:^2.9.4" - "@protobuf-ts/protoc": "npm:^2.9.4" - "@protobuf-ts/runtime": "npm:^2.9.4" - "@protobuf-ts/runtime-rpc": "npm:^2.9.4" - typescript: "npm:^3.9" - bin: - protoc-gen-dump: bin/protoc-gen-dump - protoc-gen-ts: bin/protoc-gen-ts - checksum: 10c0/dbf1506e656d4d8ca91ace656cf3e238aed93d6539747c72c140fb0be29af61ccafae4e8c9f1e6f8369ac20508263d718ccb411dcf2d15276672c8ad7ba8194c - languageName: node - linkType: hard - -"@protobuf-ts/protoc@npm:^2.9.4": - version: 2.9.4 - resolution: "@protobuf-ts/protoc@npm:2.9.4" - bin: - protoc: protoc.js - checksum: 10c0/4ce4380cdab5560d13dd3b8d3538e6aee508a10b6b43dbd649d2ffe0a774129d59bd0e270ce7f643a95b9703e19088a5c725f68939913f2187fdeb1d6b42d4b5 - languageName: node - linkType: hard - -"@protobuf-ts/runtime-rpc@npm:^2.9.4": - version: 2.9.4 - resolution: "@protobuf-ts/runtime-rpc@npm:2.9.4" - dependencies: - "@protobuf-ts/runtime": "npm:^2.9.4" - checksum: 10c0/91fa7037b669dc92073d393dbe6bb109307d7b884506f6e5a310c6bde43b3920154b1176c826e9739c81ecd108090516b826e94354d58e454df2eef7f50f3a12 - languageName: node - linkType: hard - -"@protobuf-ts/runtime@npm:^2.9.4": - version: 2.9.4 - resolution: "@protobuf-ts/runtime@npm:2.9.4" - checksum: 10c0/78a10c0e2ee33fe98b3e30d15f8a52fe1a9505de3a8c056339bc01a0a076d4108a4efe93b578dc034c91c1b8c85996643b3f4d45f95c7e2bd5c151455b4fd23f - languageName: node - linkType: hard - "@swc/core-darwin-arm64@npm:1.4.17": version: 1.4.17 resolution: "@swc/core-darwin-arm64@npm:1.4.17" @@ -1534,7 +1471,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^20.12.5, @types/node@npm:^20.12.7": +"@types/node@npm:*, @types/node@npm:^20.12.5": version: 20.12.7 resolution: "@types/node@npm:20.12.7" dependencies: @@ -1577,6 +1514,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + "abortcontroller-polyfill@npm:^1.1.9": version: 1.7.5 resolution: "abortcontroller-polyfill@npm:1.7.5" @@ -1666,6 +1612,13 @@ __metadata: languageName: node linkType: hard +"atomic-sleep@npm:^1.0.0": + version: 1.0.0 + resolution: "atomic-sleep@npm:1.0.0" + checksum: 10c0/e329a6665512736a9bbb073e1761b4ec102f7926cce35037753146a9db9c8104f5044c1662e4a863576ce544fb8be27cd2be6bc8c1a40147d03f31eb1cfb6e8a + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1682,6 +1635,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.3.0 resolution: "binary-extensions@npm:2.3.0" @@ -1735,6 +1695,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + "cacache@npm:^18.0.0": version: 18.0.2 resolution: "cacache@npm:18.0.2" @@ -1898,6 +1868,13 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^2.0.7": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 10c0/e94116ff33b0ff56f3b83b9ace895e5bf87c2a7a47b3401b8c3f3226e050d5ef76cf4072fb3325f9dc24d1698f9b730baf4e05eeaf861d74a1883073f4c98a40 + languageName: node + linkType: hard + "commander@npm:^7.0.0, commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -2001,6 +1978,13 @@ __metadata: languageName: node linkType: hard +"dateformat@npm:^4.6.3": + version: 4.6.3 + resolution: "dateformat@npm:4.6.3" + checksum: 10c0/e2023b905e8cfe2eb8444fb558562b524807a51cdfe712570f360f873271600b5c94aebffaf11efb285e2c072264a7cf243eadb68f3eba0f8cc85fb86cd25df6 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -2190,6 +2174,20 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 + languageName: node + linkType: hard + "execa@npm:^8.0.1": version: 8.0.1 resolution: "execa@npm:8.0.1" @@ -2245,6 +2243,27 @@ __metadata: languageName: node linkType: hard +"fast-copy@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-copy@npm:3.0.2" + checksum: 10c0/02e8b9fd03c8c024d2987760ce126456a0e17470850b51e11a1c3254eed6832e4733ded2d93316c82bc0b36aeb991ad1ff48d1ba95effe7add7c3ab8d8eb554a + languageName: node + linkType: hard + +"fast-redact@npm:^3.1.1": + version: 3.5.0 + resolution: "fast-redact@npm:3.5.0" + checksum: 10c0/7e2ce4aad6e7535e0775bf12bd3e4f2e53d8051d8b630e0fa9e67f68cb0b0e6070d2f7a94b1d0522ef07e32f7c7cda5755e2b677a6538f1e9070ca053c42343a + languageName: node + linkType: hard + +"fast-safe-stringify@npm:^2.1.1": + version: 2.1.1 + resolution: "fast-safe-stringify@npm:2.1.1" + checksum: 10c0/d90ec1c963394919828872f21edaa3ad6f1dddd288d2bd4e977027afff09f5db40f94e39536d4646f7e01761d704d72d51dce5af1b93717f3489ef808f5f4e4d + languageName: node + linkType: hard + "fd-slicer@npm:~1.1.0": version: 1.1.0 resolution: "fd-slicer@npm:1.1.0" @@ -2406,6 +2425,13 @@ __metadata: languageName: node linkType: hard +"help-me@npm:^5.0.0": + version: 5.0.0 + resolution: "help-me@npm:5.0.0" + checksum: 10c0/054c0e2e9ae2231c85ab5e04f75109b9d068ffcc54e58fb22079822a5ace8ff3d02c66fd45379c902ad5ab825e5d2e1451fcc2f7eab1eb49e7d488133ba4cacb + languageName: node + linkType: hard + "htmlnano@npm:^2.0.0": version: 2.1.0 resolution: "htmlnano@npm:2.1.0" @@ -2498,6 +2524,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "immutable@npm:^4.0.0": version: 4.3.5 resolution: "immutable@npm:4.3.5" @@ -2650,6 +2683,13 @@ __metadata: languageName: node linkType: hard +"joycon@npm:^3.1.1": + version: 3.1.1 + resolution: "joycon@npm:3.1.1" + checksum: 10c0/131fb1e98c9065d067fd49b6e685487ac4ad4d254191d7aa2c9e3b90f4e9ca70430c43cad001602bdbdabcf58717d3b5c5b7461c1bd8e39478c8de706b3fe6ae + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -2950,6 +2990,13 @@ __metadata: languageName: node linkType: hard +"minimist@npm:^1.2.6": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 + languageName: node + linkType: hard + "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -3265,6 +3312,13 @@ __metadata: languageName: node linkType: hard +"on-exit-leak-free@npm:^2.1.0": + version: 2.1.2 + resolution: "on-exit-leak-free@npm:2.1.2" + checksum: 10c0/faea2e1c9d696ecee919026c32be8d6a633a7ac1240b3b87e944a380e8a11dc9c95c4a1f8fb0568de7ab8db3823e790f12bda45296b1d111e341aad3922a0570 + languageName: node + linkType: hard + "once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -3396,6 +3450,68 @@ __metadata: languageName: node linkType: hard +"pino-abstract-transport@npm:^1.0.0, pino-abstract-transport@npm:^1.2.0": + version: 1.2.0 + resolution: "pino-abstract-transport@npm:1.2.0" + dependencies: + readable-stream: "npm:^4.0.0" + split2: "npm:^4.0.0" + checksum: 10c0/b4ab59529b7a91f488440147fc58ee0827a6c1c5ca3627292339354b1381072c1a6bfa9b46d03ad27872589e8477ecf74da12cf286e1e6b665ac64a3b806bf07 + languageName: node + linkType: hard + +"pino-pretty@npm:^11.2.1": + version: 11.2.1 + resolution: "pino-pretty@npm:11.2.1" + dependencies: + colorette: "npm:^2.0.7" + dateformat: "npm:^4.6.3" + fast-copy: "npm:^3.0.2" + fast-safe-stringify: "npm:^2.1.1" + help-me: "npm:^5.0.0" + joycon: "npm:^3.1.1" + minimist: "npm:^1.2.6" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^1.0.0" + pump: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + secure-json-parse: "npm:^2.4.0" + sonic-boom: "npm:^4.0.1" + strip-json-comments: "npm:^3.1.1" + bin: + pino-pretty: bin.js + checksum: 10c0/6c7f15b5bf8a007c8b7157eae445675b13cd95097ffa512d5ebd661f9e7abd328fa27592b25708756a09f098f87cb03ca81837518cd725c16e3f801129b941d4 + languageName: node + linkType: hard + +"pino-std-serializers@npm:^7.0.0": + version: 7.0.0 + resolution: "pino-std-serializers@npm:7.0.0" + checksum: 10c0/73e694d542e8de94445a03a98396cf383306de41fd75ecc07085d57ed7a57896198508a0dec6eefad8d701044af21eb27253ccc352586a03cf0d4a0bd25b4133 + languageName: node + linkType: hard + +"pino@npm:^9.3.1": + version: 9.3.1 + resolution: "pino@npm:9.3.1" + dependencies: + atomic-sleep: "npm:^1.0.0" + fast-redact: "npm:^3.1.1" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^1.2.0" + pino-std-serializers: "npm:^7.0.0" + process-warning: "npm:^3.0.0" + quick-format-unescaped: "npm:^4.0.3" + real-require: "npm:^0.2.0" + safe-stable-stringify: "npm:^2.3.1" + sonic-boom: "npm:^4.0.1" + thread-stream: "npm:^3.0.0" + bin: + pino: bin.js + checksum: 10c0/ab1e81b3e5a91852136d80a592939883eeb81442e5d3a2c070bdbdeb47c5aaa297ead246530b10eb6d5ff59445f4645d1333d342f255d9f002f73aea843e74ee + languageName: node + linkType: hard + "postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" @@ -3456,6 +3572,20 @@ __metadata: languageName: node linkType: hard +"process-warning@npm:^3.0.0": + version: 3.0.0 + resolution: "process-warning@npm:3.0.0" + checksum: 10c0/60f3c8ddee586f0706c1e6cb5aa9c86df05774b9330d792d7c8851cf0031afd759d665404d07037e0b4901b55c44a423f07bdc465c63de07d8d23196bb403622 + languageName: node + linkType: hard + +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -3476,6 +3606,13 @@ __metadata: languageName: node linkType: hard +"quick-format-unescaped@npm:^4.0.3": + version: 4.0.4 + resolution: "quick-format-unescaped@npm:4.0.4" + checksum: 10c0/fe5acc6f775b172ca5b4373df26f7e4fd347975578199e7d74b2ae4077f0af05baa27d231de1e80e8f72d88275ccc6028568a7a8c9ee5e7368ace0e18eff93a4 + languageName: node + linkType: hard + "react-error-overlay@npm:6.0.9": version: 6.0.9 resolution: "react-error-overlay@npm:6.0.9" @@ -3490,6 +3627,19 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^4.0.0": + version: 4.5.2 + resolution: "readable-stream@npm:4.5.2" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10c0/a2c80e0e53aabd91d7df0330929e32d0a73219f9477dbbb18472f6fdd6a11a699fc5d172a1beff98d50eae4f1496c950ffa85b7cc2c4c196963f289a5f39275d + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -3499,6 +3649,13 @@ __metadata: languageName: node linkType: hard +"real-require@npm:^0.2.0": + version: 0.2.0 + resolution: "real-require@npm:0.2.0" + checksum: 10c0/23eea5623642f0477412ef8b91acd3969015a1501ed34992ada0e3af521d3c865bb2fe4cdbfec5fe4b505f6d1ef6a03e5c3652520837a8c3b53decff7e74b6a0 + languageName: node + linkType: hard + "regenerator-runtime@npm:^0.13.7": version: 0.13.11 resolution: "regenerator-runtime@npm:0.13.11" @@ -3520,13 +3677,20 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:^5.0.1": +"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 languageName: node linkType: hard +"safe-stable-stringify@npm:^2.3.1": + version: 2.4.3 + resolution: "safe-stable-stringify@npm:2.4.3" + checksum: 10c0/81dede06b8f2ae794efd868b1e281e3c9000e57b39801c6c162267eb9efda17bd7a9eafa7379e1f1cacd528d4ced7c80d7460ad26f62ada7c9e01dec61b2e768 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -3547,6 +3711,13 @@ __metadata: languageName: node linkType: hard +"secure-json-parse@npm:^2.4.0": + version: 2.7.0 + resolution: "secure-json-parse@npm:2.7.0" + checksum: 10c0/f57eb6a44a38a3eeaf3548228585d769d788f59007454214fab9ed7f01fbf2e0f1929111da6db28cf0bcc1a2e89db5219a59e83eeaec3a54e413a0197ce879e4 + languageName: node + linkType: hard + "semver@npm:^7.3.5, semver@npm:^7.5.2, semver@npm:^7.6.0": version: 7.6.0 resolution: "semver@npm:7.6.0" @@ -3687,6 +3858,15 @@ __metadata: languageName: node linkType: hard +"sonic-boom@npm:^4.0.1": + version: 4.0.1 + resolution: "sonic-boom@npm:4.0.1" + dependencies: + atomic-sleep: "npm:^1.0.0" + checksum: 10c0/7b467f2bc8af7ff60bf210382f21c59728cc4b769af9b62c31dd88723f5cc472752d2320736cc366acc7c765ddd5bec3072c033b0faf249923f576a7453ba9d3 + languageName: node + linkType: hard + "source-map-js@npm:>=0.6.2 <2.0.0": version: 1.2.0 resolution: "source-map-js@npm:1.2.0" @@ -3701,6 +3881,13 @@ __metadata: languageName: node linkType: hard +"split2@npm:^4.0.0": + version: 4.2.0 + resolution: "split2@npm:4.2.0" + checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534 + languageName: node + linkType: hard + "sprintf-js@npm:^1.1.3": version: 1.1.3 resolution: "sprintf-js@npm:1.1.3" @@ -3753,6 +3940,15 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:^1.3.0": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -3778,6 +3974,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:^3.1.1": + version: 3.1.1 + resolution: "strip-json-comments@npm:3.1.1" + checksum: 10c0/9681a6257b925a7fa0f285851c0e613cc934a50661fa7bb41ca9cbbff89686bb4a0ee366e6ecedc4daafd01e83eee0720111ab294366fe7c185e935475ebcecd + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -3834,6 +4037,15 @@ __metadata: languageName: node linkType: hard +"thread-stream@npm:^3.0.0": + version: 3.1.0 + resolution: "thread-stream@npm:3.1.0" + dependencies: + real-require: "npm:^0.2.0" + checksum: 10c0/c36118379940b77a6ef3e6f4d5dd31e97b8210c3f7b9a54eb8fe6358ab173f6d0acfaf69b9c3db024b948c0c5fd2a7df93e2e49151af02076b35ada3205ec9a6 + languageName: node + linkType: hard + "timsort@npm:^0.3.0": version: 0.3.0 resolution: "timsort@npm:0.3.0" @@ -3888,16 +4100,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^3.9": - version: 3.9.10 - resolution: "typescript@npm:3.9.10" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/863cc06070fa18a0f9c6a83265fb4922a8b51bf6f2c6760fb0b73865305ce617ea4bc6477381f9f4b7c3a8cb4a455b054f5469e6e41307733fe6a2bd9aae82f8 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A>=3.0.0#optional!builtin, typescript@patch:typescript@npm%3A^5.4.4#optional!builtin": version: 5.4.5 resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" @@ -3908,16 +4110,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^3.9#optional!builtin": - version: 3.9.10 - resolution: "typescript@patch:typescript@npm%3A3.9.10#optional!builtin::version=3.9.10&hash=3bd3d3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/9041fb3886e7d6a560f985227b8c941d17a750f2edccb5f9b3a15a2480574654d9be803ad4a14aabcc2f2553c4d272a25fd698a7c42692f03f66b009fb46883c - languageName: node - linkType: hard - "undici-types@npm:~5.26.4": version: 5.26.5 resolution: "undici-types@npm:5.26.5"