Skip to content

ryll --web operator guide

ryll --web exposes a SPICE session as an HTTP endpoint serving a browser shell that talks to the SPICE server via WebRTC. Single-viewer for MVP; multi-viewer is future work.

Quick start

ryll --web session.vv

Optional flags:

  • --web-host 127.0.0.1 — bind address for the HTTP listener. Defaults to loopback; use 0.0.0.0 for LAN access. This does not affect WebRTC media — see --web-media-addr below.
  • --web-port 0 — TCP port. Defaults to ephemeral.
  • --web-media-addr — address or interface name to bind the WebRTC media (UDP) sockets to. Repeatable. Defaults to every interface address that is not loopback, unspecified or IPv6 link-local.
  • --web-media-port 0 — UDP port for those sockets. Defaults to an ephemeral port per address; pin it to write a single static firewall rule.
  • --web-ice-server — STUN or TURN URL. Repeatable, empty by default.

Every flag is tabulated in configuration.md.

The binary prints a URL with a per-launch token:

ryll: serving web frontend at http://127.0.0.1:34567/?token=abc...

Open the URL in Firefox or Chrome. The browser fetches the embedded HTML/JS/CSS shell, opens an RTCPeerConnection, exchanges SDP via POST /offer, and starts streaming.

What works

  • Display: SPICE display channel rendered in the browser via H.264 over WebRTC.
  • Inputs: keyboard and mouse from the browser to SPICE, in both mouse modes — absolute positions when the guest runs vdagent, relative deltas when it does not.
  • Cursor: rendered as an <img> overlay above the <video>, positioned from the viewer's own pointer in client mouse mode and from the guest's reported position in server mode. The host browser cursor is hidden only once the SPICE server has actually sent a cursor shape, so a guest that sends none leaves you with the ordinary browser pointer rather than with no pointer at all.
  • Audio: Opus passthrough from SPICE (no re-encoding) when the server negotiated Opus. PCM-only SPICE servers currently produce silent audio (a warning is logged).
  • Resolution: the SPICE guest resizes to match the browser viewport at connect time (via vdagent VDAgentMonitorsConfig), and the encoder follows the guest through the resize. Without vdagent in the guest the resize does not happen and the browser scales whatever resolution the guest booted at, which looks soft.
  • Ctrl-C cleanly stops the binary.

Appearance

The browser shell is styled with sfui, the Shaken Fist web UI design system, so its controls match the other Shaken Fist web interfaces. There is nothing to install or configure: the stylesheets are compiled into the ryll binary and served from the same token-gated endpoint as the rest of the shell.

The page is always dark. sfui ships a light palette and a theme toggle, and this page deliberately uses neither — the area around a letterboxed guest is page background, and a light chrome around a video reads as a bug rather than as a preference. That background is sfui's near-black --sf-bg (#0f1117) rather than pure black; if you are comparing screenshots against an older build, that is the difference you are seeing.

Reconnect behaviour

--web mode is resilient to browser disconnects.

Browser tab close → reopen

When the browser tab is closed (or the network between the browser and ryll drops), the server-side bridge reaper notices the RTCPeerConnection reaching a terminal state (Failed, Disconnected, or Closed) within ~1 second. The reaper:

  1. Takes the bridge out of the active slot and closes it, tearing down the DTLS/SRTP state.
  2. Calls EncoderInfra::stop() so the H.264 encoder task exits and CPU usage drops to idle.
  3. Clears the audio pump.

The SPICE session is left untouched. Reopening the same URL at any time establishes a fresh RTCPeerConnection via a new /offer round-trip; the encoder restarts, requests a keyframe, and the guest desktop appears within a few frames.

Browser-side auto-reconnect

On transient ICE or connection-state failures the browser retries automatically with exponential backoff:

Attempt Delay
1 1 s
2 2 s
3 4 s
4 8 s
5 16 s

After 5 failed attempts the status overlay shows "Disconnected. Click to reconnect." and a button lets the operator trigger a manual retry.

Each attempt constructs a brand-new RTCPeerConnection (no stale SDP cache), resets the backoff counter on a successful Connected transition, and retriggers the viewport-resize message so the guest resolution re-syncs.

