SynVirt Console Architecture
Synced read-only from
/home/synnet/mirrors/synvirt-product/docs/console-architecture.md. Edit at the source, not here.
SynVirt Console Architecture
Section titled “SynVirt Console Architecture”Document version: 0.9.0-alpha.2 · Module: console-gateway
1. Purpose
Section titled “1. Purpose”SynVirt’s browser-based guest console streams a VM’s framebuffer, keyboard, and mouse from the hypervisor to the operator’s browser without installing any client software and without exposing VNC on the network.
The gateway is the narrow seam that joins three layers: a
libvirt-managed QEMU process that listens for VNC on a host-local
TCP socket, the daemon’s HTTPS surface, and the vendored noVNC
client running in the browser.
SPICE was removed in 0.9.0-alpha.2. There is no “SPICE fallback”,
no spice-html5, no per-VM SPICE Unix socket, no spicevmc channel,
and no QXL video device. VNC is the single console path.
2. Three-Layer Diagram
Section titled “2. Three-Layer Diagram” Browser Daemon host QEMU process ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────┐ │ │ │ │ │ │ │ noVNC client │ │ synvirt-daemon │ │ qemu-kvm │ │ (vendored, ES modules) │ │ ↓ mount │ │ (per-VM) │ │ │ │ console-gateway │ │ │ │ WebSocket ──TLS──► │ │ ├─ router ─► axum │ │ │ │ /console/vnc/{uuid} │ │ │ POST /api/console│ │ │ │ ?ticket=… │ │ │ GET /console/ │ │ │ │ │ │ │ vnc/ws │ │ │ │ │ │ ├─ ticket │ │ │ │ │ │ │ HS256 + nonce │ │ │ │ │ │ │ 30s TTL │ │ │ │ │ │ └─ bridge ────────────┼──► 127.0.0.1: │ │ │ │ WebSocket ↔ TCP │ │ 5900+N │ │ │ │ │ │ (per VM) │ └──────────────────────────┘ └──────────────────────────┘ └──────────────┘The VNC listener is bound by libvirt to 127.0.0.1 on a per-VM port
selected at start time. The bridge resolves that port via libvirt’s
vncdisplay query — the daemon never fixes a port at the renderer
layer. No TCP exposure to other hosts. The HTTP gateway is the one
and only path from the browser to the guest display.
3. Request flow
Section titled “3. Request flow” ┌──────────┐ 1. POST /api/console/{vm_uuid} ┌──────────────┐ │ Browser │─────────────────────────────────────────►│ Daemon │ │(operator)│ Authorization: Bearer <session> │ console- │ └──────────┘ │ gateway │ ▲ 2. 200 { ticket, expires_in: 30 } └──────┬───────┘ └───────────────────────────────────────────────────┘ │ │ 3. WS upgrade /console/vnc/{uuid}?ticket=… │ ┌──────────┐─────────────────────────────────────────►┌─────▼───────┐ │ Browser │ │ bridge │ │ noVNC │◄────────── 4. RFB binary frames ────────┤ │ └──────────┘ └─────┬────────┘ │ 5. TcpStream connect dial 127.0.0.1:5900+N where N = libvirt vncdisplay ▼ ┌───────────────────────┐ │ qemu-kvm VNC listener │ └───────────────────────┘- Browser already holds a daemon session cookie / PAT. It asks the daemon to mint a console ticket for the VM it wants to view.
router::issue_ticketchecks that the session may view that VM, then hands back an HS256 JWT with a 30-second TTL and a single-use UUID nonce. The ticket is opaque to the browser.- Browser opens a WebSocket to
/console/vnc/{uuid}?ticket=…. TLS terminates at the daemon. router::upgrade_wsvalidates the ticket (signature, nonce not yet consumed, exp not reached, vm_uuid scope matches the path or the querystring) and consumes the nonce atomically. Any replay returns HTTP 401ticket_invalid.bridge::pumpresolves the per-VM VNC display via libvirt (virsh vncdisplay <name>or the equivalent API call), dials127.0.0.1:5900+N, and pumps frames half-duplex in each direction until either side EOFs.
4. Security model
Section titled “4. Security model”| Concern | Mitigation |
|---|---|
| VNC over network | Not exposed. libvirt binds the listener to 127.0.0.1 only. |
| Ticket replay | UUID v4 nonce consumed atomically in a dashmap; a second validation of the same token returns ticket_invalid. |
| Ticket theft via logs | Tickets are never logged; only the nonce is traced. |
| Ticket leak via Referer | Ticket is a POST body parameter for the issue call; the WS upgrade passes the ticket in a URL query but the WS URL is never emitted as a Referer by any browser. |
| Long-lived theft | 30-second TTL. A stolen ticket is useful only in the window between issuance and the victim connecting. |
| Key compromise | HMAC key rotates every 24h. The previous key is accepted during a 30-minute grace window to cover already-issued tickets still within their 30-second TTL. |
| Cross-VM access | JWT claims include vm_uuid; bridge refuses any TCP target that doesn’t match the libvirt-resolved VNC port for that UUID. |
| Daemon-to-QEMU trust | VNC listener is bound to 127.0.0.1; only processes on the host can reach it, and the daemon is the only such process the operator authorizes. |
5. Ticket lifecycle
Section titled “5. Ticket lifecycle” ┌────────────┐ issue() ┌──────────────┐ consume() ┌────────────┐ │ pending │───────────────────►│ issued │──────────────►│ consumed │ └────────────┘ └──────┬───────┘ └────────────┘ │ │ exp elapsed (30s) ▼ ┌──────────────┐ │ expired │ └──────────────┘issue()generates a UUID v4 nonce, inserts it into the dashmap with its exp, and returns a signed JWT whose claims carrysub = vm_uuid,jti = nonce,exp = now + 30s,iss = synvirt,aud = console.validate_and_consume()verifies the HS256 signature against the active key (and falls back to the previous key during the grace window), checksexp, then atomically removes the nonce from the dashmap. IfremovereturnsNone, the nonce was already consumed or never existed —ticket_invalid.- Expiry sweep — a tokio task wakes every 60 seconds and purges
any nonces whose stored
expis in the past. - Key rotation — every 24 hours the HMAC key is rotated.
kid=1andkid=2are held in a two-slot ring. New tickets sign with the active slot; validation tries the active slot first, then the previous slot if the previous slot is still within the 30-minute grace window.
6. Crate layout
Section titled “6. Crate layout”crates/console-gateway/├── Cargo.toml└── src/ ├── lib.rs — public surface: mount(), ConsoleState, ConsoleConfig ├── error.rs — ConsoleError + IntoResponse ├── bridge.rs — WebSocket ↔ TcpStream pump (VNC RFB) └── router.rs — axum routes (POST /api/console/{uuid} + GET /console/vnc/{uuid})
crates/console-ticket/├── Cargo.toml└── src/ └── lib.rs — TicketStore, issue(), validate_and_consume()
crates/guest-tools-iso/├── Cargo.toml├── src/main.rs — builder binary└── payload/ ├── windows/ — virtio-win drivers + SynVirt guest agent └── linux/ — qemu-guest-agent + SynVirt guest agent packages7. Why VNC, not WebRTC
Section titled “7. Why VNC, not WebRTC”VNC + noVNC is what landed in 0.9.0-alpha.1 because:
- noVNC is the de-facto browser VNC client. Maintained, stable, small bundle, BSD-2-Clause.
- libvirt + QEMU expose VNC out of the box; no extra in-guest agent required.
- TigerVNC inside a Linux GUI guest, or QEMU’s native
-vncfor any guest, both speak RFB. The bridge is protocol-agnostic — it’s a half-duplex pump.
WebRTC is reserved for post-1.0 evaluation as a possible noVNC successor. It is not a SPICE-style coexistence path; if WebRTC ships, it replaces VNC, not adds-alongside-it.
8. Out of scope
Section titled “8. Out of scope”- Multi-protocol coexistence (SPICE / RDP / WebRTC) — explicit non-goal in 0.9.0-alpha.2 and beyond. WebRTC is a successor candidate, not an additional path.
- Session recording. Audit logs name the operator and the VM and the start/stop times, nothing more.
- Cross-host migration of an open console session. The session is dropped and re-established against the new host.
- USB redirection over the console transport. Operators can attach
USB devices directly to a VM via the
/api/v1/vms/:name/usbpolicy layer; that path is unchanged by the SPICE removal.