Initial Commit
This commit is contained in:
+306
@@ -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)}},
|
||||
]
|
||||
Reference in New Issue
Block a user