Skip to content

SynVirt Guest Agent Protocol

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/GUEST_AGENT_PROTOCOL.md. Edit at the source, not here.

Version: v1 (Phase 1 of the Guest Tools Suite)

This document specifies the wire protocol used by the SynVirt host-side guest-tools module (crates/synvirt-guest-tools) and the in-guest agent (crates/synvirt-guest-agent, Phase 2). It is the single source of truth: both ends read this spec rather than each other.

  • Bus: libvirt virtio-serial <channel>.
  • Channel name: com.synnet.synvirt.0.
  • Host side: Unix socket at /var/run/synvirt/agent/{uuid}.sock, where {uuid} is the VM’s libvirt UUID. The domain XML uses <source mode='bind' path='…'/>, which makes QEMU bind and own the socket: it appears when the domain starts, disappears when it stops, and its owner is whatever qemu.conf configures (typically libvirt-qemu:kvm). The daemon never creates this file — it UnixStream::connects to it on a retry loop.
  • Directory owner: /var/run/synvirt/agent/ is staged by systemd (RuntimeDirectory=synvirt/agent on the daemon unit) so it exists with the daemon user and group before QEMU starts. The sockets inside are owned by QEMU.
  • Guest side: character device /dev/virtio-ports/com.synnet.synvirt.0 on Linux/BSD, named pipe \\.\Global\com.synnet.synvirt.0 on Windows.
  • Direction: bi-directional, full-duplex.
  • Framing: JSON Lines — one JSON object per line, LF-terminated (\n). No length prefix. No CRLF.
  • Encoding: UTF-8, no BOM.
  • Message size: soft cap 64 KiB per line. Either side MAY reject a longer line with E_PROTO_MALFORMED and close the connection.

The channel is inherently trustworthy at the kernel/libvirt layer — it cannot be reached from outside the host-guest boundary — but both sides still parse JSON strictly and reject unknown fields where required below.

Every message is a JSON object with this shape:

{
"v": 1,
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "hello",
"ts": "2026-04-23T17:05:00Z",
"payload": { ... }
}
Field Type Required Notes
v integer yes Protocol version. Always 1 in this document.
id string yes UUID v4. For ack/error responses this is a NEW id; the original request id is carried in the payload.
type string yes One of hello, heartbeat, report, command, ack, error.
ts string yes ISO-8601 / RFC-3339 UTC timestamp with Z suffix.
payload object yes Type-specific body. Always an object, never null.

Unknown top-level fields MUST be rejected with E_PROTO_MALFORMED.

Version handling: a receiver that does not understand v responds with error carrying E_PROTO_VERSION_UNSUPPORTED and closes the connection.

The first message on a fresh connection. Carries the agent’s identity so the host can update VmToolsState. The host will NOT process any other message until it has received a well-formed hello.

Payload:

Field Type Required Notes
agent_version string yes Semver of the guest agent build.
os_family string yes One of windows, linux, bsd.
os_name string yes Human-readable, e.g. "Debian GNU/Linux 13" or "Windows 11 Pro".
os_version string yes Build/release identifier, e.g. "13.0" or "10.0.22631".
arch string yes x86_64, i686, aarch64, etc.
hostname string yes Guest’s reported hostname.

Example:

{
"v": 1,
"id": "c8b1a8f0-6d9e-4e82-9f31-1b2e8f3d7a55",
"type": "hello",
"ts": "2026-04-23T17:05:00Z",
"payload": {
"agent_version": "1.0.0",
"os_family": "linux",
"os_name": "Debian GNU/Linux 13",
"os_version": "13.0",
"arch": "x86_64",
"hostname": "winxp-01"
}
}

Liveness ping, sent every 30 seconds. The host marks the agent offline after 90 seconds without any message (heartbeat, report, or ack).

Payload: empty object {}.

Example:

{
"v": 1,
"id": "f51c9a11-2f0d-4c8c-82cd-0c0fe5f7b6e1",
"type": "heartbeat",
"ts": "2026-04-23T17:05:30Z",
"payload": {}
}

Unsolicited event. The host persists relevant fields to sled and/or broadcasts a daemon event.

Payload:

Field Type Required Notes
event string yes tools_installed, tools_uninstalled, shutdown_initiated, reboot_initiated, clipboard_changed, display_resized.
details object no Event-specific context. clipboard_changed carries {"text": string}; display_resized carries {"width": u32, "height": u32}.

Example:

{
"v": 1,
"id": "90f7d3e0-7d2e-4c91-a3de-2c8a0f3e7b14",
"type": "report",
"ts": "2026-04-23T17:10:00Z",
"payload": {
"event": "tools_installed",
"details": { "version": "1.0.0" }
}
}

