Files
rustdesk-server/src/api/ab/peers.rs
T
mike 3e89d61566 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>
2026-05-01 19:07:01 +02:00

199 lines
6.0 KiB
Rust

use crate::api::ab::rules::{enforce, Rule};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::pagination::Page;
use crate::api::state::AppState;
use crate::database::AbPeerInsert;
use axum::extract::{Extension, Path, Query};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
/// `serde_urlencoded` (axum's query decoder) does not honour
/// `#[serde(flatten)]`, so the pagination fields are spelled out inline.
#[derive(Debug, Deserialize)]
pub struct AbQuery {
/// guid sent in the query string for `/api/ab/peers?ab=<guid>`.
pub ab: String,
#[serde(default = "default_current")]
pub current: i64,
#[serde(default = "default_page_size", rename = "pageSize")]
pub page_size: i64,
}
fn default_current() -> i64 {
1
}
fn default_page_size() -> i64 {
100
}
impl AbQuery {
fn offset(&self) -> i64 {
(self.current.max(1) - 1) * self.limit()
}
fn limit(&self) -> i64 {
self.page_size.clamp(1, 1000)
}
}
/// `POST /api/ab/peers?ab=<guid>` — paginated peer list inside an AB.
/// Wire shape matches the Flutter `Peer` decoder; only fields documented in
/// CONSOLE_API.md §4.4 are surfaced.
#[derive(Debug, Serialize)]
struct PeerOut {
id: String,
alias: String,
tags: Vec<String>,
note: String,
#[serde(skip_serializing_if = "String::is_empty")]
password: String,
#[serde(skip_serializing_if = "String::is_empty")]
hash: String,
#[serde(skip_serializing_if = "String::is_empty")]
username: String,
#[serde(skip_serializing_if = "String::is_empty")]
hostname: String,
#[serde(skip_serializing_if = "String::is_empty")]
platform: String,
}
pub async fn list(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Query(q): Query<AbQuery>,
) -> Result<impl IntoResponse, ApiError> {
enforce(&state, user.user_id, &q.ab, Rule::Read).await?;
let (total, rows) = state
.db
.ab_list_peers(&q.ab, q.offset(), q.limit())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let data: Vec<PeerOut> = rows
.into_iter()
.map(|r| PeerOut {
id: r.id,
alias: r.alias,
tags: r.tags,
note: r.note,
password: r.password,
hash: r.hash,
username: r.username,
hostname: r.hostname,
platform: r.platform,
})
.collect();
Ok((StatusCode::OK, Json(Page { total, data })))
}
#[derive(Debug, Deserialize)]
pub struct PeerAddBody {
pub id: String,
#[serde(default)]
pub alias: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub hash: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub platform: Option<String>,
}
/// `POST /api/ab/peer/add/{guid}` — insert one peer. **Returns HTTP 200
/// with an empty body on success**, or `{"error":"..."}` JSON body on failure
/// (also HTTP 200). The Flutter `_jsonDecodeActionResp` at
/// flutter/lib/models/ab_model.dart:2002 treats *any* non-empty success body
/// as an error to surface — including `{}` (which produces the literal string
/// "null"), so action endpoints must reply with truly empty bodies.
pub async fn add(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(body): Json<PeerAddBody>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
if body.id.is_empty() {
return Err(ApiError::BadRequest("id required".into()));
}
let max = state.cfg.ab_max_peers_per_book;
let count = state
.db
.ab_count_peers(&guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if count >= max {
return Err(ApiError::Forbidden("exceed_max_devices".into()));
}
state
.db
.ab_peer_insert(
&guid,
AbPeerInsert {
id: &body.id,
alias: body.alias.as_deref(),
note: body.note.as_deref(),
password: body.password.as_deref(),
hash: body.hash.as_deref(),
username: body.username.as_deref(),
hostname: body.hostname.as_deref(),
platform: body.platform.as_deref(),
},
body.tags.as_deref(),
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
/// `PUT /api/ab/peer/update/{guid}` — partial update. Body always carries
/// `id`, plus any subset of mutable fields. Empty success body, see `add`.
pub async fn update(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(body): Json<Value>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
let id = body
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::BadRequest("id required".into()))?;
let updated = state
.db
.ab_peer_partial_update(&guid, id, &body)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !updated {
return Err(ApiError::Forbidden("peer not found".into()));
}
Ok(StatusCode::OK)
}
/// `DELETE /api/ab/peer/{guid}` — body is a JSON array of peer IDs. Empty
/// success body, see `add`.
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(ids): Json<Vec<String>>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
state
.db
.ab_peers_delete(&guid, &ids)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}