Files
rustdesk-server/src/api/devices_cli.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.8 KiB
Rust

//! `POST /api/devices/cli` — used by `rustdesk --assign --token <T> ...`
//! to enroll a freshly installed device into a tenant slot.
//!
//! Per CONSOLE_API.md §11: bearer-authenticated; the response body is plain
//! text (empty = success, non-empty = informational message). The client
//! prints "Done!" when the body is empty.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::AbPeerInsert;
use axum::extract::Extension;
use axum::http::header;
use axum::response::IntoResponse;
use axum::Json;
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct AssignBody {
pub id: String,
pub uuid: String,
#[serde(default)]
pub user_name: Option<String>,
#[serde(default)]
pub strategy_name: Option<String>,
#[serde(default)]
pub address_book_name: Option<String>,
#[serde(default)]
pub address_book_tag: Option<String>,
#[serde(default)]
pub address_book_alias: Option<String>,
#[serde(default)]
pub address_book_password: Option<String>,
#[serde(default)]
pub address_book_note: Option<String>,
#[serde(default)]
pub device_group_name: Option<String>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub device_username: Option<String>,
#[serde(default)]
pub device_name: Option<String>,
}
pub async fn assign(
Extension(state): Extension<Arc<AppState>>,
caller: AuthedUser,
Json(body): Json<AssignBody>,
) -> Result<impl IntoResponse, ApiError> {
if body.id.is_empty() || body.uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid required".into()));
}
let mut warnings: Vec<String> = vec![];
// Resolve owner. If --user_name was supplied, that's the owner; otherwise
// the caller becomes the owner (matches `rustdesk --assign` flows where
// the operator's account is the destination).
let owner = if let Some(name) = body.user_name.as_deref().filter(|s| !s.is_empty()) {
if !caller.is_admin {
return Err(ApiError::Forbidden(
"admin required to assign to another user".into(),
));
}
match state
.db
.user_find_by_username(name)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
Some(u) => u,
None => {
return Err(ApiError::BadRequest(format!(
"no such user: {}",
name
)));
}
}
} else {
state
.db
.user_find_by_id(caller.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::Unauthorized)?
};
// Bind the device to the owner (mirrors what /api/login's device_claim
// does, but here it's an admin operation rather than user-initiated).
state.db.device_claim(owner.id, &body.id, &body.uuid).await;
// Address-book entry. We always target the *owner's* personal AB.
if let Some(ab_name) = body.address_book_name.as_deref().filter(|s| !s.is_empty()) {
let _ = ab_name; // M2's get_or_create_personal ignores the name; OSS has one personal AB per user.
let ab_guid = state
.db
.ab_get_or_create_personal(owner.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let tags: Option<Vec<String>> = body
.address_book_tag
.as_deref()
.filter(|s| !s.is_empty())
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
if let Err(e) = state
.db
.ab_peer_insert(
&ab_guid,
AbPeerInsert {
id: &body.id,
alias: body.address_book_alias.as_deref(),
note: body.address_book_note.as_deref(),
password: body.address_book_password.as_deref(),
hash: None,
username: body.device_username.as_deref(),
hostname: body.device_name.as_deref(),
platform: None,
},
tags.as_deref(),
)
.await
{
// Likely a UNIQUE conflict if the peer is already in the AB;
// surface as a warning rather than failing the whole call.
warnings.push(format!("address-book entry not added: {}", e));
}
}
// Strategy assignment by name. We attach to the device directly (peer-scoped),
// which is the most-specific tier in our resolver.
if let Some(name) = body.strategy_name.as_deref().filter(|s| !s.is_empty()) {
match resolve_strategy_id(&state, name).await? {
Some(strategy_id) => {
if let Err(e) = state
.db
.strategy_assign_peer(strategy_id, &body.id)
.await
{
warnings.push(format!("strategy assignment failed: {}", e));
}
}
None => {
warnings.push(format!("strategy {:?} does not exist", name));
}
}
}
// Device-group membership: ensure the group exists, ensure the owner is a
// member. We treat the group name as the natural key per the M2 schema.
if let Some(group_name) = body.device_group_name.as_deref().filter(|s| !s.is_empty()) {
if let Err(e) = state
.db
.device_group_ensure_member(group_name, owner.id)
.await
{
warnings.push(format!("device-group assignment failed: {}", e));
}
}
// Fields we accept but don't currently persist as discrete columns. These
// travel with the next sysinfo upload anyway (note, device_username,
// device_name end up in `device_sysinfo.payload` JSON).
if body.note.as_deref().map(|s| !s.is_empty()).unwrap_or(false) {
warnings.push(
"--note is currently surfaced via sysinfo only, not persisted as a discrete field"
.into(),
);
}
let body_text = if warnings.is_empty() {
String::new()
} else {
warnings.join("\n")
};
Ok((
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
body_text,
))
}
async fn resolve_strategy_id(
state: &AppState,
name: &str,
) -> Result<Option<i64>, ApiError> {
state
.db
.strategy_find_by_name(name)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
/// Wrap the `Value` JSON the request _could_ have under `Json<Value>` if a
/// future variation needs it. Currently unused; kept for symmetry with other
/// modules that work with raw JSON in/out.
#[allow(dead_code)]
fn ignore_value(_v: Value) {}