/** * Communication channels — QEMU monitor, serial console, QGA (QEMU Guest Agent). * * Uses Unix domain sockets stored in the machine directory on POSIX. On Windows, * launched machines use TCP localhost endpoints derived from portBase; named pipes * remain only as the no-portBase fallback path. * QGA protocol implementation lives in qga.ts — this module provides * the high-level qgaCommand() wrapper with arch guards and path resolution. */ import { existsSync } from "node:fs"; import { join, basename } from "node:path"; import { connect } from "node:net"; import type { Arch, ChannelTcpEndpoint, QgaCommand } from "./types.ts"; import { QuickCHRError } from "./types.ts"; import { qgaProbe, qgaRawCommand } from "./qga.ts"; import { qgaKvmWarning } from "./platform.ts"; /** * Resolve the IPC path for a QEMU channel (monitor, serial, qga). * On Unix: a .sock file in machineDir. * On Windows: a named pipe — both QEMU and Node.js net.connect() recognize \\.\pipe\ paths. */ export function channelPath(machineDir: string, channel: "monitor" | "serial" | "qga"): string { if (process.platform === "win32") { return `\\\\.\\pipe\\quickchr-${basename(machineDir)}-${channel}`; } return join(machineDir, `${channel}.sock`); } /** * Resolve the IPC connection options for a QEMU channel. * On Windows with a portBase, returns a TCP endpoint (QEMU's Winsock bind() can't handle named pipes). * Otherwise returns the socket path string (Unix socket or Windows named pipe fallback). */ export function channelEndpoint( machineDir: string, channel: "monitor" | "serial" | "qga", portBase?: number, ): string | ChannelTcpEndpoint { if (process.platform === "win32" && portBase !== undefined) { const OFFSETS: Record = { monitor: 6, serial: 7, qga: 8 }; return { host: "127.0.0.1", port: portBase + (OFFSETS[channel] ?? 6) }; } return channelPath(machineDir, channel); } /** Return true if the channel IPC endpoint can be checked via existsSync (Unix only). */ export function channelFileExists(path: string): boolean { if (process.platform === "win32") { // Named pipes don't appear as regular filesystem entries — let connect() fail instead. return true; } return existsSync(path); } /** Strip ANSI escapes and the monitor's per-character command echo, leaving * just the response body. The echo hid real errors from callers: QEMU renders * the typed command as incremental `cmd\x1b[K\x1b[D…` fragments before the * first newline, so an `Error: …` response never appeared at string start and * `savevm`/`loadvm` failures were silently swallowed (#31). */ export function cleanMonitorResponse(raw: string): string { // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are the point const noAnsi = raw.replace(/\u001b\[[0-9;]*[A-Za-z]/g, ""); const lines = noAnsi.split(/\r?\n/); // Everything before the first newline is the echoed command; the response // (possibly multi-line, possibly empty) follows. return lines.slice(1).join("\n").trim(); } /** Send a command to the QEMU monitor and return the cleaned response * (command echo and ANSI control sequences stripped — see cleanMonitorResponse). */ export async function monitorCommand( machineDir: string, command: string, timeoutMs: number = 5000, portBase?: number, ): Promise { const socketPath = channelPath(machineDir, "monitor"); if (!channelFileExists(socketPath)) { throw new QuickCHRError( "MACHINE_STOPPED", "Monitor socket not found — is the machine running in background mode?", ); } const endpoint = channelEndpoint(machineDir, "monitor", portBase); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { socket.destroy(); reject(new QuickCHRError("BOOT_TIMEOUT", "Monitor command timed out")); }, timeoutMs); let buffer = ""; let sentCommand = false; const socket = typeof endpoint === "string" ? connect({ path: endpoint }) : connect(endpoint.port, endpoint.host); socket.on("data", (data) => { buffer += data.toString(); // Wait for the (qemu) prompt before sending command if (!sentCommand && buffer.includes("(qemu)")) { sentCommand = true; buffer = ""; socket.write(command + "\n"); } else if (sentCommand && buffer.includes("(qemu)")) { // Got response — strip the prompt, the command echo, and ANSI noise clearTimeout(timeout); const response = cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, "")); socket.destroy(); resolve(response); } }); // When the socket closes after we've already sent the command, QEMU exited. // This is the normal outcome for `quit` — no further (qemu) prompt is sent. socket.on("close", () => { clearTimeout(timeout); if (sentCommand) { resolve(cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, ""))); } else { reject(new QuickCHRError("MACHINE_STOPPED", "Monitor socket closed before command could be sent")); } }); socket.on("error", (err) => { clearTimeout(timeout); const code = (err as NodeJS.ErrnoException).code; // ENOENT = named pipe not found (Windows); ECONNREFUSED = server not listening if (code === "ENOENT" || code === "ECONNREFUSED") { reject(new QuickCHRError("MACHINE_STOPPED", "Monitor not available — is the machine running in background mode?")); } else { reject(new QuickCHRError("PROCESS_FAILED", `Monitor connection failed: ${err.message}`)); } }); }); } /** Get a readable/writable stream pair for the serial console. */ export function serialStreams(machineDir: string, portBase?: number): { readable: ReadableStream; writable: WritableStream; } { const socketPath = channelPath(machineDir, "serial"); if (!channelFileExists(socketPath)) { throw new QuickCHRError( "MACHINE_STOPPED", "Serial socket not found — is the machine running in background mode?", ); } const endpoint = channelEndpoint(machineDir, "serial", portBase); const socket = typeof endpoint === "string" ? connect({ path: endpoint }) : connect(endpoint.port, endpoint.host); const readable = new ReadableStream({ start(controller) { socket.on("data", (chunk: Buffer) => { controller.enqueue(new Uint8Array(chunk)); }); socket.on("end", () => controller.close()); socket.on("error", (err) => controller.error(err)); }, cancel() { socket.destroy(); }, }); const writable = new WritableStream({ write(chunk) { return new Promise((resolve, reject) => { socket.write(chunk, (err) => { if (err) reject(err); else resolve(); }); }); }, close() { socket.destroy(); }, }); return { readable, writable }; } /** Send a QGA (QEMU Guest Agent) command via Unix socket. x86 only. * Delegates to qga.ts for protocol handling — this wrapper adds arch guard, * KVM availability warning, and socket path resolution. */ export async function qgaCommand( machineDir: string, arch: Arch, command: QgaCommand, args?: object, timeoutMs: number = 10000, portBase?: number, ): Promise { if (arch === "arm64") { throw new QuickCHRError( "QGA_UNSUPPORTED", "QEMU Guest Agent is not yet functional on ARM64 CHR — MikroTik arm64 guest agent support is planned but not yet released", ); } const kvmWarning = qgaKvmWarning(); if (kvmWarning) { console.warn(kvmWarning); } const socketPath = channelPath(machineDir, "qga"); if (!channelFileExists(socketPath)) { throw new QuickCHRError( "MACHINE_STOPPED", "QGA socket not found — is the machine running in background mode?", ); } const endpoint = channelEndpoint(machineDir, "qga", portBase); return qgaRawCommand(endpoint, command, args as Record | undefined, timeoutMs); } /** Check whether QGA is available and responding for a machine. */ export function isQgaReady( machineDir: string, arch: Arch, portBase?: number, ): Promise { if (arch === "arm64") return Promise.resolve(false); const socketPath = channelPath(machineDir, "qga"); // On Unix, skip the connect attempt entirely if the socket file doesn't exist. if (process.platform !== "win32" && !existsSync(socketPath)) return Promise.resolve(false); const endpoint = channelEndpoint(machineDir, "qga", portBase); return qgaProbe(endpoint, 5000); }