Request for the agent to perform an action. The agent MUST respond with either an ack or an error, carrying request_id = command.id.

Payload:

Field Type Required Notes
action string yes See catalogue below.
args object no Action-specific arguments.

Default command timeout: 30 seconds. Host-side callers MAY override per call.

Action Args Ack result shape Notes
shutdown { "delay_seconds": 0 } {} Graceful shutdown via OS API.
reboot { "delay_seconds": 0 } {} Graceful reboot via OS API.
get_info {} { "os_name": string, "os_version": string, "hostname": string, "uptime_seconds": u64, "ips": [string] } Fresh snapshot. Not cached.
set_hostname { "hostname": string } { "hostname": string } (echoes resolved) May require guest restart.
freeze_fs {} { "frozen": bool } For snapshot quiesce.
thaw_fs {} { "thawed": bool } Pair with freeze_fs.
clipboard_get { "format": "text"|"html" } { "format": string, "data": string } Linux/BSD via wl-paste/xclip; Windows via OpenClipboard+GetClipboardData.
clipboard_set { "format": "text"|"html", "data": string } {} Linux/BSD via wl-copy/xclip; Windows via OpenClipboard+SetClipboardData. Agent MAY refuse by policy.
resize_display { "width": u32, "height": u32 } { "width": u32, "height": u32 } Linux/BSD via xrandr; Windows via ChangeDisplaySettingsExW. Echoes the resolution actually applied.

Agents that do not implement an action respond with E_GUEST_COMMAND_UNSUPPORTED.

Example (get_info):

{
"v": 1,
"id": "aa5a1f8d-1c2e-4567-a3be-4c7d0a9a1f23",
"type": "command",
"ts": "2026-04-23T17:15:00Z",
"payload": { "action": "get_info", "args": {} }
}

Positive response to a command.

Payload:

Field Type Required Notes
request_id string yes The id of the command this acks.
result object yes Action-specific result (may be {}).

Example (response to the get_info above):

{
"v": 1,
"id": "d62a7c0c-5f33-4c8a-97d1-8f8b0c6f1f9b",
"type": "ack",
"ts": "2026-04-23T17:15:00Z",
"payload": {
"request_id": "aa5a1f8d-1c2e-4567-a3be-4c7d0a9a1f23",
"result": {
"os_name": "Windows 11 Pro",
"os_version": "10.0.22631",
"hostname": "winxp-01",
"uptime_seconds": 18523,
"ips": ["172.16.11.102"]
}
}
}

Negative response (to a command) or unilateral protocol error.

Payload:

Field Type Required Notes
request_id string or null no Present when the error is a response to a command.
code string yes One of the codes in §4.
message string yes Human-readable detail, English.

Example:

{
"v": 1,
"id": "7bde4e55-b4e9-47dc-a5ce-bd0f60c14dcb",
"type": "error",
"ts": "2026-04-23T17:15:01Z",
"payload": {
"request_id": "aa5a1f8d-1c2e-4567-a3be-4c7d0a9a1f23",
"code": "E_GUEST_COMMAND_UNSUPPORTED",
"message": "action get_info is not implemented by this agent"
}
}
Code Meaning
E_PROTO_VERSION_UNSUPPORTED v is missing or not understood.
E_PROTO_MALFORMED JSON parse failure, unknown type, missing required field, oversize line.
E_PROTO_TIMEOUT Command exceeded its timeout before an ack or error arrived.
E_GUEST_COMMAND_UNSUPPORTED Action name is not in this agent’s catalogue.
E_GUEST_COMMAND_FAILED Action understood but failed to complete (OS API error, etc.).

Additional codes MAY be added in future protocol revisions; receivers MUST treat unknown E_* values as fatal for the current command and non-fatal for the connection.

  1. libvirt’s virtio-serial channel comes up when QEMU opens the host socket (VM start).
  2. Host’s AgentListener accepts the connection and starts a 10 s timer.
  3. Guest writes a hello. Host validates, updates VmToolsState.{agent_connected, agent_version, os_*, last_hello}, and broadcasts AgentConnected.
  4. Host cancels the 10 s timer and arms a 90 s idle timeout.

