Kerbside Proxy Architecture¶
This document provides a detailed technical description of Kerbside's proxy architecture: the process model, the connection state machine, traffic handling, and the SPICE firewall.
The SPICE proxy is the Rust kerbside-proxy binary (rust/kerbside-proxy/).
The pure-Python kerbside package provides the REST API, the console-source
drivers, the data model, and the daemon that supervises the proxy; the proxy
itself terminates TLS, drives the SPICE handshake, and relays traffic,
consulting the Python side over a gRPC control socket for authorization and
bookkeeping. It reuses the ryll shakenfist-spice-protocol crate for the
SPICE wire format.
Why the proxy has this shape¶
- Rust, and policy left in Python. The proxy parses bytes from the internet and passes them to qemu's spice-server, which its upstream says should not face the internet and whose demarshalling of client messages has a history of memory-safety CVEs. The Python proxy this replaced relayed opaquely once a client had authenticated, and ran a process per channel. Rust brings memory safety to the component that parses untrusted input, and a task rather than a process per connection; the Python side keeps the database, the API and every policy decision ("Python decides policy; Rust enforces it").
- gRPC over a unix socket for the control plane. It brings message
framing, request/response correlation, deadlines and server push (the
ProxyControlstream). Shaken Fist's privexec/nodelock pattern, bare protobuf over a unix socket with no message framing, was rejected because a Rust peer cannot speak it reliably; Shaken Fist tracks it as technical debt in shakenfist/shakenfist#3315, and its own database daemon already uses gRPC. - Framing kept even where policy passes everything. The relay frames
the bulk server→client direction too, rather than splicing it with
copy_bidirectional: recording a session, rewriting a message and the proxy originating traffic of its own (the danger-zone recording indicator, a Kerbside-provided USB device) are not built, but all of them need message boundaries, and an unframed fast path would rule them out. The client→server direction, where strict parsing matters, carries little traffic, so parsing it is cheap.
Process Architecture¶
kerbside daemon run (kerbside/main.py) supervises the proxy as a single
child process (subprocess.Popen, via kerbside/proxy_supervisor.py), binding
the gRPC control socket first. The proxy is an async tokio program: it
accepts connections and handles each on its own task, rather than forking a
worker process per connection. Shared state (tokens, channel bookkeeping,
audit) lives in the database, reached indirectly through the control-plane
gRPC service — the proxy never touches MariaDB directly.
flowchart TD
daemon["kerbside-daemon<br/>binds the gRPC UDS,<br/>then supervises the child"]
proxy["kerbside-proxy (Rust)<br/>one tokio task per connection"]
service["KerbsideProxy service<br/>(in the daemon)"]
hypervisor["hypervisor SPICE"]
daemon -- "subprocess.Popen" --> proxy
proxy <-- "gRPC over UDS:<br/>authorize, audit, channel records" --> service
proxy -- "TLS relay" --> hypervisor
Connection Listener¶
listen.rs binds two ports (defaults):
- Secure (5900): TLS. The proxy terminates client TLS with its host certificate, then drives the SPICE link handshake.
- Insecure (5901): a minimal responder that issues the SPICE
need_securedredirect, steering clients to the TLS port. No plaintext SPICE traffic is proxied, but clients that dialport=first still need to reach it; see network-ports.md.
Accepted client sockets get TCP keepalive (mirroring the backend leg), so a silently dead peer is detected rather than pinning a task forever.
TLS Configuration¶
The proxy holds a host certificate/key (PROXY_HOST_CERT_PATH /
PROXY_HOST_CERT_KEY_PATH) and a CA bundle (CACERT_PATH). Client-facing TLS
is terminated with the host certificate. The backend leg (backend.rs, via
the ryll SpiceClient) originates TLS to the hypervisor. When the console
carries a host_subject, the backend leg verifies the server certificate's
subject against it using spice-common matching semantics, substituting for
hostname verification, so a mis-issued or substituted backend certificate is
rejected. When the console has no host_subject, the CA chain is the only
identity check. The rustls ring crypto provider is installed at startup.
A console whose Target carries a tunnel takes a different route. The
proxy reaches qemu's TLS port only through the HTTP CONNECT proxy named by
tunnel.proxy_uri, asking it for tunnel.connect_host, and dials nothing
else: neither hypervisor_ip nor insecure_port is touched. There is one
TLS attempt and no plaintext attempt or need_secured retry. A tunnel
needs a host_subject; the ryll client refuses one without it, and the
refusal is recorded as a failed connect. An empty connect host, an unusable
secure_port or a proxy URI ryll cannot parse is refused before any dial.
The connect host is a credential, so the proxy never logs it and its
errors name the field, not the value; its log lines say only whether the
target was tunnelled.
The console's ca_cert is the only trust anchor for the backend leg when
it is set: the public web roots are not consulted, so a hypervisor
certificate must chain to that CA and nothing else will do. Without a
ca_cert the public web trust store applies instead, and then the
certificate's name must match the host dialled unless a host_subject is
pinned. This is narrower than earlier releases, where a configured
ca_cert was added to the public roots rather than replacing them, so a
certificate from any public CA also passed the chain check (ryll commit
3050082, picked up with the fix for issue #477).
What that means depends on the source type, because ca_cert is not
always a free choice:
- Shaken Fist and oVirt. One
ca_certfield serves both the cloud API and the hypervisor VDI leg, and the source driver requires it to match the CA the cloud itself publishes, exactly, or the source is marked errored (kerbside/sources/shakenfist.py,kerbside/sources/ovirt.py). So it can be neither left empty nor extended into a bundle. Both platforms issue hypervisor SPICE certificates from that same cluster (or engine) CA, so the narrower trust changes nothing for them. A hypervisor whose SPICE certificate was replaced with one from another CA will now fail the backend connect. - Proxmox VE.
ca_certis required and is the cluster CA, which verifies the API and is also the trust anchor for the backend leg, since that CA signs each node's SPICE certificate. It is not compared with anything the API reports, and thecain a minted ticket is ignored. - Static and OpenStack.
ca_certis only the VDI trust anchor (see Console Sources). A deployment whose hypervisors present publicly issued certificates must leave it empty for that source rather than set it to an unrelated CA.
For Shaken Fist consoles the host_subject is not configured on the proxy:
it is taken at scrape time from the hosting node's published SPICE server
certificate subject (spice_server_cert_subject), and carried through to the
proxy in the AuthorizeConnection Target. That pin is conditional. A node
that publishes no subject (an older cluster, or a node without a SPICE
certificate) yields no host_subject, and the backend leg for its consoles
is then relayed without host-subject enforcement rather than refused
(backend.rs maps an empty subject to none). The per-source
synthesize_host_subject option substitutes CN=<node fqdn> in that case,
which is only correct on the stock cn=hostname PKI and so is off by
default. See Console Sources.
Backend host subject pinning¶
The matching is done by the TLS verifier in ryll's
shakenfist-spice-protocol crate, whose host_subject.rs module comment
states the grammar and comparison rules. The proxy only passes the
console's host_subject through (build_config in backend.rs). Its shape
has these reasons:
- The rules are spice-common's, not the earlier Python proxy's. They
replicate
subject_to_x509_nameandverify_subjectin spice-common'scommon/ssl_verify.c, which spice-gtk and virt-viewer apply to a.vvfile'shost-subject, so one subject string is accepted or refused the same way by virt-viewer, ryll and this proxy. The Python proxy rebuilt a string from only theC,OandCNattributes and compared it exactly, so a certificate carrying any other attribute could never match. Two differences from OpenSSL are deliberate: values are compared with ASCII case folding only, and the attributes of a multi-valued RDN are compared in the order the certificate holds them rather than sorted. Either can refuse a certificate OpenSSL would accept, never the reverse. - It fails closed. An unknown attribute key, a malformed string, a value encoding the matcher cannot decode (TeletexString, BMPString) and a mismatch all refuse the backend. A malformed pin is rejected when the ryll client is constructed rather than at the handshake, so it fails even where the plaintext attempt would have succeeded, and never quietly becomes an unpinned connection.
- A pin replaces hostname verification, as it does in spice-gtk, because SPICE server certificates usually carry no subjectAltName.
- A refusal is audited as an ordinary failed connect. The audit event
is
Hypervisor connection failed:followed by the error chain, which for a mismatch endsinvalid peer certificate: NotValidForName; the proxy's log lineTLS: rejecting certificate: pinned host_subject ...names both subjects. There is no separate audit event type, as there was none in the Python proxy. - oVirt's subjects need no normalisation. The oVirt API reports
host.certificate.subjectalready in the spice-common form (O=local,CN=ovirt.localfrom the oVirt CI engine, 2026-07-17), so the oVirt source stores it as reported.
The direct-qemu lane proves both outcomes against a real TLS backend; see Backend host subject checks.
Connection State Machine¶
Each accepted connection runs this sequence (session.rs, backend.rs,
relay.rs):
- TLS terminate the client connection (secure port).
- SPICE link handshake using the ryll server-role handshake drivers:
parse the client
SpiceLinkMessand reply. A client whose common capabilities lackMINI_HEADERorAUTH_SELECTIONis refused here with aversion_mismatchlink error, since the relay frames on the mini header and the ticket read expects an auth selector. Otherwise the reply offers a fixed set of capabilities per channel type (see "Link capabilities" below), and the client's own capabilities are kept for the backend leg. - Ticket decryption / authorization. The client's RSA-encrypted ticket is
decrypted and sent to the control plane via the
AuthorizeConnectionRPC, which resolves it to a hypervisorTarget(host, port, backend ticket,host_subject,session_id, and the firewall policy) or returnsDenied. A denial closes the connection. For a source whose driver mints tickets (oVirt and Proxmox VE), the control plane may call that source's API while authorizing (see "Minting a backend ticket during authorization" below), and denies the channel if it cannot. - Backend connect. The proxy opens the backend leg to the hypervisor's
SPICE port (honouring a
need_securedretry to the TLS port) and completes the server-side handshake with the backend ticket, advertising the client's capabilities rather than the proxy's own. For a tunnelled target it instead makes a single TLS connection through the target's HTTP CONNECT proxy (see "TLS Configuration"). - Relay. Traffic is relayed in both directions until either side closes, the idle-read timeout fires, the firewall issues a terminating verdict, or the control plane terminates the session.
The connection registers in the session registry (session.rs) under its
session_id after authorization and deregisters on teardown, so the control
plane can drop it (see "Session termination" below).
Link capabilities¶
The client leg's link reply has to be sent before the backend is known: it
carries the RSA key the client encrypts its ticket to, and the ticket is what
selects the backend. So the capabilities Kerbside offers a client cannot
depend on the backend. Instead, for each channel type it offers what
spice-server itself advertises for that type (rust/kerbside-proxy/src/caps.rs
cites the spice-server source for each):
| Channel | Offered channel capabilities |
|---|---|
| common (every channel) | AUTH_SELECTION, AUTH_SPICE, MINI_HEADER (never AUTH_SASL) |
| main | SEMI_SEAMLESS_MIGRATE, NAME_AND_UUID, AGENT_CONNECTED_TOKENS, SEAMLESS_MIGRATE |
| display | MONITORS_CONFIG, STREAM_REPORT, PREF_COMPRESSION, PREF_VIDEO_CODEC_TYPE |
| inputs | KEY_SCANCODE |
| playback | VOLUME, OPUS |
| record | VOLUME, OPUS |
| usbredir, port, webdav | DATA_COMPRESS_LZ4 |
| cursor, smartcard | none |
Every message those capabilities let a client send is in the L1 allowlist.
The backend leg then advertises the client's capabilities, verbatim and including any extra capability words, so spice-server chooses image compression, stream codecs and drawing operations for the client actually connected. The one exception is the authentication mechanism bits, which describe Kerbside's own ticket exchange with the backend: AUTH_SPICE is set and AUTH_SASL cleared. Before issue #477 was fixed the backend leg always advertised ryll's capabilities, so spice-server could, for example, send H.264 streams to a client that had not offered H.264.
If a backend grants fewer channel capabilities than Kerbside offered the client (a spice-server built without Opus or LZ4, say), the proxy logs a warning naming the missing capability bits; the backend's reply is logged at debug level. The client may then send something that backend refuses, which cannot be undone once the client leg's reply has been sent.
Packet Framing and the Relay¶
Post-handshake SPICE traffic uses a 6-byte mini-header
(MessageHeader: a u16 message type and a u32 size) ahead of each body.
The relay (relay.rs) frames every message by that header rather than
copying bytes opaquely, which is what makes inspection and the firewall
possible. Each direction is a pump; tokio::select! drives both plus a
cancellation arm, so whichever future finishes (a closed half, a timeout, a
terminating verdict, or a control-plane cancellation) tears the whole
connection down cleanly.
Before a body is buffered, an L0 check bounds it (per-channel size caps and an optional rate ceiling), so an attacker-controlled length in the header cannot drive an unbounded allocation. Framed messages are then checked at L1 against the per-channel/direction message-type allowlist. See "the SPICE firewall" below.
Channels¶
The proxy models the standard SPICE channels: main, display, inputs,
cursor, playback/record, usbredir, and the port channel. The L1
allowlist (rust/kerbside-proxy/src/allowlist.rs) is derived per channel and
direction from the ryll shakenfist-spice-protocol message-type tables.
Transcoding the display channel (experimental)¶
By default the proxy relays every channel. An experimental alternative terminates the display channel instead: the proxy decodes what the hypervisor sends and re-encodes it for the client, sending only what changed, as JPEG where the picture is photographic and losslessly otherwise, at the pace the client's link can take. It exists to measure whether that beats relaying on slow links; it is not for production, and the spike's verdict recommends against productising it.
It needs two things, both off by default:
- The
transcodecargo feature. It pulls in ryll's renderer and the compression crate's image encoders, whoseencodefeature also builds openh264 from source. Release wheels never enable it (tools/build-proxy-wheel.shbuilds with default features), so a releasedkerbside-proxyhas neither the flags below nor openh264. Build it withmake -C rust/kerbside-proxy build FEATURES=transcode. --transcode-display. A process-wide proxy flag: every session's first display channel is transcoded. The daemon does not pass it, so run such a build directly (see the direct-qemu harness).
| Flag | Default | Meaning |
|---|---|---|
--transcode-display |
off | Transcode each session's first display channel. |
--transcode-jpeg-quality |
85 | JPEG quality (1 to 100) for areas judged photographic; 0 sends everything losslessly. |
--transcode-lossless-delay-ms |
500 | How long an area sent as JPEG must go without change before it is sent again losslessly. |
--transcode-notsent-lowat-bytes |
65536 | TCP_NOTSENT_LOWAT on the transcoded channel's client socket: how far ahead of the link the transcoder may write. |
--transcode-ack-window |
0 | A measurement option, hidden from --help: pace on a SPICE ACK window of this many messages, as spice-server does, instead of on the client socket. 0 paces on the socket. |
--transcode-switch-after-secs |
0 | An experiment, hidden from --help: move each session's first display channel between relaying and transcoding this many seconds after it starts, and again each time that long has passed in the new mode. It starts transcoded with --transcode-display and relayed without. 0 never switches. See switching a display channel. |
How a transcoded display channel works (rust/kerbside-proxy/src/transcode/):
- Backend leg. The display channel is dialled with fixed capabilities
that describe ryll's renderer rather than the client: LZ4 and a
compression preference, so draws stay lossless and never become QUIC (which
spice-server turns into JPEG on a link its bandwidth test calls slow);
MULTI_CODECwith no codec bits, so spice-server creates no video stream; and nothing the renderer cannot draw. ryll's renderer reads the channel, answers spice-server's flow control at LAN speed, and decodes each draw into a mirror of the guest's primary surface that records where each draw landed. - Client leg. The proxy answers as a display server: it waits for the
client's
DISPLAY_INIT, then sendsSURFACE_CREATEfor the primary surface, a full lossless draw,MONITORS_CONFIGif the client has that capability, andMARK. After that each frame is the draws that bring the client up to date, thenMARK. A resize from the guest becomesSURFACE_DESTROYand the same start sequence. No video stream is ever sent, and no image asks the client to cache it. The client's messages pass through the same L0 and L1 firewall as the relay's, and its bytes count inkerbside_proxy_bytes_relayed_total. Unlike the relay, the client leg has no idle-read timeout, because once initialised a client paced on the socket has nothing to say. TCP keepalive ends a client that has gone. A client that stays connected but stops reading (a zero window, or no ACKs under ACK pacing) is ended, logged at info, once a write to it has made no progress for 15 minutes, the relay's idle-read timeout. A slow link is not a stall: the bound is on progress, not on how long a frame takes. - What a frame sends. The surface's damage says where to look; each damaged 64-pixel tile is compared with a shadow of what the client already holds, and only tiles that differ are sent. Tiles are merged into at most 32 rectangles a frame, and a rectangle over 4 MiB raw is split, keeping every message far below the proxy's 16 MiB cap. A rectangle that compresses poorly with LZ4 (more than half its raw size) is tried as JPEG, the rest go as LZ4. JPEG is used only if the client leaves the compression choice to the server. This update pipeline is copied from andris, whose thresholds were measured on real desktop screens.
- Pacing. One frame is written at a time, and the next starts only once
the kernel has taken the last, which
TCP_NOTSENT_LOWATholds back until the link has nearly drained it. Meanwhile damage merges in the mirror, so a slow client is sent the screen as it is when it can take more, never the frames it missed. NoSET_ACKis sent, so the client sends no ACKs. - Memory. Each transcoded channel holds ryll's pixmap cache (up to
256 MiB) and GLZ dictionary (up to 64 MiB) besides the mirror and the
shadow. When the channel ends the proxy logs, at info, how full the pixmap
cache got (
pixmap_cache_peak_bytes,pixmap_cache_peak_entries) and how many entries it evicted. The shaped-link rig'ssummarise.pyreports the peak.
What it does not do:
- Only a session's first display channel (channel id 0) is transcoded. A second display channel is relayed, and the proxy logs that it did so.
- The choice between relaying and transcoding is made when the channel
connects. Only the hidden
--transcode-switch-after-secsexperiment changes it while the channel runs: it keeps the client leg, closes the backend display channel and dials it again for the other mode, and keeps GLZ out of the relay, because GLZ does not survive the switch. Both modes dial with the transcoder's capabilities, so its relay assumes a client that takes LZ4 andMONITORS_CONFIG. Switching a display channel has what that costs, and what breaks in each client. - The backend leg is not firewalled. The server-to-client messages of a transcoded display channel skip the L0 and L1 policy and the relay's 16 MiB framing cap, and go straight to ryll's renderer, which frames them and decompresses guest-influenced LZ4, GLZ, QUIC and JPEG data inside the proxy process. Only the renderer's own limits bound that decode, so a decoder panic or an allocation the guest can drive is reachable from the guest, in the process that carries every other session. Only the client's messages are firewalled.
- Its scope is a virtio-gpu or std-vga guest, whose display reaches
spice-server as bitmap draws on the primary surface. A guest running the
QXL driver can send draws ryll's renderer does not implement (ROP3, stroke,
text and composite draws, palette, surface and JPEG-with-alpha images, and
draws on off-screen surfaces). The transcoder logs the first of each kind
in a session and counts every one in
kerbside_proxy_transcode_unsupported_total{kind}. It cannot fall back to the relay by then, because the client already holds the transcoder's surface, so the client shows a stale region wherever such a draw landed.
Metrics¶
The proxy exposes a Rust-native Prometheus /metrics endpoint
(metrics.rs), bound to --metrics-address (default loopback, config
PROMETHEUS_METRICS_ADDRESS) rather than the public VDI address, since the
endpoint is unauthenticated. Metrics cover accepted/authorized connections,
active connections/channels, bytes relayed per direction, firewall
verdicts, spice-server's ACK window, and what a transcoded display channel
could not draw. Configuration
lists them.
Error Handling¶
Handshake, TLS, authorization, and backend-connect failures close the
connection and are logged with a per-connection correlation id (a tracing
span around the connection lifecycle). A denied authorization, a failed
backend TLS verification, an oversized/dis-allowed message, and an idle-read
timeout each end the connection deterministically rather than leaking a task.
Log Output¶
The proxy writes tracing output to stdout. Under the daemon that stdout is
inherited into the kerbside log, so colouring is decided by the sink: ANSI
escapes are emitted only when stdout is a terminal, and a redirected log gets
plain text. This matters for anything that parses the log — with colour on,
tracing renders a structured field as name<esc>[0m<esc>[2m=<esc>[0mvalue,
so a search for a literal name= finds nothing at all.
Security Considerations¶
Token Validation¶
Tickets are validated by the control plane, not the proxy: AuthorizeConnection
resolves the decrypted ticket against the database and returns a Target or
Denied. The proxy enforces the decision and never reaches the database on
its own.
Two different tokens exist for a Shaken Fist console, and they live on different surfaces:
- The HTTP JWT bearer is the Ed25519-signed token Shaken Fist mints and a
viewer presents at the
/sf-console.vvexchange endpoint on the Python API. The API verifies it offline against the cluster's cached signing public keys (signature,aud,exp) and enforces single use through ajtireplay table (sf_token_jtis) — a replayed token is rejected. The Rust proxy never sees this JWT; the exchange happens entirely on the Python side, which then issues an ordinary Kerbside console token and.vvfile. - The on-wire SPICE ticket is what the proxy actually authorizes. It
arrives RSA-encrypted inside the SPICE link handshake, is decrypted, and is
resolved by the control plane via
AuthorizeConnection. The proxy enforces only this decision; the JWT exchange has already completed before any SPICE connection is made.
Minting a backend ticket during authorization¶
For a static source, AuthorizeConnection returns the ticket stored on the
console. For a source whose driver sets MINTS_TICKETS (oVirt and Proxmox
VE) there is no stored ticket: the first channel of a session to be
authorized mints one from the source, and the rest reuse it. The sequence
is:
- The lifetime asked for is the token's remaining life plus 30 seconds
(
TICKET_SLACKinkerbside/rpc/servicer.py), since no channel is authorized after the token expires. oVirt honours it. Proxmox VE has no way to ask: its tickets always last 30 seconds from the mint, and the reuse window below is what keeps a session's channels on one. db.session_ticket()selects the session'sconsoletokensrowFOR UPDATE. If it holds a ticket with at least 10 seconds left (REUSE_MARGIN) that ticket is returned; otherwise the caller mints and the result is stored. The lock is shared by all proxy nodes, so channels of one session arriving together on different nodes mint once.- The mint runs on a worker thread, and the caller waits at most
MINT_DEADLINE(20 seconds,kerbside/sources/minting.py). The deadline covers building the driver, logging in and the ticket request, becauseovirtsdk4does not bound its login; each engine request is separately bounded at 15 seconds. A late result from an abandoned worker is discarded, never stored. Mints for one console run one at a time in each daemon: while a worker for the console is still running, even an abandoned one, a new mint waits for it within its own deadline and is refused if it does not finish. Otherwise the late worker could replace the hypervisor's password after a newer mint had set and stored its own, and every later channel would be refused. The Proxmox VE driver narrows that further: onceMINT_POST_CUTOFF(10 seconds,kerbside/sources/proxmox.py) has passed since it started, it no longer sends the request that sets the password, so a slow mint gives up before changing it. The cutoff counts from the driver's start, not from when its caller began waiting (#656). A worker still running after five minutes (MINT_STUCK_AFTER) stops holding mints up. Daemons on different nodes do not see each other's workers, nor does the direct.vvhandler, which mints in the web workers; and any mint for a VM, whichever session it is for, replaces the password other sessions' stored tickets were for. - A failed or timed-out mint stores nothing, denies the channel with
console ticket unavailable, and is audited with the exception's class name (never its text, which an API client can fill with the request it failed on). The next channel tries again. A successful mint is audited with its lifetime. NoChannel createdevent is written for a denied channel.
The maintenance loop clears a stored ticket once it or its token has expired.
A replacement ticket leaves a session's established channels connected;
QEMU's set_password keeps connected clients. Separately, a SPICE server
serves one client at a time and drops its connected client when another
links, so a second session on the same VM disconnects the first regardless of
tickets.
Worst case for the gRPC worker pool. Every channel waiting on the row lock
occupies one of the API_GRPC_WORKERS threads (default 8) serving
AuthorizeConnection for the whole of the wait. With a healthy engine the
wait is one mint. Against an engine that
accepts connections and then hangs, the channel holding the lock gives up
after MINT_DEADLINE, and each channel behind it then takes its own turn at
the lock and its own 20 second wait. A session opening more than a few
channels at once can therefore keep that many workers busy for 20 seconds
each in turn, and the later ones can wait past MariaDB's lock wait timeout
(innodb_lock_wait_timeout, 50 seconds by default); they then fail with an
internal error rather than console ticket unavailable, and the client sees
the connection refused either way. Sessions on other VMs are not held up by
the lock, but all sessions share the worker pool, so a hung engine can
exhaust it for every source until the waits end. Each waiting channel also
holds a database connection, from the daemon's SQLAlchemy pool (5 plus 10
overflow by default), which the maintenance loop and the other gRPC calls
share; raising API_GRPC_WORKERS much above that makes a hung engine
exhaust the pool too. The deadline caps each wait; it does not make a hung
engine cheap.
Audit Logging¶
State-changing events (channel created/removed, firewall verdicts, session
termination) are recorded as audit events through the control-plane RPCs
(RecordAuditEvent, and the channel register/deregister calls), so the audit
trail is owned by the Python side and consistent across proxy nodes.
Process and Blast-Radius¶
The proxy runs as a single unprivileged process consulting a filesystem-guarded
UDS (directory 0700, socket 0600); it holds no database credentials and no
long-lived secrets. It receives each channel's ticket from the daemon when the
channel is authorized, and never logs it. The firewall
(below) bounds what an untrusted client can send through to a hypervisor.
The SPICE Firewall¶
Rather than relaying post-authentication traffic opaquely, the relay
(rust/kerbside-proxy/src/relay.rs) is inspection-first: it frames every
message by its 6-byte MessageHeader and passes it through a Policy before
forwarding. That policy is EnforcingPolicy (policy.rs): a real, enforcing
application-level SPICE firewall, on by default.
Per-message pipeline¶
For every framed message the relay's pump runs:
- Header parse. The 6-byte header (
message_type: u16,message_size: u32) is parsed first; nothing below inspects the body until this succeeds. Policy::check_header(L0, pre-body). Called exactly once per message, before its body is buffered, so an over-cap or over-rate message is refused without ever accumulating it:- a per-(channel, direction) size cap — tight (4 KiB) on the inputs-client and cursor-client directions (key/mouse events are small and fixed), generous (16 MiB) everywhere else (the display server bulk direction and any channel with no modeled grammar);
- a coarse per-direction rate/throughput ceiling (fixed window), disabled by default — a deployment can opt in once real traffic patterns justify a value.
These policy caps sit below the relay's own unconditional
MAX_MESSAGE_SIZE (16 MiB) absolute frame guard, which the relay
enforces unconditionally regardless of policy — a peer that claims an
absurd message_size is refused before the relay would buffer
gigabytes waiting for a body that never arrives.
3. Policy::inspect (L1). Once the full body has been buffered, the
message type is classified against a compiled-in per-channel,
per-direction allowlist (allowlist.rs), derived from the ryll
shakenfist-spice-protocol name tables unioned with the SPICE
common-base opcodes (server SPICE_MSG_* 1..=7, client SPICE_MSGC_*
1..=6, valid on every channel). Main, display, inputs, cursor, and
playback have their own tables; usbredir rides the spicevmc table, and
port/webdav ride the port table (the spicevmc set plus PORT_INIT and
PORT_EVENT). Record, smartcard, and tunnel have no modeled grammar — they
classify as ChannelUnmodeled and get L0-only enforcement plus
observe-only handling for their message types (never a type-based
terminate), rather than treating every type on those channels as a
violation.
4. Verdict. Forward (write the original framed bytes unchanged) or
Terminate (flush and end the whole relay — a single SPICE channel is
one duplex TCP connection, so either direction ending ends the
session). Drop is a variant on the Verdict enum reserved for future
L2/L3 use (e.g. defanging); no rule emits it today, because silently
dropping a mid-stream SPICE message would desynchronise the channel.
Why the rules are what they are¶
- A disallowed type terminates in both directions, server→client included, so a hypervisor sending malformed messages to exploit a client is cut off. When a legitimate type is refused, the fix is to add it to ryll's tables, never to weaken the verdict.
- The allowlist is only as good as ryll's name tables. A type those
tables name is allowed, so a wrong entry fails open, and a missing one
terminates real sessions. In July 2026 the main and display tables
lacked several opcodes and mislabelled
INVAL_ALL_PIXMAPS, which would have ended ordinary remote-viewer sessions; ryll PR #141 fixed them before enforcement shipped. - Message order within a channel is not checked. Rules such as "init before data" carry a high risk of refusing legitimate traffic, so they are left to future body (L2) validation; L1 checks only the type and whether the channel is permitted.
- The idle-read timeout (15 minutes) is a backstop against a silent client holding a connection permit forever. A client that trickles bytes slowly is bounded instead by the size of the permit pool and the 16 MiB absolute frame guard.
The rules were checked against real traffic before they shipped enforcing. On 2026-07-07 a remote-viewer session through the proxy in warn-only mode, across the main, display, inputs and cursor channels with about 1.17 MB relayed, recorded no verdicts at all and came nowhere near a size cap, which is why no cap has been tightened. The direct-qemu lane now holds every pull request to the same result with ryll and a WebDAV channel; see Validating the SPICE firewall.
Warn-only mode¶
FirewallPolicy.mode (EnforcementMode::Enforce default, or WarnOnly)
is the single place the enforcement decision is made
(EnforcingPolicy::apply). In Enforce, a rule's blocking verdict is
applied and recorded action=enforced. In WarnOnly, the SAME blocking
verdict is downgraded to Forward — the session is never actually
blocked — but still recorded action=observed plus a tracing::warn!.
This lets an operator run real traffic through a deployment and see
exactly what Enforce would have tripped before switching to it; it is a
mode, not a grace period, since the compiled default ships Enforce.
A forbidden channel type (a whole channel the deployment's
FirewallPolicy.permitted_channels excludes) is denied earlier, in
session.rs, before any relay is set up — a protocol-correct
PermissionDenied to the client plus an audit event, the same shape as
the existing unknown-channel-type rejection.
Audit and metrics¶
Firewall violations must not become one audit event per message — a
hostile or broken peer could otherwise flood the audit log. Instead, both
relay directions share one lock-free per-connection VerdictTally (atomic
counters keyed by (rule, action)); relay::run reads it once after the
two pumps finish and, if anything was recorded, emits a single coalesced
summary RecordAuditEvent for the whole connection (e.g. "Firewall
verdicts this connection: disallowed_type (enforced=2, observed=0)").
Every verdict is also exported live as the Prometheus counter
kerbside_proxy_firewall_verdicts_total{channel,direction,rule,action},
where rule is one of disallowed_type, unmodeled_type, size_cap, or
rate_cap, and action is enforced or observed.
Policy delivery¶
Python continues to own policy; the Rust proxy only enforces it
("Python decides policy; Rust enforces it"). The tunable knobs — mode and
permitted channels — are delivered per-connection in the
AuthorizeConnection gRPC reply as a FirewallPolicy protobuf message,
built from Python's FIREWALL_MODE/FIREWALL_PERMITTED_CHANNELS config
(see docs/configuration.md). The L1 allowlist tables themselves are
not delivered over gRPC: which message types are structurally valid on
a channel is a fact about the SPICE protocol, not a deployment policy, so
they are compiled into the proxy. Size caps, the rate ceiling, and
per-verdict severities keep their compiled defaults (no gRPC config
surface yet).
Not implemented anywhere in the Rust proxy: L2 body validation (scancode ranges, clipboard/file-transfer/usbredir device-class filtering), session recording, and L3 rewriting/injection.
Process Supervision and Session Termination¶
The Python daemon runs the Rust proxy as a child process, and API-driven session termination drops in-flight connections rather than only blocking new ones.
Process model: the daemon supervises the Rust proxy as a child¶
kerbside daemon run supervises the Rust proxy binary as a child:
flowchart TD
daemon["kerbside-daemon (main.py:daemon_run)<br/>binds the gRPC UDS server FIRST"]
proxy["kerbside-proxy (async tokio tasks)<br/>the Rust binary (rust/kerbside-proxy/)<br/>dials the gRPC UDS at startup (ClearNodeChannels)<br/>and lazily thereafter"]
daemon -- "subprocess.Popen" --> proxy
- The gRPC UDS server is bound before the child is launched, because the Rust proxy dials it at startup and would fail if the socket did not yet exist. (A subprocess child, unlike a fork, does not inherit the gRPC server's fds/threads.)
kerbside/proxy_supervisor.py'sfind_proxy_bin()resolves the binary (envKERBSIDE_PROXY_BIN→PATH→ the in-repotarget/{release,debug}dev build dir) andbuild_proxy_argv()maps config to the proxy's CLI flags (--vdi-address,--secure-port,--cert,--metrics-address, etc.) — the firewall knobs are excluded, since those are delivered per-connection over gRPC (see the firewall section above), not on the command line.- The daemon polls the child's liveness every second and forwards SIGTERM with a 15-second deadline before SIGKILLing it; exits non-zero if the child dies unexpectedly. The 15-second deadline is sized above the proxy's own 10-second graceful-drain window (below), so the daemon gives the proxy a chance to drain before forcing it.
How the binary gets there: packaging¶
find_proxy_bin()'s middle leg — shutil.which('kerbside-proxy') — is
what resolves the binary in a real deployment. The crate is published to
PyPI as a separate kerbside-proxy package: a maturin
bindings = "bin" wheel (rust/kerbside-proxy/pyproject.toml) whose
compiled binary is laid into the wheel's *.data/scripts/ directory,
which pip installs onto PATH.
The committed pyproject.toml does not carry an exact pin. It carries a
dev-inclusive FLOOR — kerbside-proxy>=0.4.0.dev0 — anchored on the
# KERBSIDE_PROXY_PIN marker. Naming a .dev0 version in the specifier
is what opts pip into pre-release resolution: without it, pip would
never consider a dev wheel a valid match for a plain >= floor. This
means a pip install from a git checkout of kerbside (not a tagged
release) resolves whatever kerbside-proxy is newest on PyPI at install
time — a tagged release, or a rolling dev wheel.
Unreleased kerbside still needs a working proxy to test against, which
is what .github/workflows/dev-proxy-wheel.yml provides: it builds and
publishes a PEP 440 dev wheel of kerbside-proxy (e.g. 0.4.1.dev163,
versioned by setuptools_scm) to PyPI, unattended, on pushes to
develop that are path-filtered to the binary's inputs (rust/**,
kerbside/rpc/kerbside.proto, tools/build-proxy-wheel.sh,
tools/stamp-dev-proxy-version.sh, tools/gen-protos.sh, and the
workflow file itself) — minus rust/kerbside-proxy/Cargo.lock, since a
lockfile-only bump cannot change the binary's interface or contract
hash. Dev wheels carry build provenance attestations, the same as
tagged releases, but they are not Sigstore tag-signed — there is no
v* tag for a dev build to sign. See "Configure Dev Release Publishing"
and "Dev releases" in RELEASE-SETUP.md. These dev releases accumulate
on PyPI over time and are pruned manually; see "Pruning dev releases"
in RELEASE-SETUP.md.
At release time the two packages release in lockstep from a single v*
tag: setuptools_scm gives kerbside its version, and
tools/stamp-proxy-version.sh stamps that same version into the crate
(for maturin) and REPLACES the committed floor line in pyproject.toml
with the exact kerbside-proxy==<version> pin. A stamped release tree
is exact-pinned; the develop branch is not.
tools/build-proxy-wheel.sh builds prebuilt manylinux_2_28 wheels for
x86_64 and aarch64 (the latter cross-compiled with maturin --zig, so
no aarch64 build host is required); no source distribution is published, so
an unsupported platform gets a clean pip error rather than a doomed source
build. In development you bypass all of this: find_proxy_bin() falls
through to the in-repo cargo build output, or you set
KERBSIDE_PROXY_BIN explicitly. See RELEASE-SETUP.md.
The gRPC contract handshake¶
Because the committed tree is not exact-pinned, an installed
kerbside-proxy binary is not guaranteed to speak the same gRPC
contract as the kerbside it is paired with — the pin only guarantees
that at release time. The handshake is the backstop for every other
case (dev installs, a stale local build, an operator mismatching
versions by hand):
tools/gen-protos.sh(run viatox -egenprotos, wheneverkerbside/rpc/kerbside.protochanges) writes the sha256 of the proto's raw bytes to a generated constant,CONTRACT_HASH, in the committedkerbside/rpc/contract.py.rust/kerbside-proxy/build.rscomputes the same sha256 from its copy ofkerbside.protoat compile time and embeds it in the binary; the binary prints it and exits when run with--contract-hash.- Before launching the proxy,
launch_rust_proxy()(kerbside/proxy_supervisor.py) callscheck_contract(), which runs the resolved binary with--contract-hashand compares the result toCONTRACT_HASH. A mismatch — including a binary that predates the--contract-hashflag entirely, which reports as "unknown" rather than a hash — raisesRuntimeErrornaming both hashes and four remediations (upgrade the wheel, rebuild the local Rust tree, pointKERBSIDE_PROXY_BINat a matching binary, or bypass the check). KERBSIDE_SKIP_CONTRACT_CHECKis that bypass: set it to a recognised truthy value (1,true,yes, oron, case-insensitively) and the mismatch is logged as a warning instead of refused. It exists for debugging only and is not a supported deployment posture.
The handshake is a compatibility check, not an integrity control. It
detects accidental build skew — a stale local cargo build, a wheel
that did not upgrade with the package, a hand-mismatched pair — and it
detects nothing at all about a substituted or tampered binary. An
attacker who can replace the binary controls what it prints when asked
for its hash, and more fundamentally the check has to execute the
untrusted binary in order to decide whether to trust it, so anyone in a
position to defeat it already has code execution. Nothing downstream of
this may treat a passing handshake as evidence that the binary is the
one this package shipped with.
Session termination: dropping in-flight connections¶
Removing or expiring the DB token blocks a new connection attempt, but
it does nothing to a client that is already connected: that client keeps
its channels open until it disconnects itself. Dropping a live session
needs a path from "the API terminated this session" to "the proxy holding
that session's sockets finds out", so
ConsolesTerminate/SessionTerminate do both.
The distributed-deployment constraint. Kerbside can run with the REST
API and the proxy processes on different machines (the proxy is often
sized separately from the API), and proxies can sit behind a load balancer
so that different channels of the same session land on different proxy
nodes. The only component every one of these can reach is the shared
MariaDB — there is no API→proxy or proxy→proxy RPC across machines. The
ProxyControl gRPC stream, by contrast, is strictly local: one stream
between a daemon and the single proxy it supervises on the same host, over
the same UDS as every other RPC in the table above. Termination therefore
has to be a DB-mediated intent that each node acts on independently for
the channels it happens to hold. Two simpler designs were rejected:
inferring termination from the token's absence would conflate it with
natural expiry, which must never drop a live session, and an RPC from the
API to a daemon cannot reach a proxy on another machine or behind a load
balancer.
flowchart TD
api["REST API<br/>(may be elsewhere)"]
db[("MariaDB<br/>the only shared bus")]
api -- "INSERT session_terminations(S)" --> db
%% Node B is declared FIRST on purpose: as of 2026-08, the mermaid
%% version GitHub renders lays subgraphs out in reverse declaration
%% order, so this is what puts node A on the left. That is observed
%% behaviour, not documented -- verify after a mermaid bump. Left
%% versus right carries no meaning either way.
subgraph nodeB["Proxy node B — holds other channels of session S"]
direction TB
daemonB["Daemon stream:<br/>ProxyControl TerminateSession(S)"]
registryB["Rust proxy:<br/>SessionRegistry.terminate(S)"]
relayB["relay::run's select! sees token.cancelled()<br/>→ teardown, once per channel this node holds"]
daemonB --> registryB --> relayB
end
subgraph nodeA["Proxy node A — holds channels 1, 2 of session S"]
direction TB
daemonA["Daemon stream:<br/>ProxyControl TerminateSession(S)"]
registryA["Rust proxy:<br/>SessionRegistry.terminate(S)"]
relayA["relay::run's select! sees token.cancelled()<br/>→ teardown, once per channel this node<br/>holds — here, 2 relays end"]
daemonA --> registryA --> relayA
end
db -- "polls get_terminations_for_node(A)" --> daemonA
db -- "polls get_terminations_for_node(B)" --> daemonB
Concretely:
- API (
api.py,ConsolesTerminate/SessionTerminate): expires or removes the token, and callsdb.request_session_termination(session_id, reason), which upserts a row into thesession_terminationstable (session_idprimary key,requested_at, optionalreason; migrationc4e7a1b9d2f3). Idempotent — re-terminating an already-terminated session just refreshesrequested_at. - Daemon (
servicer.py::ProxyControl, one stream per local proxy): each poll interval, callsdb.get_terminations_for_node(NODE_NAME)— an intersection ofsession_terminationswith this node's liveproxychannelsrows — and yields aTerminateSession(session_id)for every id not already sent on this stream, interleaved withHeartbeats so the stream stays alive between events. A DB error is logged and the loop continues rather than killing the stream. Natural token expiry (no explicit termination) is never pushed — expiry only blocks new connections. - Rust proxy (
session.rs::SessionRegistry,rpc.rs:: run_proxy_control): aMutex<HashMap<session_id, CancellationToken>>refcounted across the session's live channels (one entry per session, incremented on each channel's registration, decremented and removed on the last channel's teardown). ReceivingTerminateSessioncancels the token;relay.rs'sselect!gained a third arm ontoken.cancelled()alongside the two pumps, so every channel of the session this node holds tears down cleanly (no error, same shape as a protocol-levelTerminateverdict) and the client is disconnected. Cancelling an unknown or already-torn-down session is a no-op, so a late or duplicate push (another node's poll cycle, a retry) is safe. - Reaper (daemon maintenance loop,
reap_session_terminations): deletessession_terminationsrows older than 5 minutes — long past the point every node has polled and acted — keeping the table small.
A merely-expired (not explicitly terminated) token is never pushed as a
TerminateSession, so natural token expiry does not tear a live session
down mid-use — only an explicit API terminate does.
Graceful drain on shutdown¶
On SIGTERM, the proxy's shutdown_signal future resolving triggers the
same teardown path used for termination: it stops the listeners from
accepting new connections, then calls SessionRegistry::terminate_all()
(cancelling every live session's token at once) and waits up to a
10-second deadline for the active-connection count (the same gauge behind
/metrics) to reach zero before returning. The 10-second drain deadline is
deliberately shorter than the daemon's 15-second SIGTERM-to-SIGKILL window
above it, so a supervised restart lets in-flight sessions finish tearing
down on their own terms rather than being cut off mid-drain by the
daemon's SIGKILL.
Verifying it live¶
The mock harness (direct-qemu-harness.md)
drives this end to end against a
real client: the mock gRPC server's ProxyControl stream emits a one-shot
TerminateSession a configurable number of seconds after the first
authorization (MOCK_GRPC_TERMINATE_AFTER), standing in for the
API→DB→daemon-poll leg so the harness can exercise just the proxy side
live. A recorded run against remote-viewer shows all four of its
channels (main, display, inputs, cursor) torn down by a single
TerminateSession event.
Related Documentation¶
- Protocol Overview - SPICE protocol introduction
- Link Protocol - Connection handshake details
- Channel Protocols - Per-channel message formats