Graceful shutdown

Ctrl-C or SIGTERM drains the axum HTTP server (existing graceful-shutdown path) then explicitly closes any active bridge before the process exits, ensuring DTLS/SRTP state tears down cleanly.

Limitations (MVP)

  • Single viewer at a time. A second offer replaces the existing connection.
  • No clipboard sync, USB redirection, or folder sharing (out of MVP scope).
  • No multi-monitor (single video track, single primary surface).
  • Browser audio autoplay policy: click the volume button on the page to enable sound after the page loads.

Native TLS

ryll supports HTTPS natively via two flags:

ryll --web session.vv \
    --web-tls-cert /path/to/cert.pem \
    --web-tls-key  /path/to/key.pem

Both flags must be supplied together; clap rejects one without the other at parse time. Omitting both keeps the default plain-HTTP behaviour.

Accepted formats: PEM-encoded certificate chain (cert.pem) and PEM-encoded private key (key.pem). A chain file should contain the leaf certificate first, followed by any intermediate CA certificates.

When TLS is active the startup URL line prints https://:

ryll: serving web frontend at https://0.0.0.0:8443/?token=...

Cert rotation (MVP): ryll does not support inline cert reload. To rotate a certificate, replace the files on disk then restart the process (or systemctl restart ryll-web). The URL token changes on each restart.

Security layers: WebRTC's media path is always encrypted by DTLS-SRTP at the protocol level, regardless of whether the signalling page is over HTTPS. Native TLS protects the URL token and the signalling page (GET /, POST /offer). DTLS-SRTP protects the audio/video media stream. The two layers are complementary: use native TLS for the signalling path whenever the traffic crosses any untrusted network.

Cert recipes

mkcert (LAN dev)

mkcert installs a local CA into your machine's trust store so browsers on that machine accept the generated cert without a warning:

mkcert -install
mkcert ryll.lan 192.168.1.10

Pass the resulting .pem files directly to --web-tls-cert and --web-tls-key.

certbot (public DNS)

For a host with a public DNS A record and ports 80/443 reachable:

certbot certonly --standalone -d ryll.example.com

Certs land at /etc/letsencrypt/live/ryll.example.com/fullchain.pem and .../privkey.pem. Auto-renewal:

# /etc/cron.d/certbot-renew (or use certbot's timer)
0 3 * * * root certbot renew --quiet \
    --deploy-hook "systemctl restart ryll-web"

openssl one-off (self-signed)

For a one-afternoon diagnostic session where a browser warning is acceptable:

openssl req -x509 -newkey rsa:2048 \
    -keyout key.pem -out cert.pem \
    -days 30 -nodes -subj "/CN=ryll.lan"

The browser will show an untrusted-cert warning. Proceed by adding a permanent exception, or use mkcert instead.

Internal CA

For org-managed PKI, request a cert from your internal CA and follow your org's cert-issuance documentation. The output should be a PEM chain file and a PEM key file, which pass directly to --web-tls-cert/--web-tls-key.

Reverse-proxy fallback

If you already terminate TLS at a reverse proxy for unrelated reasons, you can pass the plain-HTTP URL through to ryll and let the proxy handle HTTPS. Native TLS is recommended for new deployments — the reverse-proxy path is documented here as a fallback only.

Caddy (autocert handles the cert lifecycle):

ryll.example.com {
    reverse_proxy 127.0.0.1:8080
}

That is the entire config for a publicly-reachable deployment with an A record pointing at the host. Caddy talks to Let's Encrypt automatically.

nginx (operator manages the cert separately):

proxy_pass http://127.0.0.1:8080;

A full nginx server block follows the standard proxy_pass + ssl_certificate / ssl_certificate_key pattern.

WebRTC media is not proxied

ICE candidates emitted by ryll point at ryll's host and port directly. The browser opens UDP flows to that endpoint — they never go through the reverse proxy. The proxy carries only the HTTP signalling page and the POST /offer request.