If the 10 s timer elapses without a valid hello, the host closes the socket and discards the session.

  • Guest sends heartbeat every 30 s.
  • Guest sends report for noteworthy events.
  • Host sends command on demand; guest replies with ack or error.
  • Every received message (any type) resets the 90 s idle timer.
  • Guest shutdown / reboot typically closes the channel cleanly.
  • Idle timeout (90 s) triggers host-side close, VmToolsState.agent_connected := false, AgentDisconnected broadcast.
  • Any parse error or oversize line also closes the socket after emitting an error with the appropriate code.
  • The virtio-serial channel is local to the hypervisor and flows through a libvirt-mediated Unix socket. Network attackers cannot reach it.
  • Guests are nevertheless treated as UNTRUSTED: the host strictly parses every field, enforces size limits, and never executes guest-supplied code or paths.
  • clipboard_set and set_hostname payloads are echoed back to the operator via the dashboard. The daemon escapes all guest-sourced strings before rendering.
  • Timing: every command has a bounded timeout (E_PROTO_TIMEOUT). Every connection has a bounded idle window. A hung or malicious guest cannot pin host resources indefinitely.
  • v1 (this document): the baseline shipped in SynVirt 0.8.2.
  • Minor additions (new optional fields, new report events, new command actions) do NOT bump v. Consumers ignore unknown optional fields except where the spec explicitly marks “reject unknown fields”.
  • Incompatible changes (field type change, removed required field, breaking rename) bump v to 2+. Both agent and host must support the new version; otherwise they fall back to the highest mutually supported v (Phase 2 material).

When v2 is drafted: the guest’s hello carries an optional "supported": [1, 2] field that v1 hosts ignore but a v2 host acknowledges with the chosen v. A v2 agent that never sees such a reply infers “v1 only” after a grace window and downgrades. Both sides then keep the agreed version for the rest of the connection; it is never renegotiated per message. Mixed-version fleets keep working without a flag day.

States: Disconnected → Connecting → Connected → Disconnected. The supervisor dials when QEMU binds the per-VM socket on VM start and arms a 10 s hello deadline; once a valid hello arrives the connection moves to Connected, exchanging command/ack and heartbeat/report until any of EOF, parse error, oversize line, idle > 90 s, an explicit terminating error, or supervisor shutdown returns it to Disconnected. The connector then loops back to the retry-connect loop without manual intervention. The deeper state diagram (with branches) lives in docs/modules/guest-tools/ARCHITECTURE.md.

Timers, at a glance:

Timer Duration Scope
Hello deadline 10 s From TCP/Unix connect to first hello.
Heartbeat cadence 30 s Guest MUST emit one every 30 s.
Idle deadline (host) 90 s Any inbound message resets it.
Command timeout 30 s Host MAY override per call; guest MUST enforce an absolute cap the spec does not pin.

Sections 1-8 describe the JSON-Lines control plane on virtio-serial. Bulk telemetry rides a separate virtio-vsock stream so a chatty sample loop can never queue behind a clipboard, shutdown, or reboot control message.

  • Bus: virtio-vsock.
  • Host endpoint: VMADDR_CID_HOST (kernel-defined 2), port VSOCK_TELEMETRY_PORT = 7777.
  • Guest CID: kernel-allocated. The daemon emits <vsock model='virtio'><cid auto='yes'/></vsock> in the libvirt domain XML and reads back the resolved address from virsh dumpxml after the domain starts.
  • Cadence: one [TelemetrySample] per TELEMETRY_INTERVAL_SECS = 5 seconds.
  • Framing: JSON-Lines — one TelemetrySample per \n-terminated line, UTF-8, no envelope, deny_unknown_fields.
{
"timestamp_unix_ms": 1714693512345,
"cpu_percent": 12.5,
"mem_used_bytes": 268435456,
"mem_total_bytes": 1073741824,
"disk_io": { "read_bytes_per_sec": 1234, "write_bytes_per_sec": 5678 },
"net_io": { "rx_bytes_per_sec": 90, "tx_bytes_per_sec": 80 }
}
Field Type Required Notes
timestamp_unix_ms u64 yes Wall-clock at sample time, Unix epoch milliseconds. The host stamps its own received_at for UI freshness so guest-clock drift is harmless.
cpu_percent f32 yes Aggregate CPU utilisation across every vCPU, range [0.0, 100.0]. NaN reserved for “could not measure”.
mem_used_bytes u64 yes Resident memory in use by the guest OS.
mem_total_bytes u64 yes Total memory the guest sees (matches <memory> in the libvirt domain XML modulo balloon adjustments).
disk_io { read_bytes_per_sec: u64, write_bytes_per_sec: u64 } no Aggregate block-device throughput, averaged over the last TELEMETRY_INTERVAL_SECS window.
net_io { rx_bytes_per_sec: u64, tx_bytes_per_sec: u64 } no Aggregate non-loopback throughput over the same window.

Older agents that ship without IO sampling omit both keys entirely. The schema still validates with deny_unknown_fields, and the host listener treats None as “not measured” — the dashboard renders an em-dash on the IO cards rather than a misleading zero. Newer agents that do sample IO emit both blocks and the chart fills in.

