diff --git a/docs-site/.dockerignore b/docs-site/.dockerignore new file mode 100644 index 0000000..f64e7ca --- /dev/null +++ b/docs-site/.dockerignore @@ -0,0 +1,19 @@ +node_modules/ +dist/ +.astro/ + +.env +.env.local +.env.production + +.git/ +.gitignore + +*.log +.DS_Store +.vscode/ +.idea/ + +# don't pull in repo-root artifacts +../artifacts/ +README.md diff --git a/docs-site/.env.example b/docs-site/.env.example new file mode 100644 index 0000000..9eb88d6 --- /dev/null +++ b/docs-site/.env.example @@ -0,0 +1,13 @@ +# mcqemu docs-site — environment template. +# Copy to `.env` and adjust before `make prod` / `make dev`. + +# Keeps this stack's containers, networks and volumes distinct from the +# sibling docs sites on the same host. +COMPOSE_PROJECT_NAME=mcqemu-docs + +# Domain served. caddy-docker-proxy reads this from the labels and obtains +# a certificate over ACME DNS-01. +# +# Production: mcqemu.warehack.ing +# Local dev: mcqemu.l.warehack.ing +DOMAIN=mcqemu.warehack.ing diff --git a/docs-site/.gitignore b/docs-site/.gitignore new file mode 100644 index 0000000..d9f74c3 --- /dev/null +++ b/docs-site/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.astro/ +.env +.env.local +.env.production +*.log +package-lock.json diff --git a/docs-site/Caddyfile b/docs-site/Caddyfile new file mode 100644 index 0000000..590a6ee --- /dev/null +++ b/docs-site/Caddyfile @@ -0,0 +1,18 @@ +:80 { + root * /srv/docs + encode zstd gzip + try_files {path} {path}/ + file_server + + # Serve the 404 page with a REAL 404 status. The older + # `try_files ... /404.html` form returned HTTP 200 for every missing + # path, which silently broke uptime checks, smoke tests, and Search + # Console (they all saw a "live" page). + handle_errors { + @404 expression {err.status_code} == 404 + handle @404 { + rewrite * /404.html + file_server + } + } +} diff --git a/docs-site/Dockerfile b/docs-site/Dockerfile new file mode 100644 index 0000000..0ac987d --- /dev/null +++ b/docs-site/Dockerfile @@ -0,0 +1,47 @@ +# Multi-stage build for the mcqemu docs site. +# +# Stages: +# - base : Node, pnpm/npm tooling, deps installed +# - dev : runs `astro dev` with HMR for local development +# - builder : produces the static `dist/` +# - prod : caddy:alpine that serves `dist/` (no Node at runtime) +# +# `docker compose --profile dev up` → dev target +# `docker compose up` (no profile) → prod target + +# Pinned through the mirror.gcr.io pass-through to dodge intermittent +# Docker Hub TLS hiccups during builds. Same content, more reliable +# fetch path. The `docker pull` resolves identically against either. +FROM mirror.gcr.io/library/node:22-alpine AS base +WORKDIR /app +COPY package.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm install --no-audit --no-fund + + +# ----- dev: astro dev server with HMR ----- +FROM base AS dev +# Astro's binary is in node_modules/.bin — package.json's `dev` script +# already binds to 0.0.0.0 for HMR-behind-Caddy. +COPY . . +ENV ASTRO_TELEMETRY_DISABLED=1 +EXPOSE 4321 +CMD ["npm", "run", "dev"] + + +# ----- builder: produce dist/ ----- +FROM base AS builder +COPY . . +ENV ASTRO_TELEMETRY_DISABLED=1 +RUN npm run build + + +# ----- prod: caddy serves the static build ----- +FROM mirror.gcr.io/library/caddy:2-alpine AS prod +# Caddyfile is intentionally minimal — caddy-docker-proxy on the host +# handles TLS, routing, and the public-facing reverse proxy. This +# container just serves files locally; the proxy points at it. +RUN mkdir -p /srv/docs +COPY --from=builder /app/dist /srv/docs +COPY Caddyfile /etc/caddy/Caddyfile +EXPOSE 80 diff --git a/docs-site/Makefile b/docs-site/Makefile new file mode 100644 index 0000000..467d8d0 --- /dev/null +++ b/docs-site/Makefile @@ -0,0 +1,50 @@ +# mcqemu docs — targets follow the warehacking cookie-cutter. + +SHELL := /usr/bin/env bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := help + +.PHONY: help +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +.PHONY: prod +prod: ## Build + run the production docs container (Caddy serves dist/) + docker compose up -d --build docs + +.PHONY: dev +dev: ## Run the Astro dev server with HMR (--profile dev) + docker compose --profile dev up --build docs-dev + +.PHONY: down +down: ## Stop and remove the docs containers + docker compose --profile dev down + docker compose down + +.PHONY: logs +logs: ## Tail logs (works for whichever profile is up) + docker compose logs -f --tail=100 + +.PHONY: build +build: ## Build the static site without bringing up Caddy (CI gate) + docker compose build docs + +# ---- Production deploy -------------------------------------------------- +# +# Pulls origin/main on the warehack.ing prod host and rebuilds the docs +# container. Agent forwarding (-A) lets the remote git pull use the +# operator's key for Gitea, so nothing persistent is provisioned there. +# +# Use docker-2.supportedsystems.net, not warehack.ing: the latter resolves +# to an IPv4 whose port 22 is unreachable from some networks. + +DEPLOY_HOST ?= warehack-ing@docker-2.supportedsystems.net +DEPLOY_PATH ?= ~/mcqemu + +.PHONY: deploy +deploy: ## Pull main + rebuild the docs container on the prod host + @echo "==> deploying $(DEPLOY_HOST):$(DEPLOY_PATH)" + ssh -A $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && git fetch origin main && git reset --hard origin/main && cd docs-site && make prod" + @echo "==> verifying by content (these sites return 200 for every path)" + @curl -sS https://mcqemu.warehack.ing/ | grep -oE "[^<]*" || echo " homepage title not found" + @curl -sS https://mcqemu.warehack.ing/definitely-not-a-page-xyz/ | grep -oE "[^<]*" || echo " (bogus path returned no title)" diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs new file mode 100644 index 0000000..8fa9083 --- /dev/null +++ b/docs-site/astro.config.mjs @@ -0,0 +1,92 @@ +// mcqemu docs — Starlight with a diataxis-shaped sidebar. +// +// Telemetry and devToolbar are off per project convention. The HMR block +// matters when the dev server runs behind Caddy: Vite's WebSocket needs an +// explicit host, protocol and clientPort or HMR drops every few seconds +// with "server connection lost". +// +// DOMAIN drives both the canonical site URL and the HMR host, so the same +// image serves mcqemu.warehack.ing (prod) and mcqemu.l.warehack.ing (dev). + +import mdx from "@astrojs/mdx"; +import sitemap from "@astrojs/sitemap"; +import starlight from "@astrojs/starlight"; +import { defineConfig } from "astro/config"; +import remarkGfm from "remark-gfm"; +import starlightLinksValidator from "starlight-links-validator"; + +const domain = process.env.DOMAIN ?? "mcqemu.warehack.ing"; + +export default defineConfig({ + site: `https://${domain}`, + telemetry: false, + devToolbar: { enabled: false }, + + vite: { + server: { + host: "0.0.0.0", + hmr: { + host: domain, + protocol: "wss", + clientPort: 443, + }, + }, + }, + + integrations: [ + starlight({ + title: "mcqemu", + description: + "MCP server for QEMU: VM lifecycle, disposable sandboxes, live snapshots, guest-agent access, and screenshot-driven control.", + favicon: "/favicon.svg", + customCss: ["./src/styles/theme.css"], + + social: [ + { + icon: "seti:git", + label: "Source", + href: "https://git.supported.systems/warehack.ing/mcqemu", + }, + ], + + // Diataxis order: orientation, then learning, then doing, then + // looking up, then understanding. + // Starlight v0.39+ needs {label, items:[{autogenerate}]}; the inline + // {label, autogenerate} shorthand was removed. + sidebar: [ + { + label: "Start here", + items: [{ label: "What is mcqemu?", slug: "overview" }], + }, + { + label: "Tutorial", + items: [{ autogenerate: { directory: "tutorial" } }], + }, + { + label: "How-to", + items: [{ autogenerate: { directory: "how-to" } }], + }, + { + label: "Reference", + items: [{ autogenerate: { directory: "reference" } }], + }, + { + label: "Explanation", + items: [{ autogenerate: { directory: "explanation" } }], + }, + ], + + plugins: [ + // Broken internal links fail the build instead of shipping. + starlightLinksValidator({ errorOnRelativeLinks: false }), + ], + + pagination: true, + lastUpdated: true, + }), + // Astro enables GFM for .md, but the MDX pipeline does not inherit it, + // so tables render as run-together paragraphs without this. + mdx({ remarkPlugins: [remarkGfm] }), + sitemap(), + ], +}); diff --git a/docs-site/docker-compose.yml b/docs-site/docker-compose.yml new file mode 100644 index 0000000..a026dd1 --- /dev/null +++ b/docs-site/docker-compose.yml @@ -0,0 +1,68 @@ +# mcqemu docs site — two profiles. +# +# Default (no --profile flag): +# production — Caddy serves the built dist/. This is what runs at +# mcqemu.warehack.ing. +# +# --profile dev: +# Astro dev server with HMR, src/ bind-mounted for live reload. The Vite +# HMR WebSocket is configured in astro.config.mjs to survive the +# TLS-terminating proxy; the caddy.reverse_proxy.* labels below keep the +# connection from being closed as idle. +# +# Both services join the external `caddy` network and advertise themselves +# to caddy-docker-proxy through labels. DOMAIN in .env switches between the +# production and local-dev tiers. + +services: + docs: + profiles: ["prod", ""] + build: + context: . + target: prod + image: mcqemu-docs:prod + container_name: mcqemu-docs + restart: unless-stopped + networks: + - caddy + labels: + caddy: ${DOMAIN:-mcqemu.warehack.ing} + caddy.reverse_proxy: "{{upstreams 80}}" + + docs-dev: + profiles: ["dev"] + build: + context: . + target: dev + image: mcqemu-docs:dev + container_name: mcqemu-docs-dev + restart: unless-stopped + environment: + - DOMAIN=${DOMAIN:-mcqemu.l.warehack.ing} + - ASTRO_TELEMETRY_DISABLED=1 + volumes: + # node_modules stays inside the container so host/platform mismatches + # cannot break native deps. + - ./astro.config.mjs:/app/astro.config.mjs:ro + - ./tsconfig.json:/app/tsconfig.json:ro + - ./src:/app/src + - ./public:/app/public + networks: + - caddy + labels: + caddy: ${DOMAIN:-mcqemu.l.warehack.ing} + caddy.reverse_proxy: "{{upstreams 4321}}" + # Vite HMR sends no application-level pings, so Caddy's default idle + # timeouts would close the socket every 10-15s. Needs Caddy 2.10+. + caddy.reverse_proxy.flush_interval: "-1" + caddy.reverse_proxy.transport: "http" + caddy.reverse_proxy.transport.read_timeout: "0" + caddy.reverse_proxy.transport.write_timeout: "0" + caddy.reverse_proxy.transport.keepalive: "5m" + caddy.reverse_proxy.transport.keepalive_idle_conns: "10" + caddy.reverse_proxy.stream_timeout: "24h" + caddy.reverse_proxy.stream_close_delay: "5s" + +networks: + caddy: + external: true diff --git a/docs-site/package.json b/docs-site/package.json new file mode 100644 index 0000000..851f1fd --- /dev/null +++ b/docs-site/package.json @@ -0,0 +1,21 @@ +{ + "name": "mcqemu-docs", + "type": "module", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "astro dev --host 0.0.0.0", + "build": "astro build", + "preview": "astro preview --host 0.0.0.0", + "astro": "astro" + }, + "dependencies": { + "@astrojs/mdx": "^5.0.4", + "@astrojs/sitemap": "^3.7.2", + "@astrojs/starlight": "^0.39.2", + "astro": "^6.3.1", + "remark-gfm": "^4.0.1", + "sharp": "^0.34.0", + "starlight-links-validator": "^0.24.0" + } +} diff --git a/docs-site/public/favicon.svg b/docs-site/public/favicon.svg new file mode 100644 index 0000000..97b2a73 --- /dev/null +++ b/docs-site/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs-site/src/content.config.ts b/docs-site/src/content.config.ts new file mode 100644 index 0000000..56a1009 --- /dev/null +++ b/docs-site/src/content.config.ts @@ -0,0 +1,8 @@ +// Starlight content collection — required for content.config.ts in Astro 6.x. +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; +import { defineCollection } from "astro:content"; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/docs-site/src/content/docs/explanation/architecture.mdx b/docs-site/src/content/docs/explanation/architecture.mdx new file mode 100644 index 0000000..4a79e3a --- /dev/null +++ b/docs-site/src/content/docs/explanation/architecture.mdx @@ -0,0 +1,170 @@ +--- +title: Architecture +description: How the MCP server, the QEMU processes, the QMP and guest-agent sockets, qemu-img and the JSON registry fit together, and why VMs are daemonized and monitor sessions are short-lived. +--- + +mcqemu is a thin control plane. It does not emulate anything, it does not proxy +guest traffic, and it does not keep VMs alive. Everything it does is arranged so +that the interesting state lives in the operating system (processes, sockets, +files) rather than in the server's memory, because the server is the part most +likely to be restarted at an awkward moment. + +## The pieces + +| Piece | What it is | Lifetime | +| --- | --- | --- | +| The MCP server | A Python process speaking MCP to your client (usually over stdio) | As long as your client keeps it | +| A VM | A `qemu-system-*` process, daemonized, with its own pidfile | Until something stops it | +| The QMP socket | A unix socket per VM, QEMU's machine monitor | The VM's lifetime | +| The guest-agent socket | A second unix socket per VM, backing a virtio-serial port | The VM's lifetime | +| `qemu-img` | A subprocess, spawned per image operation | One command | +| The registry | A JSON file plus a lock file on disk | Persistent | + +The server owns none of the VMs in any operating-system sense. It is not their +parent process, it does not hold their sockets open, and if you kill it nothing +happens to them. The registry is a notebook, not an authority. When a tool needs +to know whether a VM is running, it does not consult the notebook, it looks at +`/proc` and at the socket. + +The two decisions that shape everything else are the process model and the +session model. + +## Decision one: VMs outlive the server + +VMs are launched with `-daemonize` and `-pidfile`. QEMU forks, the foreground +process exits, and the VM continues as a session leader unattached to anything +mcqemu owns. Restarting your editor, upgrading mcqemu, or crashing the server +leaves a Debian install halfway through its partitioner exactly where it was. + +This matters more than it might sound. An agent driving a VM will typically be +in the middle of a long operation (an OS installation, a package build, a boot +that takes two minutes under TCG emulation) and the MCP server is attached to an +editor session that gets reloaded for unrelated reasons. A model where the VM +dies with its parent would make those operations quietly unreliable in a way +that is very hard to attribute. + +`-daemonize` also buys a clean launch verdict. QEMU's foreground process exits +zero only after the VM is fully initialized and the QMP socket is listening, so +awaiting that process gives a synchronous answer with real stderr on failure, +rather than the usual "spawn it and poll for a socket" dance. When QEMU fails +because a disk is locked or a machine type does not exist, the launch call +returns that message directly. Daemonized QEMU routes later errors to its `-D` +log file instead of stderr, so if the exit status is non-zero but stderr is +empty, the launcher reads the last line of the log. + +### Liveness, and why the pidfile is not enough + +Reading a PID from a pidfile and calling `kill(pid, 0)` is the obvious liveness +check and it is wrong. PIDs are recycled. A pidfile written days ago, on a host +that has since rebooted or simply wrapped its PID space, can name a process that +is very much alive and has nothing to do with your VM. Acting on that (sending a +signal, reporting the VM as running) is a bug with real consequences. + +So liveness is two checks. First, `/proc//comm` must read back as something +starting with `qemu-system`; a recycled PID belonging to a shell or a browser +fails immediately. Second, for VMs mcqemu launched, `/proc//cmdline` is +parsed for the `-name` argument and compared with the registry key. That second +check is deliberately generous: it returns "matches" whenever it cannot prove +otherwise (unreadable cmdline, no `-name` present), because its job is to veto a +positive identification, not to manufacture a negative one. + +Attached VMs are judged differently. mcqemu did not choose their `-name` and may +not know their PID at all, so when no PID was supplied their liveness comes from +connecting to the QMP socket. A stat is not enough there either: a SIGKILLed +QEMU leaves its socket file on disk, so the check opens a connection and sees +whether anything accepts. + +## Decision two: QMP sessions are opened per call + +QEMU's QMP unix socket accepts exactly one client at a time. That single fact +drives the session model. If mcqemu connected once at launch and held the +connection for the VM's lifetime, it would own the monitor exclusively and +everything else would be locked out: no `qmp-shell` for a human looking over the +agent's shoulder, no second tool, no debugging. It would also leave connection +state to reconcile whenever either side restarted, which for a server whose VMs +outlive it is a recurring problem rather than an edge case. + +Instead every tool call opens a session, runs its commands, and disconnects. In +between calls the socket is free. You can attach `qmp-shell` to a VM the agent is +driving, poke at it, and detach, and the next tool call simply connects. + +The cost is that concurrent calls against the same VM would collide, and they +would collide badly: the second connect would sit there until its five second +timeout, then report the VM unreachable, which reads exactly like a VM that has +exited. So sessions are serialized with a lock keyed by VM name. It is a mutex +table over resources rather than shared mutable state, which means two calls +against two different VMs still run in parallel, and only same-VM calls queue. + +That lock exists inside one server process. It cannot serialize a second mcqemu +instance or your `qmp-shell`, and that is exactly why the connect-failure message +is careful (see [Reliability](/explanation/reliability/)): when a connect fails +but the process is demonstrably alive, the error says another client may hold the +monitor and explicitly tells the caller not to relaunch. + +## The guest agent is a separate channel + +Every launched VM gets a second socket wired to a virtio-serial port named +`org.qemu.guest_agent.0`. `guest_exec`, `guest_file_read`, `guest_file_write`, +`guest_ping` and `guest_info` all speak to that socket, never to QMP. + +Keeping the channels separate is worth the extra socket. Guest-agent calls depend +on software inside the guest and are therefore allowed to hang, time out, or +never work at all; monitor calls control the machine from outside and must keep +working when the guest is wedged. Sharing one channel would let a frozen guest +interfere with the tools you use to deal with a frozen guest. + +The guest agent happens to speak the QMP wire format, so the same client library +works with greeting and capability negotiation switched off. The protocol's +mandatory `guest-sync` handshake, which echoes a random token, doubles as the +probe for "is there actually an agent in there": if the token does not come back +within three seconds, the guest does not have a working `qemu-guest-agent` and +the tools say so rather than timing out on the real command later. See +[Install the guest agent](/how-to/install-the-guest-agent/) for the guest side. + +## Images are `qemu-img` subprocesses + +Image work shells out to `qemu-img` and parses its JSON output. There is no +library binding and no attempt to open qcow2 files directly. + +The reason is safety rather than convenience. `qemu-img` participates in QEMU's +image locking protocol, so an attempt to convert or resize a disk that a running +VM has open fails with a lock error instead of corrupting the image. mcqemu +recognises that error and rewrites it into a sentence that names the actual +problem. The registry's own view of which disks are busy is advisory only (it +does not know an attached VM's disks and does not walk backing chains), so the +image lock is treated as the authority and the registry view as a hint. + +## The registry is bookkeeping, not truth + +The registry is a JSON file under the state directory holding one record per VM: +socket paths, pidfile, PID, the exact argv used, the launch config, log paths. +It exists so that a freshly started server can find VMs that a previous one +launched. + +Two properties matter more than speed. It must never take the server down, and +it must tolerate more than one writer. + +More than one writer is not hypothetical. A second MCP client, a stray +`uvx mcqemu`, or two clients configured against the same state directory all +share one file. So every write is a read-modify-write under an exclusive +`flock`, and the temp file used for the atomic rename carries the writing +process's PID in its name. Without the lock the last writer's snapshot silently +erases the other's VMs; with a shared temp name two writers could interleave and +publish the mixture. + +Never taking the server down is the other half, and it is covered in +[Reliability](/explanation/reliability/): an unreadable registry is quarantined +and the server starts empty, reporting the damage through `list_vms` rather than +refusing to start. + +## What this means in practice + +VMs survive server restarts, so `list_vms` after a restart shows what is really +running rather than an empty list. The monitor socket is available to other +tooling between calls. Two agents can share a host without clobbering each +other's records, though they cannot serialize each other's monitor access, so +same-VM contention shows up as a retryable error rather than a wedge. + +Paths for the state and runtime directories are documented in +[Configuration](/reference/configuration/), and every tool named here is listed +with its parameters in the [Tool reference](/reference/tools/). diff --git a/docs-site/src/content/docs/explanation/reliability.mdx b/docs-site/src/content/docs/explanation/reliability.mdx new file mode 100644 index 0000000..b51679d --- /dev/null +++ b/docs-site/src/content/docs/explanation/reliability.mdx @@ -0,0 +1,191 @@ +--- +title: How mcqemu handles failure +description: The four rules behind mcqemu's error handling (verify before destroying, degrade instead of dying, never guess an unchecked cause, bound every wait) and what each of them costs. +--- + +An MCP server that manages VMs is in an unusual position. Its callers are often +models, which are good at acting on a plausible-sounding sentence and less good +at doubting one. Its subjects are long-lived processes holding gigabytes of state +that no one wants to lose. And it works on a host that is doing other things at +the same time. + +Four rules follow from that. None of them is clever; all of them are the boring +option, chosen deliberately. + +## Never delete without verifying what you waited for + +Waiting for something and then assuming it happened is the standard way to +destroy data. `sandbox_destroy` is the only tool in mcqemu that deletes files, so +it is where this rule is enforced hardest. + +It sends `quit` over the monitor, then polls until the PID is actually gone, +giving it ten seconds. If the process is still there it escalates to `SIGKILL` +and polls again for five. If the process is *still* alive after that, it stops +and fails, and nothing is deleted. The error names the PID and says the overlay +is still open by that process. + +That last branch is the point. Unlinking a disk image that QEMU still has open +does not free the space (the kernel keeps the inode alive for the open file +descriptor) and it does not stop the guest writing, so you end up with a running +VM writing into a file that no longer has a name, consuming disk you cannot +account for. Refusing to delete leaves you with a mess you can see, which is +strictly better than a mess you cannot. + +The same instinct shows up in the surrounding checks. `sandbox_destroy` refuses +outright to touch a VM that was not created by `sandbox_vm`, because `stop_vm` +and `forget_vm` never delete disks and users reasonably assume the same of +everything else. It re-validates the VM name it was handed rather than trusting +the registry key it came from, since a corrupted record is exactly the input that +turns a delete into a disaster. It resolves the directories it is about to remove +and confirms they are genuinely inside the state and runtime roots, and are not +the roots themselves. Cleanup errors are collected rather than swallowed, so the +result distinguishes "destroyed" from "mostly destroyed, here is what is left". + +## Degrade instead of dying + +If the registry file is unreadable, mcqemu does not refuse to start. It moves the +damaged file aside to a timestamped quarantine name, starts with an empty +registry, and records what happened. `list_vms` returns that record in +`registry_warnings` alongside the (now empty) VM list. Individual records that +fail to parse are skipped one at a time with their own warning, rather than +taking the whole file down with them. + +The reasoning is a direct consequence of the process model described in +[Architecture](/explanation/architecture/). VMs outlive this server. A server +that will not start because its notebook is corrupt is a server that cannot stop +the runaway VM eating the host's memory, and the notebook has no bearing on +whether that VM exists. Bookkeeping failures must not disarm the controls. + +The warning matters as much as the degradation. A quietly empty list would be +read as "no VMs are running", which in this situation is exactly wrong: VMs may +well be running untracked, and their QMP sockets are still sitting under the +runtime directory where `attach_vm` can pick them up. So the tool's contract makes +the warning list impossible to miss, and its own description tells the caller to +report it rather than treat the list as complete. + +A registry written by a newer mcqemu is handled the other way: if the schema +version is higher than this build understands, it is not loaded and not +quarantined, because reading it optimistically and writing it back would damage +records the newer build owns. Refusing and saying so is the safe direction. + +The same preference for a usable degraded state appears at launch. If QEMU exits +zero (which means the VM is up and its monitor is listening) but the pidfile +cannot be read, the launcher does not raise. Raising there would leave a running, +unregistered VM that no tool could find or stop, which is the worst outcome +available. It returns a record without a PID, and only fails when neither a +pidfile nor a live QMP socket can be found, at which point it prints the `pgrep` +command to locate the process by hand. + +## Do not guess a cause you have not checked + +The most consequential error message in mcqemu is the one for a failed QMP +connect, and it has two forms. + +If the process is not alive, the message says the VM has likely exited and points +at the QEMU log. If the process *is* alive, it says something quite different: +that QEMU's monitor accepts one client at a time, that another tool call or an +external `qmp-shell` may be holding it, and, explicitly, not to relaunch the VM. + +The difference is not politeness. A caller told "the VM has exited" will do the +sensible thing and start it again, and if the VM was merely busy that means two +QEMU processes fighting over one disk image, or a launch that fails confusingly +on a locked image, or in the worst case a second guest writing to a filesystem +the first one is already mounting. The wrong diagnosis produces a wrong action +that produces real damage. So the liveness check runs before the message is +written, and the message only claims what was checked. + +The same principle appears in a few other places: + +Waiting for a shutdown polls the process, not just the event queue. The client +library's event queue never wakes on a dropped connection, so a VM that crashes +mid-wait would burn the caller's entire timeout and then be reported as a guest +that ignores ACPI, sending them to `force=True` for a machine that already died. +Polling liveness once a second turns that into "process exited during shutdown", +which is what happened. + +The identity check on a PID is deliberately one-sided. It compares the `-name` in +`/proc//cmdline` against the registry key, and returns "this is not a +different VM" whenever it cannot prove otherwise: unreadable cmdline, no `-name` +present, a truncated argument list. Its job is to veto a mistaken identification, +so an inconclusive read must not be allowed to contradict a positive liveness +check. + +Liveness for an attached VM connects to its socket rather than stat-ing it, +because a SIGKILLed QEMU leaves the socket file behind and the file's existence +proves nothing. The same check protects launch: a leftover socket file under a +name you are reusing is deleted, but only after confirming that nothing is +listening on it, since an unregistered but running QEMU would otherwise lose its +monitor and become permanently unreachable. + +And when a tool cannot tell, it says so. Timeouts report the events actually seen +while waiting. Guest-agent failures name the specific thing that is missing +(`qemu-guest-agent` installed and running in the guest) rather than reporting a +generic connection error, and a guest agent that refuses a command on a +RHEL-family guest gets the specific advice about `BLOCK_RPCS` in +`/etc/sysconfig/qemu-ga`. + +## Bound every wait + +Every operation that waits has a deadline. An unbounded wait in a server managing +multiple VMs does not just hang one call, it holds that VM's session lock and +blocks every subsequent call against the same machine, so one wedged guest takes +out an entire VM's tooling. + +| Wait | Bound | Why that number | +| --- | --- | --- | +| QMP connect | 5s | Either the socket is there or it is not | +| QMP command | 30s | Ordinary monitor commands are fast; 30s means wedged | +| Guest-agent handshake | 3s | Doubles as the "is there an agent?" probe | +| Guest-agent call | 10s | The handshake succeeded, so the agent was alive a moment ago | +| `guest_exec` | Caller's `timeout`, plus an outer bound | The poll loop checks the clock between awaits, so a single hung call needs its own ceiling | +| `stop_vm` (graceful) | 30s, configurable | Long enough for a normal shutdown, short enough to notice ACPI being ignored | +| `savevm` / `loadvm` | 900s | Writing or reading all of guest RAM legitimately takes minutes | +| `sandbox_vm` agent wait | 90s, configurable | A cold boot plus the agent's own startup | + +`savevm` is the interesting one. Snapshotting a large VM writes its entire RAM +into the qcow2 file and can honestly take minutes, so a 30 second bound would +turn a working operation into a spurious failure. The response is a much longer +bound rather than no bound at all, because "this can take a while" is not the same +statement as "this can take forever", and a wedged snapshot still needs to +surface eventually. + +`sandbox_vm` shows the other half of bounding a wait well: it does not simply +sleep until its deadline. While waiting for the guest agent it also checks +whether the VM is still alive, so a guest that dies during boot is reported as +having exited, with the tail of the QEMU log, at the moment it happens, instead +of being blamed ninety seconds later on a missing guest agent. + +## Concurrency is a failure mode too + +Two mcqemu instances can share one registry file, and nothing prevents it, so +every write is a read-modify-write under an exclusive file lock and the atomic +rename uses a temp name carrying the writer's PID. Without the lock the second +writer's snapshot silently erases the first writer's VMs, and those VMs keep +running with nobody tracking them. `list_vms` re-reads the file before answering +so records another instance created are visible. + +Within one process, calls against the same VM are serialized by a per-VM lock +(the monitor socket takes one client at a time), and a launch reserves its name +for the duration, because launching involves awaits between checking that a name +is free and registering it, and two concurrent launches would otherwise both pass +the check and race over the same sockets and pidfile. + +## What all of this costs + +More calls fail than would otherwise. A tool that refuses to delete, refuses to +guess, and gives up after a timeout will return errors in situations where a more +optimistic implementation would have carried on and usually been fine. That is +the trade being made, and it is made on purpose: the failures it avoids are the +ones that lose data or leave two VMs on one disk, and those are not recoverable +by retrying. + +A bounded wait can also fire on a slow but perfectly healthy machine, which is +why the bounds most likely to be wrong for your host are parameters rather than +constants. `stop_vm` takes a `timeout`, `guest_exec` takes a `timeout`, and +`sandbox_vm` takes `wait_agent_s`. If you are running a guest under TCG emulation +on a busy laptop, raise them rather than fighting the defaults. + +The errors are written to be acted on, which is worth knowing if you are reading +them as a model: they usually name the next tool to call, and when the safe move +is to do nothing they say that explicitly. Full parameter details for every tool +mentioned here are in the [Tool reference](/reference/tools/). diff --git a/docs-site/src/content/docs/explanation/sandboxing.mdx b/docs-site/src/content/docs/explanation/sandboxing.mdx new file mode 100644 index 0000000..5efc2a3 --- /dev/null +++ b/docs-site/src/content/docs/explanation/sandboxing.mdx @@ -0,0 +1,154 @@ +--- +title: What sandboxing does and does not give you +description: The isolation a mcqemu sandbox actually provides (a separate guest kernel, a copy-on-write overlay, blocked outbound networking) and an honest account of where that boundary ends. +--- + +A sandbox in mcqemu is a QEMU virtual machine with two defaults changed: its disk +is a copy-on-write overlay over a base image, and its outbound networking is +switched off. `sandbox_vm` does the overlay, the launch, and the wait for the +guest agent in one call; `sandbox_destroy` tears the whole thing down. That is +the entire mechanism. Everything below is about what those two defaults buy you +and where they stop. + +## A separate kernel is the real boundary + +The isolation that matters here is not something mcqemu implements. It is the +fact that a guest runs its own kernel on emulated hardware. Guest processes make +syscalls into the guest kernel, which drives virtual devices, which QEMU +implements in userspace on the host. Nothing in the guest addresses host memory, +host processes, or host files, because there is no path from guest userspace to +those things that does not go through the device model. + +That is a much stronger position than a container, where the guest and the host +share one kernel and isolation is a matter of namespaces and seccomp filters +being configured correctly. If you are running software you have reason to +distrust, the kernel boundary is the reason to reach for a VM. + +The cost is that you pay for it. A VM boots a whole operating system, wants its +own memory, and (when the guest architecture does not match the host) runs under +TCG emulation, which is correct but slow. + +## The overlay: the base image is never written + +`sandbox_vm` creates a qcow2 overlay whose backing file is your base image, and +gives the VM only the overlay. Every write the guest makes lands in the overlay. +The base image is opened read-only through the backing chain and comes out +byte-identical no matter what happens inside. + +This is what makes sandboxes cheap enough to be disposable. Creating one is +writing a small file with a pointer to a big one, so a fresh sandbox from a +20 GB base costs kilobytes and a second or two. It is also what makes +`sandbox_destroy` safe: it deletes the overlay and the VM's state and runtime +directories, and reports the base image path back under `base_image_untouched` +so the answer is visible rather than assumed. + +Two consequences worth knowing. The overlay is recreated fresh on every +`sandbox_vm` call for a given name, so a stale overlay from a dead sandbox never +resurrects old state. And because the base is genuinely in use while a sandbox +runs, mcqemu counts it among the disks in use, so an attempt to modify the base +underneath a running sandbox is refused. + +If you want state to survive, take a snapshot (see [Snapshots](/how-to/snapshots/)) +or launch a normal VM against a real disk with `launch_vm` instead. Sandboxes +are for work you intend to throw away. [Disposable sandboxes](/tutorial/disposable-sandboxes/) +walks through the loop. + +## Outbound networking is off by default + +`sandbox_vm` launches with QEMU's user-mode networking (SLIRP) in restricted +mode. Guest-initiated traffic is dropped. Inbound port forwards keep working, so +the default forward from a free host port to guest port 22 is still how you get +in, and `guest_exec` over the agent socket is unaffected because it never touches +the network at all. + +The reason this is the default is specific, and it is worth understanding rather +than taking on faith. SLIRP synthesizes a small private network for the guest, +and in that network the address `10.0.2.2` is the host's loopback interface. A +guest with unrestricted user-mode networking can therefore open connections to +services listening on the host's `127.0.0.1`: a development database that never +bothered with authentication because it only listens locally, an SSH agent +forwarding socket, an unauthenticated Ollama or Redis, a `docker` API on a TCP +port. The mental model of "it is only on localhost, so it is private" is exactly +the model SLIRP breaks. `restrict=on` closes that path along with internet +access. + +When a guest legitimately needs to fetch packages, pass `allow_network=True`, and +be aware you are re-opening the host loopback path at the same time. There is no +setting that gives internet access while blocking `10.0.2.2`, because SLIRP does +not offer that distinction. If you need it, give the VM a real bridged or TAP +interface and enforce the policy in your host firewall, which means starting +QEMU yourself and registering it with +[attach_vm](/how-to/attach-existing-vm/). + +`launch_vm` takes the same `restrict_net` flag, plus `no_net=True` to remove the +network card entirely. A VM with no NIC has no forwards either, so it is only +reachable through the guest agent and the display. + +## Where the boundary ends + +Everything above is real. None of it makes this a hardened security boundary +against a determined attacker. + +**QEMU escapes exist.** The device model is a large body of C code parsing input +that a hostile guest controls, and it has had exploitable bugs, some of them +serious. mcqemu changes nothing about that surface. If your threat model +includes an adversary willing to burn a QEMU escape on you, a mcqemu sandbox is +not the control you want on its own. Real defense in depth for that case means +running QEMU as a dedicated unprivileged user, on a host that holds nothing +valuable, behind whatever seccomp and MAC policy your distribution offers, and +not on your workstation. + +**The QEMU process runs as you.** It has your file permissions, your network +access, and your ability to write to your own home directory. The boundary being +defended is "the guest cannot reach the host", and it holds against the guest +playing by the rules of the device model. It does not hold against an escape, +because the thing that escapes lands with your privileges. + +**`extra_args` can weaken isolation.** `launch_vm` accepts raw QEMU flags, +because there will always be a machine type or device you need and no wrapper +anticipated. That escape hatch is an operator tool. Values you wrote yourself are +fine; values derived from anything untrusted are not, because a few flags turn a +VM into a hole straight through to the host. Those are rejected outright: + +| Rejected | Why | +| --- | --- | +| `-fsdev`, `-virtfs` | Host filesystem passthrough into the guest | +| `-drive` / `-blockdev` naming `/dev/...` | Attaches a host block device | +| `-chardev` with `spawn` | Runs a host command | +| `-runas` | Changes the user the QEMU process runs as | +| `-monitor` | Exposes the human monitor outside the managed socket | +| `-qmp`, `-pidfile`, `-daemonize` | Collide with the sockets and process model mcqemu manages | + +Read that list as what it is: a guardrail against an agent constructing arguments +from untrusted input, not a security perimeter. It is a fixed list of known-bad +flags, not a proof that everything else is safe, and QEMU has a very large flag +surface. An operator who genuinely wants host passthrough can start QEMU by hand +and manage it with `attach_vm`, and nothing stops them, which is correct. The +point of the check is that the path to weakening isolation should require a +person deciding to do it. + +**The guest agent is root inside the guest, by design.** `guest_exec` runs +commands as the agent's user, which is normally root, and `guest_file_write` will +write anywhere the agent can reach. That is not a leak, it is the feature: you +already control the VM's power button, its disks and its display from the host, +so declining to give you a shell would be theatre. What it does mean is that +installing the guest agent is a decision about the guest, not a neutral +convenience. Do not install it in a VM whose contents you are supposed to be +analysing at arm's length, and remember that a sandbox with the agent running has +no meaningful defense against the host, only against the guest reaching out. + +## A summary you can act on + +Protected: your host filesystem from guest processes, your host services on +loopback (with the default restricted networking), your base images from any +guest writes, and your host kernel from guest syscalls. + +Not protected: anything, if QEMU itself is exploited; anything, if you pass +isolation-weakening `extra_args`; the guest's own contents from you, since the +agent and the monitor give you full control; and the host loopback path, if you +pass `allow_network=True`. + +Use a sandbox to run software you have not read, to test an installer, to try a +package that wants to touch your whole system, or to give an agent somewhere to +make a mess. Do not use one as the only thing standing between you and code +written specifically to attack you. diff --git a/docs-site/src/content/docs/explanation/see-and-drive.mdx b/docs-site/src/content/docs/explanation/see-and-drive.mdx new file mode 100644 index 0000000..7618e88 --- /dev/null +++ b/docs-site/src/content/docs/explanation/see-and-drive.mdx @@ -0,0 +1,148 @@ +--- +title: Seeing and driving a VM +description: Why screenshots and scancode-level input let an agent operate a machine that has no OS yet, how absolute and relative pointers differ, and why the look-act-look loop beats a blind sequence of keystrokes. +--- + +Most tooling that automates a computer needs the computer's cooperation. SSH +needs a running sshd, and a network. A guest agent needs a package installed and +a service started. Configuration management needs an OS that already booted. +Every one of those requires that somebody already got the machine into a working +state, which is precisely the part that is tedious to do by hand. + +mcqemu can see and drive a VM without any of it. Screenshots come out of QEMU's +framebuffer and keystrokes go into QEMU's emulated keyboard controller, so both +work at a BIOS setup screen, a GRUB menu, a partitioner, a login prompt, or a +kernel panic. That is the capability the display tools exist for. + +## Screenshots come from the framebuffer, not the guest + +`vm_screenshot` issues QMP's `screendump`, which asks QEMU to render whatever its +emulated display device currently holds and write it to a file on the host as a +PNG. The server reads that file, returns the image, and deletes it. The guest is +not asked, not interrupted, and does not need to be capable of anything. + +Each call writes to a filename unique to that call. A shared name would let a +concurrent screenshot swap the frame between capture and use (which matters for +`vm_click`, described below), and would leave a picture of the guest's screen +sitting on disk afterwards. + +Two things follow from "this is the framebuffer, not a rendering of guest state". +A blank image is a real answer, not a failure: consoles blank, guests switch to +a mode with nothing drawn yet, and a VM sitting at a black screen may be fine. +And the resolution is whatever the guest last programmed, so it changes when an +installer switches from text mode to a graphical mode, or when a display manager +starts. Coordinates from an old screenshot do not survive a mode change. + +For text-mode guests there is a cheaper channel. If the guest writes to its +serial port (a kernel booted with `console=ttyS0`, or a text installer), +`vm_serial_read` returns the tail of the serial log as text, which is far easier +to read than pixels and keeps scrollback that a screenshot cannot. It returns +nothing useful for a guest that only draws to the screen. + +## Keyboard input is synthesized at the scancode level + +`vm_send_keys` uses QMP's `send-key`, which injects key events into the emulated +keyboard controller as QEMU's `qcode` key identifiers. The guest sees exactly +what it would see from a physical keyboard on the emulated hardware. Nothing +above the hardware layer is involved, which is why this works before an OS +exists. + +Each entry in the `keys` list is one press. A single key is `"ret"`, `"esc"`, +`"f2"`, `"a"`; a chord is written with hyphens (`"ctrl-alt-f2"`, `"ctrl-c"`) and +its keys are held together. Friendly aliases are accepted, so `"enter"`, +`"space"` and `"escape"` resolve to `ret`, `spc` and `esc`. Between entries there +is a configurable delay, and each press has a hold time, because firmware menus +and some bootloaders sample the keyboard on a timer and simply miss a press that +is too brief. + +`vm_type_text` is the same mechanism with a translation layer: each character is +mapped through a US layout to a qcode plus an optional shift, and sent as its own +press. This has consequences you should expect rather than discover. The layout +is fixed, so a guest configured for a non-US keyboard will produce different +characters than you typed, most visibly for symbols and for anything involving +AltGr. And every character is a separate round trip over the monitor, so typing +is slow and is capped at 4096 characters. Writing a config file by typing it into +an editor works and is sometimes the only option; when the guest agent is +available, `guest_file_write` is the right tool for anything longer than a +command line. + +## Pointer input has two modes, and they behave differently + +**Absolute (the default).** VMs launched by mcqemu get a `virtio-tablet-pci` +device, which reports positions rather than movements, exactly like a touchscreen +or a drawing tablet. `vm_click` takes pixel coordinates that match what +`vm_screenshot` showed you. It takes a screenshot first, both to prove a display +exists and to read the resolution, checks that your coordinates are inside it, +scales them into QEMU's absolute coordinate space (0 to 32767 on each axis), then +sends the position and the button events. There is no cursor to chase: the +pointer arrives where you said. + +The catch is that the guest needs a driver for that device. Modern Linux, recent +Windows with virtio drivers, and many live images have one. A guest from before +about 2010, or a minimal install without the driver, does not, and `vm_click` +will appear to do nothing at all. That silent non-effect is the symptom to +recognise. + +**Relative (the fallback).** `vm_mouse_move` drives the emulated PS/2 mouse +through the human monitor, which every guest with any mouse support understands. +The trade is that you are no longer specifying a position, you are specifying +movement, and the guest decides what to do with it. Pointer acceleration means a +50 pixel delta may move the cursor 50 pixels, or 90, depending on the guest's +settings. Large deltas also desync some guests outright, which is why motion is +sent in small packets (32 pixels by default, never more than 120, because PS/2 +deltas are small signed values). + +Because you cannot address a position directly, the reliable technique is to +manufacture one. Pass `home="bottom-right"` (or any corner) and mcqemu pushes the +cursor hard against that corner, overshooting deliberately, which pins it to a +known location regardless of where it was. From there you move toward the target +in small steps, take a screenshot to see where the cursor actually landed, +correct with further small moves, and only then click. The tool's own response +says as much, because getting this wrong is the default outcome and a click in +the wrong place in an installer is expensive. + +## The look-act-look loop + +The single most useful habit when driving a VM is: take a screenshot, perform one +action, take another screenshot. It feels wasteful and it is not. + +Blind sequences of keystrokes go wrong for reasons that have nothing to do with +the sequence being incorrect. Guests take unpredictable time to react, and a VM +under TCG emulation can be an order of magnitude slower than the same guest with +KVM, so timing tuned once does not transfer. Input sent while the guest is +probing devices or switching video modes is dropped on the floor with no error +anywhere; the keystroke simply never happened. Installers reorder or insert +screens depending on what hardware they find, what mirror they reach, and whether +a disk already has a partition table. Focus moves on its own when a dialog +appears. And the failure mode compounds: once one keystroke lands on the wrong +screen, every subsequent keystroke in the sequence is being interpreted by a +program you did not intend, and the machine can end up in a state that is worse +than where it started (a partitioner that has silently selected the wrong disk is +the memorable example). + +None of this is detectable from the return value of a key-send. The keystroke was +delivered to the emulated keyboard successfully; that is all the success of that +call means. The only evidence about what actually happened is the next +screenshot. + +So the loop is: look at where you are, decide one action, take it, look again to +confirm it did what you expected. When a step is slow (a package installation, a +reboot, a filesystem being written) look repeatedly rather than sleeping for a +guess. When something unexpected appears, you find out one action after it +happened instead of twenty. [Drive an installer](/how-to/drive-an-installer/) +applies this to a concrete case, and [your first sandbox](/tutorial/first-sandbox/) +is a gentler place to get a feel for it. + +## What this does not give you + +The model is reading pixels. There is no accessibility tree, no DOM, no list of +widgets, and no text extraction: a button is a shape with letters drawn on it, so +low-contrast themes, unusual fonts, small text and scaled displays all degrade +the read. Coordinates are display-space and go stale on a mode change. There is +no way to ask what has focus, so a screenshot has to be interpreted for it. + +The compensation is that all of this works on absolutely any guest, including one +whose OS does not exist yet. Once a guest is booted and has the agent installed, +`guest_exec` is faster, more precise and much easier to check for success, and it +is the right tool for anything reachable that way. Screenshots and scancodes are +for getting to that point, and for the times when the guest cannot help you. diff --git a/docs-site/src/content/docs/how-to/attach-existing-vm.mdx b/docs-site/src/content/docs/how-to/attach-existing-vm.mdx new file mode 100644 index 0000000..1adbe0a --- /dev/null +++ b/docs-site/src/content/docs/how-to/attach-existing-vm.mdx @@ -0,0 +1,149 @@ +--- +title: Manage a VM you started yourself +description: Register an externally launched QEMU process with attach_vm, know which tools work on it, and understand what forget_vm does. +--- + +import { Steps, Aside } from '@astrojs/starlight/components'; + +`launch_vm` builds its own QEMU command line, which is convenient until you +need something it does not offer: a device model it never adds, a bridged +network, a specific machine type, or a VM that libvirt or a script already +starts for you. + +`attach_vm` covers that case. You start QEMU however you like, and mcqemu +manages it through its QMP control socket. It never rewrites your command line +and never launches the process; it only connects to what is already there. + +## Give QEMU the sockets + +The one hard requirement is a QMP unix socket, added with +`-qmp unix:/path,server=on,wait=off`. The `server=on` part makes QEMU create +and listen on the socket, and `wait=off` stops it from blocking at startup +until a client connects. + +Two optional additions are worth including, because retrofitting them means +restarting the guest: + +- A virtio-serial channel named `org.qemu.guest_agent.0` for the `guest_*` + tools. +- `virtio-tablet-pci` for absolute pointer positioning, which is what + `vm_click` needs. + +A complete example: + +```bash +qemu-system-x86_64 \ + -name legacy \ + -machine q35,accel=kvm -cpu host -m 4096 -smp 4 \ + -drive file=$HOME/vms/legacy.qcow2,if=virtio,format=qcow2 \ + -display none \ + -qmp unix:$XDG_RUNTIME_DIR/legacy-qmp.sock,server=on,wait=off \ + -chardev socket,id=qga0,path=$XDG_RUNTIME_DIR/legacy-qga.sock,server=on,wait=off \ + -device virtio-serial \ + -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 \ + -device virtio-tablet-pci \ + -daemonize -pidfile $XDG_RUNTIME_DIR/legacy.pid +``` + +If the VM is already running without a QMP socket, there is no way to add one +without restarting it. QMP is the only channel these tools have. + +## Attach it + + + +1. Register the process: + + ```json + attach_vm(name="legacy", + qmp_socket="/run/user/1000/legacy-qmp.sock", + qga_socket="/run/user/1000/legacy-qga.sock", + pid=48213) + ``` + + Only `name` and `qmp_socket` are required. `qga_socket` enables the + `guest_*` tools; `pid` lets liveness checks tell "stopped" apart from + "unreachable" without probing the socket. + +2. The call verifies the socket exists, is really a unix socket, and answers a + QMP status query before registering anything, then returns the guest's + current status. A failure here means QEMU is not listening where you said. + +3. Confirm it shows up alongside everything else: + + ```json + list_vms() + ``` + + The entry has `source: "attached"`, which is how the other tools know it was + not spawned here. + + + +Names must be unique across the registry. If the name is taken, either pick +another or `forget_vm` the old entry first. + +## What works, and what does not + +Once attached, almost everything behaves as it does for a launched VM: + +- `vm_info`, `list_vms`, `pause_vm`, `resume_vm` all work over QMP. +- `vm_screenshot`, `vm_send_keys` and `vm_type_text` work if the VM has a + display device. +- `vm_click` needs the tablet device; without it, use `vm_mouse_move` (see + [Drive an installer](/how-to/drive-an-installer/)). +- `guest_*` tools work if you passed `qga_socket` and the guest is running + `qemu-guest-agent`. +- `vm_snapshot_*` works if the VM's writable disks are qcow2. +- `stop_vm` sends an ACPI power button press over QMP, and with `force=true` + tells QEMU to quit outright. + +The exception is `vm_serial_read`. mcqemu reads the serial log file it set up +at launch, and an attached VM's serial output goes wherever your own command +line sent it. The tool reports that rather than guessing. Read your own log +file directly, or use `vm_screenshot`. + +`sandbox_destroy` also refuses attached VMs, along with any VM that +`sandbox_vm` did not create. It deletes disks, and it will only delete overlays +it made itself. + + + +## What forget_vm does + +`forget_vm` removes the registry entry. That is all it does. + +```json +forget_vm(name="legacy") +``` + +It does not stop the VM, does not touch the QEMU process, and never deletes a +disk. Afterwards the VM keeps running exactly as before, and mcqemu simply has +no record of it. The result reports `process_left_running` so you know whether +you have just stopped tracking a live process. + +Use it to hand a VM back to whatever else owns it, to clear a stale entry for a +VM that died elsewhere, or to free up a name. + +For a VM that this server launched, `forget_vm` refuses while the process is +still alive unless you pass `force=true`. Forgetting a running spawned VM +orphans it: the process stays up holding its disks and ports, and no tool can +find it any more. The refusal exists so that only happens on purpose. If you do +orphan one, find it with `pgrep -af qemu-system` and either kill it or attach +it again by its socket path. + +Re-attaching later is just `attach_vm` with the same socket paths. Nothing +about the running VM changed while it was unregistered. + +## Related + +- [Install and connect](/how-to/install-and-connect/) for host requirements. +- [Architecture](/explanation/architecture/) for how the registry tracks VMs + and why they outlive the server. +- [Tool reference](/reference/tools/) for parameters and defaults. diff --git a/docs-site/src/content/docs/how-to/drive-an-installer.mdx b/docs-site/src/content/docs/how-to/drive-an-installer.mdx new file mode 100644 index 0000000..645a46e --- /dev/null +++ b/docs-site/src/content/docs/how-to/drive-an-installer.mdx @@ -0,0 +1,214 @@ +--- +title: Drive an installer +description: Use the screenshot, act, screenshot loop to get through a bootloader, a text installer or a graphical one, including when to use vm_click versus vm_mouse_move. +--- + +import { Steps, Aside } from '@astrojs/starlight/components'; + +Installers run before there is anything inside the guest to talk to. No SSH, no +guest agent, no shell. The tools in this page work at the level QEMU emulates +hardware: a framebuffer you can photograph, a keyboard that emits scancodes, +and a mouse. That means they work in BIOS menus, bootloaders, partitioners and +desktop environments alike, on any operating system, with nothing installed in +the guest. + +This page assumes a VM is already running (see +[Your first virtual machine](/tutorial/first-sandbox/) if not). + +## The loop + +Every interaction is the same three beats: look, act, look again. + + + +1. `vm_screenshot(name="vm")` returns the display as a PNG. Read what is + actually on screen. + +2. Send exactly one meaningful action: a keystroke, a line of text, a click. + +3. Screenshot again to see what it did. + + + +The second screenshot is not optional bookkeeping, it is the whole method. A +guest takes time to react, and an installer's next question is rarely the one +you predicted. Chaining three blind actions and screenshotting at the end +usually means finding out that action one landed somewhere unexpected and +actions two and three went into a dialog you did not know was there. + + + +Guests need time. After pressing Enter on a step that formats a filesystem or +copies packages, wait before screenshotting again, and if the screen has not +changed, wait longer rather than sending the key again. Repeated keystrokes +queue up and fire all at once when the guest catches up. + +## Keyboard + +`vm_send_keys` presses keys and chords. Each entry in `keys` is one press: + +```json +vm_send_keys(name="vm", keys=["down", "down", "ret"]) +vm_send_keys(name="vm", keys=["ctrl-alt-f2"]) +vm_send_keys(name="vm", keys=["esc"]) +``` + +A chord is written with hyphens and is pressed together, so `"ctrl-c"` is one +entry, not two. Aliases like `enter`, `space` and `escape` work alongside the +QEMU names `ret`, `spc`, `esc`. Function keys are `f1` through `f12`. + +Two timing knobs matter when a guest is slow or an installer eats keys during +a redraw: `hold_ms` (default 100) is how long each key is held down, and +`delay_ms` (default 50) is the gap between entries. Raising `delay_ms` to 200 +or so is the usual fix for a menu that seems to miss presses. + +`vm_type_text` types a string character by character, which is what you want +for hostnames, passwords and shell commands: + +```json +vm_type_text(name="vm", text="setup-alpine", enter=true) +``` + +`enter=true` presses Enter at the end. The layout is US ASCII: printable +characters plus tab and newline. There is a 4096 character limit, because every +character is a separate round trip to the guest. If you need to put a large +file into a guest, wait until the guest agent is available and use +`guest_file_write` instead of typing it. + +## Text installers: read the serial console + +Many text-mode installers and Linux kernels booted with `console=ttyS0` write +to the serial port, and mcqemu logs that to a file for every VM it launches: + +```json +vm_serial_read(name="vm", tail_lines=100) +``` + +Text is much cheaper to read than a screenshot and it carries scrollback, so +prefer it when the guest is producing it. Guests that only paint a graphical +screen log nothing here, and VMs registered with `attach_vm` manage their own +serial output, so `vm_serial_read` has nothing to read for those. + +## Mouse: which tool + +Two tools move the pointer, and picking the wrong one wastes a lot of time +because the failure is silent. Nothing errors, the click just does not land. + +**`vm_click` is the one to try first.** It takes pixel coordinates that match +what `vm_screenshot` showed you, and drives an absolute-position tablet device +that every VM launched by this server has: + +```json +vm_click(name="vm", x=412, y=337) +vm_click(name="vm", x=88, y=120, button="right") +vm_click(name="vm", x=200, y=150, double=true) +``` + +Coordinates are validated against the current display size, so a click outside +the screen is rejected rather than silently dropped. + +This works when the guest has a driver for the tablet device. Modern Linux, +Windows 7 and later, and current BSDs do. Screenshot after the click: if the +pointer did not move to where you clicked, the guest has no absolute pointer +driver and you need the other tool. + +**`vm_mouse_move` is for guests without tablet drivers**, which in practice +means most operating systems older than about 2010, and some installers before +their drivers load. It drives the emulated PS/2 mouse, which only understands +relative motion: "three pixels left", never "go to 412, 337". The guest may +also apply its own pointer acceleration, so a request to move 200 pixels can +land 260 pixels away. + +The reliable pattern is corner, step, verify: + + + +1. Home the cursor against a corner so you know where it is. Motion is + deliberately overshot toward the edge, so the cursor pins there whatever its + previous position was. + + ```json + vm_mouse_move(name="vm", home="top-left") + ``` + + Valid corners are `top-left`, `top-right`, `bottom-left`, `bottom-right`. + +2. Move toward the target with `dx` and `dy` relative to that corner: + + ```json + vm_mouse_move(name="vm", home="top-left", dx=412, dy=337) + ``` + + Motion is sent in packets no larger than `step` pixels (default 32) because + guests commonly desync or over-accelerate on large deltas. Lower `step` if a + guest behaves erratically; the cost is more round trips. + +3. Screenshot and find the cursor. Acceleration means it is often not where you + asked. + +4. Correct with small relative moves, no `home` this time, until the cursor is + on the target: + + ```json + vm_mouse_move(name="vm", dx=-14, dy=6) + ``` + +5. Screenshot to confirm, then click: + + ```json + vm_mouse_move(name="vm", click="left") + ``` + + You can also move and click in one call by passing `click` alongside `dx` + and `dy`, but only once you trust that guest's scaling. `double=true` + double-clicks. + + + +Homing costs a screenshot and a burst of motion packets, so for a sequence of +clicks in the same area, home once and then work relatively from where you +know the cursor is, re-homing whenever you lose track. + +## Worked example: a graphical installer + + + +1. Screenshot. The bootloader menu is showing. + +2. `vm_send_keys(name="vm", keys=["ret"])` to take the default entry. + +3. Wait, then screenshot. Repeat until the installer's first screen appears; + graphical installers can take a minute to start under emulation. + +4. Screenshot, find the "Next" button, and `vm_click` on its centre. + +5. Screenshot. If the button highlighted or the page advanced, the tablet works + and you can use `vm_click` for the rest of the installation. If nothing + moved, switch to `vm_mouse_move` with the corner, step, verify pattern. + +6. For text fields, click into the field, screenshot to confirm the caret is + there, then `vm_type_text`. Do not assume focus; installers move it around + between pages. + +7. At the partitioning step, screenshot before and after every action, and read + the confirmation dialog before answering it. + + + +## After the install + +When the guest is up, install `qemu-guest-agent` inside it (see +[Install the guest agent](/how-to/install-the-guest-agent/)). Screenshots and +keystrokes keep working afterwards, and stay the right tool for boot menus and +crash screens, but for anything the guest can do itself, `guest_exec` gives you +exit codes and text instead of pixels. + +## Related + +- [See and drive](/explanation/see-and-drive/) for how input injection and + screenshots work underneath. +- [Tool reference](/reference/tools/) for the full parameter list. diff --git a/docs-site/src/content/docs/how-to/install-and-connect.mdx b/docs-site/src/content/docs/how-to/install-and-connect.mdx new file mode 100644 index 0000000..65ac1b7 --- /dev/null +++ b/docs-site/src/content/docs/how-to/install-and-connect.mdx @@ -0,0 +1,172 @@ +--- +title: Install and connect +description: Install mcqemu with uv, register it with Claude Code at project or user scope, and confirm the host can actually run VMs. +--- + +import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +## Requirements + +mcqemu drives local QEMU processes, so it runs where QEMU runs: + +- Linux, with `qemu-system-x86_64` and `qemu-img` on your PATH. Other guest + architectures need their own binary (`qemu-system-aarch64` and so on). +- Python 3.11 or newer, managed with [uv](https://docs.astral.sh/uv/). +- Read and write access to `/dev/kvm` for hardware acceleration. This is + optional: without it QEMU falls back to TCG software emulation, which works + and is considerably slower. + +Check the host before blaming the server: + +```bash +qemu-system-x86_64 --version +qemu-img --version +ls -l /dev/kvm && test -w /dev/kvm && echo "kvm writable" +``` + +If `/dev/kvm` exists but is not writable, add yourself to the group that owns +it (usually `kvm`) and log back in. + +## Install + + + + +`uvx` downloads and runs the published package, with no install step to +maintain: + +```bash +uvx mcqemu +``` + +It prints a version banner to stderr and then waits for an MCP client on +stdin. That is a healthy server; press Ctrl-C. + + + + +Working on mcqemu itself, or pinning to a local revision: + +```bash +git clone https://git.supported.systems/warehack.ing/mcqemu +cd mcqemu +uv sync +uv run mcqemu +``` + + + + +## Register with Claude Code + +Pick the scope by asking who should get these tools. + + + + +Project scope writes `.mcp.json` in the repository, so anyone who checks it out +gets the same server. Use this when VMs are part of the project's workflow, +such as a repository whose tests need a throwaway guest. + +```bash +claude mcp add --scope project mcqemu -- uvx mcqemu +``` + +The resulting file, which you can also write by hand: + +```json title=".mcp.json" +{ + "mcpServers": { + "mcqemu": { + "type": "stdio", + "command": "uvx", + "args": ["mcqemu"], + "env": {} + } + } +} +``` + +From a checkout, use `"command": "uv"` with +`"args": ["run", "--directory", "/path/to/mcqemu", "mcqemu"]` so the server +runs regardless of which directory Claude Code starts in. + + + + +User scope makes the tools available in every project you open. Use this when +VMs are part of how you work rather than part of one repository. + +```bash +claude mcp add --scope user mcqemu -- uvx mcqemu +``` + + + + + + +## Confirm it works + + + +1. Run `/mcp` in Claude Code. `mcqemu` should be listed as connected. If it is + listed as failed, run the exact command from your config in a terminal; the + error is usually a missing `uv` on PATH or a wrong `--directory`. + +2. Ask the agent to call `list_vms`. A fresh install answers with an empty list + and empty `registry_warnings`. + +3. Launch something disposable to prove QEMU itself works. Any ISO will do, and + it does not need to boot anywhere useful: + + ```json + launch_vm(name="smoketest", iso="~/isos/alpine-virt.iso") + ``` + + The result reports `accel: "kvm"` or `accel: "tcg"`, which tells you whether + acceleration is active. + +4. Take a screenshot with `vm_screenshot(name="smoketest")`. If you get a PNG of + a bootloader, every layer is working: the server, QEMU, and the QMP control + channel. + +5. Clean up: `stop_vm(name="smoketest", force=true)`. Force is appropriate here + because no operating system is running to answer a power button press. + + + +## When something fails + +**`qemu-system-x86_64 not found on PATH`.** QEMU is not installed, or not for +that architecture. On Arch, `pacman -S qemu-full`; on Debian and Ubuntu, +`apt install qemu-system-x86`. + +**The launch result says `accel: "tcg"` and you expected KVM.** Either +`/dev/kvm` is not writable by your user, or the guest architecture does not +match the host. KVM only applies when the two match; an aarch64 guest on an +x86_64 host is always emulated. + +**A VM is running but no tool can see it.** VMs survive restarts of the MCP +server because they are daemonized QEMU processes, and the registry on disk is +what connects them. If `list_vms` returns a non-empty `registry_warnings`, that +bookkeeping was damaged and VMs may be running untracked. Find them with +`pgrep -af qemu-system` before launching anything with the same name. + +**You want the state kept somewhere else.** `MCQEMU_STATE_DIR` and +`MCQEMU_RUNTIME_DIR` move the registry, logs and sockets; +`MCQEMU_LOG_LEVEL=DEBUG` makes the server chattier on stderr. See +[Configuration](/reference/configuration/). + +## Next + +- [Your first virtual machine](/tutorial/first-sandbox/) for a guided + install from an ISO. +- [Tool reference](/reference/tools/) for every tool, parameter and default. +- [Architecture](/explanation/architecture/) for what the server keeps on disk + and why VMs outlive it. diff --git a/docs-site/src/content/docs/how-to/install-the-guest-agent.mdx b/docs-site/src/content/docs/how-to/install-the-guest-agent.mdx new file mode 100644 index 0000000..001f071 --- /dev/null +++ b/docs-site/src/content/docs/how-to/install-the-guest-agent.mdx @@ -0,0 +1,208 @@ +--- +title: Install the guest agent +description: Get qemu-guest-agent into a guest, either from inside a running VM or by injecting it into a stopped disk image with qemu-nbd and chroot. +--- + +import { Steps, Aside } from '@astrojs/starlight/components'; + +`guest_exec`, `guest_file_read` and `guest_file_write` talk to +`qemu-guest-agent` running inside the guest operating system. mcqemu wires the +host side of that channel (a virtio-serial port named +`org.qemu.guest_agent.0`) into every VM it launches, so the only missing piece +is the package inside the guest. + +Check what you have: + +```json +guest_ping(name="vm") +``` + +Success means you are done. Failure means the agent is not installed, not +running, or the guest is paused or frozen. `guest_info` then reports the guest +OS, the agent version, and which agent commands are enabled. + +## Option 1: from inside a running guest + +Use this when the guest is up and you can reach a console (with +`vm_type_text`, see [Drive an installer](/how-to/drive-an-installer/)) or an +SSH session through a forwarded port. + +The guest needs package downloads to work, so if this VM was created with +`sandbox_vm`, it has outbound networking blocked by default; recreate it with +`allow_network=true` for the install. + +Run the pair of commands for the distribution, as root: + +```bash +# Debian, Ubuntu +apt-get update && apt-get install -y qemu-guest-agent +systemctl enable --now qemu-guest-agent + +# Fedora, RHEL, CentOS, Rocky, Alma +dnf install -y qemu-guest-agent +systemctl enable --now qemu-guest-agent + +# Arch +pacman -S --noconfirm qemu-guest-agent +systemctl enable --now qemu-guest-agent + +# Alpine +apk add qemu-guest-agent +rc-update add qemu-guest-agent default && rc-service qemu-guest-agent start + +# openSUSE +zypper install -y qemu-guest-agent +systemctl enable --now qemu-guest-agent +``` + +Windows guests get the agent from the +[virtio-win](https://github.com/virtio-win/virtio-win-pkg-scripts) ISO: attach +it with `launch_vm(..., iso="/path/to/virtio-win.iso")` and run +`guest-agent\qemu-ga-x86_64.msi` from inside the guest. + +Then confirm from the host: + +```json +guest_ping(name="vm") +guest_exec(name="vm", command="uname", args=["-a"]) +``` + + + +Enabling the service matters as much as installing the package. An agent that +is running now but not enabled at boot will be missing the next time you launch +that image, and every sandbox built on it. + +## Option 2: inject it into a stopped disk image + +Use this when you cannot get a console: a downloaded cloud image with no +password set, an image whose network never comes up, or when you are preparing +a base image and would rather not boot it at all. + +The technique exposes the disk image to the host as a block device with +`qemu-nbd`, mounts the guest's root filesystem, and installs the package into +it through `chroot`. + + + + + +1. Load the network block device module and connect the image: + + ```bash + sudo modprobe nbd max_part=8 + sudo qemu-nbd --connect=/dev/nbd0 ~/vms/debian.qcow2 + ``` + +2. Find the root partition. Do not guess: images vary, and picking a boot or + EFI partition wastes time. + + ```bash + lsblk /dev/nbd0 + sudo blkid /dev/nbd0p* + ``` + + The root filesystem is usually the largest ext4, xfs or btrfs partition. + If `lsblk` shows LVM physical volumes instead of a plain filesystem, run + `sudo vgchange -ay` and mount the resulting device under `/dev/mapper/`. + +3. Mount it, plus the pseudo-filesystems the package manager needs: + + ```bash + sudo mkdir -p /mnt/guest + sudo mount /dev/nbd0p1 /mnt/guest + sudo mount --bind /dev /mnt/guest/dev + sudo mount --bind /proc /mnt/guest/proc + sudo mount --bind /sys /mnt/guest/sys + ``` + + If the image has a separate `/boot` or EFI partition, mount those inside + `/mnt/guest` too. Installing the agent does not need them, but a package + manager that decides to regenerate an initramfs will fail without them. + +4. Give the chroot working DNS, keeping the original so you can put it back: + + ```bash + sudo cp /mnt/guest/etc/resolv.conf /mnt/guest/etc/resolv.conf.orig + sudo cp /etc/resolv.conf /mnt/guest/etc/resolv.conf + ``` + +5. Install the agent inside the image: + + ```bash + sudo chroot /mnt/guest apt-get update + sudo chroot /mnt/guest apt-get install -y qemu-guest-agent + sudo chroot /mnt/guest systemctl enable qemu-guest-agent + ``` + + `systemctl enable` works in a chroot because it only creates symlinks; it + does not need systemd to be running. `systemctl start` would fail, which is + fine, because the guest starts the service on its next boot. For dnf-based + images substitute `dnf install -y qemu-guest-agent`, and for Alpine + `apk add qemu-guest-agent` with `rc-update add qemu-guest-agent default`. + +6. Restore the guest's own resolver: + + ```bash + sudo mv /mnt/guest/etc/resolv.conf.orig /mnt/guest/etc/resolv.conf + ``` + +7. Tear down in reverse order, and check each step succeeded: + + ```bash + sudo umount -R /mnt/guest + sudo qemu-nbd --disconnect /dev/nbd0 + ``` + + If `umount` reports the target is busy, find what is holding it with + `sudo lsof +D /mnt/guest` and stop that before retrying. Disconnecting the + nbd device while a filesystem is still mounted loses writes. + +8. Boot it and check: + + ```json + launch_vm(name="debian", disks=["~/vms/debian.qcow2"]) + guest_ping(name="debian") + ``` + + + + + +## Making it a base image + +Once `guest_ping` answers, that image is ready to be the base for disposable +clones. Shut the VM down cleanly with `stop_vm` so the filesystem is +consistent, then never launch the base directly again; build sandboxes on it +instead, as in [Disposable sandboxes](/tutorial/disposable-sandboxes/). + +While you are in there, two things are worth doing to the image because every +future sandbox inherits them: install the packages you always want, and make +sure the SSH server is enabled if you plan to use the forwarded port that +`sandbox_vm` sets up. + +## Related + +- [Disposable sandboxes](/tutorial/disposable-sandboxes/) for using the + finished base image. +- [Snapshots](/how-to/snapshots/) for checkpointing an image before you modify + it. +- [Tool reference](/reference/tools/) for the `guest_*` tool parameters. diff --git a/docs-site/src/content/docs/how-to/snapshots.mdx b/docs-site/src/content/docs/how-to/snapshots.mdx new file mode 100644 index 0000000..71fcdb5 --- /dev/null +++ b/docs-site/src/content/docs/how-to/snapshots.mdx @@ -0,0 +1,132 @@ +--- +title: Take and restore snapshots +description: Live snapshots of running VMs capture RAM and restore instantly; offline image snapshots work on stopped disks. When to use each. +--- + +import { Steps, Aside } from '@astrojs/starlight/components'; + +There are two kinds of snapshot, and choosing between them comes down to one +question: is the VM running right now? + +| | Live snapshot | Offline image snapshot | +|---|---|---| +| Tools | `vm_snapshot_create` / `_restore` / `_delete` / `_list` | `image_snapshot_create` / `_apply` / `_delete` / `_list` | +| Addressed by | VM name | disk image path | +| VM state | must be running | must be stopped | +| Captures | RAM, devices and disk | disk contents only | +| Restoring gives you | the exact moment you snapshotted, applications still open | the disk as it was, which then boots from cold | +| Requires | writable disks in qcow2 format | a qcow2 image | + +Both store their data inside the qcow2 file itself, so snapshots travel with +the image when you copy it, and they cost disk space in the same file. + +## Live snapshots of a running VM + +Use these for checkpoints in the middle of work: before applying an update, +before a change you expect to have to undo, before letting something untrusted +run. + + + +1. Create the checkpoint. The VM pauses briefly while its RAM is written into + the disk image, then continues. + + ```json + vm_snapshot_create(name="dev", tag="before-upgrade") + ``` + + Tags use letters, digits, dots, underscores and hyphens, start with a letter + or digit, and are at most 64 characters. + +2. Do the risky thing. + +3. List what you have if you have lost track: + + ```json + vm_snapshot_list(name="dev") + ``` + +4. Roll back. RAM, devices and disk all revert together, so the guest comes + back mid-sentence rather than booting. + + ```json + vm_snapshot_restore(name="dev", tag="before-upgrade") + ``` + +5. Drop a checkpoint you no longer need. The VM keeps running; only the saved + state is removed. + + ```json + vm_snapshot_delete(name="dev", tag="before-upgrade") + ``` + + + + + +How long a snapshot takes scales with how much RAM the VM has, because that RAM +is being written to disk. A 2 GB VM is a few seconds; a 32 GB VM is a coffee +break. Restoring is the same work in reverse and is still far quicker than a +boot plus getting an application back to the state it was in. + +Live snapshots need every writable disk to be qcow2. If a VM has a raw disk +attached, `vm_snapshot_create` fails, and the fix is to convert the disk while +the VM is stopped: + +```json +image_convert(source="~/vms/data.img", dest="~/vms/data.qcow2", format="qcow2") +``` + +## Offline snapshots of a stopped image + +Use these when the VM is not running: marking a known-good state of a base +image, or checkpointing a disk before you modify it from the host (for example +before injecting the guest agent, see +[Install the guest agent](/how-to/install-the-guest-agent/)). + +```json +image_snapshot_create(path="~/vms/base.qcow2", tag="clean-install") +image_snapshot_list(path="~/vms/base.qcow2") +image_snapshot_apply(path="~/vms/base.qcow2", tag="clean-install") +image_snapshot_delete(path="~/vms/base.qcow2", tag="clean-install") +``` + +These tools refuse to touch a disk that is attached to a running VM, and say +which VM is holding it. Stop that VM first. The refusal is deliberate: editing +a disk underneath a live guest corrupts it, and the guest would not notice +until much later. + +Only disk contents are stored, so `image_snapshot_apply` gives you an image +that boots from cold. That is exactly what you want for a base image, and not +what you want mid-session, which is why the live tools exist. + +## Choosing + +**Working inside a VM and about to do something reversible.** Live snapshot. +The restore puts you back in the room you were standing in. + +**Preparing a base image, or about to edit a disk from the host.** Offline +snapshot, with the VM stopped. + +**You want a clean throwaway VM every time, not a checkpoint.** Neither. Use +`sandbox_vm` with an overlay, which is faster to create and cheaper to discard +than any snapshot; see [Disposable sandboxes](/tutorial/disposable-sandboxes/). +Snapshots are for going back inside one VM's history; overlays are for never +changing the original in the first place. + +**You need a copy you can move to another machine or keep for months.** +Neither. `image_convert` writes a standalone image, flattening any backing +chain, which is easier to reason about than a snapshot living inside a file +that also holds other states. + +## Related + +- [Tool reference](/reference/tools/) for exact parameters. +- [Architecture](/explanation/architecture/) for how snapshot state is stored + in qcow2. diff --git a/docs-site/src/content/docs/index.mdx b/docs-site/src/content/docs/index.mdx new file mode 100644 index 0000000..e79bfbe --- /dev/null +++ b/docs-site/src/content/docs/index.mdx @@ -0,0 +1,52 @@ +--- +title: mcqemu +description: An MCP server for QEMU. Launch and drive virtual machines, run disposable sandboxes, take live snapshots, and reach inside guests, all as tools an agent can call. +template: splash +hero: + tagline: Give an agent a hypervisor. Launch VMs, watch their screens, type into them, snapshot them mid-flight, and throw them away. + actions: + - text: Start the tutorial + link: /tutorial/first-sandbox/ + icon: right-arrow + variant: primary + - text: Tool reference + link: /reference/tools/ + icon: open-book + variant: minimal +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; + + + + One call gives you a copy-on-write clone of a base image, booted, with the + guest agent answering. One more call destroys it. The base image is never + written to, and outbound networking is off unless you ask for it. + + + Screenshots come from the framebuffer, so they work at a BIOS menu or a + bootloader with no guest cooperation. Keys are injected as scancodes, which + is why they work before an OS exists. + + + Save RAM, devices and disks at a moment in time, keep working, then roll the + whole machine back. Restore is instant because the memory came with it. + + + With qemu-guest-agent installed, run commands and read or write files over + virtio-serial. No SSH, no network, no credentials. + + + +## Install + +```bash +uvx mcqemu # run it +claude mcp add mcqemu -- uvx mcqemu # add it to Claude Code +``` + +Requires Linux with QEMU on the PATH. `/dev/kvm` access is optional; without it +guests run under software emulation, which works but is much slower. + +New here? [What is mcqemu?](/overview/) explains the shape of it in a couple of +minutes, then the [tutorial](/tutorial/first-sandbox/) gets you to a running VM. diff --git a/docs-site/src/content/docs/overview.mdx b/docs-site/src/content/docs/overview.mdx new file mode 100644 index 0000000..336786e --- /dev/null +++ b/docs-site/src/content/docs/overview.mdx @@ -0,0 +1,59 @@ +--- +title: What is mcqemu? +description: "The shape of the project in two minutes: what it manages, what an agent can do with it, and what it deliberately does not try to be." +--- + +mcqemu is an MCP server that turns QEMU into tools an agent can call. It manages +virtual machines on the machine it runs on: creating disks, booting them, +watching their screens, typing into them, snapshotting them, running commands +inside them, and tearing them down. + +QEMU already speaks a machine-friendly protocol. QMP is JSON over a unix socket, +and the guest agent speaks the same framing over a virtio-serial channel. Most of +mcqemu is a careful mapping from that surface onto tools with names and +descriptions written for the thing calling them. + +## What you can do with it + +Boot an operating system from an installer ISO and drive the installation by +looking at screenshots and sending keystrokes, without a display attached. +Keep a base image around with the guest agent installed, then spin up disposable +clones of it in seconds and destroy them when you are done. Freeze a running +machine, try something destructive, and roll back to the exact moment before, +memory included. Reach into a guest to run a command or drop in a file when there +is no network and no credentials. + +## The pieces + +A VM launched by mcqemu is a daemonized QEMU process with two unix sockets: one +for QMP, one for the guest agent. A small JSON registry records what exists. +Because the QEMU processes are daemonized, they outlive the MCP server, so +restarting your editor does not take down your machines. See +[architecture](/explanation/architecture/) for why that shape was chosen and what +it costs. + +Disk images are managed through `qemu-img`. Sandboxes are copy-on-write overlays +on top of a base image, which is what makes them cheap to create and safe to +throw away. + +## What it is not + +It is not a cluster manager. There is no scheduler, no migration, no multi-host +awareness; mcqemu manages VMs on one machine and says so. + +It is not a hardened security boundary. A sandbox is a real VM with a separate +kernel and no outbound network by default, which is a meaningful barrier against +software that misbehaves, but it is not a claim that a determined attacker with a +QEMU escape cannot get out. [Sandboxing](/explanation/sandboxing/) is explicit +about where the line sits. + +It does not require anything of a guest in order to watch or drive it. Screens +and keystrokes work at the firmware level. Only the `guest_*` tools need +cooperation, in the form of `qemu-guest-agent` running inside. + +## Where to go next + +The [tutorial](/tutorial/first-sandbox/) starts from nothing and ends with a +working VM you can talk to. The [tool reference](/reference/tools/) lists every +tool with its parameters. If you would rather understand the machinery first, +start with [architecture](/explanation/architecture/). diff --git a/docs-site/src/content/docs/reference/configuration.mdx b/docs-site/src/content/docs/reference/configuration.mdx new file mode 100644 index 0000000..d4d47e7 --- /dev/null +++ b/docs-site/src/content/docs/reference/configuration.mdx @@ -0,0 +1,93 @@ +--- +title: Configuration and file layout +description: Environment variables, where state and sockets live, launch defaults, and how port forwarding and firmware are chosen. +sidebar: + order: 2 +--- + +## Environment variables + +| Variable | Default | What it does | +| --- | --- | --- | +| `MCQEMU_STATE_DIR` | `$XDG_DATA_HOME/mcqemu`, else `~/.local/share/mcqemu` | Registry, per-VM logs, sandbox overlays, UEFI variable stores | +| `MCQEMU_RUNTIME_DIR` | `$XDG_RUNTIME_DIR/mcqemu`, else `/tmp/mcqemu-$UID` | QMP sockets, guest-agent sockets, pidfiles | +| `MCQEMU_LOG_LEVEL` | `INFO` | Log level for the server's own stderr logging | + +Both directory variables are useful for tests and for running more than one +independent instance. Sockets live under the runtime directory partly because +unix socket paths are limited to about 107 bytes, and a short path leaves room +for long VM names. + +## What lives where + +``` +$MCQEMU_STATE_DIR/ + vms.json registry (schema-versioned, atomically written) + vms.json.lock advisory lock held during read-modify-write + vms// + qemu.log QEMU's own log, written after daemonizing + serial.log the guest's serial console + overlay.qcow2 sandbox overlay disk (sandboxes only) + uefi-vars.fd per-VM UEFI variable store (firmware="uefi") + +$MCQEMU_RUNTIME_DIR// + qmp.sock QMP control socket + qga.sock guest-agent channel + pid pidfile written by QEMU +``` + +The registry is rewritten under an exclusive lock, re-reading first, so a second +mcqemu instance sharing the same state directory merges rather than overwrites. +If the file is ever unreadable it is quarantined as `vms.corrupt..json` +and the server starts with an empty registry, reporting the damage in the +`registry_warnings` field of `list_vms`. Running VMs are unaffected by that; they +can be re-registered with `attach_vm` using the sockets above. + +## Launch defaults + +`launch_vm` aims for a sensible modern machine and lets you override any of it. + +| Aspect | Default | +| --- | --- | +| Machine type | `q35` on x86, `virt` on aarch64 and riscv64, QEMU's own default elsewhere | +| Acceleration | KVM with `-cpu host` when the guest architecture matches the host and `/dev/kvm` is writable, otherwise TCG with `-cpu max` | +| Memory and CPUs | 2048 MB, 2 vCPUs | +| Disks | virtio, with the format probed by `qemu-img` rather than guessed | +| Display | none, with a display device still present so screenshots work | +| Serial | written to `serial.log` | +| Pointer | virtio tablet, so `vm_click` can target exact pixels | +| Guest agent | virtio-serial channel wired on every launch | +| Networking | user-mode with virtio-net; `restrict=on` when `restrict_net` is set | + +The result of `launch_vm` reports which accelerator was used, so a VM that +quietly fell back to software emulation is visible rather than merely slow. + +## Port forwarding + +`port_forwards` accepts three spellings: + +| Form | Meaning | +| --- | --- | +| `"2222:22"` | Host port 2222 to guest port 22. The host port is bind-tested first, so a collision fails immediately with a clear message. | +| `"auto:22"` | A free host port is chosen. The launch result reports which one. | +| `"22"` | Shorthand for `auto:22`. | + +Duplicate host ports within one list are rejected rather than left for QEMU to +fail on later. + +## Firmware + +`firmware="bios"` is the default. `firmware="uefi"` uses the edk2 images +installed on the host: on x86_64 that is a read-only `OVMF_CODE` pflash plus a +writable per-VM copy of `OVMF_VARS`, so UEFI variables persist per VM without +touching the shared firmware. On aarch64 and riscv64 the firmware is supplied +with `-bios` instead. + +## Escape hatch + +`extra_args` appends raw flags to the QEMU command line. It exists for tuning +the tool surface does not cover, and it is for values you wrote yourself, never +for values derived from untrusted input. Flags that would breach the isolation a +VM is supposed to provide are rejected: host filesystem passthrough (`-fsdev`, +`-virtfs`), host block devices, spawning chardevs, `-runas`, and flags that would +collide with the sockets and pidfile mcqemu manages. diff --git a/docs-site/src/content/docs/reference/tools.mdx b/docs-site/src/content/docs/reference/tools.mdx new file mode 100644 index 0000000..1dda333 --- /dev/null +++ b/docs-site/src/content/docs/reference/tools.mdx @@ -0,0 +1,448 @@ +--- +title: Tool reference +description: "Every mcqemu tool, its parameters and defaults, grouped by what it does." +sidebar: + order: 1 +--- + +The full tool surface, generated from the running server so it matches the code +exactly. Descriptions are the text the calling model sees. Parameters marked +`required` have no default; everything else may be omitted. + +## Sandboxes + +One call to a working disposable VM, and one to remove every trace of it. + +### `sandbox_vm` + +Spin up a disposable sandbox VM from a base disk image, in one call: creates a copy-on- +write overlay (the base image is never modified), launches the VM, and waits for the +guest agent to come up so guest_exec / guest_file_* are immediately usable. By default a +free host port is forwarded to guest port 22 (pass port_forwards=[] to disable, or your +own list). If no `name` is given, sandbox / sandbox-2 / ... is chosen. Tear everything +down later with sandbox_destroy. Outbound networking is OFF by default: the guest cannot +reach the internet or any service on the host, which is what makes it a sandbox. Inbound +port forwards still work. Pass allow_network=True when the guest legitimately needs to +fetch packages. Note that the guest agent answers well before the guest finishes +booting, so this returns while services like networking are still starting. If a command +depends on one, wait for it inside the guest (e.g. poll 'systemctl is-active +NetworkManager') rather than assuming it is up. + +| Parameter | Type | Default | +| --- | --- | --- | +| `base_image` | string | required | +| `name` | string | `null` | +| `memory_mb` | integer | `2048` | +| `cpus` | integer | `2` | +| `port_forwards` | array | `null` | +| `allow_network` | boolean | `false` | +| `wait_agent_s` | integer | `90` | + +### `sandbox_destroy` + +Destroy a sandbox created with sandbox_vm: force-stop the VM, remove it from the +registry, and delete its overlay disk and logs. The base image is untouched. Refuses to +operate on VMs that were not created by sandbox_vm — use stop_vm / forget_vm for those +(they never delete disks). If the VM cannot be killed, nothing is deleted and the call +fails. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | + +## Lifecycle + +Starting, stopping and tracking virtual machines. + +### `launch_vm` + +Launch a new QEMU virtual machine and register it for management. Use `disks` for +existing image files (create them first with image_create); use `iso` to boot an +installer or live CD. With both, the VM boots the ISO once then the disk afterwards. +`port_forwards` maps host ports to guest ports (user-mode networking): "2222:22" is +explicit, "auto:22" (or just "22") picks a free host port — the result reports what was +chosen. KVM acceleration is used automatically when the guest arch matches the host. The +VM keeps running even if this MCP server restarts; stop it with stop_vm. +restrict_net=True drops all guest-initiated traffic (no internet, and no reaching +services on the host) while keeping port_forwards working inbound — use it when running +untrusted software. no_net=True removes the NIC entirely. extra_args passes raw flags to +qemu-system-* and is an operator escape hatch: only use values you wrote yourself, never +values derived from untrusted input. Flags that would breach VM isolation (host +filesystem passthrough, host block devices, spawning chardevs) are rejected. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `arch` | string | `"x86_64"` | +| `disks` | array | `null` | +| `iso` | string | `null` | +| `memory_mb` | integer | `2048` | +| `cpus` | integer | `2` | +| `machine` | string | `null` | +| `firmware` | string | `"bios"` | +| `port_forwards` | array | `null` | +| `no_net` | boolean | `false` | +| `restrict_net` | boolean | `false` | +| `extra_args` | array | `null` | + +### `stop_vm` + +Stop a VM. By default sends a graceful ACPI power-button press and waits for the guest +to shut down; if the guest ignores it (no OS booted, or OS without ACPI handling), the +call fails with advice to retry with force=True, which terminates QEMU immediately (like +pulling the power cord). + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `force` | boolean | `false` | +| `timeout` | integer | `30` | + +### `pause_vm` + +Pause (freeze) a running VM's virtual CPUs. The VM stays in memory; resume it with +resume_vm. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | + +### `resume_vm` + +Resume a VM previously frozen with pause_vm. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | + +### `attach_vm` + +Register an externally launched QEMU process so the other tools can manage it. Point +qmp_socket at its QMP unix socket (the QEMU process must have been started with e.g. +-qmp unix:/path,server=on,wait=off). Optionally provide qga_socket for guest-agent tools +and pid for liveness tracking. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `qmp_socket` | string | required | +| `qga_socket` | string | `null` | +| `pid` | integer | `null` | + +### `forget_vm` + +Remove a VM from the registry WITHOUT stopping it — the QEMU process is left untouched. +Refuses to forget a running VM this server spawned unless force=True (to avoid orphaning +processes by accident). + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `force` | boolean | `false` | + +## Inspection + +What exists and what state it is in. + +### `list_vms` + +List every registered VM with its live status (running, paused, shutdown, stopped, or +unreachable). Includes both VMs launched by this server and externally attached ones. +Returns \{"vms": [...], "registry_warnings": [...]\}. A non-empty registry_warnings +means bookkeeping was damaged and some VMs may be running but untracked — report it +rather than assuming the list is complete. + +### `vm_info` + +Detailed information about one VM: its launch configuration, log file paths, and — when +running — live QMP state (status, vCPUs, block devices). + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | + +## See and drive + +Watching a VM's display and injecting input, with no guest software required. + +### `vm_screenshot` + +Capture the VM's current display as a PNG image. Works on any running VM with a display +device (the default for launched x86 VMs) — no guest software needed. Use this to watch +installers, read console output, or verify GUI state before sending keys with +vm_send_keys / vm_type_text. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | + +### `vm_send_keys` + +Press keys or key combos in the VM. Each list entry is one press: a single key ("ret", +"esc", "f2", "a") or a chord pressed together ("ctrl-alt-f2", "ctrl-c"). Entries are +sent in order with delay_ms between them. Aliases like "enter", "space", "escape" work. +To type prose, use vm_type_text instead. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `keys` | array | required | +| `hold_ms` | integer | `100` | +| `delay_ms` | integer | `50` | + +### `vm_type_text` + +Type a string into the VM, character by character (US keyboard layout; printable ASCII +plus tab and newline). Set enter=True to press Enter at the end — handy for shell +commands at a console login or terminal. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `text` | string | required | +| `enter` | boolean | `false` | +| `delay_ms` | integer | `30` | + +### `vm_click` + +Click at pixel coordinates (x, y) on the VM display — coordinates match what +vm_screenshot shows. Requires the VM's tablet device for absolute positioning (present +on VMs launched by this server). button: left, right, or middle. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `x` | integer | required | +| `y` | integer | required | +| `button` | string | `"left"` | +| `double` | boolean | `false` | + +### `vm_mouse_move` + +Relative mouse control for guests WITHOUT absolute-pointer (tablet) drivers — most +pre-2010 OSes. Use this when vm_click has no visible effect. Motion goes to the emulated +PS/2 mouse in small steps (\<= `step` px per packet) because guests often desync or +apply acceleration on large deltas. Recommended pattern: pass home="bottom-right" (or +another corner) to pin the cursor to a known position first, then dx/dy toward the +target, then vm_screenshot to verify where the cursor actually landed (guest +acceleration may scale motion), correct with further small moves, and finally click. +`click` presses that button after moving; double=True double-clicks. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `dx` | integer | `0` | +| `dy` | integer | `0` | +| `home` | string | `null` | +| `click` | string | `null` | +| `double` | boolean | `false` | +| `step` | integer | `32` | + +### `vm_serial_read` + +Read the last lines of the VM's serial console log. Only useful when the guest writes to +its serial port (kernel console=ttyS0, or text-mode installers); GUI-only guests log +nothing here — use vm_screenshot for those. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `tail_lines` | integer | `50` | + +## Live snapshots + +Point-in-time state of a running VM, including RAM. Requires qcow2 disks. + +### `vm_snapshot_create` + +Save a live internal snapshot (RAM + device + disk state) of a running VM under `tag`. +Requires the VM's writable disks to be qcow2. The VM pauses briefly while state is +written. Restore later with vm_snapshot_restore. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `tag` | string | required | + +### `vm_snapshot_restore` + +Roll a running VM back to the internal snapshot `tag` (RAM, devices and disks all +revert). Anything that happened after the snapshot is lost. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `tag` | string | required | + +### `vm_snapshot_delete` + +Delete the internal snapshot `tag` from a running VM's disks. The VM keeps running; only +the saved snapshot is removed. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `tag` | string | required | + +### `vm_snapshot_list` + +List internal snapshots visible to a running VM. For stopped VMs use image_snapshot_list +on the disk file instead. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | + +## Disk images + +qemu-img operations on images that are not currently attached to a running VM. + +### `image_create` + +Create a new disk image. `size` uses qemu-img suffixes, e.g. "20G". qcow2 grows on +demand, so a large virtual size costs almost nothing up front. With `backing_file`, the +new image becomes a copy-on-write overlay — great for cheap disposable clones of a base +image. Refuses to replace an existing file unless overwrite=True. + +| Parameter | Type | Default | +| --- | --- | --- | +| `path` | string | required | +| `size` | string | required | +| `format` | string | `"qcow2"` | +| `backing_file` | string | `null` | +| `overwrite` | boolean | `false` | + +### `image_info` + +Inspect a disk image: format, virtual and on-disk size, internal snapshots, and (with +backing_chain=True) the full copy-on-write chain. + +| Parameter | Type | Default | +| --- | --- | --- | +| `path` | string | required | +| `backing_chain` | boolean | `false` | + +### `image_convert` + +Convert a disk image to another format (e.g. raw -> qcow2, vmdk -> qcow2). Conversion +flattens any backing chain into a standalone image. compress=True enables qcow2 +compression (smaller, slower). + +| Parameter | Type | Default | +| --- | --- | --- | +| `source` | string | required | +| `dest` | string | required | +| `format` | string | `"qcow2"` | +| `compress` | boolean | `false` | +| `overwrite` | boolean | `false` | + +### `image_resize` + +Resize a disk image's virtual size (e.g. size="30G", or "+10G" to grow relatively). +Growing is safe; shrinking DESTROYS data beyond the new size and requires shrink=True as +explicit confirmation (shrink the guest filesystem first!). + +| Parameter | Type | Default | +| --- | --- | --- | +| `path` | string | required | +| `size` | string | required | +| `shrink` | boolean | `false` | + +### `image_snapshot_create` + +Create an internal disk-only snapshot in an offline qcow2 image. For running VMs use +vm_snapshot_create instead (it also captures RAM). + +| Parameter | Type | Default | +| --- | --- | --- | +| `path` | string | required | +| `tag` | string | required | + +### `image_snapshot_apply` + +Revert an offline qcow2 image to internal snapshot `tag`. Data written after the +snapshot is lost. + +| Parameter | Type | Default | +| --- | --- | --- | +| `path` | string | required | +| `tag` | string | required | + +### `image_snapshot_delete` + +Delete internal snapshot `tag` from an offline qcow2 image. + +| Parameter | Type | Default | +| --- | --- | --- | +| `path` | string | required | +| `tag` | string | required | + +### `image_snapshot_list` + +List internal snapshots stored in a (not currently running) qcow2 image. + +| Parameter | Type | Default | +| --- | --- | --- | +| `path` | string | required | + +## Guest agent + +Reaching inside a guest. Needs qemu-guest-agent installed and running in the guest OS. + +### `guest_ping` + +Check whether the qemu-guest-agent inside the VM is alive and responding. A failure +means the guest OS doesn't have the agent installed or running — the other guest_* tools +won't work until it does. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | + +### `guest_info` + +Report the guest OS details (name, version, kernel) and the guest agent's version and +supported commands. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | + +### `guest_exec` + +Run a command inside the guest OS and return its stdout, stderr, and exit code. +`command` is the executable path or name; pass arguments separately in `args` (this is +exec, not a shell — for shell features use command="/bin/sh", args=["-c", "your | +pipeline"]). Requires qemu-guest-agent in the guest. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `command` | string | required | +| `args` | array | `null` | +| `stdin` | string | `null` | +| `timeout` | integer | `30` | + +### `guest_file_read` + +Read a text file from inside the guest (up to max_bytes, default 1 MiB, 8 MiB ceiling). +Requires qemu-guest-agent in the guest. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `path` | string | required | +| `max_bytes` | integer | `1048576` | + +### `guest_file_write` + +Write a text file inside the guest (mode 'w' truncates, append=True appends). Requires +qemu-guest-agent in the guest. + +| Parameter | Type | Default | +| --- | --- | --- | +| `name` | string | required | +| `path` | string | required | +| `content` | string | required | +| `append` | boolean | `false` | + diff --git a/docs-site/src/content/docs/tutorial/disposable-sandboxes.mdx b/docs-site/src/content/docs/tutorial/disposable-sandboxes.mdx new file mode 100644 index 0000000..c93e323 --- /dev/null +++ b/docs-site/src/content/docs/tutorial/disposable-sandboxes.mdx @@ -0,0 +1,206 @@ +--- +title: Disposable sandboxes +description: Turn a base image into throwaway VMs that boot in seconds, run something in one, then delete it without touching the base. +sidebar: + order: 2 +--- + +import { Steps, Aside } from '@astrojs/starlight/components'; + +In [Your first virtual machine](/tutorial/first-sandbox/) we spent twenty +minutes installing an operating system. In this tutorial we get a working VM +from that same image in a few seconds, run a command in it, deliberately make a +mess inside it, and throw it away. Then we do it again and watch the mess be +gone. + +This is the loop worth internalising: build a base image once, then treat +individual VMs as cheap and disposable. + +## What you need + +The base image from the first tutorial, `~/vms/alpine-agent.qcow2`, with +`qemu-guest-agent` installed inside it, and the VM built on it shut down. + + + +## Create a sandbox + + + +1. Ask the agent for a sandbox: + + ```json + sandbox_vm(base_image="~/vms/alpine-agent.qcow2") + ``` + + One call does four things: it creates a copy-on-write overlay disk, launches + a VM on that overlay, forwards a free host port to guest port 22, and waits + for the guest agent to answer before returning. + +2. Read the result closely, because it tells us what we got: + + ```json + { + "name": "sandbox", + "status": "running", + "sandbox": true, + "overlay": "~/.local/share/mcqemu/vms/sandbox/overlay.qcow2", + "base_image": "/home/you/vms/alpine-agent.qcow2", + "network": "outbound blocked", + "guest_agent": "responding", + "port_forwards": ["39221:22"] + } + ``` + + We did not pass a name, so it picked `sandbox` (the next one would be + `sandbox-2`). `guest_agent: responding` means `guest_exec` will work right + now, with no waiting and no login. + +3. Prove the guest is real: + + ```json + guest_exec(name="sandbox", command="uname", args=["-a"]) + ``` + + + + + +## The base image is never modified + +The sandbox writes to an overlay file, and the overlay refers back to the base +for anything it has not changed. Let us see it. + + + +1. Write a file inside the guest: + + ```json + guest_file_write(name="sandbox", path="/root/evidence.txt", + content="this sandbox was here\n") + ``` + +2. Read it back to confirm it landed: + + ```json + guest_file_read(name="sandbox", path="/root/evidence.txt") + ``` + +3. Now do some real damage, the kind you would never risk on a machine you + cared about: + + ```json + guest_exec(name="sandbox", command="/bin/sh", args=["-c", "rm -rf /etc/apk"]) + ``` + +4. Look at the relationship between the two disks: + + ```json + image_info(path="~/.local/share/mcqemu/vms/sandbox/overlay.qcow2", backing_chain=true) + ``` + + The chain shows the overlay on top and `~/vms/alpine-agent.qcow2` below it + as the backing file. Check the base image's modification time on the host: + it has not changed. Every write the guest made went into the overlay. + + + +## Outbound networking is off by default + +Try to reach the internet from inside the sandbox: + +```json +guest_exec(name="sandbox", command="/bin/sh", args=["-c", "apk update"]) +``` + +It fails. That is the point of the default: traffic the guest starts is +dropped, so it cannot reach the internet or any service listening on your host. +Inbound port forwards still work, which is why the forwarded SSH port is +useful even with networking restricted. + +When a sandbox legitimately needs to fetch packages, ask for it explicitly with +`allow_network=true`, and know that you have opened the door on purpose. + +## Throw it away + + + +1. Destroy the sandbox: + + ```json + sandbox_destroy(name="sandbox") + ``` + + The result confirms `overlay_deleted: true` and names the base image it left + alone. The VM is force-stopped, removed from the registry, and its overlay + and logs are deleted. + +2. Create a fresh one from the same base: + + ```json + sandbox_vm(base_image="~/vms/alpine-agent.qcow2") + ``` + +3. Look for the damage: + + ```json + guest_file_read(name="sandbox", path="/root/evidence.txt") + ``` + + The file does not exist, and `/etc/apk` is back. The new sandbox started + from the base image exactly as it was. + +4. Clean up: + + ```json + sandbox_destroy(name="sandbox") + ``` + + + + + +## What we learned + +A base image plus an overlay gives us VMs that cost seconds instead of minutes, +because the expensive part (installing an operating system) already happened +and is shared by every sandbox built on it. + +Overlays make throwing a VM away the normal outcome rather than a loss. +`sandbox_destroy` then `sandbox_vm` is a full reset back to a known state, and +the base image is safe from anything the guest does because the guest can only +write to the overlay. + +Restricting outbound traffic by default is what makes it a sandbox rather than +just a fast VM. Software running inside it cannot phone anywhere or poke at +services on your host unless you decide otherwise. + +## Next + +- [Snapshots](/how-to/snapshots/) if you want checkpoints inside a + longer-lived VM instead of a clean slate every time. +- [Install the guest agent](/how-to/install-the-guest-agent/) to turn another + operating system into a base image, including injecting the agent into a + downloaded cloud image offline. +- [Sandboxing](/explanation/sandboxing/) for what these isolation boundaries do + and do not promise. diff --git a/docs-site/src/content/docs/tutorial/first-sandbox.mdx b/docs-site/src/content/docs/tutorial/first-sandbox.mdx new file mode 100644 index 0000000..7bf620a --- /dev/null +++ b/docs-site/src/content/docs/tutorial/first-sandbox.mdx @@ -0,0 +1,283 @@ +--- +title: Your first virtual machine +description: Install mcqemu, create a disk, install an operating system from an ISO, and end with a VM your agent can run commands inside. +sidebar: + order: 1 +--- + +import { Steps, Aside } from '@astrojs/starlight/components'; + +In this tutorial we install mcqemu, create a blank disk, install Alpine Linux +onto it from an installer ISO while watching the screen, and finish with a VM +whose guest agent answers, so the agent can run commands inside it. + +We will drive everything through an LLM agent (the examples assume Claude Code). +Each step shows the tool call to ask for, and what the result should look like, +so you can tell at a glance whether the step worked. + +Expect this to take around twenty minutes, most of which is the operating +system installer doing its own thing. + +## What you need + +A Linux host with QEMU installed (`qemu-system-x86_64` and `qemu-img` on your +PATH), [uv](https://docs.astral.sh/uv/), and about 2 GB of free disk space. +Access to `/dev/kvm` makes everything much faster, but the tutorial works +without it under software emulation. + +Download the Alpine "virt" ISO before starting, from +[alpinelinux.org/downloads](https://alpinelinux.org/downloads/). It is around +60 MB. We will assume it is at `~/isos/alpine-virt.iso`, and we will keep our +VM disk at `~/vms/alpine-agent.qcow2`, so create those directories now: + +```bash +mkdir -p ~/isos ~/vms +``` + +## Install and connect the server + + + +1. Check that the server starts. `uvx` fetches mcqemu and runs it; the version + banner appears on stderr and then it waits for a client, which is what an + MCP server is supposed to do. + + ```bash + uvx mcqemu + ``` + + Press Ctrl-C to stop it. Seeing `mcqemu v...` is all we needed. + +2. Register it with Claude Code: + + ```bash + claude mcp add mcqemu -- uvx mcqemu + ``` + +3. Start Claude Code in any directory and run `/mcp`. The list should include + `mcqemu` as connected. If it does not, [Install and connect](/how-to/install-and-connect/) + covers the usual causes. + +4. Ask the agent to call `list_vms`. On a fresh install it answers with an + empty list: + + ```json + {"vms": [], "registry_warnings": []} + ``` + + An empty `registry_warnings` means the bookkeeping is healthy. We now have a + working server with nothing to manage yet. + + + +## Create a disk and start the installer + + + +1. Ask the agent to create the disk: + + ```json + image_create(path="~/vms/alpine-agent.qcow2", size="8G") + ``` + + The result reports `format: qcow2` and a virtual size of 8 GB. Check the + file on the host and you will see it occupies only a couple of hundred + kilobytes. qcow2 images grow as the guest writes, so a generous virtual size + costs almost nothing up front. + +2. Launch the VM with the installer ISO attached: + + ```json + launch_vm(name="alpine", disks=["~/vms/alpine-agent.qcow2"], + iso="~/isos/alpine-virt.iso", memory_mb=2048) + ``` + + The result tells us the VM is running, which acceleration it got (`kvm` or + `tcg`), the process ID, and paths to its logs. When a VM has both a disk and + an ISO, it boots the ISO this once and the disk on every later boot, so we + will not have to detach anything by hand. + +3. Give it fifteen seconds, then look at the screen: + + ```json + vm_screenshot(name="alpine") + ``` + + The agent gets a PNG of the guest display back. You should see Alpine's + boot messages or a `localhost login:` prompt. Nothing is installed inside + the guest to make this work; QEMU is handing over the framebuffer directly, + which is why screenshots work in bootloaders and BIOS menus too. + + If the screen still shows a bootloader, wait and take another screenshot. + Watching, rather than assuming, is the habit this whole workflow is built + on. + + + +## Install Alpine onto the disk + +The installer is an interactive console session, so we type into it the same +way a person at a keyboard would. + + + +1. Log in as root (Alpine's live image has no root password): + + ```json + vm_type_text(name="alpine", text="root", enter=true) + ``` + + Take a screenshot to confirm we landed at a shell prompt before continuing. + +2. Start the installer: + + ```json + vm_type_text(name="alpine", text="setup-alpine", enter=true) + ``` + +3. Work through the questions with `vm_type_text`, taking a screenshot after + each answer to see what is being asked next. Most answers can be the + default (press Enter with `vm_send_keys(name="alpine", keys=["ret"])`). + The three that matter: + + - Set a root password when asked, and remember it. We need it to log in later. + - When asked which disk to use, answer `vda`. Virtio disks appear under that + name inside the guest. + - When asked how to use it, answer `sys`. That installs to the disk rather + than running from RAM. + + Alpine asks for a final `y` to erase the disk. Nothing else on your machine + is at risk; the only disk the VM can see is the image file we created. + +4. When the installer reports it is done, shut the guest down cleanly: + + ```json + stop_vm(name="alpine") + ``` + + `stop_vm` presses the virtual power button and waits for the guest to + shut itself down, so filesystems get flushed properly. + + + + + +## Boot from disk + + + +1. Launch again, this time with no ISO, and forward a host port to the guest's + SSH port so we have a way in later: + + ```json + launch_vm(name="alpine", disks=["~/vms/alpine-agent.qcow2"], + port_forwards=["auto:22"]) + ``` + + `auto:22` means "pick any free host port"; the result reports which one it + chose, for example `port_forwards: ["43617:22"]`. You can also ask for a + specific one with `"2222:22"`, and mcqemu checks up front that the port is + free instead of letting QEMU fail obscurely. + +2. Screenshot after a few seconds. This time the login prompt comes from the + installed system on the disk, not from the ISO. + +3. Log in with `root` and the password you set: + + ```json + vm_type_text(name="alpine", text="root", enter=true) + vm_type_text(name="alpine", text="", enter=true) + ``` + + + +## Install the guest agent + +Everything so far worked through the screen and keyboard, with no cooperation +from the guest operating system. The `guest_*` tools are different: they need +`qemu-guest-agent` running inside the guest. mcqemu wires up the host side of +that channel on every launch, so installing the package is the only step left. + + + +1. Confirm the agent is genuinely missing, so the difference is visible: + + ```json + guest_ping(name="alpine") + ``` + + This fails, and the error says the guest does not have the agent installed + or running. + +2. Type these three commands into the guest console, one at a time, with + `vm_type_text(..., enter=true)`: + + ```text + apk add qemu-guest-agent + rc-update add qemu-guest-agent default + rc-service qemu-guest-agent start + ``` + + The VM has outbound network access (we did not restrict it), so `apk` can + reach Alpine's mirrors. Take a screenshot after the first command to check + the download succeeded; if `apk` cannot find the package, run + `setup-apkrepos -c -1` to enable the community repository and try again. + +3. Ask again: + + ```json + guest_ping(name="alpine") + ``` + + Now it answers `guest_agent: responding`. + +4. Run a command inside the guest without touching the keyboard: + + ```json + guest_exec(name="alpine", command="uname", args=["-a"]) + ``` + + The result carries the guest's stdout, stderr, and exit code. Note that + `command` is an executable and `args` are its arguments; this is exec, not a + shell. For pipelines, use `command="/bin/sh"` with + `args=["-c", "your | pipeline"]`. + +5. Shut the VM down. We want the image quiescent for the next tutorial. + + ```json + stop_vm(name="alpine") + ``` + + + +## What we built + +`~/vms/alpine-agent.qcow2` is now an installed Alpine system with a working +guest agent. Along the way we saw the two ways to interact with a VM: + +- Through the display and keyboard (`vm_screenshot`, `vm_type_text`, + `vm_send_keys`), which works on any guest at any stage of boot, including + installers and bootloaders, because it operates below the operating system. +- Through the guest agent (`guest_ping`, `guest_exec`), which is faster and + gives you structured output, but only after you have put the agent inside + the guest. + +We also saw that disks and VMs are separate things: `image_create` makes a +disk, `launch_vm` runs a VM around it, and the same disk can be booted again +later with different settings. + +## Next + +Keep that image. In [Disposable sandboxes](/tutorial/disposable-sandboxes/) we +use it as a base to spin up throwaway clones in seconds, run untrusted things +in them, and delete them without the base ever changing. + +If you want to install something bigger than Alpine next, read +[Drive an installer](/how-to/drive-an-installer/) for the screenshot and input +loop in detail, including mouse control for graphical installers. diff --git a/docs-site/src/styles/theme.css b/docs-site/src/styles/theme.css new file mode 100644 index 0000000..33069b1 --- /dev/null +++ b/docs-site/src/styles/theme.css @@ -0,0 +1,74 @@ +/* mcqemu — a restrained terminal palette. + * + * The accent is the amber of a monitor that has been on too long, against + * slate greys. Starlight derives most of its surface colours from these + * tokens, so overriding the scale is enough for both themes. + */ + +:root { + --sl-color-accent-low: #3a2a08; + --sl-color-accent: #b5820f; + --sl-color-accent-high: #f0c674; + + --sl-color-white: #f4f4f2; + --sl-color-gray-1: #e3e4e1; + --sl-color-gray-2: #c0c2be; + --sl-color-gray-3: #8b8e88; + --sl-color-gray-4: #55584f; + --sl-color-gray-5: #34362f; + --sl-color-gray-6: #24261f; + --sl-color-black: #16170f; + + --sl-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --sl-font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, monospace; +} + +:root[data-theme="light"] { + --sl-color-accent-low: #f5e6c0; + --sl-color-accent: #8a610a; + --sl-color-accent-high: #4a3405; + + --sl-color-white: #1b1c16; + --sl-color-gray-1: #24261f; + --sl-color-gray-2: #3f423a; + --sl-color-gray-3: #6b6e66; + --sl-color-gray-4: #9a9d94; + --sl-color-gray-5: #d3d5cf; + --sl-color-gray-6: #eceee8; + --sl-color-gray-7: #f6f7f3; + --sl-color-black: #ffffff; +} + +/* Starlight's staggered CardGrid rotates card icon backgrounds through a + * built-in palette that includes purple and magenta. Hold the whole grid on + * the accent so the page reads as one thing. */ +.card .icon { + background-color: var(--sl-color-accent-low) !important; + color: var(--sl-color-accent-high) !important; +} + +/* Tool names appear constantly in the prose; give inline code enough + * contrast to scan without turning every paragraph into a rash. */ +:not(pre) > code { + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.25rem; + padding: 0.1rem 0.35rem; + font-size: 0.9em; +} + +/* Reference tables are dense and mostly two or three narrow columns. */ +.sl-markdown-content table { + display: table; + width: 100%; + font-size: 0.95em; +} + +.sl-markdown-content th { + text-align: left; + font-weight: 600; +} + +/* The splash hero reads better slightly narrower than the default. */ +.hero > .stack { + max-width: 46rem; +} diff --git a/docs-site/tsconfig.json b/docs-site/tsconfig.json new file mode 100644 index 0000000..8bf91d3 --- /dev/null +++ b/docs-site/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/pyproject.toml b/pyproject.toml index 887f854..dda074a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,8 @@ dependencies = [ ] [project.urls] -Repository = "https://git.supported.systems/MCP/mcqemu" +Documentation = "https://mcqemu.warehack.ing" +Repository = "https://git.supported.systems/warehack.ing/mcqemu" [project.scripts] mcqemu = "mcqemu.server:main"