feat: add Pro-equivalent management API on top of OSS hbbs
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>
This commit is contained in:
Generated
+131
-3
@@ -195,6 +195,12 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base32"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.0"
|
||||
@@ -414,6 +420,12 @@ dependencies = [
|
||||
"toml 0.5.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -567,12 +579,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.3"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -668,6 +681,22 @@ version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a87260449b06739ee78d6281c68d2a0ff3e3af64a78df63d3a1aeb3c06997c8a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -1087,6 +1116,7 @@ dependencies = [
|
||||
"ipnetwork",
|
||||
"jsonwebtoken",
|
||||
"lazy_static",
|
||||
"lettre",
|
||||
"local-ip-address",
|
||||
"mac_address",
|
||||
"machine-uid 0.2.0",
|
||||
@@ -1101,7 +1131,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sodiumoxide",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml 0.7.8",
|
||||
"totp-rs",
|
||||
"tower-http",
|
||||
"tungstenite",
|
||||
"uuid",
|
||||
@@ -1163,6 +1196,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.7"
|
||||
@@ -1294,6 +1336,16 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.8.1"
|
||||
@@ -1438,6 +1490,33 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna 0.3.0",
|
||||
"mime",
|
||||
"nom 7.1.1",
|
||||
"once_cell",
|
||||
"quoted_printable",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.0",
|
||||
"socket2 0.4.4",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
"webpki-roots 0.23.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lexical-core"
|
||||
version = "0.7.6"
|
||||
@@ -2083,6 +2162,12 @@ dependencies = [
|
||||
"proc-macro2 1.0.93",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
@@ -2404,6 +2489,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.100.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3"
|
||||
dependencies = [
|
||||
"ring 0.16.20",
|
||||
"untrusted 0.7.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
@@ -2558,6 +2653,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.2"
|
||||
@@ -3141,6 +3247,19 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"sha1",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.12"
|
||||
@@ -3338,7 +3457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"idna 0.2.3",
|
||||
"matches",
|
||||
"percent-encoding",
|
||||
]
|
||||
@@ -3503,6 +3622,15 @@ dependencies = [
|
||||
"webpki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338"
|
||||
dependencies = [
|
||||
"rustls-webpki 0.100.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.25.4"
|
||||
|
||||
Reference in New Issue
Block a user