307 lines
14 KiB
Python
307 lines
14 KiB
Python
"""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)}},
|
|
]
|