Everything in this section is additive with respect to the v1 control plane:

  • TelemetrySample lives on a separate transport (vsock) and never touches the JSON-Lines envelope, so a v1 host that has not been rebuilt with the side-channel listener simply never opens that port — the agent’s connect attempts fail and it backs off, which is harmless.
  • ResizeDisplay (CommandAction) and ClipboardChanged / DisplayResized (ReportEvent) are new variants of existing enums. Per §7, “minor additions … do NOT bump v.” A host that predates these variants responds to an unknown command with E_GUEST_COMMAND_UNSUPPORTED and forwards an unknown report through AgentEvent::Unknown without crashing.
  • disk_io / net_io on TelemetrySample are Option, so an agent that does not measure IO still produces a payload that validates against the strictly-checked host schema.

Existing agents on protocol v1 keep working unchanged.

9.2 Telemetry extensions (v0.16.0): top-K processes + inventory

Section titled “9.2 Telemetry extensions (v0.16.0): top-K processes + inventory”

The base TelemetrySample from §9.1 stays the source of truth. v0.16.0 adds two optional sub-blocks that ride inside the same envelope on the same vsock stream — no new transport, no new port, no new envelope.

{
"timestamp_unix_ms": 1714693512345,
"cpu_percent": 12.5,
"mem_used_bytes": 268435456,
"mem_total_bytes": 1073741824,
"disk_io": { "read_bytes_per_sec": 1234, "write_bytes_per_sec": 5678 },
"net_io": { "rx_bytes_per_sec": 90, "tx_bytes_per_sec": 80 },
"top_procs": {
"by_cpu": [ { "pid": 1234, "name": "nginx", "user": "www-data", "cpu_percent": 12.25, "rss_bytes": 33554432, "read_bytes_per_sec": 0, "write_bytes_per_sec": 0, "rx_bytes_per_sec": 0, "tx_bytes_per_sec": 0 } ],
"by_mem": [],
"by_disk": [],
"by_net": []
},
"inventory": {
"hostname": "vm-test",
"distro": "Debian GNU/Linux 13",
"kernel": "6.1.0-21-amd64",
"arch": "x86_64",
"agent_version": "0.16.0",
"timezone": "America/Mazatlan",
"boot_time_unix_s": 1700000000,
"nics": [ { "name": "eth0", "mac": "52:54:00:11:22:33", "ips_v4": ["10.10.26.65"], "ips_v6": ["fe80::1"], "mtu": 1500 } ]
}
}
Block Cadence Constant
Base sample 5 s TELEMETRY_INTERVAL_SECS
top_procs 30 s TELEMETRY_TOPK_INTERVAL_SECS
inventory 300 s TELEMETRY_INVENTORY_INTERVAL_SECS

The agent emits one base sample every 5 s on the same vsock stream as v0.15. Every sixth sample additionally fills top_procs; every sixtieth additionally fills inventory. The agent MAY also emit an inventory block off-cadence on a detected change (hostname, IP list, kernel version), so consumers MUST treat the field as “latest-wins” and not assume a fixed period.

top_procs.by_* is capped at TELEMETRY_TOPK_LIMIT = 20 entries per ranking. Bumped from 10 in v0.16 after the Telemetry tab landed and operators asked for more long-tail visibility per axis. The wire is backwards compatible — a v0.15 host that decoded the first 10 still parses the new payload (no schema bump), it just sees more entries.

Each new block carries #[serde(deny_unknown_fields)] exactly like the base sample. The envelope is unchanged — both blocks live as optional keys directly on TelemetrySample, omitted via skip_serializing_if when the agent has nothing to report on that tick.

Field-by-field documentation lives on the Rust types in crates/synvirt-guest-proto/src/lib.rs: TopProcsBlock, ProcEntry, InventoryBlock, NicInventory.

Forward (new agent, old host): the agent emits top_procs / inventory keys only when it has data. A v0.15 host that consumed TelemetrySample with deny_unknown_fields would have rejected the extra keys; the v0.16 host adds them as Option fields and accepts both shapes. Operators rolling out v0.16 SHOULD upgrade hosts before agents to avoid that asymmetry; the agent’s vsock backoff makes the window harmless either way.

Backward (old agent, new host): a v0.15 agent never emits the new keys. The v0.16 host’s serde(default) defaults both fields to None, so the legacy line parses unchanged and the dashboard simply shows blank top-K and inventory panels for that VM.

Top-K via the existing CommandAction::TopProcessesByMemory request on the virtio-serial control plane (§3.4.1) stays available for operators who want a one-shot snapshot off-cadence.