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:
2026-05-01 19:07:01 +02:00
parent 815c728837
commit 3e89d61566
42 changed files with 5848 additions and 15 deletions
+361
View File
@@ -0,0 +1,361 @@
use crate::api::email;
use crate::api::error::ApiError;
use crate::api::middleware::{sha256_token, AuthedUser};
use crate::api::state::AppState;
use crate::api::users::{verify_password, UserPayload};
use crate::database::UserRow;
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use totp_rs::{Algorithm, Secret, TOTP};
const EMAIL_CODE_TTL_SECS: i64 = 600;
/// `LoginRequest` mirrors the Flutter client at
/// flutter/lib/common/hbbs/hbbs.dart:133. M1 only consults `username`,
/// `password`, and `type`; the other fields are tolerated for forward-compat.
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub uuid: Option<String>,
#[serde(default, rename = "type")]
pub kind: Option<String>,
#[serde(default, rename = "deviceInfo")]
pub device_info: Option<Value>,
// Tolerated, ignored in M1:
#[serde(default)]
pub auto_login: Option<bool>,
#[serde(default, rename = "verificationCode")]
pub verification_code: Option<String>,
#[serde(default, rename = "tfaCode")]
pub tfa_code: Option<String>,
#[serde(default)]
pub secret: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct IdUuidBody {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub uuid: Option<String>,
}
pub async fn login_options_head() -> StatusCode {
StatusCode::OK
}
pub async fn login_options(Extension(state): Extension<Arc<AppState>>) -> Json<Vec<String>> {
// Static base set from config (account / email_code), plus a dynamic
// `oidc/<name>` entry per enabled provider in the DB. Recomputed per
// request so adding a provider via SQL takes effect without a restart.
let mut out = state.cfg.login_options.clone();
if !state.cfg.public_base_url.is_empty() {
if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
for p in providers {
out.push(format!("oidc/{}", p.name));
}
}
}
Json(out)
}
const TFA_CHALLENGE_TTL_SECS: i64 = 300;
pub async fn login(
Extension(state): Extension<Arc<AppState>>,
Json(req): Json<LoginRequest>,
) -> Result<Json<Value>, ApiError> {
// Branch on `type`. Empty / "account" is the password path; "tfa_code"
// is the second leg of a TOTP challenge issued earlier in this same
// dance. Reject anything else for now — M4 will add email_code etc.
let kind = req.kind.as_deref().unwrap_or("account");
match kind {
"account" | "" => login_account(state, req).await,
"tfa_code" => login_tfa_code(state, req).await,
"email_code" => login_email_code(state, req).await,
other => Err(ApiError::BadRequest(format!(
"unsupported login type: {}",
other
))),
}
}
/// Two-leg passwordless login by email. Leg 1 (no `verificationCode`) mints a
/// fresh 6-digit code and emails it to the user (or logs to stdout when SMTP
/// is unconfigured). Leg 2 (with `verificationCode`) verifies the code,
/// consumes it, and issues an access token.
async fn login_email_code(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
// The Flutter client passes the email/username in the `username` field;
// accept it either as a literal email or as a username we can map to one.
let identifier = req
.username
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("username (email) required".into()))?;
let user = resolve_user_by_identifier(&state, identifier).await?;
let email = if !user.email.is_empty() {
user.email.clone()
} else if user.username.contains('@') {
// Operator bootstraps users with email-as-username — accept that.
user.username.clone()
} else {
return Err(ApiError::BadRequest(
"user has no email address on file".into(),
));
};
if let Some(code) = req
.verification_code
.as_deref()
.filter(|s| !s.is_empty())
{
// Leg 2: verify.
let supplied_hash = sodiumoxide::crypto::hash::sha256::hash(code.as_bytes())
.as_ref()
.to_vec();
let ok = state
.db
.email_code_verify(&email, &supplied_hash)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !ok {
return Err(ApiError::BadCredentials);
}
if user.status == 0 {
return Err(ApiError::AccountDisabled);
}
if user.status == -1 {
return Err(ApiError::Unverified);
}
return issue_session(&state, &req, &user).await;
}
// Leg 1: mint + send a fresh code.
let (code, code_hash) = email::mint_code();
state
.db
.email_code_create(&email, &code_hash, EMAIL_CODE_TTL_SECS)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if let Err(e) = email::send_login_code(state.cfg.email.as_ref(), &email, &code).await {
return Err(ApiError::Internal(e));
}
Ok(Json(json!({ "type": "email_check" })))
}
async fn resolve_user_by_identifier(
state: &AppState,
identifier: &str,
) -> Result<UserRow, ApiError> {
if identifier.contains('@') {
if let Some(u) = state
.db
.user_find_by_email(identifier)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
return Ok(u);
}
}
state
.db
.user_find_by_username(identifier)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::BadCredentials)
}
async fn login_account(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
let username = req
.username
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("username required".into()))?;
let password = req
.password
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("password required".into()))?;
let user = state
.db
.user_find_by_username(username)
.await?
.ok_or(ApiError::BadCredentials)?;
let ok = verify_password(user.password_hash.clone(), password.to_string()).await?;
if !ok {
return Err(ApiError::BadCredentials);
}
if user.status == 0 {
return Err(ApiError::AccountDisabled);
}
if user.status == -1 {
return Err(ApiError::Unverified);
}
// 2FA gate: if the user has TOTP enrolled, mint a short-lived nonce and
// tell the client we want the TOTP code in a follow-up POST. The client
// echoes the nonce back as `secret`.
if state.db.totp_get_secret(user.id).await?.is_some() {
let nonce = state
.db
.tfa_challenge_create(user.id, TFA_CHALLENGE_TTL_SECS)
.await?;
return Ok(Json(json!({
"type": "tfa_check",
"tfa_type": "totp",
"secret": nonce,
})));
}
issue_session(&state, &req, &user).await
}
async fn login_tfa_code(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
let nonce = req
.secret
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("secret required".into()))?;
let code = req
.tfa_code
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("tfaCode required".into()))?;
let user_id = state
.db
.tfa_challenge_lookup(nonce)
.await?
.ok_or_else(|| ApiError::BadRequest("invalid or expired challenge".into()))?;
let secret_b32 = state
.db
.totp_get_secret(user_id)
.await?
.ok_or_else(|| ApiError::BadRequest("TOTP not enrolled".into()))?;
if !verify_totp(&secret_b32, code)? {
// Leave the challenge row alive — operators may want short retries.
return Err(ApiError::BadCredentials);
}
state.db.tfa_challenge_consume(nonce).await?;
let user = state
.db
.user_find_by_id(user_id)
.await?
.ok_or(ApiError::Unauthorized)?;
issue_session(&state, &req, &user).await
}
/// Build and persist a fresh access token, claim the calling device, and
/// return the standard logged-in response shape. Shared by the password,
/// post-TOTP, post-email-code, and (later) post-OIDC paths.
async fn issue_session(
state: &AppState,
req: &LoginRequest,
user: &UserRow,
) -> Result<Json<Value>, ApiError> {
let token = mint_token();
let sha = sha256_token(&token);
let device_info_json = req
.device_info
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
state
.db
.token_insert(
user.id,
&sha,
req.id.as_deref().unwrap_or_default(),
req.uuid.as_deref().unwrap_or_default(),
&device_info_json,
state.cfg.session_ttl_secs,
)
.await?;
// Bind the calling device to this user so /api/peers shows it correctly.
state
.db
.device_claim(
user.id,
req.id.as_deref().unwrap_or_default(),
req.uuid.as_deref().unwrap_or_default(),
)
.await;
Ok(Json(json!({
"access_token": token,
"type": "access_token",
"user": UserPayload::from(user),
})))
}
fn verify_totp(secret_b32: &str, code: &str) -> Result<bool, ApiError> {
let secret = Secret::Encoded(secret_b32.to_string())
.to_bytes()
.map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?;
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, secret)
.map_err(|e| ApiError::Internal(format!("TOTP init: {}", e)))?;
totp.check_current(code)
.map_err(|e| ApiError::Internal(format!("TOTP check: {}", e)))
}
pub async fn current_user(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
// Body is required by the client but its content is purely advisory.
Json(_body): Json<IdUuidBody>,
) -> Result<Json<UserPayload>, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await?
.ok_or(ApiError::Unauthorized)?;
Ok(Json(UserPayload::from(&row)))
}
pub async fn logout(
Extension(state): Extension<Arc<AppState>>,
headers: axum::http::HeaderMap,
Json(_body): Json<IdUuidBody>,
) -> StatusCode {
// Best-effort: parse the bearer ourselves so a missing/invalid token still
// returns 200 (matches the client's fire-and-forget logout flow).
if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) {
if let Ok(s) = auth.to_str() {
if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) {
if !tok.is_empty() {
let sha = sha256_token(tok);
let _ = state.db.token_delete(&sha).await;
}
}
}
}
StatusCode::OK
}
pub(crate) fn mint_token() -> String {
let bytes = sodiumoxide::randombytes::randombytes(32);
base64::encode_config(bytes, base64::URL_SAFE_NO_PAD)
}