# 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. | | `--http-listen=` | 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=` | 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=` | `./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. | The matching flag on **hbbr** is `--ws-listen=` (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=` | 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 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":}`. 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":}`. 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/` 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 `