Consequences:

  • ryll's UDP ports must be reachable from the browser. By default ryll enumerates the host's non-loopback network interface addresses and binds one ephemeral UDP socket per address; the OS assigns the actual port per socket (typically from 32768–60999 on Linux), so a firewall rule written against the default has to open that whole range. --web-media-port 41000 pins the port instead, and one static rule covers it. The pinned port applies to every bound address, and a port already in use fails the connection loudly rather than falling back to an ephemeral one — a silent fallback would leave a session the firewall rule no longer matches.
  • --web-host does not select the media addresses; --web-media-addr does. --web-host controls only the HTTP/HTTPS signalling listener (GET /, POST /offer). With no --web-media-addr, ryll binds and advertises a candidate for every non-loopback interface address, so a host with both a public IP and a private LAN IP tells the browser about both. Pass --web-media-addr — repeatably, with an address or an interface name — to narrow that:

    ryll --web session.vv \ --web-media-addr eth0 \ --web-media-port 41000

Naming addresses explicitly also overrides the default exclusions, which is how a loopback-only host is served (--web-media-addr 127.0.0.1). The two exclusions that cannot be overridden are the unspecified addresses (0.0.0.0, ::) and zoneless fe80::/10: neither can become an ICE candidate a browser will use, so ryll refuses them at startup rather than at the first viewer.

Troubleshooting

Page loads, video stays black for >10 seconds

Likely causes:

  • ICE failure — UDP between the browser and ryll is blocked. Open the browser DevTools console and check the RTCPeerConnection connection state. If it is stuck on connecting, ICE negotiation has not completed. Fix: ensure ryll's UDP port range is reachable from the browser host (firewall / security-group rules). If ryll is behind a reverse proxy, see the callout in the Reverse-proxy fallback section above.

  • Encoder didn't startRTCPeerConnection reached connected but no frames arrived. Check ryll's stderr for encoder errors. The encoder requests a keyframe on the Connected transition; the first frame may take up to ~1 second. If no frame arrives after 10 seconds, the encoder task is wedged — restart ryll and file a bug.

Video is soft or blurry

Most often the guest never resized, so the browser is scaling a smaller desktop up to fill the window. Check what the encoder is actually running at:

web: encoder restarted at 1024x768@30fps

and compare it to the browser window. In the browser console, [ryll] viewport sent: W x H says the viewport message went out; [ryll] viewport deferred means the control channel was not open yet and it will be re-sent when it opens. Server-side, run ryll with --verbose and look for:

web inputs: viewport WxH

If that never appears, the guest is not being told to resize — the usual cause is no spice-vdagent in the guest, since the resize is delivered as a VDAgentMonitorsConfig message.

Two known limits, neither of which is a fault to chase:

  • The viewport is sent once per connection. Resizing or maximising the browser window mid-session leaves the guest at the resolution it was given when the datachannel opened, and the browser goes back to upscaling. Reload the page to resize the guest.
  • Sizes are in CSS pixels. On a HiDPI display the guest is asked for fewer pixels than the panel physically has, so the image is scaled up by the device pixel ratio no matter what the guest does.

Odd sizes are rounded down by one pixel before the guest is asked, because H.264 cannot encode an odd dimension. A one-pixel border is not what "blurry" looks like.

The guest pointer does not move, or lands in the wrong place

Check the negotiated mouse mode in ryll's log:

main: mouse mode=2 (client (absolute)), supported_modes=3

supported_modes=1 means the guest is not running spice-vdagent, so the SPICE server cannot offer client mode and the session stays relative. ryll handles both, but a guest with no agent also has no absolute pointing device, so the pointer is driven by deltas and cannot be warped to a position.

If the pointer stops responding entirely part-way through a session, look for:

inputs: N consecutive pointer moves dropped ...

That is the ack window wedged — the server acknowledges only the pointer messages it consumes, so a client sending the form the server did not negotiate fills the window once and drops everything after it.

In server mode specifically, expect the guest pointer and your own to drift apart over a session. The browser reports absolute positions and ryll converts consecutive ones into deltas, but the guest then applies its own pointer acceleration to those deltas, so the two diverge — and there is no warp in server mode to re-synchronise them. The same conversion means that once your pointer reaches the edge of the video element, no further movement is reported in that direction and the guest pointer stops even though the guest desktop has room. Both go away with Pointer Lock, which the web frontend does not implement yet; installing spice-vdagent in the guest avoids the whole class of problem by getting you client mode. Moving your pointer back to the middle of the window and continuing is the workaround.

