commit 7ec901885dbb2291e9c0f585e1b651d758d6fede Author: modeco80 Date: Tue Jul 16 07:59:02 2024 -0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb8db6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.parcel-cache +.yarn/ +dist/ +node_modules +yarn.lock \ No newline at end of file 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/LICENSE b/LICENSE new file mode 100644 index 0000000..e5edcd7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright 2023-2024 Lily Tsuru/modeco80 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..21a1235 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# superqemu + +A QEMU supervision library for Node.js diff --git a/examples/simple.js b/examples/simple.js new file mode 100644 index 0000000..20eb9f1 --- /dev/null +++ b/examples/simple.js @@ -0,0 +1,25 @@ +// A simple example of how to use superqemu. +// Note that this example requires a valid desktop environment to function +// due to `-display gtk`, but you can remove it and run it headless. + +import { QemuVM } from "../dist/index.js"; + +import pino from 'pino'; + +let logger = pino(); + +let vm = new QemuVM( + { + id: "testvm", + command: "qemu-system-x86_64 -M pc,hpet=off,accel=kvm -cpu host -m 512 -display gtk", + snapshot: true + } +); + +vm.on('statechange', (newState) => { + logger.info(`state changed to ${newState}`); +}); + +(async () => { + await vm.Start(); +})(); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa57296 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@computenewb/superqemu", + "version": "1.0.0", + "description": "A simple and easy to use QEMU supervision runtime for Node.js", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "files": [ + "./dist", + "LICENSE" + ], + "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": "^0.3.0", + "execa": "^8.0.1", + "pino": "^9.3.1" + }, + "devDependencies": { + "@parcel/packager-ts": "2.12.0", + "@parcel/transformer-typescript-types": "2.12.0", + "@types/node": "^20.14.10", + "parcel": "^2.12.0", + "pino-pretty": "^11.2.1", + "typescript": ">=3.0.0" + }, + "packageManager": "yarn@4.1.1" +} diff --git a/src/QemuDisplay.ts b/src/QemuDisplay.ts new file mode 100644 index 0000000..9d54b8d --- /dev/null +++ b/src/QemuDisplay.ts @@ -0,0 +1,150 @@ +import { VncClient } from '@computernewb/nodejs-rfb'; +import { EventEmitter } from 'node:events'; +import { Size, Rect, Clamp, BatchRects } from './QemuUtil.js'; + +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/src/QemuUtil.ts b/src/QemuUtil.ts new file mode 100644 index 0000000..a90ea5e --- /dev/null +++ b/src/QemuUtil.ts @@ -0,0 +1,55 @@ +export type Size = { + width: number; + height: number; +}; + +export type Rect = { + x: number; + y: number; + width: number; + height: number; +}; + +export function Clamp(input: number, min: number, max: number) { + return Math.min(Math.max(input, min), max); +} + +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/src/QemuVM.ts b/src/QemuVM.ts new file mode 100644 index 0000000..efce662 --- /dev/null +++ b/src/QemuVM.ts @@ -0,0 +1,277 @@ +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 pino from 'pino'; +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 declare interface QemuVM { + on(event: 'statechange', listener: (newState: VMState) => void): this; +} + +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: pino.Logger; + + constructor(def: QemuVmDefinition) { + super(); + this.definition = def; + this.logger = pino({ + name: `SuperQEMU.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.logger.info('QMP ready'); + + this.display = new QemuDisplay(this.GetVncPath()); + + self.display?.on('connected', () => { + // The VM can now be considered started + self.logger.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}/superqemu-${this.definition.id}-vnc`; + } + + private async StartQemu(split: string) { + let self = this; + + this.SetState(VMState.Starting); + + this.logger.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.logger.error(`QEMU stderr: ${data.toString('utf8')}`); + }); + + this.qemuProcess.on('spawn', async () => { + self.logger.info('QEMU started'); + await self.QmpStdioInit(); + }); + + this.qemuProcess.on('exit', async (code) => { + self.logger.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.logger.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.logger.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/src/QmpClient.ts b/src/QmpClient.ts new file mode 100644 index 0000000..bc7a704 --- /dev/null +++ b/src/QmpClient.ts @@ -0,0 +1,170 @@ +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/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fa28604 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export * from './QemuDisplay.js'; +export * from './QemuUtil.js'; +export * from './QemuVM.js'; +export * from './QmpClient.js'; diff --git a/tsconfig.json b/tsconfig.json new file mode 120000 index 0000000..4ec6ff6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1 @@ +../tsconfig.json \ No newline at end of file