"""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