Files
nest/uvm/guest_agent.py

474 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Standalone, stdlib-only guest execution agent (Python 3.10+).
This file is copied into guest images and must not import the host application.
The host also imports its wire-format helpers; importing it never starts a server.
"""
from __future__ import annotations
import base64
import errno
import fcntl
import json
import math
import os
import pwd
import re
import selectors
import signal
import socket
import stat
import struct
import subprocess
import sys
import termios
import threading
import time
import uuid
from pathlib import Path
PROTOCOL_VERSION = 1
AGENT_PORT = 1070
DEFAULT_TIMEOUT_SECONDS = 60.0
MAX_TIMEOUT_SECONDS = 3600.0
MAX_COMMAND_BYTES = 65536
MAX_OUTPUT_BYTES = 1024 * 1024 # Combined stdout and stderr, before UTF-8 decoding.
MAX_REQUEST_BYTES = 6 * MAX_COMMAND_BYTES + 4096
MAX_RESPONSE_BYTES = 6 * MAX_OUTPUT_BYTES + 4096
CONNECT_TIMEOUT_SECONDS = 5.0
CLEANUP_SECONDS = 5.0
MAX_CONCURRENT_COMMANDS = 8
MAX_TERMINAL_BYTES = 8192
MAX_TERMINAL_FRAME = 16384
CONFIG_PATH = Path("/etc/uvm-agent.json")
class ProtocolError(ValueError):
"""An invalid or oversized wire message; never include command text."""
class ExecutionFailure(Exception):
def __init__(self, message: str, *, outcome_unknown: bool = False) -> None:
super().__init__(message)
self.outcome_unknown = outcome_unknown
def validate_terminal_size(rows: object, cols: object) -> None:
if type(rows) is not int or type(cols) is not int or not (2 <= rows <= 200 and 2 <= cols <= 400):
raise ProtocolError("terminal dimensions must be integers: 2–200 rows and 2–400 columns")
def terminal_data(value: object) -> bytes:
if not isinstance(value, str) or len(value) > 4 * ((MAX_TERMINAL_BYTES + 2) // 3):
raise ProtocolError("terminal data exceeds its size limit")
try:
data = base64.b64decode(value, validate=True)
except (ValueError, UnicodeError) as error:
raise ProtocolError("invalid terminal data") from error
if len(data) > MAX_TERMINAL_BYTES:
raise ProtocolError("terminal data exceeds its size limit")
return data
def validate_command(command: object, sudo: object, timeout_seconds: object) -> None:
if not isinstance(command, str) or not command.strip() or "\0" in command:
raise ProtocolError("command must be non-empty text without NUL characters")
try:
size = len(command.encode("utf-8"))
except UnicodeError as error:
raise ProtocolError("command must be valid UTF-8 text") from error
if size > MAX_COMMAND_BYTES:
raise ProtocolError(f"command exceeds {MAX_COMMAND_BYTES} UTF-8 bytes")
if type(sudo) is not bool:
raise ProtocolError("sudo must be a boolean")
try:
valid_timeout = (
type(timeout_seconds) in (int, float)
and math.isfinite(timeout_seconds)
and 0 < timeout_seconds <= MAX_TIMEOUT_SECONDS
)
except OverflowError:
valid_timeout = False
if not valid_timeout:
raise ProtocolError(f"timeout_seconds must be finite and greater than 0, up to {MAX_TIMEOUT_SECONDS:g}")
def _remaining(deadline: float) -> float:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("guest protocol deadline exceeded")
return remaining
def receive_exact(connection: socket.socket, size: int, deadline: float) -> bytes:
parts = bytearray()
while len(parts) < size:
connection.settimeout(_remaining(deadline))
chunk = connection.recv(size - len(parts))
if not chunk:
raise EOFError("guest connection closed")
parts.extend(chunk)
return bytes(parts)
def receive_frame(connection: socket.socket, limit: int, deadline: float) -> dict:
size, = struct.unpack("!I", receive_exact(connection, 4, deadline))
if not 0 < size <= limit:
raise ProtocolError("invalid guest message length")
try:
message = json.loads(receive_exact(connection, size, deadline))
except (ValueError, UnicodeError, RecursionError) as error:
raise ProtocolError("invalid guest JSON message") from error
if not isinstance(message, dict):
raise ProtocolError("guest message must be an object")
return message
def send_frame(connection: socket.socket, message: dict, limit: int, deadline: float) -> None:
payload = json.dumps(message, ensure_ascii=True, allow_nan=False, separators=(",", ":")).encode("ascii")
if len(payload) > limit:
raise ProtocolError("guest message exceeds its size limit")
connection.settimeout(_remaining(deadline))
connection.sendall(struct.pack("!I", len(payload)) + payload)
class JobScope:
"""A delegated guest cgroup, keeping detached descendants in the same job."""
def __init__(self) -> None:
entries = Path("/proc/self/cgroup").read_text(encoding="ascii").splitlines()
unified = next((entry[3:] for entry in entries if entry.startswith("0::")), None)
if unified is None or ".." in Path(unified).parts:
raise ExecutionFailure("guest execution requires a delegated cgroup v2 hierarchy")
parent = Path("/sys/fs/cgroup") / unified.lstrip("/")
self.path = parent / f"uvm-exec-{uuid.uuid4().hex}"
self.path.mkdir(mode=0o700)
if not (self.path / "cgroup.kill").is_file():
self.path.rmdir()
raise ExecutionFailure("guest execution requires cgroup.kill (Linux 5.14 or newer)")
def kill(self) -> None:
(self.path / "cgroup.kill").write_text("1", encoding="ascii")
def close(self) -> None:
# cgroup.events reports descendants as well as processes in this cgroup.
deadline = time.monotonic() + 1.0
while "populated 1" in (self.path / "cgroup.events").read_text(encoding="ascii"):
if time.monotonic() >= deadline:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=True)
time.sleep(0.01)
for child in sorted(self.path.rglob("*"), key=lambda path: len(path.parts), reverse=True):
if child.is_dir():
child.rmdir()
self.path.rmdir()
def _signal_group(process: subprocess.Popen, number: int) -> None:
# Do not reap the group leader until after signaling: its PID pins the PGID.
try:
os.killpg(process.pid, number)
except ProcessLookupError:
pass
def _exited(process: subprocess.Popen) -> bool:
return os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) is not None
def _cleanup_job(process: subprocess.Popen | None, scope: JobScope) -> None:
failed = False
try:
scope.kill()
except (OSError, ExecutionFailure):
failed = True
if process is not None:
try:
_signal_group(process, signal.SIGKILL)
process.wait(timeout=1.0)
except (OSError, subprocess.TimeoutExpired):
failed = True
finally:
for name in ("stdout", "stderr"):
stream = getattr(process, name)
if stream is not None:
stream.close()
try:
scope.close()
except (OSError, ExecutionFailure):
failed = True
if failed:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=process is not None)
def run_command(command: str, username: str, timeout_seconds: float, *, scope_factory=JobScope) -> dict:
"""Run one non-interactive guest job, drain both pipes, and bound its lifetime."""
scope = scope_factory()
process = None
buffers = {"stdout": bytearray(), "stderr": bytearray()}
captured = 0
truncated = False
timed_out = False
try:
# The fresh child joins its scope BEFORE dropping credentials or executing
# user text. Avoid preexec_fn in the threaded agent.
process = subprocess.Popen(
[sys.executable, "-I", str(Path(__file__).resolve()), "--child", str(scope.path), username, command],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True, env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
)
deadline = time.monotonic() + timeout_seconds
terminating = None
killed = False
with selectors.DefaultSelector() as selector:
for name in buffers:
stream = getattr(process, name)
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ, name)
while True:
now = time.monotonic()
exited = _exited(process)
if terminating is None and (exited or now >= deadline):
timed_out = not exited
terminating = now
_signal_group(process, signal.SIGTERM if timed_out else signal.SIGKILL)
if terminating is not None:
if not killed and (exited or now >= terminating + 0.5):
scope.kill()
_signal_group(process, signal.SIGKILL)
killed = True
if killed and exited and not selector.get_map():
break
if now >= terminating + 3.0:
raise ExecutionFailure("guest job cleanup could not be confirmed", outcome_unknown=True)
for key, _events in selector.select(0.05):
chunk = os.read(key.fileobj.fileno(), 65536)
if not chunk:
selector.unregister(key.fileobj)
continue
keep = min(len(chunk), MAX_OUTPUT_BYTES - captured)
buffers[key.data].extend(chunk[:keep])
captured += keep
truncated |= keep < len(chunk)
# Finally below kills the scope and reaps the leader before returning.
except OSError as error:
raise ExecutionFailure("guest command execution failed", outcome_unknown=process is not None) from error
finally:
_cleanup_job(process, scope)
return {
"exit_code": None if timed_out else process.returncode,
"stdout": buffers["stdout"].decode("utf-8", errors="replace"),
"stderr": buffers["stderr"].decode("utf-8", errors="replace"),
"timed_out": timed_out,
"truncated": truncated,
}
def child_main(scope: Path, username: str, command: str | None) -> None:
"""Enter the job cgroup and account in a fresh interpreter, then replace it."""
(scope / "cgroup.procs").write_text(str(os.getpid()), encoding="ascii")
if command is None:
# Popen's start_new_session already performed setsid in this fresh child.
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
account = pwd.getpwnam(username)
if os.geteuid() == 0:
os.initgroups(account.pw_name, account.pw_gid)
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
elif (account.pw_uid, account.pw_gid) != (os.geteuid(), os.getegid()):
raise PermissionError("guest agent cannot assume the requested account")
os.chdir(account.pw_dir)
os.umask(0o022)
environment = {
"HOME": account.pw_dir, "USER": account.pw_name, "LOGNAME": account.pw_name,
"SHELL": "/bin/sh", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C.UTF-8",
}
if command is None:
shell = account.pw_shell or "/bin/sh"
environment.update(SHELL=shell, TERM="xterm-256color", COLORTERM="truecolor")
os.execve(shell, [shell, "-i"], environment)
else:
os.execve("/bin/sh", ["sh", "-c", command], environment)
def run_terminal(connection: socket.socket, username: str, rows: int, cols: int, *, scope_factory=JobScope) -> None:
"""Bridge one real guest PTY until shell exit, explicit close, or host loss."""
validate_terminal_size(rows, cols)
scope = scope_factory()
process = None
master = slave = None
try:
master, slave = os.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
process = subprocess.Popen(
[sys.executable, "-I", str(Path(__file__).resolve()), "--terminal-child", str(scope.path), username],
stdin=slave, stdout=slave, stderr=slave, start_new_session=True,
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"},
)
os.close(slave)
slave = None
os.set_blocking(master, False)
send_frame(connection, {"type": "ready"}, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
pending = bytearray()
with selectors.DefaultSelector() as selector:
selector.register(connection, selectors.EVENT_READ, "host")
selector.register(master, selectors.EVENT_READ, "pty")
finished = False
while not finished:
for key, events in selector.select(0.1):
if key.data == "host":
message = receive_frame(connection, MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
if message == {"type": "close"}:
finished = True
break
if set(message) == {"type", "data"} and message["type"] == "input":
pending.extend(terminal_data(message["data"]))
if len(pending) > 65536:
raise ProtocolError("terminal input backlog exceeded")
elif set(message) == {"type", "rows", "cols"} and message["type"] == "resize":
validate_terminal_size(message["rows"], message["cols"])
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", message["rows"], message["cols"], 0, 0))
else:
raise ProtocolError("invalid terminal control message")
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
else:
if events & selectors.EVENT_READ:
try:
data = os.read(master, MAX_TERMINAL_BYTES)
except OSError as error:
if error.errno != errno.EIO:
raise
data = b""
if not data:
finished = True
break
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
if events & selectors.EVENT_WRITE and pending:
try:
del pending[:os.write(master, pending)]
except BlockingIOError:
pass
selector.modify(master, selectors.EVENT_READ | (selectors.EVENT_WRITE if pending else 0), "pty")
if _exited(process):
# Drain the leader's final output before terminating any background jobs.
for _ in range(16):
try:
data = os.read(master, MAX_TERMINAL_BYTES)
except (BlockingIOError, OSError):
break
if not data:
break
send_frame(connection, {"type": "output", "data": base64.b64encode(data).decode("ascii")},
MAX_TERMINAL_FRAME, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
break
finally:
try:
_cleanup_job(process, scope)
finally:
for descriptor in (master, slave):
if descriptor is not None:
os.close(descriptor)
send_frame(connection, {"type": "exit", "exit_code": process.returncode}, MAX_TERMINAL_FRAME,
time.monotonic() + CONNECT_TIMEOUT_SECONDS)
def serve_connection(connection: socket.socket, config: dict, *, runner=run_command, terminal_runner=run_terminal) -> None:
with connection:
try:
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
send_frame(connection, {"version": PROTOCOL_VERSION, **config}, 4096, deadline)
request = receive_frame(connection, MAX_REQUEST_BYTES, deadline)
if set(request) == {"version", "terminal"}:
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
raise ProtocolError("unsupported guest protocol version")
size = request["terminal"]
if not isinstance(size, dict) or set(size) != {"rows", "cols"}:
raise ProtocolError("invalid terminal request")
validate_terminal_size(size["rows"], size["cols"])
terminal_runner(connection, config["username"], size["rows"], size["cols"])
return
if set(request) != {"version", "command", "sudo", "timeout_seconds"}:
raise ProtocolError("invalid execution request fields")
if type(request["version"]) is not int or request["version"] != PROTOCOL_VERSION:
raise ProtocolError("unsupported guest protocol version")
validate_command(request["command"], request["sudo"], request["timeout_seconds"])
username = "root" if request["sudo"] else config["username"]
result = runner(request["command"], username, request["timeout_seconds"])
send_frame(connection, result, MAX_RESPONSE_BYTES, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
except (ProtocolError, ExecutionFailure, OSError, KeyError) as error:
# Never log request text, guest output, or credentials to the console.
try:
send_frame(connection, {
"error": "guest execution failed: " + str(error),
"outcome_unknown": isinstance(error, ExecutionFailure) and error.outcome_unknown,
}, 4096, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
except (OSError, ProtocolError):
pass
except EOFError:
pass
def load_config(path: Path = CONFIG_PATH) -> dict:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(descriptor, encoding="utf-8") as source:
metadata = os.fstat(source.fileno())
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0
or metadata.st_mode & 0o077 or metadata.st_size > 4096):
raise ValueError("guest agent configuration must be a root-owned private regular file")
config = json.load(source)
if (not isinstance(config, dict) or set(config) != {"vm_id", "username"}
or not isinstance(config["vm_id"], str)
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", config["vm_id"]) is None
or not isinstance(config["username"], str)
or re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", config["username"]) is None):
raise ValueError("invalid guest agent configuration")
pwd.getpwnam(config["username"])
return config
def main() -> None:
if len(sys.argv) == 4 and sys.argv[1] == "--terminal-child":
try:
child_main(Path(sys.argv[2]), sys.argv[3], None)
except (OSError, KeyError) as error:
print(f"uvm-agent: could not launch guest terminal: {error}", file=sys.stderr)
sys.exit(126)
return
if len(sys.argv) == 5 and sys.argv[1] == "--child":
try:
child_main(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
except (OSError, KeyError) as error:
print(f"uvm-agent: could not launch guest command: {error}", file=sys.stderr)
sys.exit(126)
return
if len(sys.argv) != 1 or os.geteuid() != 0:
raise SystemExit("uvm-agent must run as a root systemd service")
config = load_config()
slots = threading.BoundedSemaphore(MAX_CONCURRENT_COMMANDS)
def handle(connection: socket.socket) -> None:
try:
serve_connection(connection, config)
finally:
slots.release()
with socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) as listener:
listener.bind((socket.VMADDR_CID_ANY, AGENT_PORT))
listener.listen(MAX_CONCURRENT_COMMANDS)
while True:
connection, peer = listener.accept()
# Guest-local callers must not use the root agent to elevate themselves.
if peer[0] != socket.VMADDR_CID_HOST:
connection.close()
continue
if not slots.acquire(blocking=False):
connection.close()
continue
threading.Thread(target=handle, args=(connection,), daemon=True).start()
if __name__ == "__main__":
main()