"""QMP client, exposing typed verbs and nothing else. QMP IS A CAPABILITY, NOT A DEBUG PORT The monitor this talks to can run `human-monitor-command` (arbitrary monitor commands), `migrate exec:` (spawn a process), `blockdev-add` (open any path) and `screendump` with a caller-chosen filename (write any path the qemu uid can reach). Handing an agent a `qmp(command, args)` tool would therefore be handing it the host. So nothing in this module takes a command name from its caller. The public surface is a fixed list of verbs, each of which builds its own command from constants, and the one place a caller-supplied value reaches QEMU at all - a key name - is checked against QEMU's own QKeyCode enum first. Screenshot paths are chosen here, never passed in. The socket itself is 0600 inside a 0700 directory, so this is the second line of defence rather than the first. """ from __future__ import annotations import json import os import socket import time from typing import Any # QEMU 8.2's QKeyCode, read out of `query-qmp-schema` on this host rather # than typed from the documentation. A key name that is not in here is a # typo or an attempt at something else; either way it is refused rather # than forwarded. QKEYCODES = frozenset({ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "ac_back", "ac_bookmarks", "ac_forward", "ac_home", "ac_refresh", "again", "alt", "alt_r", "apostrophe", "asterisk", "audiomute", "audionext", "audioplay", "audioprev", "audiostop", "b", "backslash", "backspace", "bracket_left", "bracket_right", "c", "calculator", "caps_lock", "comma", "compose", "computer", "copy", "ctrl", "ctrl_r", "cut", "d", "delete", "dot", "down", "e", "end", "equal", "esc", "f", "f1", "f10", "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18", "f19", "f2", "f20", "f21", "f22", "f23", "f24", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "find", "front", "g", "grave_accent", "h", "help", "henkan", "hiragana", "home", "i", "insert", "j", "k", "katakanahiragana", "kp_0", "kp_1", "kp_2", "kp_3", "kp_4", "kp_5", "kp_6", "kp_7", "kp_8", "kp_9", "kp_add", "kp_comma", "kp_decimal", "kp_divide", "kp_enter", "kp_equals", "kp_multiply", "kp_subtract", "l", "lang1", "lang2", "left", "less", "lf", "m", "mail", "mediaselect", "menu", "meta_l", "meta_r", "minus", "muhenkan", "n", "num_lock", "o", "open", "p", "paste", "pause", "pgdn", "pgup", "power", "print", "props", "q", "r", "ret", "right", "ro", "s", "scroll_lock", "semicolon", "shift", "shift_r", "slash", "sleep", "spc", "stop", "sysrq", "t", "tab", "u", "undo", "unmapped", "up", "v", "volumedown", "volumeup", "w", "wake", "x", "y", "yen", "z", }) # The absolute pointing device reports on a fixed axis range regardless of # the guest's resolution, so every coordinate is scaled into this. ABS_MAX = 32767 # Where a screendump or a saved machine may be written. This is a # security control, not a convenience: QMP's screendump takes a # caller-chosen filename and is therefore an arbitrary file write, so the # destination is pinned to the overlay dataset and nowhere else. The pool # is a setting (see project.py); the confinement is not. OVERLAY_PREFIX = ((os.environ.get("OWUIVM_POOL_ROOT") or "/Pool").rstrip("/") or "/Pool") + "/vmrun/" BUTTONS = frozenset({"left", "right", "middle"}) class QmpError(RuntimeError): """QEMU refused a command, or the monitor is not answering.""" class Qmp: def __init__(self, path: str, timeout: float = 10.0) -> None: self.path = path self.timeout = timeout self._sock: socket.socket | None = None self._buf = b"" # -- plumbing ------------------------------------------------------ def __enter__(self) -> "Qmp": self.connect() return self def __exit__(self, *_exc: object) -> None: self.close() def connect(self) -> None: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(self.timeout) sock.connect(self.path) self._sock = sock self._read_json() # the greeting self._execute("qmp_capabilities") def close(self) -> None: if self._sock is not None: try: self._sock.close() finally: self._sock = None def _read_json(self) -> dict[str, Any]: assert self._sock is not None while True: line, sep, rest = self._buf.partition(b"\n") if sep: self._buf = rest if line.strip(): return json.loads(line) continue chunk = self._sock.recv(65536) if not chunk: raise QmpError("the QEMU monitor closed the connection") self._buf += chunk def _execute(self, command: str, **arguments: Any) -> Any: """Private on purpose. See the module docstring.""" assert self._sock is not None message: dict[str, Any] = {"execute": command} if arguments: message["arguments"] = arguments self._sock.sendall(json.dumps(message).encode() + b"\n") while True: reply = self._read_json() if "event" in reply: # asynchronous, not our answer continue if "error" in reply: raise QmpError(f"{command}: {reply['error'].get('desc', reply['error'])}") return reply.get("return") # -- the verbs ----------------------------------------------------- def status(self) -> str: return str(self._execute("query-status").get("status", "unknown")) def screenshot(self, path: str) -> str: """Write a PNG of the console. The caller does not choose the path. `format` is measured, not assumed: query-qmp-schema on QEMU 8.2 here reports screendump's format enum as ['ppm', 'png']. """ if not os.path.isabs(path): raise QmpError("internal error: screenshot path must be absolute") self._execute("screendump", filename=path, format="png") return path def save_state_to_fd(self, fd: int, timeout: float = 900.0) -> int: """Write the machine's state to a file descriptor the CALLER opened. WHY A DESCRIPTOR AND NOT A PATH. qemu runs as the sandbox user and creates its files 0600, owned by itself - so a state file it writes by path is one vmd then cannot read, and the obvious fixes are both wrong. Copying it needs somebody to relax the mode on four gigabytes of machine state; letting qemu write straight into the saved-machine directory would mean the process running guest code can write the thing a later session resumes from, which is the one file that must not be writable by it. Passing a descriptor settles it: vmd creates the file, in vmd's own directory, owned by vmd, and qemu writes through a handle it was given and cannot name. """ assert self._sock is not None message = json.dumps({"execute": "getfd", "arguments": {"fdname": "statefd"}}).encode() + b"\n" socket.send_fds(self._sock, [message], [fd]) while True: reply = self._read_json() if "event" in reply: continue if "error" in reply: raise QmpError("getfd: " + str(reply["error"].get("desc", reply["error"]))) break self._execute("migrate-set-parameters", **{"max-bandwidth": 0}) self._execute("migrate", uri="fd:statefd") deadline = time.monotonic() + timeout while time.monotonic() < deadline: status = self._execute("query-migrate") state = status.get("status") if state == "completed": return int((status.get("ram") or {}).get("total", 0)) if state in ("failed", "cancelled"): raise QmpError(f"saving the machine {state}: " f"{status.get('error-desc', 'no reason given')}") time.sleep(0.5) raise QmpError("saving the machine did not finish in time") def save_state(self, path: str, timeout: float = 600.0) -> int: """Write the machine's state to a file, and wait for it to finish. `file:` and not `exec:`. Measured: `exec:` fails with "Unable to write to command: Broken pipe" because the unit's syscall filter stops qemu spawning the helper - the sandbox working as intended. `file:` opens a file and needs nobody. The machine is left paused afterwards (`postmigrate`), which is what makes the result trustworthy: a machine that kept running would have a disk that no longer matches the memory just saved. """ if not path.startswith(OVERLAY_PREFIX) or ".." in path: raise QmpError(f"state may only be written to the run area: {path}") self._execute("migrate-set-parameters", **{"max-bandwidth": 0}) self._execute("migrate", uri=f"file:{path}") deadline = time.monotonic() + timeout while time.monotonic() < deadline: status = self._execute("query-migrate") state = status.get("status") if state == "completed": return int((status.get("ram") or {}).get("total", 0)) if state in ("failed", "cancelled"): raise QmpError(f"saving the machine {state}: " f"{status.get('error-desc', 'no reason given')}") time.sleep(0.5) raise QmpError("saving the machine did not finish in time") def resume(self) -> str: """Start a machine that was loaded from a saved state.""" status = self.status() if status in ("inmigrate", "paused", "prelaunch", "postmigrate"): self._execute("cont") return self.status() def attach_share(self, socket_path: str) -> None: """Plug the project share into a machine that resumed without one. Two commands, both built here from constants, because this is the one place a path from outside becomes a device. The socket is virtiofsd's, created by vmd moments earlier; everything else - the tag, the bus, the address - matches what a cold boot puts on the same port, so the guest sees one device either way rather than two that differ by how the session started. """ if not socket_path.startswith("/run/owuivm/") or ".." in socket_path: raise QmpError(f"that is not a share socket this host made: {socket_path}") self._execute("chardev-add", id="chfs", backend={"type": "socket", "data": {"addr": {"type": "unix", "data": {"path": socket_path}}, "server": False}}) self._execute("device_add", driver="vhost-user-fs-pci", id="vfs0", chardev="chfs", tag="project", bus="vfsport", addr="0x0") def key(self, keys: list[str], hold_ms: int = 60) -> None: """Press a chord. Every name is checked against QEMU's own enum.""" if not keys: raise QmpError("no keys were given") if len(keys) > 5: raise QmpError("a chord of more than five keys is not a chord") bad = [k for k in keys if k not in QKEYCODES] if bad: raise QmpError( f"not key names QEMU knows: {', '.join(bad)}. " "Use names like 'ctrl', 'alt', 'ret', 'tab', 'esc', 'a', 'f4'." ) self._execute( "send-key", keys=[{"type": "qcode", "data": k} for k in keys], **{"hold-time": max(1, min(int(hold_ms), 2000))}, ) def move(self, x: int, y: int, width: int, height: int) -> None: self._execute("input-send-event", events=self._abs_events(x, y, width, height)) def click(self, x: int, y: int, width: int, height: int, button: str = "left") -> None: if button not in BUTTONS: raise QmpError(f"'{button}' is not a mouse button; use left, right or middle") # Move first, in one batch, then press and release. A click sent # without a preceding position lands wherever the pointer already # was, which is the sort of bug that looks like the model being # bad at aiming. self._execute("input-send-event", events=self._abs_events(x, y, width, height)) self._execute("input-send-event", events=[{"type": "btn", "data": {"down": True, "button": button}}]) self._execute("input-send-event", events=[{"type": "btn", "data": {"down": False, "button": button}}]) def scroll(self, x: int, y: int, width: int, height: int, amount: int) -> None: button = "wheel-up" if amount > 0 else "wheel-down" self._execute("input-send-event", events=self._abs_events(x, y, width, height)) for _ in range(min(abs(int(amount)), 20)): self._execute("input-send-event", events=[{"type": "btn", "data": {"down": True, "button": button}}, {"type": "btn", "data": {"down": False, "button": button}}]) def powerdown(self) -> None: """ACPI power button: the guest gets to shut itself down cleanly.""" self._execute("system_powerdown") def reset(self) -> None: self._execute("system_reset") # -- internals ----------------------------------------------------- @staticmethod def _abs_events(x: int, y: int, width: int, height: int) -> list[dict[str, Any]]: if width <= 0 or height <= 0: raise QmpError("internal error: screen size is not known") cx = max(0, min(int(x), width - 1)) cy = max(0, min(int(y), height - 1)) return [ {"type": "abs", "data": {"axis": "x", "value": cx * ABS_MAX // (width - 1 or 1)}}, {"type": "abs", "data": {"axis": "y", "value": cy * ABS_MAX // (height - 1 or 1)}}, ]