Initial Commit
This commit is contained in:
+311
@@ -0,0 +1,311 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user