# 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=` | `21116` | TCP/UDP rendezvous port. | | `--rendezvous-servers=` | unset | Peer rendezvous servers (comma-separated). | | `--relay-servers=` | unset | Default relay hosts handed to clients. | | `--rmem=` | platform default | UDP recv buffer size. Bump along with `net.core.rmem_max`. | | `--mask=` | unset | LAN mask (e.g. `192.168.0.0/16`) used to flag local connections. | | `--key=` | 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=` | `21114` | HTTP API port (`/api/*`) and admin dashboard (`/admin/*`). `0` disables both. | | `--admin-ui-dir=` | `./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=` | 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=` | 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=` | 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=` | `off` | When `on`, `/api/ab/personal` returns 404. Forces clients into the legacy single-blob AB mode. | | `--ab-max-peers-per-book=` | `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=` | `./recordings` | Root for `/api/record` uploads. One subdirectory per peer. | | `--recording-max-size-mb=` | 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=` | `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=` | 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=` | `587` | | | `--smtp-user=` | unset | Omit for unauthenticated relays. | | `--smtp-pass=` | unset | | | `--smtp-from=` | `noreply@` | From: header. | | `--smtp-tls=` | `on` | STARTTLS on the SMTP transport. | ### OIDC | Flag | Purpose | |---|---| | `--oidc-config=` | 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=` | **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/` 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/` 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/`, `/api/login-options` # advertises `oidc/`). 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 `/.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**: `/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 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":}`. 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/` 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 `