The Users and Devices tables had `overflow-hidden` on the wrapper div for
clean rounded corners. That same clipping was hiding the bottom half of
the per-row action menu (a `<details>`/`<summary>` popover absolutely
positioned inside the last cell). Removing `overflow-hidden` lets the
dropdown extend past the table edge — the popover already has its own
border + shadow, so the loss in corner aesthetics is negligible.
The other read-only tables (audit, recordings, oidc, address_books) keep
`overflow-hidden` since they don't host popovers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A web admin UI for the rustdesk-server, mounted at /admin/* on the
existing HTTP API listener. Single-binary deploy preserved — the two
HTML files live in admin_ui/ and are pulled into the binary via
include_str! at build time, so there's nothing extra to ship.
================================================================================
Architecture
================================================================================
- Stack: HTMX 1.9 + Tailwind play CDN. No SPA, no Node toolchain. Pages
are server-rendered HTML fragments returned by Rust handlers via
Html<String>; the index.html shell uses hx-get to drop a fragment into
the main pane and hx-push-url for back-button history.
- Auth: same Bearer-token table the API uses. The dashboard log-in form
POSTs username + password (+ optional TOTP) to /admin/login; on success
the server mints a token and pins it in an HttpOnly + SameSite=Strict
cookie (`rd_admin_session`). The AuthedUser extractor was extended to
accept either the Authorization: Bearer header (curl, desktop client)
OR the session cookie (browser).
- Embedding: src/api/admin/mod.rs has `include_str!("../../../admin_ui/index.html")`
+ login.html. No tower_http::ServeDir wildcard — we ran into axum 0.5
routing conflicts between literal /admin/login routes and an /admin/*
catch-all, so each HTML file is its own explicit route.
================================================================================
M5a — foundation
================================================================================
Files:
admin_ui/index.html page shell + sidebar + HTMX + 401-bounces-to-login
admin_ui/login.html credentials + TOTP form, posts to /admin/login
src/api/admin/mod.rs router + include_str! + Cache-Control: no-cache
src/api/admin/auth.rs /admin/login POST (form-encoded), /admin/logout POST
src/api/admin/me.rs sidebar fragment ("Signed in as <name>")
src/api/middleware.rs `AuthedUser` now reads either Bearer OR cookie
src/api/state.rs `admin_ui_dir` (informational; UI is embedded)
src/main.rs --admin-ui-dir flag (empty disables the dashboard)
The login flow asks for TOTP transparently in the same form when the
target user has a secret enrolled, so the dashboard inherits the TOTP
gate from the API auth surface for free.
================================================================================
M5b — full CRUD pages
================================================================================
- Users (src/api/admin/pages/users.rs) — list, create, password reset,
toggle admin / status, TOTP enroll / unenroll, delete. TOTP enroll
surfaces the secret + otpauth URL once, on a dismissible banner above
the table.
- Devices (devices.rs) — list with hostname/OS/last-heartbeat/conn count,
force-disconnect (queues `heartbeat_commands` row consumed at the next
/api/heartbeat tick), force-sysinfo refresh.
- Device groups (groups.rs) — list / create / delete / add member /
remove member. Per-group section, with an add-member dropdown of users
not yet in the group.
- Strategies (strategies.rs) — list / create / edit config_options /
delete. config_options is validated as a JSON object on the server side
before persist; bad JSON is reflected to the page with a friendly
error notice.
- Address books (address_books.rs) — read-only overview of all books
with owner, kind (personal / shared badge), peer count, GUID.
- OIDC providers (oidc.rs) — read-only list of what's configured. Editing
remains operator-side via --oidc-config TOML or direct SQL.
================================================================================
M5c — audit + recordings browsers
================================================================================
- Audit log (audit.rs) — three tabs (Connections / File transfers /
Alarms), each capped at the latest 200 rows. Tab pills are HTMX links
with hx-get + hx-target="#main" so the tab switch is a single fetch.
- Recordings (recordings.rs) — read-only list with peer / size / state /
start / finish. Streaming download is a follow-up; for now operators
pull files from --recording-dir directly.
================================================================================
DB methods added
================================================================================
- Users: users_list_all, user_set_status, user_set_admin,
user_set_password, user_delete, user_has_totp,
raw_update_user_email
- Devices: devices_list_all, device_sysinfo_get_conns,
heartbeat_command_queue (also used elsewhere; surfaced)
- Groups: device_groups_list_all, device_group_members,
device_group_create, device_group_delete,
device_group_add_member, device_group_remove_member
- Strategy: strategies_list_all, strategy_create,
strategy_update_config, strategy_delete
- Audit: audit_conn_list, audit_file_list, audit_alarm_list
- Misc: ab_list_all_with_owner, recordings_list
All use the runtime sqlx::query("...") form (matching the project-wide
convention) so the SQLite compile-time-check macros don't require these
new tables to pre-exist in the dev DB.
================================================================================
Conventions enforced
================================================================================
- Every page handler gates on require_admin(&AuthedUser) — non-admin
users get an HTTP 403 + JSON envelope, which the SPA shell catches and
bounces back to the login form.
- HTML fragments are produced via `format!`-with-named-args; html_escape
is centralized in src/api/admin/pages/shared.rs and applied to every
user-supplied string before it lands in the DOM.
- All mutations return either the updated table fragment OR
notice_html(kind, msg) + the table — same pattern across pages, so
HTMX swap targets stay simple (always #region innerHTML).
- Cookie carries no path restriction so it also authorizes /api/* calls
the dashboard might want to make from the browser; HttpOnly +
SameSite=Strict mitigates XSS / CSRF; Max-Age tracks ApiConfig's
session_ttl_secs (30 days).
================================================================================
Verification
================================================================================
1. cargo build --release — clean.
2. End-to-end smoke test:
- /admin/ serves index.html (4406 bytes), /admin/login.html serves
login.html (2598 bytes).
- POST /admin/login with valid creds returns 200 + Set-Cookie
`rd_admin_session=…; HttpOnly; Path=/; SameSite=Strict; Max-Age=…`.
- All eight /admin/pages/* fragments return 200 with cookie.
- Users CRUD round-trip: create alice → toggle admin → disable →
reset password → enroll TOTP (32-char secret displayed once) →
unenroll → delete; self-action guard rejects suicide deletes.
- Groups CRUD: create engineering → add alice as member → SQL
confirms the row.
- Strategies: valid JSON accepted, invalid JSON rejected with a
friendly notice.
- Audit tabs: all three render 200; empty-state messages appear when
no rows.
- /admin/logout clears the cookie; subsequent /admin/me returns 401.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the M4 plan. When `OPTION_USE_RAW_TCP_FOR_API=Y` (typical in
locked-down networks where direct HTTPS to port 21114 is blocked), the
client wraps every /api/* request in an HttpProxyRequest protobuf and
ships it over the already-encrypted rendezvous TCP channel. We now decode
those messages on hbbs and dispatch them through the *same* axum Router
the HTTPS listener uses — so every existing handler (login, AB, audit,
TOTP, OIDC, devices/cli, plugin-sign, …) is reachable through this path
with zero per-route plumbing.
Components
==========
- libs/hbb_common (submodule, pro-features-httpproxy branch): backports
HeaderEntry / HttpProxyRequest / HttpProxyResponse + union tags 27/28
from upstream @87b11a7 onto our pinned @83419b6. Proto-only — the rest
of hbb_common is unchanged so we keep the tokio 1.x / axum 0.5 / pinned
reqwest fork intact (a full submodule bump risked breaking those).
- src/api/http_proxy.rs: the dispatch shim. Holds a `Mutex<Option<Router>>`
populated by `api::serve` before the HTTPS listener starts, builds an
`http::Request<Body>` from the proto fields (sanitizing hop-by-hop
headers, defaulting Content-Type: application/json), runs it through
`router.oneshot(req)`, and serializes the response into HttpProxyResponse.
Tower added as a direct dep with the `util` feature for ServiceExt.
- src/api/mod.rs: pub mod http_proxy; install_router(app.clone()) before
axum::Server::bind to share the router.
- src/rendezvous_server.rs::handle_tcp: new match arm right before the
catch-all that decodes HttpProxyRequest and replies with an
HttpProxyResponse via the existing Sink::TcpStream(..., Encrypt) path.
The reply is automatically secretbox-sealed by `send_to_sink`, so the
end-to-end channel is encrypted symmetrically with secure_tcp.
- examples/http_proxy_test.rs: end-to-end smoke test that opens a TCP
connection, walks the secure_tcp handshake by hand (read server's
signed box pubkey, derive symmetric key, send sealed reply), then
ships an HttpProxyRequest GET /api/login-options and verifies the
response is 200 + ["account"]. Used as the validation gate.
New crate deps
==============
- tower = "0.4" (features = ["util"]) — for ServiceExt::oneshot
- http-body = "0.4" — for the Body trait import in dispatch
Verification
============
1. cargo build --release — clean.
2. examples/http_proxy_test against a fresh hbbs:
[ok] secure_tcp handshake complete
[ok] sent HttpProxyRequest GET /api/login-options
[ok] response status = 200
[ok] response body = ["account"]
[pass] full HTTP-over-rendezvous round trip verified
3. hbbs log confirms the secure_tcp handshake completed and the dispatch
went through the standard axum router.
Notes on cherry-pick vs submodule bump
======================================
The plan flagged the bump as the riskiest M4 item because newer
hbb_common pulls newer tokio that breaks axum 0.5. The proto-only cherry
pick keeps everything stable; the upstream-divergence cost is one extra
commit in the hbb_common submodule that we own.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the rustdesk-server up to feature parity with RustDesk Server Pro for
the API surface the desktop client expects (CONSOLE_API.md). Implemented as
an in-process axum router mounted by hbbs alongside its existing
rendezvous + relay TCP/UDP/WS listeners; everything persists in the existing
SQLx + SQLite database via additional CREATE TABLE IF NOT EXISTS migrations.
================================================================================
M1 — Auth foundation + heartbeat + sysinfo
================================================================================
- New tables: users, tokens, device_sysinfo.
- Endpoints: HEAD+GET /api/login-options, POST /api/login, POST /api/logout,
POST /api/currentUser, POST /api/heartbeat, POST /api/sysinfo_ver,
POST /api/sysinfo.
- Bearer-token auth: tokens are 32 random bytes (base64url); only the
sha256 of the token is stored. `tokens.last_used_at`/`expires_at` slide
forward on every authenticated request (30-day TTL by default).
- Bcrypt-cost-10 password hashing, always wrapped in
tokio::task::spawn_blocking to keep the runtime responsive.
- New CLI flags --http-port, --bootstrap-admin-username,
--bootstrap-admin-password.
- Heartbeat returns the `sysinfo: true` flag on first contact and after
cfg.sysinfo_ver bumps; sysinfo upload returns the bare-string body
("SYSINFO_UPDATED" / "ID_NOT_FOUND") the client expects.
================================================================================
M2 — Address book, device groups, accessible peers
================================================================================
- New tables: address_books, address_book_shares, address_book_peers,
address_book_tags, address_book_peer_tags, device_groups,
device_group_members. Soft-ALTER adds device_sysinfo.user_id (the
binding from a device to its enrolled user, set by /api/login).
- Endpoints: POST /api/ab/settings, POST /api/ab/personal,
POST /api/ab/shared/profiles, POST /api/ab/peers, POST /api/ab/tags/{guid},
POST /api/ab/peer/add/{guid}, PUT /api/ab/peer/update/{guid},
DELETE /api/ab/peer/{guid}, POST /api/ab/tag/add/{guid},
PUT /api/ab/tag/rename/{guid}, PUT /api/ab/tag/update/{guid},
DELETE /api/ab/tag/{guid}, GET+POST /api/ab (legacy single-blob fallback),
GET /api/device-group/accessible, GET /api/users, GET /api/peers.
- Share-rule enforcement (1=read, 2=read/write, 3=full) at the top of every
AB mutation. Owners are full; other rules come from
address_book_shares (direct or via device_group). Rejection is HTTP 200 +
{"error":"read-only"} so the client doesn't yank the session.
- New CLI flags --ab-legacy-mode, --ab-max-peers-per-book.
- Action endpoints (peer add/update/delete, tag CRUD) return HTTP 200 with
EMPTY body on success — matches the Flutter _jsonDecodeActionResp at
ab_model.dart:2002 which treats {} as the literal error string "null".
================================================================================
M3 — Audit, recording, strategy push
================================================================================
- New tables: audit_conn (PK guid echoed back to client),
audit_file, audit_alarm, recordings, strategies, strategy_assignments,
heartbeat_commands.
- Endpoints: POST /api/audit/conn (returns {"guid":"..."}),
POST /api/audit/file, POST /api/audit/alarm, PUT /api/audit (note update),
POST /api/record?type={new|part|tail|remove}.
- Recording uploader: filesystem state machine under --recording-dir;
filenames sanitized to a single Normal path component to block traversal;
`tail` writes the first ≤1024 bytes at offset 0 after all `part` chunks.
- Heartbeat extended to:
* resolve a per-peer strategy (peer > device-group > user, highest
priority wins) and emit `strategy.config_options` + `extra` +
`modified_at`.
* read-and-delete heartbeat_commands rows so an admin can queue
`disconnect: [conn_id]` or force `sysinfo: true` via SQL and have it
delivered on the next 15-second tick.
- New CLI flags --recording-dir (default ./recordings),
--recording-max-size-mb, --audit-retention-days.
================================================================================
secure_tcp on the rendezvous TCP listener (M3 polish)
================================================================================
A logged-in client conditionally calls secure_tcp() on its TCP rendezvous
connection (src/client.rs:427-431, gated on `key && token` both non-empty).
OSS hbbs had no KeyExchange handler at all on TCP rendezvous, so the
client's secure_tcp_impl read timed out with "Failed to secure tcp:
deadline has elapsed". Added:
- A try_secure_tcp_handshake helper that, on every accepted TCP connection,
generates an ephemeral box keypair, signs the box public key with the
server's Ed25519 sk (already loaded for relay-response signing), sends
KeyExchange, then waits 5s for the client's reply.
- Reply is KeyExchange[client_box_pk, sealed_sym_key] -> decrypt the
sealed key, install Encrypt on both halves of the stream.
- Reply is any other RendezvousMessage -> buffer it and replay through
the normal handle_tcp dispatcher (plain-mode clients filter unsolicited
KeyExchange via get_next_nonkeyexchange_msg, so our preceding KX is
harmless).
- Reply never comes (timeout) -> fall through to plain mode.
- Sink::TcpStream now carries an Option<Encrypt>; outgoing writes are
sealed when keyed. Symmetric Encrypt is cloned for inbound (`dec`) and
outbound (`enc`) so the two directions track independent counters.
================================================================================
M4 — Advanced auth (TOTP, email-code, OIDC), CLI assign, plugin signing
================================================================================
- New tables: user_totp_secrets, pending_tfa_challenges,
pending_email_codes, oidc_providers, oidc_sessions. Soft-ALTER adds
users.oidc_subject.
- /api/login extended:
* type:"account" (existing) — issues an `tfa_check` challenge (5-min
nonce in `secret`) when the user has TOTP enrolled.
* type:"tfa_code" — verifies the nonce + the 6-digit TOTP code against
user_totp_secrets.secret_b32.
* type:"email_code" — passwordless. First leg mints a 6-digit code and
sends it via SMTP (or logs to stdout when --smtp-host is empty);
second leg verifies. Brute-force capped at 5 attempts per code, then
the row is purged.
- /api/oidc/auth + GET /oidc/callback + GET /api/oidc/auth-query implement
the standard OAuth2 authorization-code flow with userinfo. Discovery via
<issuer>/.well-known/openid-configuration with an in-memory cache.
--oidc-config TOML upserts providers at startup; --public-base-url builds
the redirect_uri.
- New endpoints: POST /api/2fa/enroll (admin-only, returns secret_b32 +
otpauth_url), POST /api/2fa/unenroll, POST /api/devices/cli (used by
`rustdesk --assign`; binds device to user, ensures device-group, adds
AB entry, attaches peer-scoped strategy), POST /lic/web/api/plugin-sign
(Ed25519 over the request body using the same id_ed25519 secret).
- /api/login-options is now dynamic: returns ["account"], plus "email_code"
when SMTP or ALLOW_DEV_EMAIL_CODE is set, plus an "oidc/<name>" entry
per enabled provider in oidc_providers.
- New CLI flags --smtp-host, --smtp-port, --smtp-user, --smtp-pass,
--smtp-from, --smtp-tls, --public-base-url, --oidc-config.
- New crate deps: tokio (fs/io-util features), totp-rs, lettre (rustls +
builder + smtp-transport, no defaults), toml.
================================================================================
Code organization
================================================================================
- src/api/ axum router + shared state + error envelope
├── ab/ address book endpoints (settings/profiles/peers/
│ tags/legacy/rules)
├── audit/ conn/file/alarm/note
├── oidc/ providers/discovery/auth/callback/poll
├── record/ storage state machine + handler
├── strategy/ resolver wrapper around DB
├── auth.rs login/logout/currentUser
├── devices_cli.rs /api/devices/cli
├── email.rs SMTP transport (lettre) + dev-mode stdout fallback
├── error.rs ApiError enum -> HTTP 200/401/403/404 + JSON envelope
├── groups.rs /api/device-group/accessible
├── heartbeat.rs /api/heartbeat
├── middleware.rs AuthedUser extractor (Bearer -> sha256 -> token row)
├── pagination.rs Page<T> + PageQuery
├── peers.rs /api/peers
├── plugin_sign.rs /lic/web/api/plugin-sign
├── state.rs AppState + ApiConfig (incl. EmailConfig)
├── sysinfo.rs /api/sysinfo, /api/sysinfo_ver
├── twofa.rs /api/2fa/enroll, /unenroll
└── users.rs UserPayload + /api/users + bcrypt helpers
================================================================================
Conventions enforced throughout
================================================================================
- All new SQL uses the runtime sqlx::query("...") form (NOT the query!
macro) so first-time builds don't require DATABASE_URL to point at a DB
containing the new tables.
- Soft-ALTER helper (try_alter) swallows "duplicate column name" errors so
schema migrations are idempotent across re-runs and existing-DB upgrades.
- Bcrypt compares always via spawn_blocking.
- Tokens (Bearer access_token, TFA challenge nonce, OIDC poll handle) are
always 24-32 random bytes from sodiumoxide::randombytes; the Bearer is
stored only as its sha256.
- Constant-time hash comparison for email codes.
- Action endpoints return HTTP 200 with empty body on success; HTTP 200 +
{"error": "..."} for business errors so the client doesn't get logged
out; 401 only from the auth middleware.
Tested end-to-end via curl + a stock RustDesk client (M1-M2 verified
against two laptops; M3 verified against the strategy-push and
force-disconnect paths; M4 verified via direct flow tests + a mock IdP for
OIDC). Stock client connect now works whether the user is signed in or
not (the secure_tcp regression that blocked logged-in connect is fixed).
The remaining piece on the M4 plan — HttpProxyRequest, the TCP-over-
rendezvous fallback for clients with OPTION_USE_RAW_TCP_FOR_API=Y — is
gated on bumping the OSS server's vendored hbb_common to a commit that
includes proto tags 27 and 28. That work lives on a separate branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* debian/changelog more like the first two
Added "Who and When" lines, added empty lines as separator.
The time stamps where retrieved from the git commit log.
All entries look now like:
rustdesk-server (1.1.7) UNRELEASED; urgency=medium
* ipv6 support
-- rustdesk <info@rustdesk.com> Wed, 11 Jan 2023 11:27:00 +0800
rustdesk-server (1.1.6) UNRELEASED; urgency=medium
* Initial release
-- open-trade <info@rustdesk.com> Fri, 15 Jul 2022 12:27:27 +0200
* debian/changelog: reformat a date stamp
The "wrong format" was discovered by Lintian.
* Update README.md
* make hbbs first everywhere
* Update README.md
* Fix link
* dockerhub to Docker Hub; Suggest user use ghcr if can't access Docker Hub
* Add `
* Add Debian 12