Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
"""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)}},
]