Files
rustdesk-server/docs/CONFIGURATION.md
T
mike 4308a2f112 feat: M6 web client QoL — clipboard, multi-monitor, quality, reconnect, H.264, docs
Builds on the M6a-g MVP (d07e98e) with five user-facing features that
take the in-browser remote-control client from "demo toy" to "workable
for daily use", plus user-facing documentation.

M6h — Text clipboard sync (both directions)
- Host → browser via Clipboard{format: Text} → navigator.clipboard.writeText.
- Browser → host via Cmd-V/Ctrl-V keydown intercept → navigator.clipboard
  .readText → Message{clipboard}, sent BEFORE the V keystroke so the
  host's paste hotkey lands on the freshly-synced text.
- Handles both single-format Clipboard (older peers) and MultiClipboards
  wrapper (peers ≥ 1.3.0; gated by clipboard.rs:is_support_multi_clipboard).
- Switched printable-with-modifier hotkeys (Ctrl-C etc.) from Unicode to
  Chr payload — host's process_unicode does key_sequence(char) which
  ignores modifiers, breaking copy/cut; process_chr respects them.
- Firefox refuses navigator.clipboard.readText() by default — accepted as
  a known browser limitation, host → browser direction works regardless.

M6i — Multi-monitor switching
- HUD picker shown when peer_info.displays > 1.
- On change: SwitchDisplay + CaptureDisplays{set:[idx]} two-message dance
  — required for clients ≥ 1.2.4 (we send "1.4.0"). Without the follow-
  up, switch_display_to leaves both video services subscribed and
  switching display 0 → 1 → 0 doesn't restore display 0.
- Mouse coords offset by the active display's virtual-desktop origin
  (DisplayInfo.x, .y). Without this, clicks on display 2 landed on
  display 1 because both share canvas (0,0) but only display 1 has
  origin (0,0) in virtual-desktop space.

M6j — Quality / FPS / mute controls
- Image quality preset (Low/Balanced/Best) → Misc{option: {image_quality}}.
- Custom FPS (15/30/60) → Misc{option: {custom_fps}}; host caps at 30
  unless allow_more_fps is advertised.
- Mute toggle additionally sends Misc{option: {disable_audio: Yes/No}}
  so the peer stops encoding audio while muted (saves CPU + bandwidth).

M6k — Auto-reconnect on transient drops
- session.recv() throw → reconnect with exponential backoff: 1s, 2s, 4s,
  8s, 16s, 30s, 30s, capped at 30s, max 10 attempts.
- Dim overlay sits on top of the canvas during retry; canvas keeps
  last-known frame for visual continuity.
- Auth errors (password/signature) bail immediately — no point retrying.
- User options (mute, image_quality, custom_fps, current display)
  re-applied to host on each successful reconnect, since host treats
  every session as fresh and resets to defaults.
- Architecture: `session` is a let-binding mutated on reconnect; HUD
  button closures read it at click-time so they automatically retarget.
  Input modules (mouse/keyboard/clipboard) get a Proxy that forwards
  method calls to whatever session is current — avoids re-binding
  window/canvas listeners on each reconnect.

M6l — H.264 video decode (Annex-B + SPS-derived codec string)
- decode/bitstream.ts: iterate Annex-B NAL units, derive avc1.PPCCLL
  from the keyframe's inline SPS (host's hwcodec defaults to high
  profile; a hardcoded baseline string would make WebCodecs refuse the
  stream).
- Defer H.264 decoder configure until first keyframe arrives.
- VP9 codec string corrected from level 1.0 (vp09.00.10.08) to level
  5.0 (vp09.00.50.08) — wrong level was probably forcing software
  decode in some browsers.
- Default prefer flipped to VP8 (cheapest software encoder; H.264 path
  stays implemented for hosts with hwcodec/nvenc).

M6m — docs/CONFIGURATION.md "Web client" section: routes, browser
matrix, network requirements (relay reachability + reverse-proxy WS
upgrade), feature status table, codec selection rationale, the
recv/dec/draw HUD diagnostic, build commands.

Bundle: 535 KB / ~75 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:43:23 +02:00

22 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.
--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.

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's ENABLE flag at src/hbbs_http/record_upload.rs has no setter in OSS source. Server-side recording requires a custom client build that flips that flag. The Recordings admin 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:

  1. Desktop client/api/login-options advertises oidc/<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-query poll).
  2. Admin dashboard/admin/login.html fetches /admin/oidc/providers and 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

  1. Project → New project (or pick an existing one).
  2. 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_secret in the form body — both modes accept that)
    • Redirect URIs: <public-base-url>/oidc/callback — character-exact, including scheme. Zitadel rejects http:// redirects on non-localhost unless dev mode is on, so use TLS in production.
  3. Authorizations — assign the project's roles to whichever users you want to be admins.
  4. Project → General: turn on "Assert Roles On Authentication" so roles flow into the userinfo response.
  5. 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 see status=success lines that don't stop, suspect a wire-shape mismatch. (This was a real bug we hit and fixed; see git log for oidc envelope.)
  • Browser shows "identity provider returned an error": check oidc_sessions.error for the row that just failed. Most common: redirect_uri mismatch 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-url is empty, /admin/oidc/providers and /api/login-options both 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_role isn'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

Per-user TOTP is enrolled from the dashboard:

  1. Sign in as an admin → Users page.
  2. Pick a user → action menu → Enroll TOTP.
  3. Scan the QR code into an authenticator (1Password, Authy, Google Authenticator, etc.). The secret is shown once and stored in user_totp_secrets.

After enrollment, the next desktop-client login flow is:

  1. Username + password → server returns {"type":"email_check","tfa_type":"tfa_check","secret":<nonce>}.
  2. Client opens its verification-code dialog → user enters the 6-digit code → re-POSTs /api/login with type:"email_code" (yes, that's what the desktop client sends for both email and TOTP second legs), tfaCode set, secret echoed back.
  3. 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:

  1. Direct peer-scoped assignment (strategy_assignments.peer_id)
  2. Device-group assignment via the peer's owner
  3. 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 / full access. 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/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
/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

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. WebCodecs VideoDecoder / AudioDecoder are gated to secure contexts. Plain http://lan-ip:21114 will fail; either use TLS in front of hbbs or do ssh -L 21114:localhost:21114 hbbs-host and connect via http://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 uses writeText, 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 open ws://<relay-host>:21119/, the session dies after the rendezvous step. A common gotcha: setting --relay-servers=hbbr-internal.local works for desktop clients on the LAN but breaks for browsers off-LAN.
  • Reverse proxies must forward the WebSocket upgrade for both 21118 (rendezvous) and 21119 (relay). Caddy: reverse_proxy /ws/* hbbs:21118 plus equivalent for 21119; nginx: the standard proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; block.
  • Audit rows are written under the admin's cookie via the existing /api/audit/conn endpoint; no new server endpoint.

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 TestDelay RTT measurements.
  • recv high, dec low → browser fell back to software decode. Check the codec string at the end of the HUD line; mismatched profile/level (e.g. avc1.42E01E for a high-profile stream) forces software.
  • dec high, draw low → 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-port (Caddy / nginx / Traefik). Required for OIDC redirect URIs in production.
  • --public-base-url set to the externally reachable URL, including the scheme.
  • --bootstrap-admin-password rotated immediately after first login (Users page → reset password).
  • --key / id_ed25519 not 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.