Caught up the docs to match what the dashboard actually does. Four spots
were stale enough to be misleading.
- TOTP / 2FA section rewritten. The doc still claimed admins enrolled
TOTP from the Users action menu, but that button was removed when
TOTP enrollment moved to the self-service profile page (two-step
with QR + 6-digit confirmation; nothing written to user_totp_secrets
until the user proves they have a working authenticator). Admins can
disable a user's TOTP but can no longer enroll on someone's behalf.
Also called out that OIDC-linked users skip local TOTP — their MFA
lives at the IdP.
- Admin dashboard URLs table was missing nine routes that exist
today: /admin/assets/{tailwindcss,htmx.min}.js (vendored CDN
assets), /admin/pages/profile + four sub-routes (self-service
profile flow), /admin/connect/:peer_id, and the two web-client SPA
asset routes. Updated the Users-page row to mention the inline
edit-profile + TOTP-disable controls.
- CLI flags / HTTP API & dashboard table now lists --http-listen and
--ws-listen (they previously only appeared inside the nginx
subsection — discoverability matters when an operator scans the
flag tables looking for what's available). Added a one-liner about
hbbr's matching --ws-listen flag.
- Security checklist gained a bind-flags hardening tip
(--http-listen=127.0.0.1, --ws-listen=127.0.0.1 on both daemons
when fronted by nginx) and a note about forwarding
X-Forwarded-Proto: https so the dashboard generates wss:// URLs.
Sections cross-checked and confirmed accurate as-is: OIDC walk-through
+ role sync + troubleshooting, strategies, address books, recordings,
audit retention, SMTP, web client (routes / browser reqs / codec /
HUD diagnostics / build), database / backup notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
32 KiB
RustDesk Server (hbbs) — Configuration Guide
This document covers the runtime flags exposed by hbbs, the file formats
it reads (notably oidc.toml), and the operator workflows that string
those together — bootstrap admin, OIDC sign-in, TOTP, address books,
strategies, recordings, the admin dashboard.
The matching desktop-client API surface is documented separately in the
rustdesk repo at docs/CONSOLE_API.md.
CLI flags
Pass flags directly on the command line. There is no config file for
hbbs itself (only the optional oidc.toml referenced from a flag).
Networking & rendezvous
| Flag | Default | Purpose |
|---|---|---|
--port=<NUM> |
21116 |
TCP/UDP rendezvous port. |
--rendezvous-servers=<HOSTS> |
unset | Peer rendezvous servers (comma-separated). |
--relay-servers=<HOSTS> |
unset | Default relay hosts handed to clients. |
--rmem=<BYTES> |
platform default | UDP recv buffer size. Bump along with net.core.rmem_max. |
--mask=<CIDR> |
unset | LAN mask (e.g. 192.168.0.0/16) used to flag local connections. |
--key=<B64> |
derived from id_ed25519 |
Force a specific public key; clients must match. Leave unset to auto-load id_ed25519 next to the binary. |
HTTP API & dashboard
| Flag | Default | Purpose |
|---|---|---|
--http-port=<NUM> |
21114 |
HTTP API port (/api/*) and admin dashboard (/admin/*). 0 disables both. |
--http-listen=<HOST> |
wildcard | Bind address for --http-port. Set to 127.0.0.1 (or ::1) when nginx/Caddy fronts this port for TLS so the reverse proxy can claim the public port without colliding. See "TLS deployment with nginx" below. |
--ws-listen=<HOST> |
wildcard | Bind address for the browser-facing WebSocket rendezvous port (port + 2, default 21118). Same usage pattern as --http-listen. The plain TCP/UDP rendezvous ports (21115/21116) intentionally stay on the wildcard — desktop clients don't go through the reverse proxy. |
--admin-ui-dir=<PATH> |
./admin_ui |
Hint at where the dashboard's static HTML lives. The HTML is embedded in the binary; this flag is informational. Setting it to empty (--admin-ui-dir=) disables the dashboard entirely. |
--public-base-url=<URL> |
unset | The externally-reachable HTTP base of this server, e.g. https://rustdesk.example.com:21114. Required when OIDC is enabled — used to build /oidc/callback redirect URIs. |
The matching flag on hbbr is --ws-listen=<HOST> (binds the relay's WS port, default 21119). hbbr's plain TCP relay port (21117) stays on the wildcard for desktop clients. See ./hbbr --help for the full list.
Bootstrap admin
| Flag | Purpose |
|---|---|
--bootstrap-admin-username=<USER> |
On first startup, if the users table is empty and both flags are set, insert one admin user. Subsequent restarts ignore these flags (no overwrite). |
--bootstrap-admin-password=<PASS> |
Same. Bcrypt-hashed at insert time. |
If you forget to bootstrap, hbbs logs a warning at startup ("no users in users table"); recover by either restarting with the flags or INSERT INTO users directly via sqlite3.
Address books
| Flag | Default | Purpose |
|---|---|---|
--ab-legacy-mode=<on|off> |
off |
When on, /api/ab/personal returns 404. Forces clients into the legacy single-blob AB mode. |
--ab-max-peers-per-book=<NUM> |
100 |
Surfaced via /api/ab/settings.max_peer_one_ab. Soft cap; the client uses it for UI hints. |
Recordings
| Flag | Default | Purpose |
|---|---|---|
--recording-dir=<PATH> |
./recordings |
Root for /api/record uploads. One subdirectory per peer. |
--recording-max-size-mb=<NUM> |
unset (=unlimited) | Per-file ceiling. Aborts oversized parts. |
Note: Stock OSS RustDesk clients do not upload recordings to
/api/record— the uploader'sENABLEflag atsrc/hbbs_http/record_upload.rshas no setter in OSS source. Server-side recording requires a custom client build that flips that flag. TheRecordingsadmin tab will stay empty for stock clients; the endpoint is provided for wire parity with Pro clients.
Audit retention
| Flag | Default | Purpose |
|---|---|---|
--audit-retention-days=<NUM> |
0 (=keep forever) |
Hourly task deletes audit_conn / audit_file / audit_alarm rows older than N days. |
Email-code login (/api/login with type:"email_code")
| Flag | Default | Purpose |
|---|---|---|
--smtp-host=<HOST> |
unset | If unset, codes are logged to stdout (dev mode) instead of mailed. The email_code login option is also dropped from /api/login-options until SMTP is configured. |
--smtp-port=<NUM> |
587 |
|
--smtp-user=<USER> |
unset | Omit for unauthenticated relays. |
--smtp-pass=<PASS> |
unset | |
--smtp-from=<ADDR> |
noreply@<smtp-host> |
From: header. |
--smtp-tls=<on|off> |
on |
STARTTLS on the SMTP transport. |
OIDC
| Flag | Purpose |
|---|---|
--oidc-config=<PATH> |
TOML file (see below). Providers are upserted into oidc_providers at startup. Re-run with a different file to change providers; rows missing from the new file remain in the DB but can be enabled=0'd via SQL. |
--public-base-url=<URL> |
Required if any provider is configured. Determines the redirect URI registered with the IdP. |
OIDC integration
The server speaks standard OIDC Authorization Code flow with discovery
(/.well-known/openid-configuration). Tested against Zitadel; should work
with any standards-compliant IdP (Keycloak, Auth0, Google, Okta, Authelia,
Dex, etc.).
Two entry points are wired:
- Desktop client —
/api/login-optionsadvertisesoidc/<name>per enabled provider. The Flutter login dialog renders a button per advertised name. Clicking starts the device-flow polling cycle (/api/oidc/auth→ browser →/oidc/callback→/api/oidc/auth-querypoll). - Admin dashboard —
/admin/login.htmlfetches/admin/oidc/providersand renders a "Sign in with X" button per provider. Clicking jumps the browser to/admin/login/oidc/<name>which 302-redirects to the IdP. After the IdP returns to/oidc/callback, the server detects the admin-flow sentinel and finishes by setting the dashboard session cookie + redirecting to/admin/.
oidc.toml schema
Pass via --oidc-config /path/to/oidc.toml.
[[providers]]
# Slug used in URLs (`/admin/login/oidc/<name>`, `/api/login-options`
# advertises `oidc/<name>`). Lowercase, no spaces.
name = "zitadel"
# Display label on the sign-in button.
display_name = "Sign in with Zitadel"
# Optional. Square icon URL shown next to the label (not used yet by all
# UIs; reserved for future button rendering).
# icon_url = "https://example.com/zitadel.svg"
# OIDC issuer. The server fetches `<issuer_url>/.well-known/openid-configuration`
# and caches the discovery doc in-process. Trailing slash is stripped.
issuer_url = "https://idp.example.com"
# Application credentials from the IdP.
client_id = "..."
client_secret = "..."
# Scopes requested at the authorization endpoint. Most setups want
# "openid email profile". Zitadel additionally needs the project audience
# scope to receive role claims (see Role-based admin sync below).
scopes = "openid email profile"
# Optional. If unset, computed as `<--public-base-url>/oidc/callback`.
# Override only when you reverse-proxy under a different host.
# redirect_url = "https://rustdesk.example.com/oidc/callback"
# Optional. Defaults to true.
enabled = true
# --- Role-based admin sync (optional) ---
# When `admin_role` is set, every successful sign-in via this provider
# evaluates the userinfo claim at `roles_claim` and forces the local
# user's `is_admin` to (role present in claim). Promotion AND demotion
# at the IdP propagate. Leave both unset to manage admin status manually
# from the dashboard.
# admin_role = "admin"
# roles_claim = "roles" # or e.g. "urn:zitadel:iam:org:project:roles"
oidc.toml may contain multiple [[providers]] blocks for multi-IdP setups.
Walk-through: Zitadel
In Zitadel
- Project → New project (or pick an existing one).
- New application under the project:
- Type: Web
- Authentication flow: Code (Authorization Code with client secret)
- Auth method: Basic or Post (server sends
client_id+client_secretin the form body — both modes accept that) - Redirect URIs:
<public-base-url>/oidc/callback— character-exact, including scheme. Zitadel rejectshttp://redirects on non-localhost unless dev mode is on, so use TLS in production.
- Authorizations — assign the project's roles to whichever users you want to be admins.
- Project → General: turn on "Assert Roles On Authentication" so roles flow into the userinfo response.
- Copy Client ID and Client Secret from the application's overview page.
oidc.toml
[[providers]]
name = "zitadel"
display_name = "Sign in with Zitadel"
issuer_url = "https://your-instance.zitadel.cloud"
client_id = "PASTE_FROM_ZITADEL"
client_secret = "PASTE_FROM_ZITADEL"
# `urn:zitadel:iam:org:project:id:zitadel:aud` is required for the project's
# roles to be included in the userinfo response.
scopes = "openid email profile urn:zitadel:iam:org:project:id:zitadel:aud"
admin_role = "admin"
roles_claim = "urn:zitadel:iam:org:project:roles"
hbbs flags
./hbbs --http-port 21114 \
--public-base-url 'https://rustdesk.example.com:21114' \
--oidc-config /etc/rustdesk/oidc.toml
Verify
After hbbs starts, look for:
oidc: provider "zitadel" configured
oidc: loaded 1 providers from /etc/rustdesk/oidc.toml
Then:
# 1. Provider visible to the desktop client
curl -s http://127.0.0.1:21114/api/login-options
# expect a list including "oidc/zitadel"
# 2. Provider visible to the admin dashboard
curl -s http://127.0.0.1:21114/admin/oidc/providers
# expect [{"name":"zitadel","display_name":"Sign in with Zitadel",...}]
# 3. Discovery is reachable (IdP-side)
curl -s https://your-instance.zitadel.cloud/.well-known/openid-configuration | jq .issuer
Role-based admin sync
When admin_role is set on a provider, every successful sign-in evaluates
the userinfo claim at roles_claim (defaults to "roles" if unset) and
forces users.is_admin accordingly. Promotion and demotion at the IdP
propagate on the next login.
Two claim shapes are supported:
- Object (Zitadel default at
urn:zitadel:iam:org:project:roles): role names are keys."urn:zitadel:iam:org:project:roles": { "admin": {"123": "myorg"}, "user": {"123": "myorg"} } - Array of strings (generic, common with Keycloak, Auth0 custom claims):
"roles": ["admin", "user"]
Set admin_role = "admin" and either set roles_claim to the exact claim
name (Zitadel) or omit it to default to "roles" (generic).
Sharp edge: when role-sync is configured, manually-granted admin rights in the dashboard get revoked on the next OIDC login if the role isn't present at the IdP. This is the correct contract for a single source of truth, but surprising if you forget. Manage admin status in one place at a time.
Troubleshooting OIDC
- "Sign-in complete" page in browser but desktop client stays at "Waiting account auth": usually a state mismatch between server and client. Check
hbbs.log— the poll endpoint logs every tick at INFO. If you seestatus=successlines that don't stop, suspect a wire-shape mismatch. (This was a real bug we hit and fixed; see git log foroidc envelope.) - Browser shows "identity provider returned an error": check
oidc_sessions.errorfor the row that just failed. Most common:redirect_urimismatch between Zitadel and--public-base-url. - No "Sign in with X" button in the dashboard or desktop client: check
oidc_provider_list_enabled()returns rows. If--public-base-urlis empty,/admin/oidc/providersand/api/login-optionsboth suppress OIDC entries (the redirect URI would be unbuildable). - Admin landing on the "no admin access" error after first OIDC sign-in: expected if
admin_roleisn't configured. Either configure role-sync (preferred), or have the user sign in once to create their row, then promote them on the Users page. The next OIDC sign-in resolves to that row.
TOTP / 2FA
TOTP enrollment is self-service: each user enables it for their own account from the dashboard's "My profile" page. The flow is two-step with QR confirmation, so no secret is stored until the user proves they have a working authenticator:
- Sign in to the dashboard → My profile (sidebar) → Enroll TOTP.
- Server generates a fresh secret and renders an inline SVG QR code +
the base32 secret for manual entry. Nothing is written to
user_totp_secretsat this point. - User scans the QR into an authenticator (1Password, Authy, Google Authenticator, etc.) and submits the 6-digit code shown.
- Server verifies the code against the pending secret. On match, the
secret lands in
user_totp_secrets. Wrong code re-renders the same QR with an error notice — no need to re-scan. - Removing TOTP requires the user's current password.
Admins can disable a user's TOTP from the Users page action menu (useful when a user lost their authenticator), but cannot enroll it on someone else's behalf — the user has to do that themselves so the secret never travels through admin hands. OIDC-linked accounts skip local TOTP entirely; their MFA lives at the IdP.
After enrollment, the next desktop-client login flow is:
- Username + password → server returns
{"type":"email_check","tfa_type":"tfa_check","secret":<nonce>}. - Client opens its verification-code dialog → user enters the 6-digit code → re-POSTs
/api/loginwithtype:"email_code"(yes, that's what the desktop client sends for both email and TOTP second legs),tfaCodeset,secretechoed back. - Server verifies the code against
user_totp_secrets, mints an access token, returns{"type":"access_token", ...}.
For dashboard logins, the inline form at /admin/login.html shows the TOTP field after the first password submit returns the prompt fragment.
Strategies (server-pushed config)
Strategies push config_options to peers via heartbeat replies. They are
managed entirely from the dashboard's Strategies page. Resolution
order per peer:
- Direct peer-scoped assignment (
strategy_assignments.peer_id) - Device-group assignment via the peer's owner
- User assignment
The peer's Config::get_option calls reflect the resolved values within
~15 s of any change to modified_at on the strategy row.
Address books
- Personal books are owned per-user and managed from the user's desktop client. The dashboard surfaces them read-only.
- Shared books are server-side artifacts. Create from the dashboard's Address books page → "Manage shares" → grant per-user
read/read+write/fullaccess. Clients pick up shared books on their next AB sync (~30 s).
If you set --ab-legacy-mode=on, /api/ab/personal 404s and clients fall back to the single-blob /api/ab path. Use only if a stock client misbehaves on the modern path.
Admin dashboard URLs
| Path | Auth | What |
|---|---|---|
/admin/, /admin/index.html |
none (login page redirects in JS) | Single-page shell |
/admin/login.html |
none | Sign-in form (password / TOTP / OIDC buttons) |
/admin/login |
none (POST form) | Password+TOTP submit → sets rd_admin_session cookie |
/admin/logout |
cookie | Clears cookie |
/admin/me |
cookie | Sidebar's logged-in-as widget |
/admin/assets/tailwindcss.js |
none | Vendored Tailwind 3.4.16 Play CDN. Long-cache. |
/admin/assets/htmx.min.js |
none | Vendored htmx.org 1.9.10. Long-cache. |
/admin/oidc/providers |
none | JSON list of enabled providers, used by login.html |
/admin/login/oidc/:name |
none | Starts admin OIDC flow (302s to IdP) |
/admin/pages/users |
cookie + admin | Users page fragment (incl. inline edit-profile / password-reset / TOTP-disable per row) |
/admin/pages/devices |
cookie + admin | Devices (incl. delete) |
/admin/pages/groups |
cookie + admin | Device groups |
/admin/pages/strategies |
cookie + admin | Strategy management |
/admin/pages/address-books |
cookie + admin | Personal + shared books |
/admin/pages/oidc |
cookie + admin | Read-only OIDC provider listing |
/admin/pages/audit |
cookie + admin | Audit log browser |
/admin/pages/recordings |
cookie + admin | Recording file listing |
/admin/pages/deploy |
cookie + admin | --config blob + renamed-installer generator |
/admin/pages/profile |
cookie | Self-service profile (display name, email, password, TOTP enroll/remove). Available to all signed-in users — no admin gate. |
/admin/pages/profile/update-info |
cookie (POST) | Display name + email update |
/admin/pages/profile/change-password |
cookie (POST) | Requires current password; refused for OIDC-linked accounts |
/admin/pages/profile/totp/{start,confirm,remove} |
cookie (POST) | Two-step QR enroll, plus password-gated removal |
/admin/connect/:peer_id |
cookie + admin | Web-client SPA shell — opens a browser-based remote-control session in a new tab |
/admin/connect/assets/bundle.{js,css} |
cookie + admin | Compiled web-client SPA bundle |
The session cookie (rd_admin_session) is HttpOnly + SameSite=Strict.
The middleware accepts the same cookie or Authorization: Bearer …,
so the same auth covers /api/* for the desktop client and /admin/*
for the dashboard with no separate session model.
Web client
Admins can remote-control any peer directly from the browser — no desktop
RustDesk install required. The Devices page row dropdown surfaces a
Connect button that opens /admin/connect/<peer_id> in a new tab.
The page is a TypeScript SPA bundled into hbbs (include_bytes! from
web_client/dist/), so there's no separate process or service to run.
Routes
| Route | Auth | Purpose |
|---|---|---|
/admin/connect/:peer_id |
cookie + admin | Server-rendered HTML wrapper that injects per-request config (rendezvous host, relay host, server pk, peer id, admin display name) into a <script id="custom-config"> tag, then loads bundle.js. |
/admin/connect/assets/bundle.js |
cookie + admin | The compiled SPA. ~535 KB, ~75 KB gzipped. |
/admin/connect/assets/bundle.css |
cookie + admin | Minimal dark theme + reconnect overlay. |
How it talks to peers
The browser opens WebSockets directly to the existing rendezvous
(hbbs:21118, the ws_port) and relay (hbbr:21119, port + 2)
sockets. The wire shape — protobuf RendezvousMessage + secretbox-
encrypted Message stream — is identical to what the desktop client
uses; the SPA is just a from-scratch reimplementation of the protocol
talking through WS frames instead of TCP.
Browsers can't do UDP NAT-punching, so the SPA always sets force_relay = true in PunchHoleRequest and follows the RelayResponse branch
unconditionally. Direct peer-to-peer is never attempted.
Browser requirements
- Secure context — page must be served over HTTPS or
http://localhost. WebCodecsVideoDecoder/AudioDecoderare gated to secure contexts. Plainhttp://lan-ip:21114will fail; either use TLS in front of hbbs or dossh -L 21114:localhost:21114 hbbs-hostand connect viahttp://localhost:21114. - Browser version — Chrome 94+, Edge 94+, Firefox 130+, Safari 16.4+. Older browsers lack WebCodecs and will throw "VideoDecoder unavailable" on session start.
- Clipboard permission — Firefox refuses
navigator.clipboard.readText()by default, so admin → host paste is silently no-op there. Host → admin paste works (it useswriteText, which only needs a recent click). Chrome/Safari prompt once.
Network requirements
- The relay host advertised to clients (
--relay-servers=<HOSTS>on hbbs) must resolve and be reachable from the end-user's browser on port 21119. The relay is what carries the actual session bytes — if a user's browser can't openws://<relay-host>:21119/, the session dies after the rendezvous step. A common gotcha: setting--relay-servers=hbbr-internal.localworks for desktop clients on the LAN but breaks for browsers off-LAN. - Audit rows are written under the admin's cookie via the existing
/api/audit/connendpoint; no new server endpoint.
TLS deployment with nginx
The dashboard and the two browser-facing WebSocket ports (21118 = rendezvous, 21119 = relay) all need TLS in front of them when accessed from a browser, since the page is served over HTTPS and mixed-content ws:// is blocked. nginx is the canonical setup; Caddy works similarly with much less ceremony.
Port plan
| Public port | TLS terminator | Backed by |
|---|---|---|
| 443/tcp | nginx | 127.0.0.1:21114 (hbbs HTTP API + dashboard) |
| 21118/tcp | nginx | 127.0.0.1:21118 (hbbs WS rendezvous) |
| 21119/tcp | nginx | 127.0.0.1:21119 (hbbr WS relay) |
| 21115/tcp | — | hbbs (NAT test, plain TCP, desktop clients only) |
| 21116/tcp+udp | — | hbbs (main rendezvous, desktop clients only) |
| 21117/tcp | — | hbbr (relay for desktop clients, plain TCP) |
Desktop clients use plain TCP/UDP on 21115 / 21116 / 21117 and bring their own framing + secretbox encryption — no TLS needed. Only browsers go through nginx.
Pin hbbs / hbbr to localhost
By default both binaries bind every port to the wildcard ([::]), which collides with nginx wanting to take the same public port. Use the bind flags so nginx can claim the public port and forward to localhost:
# hbbs — desktop-client ports stay on the wildcard, browser ports go local
./hbbs --port 21116 \
--http-port 21114 --http-listen 127.0.0.1 \
--ws-listen 127.0.0.1 \
--relay-servers rd.example.com \
--public-base-url https://rd.example.com \
# ... rest of your flags
# hbbr — TCP relay (21117) stays public, WS relay (21119) goes local
./hbbr --port 21117 --ws-listen 127.0.0.1
After restart, ss -tlnp should show:
LISTEN 127.0.0.1:21114 <-- hbbs HTTP, fronted by nginx 443
LISTEN 127.0.0.1:21118 <-- hbbs WS, fronted by nginx 21118
LISTEN 127.0.0.1:21119 <-- hbbr WS, fronted by nginx 21119
LISTEN 0.0.0.0:21115 <-- hbbs NAT test (public)
LISTEN 0.0.0.0:21116 <-- hbbs rendezvous tcp (public)
LISTEN 0.0.0.0:21117 <-- hbbr relay tcp (public)
# plus 0.0.0.0:21116/udp
nginx site config
Three server { } blocks. The dashboard one is normal HTTP/2 + reverse-proxy; the two WS blocks need the Upgrade/Connection headers and a long proxy_read_timeout so idle web sessions don't get severed mid-screen-share.
# /etc/nginx/sites-available/rustdesk
# Helper for WS upgrade — referenced by both WS blocks below.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 1. Dashboard + admin API on 443
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name rd.example.com;
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
# The dashboard streams audit logs / device events via plain HTTP today
# but we still need WS-upgrade pass-through here for the /admin/connect/
# SPA's own asset requests are HTTP, but if you ever proxy ws under
# /ws/* in the future, this stays correct.
location / {
proxy_pass http://127.0.0.1:21114;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
# Force HTTPS — drop this block if you don't need port 80 at all.
server {
listen 80;
listen [::]:80;
server_name rd.example.com;
return 301 https://$host$request_uri;
}
# 2. WSS rendezvous on 21118
server {
listen 21118 ssl http2;
listen [::]:21118 ssl http2;
server_name rd.example.com;
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:21118;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Web sessions can sit idle on the rendezvous WS; bump the read
# timeout so nginx doesn't reset the connection before the relay
# leg finishes negotiating.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
# 3. WSS relay on 21119
server {
listen 21119 ssl http2;
listen [::]:21119 ssl http2;
server_name rd.example.com;
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:21119;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Relay carries the live session for as long as the user is
# remote-controlling. Pick a value larger than the longest
# session you expect (24h here).
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
The Let's Encrypt cert covers all three ports — same hostname, just different listen ports. With certbot's nginx plugin the cert was already obtained for the 443 block; the other two blocks just point at the same files.
Open the firewall for 80, 443, 21115, 21116, 21117, 21118, 21119 (TCP) and 21116 (UDP). Everything else can stay closed.
Verify after reload: in DevTools → Network, wss://rd.example.com:21118/ and wss://rd.example.com:21119/ should each show status 101 Switching Protocols.
Common failure modes:
ERR_SSL_PROTOCOL_ERRORon 21118 or 21119 — nginx isn't terminating TLS on that port. Check the listener block + cert paths.ERR_CONNECTION_REFUSED— firewall is blocking the public port, OR nginx itself isn't listening on it (checkss -tlnp).502 Bad Gatewayat the dashboard — hbbs isn't running, or--http-listendoesn't match what nginx isproxy_passing to.- WS upgrade hangs / 200 instead of 101 —
Upgrade/Connectionheaders aren't being forwarded. The$connection_upgrademap at the top of the config is what makes this work; without it,proxy_set_header Connection "upgrade"would also work but breaks plain HTTP requests.
Features
| Feature | Status |
|---|---|
| Video (VP8 / VP9 / H.264) | ✅ — preference is VP8 (lightest software encoder; H.264 path also implemented for hosts with hwcodec) |
| Audio (Opus) | ✅ |
| Mouse + keyboard input | ✅ — Legacy keyboard mode; Translate mode silently drops Unicode/ControlKey payloads on the host |
| Text clipboard sync (both directions) | ✅ — handles both single-format Clipboard and MultiClipboards (peers ≥ 1.3.0) |
| Multi-monitor switching | ✅ — SwitchDisplay + CaptureDisplays two-message dance for hosts ≥ 1.2.4; mouse coords offset by display's virtual-desktop origin |
| Image quality presets (Low/Balanced/Best) | ✅ |
| Custom FPS (15/30/60) | ✅ — host caps at 30 unless allow_more_fps is advertised |
| Mute toggle | ✅ — also tells host to stop encoding audio (saves CPU + relay bandwidth) |
| Ctrl+Alt+Del | ✅ — Windows hosts only (server-side #[cfg(windows)]) |
| Auto-reconnect on transient drops | ✅ — up to 10 attempts, exponential backoff 1s → 30s, dim overlay during retry, user options re-applied on success |
| File transfer | ❌ deferred — separate FileAction family, double the surface area |
| AV1 / H.265 decode | ❌ deferred — VP8/VP9/H.264 covers the common cases |
| IME / compose input | ❌ deferred — needs compositionend + KeyEvent.seq |
| Touch gestures | ❌ deferred |
| Cursor sprite rendering | ❌ deferred — host-side cursor visible in the video; we don't draw a separate one |
Codec selection
The SPA advertises VP8/VP9/H.264 decode and prefers VP8. The host's
codec picker (libs/scrap/src/common/codec.rs) honours the preference
when the host has the matching encoder available, else falls back to
its "auto" path (H.265 → H.264 → AV1/VP9/VP8).
VP8 is the default because it's the cheapest software encoder; on a
host without hwcodec, VP9 software-encode caps screen sharing at single
digits FPS, while VP8 keeps headroom for the screen-capture pipeline.
On a host with hwcodec H.264 (nvenc/qsv on most Windows boxes), flip
the preference in web_client/src/transport/session.ts to
PreferCodec.H264 — the SPA's H.264 path parses SPS to derive the
correct avc1.PPCCLL codec string for WebCodecs.
Performance gotchas
The HUD shows three live numbers: recv (frames/sec arriving from the
relay), dec (frames/sec the browser decoded), draw (frames/sec
painted to canvas). Use them to localise FPS issues:
- All three low → host is encoding slowly. Either CPU-bound (no
hwcodec, VP9 chosen) or QoS-throttled by host based on
TestDelayRTT measurements. recvhigh,declow → browser fell back to software decode. Check the codec string at the end of the HUD line; mismatched profile/level (e.g.avc1.42E01Efor a high-profile stream) forces software.dechigh,drawlow → main thread is overwhelmed (very rare with hardware decode).
Building the bundle
The bundle is committed under web_client/dist/ so a Cargo build
doesn't need a Node toolchain. To regenerate after editing the SPA:
cd web_client
npm install # one-time
npm run build # → dist/bundle.{js,css}
git add dist/
cd .. && cargo build --release -p hbbs
To regenerate protobuf bindings after a libs/hbb_common/protos/*.proto
change:
cd web_client && npm run protogen
Database
SQLite, file db_v2.sqlite3 in hbbs's working directory. Tables created
at startup with CREATE TABLE IF NOT EXISTS; column additions use
ALTER TABLE ADD COLUMN guarded by a duplicate-column-name swallower
(SQLite < 3.35 lacks ADD COLUMN IF NOT EXISTS).
Backup is a plain file copy while hbbs is stopped, or sqlite3 db_v2.sqlite3 .dump > backup.sql while running. There is no
multi-instance HA; run a single hbbs against a single SQLite file.
Security checklist before exposing to the internet
- TLS in front of
--http-portand the WebSocket ports (Caddy / nginx / Traefik). Required for OIDC redirect URIs and for the web client (browsers block mixedws://). See "TLS deployment with nginx" for a worked config. - Pin browser-facing ports to localhost when a reverse proxy is in front:
--http-listen=127.0.0.1on hbbs,--ws-listen=127.0.0.1on both hbbs and hbbr. Keeps the plain-HTTP / plain-ws surface unreachable from the public internet — the proxy is the only path in. --public-base-urlset to the externally reachable URL, including the scheme.--bootstrap-admin-passwordrotated immediately after first login (Users page → reset password, or via the admin's own "My profile" page).--key/id_ed25519not committed to source control. Treat the private key as a deploy secret.- Audit retention (
--audit-retention-days) set to a value that matches your data-retention policy. - If running behind a reverse proxy: forward the original
Host:header so OIDC redirect-URI validation matches, and forwardX-Forwarded-Proto: httpsso the dashboard generateswss://URLs.