Firefox: no cursor, and mouse mode never changes (fixed)

On releases before this fix, Firefox showed the guest video and accepted input, but no cursor overlay ever appeared, the mouse mode never switched to client (absolute), and the "no H.264 codec" panel never rendered. Chrome and Chromium were unaffected.

Every one of those is a message the server pushes down the control datachannel, and app.js was reading them without setting binaryType on the channel. The W3C default is 'blob', which fails the decode in the message handler; Chromium defaults to 'arraybuffer' in defiance of the spec, which is why only Firefox saw it. Nothing appeared in either the browser console or ryll's log — the failure was swallowed silently.

The fix is in the served app.js, so an already-open tab keeps the broken copy until it is hard-reloaded (Ctrl-Shift-R). Input from browser to server always worked, so the session looks alive throughout.

No audio, video works

Likely causes:

  • Browser autoplay policy — the <video> element is muted by default to satisfy autoplay rules. Click the volume button on the page to enable audio.

  • PCM-only SPICE server — ryll does Opus passthrough only in MVP. If the SPICE server negotiated PCM playback (no Opus), ryll logs a warning and audio will be silent until a future PCM→Opus encoder lands.

"Click to reconnect" loop

The browser retries automatically five times with exponential backoff, then shows a manual button. If the button appears every time you reconnect, check ryll's logs for:

bridge reaper: bridge died, reaping

If this line is absent, the reaper task may not be running or the bridge is not reaching a terminal state. Restart ryll and file a bug with the full log.

High CPU when no browser is connected

This should not happen. The bridge reaper drops the H.264 encoder when the browser disconnects, so CPU usage returns to near-idle. If you observe sustained high CPU with no active browser session, check that the reaper task is reaching the dead-bridge signal in the logs. If it is absent, file a bug with ryll version and log.

Every /offer returns 500 on a host with no network

By default --web needs at least one non-loopback interface address, even when you are browsing from the same machine. WebRTC binds its own UDP sockets and advertises their addresses as ICE candidates; loopback is excluded by default because a candidate a remote browser cannot reach is worse than a clear failure. On a host with networking down, or a network-isolated container, WebrtcBridge::new has nothing to bind and fails the request rather than handing the browser an answer it can never connect to.

Two fixes, depending on what you are doing:

  • Browsing from the same host: --web-media-addr 127.0.0.1. Naming loopback explicitly overrides the default exclusion. Candidates then work only for a browser on that host, which is exactly the case this covers.
  • Otherwise: connect any interface, including a bridge or a VPN tunnel.

The error distinguishes the two ways this happens. "No bindable network interface" means the host offered nothing usable; "no media bind address matched" means --web-media-addr named something this host does not have — check the interface name against ip addr.

Cert load errors at startup

ryll prints a clear error chain on cert-load failure, for example:

Error: loading --web TLS cert/key from /etc/ryll/tls/cert.pem /
/etc/ryll/tls/key.pem: ...

Common causes and fixes:

  • File permissions — the ryll process must be able to read both files. Fix:

    chown ryll:ryll cert.pem key.pem chmod 0600 key.pem

  • Malformed PEM — the file is not valid PEM. Re-export the cert/key from your CA or regenerate with openssl.

  • Mismatched cert/key — the public key in the cert does not match the private key. Verify they were generated together.

Browser shows cert warning

The certificate is self-signed or the browser does not trust the issuing CA. Options:

  • Use mkcert (see Cert recipes), which installs its CA into the system trust store automatically.
  • Install your internal CA's root cert into the browser's trust store.
  • Accept the browser warning for one-off / diagnostic access (the media path is still DTLS-SRTP encrypted).

Ctrl-C ignored (historic)

Old ryll builds had a race where Ctrl-C was delivered before the axum server was ready to drain. The with_graceful_shutdown / Handle::graceful_shutdown path fixed it. If you see this on a current ryll build, file a bug; otherwise, update.

