Files
rustdesk-server/docs/CONFIGURATION.md
mike 6a0b698384
build / build-linux-amd64 (push) Successful in 1m58s
Implement remote execution
2026-05-22 14:18:48 +02:00

760 lines
37 KiB
Markdown

# 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'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`.
```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`
```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
```sh
./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:
```sh
# 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.
```json
"urn:zitadel:iam:org:project:roles": {
"admin": {"123": "myorg"},
"user": {"123": "myorg"}
}
```
- **Array of strings** (generic, common with Keycloak, Auth0 custom claims):
```json
"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
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:
1. Sign in to the dashboard → **My profile** (sidebar) → **Enroll TOTP**.
2. Server generates a fresh secret and renders an inline SVG QR code +
the base32 secret for manual entry. Nothing is written to
`user_totp_secrets` at this point.
3. User scans the QR into an authenticator (1Password, Authy, Google
Authenticator, etc.) and submits the 6-digit code shown.
4. 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.
5. 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:
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.
See [STRATEGIES.md](STRATEGIES.md) for the full list of `config_options`
keys and what each one does.
---
## Remote PowerShell exec (per-peer, strategy-gated)
Admins can dispatch a PowerShell script to a managed device from the
dashboard's **Run command…** action (Devices page row menu, or directly
via `/admin/pages/devices/:peer_id/exec`). The agent runs the script as
its service account — typically LocalSystem on Windows — and the
output streams back into the dashboard within ~1 s.
This feature is **disabled by default**. To enable it for a peer (or
fleet):
1. Edit (or create) a strategy on the **Strategies** page with the JSON:
```json
{ "enable-remote-exec": "Y" }
```
(mix with whatever other strategy options you already push)
2. Assign that strategy to the peer, its device group, or its owner.
3. The peer's `Auth` column must show **Signed** — exec is refused on
`peer.managed=0` peers. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md).
All three gates (admin role, managed=1, strategy opt-in) are enforced
server-side at dispatch time. The strategy key is never pushed to the
client — it's checked on the server only and serves purely as the
authorization toggle.
Caps (defaults; live in `src/api/heartbeat.rs` and
`src/api/admin/pages/exec.rs`):
- Script size: **32 KiB** per dispatch.
- Wall clock: **5 minutes** per command; the agent kills the process
on timeout and marks the row `timed_out`.
- Output capture: **1 MiB** combined stdout+stderr; further bytes are
drained and discarded, the row gets `truncated=true`.
- One in-flight exec per peer at a time.
See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for the wire format,
authentication, and threat model. Result POSTs are mandatory-signed —
there's no legacy/unsigned path for the exec result endpoint.
---
## Agent API signing (per-peer)
`POST /api/heartbeat`, `POST /api/sysinfo`, and
`POST /api/unattended-password` are the three agent-facing endpoints
that write per-device state. Stock RustDesk and managed builds
(hello-agent) both call the first two; only managed builds use the
third. Each peer row has a `managed` flag that gates whether the
server requires a per-request Ed25519 signature on these endpoints;
everything else (`/api/peers`, `/api/ab/*`, audit, recordings, OIDC,
etc.) is unaffected. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for
the full out-of-scope list.
| `peer.managed` | Heartbeat / sysinfo behaviour |
|----------------|----------------------------------------------------------------------------------------|
| `0` (default) | Unsigned posts accepted (stock-client compatible). Signed posts still verified. |
| `1` | Signature required; unsigned posts return 401. First valid sig auto-promoted to here. |
Default is `0` after the migration, so **stock RustDesk clients are not
affected by the rollout** — they keep posting unsigned, the server keeps
accepting. The first valid signature the server sees from a peer is the
TOFU promote: that peer's `managed` flips to `1` for good, and unsigned
requests claiming that `id` are rejected from then on.
The wire format and verification details live in
[AGENT-API-AUTH.md](AGENT-API-AUTH.md). What you need to know to operate:
### Dashboard
The Devices page has a per-row **Auth** column:
- *Signed* (emerald badge) — `peer.managed = 1`. The peer's heartbeat
and sysinfo posts must carry a valid signature; spoofed unsigned
requests are rejected.
- *Unsigned* (slate badge) — `peer.managed = 0`. Legacy path. Anyone
who knows the id+uuid can post inventory and heartbeats as this
device.
The row's action menu has two new entries (mutually exclusive based on
current state):
- **Require signed API** — flips `managed` to 1 (no confirm — it
strengthens security). Useful for pre-enrolling a peer record
before the agent has booted, or for force-locking a peer if you
want to fail fast when an agent is not signing yet.
- **Allow unsigned API** — flips `managed` to 0 (confirm dialog,
because this reopens the spoofing surface). Use when a managed
agent has been uninstalled and replaced with stock RustDesk on the
same hardware.
### API
`PUT /api/peers/:id/managed` with body `{"managed": true|false}`, gated
on the `is_admin` flag of the calling session, returns
`{"ok":true,"managed":<bool>}`. Same effect as the dashboard toggle —
the dashboard handler just calls this internally after reading the
current value to avoid stale-toggle races.
### Operational notes
- **Mixed fleets are fine.** Stock and hello-agent clients can target
the same hbbs. The gate is per-peer, not per-deployment.
- **Replacing hello-agent with stock RustDesk on a device.** The
device's `peer.managed` is stuck at 1; the stock client doesn't
sign and will start getting 401s. Either re-deploy a signing build
*or* flip the peer back to Unsigned in the dashboard.
- **TLS still recommended.** Signing protects against id+uuid spoof,
not against the unsigned-by-default endpoint surface elsewhere
(`/api/login`, `/api/record`, dashboard) — those still rely on
whatever TLS termination is in front of hbbs. See *TLS deployment*
earlier in this doc.
- **Clock skew tolerance is ±5 minutes.** If a host's clock drifts
past that, heartbeat starts failing 401. Keep NTP healthy on
managed peers; the server's clock is the canonical one.
- **The replay cache lives in-memory only.** A hbbs restart clears
it. The 5-minute timestamp window bounds the worst-case replay
exposure across restarts.
---
## 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/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, force-disconnect, force-sysinfo, toggle managed-auth — see [AGENT-API-AUTH.md](AGENT-API-AUTH.md)) |
| `/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`. 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.
- Audit rows are written under the admin's cookie via the existing `/api/audit/conn` endpoint; 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:
```sh
# 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.
```nginx
# /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_ERROR`** on 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 (check `ss -tlnp`).
- **`502 Bad Gateway`** at the dashboard — hbbs isn't running, or `--http-listen` doesn't match what nginx is `proxy_pass`ing to.
- **WS upgrade hangs / 200 instead of 101** — `Upgrade` / `Connection` headers aren't being forwarded. The `$connection_upgrade` map 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 `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:
```sh
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:
```sh
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` and the WebSocket ports (Caddy / nginx / Traefik). Required for OIDC redirect URIs and for the web client (browsers block mixed `ws://`). 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.1` on hbbs, `--ws-listen=127.0.0.1` on 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-url` set to the *externally* reachable URL, including the scheme.
- `--bootstrap-admin-password` rotated immediately after first login (Users page → reset password, or via the admin's own "My profile" page).
- `--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, and forward `X-Forwarded-Proto: https` so the dashboard generates `wss://` URLs.