760 lines
37 KiB
Markdown
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.
|