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

---

## 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) |
| `/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.