Security note

ryll supports native HTTPS via --web-tls-cert / --web-tls-key — this is the recommended deployment for any traffic that crosses a network you do not fully control. Plain HTTP is acceptable only for loopback-only (--web-host 127.0.0.1, the default) or fully-trusted-LAN deployments where the URL token is the only sensitive material on the wire and you control all endpoints.

In all cases, the WebRTC media path (audio and video) is encrypted at the protocol level by DTLS-SRTP, independent of whether the signalling page is served over HTTPS.

Service mode

For long-lived deployments, run ryll under systemd so it restarts automatically on failure and logs go to the journal.

A reference unit file is at examples/ryll-web.service. Copy it to /etc/systemd/system/ryll-web.service, then:

systemctl daemon-reload
systemctl enable --now ryll-web

User and group

Create a dedicated unprivileged account:

useradd -r -s /usr/sbin/nologin ryll

The unit runs as User=ryll Group=ryll.

EnvironmentFile

The unit reads /etc/ryll/web.env so you can tune all parameters without touching the unit file. Create it with owner root:ryll, mode 0640:

install -d -o root -g ryll -m 750 /etc/ryll
install -o root -g ryll -m 640 /dev/null /etc/ryll/web.env

Example /etc/ryll/web.env:

VV_FILE=/etc/ryll/session.vv
WEB_HOST=0.0.0.0
WEB_PORT=8443
WEB_TLS_CERT=/etc/ryll/tls/cert.pem
WEB_TLS_KEY=/etc/ryll/tls/key.pem

The .vv file should be readable only by the ryll user:

install -o ryll -g ryll -m 600 /dev/null /etc/ryll/session.vv

Cert file permissions

The TLS key must be readable by the ryll user:

chown ryll:ryll /etc/ryll/tls/cert.pem /etc/ryll/tls/key.pem
chmod 0600 /etc/ryll/tls/key.pem

Extracting the per-launch URL

ryll prints its URL with the per-launch token directly to stdout (not via the tracing pipeline, so the token never reaches journald or log aggregators). Under systemd, stdout is captured in the journal only if the unit uses StandardOutput=journal. With the default StandardOutput=inherit the URL goes to the terminal where you launched the service. To read it from the journal when it is captured there:

journalctl -u ryll-web -n 50 --no-pager \
    | grep -oE 'https?://[^ ]+token=[^ ]+' | tail -1

The URL includes the token and is valid until the service restarts.

Graceful shutdown

KillSignal=SIGTERM causes systemctl stop ryll-web to send SIGTERM. This engages the graceful-shutdown path (with_graceful_shutdown / Handle::graceful_shutdown), which drains in-flight HTTP requests and tears down any active WebRTC bridge cleanly. TimeoutStopSec=10s is a generous ceiling; normal shutdown completes within ~5 seconds.

Cert rotation (MVP)

ryll does not support inline cert reload. To rotate a certificate:

# Install new cert/key into /etc/ryll/tls/, then:
systemctl restart ryll-web

The URL token changes on each restart. Extract the new URL from the journal using the recipe above.

Hardening note

ProtectSystem=strict + ReadOnlyPaths=/etc/ryll prevents writes outside the declared paths. If you use --log-file to write logs to disk, add ReadWritePaths=/var/log/ryll (or your chosen path) to the unit's [Service] section to relax the restriction.

CI smoke test

tools/web-smoke.sh runs on every Linux PR in CI. It launches ryll --web with a stub .vv file (pointing at a non-existent SPICE server on a local nc listener), waits 3 seconds to verify the process has not exited prematurely, sends SIGTERM, and asserts that ryll exits cleanly within 5 seconds. This catches regressions in HTTP-server startup, rustls provider install, and SIGTERM handling without requiring a real SPICE session.

macOS and Windows CI builds verify the --web dependencies link correctly but do not run the smoke test (runtime smoke is Linux-only for the MVP; see portability).

Project status

The --web mode ships end-to-end: display, audio, inputs, cursor, reconnect, CI packaging, native TLS, and operator documentation. See the web frontend plan for the development history.

📝 Report an issue with this page