Files
2026-09-15 15:24:03 -04:00

312 lines
13 KiB
Python

"""The guest channel: qemu-guest-agent over virtio-serial.
WHY NOT SSH
The obvious way to run a command in a VM is sshd and a forwarded port,
and it was rejected for three reasons that all matter here:
* a forwarded port is a LISTENING SOCKET on this host. The whole
isolation argument is that the sandbox has no inbound surface at
all, and `hostfwd=tcp::2222-:22` - the form everyone writes -
binds 0.0.0.0, i.e. the LAN.
* it needs key material, generated per session, living somewhere.
* it stops working the moment the agent breaks the guest's networking,
which is exactly the moment the agent most needs to be reachable.
A machine that has to be healthy to be fixed is not self-healing.
virtio-serial has none of those properties. It is a character device on
the PCI bus; there is no address, no port and no key, and it works with
the guest's network stack down entirely.
WHAT "A NORMAL SUPERUSER BASH" MEANS
On Linux qemu-ga runs as root, so `guest-exec` of `/bin/bash -lc` is
literally a root login shell - a login shell rather than `-c`, so PATH
and the profile are what a person would see. On Windows the same agent
runs as LocalSystem, which is NOT a normal elevated session: no user
profile, different COM and GUI behaviour, and half of PowerShell's module
autoloading behaves oddly. That is why the Windows image carries a second
broker on its own virtio-serial port, running in the interactive admin
session, and this module is the Linux half.
"""
from __future__ import annotations
import base64
import json
import socket
import time
from typing import Any
# qemu-ga buffers a command's output in the guest before we read it, so
# this is a cap on what the guest can make vmd hold in memory, not just a
# tidiness limit. The agent's own tool layer trims again for context.
MAX_CAPTURE = 4_000_000
class GuestError(RuntimeError):
"""The guest agent is not answering, or refused a request."""
class GuestTimeout(GuestError):
"""A command outlived its timeout and was abandoned."""
class Guest:
def __init__(self, path: str, timeout: float = 15.0,
os_name: str = "ubuntu") -> None:
# Which machine this is, because the shell is not the same one.
self.os_name = (os_name or "ubuntu").strip().lower()
self.path = path
self.timeout = timeout
self._sock: socket.socket | None = None
self._buf = b""
def __enter__(self) -> "Guest":
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._resync()
def close(self) -> None:
if self._sock is not None:
try:
self._sock.close()
finally:
self._sock = None
def _resync(self) -> None:
"""Discard whatever the last conversation left in the pipe.
virtio-serial is a stream with no framing and no reset. If a
previous command timed out, its reply is still queued, and the
next request reads that instead - every answer one behind the
question, which is a genuinely horrible bug to diagnose. The
documented fix is a 0xFF byte, which the agent treats as "discard
the parser state", followed by a sync token it echoes back.
"""
assert self._sock is not None
token = int(time.time() * 1000) & 0x7FFFFFFF
self._sock.sendall(b"\xff" + json.dumps(
{"execute": "guest-sync-delimited", "arguments": {"id": token}}
).encode() + b"\n")
deadline = time.monotonic() + self.timeout
self._buf = b""
while time.monotonic() < deadline:
try:
chunk = self._sock.recv(65536)
except socket.timeout:
break
if not chunk:
break
self._buf += chunk
# The reply is preceded by its own 0xFF; everything before it
# is the stale output we are trying to get rid of.
if b"\xff" in self._buf:
self._buf = self._buf.split(b"\xff", 1)[1]
for line in self._buf.split(b"\n"):
if not line.strip():
continue
try:
message = json.loads(line)
except ValueError:
continue
if message.get("return") == token:
self._buf = b""
return
raise GuestError(
"the guest agent did not answer a sync. The machine is still "
"booting, or qemu-guest-agent is not running inside it."
)
def _call(self, command: str, timeout: float | None = None, **arguments: Any) -> Any:
assert self._sock is not None, "not connected"
self._sock.settimeout(timeout or self.timeout)
message: dict[str, Any] = {"execute": command}
if arguments:
message["arguments"] = arguments
self._sock.sendall(json.dumps(message).encode() + b"\n")
deadline = time.monotonic() + (timeout or self.timeout)
while time.monotonic() < deadline:
line, sep, rest = self._buf.partition(b"\n")
if sep:
self._buf = rest
if not line.strip():
continue
reply = json.loads(line)
if "error" in reply:
raise GuestError(f"{command}: {reply['error'].get('desc', reply['error'])}")
return reply.get("return")
try:
chunk = self._sock.recv(65536)
except socket.timeout:
break
if not chunk:
raise GuestError("the guest agent closed the channel")
self._buf += chunk
raise GuestTimeout(f"the guest agent did not answer '{command}' in time")
# -- verbs ---------------------------------------------------------
def ping(self, timeout: float = 5.0) -> bool:
try:
self._call("guest-ping", timeout=timeout)
return True
except (GuestError, OSError):
return False
def info(self) -> dict[str, Any]:
return self._call("guest-info") or {}
def exec(self, argv: list[str], timeout_s: float = 120.0,
cwd: str | None = None, env: list[str] | None = None) -> dict[str, Any]:
"""Run a command and wait for it. Always returns all three of
exit code, stdout and stderr - a tool that returns only the
output it thinks you wanted is a tool that hides the reason
something failed.
"""
if not argv:
raise GuestError("no command was given")
arguments: dict[str, Any] = {
"path": argv[0], "arg": argv[1:], "capture-output": True,
}
if env:
arguments["env"] = env
started = time.monotonic()
pid = self._call("guest-exec", **arguments)["pid"]
# Poll rather than block: the agent has no "wait" verb, and a
# long command would otherwise sit inside a socket timeout with
# no way to notice a cancellation.
delay = 0.05
while True:
status = self._call("guest-exec-status", pid=pid)
if status.get("exited"):
out = base64.b64decode(status.get("out-data", "") or "")[:MAX_CAPTURE]
err = base64.b64decode(status.get("err-data", "") or "")[:MAX_CAPTURE]
code = status.get("exitcode")
if code is None and "signal" in status:
# Reported as 128+signal so the caller sees one number.
code = 128 + int(status["signal"])
return {
"exit_code": int(code if code is not None else -1),
"stdout": out.decode("utf-8", "replace"),
"stderr": err.decode("utf-8", "replace"),
"truncated": bool(status.get("out-truncated") or status.get("err-truncated")),
"seconds": round(time.monotonic() - started, 2),
}
if time.monotonic() - started > timeout_s:
# There is no way to kill it from here - the agent
# exposes no such verb - so say so rather than implying
# the command stopped. It is still running in the guest.
raise GuestTimeout(
f"the command was still running after {timeout_s:.0f}s and was "
"abandoned. It has NOT been stopped inside the machine."
)
time.sleep(delay)
delay = min(delay * 1.5, 1.0)
def shell(self, script: str, timeout_s: float = 120.0) -> dict[str, Any]:
"""A superuser shell on whichever operating system this is.
THIS SENT `/bin/bash -lc` TO WINDOWS FOR AS LONG AS WINDOWS EXISTED.
The module was written as the Linux half and the Windows half was
left for the broker, so every command in a Windows session came
back `guest-exec: Failed to execute child process (No such file or
directory)`. That does not read as "wrong shell" to an agent - it
reads as a machine that has stopped answering, so it rebooted the
machine and waited, twice, and then did it again.
PowerShell is what a Windows guest has. `-NoProfile` because
qemu-ga runs as LocalSystem and a profile that is not there costs
a second per call; `-NonInteractive` so a prompt fails instead of
hanging until the timeout.
WHAT THIS IS NOT: an interactive elevated session. qemu-ga is
LocalSystem in session 0, so anything graphical started from here
runs where nobody can see it, and per-user state is not the
logged-in user's. Scripts and their output are exactly right;
that is most of what this machine is for. The in-guest broker is
still what makes the rest true.
"""
if self.os_name == "windows":
return self.exec(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
timeout_s=timeout_s,
)
return self.exec(["/bin/bash", "-lc", script], timeout_s=timeout_s)
def write_file(self, path: str, content: bytes) -> int:
handle = self._call("guest-file-open", path=path, mode="wb")
try:
written = 0
# 512 KB of raw bytes per call; base64 makes each message a
# third larger again, and the agent has a maximum message size.
for start in range(0, len(content), 524288):
chunk = content[start:start + 524288]
result = self._call(
"guest-file-write", handle=handle,
**{"buf-b64": base64.b64encode(chunk).decode()},
)
written += int(result.get("count", 0))
return written
finally:
self._call("guest-file-close", handle=handle)
def read_file(self, path: str, max_bytes: int = 1_000_000) -> bytes:
handle = self._call("guest-file-open", path=path, mode="rb")
try:
out = b""
while len(out) < max_bytes:
result = self._call("guest-file-read", handle=handle,
count=min(524288, max_bytes - len(out)))
out += base64.b64decode(result.get("buf-b64", "") or "")
if result.get("eof"):
break
return out
finally:
self._call("guest-file-close", handle=handle)
def set_time(self, when_ns: int) -> None:
"""Set the guest's clock, in UTC nanoseconds, from the host's.
EVERY SESSION, NOT ONLY RESUMED ONES.
A machine resumed from a saved state wakes up believing it is
whenever the state was taken - measured at three minutes stale on
the first restore, and it would be days on a snapshot left alone
over a weekend. Windows minds more than Linux: a clock that is
wrong fails TLS certificate validation, so `pip`, `winget` and
anything that downloads break in a way that reads as a network
fault rather than a clock.
The time is passed explicitly rather than letting the agent sync
from the RTC. The RTC here is `base=localtime` for Windows and
`base=utc` for Linux, and a verb whose meaning depends on which
machine it is talking to is a verb that will be wrong on one of
them.
"""
self._call("guest-set-time", time=int(when_ns))
def info_time(self) -> int:
"""The guest's own clock, in nanoseconds UTC. For checking a set."""
return int(self._call("guest-get-time"))
def shutdown(self, mode: str = "powerdown") -> None:
if mode not in ("powerdown", "reboot", "halt"):
raise GuestError(f"'{mode}' is not a shutdown mode")
try:
self._call("guest-shutdown", mode=mode, timeout=5.0)
except (GuestError, OSError):
# The agent goes away mid-shutdown by definition; an error
# here means it worked, not that it failed.
pass