/** * QEMU argument builder and process lifecycle management. * * Builds QEMU command-line arguments for x86_64 and aarch64 CHR, * manages process spawn/stop, and handles PID tracking. */ import { existsSync, writeFileSync, readFileSync, copyFileSync, statSync, openSync, closeSync, unlinkSync, truncateSync } from "node:fs"; import { join } from "node:path"; import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts"; import { QuickCHRError } from "./types.ts"; import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./platform.ts"; import { buildHostfwdString } from "./network.ts"; import { ensureDir } from "./state.ts"; import { restGet } from "./rest.ts"; export interface QemuLaunchConfig { arch: Arch; machineDir: string; bootDisk: { path: string; format: BootDiskFormat }; extraDisks?: { path: string; format: "qcow2" }[]; mem: number; cpu: number; ports: Record; /** @deprecated Use `networks` instead. */ network?: NetworkMode; /** Network interfaces with resolved QEMU args. */ networks: NetworkConfig[]; background: boolean; /** Port block base for this machine (portBase+6=monitor, +7=serial, +8=qga on Windows TCP). */ portBase: number; /** * Pre-detected QEMU accelerator (kvm/hvf/tcg). * If provided, skips the internal detectAccel() call so the same value * can be used for both QEMU args and boot timeout calculation. */ accel?: string; } /** Build the full QEMU command-line arguments array. */ export async function buildQemuArgs(config: QemuLaunchConfig): Promise { const { arch, machineDir, bootDisk, extraDisks, mem, cpu, ports, networks, background, portBase } = config; const qemuBin = requireQemu(arch); const accel = config.accel ?? await detectAccel(arch); const args: string[] = [qemuBin]; // Machine type if (arch === "x86") { args.push("-M", "pc"); } else { args.push("-M", "virt"); } // CPU and memory args.push("-m", String(mem), "-smp", String(cpu)); // Acceleration if (accel === "tcg") { args.push("-accel", "tcg,tb-size=256"); } else { args.push("-accel", accel); } // Use -cpu host for hardware-backed acceleration (KVM / HVF) to expose // the real host CPU features. This eliminates CPUID mismatch warnings // (e.g. SVM on nested-KVM CI runners) and improves guest performance. if (arch === "x86") { if (accel === "kvm" || accel === "hvf") { args.push("-cpu", "host"); } } else { if (accel === "hvf") { args.push("-cpu", "host"); } else { args.push("-cpu", "cortex-a710"); } } // UEFI firmware (arm64 only) if (arch === "arm64") { const fw = requireFirmware(); // The per-machine EFI vars pflash is qcow2, NOT raw: QEMU refuses // savevm/loadvm while any writable block device is non-qcow2 ("Device // 'pflash1' is writable but does not support snapshots"), which made // snapshots impossible on arm64 (#31). Legacy raw efi-vars.fd machines // are converted in place, preserving their NVRAM state. const varsPath = join(machineDir, "efi-vars.qcow2"); if (!existsSync(varsPath)) { await prepareEfiVars(fw.code, fw.vars, varsPath, join(machineDir, "efi-vars.fd")); } args.push( "-drive", `if=pflash,format=raw,readonly=on,unit=0,file=${fw.code}`, "-drive", `if=pflash,format=qcow2,unit=1,file=${varsPath}`, ); } // Boot disk drive if (arch === "arm64") { // ARM64: MUST use explicit virtio-blk-pci (not if=virtio which maps to MMIO) args.push( "-drive", `file=${bootDisk.path},format=${bootDisk.format},if=none,id=drive0`, "-device", "virtio-blk-pci,drive=drive0", ); } else { // x86: if=virtio is fine (maps to PCI on q35/pc) args.push("-drive", `file=${bootDisk.path},format=${bootDisk.format},if=virtio`); } // Extra disks if (extraDisks && extraDisks.length > 0) { for (let i = 0; i < extraDisks.length; i++) { const disk = extraDisks[i] as { path: string; format: "qcow2" }; const driveId = `drive${i + 1}`; if (arch === "arm64") { args.push( "-drive", `file=${disk.path},format=${disk.format},if=none,id=${driveId}`, "-device", `virtio-blk-pci,drive=${driveId}`, ); } else { args.push("-drive", `file=${disk.path},format=${disk.format},if=virtio`); } } } // Networking buildNetworkArgs(args, ports, networks); // Display args.push("-display", "none"); // Serial, monitor, QGA channels buildChannelArgs(args, arch, machineDir, background, portBase); return args; } /** Build networking arguments from NetworkConfig array. */ function buildNetworkArgs( args: string[], ports: Record, networks: NetworkConfig[], ): void { for (const net of networks) { // If resolution produced QEMU args, use those directly if (net.resolved?.qemuNetdevArgs) { args.push(...net.resolved.qemuNetdevArgs); continue; } // Fallback: build args from specifier (legacy path and simple cases) const spec = net.specifier; const id = net.id; if (spec === "vmnet-shared") { args.push( "-netdev", `vmnet-shared,id=${id}`, "-device", `virtio-net-pci,netdev=${id}`, ); } else if (typeof spec === "object" && spec.type === "vmnet-bridged") { args.push( "-netdev", `vmnet-bridged,id=${id},ifname=${spec.iface}`, "-device", `virtio-net-pci,netdev=${id}`, ); } else if (typeof spec === "object" && spec.type === "socket-listen") { args.push( "-netdev", `socket,id=${id},listen=:${spec.port}`, "-device", `virtio-net-pci,netdev=${id}`, ); } else if (typeof spec === "object" && spec.type === "socket-connect") { args.push( "-netdev", `socket,id=${id},connect=127.0.0.1:${spec.port}`, "-device", `virtio-net-pci,netdev=${id}`, ); } else if (typeof spec === "object" && spec.type === "socket-mcast") { args.push( "-netdev", `socket,id=${id},mcast=${spec.group}:${spec.port}`, "-device", `virtio-net-pci,netdev=${id}`, ); } else if (typeof spec === "object" && spec.type === "tap") { args.push( "-netdev", `tap,id=${id},ifname=${spec.ifname},script=no,downscript=no`, "-device", `virtio-net-pci,netdev=${id}`, ); } else { // Default: user-mode networking with port forwarding (only on the first user NIC) const hostfwd = buildHostfwdString(ports); args.push( "-netdev", `user,id=${id}${hostfwd ? "," + hostfwd : ""}`, "-device", `virtio-net-pci,netdev=${id}`, ); } } } /** Build serial/monitor/QGA channel arguments. */ function buildChannelArgs( args: string[], arch: Arch, machineDir: string, background: boolean, portBase: number, ): void { const isWin = process.platform === "win32"; let monitorSock: string; let serialSock: string; let qgaSock: string; if (isWin) { // QEMU on Windows: chardev socket bind() cannot handle \\.\pipe\ paths // on all builds. Use TCP localhost instead; the port block reserves +6/+7/+8 // for monitor/serial/qga IPC. monitorSock = `host=127.0.0.1,port=${portBase + 6}`; serialSock = `host=127.0.0.1,port=${portBase + 7}`; qgaSock = `host=127.0.0.1,port=${portBase + 8}`; } else { monitorSock = `path=${join(machineDir, "monitor.sock")}`; serialSock = `path=${join(machineDir, "serial.sock")}`; qgaSock = `path=${join(machineDir, "qga.sock")}`; } if (background) { // Background: all channels on sockets/pipes args.push( "-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`, "-mon", "chardev=monitor0,mode=readline", "-chardev", `socket,id=serial0,${serialSock},server=on,wait=off`, "-serial", "chardev:serial0", ); } else { // Foreground: serial on stdio with mux (Ctrl-A C for monitor). // Also bind a socket monitor so monitorCommand() works while in fg mode. args.push( "-chardev", "stdio,id=serial0,mux=on,signal=off", "-mon", "chardev=serial0,mode=readline", "-serial", "chardev:serial0", "-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`, "-mon", "chardev=monitor0,mode=readline", ); } // QGA channel — x86 only. RouterOS QGA requires KVM; arm64 CHR does not implement QGA. if (arch === "x86") { args.push( "-device", "virtio-serial-pci,id=virtio-serial-qga", "-chardev", `socket,id=qga0,${qgaSock},server=on,wait=off`, "-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0,id=qga-port0", ); } } /** Build the per-machine qcow2 EFI vars pflash: start from the legacy raw * vars file if the machine has one (preserves NVRAM state), else from the * firmware template; size-match to the code ROM; convert raw → qcow2 so * savevm/loadvm work (see the pflash comment at the call site / #31). */ async function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string, legacyRawPath: string): Promise { ensureDir(join(destPath, "..")); // Stage the raw image (legacy per-machine file wins over the template). const rawStage = `${destPath}.raw-stage`; copyFileSync(existsSync(legacyRawPath) ? legacyRawPath : varsTemplatePath, rawStage); // pflash units must be identical size — pad/truncate vars to match code. // truncateSync extends with zero bytes when target is larger. const codeSize = statSync(codePath).size; try { if (statSync(rawStage).size !== codeSize) truncateSync(rawStage, codeSize); } catch (e) { try { unlinkSync(rawStage); } catch { /* best effort */ } throw new QuickCHRError( "PROCESS_FAILED", `Failed to size-match EFI vars: ${e instanceof Error ? e.message : String(e)}`, ); } const qemuImg = requireQemuImg(); const conversion = Bun.spawn([qemuImg, "convert", "-f", "raw", "-O", "qcow2", rawStage, destPath], { stdout: "pipe", stderr: "pipe", }); const exitCode = await conversion.exited; const [stderr, stdout] = await Promise.all([ new Response(conversion.stderr).text(), new Response(conversion.stdout).text(), ]); try { unlinkSync(rawStage); } catch { /* best effort */ } if (exitCode !== 0) { // A failed convert can leave a partial destPath behind — remove it so the // next launch regenerates instead of booting (and re-failing on) the stub. try { unlinkSync(destPath); } catch { /* best effort */ } throw new QuickCHRError( "PROCESS_FAILED", `qemu-img convert of EFI vars failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`, ); } // Legacy raw file is superseded — remove so nothing boots the stale copy. if (existsSync(legacyRawPath)) { try { unlinkSync(legacyRawPath); } catch { /* best effort */ } } } /** Extract socket_vmnet wrapper from resolved networks (at most one allowed). */ export function extractWrapper( networks: NetworkConfig[], ): { command: string; args: string[] } | undefined { const wrappers: string[][] = []; for (const n of networks) { if (n.resolved?.wrapper) { wrappers.push(n.resolved.wrapper); } } if (wrappers.length > 1) { throw new QuickCHRError( "INVALID_NETWORK", "Multiple networks require socket_vmnet wrapper — only one is supported per VM", ); } const w = wrappers[0]; if (!w || w.length === 0) return undefined; const [command, ...wrapperArgs] = w; if (!command) return undefined; return { command, args: wrapperArgs }; } /** Parse QEMU log output and produce a human-friendly error message. */ export function buildQemuErrorMessage(log: string): string { if (log.includes("Permission denied")) { return `QEMU exited immediately (permission denied — check image file permissions):\n${log}`; } if (log.includes("pflash") || (log.includes("EFI") && log.includes("size"))) { return `QEMU exited immediately (EFI firmware size mismatch — try 'quickchr clean ' to reset):\n${log}`; } if (log.includes("Address already in use")) { return `QEMU exited immediately (port already in use — another process may be using the same ports):\n${log}`; } return `QEMU exited immediately:\n${log}`; } /** Spawn QEMU process. Returns the process or PID. */ export async function spawnQemu( qemuArgs: string[], machineDir: string, background: boolean, wrapper?: { command: string; args: string[] }, ): Promise<{ pid: number; process?: ReturnType }> { ensureDir(machineDir); const logPath = join(machineDir, "qemu.log"); const pidPath = join(machineDir, "qemu.pid"); const [bin, ...args] = qemuArgs; if (!bin) throw new QuickCHRError("SPAWN_FAILED", "Empty QEMU args"); let spawnCmd: string[]; if (wrapper) { // socket_vmnet wrapper path logged at debug level — full paths belong in `doctor` if (process.env.QUICKCHR_DEBUG === "1") { process.stderr.write(`[quickchr] Using socket_vmnet wrapper: ${wrapper.command} ${wrapper.args.join(" ")}\n`); } spawnCmd = [wrapper.command, ...wrapper.args, bin, ...args]; } else { spawnCmd = [bin, ...args]; } if (background) { // Open log file as an fd — more reliable than BunFile for subprocess stdio. // After spawn, close parent's copy; child process inherits its own copy. const logFd = openSync(logPath, "a"); let spawnedPid: number; if (process.platform === "win32") { // On Windows, Bun.spawn().unref() does NOT escape the Windows Job Object. // When the parent CLI process exits, Windows terminates all processes in // the same job, killing QEMU. Use node:child_process with detached: true // to create QEMU in a new process group outside the parent job object. const { spawn: cpSpawn } = await import("node:child_process"); const [spawnBin, ...spawnArgs] = spawnCmd; if (!spawnBin) throw new QuickCHRError("SPAWN_FAILED", "Empty QEMU args"); const child = cpSpawn(spawnBin, spawnArgs, { detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true, }); closeSync(logFd); if (child.pid === undefined) { throw new QuickCHRError("SPAWN_FAILED", "Failed to spawn QEMU process on Windows"); } child.unref(); spawnedPid = child.pid; } else { const proc = Bun.spawn(spawnCmd, { stdout: logFd, stderr: logFd, stdin: "ignore", }); closeSync(logFd); // unref() lets the parent Bun process exit without waiting for QEMU. // QEMU becomes an orphan adopted by init/launchd and keeps running. proc.unref(); spawnedPid = proc.pid; } // Give QEMU 1.5 s to fully initialise — if it exits immediately the // arguments were bad (e.g. port already in use). Detect this early so // the caller gets a clear error instead of a 120-second waitForBoot. await Bun.sleep(1500); try { process.kill(spawnedPid, 0); } catch { let logTail = "(no log)"; try { if (existsSync(logPath)) { logTail = readFileSync(logPath, "utf-8").slice(-800); } } catch { /* ignore */ } throw new QuickCHRError("SPAWN_FAILED", buildQemuErrorMessage(logTail)); } writeFileSync(pidPath, String(spawnedPid)); return { pid: spawnedPid }; } // Foreground — stdin/stdout connected to terminal. Blocks until QEMU exits. const proc = Bun.spawn(spawnCmd, { stdout: "inherit", stderr: "inherit", stdin: "inherit", }); const pid = proc.pid; writeFileSync(pidPath, String(pid)); await proc.exited; try { unlinkSync(pidPath); } catch { /* ignore */ } return { pid }; } /** Stop a QEMU process by PID. Tries SIGTERM first, then SIGKILL. */ export async function stopQemu(pid: number): Promise { try { process.kill(pid, 0); // Check if alive } catch { return false; // Already dead } // Try graceful shutdown process.kill(pid, "SIGTERM"); // Wait up to 5s for graceful exit for (let i = 0; i < 50; i++) { await Bun.sleep(100); try { process.kill(pid, 0); } catch { return true; // Exited } } // Force kill try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ } return true; } /** Remove the listening unix-socket files QEMU binds (monitor/serial/qga). * A SIGKILLed QEMU leaves these behind; a respawn with `server=on` then fails * to bind ("Address already in use"). Call before re-spawning into the same dir. */ export function cleanupQemuSockets(machineDir: string): void { for (const sock of ["monitor.sock", "serial.sock", "qga.sock"]) { const path = join(machineDir, sock); try { if (existsSync(path)) unlinkSync(path); } catch { /* ignore */ } } } /** Stop a machine by name. */ export async function stopMachineByName(_name: string, state: MachineState): Promise { if (!state.pid) return false; const stopped = await stopQemu(state.pid); cleanupQemuSockets(state.machineDir); return stopped; } /** * Wait for the RouterOS REST layer to be fully stable. * * RouterOS startup is staged from our perspective: * 1. Connection refused — QEMU still booting * 2. ECONNRESET — HTTP server started but REST subsystem not yet accepting * 3. 401 from /rest — auth middleware is up; REST handler may still be initializing * 4. 200 but wrong body — startup race: REST initializes before routing tables settle; /system/resource may return an array (e.g. /user list) for a brief window * 5. 200 with correct body — REST is fully operational * * With proper credentials this function reaches stage 5 before returning. With * unauthenticated or admin-disabled instances (where we get 401) it accepts * stage 3 — auth rejection proves the REST layer responded, which is sufficient * for callers that do not need body validation. * * Requires 2 consecutive successful probes to prevent false-positives during * the brief window where RouterOS intermittently resets new connections * immediately after coming up. * * @param httpPort HTTP port of the CHR instance. * @param timeoutMs Overall deadline (default 120 s). * @param auth HTTP Basic Authorization header to use for probes. * Defaults to `admin:` (empty password) which works on fresh * CHR images; pass instance credentials for post-reboot probes * on provisioned machines so body validation can be performed. */ export async function waitForBoot( httpPort: number, timeoutMs: number = 120_000, auth: string = `Basic ${btoa("admin:")}`, ): Promise { const url = `http://127.0.0.1:${httpPort}/rest/system/resource`; const deadline = Date.now() + timeoutMs; let consecutiveReady = 0; while (Date.now() < deadline) { try { const { status, body } = await restGet(url, auth, 3000); if (status === 401 || status === 403) { // Auth rejected — the REST layer is up and responded, even though // we cannot validate the body. Count as ready. consecutiveReady++; } else if (status >= 200 && status < 300) { // RouterOS REST may return wrong data (array body) briefly after boot — // a startup race. Only accept once the body is the expected singleton object. let parsed: unknown = null; try { parsed = JSON.parse(body); } catch { /* ignore */ } if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "board-name" in (parsed as object)) { consecutiveReady++; } else { consecutiveReady = 0; // wrong body — REST not fully initialized yet } } else { consecutiveReady = 0; } if (consecutiveReady >= 2) return true; } catch { // ECONNRESET, connection refused, timeout — not ready yet consecutiveReady = 0; } await Bun.sleep(2000); } return false; }