Implement signed API communication to improve security
build / build-linux-amd64 (push) Successful in 1m50s

This commit is contained in:
2026-05-22 12:50:42 +02:00
parent 21b25bcc1b
commit 475da0e950
12 changed files with 906 additions and 24 deletions
+77
View File
@@ -801,6 +801,83 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"Dispozitivul a fost deja șters.",
"El dispositivo ya se eliminó.",
),
"devices.col_auth" => (
"Auth",
"Auth",
"Auth",
"Auth",
"Auth",
),
"devices.auth_signed" => (
"Signed",
"Signiert",
"Signé",
"Semnat",
"Firmado",
),
"devices.auth_signed_tooltip" => (
"Heartbeat and sysinfo posts must carry a valid Ed25519 signature. Unsigned requests for this peer are rejected.",
"Heartbeat- und Sysinfo-Posts müssen eine gültige Ed25519-Signatur enthalten. Unsignierte Anfragen für dieses Gerät werden abgelehnt.",
"Les requêtes heartbeat et sysinfo doivent porter une signature Ed25519 valide. Les requêtes non signées pour ce pair sont rejetées.",
"Cererile heartbeat și sysinfo trebuie să poarte o semnătură Ed25519 validă. Cererile nesemnate pentru acest peer sunt respinse.",
"Las solicitudes heartbeat y sysinfo deben llevar una firma Ed25519 válida. Las solicitudes sin firma para este par se rechazan.",
),
"devices.auth_unsigned" => (
"Unsigned",
"Unsigniert",
"Non signé",
"Nesemnat",
"Sin firma",
),
"devices.auth_unsigned_tooltip" => (
"Legacy / stock-client path. The server accepts unsigned heartbeat and sysinfo posts identifying as this peer — any caller knowing the id+uuid could inject inventory. The first valid signature flips this to Signed automatically.",
"Legacy-/Stock-Client-Pfad. Der Server akzeptiert unsignierte Heartbeat- und Sysinfo-Posts mit dieser Peer-Identität — jeder Aufrufer, der id+uuid kennt, kann Inventar einschleusen. Die erste gültige Signatur schaltet automatisch auf Signiert.",
"Chemin client hérité / standard. Le serveur accepte des requêtes heartbeat et sysinfo non signées identifiées comme ce pair — tout appelant connaissant id+uuid peut injecter de l'inventaire. La première signature valide bascule automatiquement sur Signé.",
"Calea client legacy / standard. Serverul acceptă cereri heartbeat și sysinfo nesemnate care se identifică drept acest peer — orice apelant care cunoaște id+uuid poate injecta inventar. Prima semnătură validă comută automat la Semnat.",
"Ruta cliente heredada / estándar. El servidor acepta solicitudes heartbeat y sysinfo sin firmar identificadas como este par — cualquier llamador que conozca id+uuid puede inyectar inventario. La primera firma válida cambia automáticamente a Firmado.",
),
"devices.mark_managed" => (
"Require signed API",
"Signierte API erzwingen",
"Exiger l'API signée",
"Cere API semnat",
"Requerir API firmada",
),
"devices.mark_unsigned" => (
"Allow unsigned API",
"Unsignierte API erlauben",
"Autoriser l'API non signée",
"Permite API nesemnat",
"Permitir API sin firma",
),
"devices.confirm_managed_off" => (
"Downgrade {0} to the unsigned API path? Anyone who knows the id+uuid will again be able to post inventory and heartbeats as this device.",
"{0} auf den unsignierten API-Pfad herabstufen? Jeder, der id+uuid kennt, kann dann wieder Inventar und Heartbeats als dieses Gerät posten.",
"Rétrograder {0} vers le chemin API non signé ? Toute personne connaissant id+uuid pourra de nouveau publier de l'inventaire et des heartbeats en tant que cet appareil.",
"Retrogradați {0} la calea API nesemnat? Oricine cunoaște id+uuid va putea din nou să publice inventar și heartbeat-uri ca acest dispozitiv.",
"¿Degradar {0} a la ruta API sin firma? Cualquiera que conozca id+uuid podrá volver a publicar inventario y heartbeats como este dispositivo.",
),
"devices.managed_now_on" => (
"{0} now requires signed API requests.",
"{0} erfordert nun signierte API-Anfragen.",
"{0} exige désormais des requêtes API signées.",
"{0} cere acum cereri API semnate.",
"{0} ahora requiere solicitudes API firmadas.",
),
"devices.managed_now_off" => (
"{0} now accepts unsigned API requests.",
"{0} akzeptiert nun unsignierte API-Anfragen.",
"{0} accepte désormais des requêtes API non signées.",
"{0} acceptă acum cereri API nesemnate.",
"{0} ahora acepta solicitudes API sin firma.",
),
"devices.managed_no_peer" => (
"Peer {0} not found in the rendezvous identity table — cannot set managed flag. The agent must complete a rendezvous handshake first.",
"Peer {0} nicht in der Rendezvous-Identitätstabelle gefunden — Managed-Flag kann nicht gesetzt werden. Der Agent muss zuerst einen Rendezvous-Handshake abschließen.",
"Pair {0} introuvable dans la table d'identité rendezvous — impossible de définir l'indicateur managed. L'agent doit d'abord effectuer un handshake rendezvous.",
"Peer-ul {0} nu a fost găsit în tabelul de identitate rendezvous — nu se poate seta indicatorul managed. Agentul trebuie să finalizeze mai întâi un handshake rendezvous.",
"Par {0} no encontrado en la tabla de identidad rendezvous — no se puede establecer el indicador managed. El agente debe completar primero un apretón de manos rendezvous.",
),
"devices.back" => (
"← Back to devices",
"← Zurück zu Geräten",
+4
View File
@@ -113,6 +113,10 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/devices/:peer_id/delete",
post(pages::devices::delete),
)
.route(
"/admin/pages/devices/:peer_id/toggle-managed",
post(pages::devices::toggle_managed),
)
// Groups
.route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
+109 -1
View File
@@ -109,6 +109,56 @@ pub async fn delete(
notice_then_table(&state, lang, if ok { "ok" } else { "error" }, &msg).await
}
/// Flip `peer.managed` between 0 and 1. Same effect as calling the JSON
/// API `PUT /api/peers/:id/managed`, but rendered as an HTMX action so the
/// table refreshes in place. The handler reads the current value, flips
/// it, and writes back — this avoids a stale-toggle race where the row
/// the admin clicked on showed a stale state (e.g. TOFU just promoted it
/// in the background) and a "set to N" command would no-op silently.
pub async fn toggle_managed(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let row = state
.db
.peer_get_auth(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (_pk, was_managed) = match row {
Some(r) => r,
None => {
return notice_then_table(
&state,
lang,
"error",
&tf1(lang, "devices.managed_no_peer", &peer_id),
)
.await;
}
};
let new_value = !was_managed;
state
.db
.peer_set_managed(&peer_id, new_value)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
hbb_common::log::info!(
"admin {} set peer {} managed={} via dashboard",
admin.name,
peer_id,
new_value
);
let key = if new_value {
"devices.managed_now_on"
} else {
"devices.managed_now_off"
};
notice_then_table(&state, lang, "ok", &tf1(lang, key, &peer_id)).await
}
/// Per-device detail page: hardware / OS inventory reported by hello-agent
/// alongside the standard sysinfo (CPU/RAM/OS/hostname). Replaces the
/// devices list in `#devices-region` via HTMX; a "Back to devices" button
@@ -189,6 +239,7 @@ async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiEr
<th class="text-left font-medium px-3 py-2">{c_ver}</th>
<th class="text-left font-medium px-3 py-2">{c_last}</th>
<th class="text-left font-medium px-3 py-2">{c_conns}</th>
<th class="text-left font-medium px-3 py-2">{c_auth}</th>
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
</tr>
</thead>
@@ -202,12 +253,13 @@ async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiEr
c_ver = t(lang, "devices.col_version"),
c_last = t(lang, "devices.col_last_heartbeat"),
c_conns = t(lang, "devices.col_conns"),
c_auth = t(lang, "devices.col_auth"),
c_actions = t(lang, "common.actions"),
);
if devices.is_empty() {
let _ = write!(
s,
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
r##"<tr><td colspan="11" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
t(lang, "devices.no_devices"),
);
}
@@ -332,6 +384,57 @@ fn render_device_row(
dot = dot_class,
id = html_escape(&d.id),
);
// Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat /
// sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when
// managed=0 and the device still posts unsigned bodies. The tooltip
// gives the operator the one-line explanation so they know what
// flipping the flag will do.
let auth_cell = if d.managed {
format!(
r##"<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1 rounded border border-emerald-700/50 bg-emerald-900/30 px-2 py-0.5 text-xs text-emerald-300" title="{tt}">{label}</span>
</td>"##,
tt = html_escape(t(lang, "devices.auth_signed_tooltip")),
label = t(lang, "devices.auth_signed"),
)
} else {
format!(
r##"<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-800/40 px-2 py-0.5 text-xs text-slate-400" title="{tt}">{label}</span>
</td>"##,
tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")),
label = t(lang, "devices.auth_unsigned"),
)
};
// Auth toggle: the menu entry's label flips based on current state,
// and only the off→on transition needs no confirm (it strengthens
// security). on→off removes the signature requirement and reintroduces
// the spoofing surface, so we require a confirm on that direction.
let toggle_managed_item = if d.managed {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/toggle-managed"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="{confirm}">
{label}
</button>"##,
id = html_escape(&d.id),
confirm = html_escape(&tf1(lang, "devices.confirm_managed_off", &d.id)),
label = t(lang, "devices.mark_unsigned"),
)
} else {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/toggle-managed"
hx-target="#devices-region" hx-swap="innerHTML">
{label}
</button>"##,
id = html_escape(&d.id),
label = t(lang, "devices.mark_managed"),
)
};
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
@@ -344,6 +447,7 @@ fn render_device_row(
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
<td class="px-3 py-2 text-slate-400">{n}</td>
{auth_cell}
<td class="px-3 py-2">
<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
@@ -358,6 +462,8 @@ fn render_device_row(
{details}
</button>
<hr class="border-slate-700 my-1" />
{toggle_managed_item}
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/disconnect"
hx-target="#devices-region" hx-swap="innerHTML"
@@ -394,6 +500,8 @@ fn render_device_row(
ver = html_escape(&version_label),
last = html_escape(&d.last_heartbeat_at),
n = conn_count,
auth_cell = auth_cell,
toggle_managed_item = toggle_managed_item,
connect_web = t(lang, "devices.connect_web"),
details = t(lang, "devices.details"),
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
+184
View File
@@ -0,0 +1,184 @@
//! Ed25519-signature gate for the agent-facing HTTP API
//! (`/api/heartbeat`, `/api/sysinfo`).
//!
//! Trust root: the device's Ed25519 public key is already written into
//! `peer.pk` during the rendezvous `RegisterPk` handshake (TCP/protobuf,
//! port 21116). That handshake proves possession of the matching private key
//! to the rendezvous server — so any later HTTP request signed by the same
//! key is provably from the same device.
//!
//! Cutover: per-peer. `peer.managed = 0` (default) keeps stock-client
//! behaviour — no signature required. `managed = 1` requires a valid sig on
//! every request. The flag flips from 0→1 on the first valid signature we
//! observe (TOFU) or via the admin endpoint. It never flips back from a
//! request — only an admin can downgrade.
//!
//! Wire format (both headers required on signed requests):
//! X-RD-Device-Id: <id>
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
//! where the signed message is:
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::http::HeaderMap;
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::Arc;
const SIG_VERSION: &str = "v1";
const HEADER_ID: &str = "x-rd-device-id";
const HEADER_SIG: &str = "x-rd-signature";
const SKEW_TOLERANCE_SECS: i64 = 300;
const REPLAY_WINDOW_SECS: i64 = 600;
const REPLAY_CACHE_MAX: usize = 16_384;
/// Outcome of running the gate. The handler uses this to decide which `id`
/// to trust as the device identity:
/// - `Verified` → caller is cryptographically that device.
/// - `LegacyUnsigned` → managed=0 peer that sent no sig headers; the
/// handler may proceed but the body `id` is trusted only weakly
/// (same risk as today). The handler still calls `get_peer` to confirm
/// the id is known.
pub enum AuthOutcome {
Verified { id: String },
LegacyUnsigned,
}
lazy_static! {
/// Replay cache. Key: "<id>|<ts>|<sig_first32>". Value: expiry unix ts.
/// Small enough that the sweep-on-insert cost is negligible.
static ref REPLAY: Mutex<HashMap<String, i64>> = Mutex::new(HashMap::new());
}
pub async fn verify(
state: &Arc<AppState>,
method: &str,
path: &str,
headers: &HeaderMap,
body: &[u8],
) -> Result<AuthOutcome, ApiError> {
let sig_hdr = headers.get(HEADER_SIG).and_then(|v| v.to_str().ok());
let id_hdr = headers.get(HEADER_ID).and_then(|v| v.to_str().ok());
// No signature headers at all → legacy path. Even then we still need to
// check that the peer (if it claims an id in the body) isn't marked
// `managed=1`. The handler doesn't know the body id yet, so we defer
// the managed-check to a second call (`enforce_managed_for_id`) after
// the handler has parsed the body. Returning LegacyUnsigned here just
// means "no sig present, you must call enforce_managed_for_id next".
let (sig_hdr, id_hdr) = match (sig_hdr, id_hdr) {
(Some(s), Some(i)) if !s.is_empty() && !i.is_empty() => (s, i),
(None, None) => return Ok(AuthOutcome::LegacyUnsigned),
// Partial headers: someone tried to sign but messed up the request.
// Don't fall through to legacy — treat as an outright failure so we
// don't silently downgrade a misconfigured agent.
_ => return Err(ApiError::Unauthorized),
};
// Parse "v1.<ts>.<b64>".
let mut parts = sig_hdr.splitn(3, '.');
let ver = parts.next().unwrap_or("");
let ts_s = parts.next().unwrap_or("");
let sig_b64 = parts.next().unwrap_or("");
if ver != SIG_VERSION || ts_s.is_empty() || sig_b64.is_empty() {
return Err(ApiError::Unauthorized);
}
let ts: i64 = ts_s.parse().map_err(|_| ApiError::Unauthorized)?;
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > SKEW_TOLERANCE_SECS {
return Err(ApiError::Unauthorized);
}
let sig_bytes = base64::decode(sig_b64).map_err(|_| ApiError::Unauthorized)?;
// Replay check before the expensive crypto. The (id, ts, sig-prefix)
// tuple is unique per request from a non-broken agent.
let replay_key = {
let prefix: String = sig_b64.chars().take(32).collect();
format!("{}|{}|{}", id_hdr, ts, prefix)
};
{
let mut cache = REPLAY.lock().unwrap();
cache.retain(|_, exp| *exp > now);
if cache.contains_key(&replay_key) {
return Err(ApiError::Unauthorized);
}
if cache.len() < REPLAY_CACHE_MAX {
cache.insert(replay_key, now + REPLAY_WINDOW_SECS);
}
// If the cache is full we accept (no DoS via cache exhaustion). The
// 5-min skew window already bounds replay risk.
}
// Look up the peer's pk and managed flag in one query.
let (pk_bytes, managed) = state
.db
.peer_get_auth(id_hdr)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::Unauthorized)?;
if pk_bytes.is_empty() {
// No PK registered — rendezvous hasn't completed. Can't verify.
return Err(ApiError::Unauthorized);
}
// Build the canonical signed message:
// "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
let body_sha = sodiumoxide::crypto::hash::sha256::hash(body);
let mut msg = Vec::with_capacity(64 + method.len() + path.len());
msg.extend_from_slice(b"rd-api-v1\n");
msg.extend_from_slice(method.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(path.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(ts_s.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(body_sha.as_ref());
let pk = sodiumoxide::crypto::sign::PublicKey::from_slice(&pk_bytes)
.ok_or(ApiError::Unauthorized)?;
let sig = sodiumoxide::crypto::sign::Signature::from_bytes(&sig_bytes)
.map_err(|_| ApiError::Unauthorized)?;
if !sodiumoxide::crypto::sign::verify_detached(&sig, &msg, &pk) {
return Err(ApiError::Unauthorized);
}
// TOFU promote: first valid sig flips managed=0 → 1. After this, the
// same device can no longer fall back to the legacy unsigned path.
if !managed {
if let Err(e) = state.db.peer_set_managed(id_hdr, true).await {
hbb_common::log::warn!("peer_set_managed({}) failed: {}", id_hdr, e);
// Don't fail the request — the sig was valid, the promote is
// best-effort. Next request will retry the promote.
} else {
hbb_common::log::info!("peer {} TOFU-promoted to managed=1", id_hdr);
}
}
Ok(AuthOutcome::Verified {
id: id_hdr.to_string(),
})
}
/// Called by handlers AFTER they've parsed the body and extracted the
/// device id. Only meaningful when `verify` returned `LegacyUnsigned`.
/// Enforces: if the peer is currently managed=1, an unsigned request for
/// that id must be rejected.
pub async fn enforce_managed_for_id(
state: &Arc<AppState>,
id: &str,
) -> Result<(), ApiError> {
if id.is_empty() {
return Ok(());
}
let row = state
.db
.peer_get_auth(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
match row {
Some((_, true)) => Err(ApiError::Unauthorized),
_ => Ok(()),
}
}
+22 -2
View File
@@ -5,12 +5,18 @@
//! - `disconnect: [conn_id, ...]` — tell the client to drop those sessions,
//! - `modified_at` + `strategy` — push a config-options merge.
//!
//! Auth: none (the client identifies the device by `(id, uuid)` body fields).
//! Auth: signed agents (peer.managed=1) must carry `X-RD-Device-Id` +
//! `X-RD-Signature` headers — see `device_auth::verify`. Stock clients
//! (peer.managed=0) keep posting unsigned bodies; the first valid sig we
//! see flips the peer to managed=1 (TOFU).
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use crate::api::strategy;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -45,11 +51,25 @@ pub struct HeartbeatResp {
pub async fn heartbeat(
Extension(state): Extension<Arc<AppState>>,
Json(body): Json<HeartbeatBody>,
headers: HeaderMap,
raw: Bytes,
) -> Result<Json<HeartbeatResp>, ApiError> {
let outcome = device_auth::verify(&state, "POST", "/api/heartbeat", &headers, &raw).await?;
let body: HeartbeatBody = serde_json::from_slice(&raw)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
if body.id.is_empty() || body.uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid required".into()));
}
match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body.id != signed_id {
return Err(ApiError::Unauthorized);
}
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, &body.id).await?;
}
}
let conns_json = serde_json::to_string(&body.conns.unwrap_or_default())
.unwrap_or_else(|_| "[]".into());
+2
View File
@@ -7,6 +7,7 @@ pub mod ab;
pub mod admin;
pub mod audit;
pub mod auth;
pub mod device_auth;
pub mod devices_cli;
pub mod email;
pub mod error;
@@ -80,6 +81,7 @@ pub fn router(state: Arc<AppState>) -> Router {
)
.route("/api/users", get(users::list))
.route("/api/peers", get(peers::list))
.route("/api/peers/:id/managed", put(peers::set_managed))
// M3: audit
.route("/api/audit/conn", post(audit::conn::conn))
.route("/api/audit/file", post(audit::file::file))
+46 -2
View File
@@ -7,9 +7,9 @@ use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::pagination::{Page, PageQuery};
use crate::api::state::AppState;
use axum::extract::{Extension, Query};
use axum::extract::{Extension, Path, Query};
use axum::Json;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
@@ -64,3 +64,47 @@ pub async fn list(
.collect();
Ok(Json(Page { total, data }))
}
#[derive(Debug, Deserialize)]
pub struct SetManagedBody {
pub managed: bool,
}
/// `PUT /api/peers/:id/managed` — admin-only toggle for the signed-API gate.
/// Setting `managed=true` is also done TOFU-style by the sig-verify helper
/// on the first valid signature, so this endpoint is mainly useful for:
/// - Pre-enrolling a peer before its agent boots.
/// - Downgrading a peer back to the unsigned path after a managed agent
/// is uninstalled or replaced with stock RustDesk.
pub async fn set_managed(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(id): Path<String>,
Json(body): Json<SetManagedBody>,
) -> Result<Json<Value>, ApiError> {
if !user.is_admin {
return Err(ApiError::Forbidden("admin only".into()));
}
// Confirm the peer exists before flipping, so an admin typo doesn't
// silently create a row-not-found situation.
let row = state
.db
.peer_get_auth(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if row.is_none() {
return Err(ApiError::NotFound);
}
state
.db
.peer_set_managed(&id, body.managed)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
hbb_common::log::info!(
"admin {} set peer {} managed={}",
user.name,
id,
body.managed
);
Ok(Json(json!({ "ok": true, "managed": body.managed })))
}
+33 -6
View File
@@ -1,7 +1,9 @@
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::Json;
use axum::http::HeaderMap;
use serde_json::Value;
use std::sync::Arc;
@@ -17,9 +19,18 @@ pub async fn sysinfo_ver(Extension(state): Extension<Arc<AppState>>) -> String {
/// `==` comparison on these — do not wrap in JSON.
pub async fn sysinfo(
Extension(state): Extension<Arc<AppState>>,
Json(payload): Json<Value>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let id = payload
// Step 1: signature gate. Verified → trust the id from the signed
// header. LegacyUnsigned → fall through but enforce that the body id
// isn't a managed peer (would be downgrade attempt).
let outcome = device_auth::verify(&state, "POST", "/api/sysinfo", &headers, &body).await?;
// Step 2: parse body.
let payload: Value = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
let body_id = payload
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default();
@@ -27,15 +38,31 @@ pub async fn sysinfo(
.get("uuid")
.and_then(|v| v.as_str())
.unwrap_or_default();
if id.is_empty() || uuid.is_empty() {
if body_id.is_empty() || uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid required".into()));
}
// Step 3: bind the trusted identity to the body. For signed requests,
// the body id must match the header id — otherwise the agent is trying
// to write inventory for someone else.
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body_id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, body_id).await?;
body_id.to_string()
}
};
// Tie sysinfo storage to a real rendezvous-registered peer. Without this
// gate, any caller could populate device_sysinfo for arbitrary IDs.
let peer = state
.db
.get_peer(id)
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
@@ -45,7 +72,7 @@ pub async fn sysinfo(
let version = parse_version_number(payload.get("version").and_then(|v| v.as_str()));
state
.db
.sysinfo_upsert(id, uuid, &payload.to_string(), &state.cfg.sysinfo_ver, version)
.sysinfo_upsert(&id, uuid, &payload.to_string(), &state.cfg.sysinfo_ver, version)
.await?;
Ok("SYSINFO_UPDATED".to_string())
}
+42 -10
View File
@@ -4,15 +4,18 @@
//! every time the service starts and posts it here so the admin UI can
//! surface it for support staff.
//!
//! Auth model mirrors `/api/sysinfo`: the request must carry the agent's
//! `(id, uuid)` and that pair must already correspond to a registered
//! peer in `peer`. There's no shared secret beyond that — same trust
//! boundary the existing sysinfo endpoint already operates under.
//! Auth: same per-peer signed-API gate as `/api/sysinfo` and
//! `/api/heartbeat` — see [`crate::api::device_auth`]. Managed peers
//! (`peer.managed = 1`) must carry a valid Ed25519 signature; stock
//! clients keep posting unsigned and the first valid signature TOFU-
//! promotes the peer.
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::Json;
use axum::http::HeaderMap;
use serde_json::Value;
use std::sync::Arc;
@@ -20,9 +23,21 @@ use std::sync::Arc;
/// Response (bare string, like sysinfo): `"OK"` or `"ID_NOT_FOUND"`.
pub async fn unattended_password(
Extension(state): Extension<Arc<AppState>>,
Json(payload): Json<Value>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let id = payload
let outcome = device_auth::verify(
&state,
"POST",
"/api/unattended-password",
&headers,
&body,
)
.await?;
let payload: Value = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
let body_id = payload
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default();
@@ -34,15 +49,32 @@ pub async fn unattended_password(
.get("password")
.and_then(|v| v.as_str())
.unwrap_or_default();
if id.is_empty() || uuid.is_empty() || password.is_empty() {
if body_id.is_empty() || uuid.is_empty() || password.is_empty() {
return Err(ApiError::BadRequest(
"id, uuid, and password are required".into(),
));
}
// Bind the trusted identity to the body. For signed requests the body
// id must match the header id, or the agent is trying to overwrite
// someone else's displayed password. For unsigned requests we just
// need to ensure the peer isn't already locked down as managed.
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body_id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, body_id).await?;
body_id.to_string()
}
};
let peer = state
.db
.get_peer(id)
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
@@ -51,7 +83,7 @@ pub async fn unattended_password(
state
.db
.set_unattended_password(id, uuid, password)
.set_unattended_password(&id, uuid, password)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok("OK".to_string())
+53 -2
View File
@@ -224,6 +224,12 @@ pub struct DashboardDeviceRow {
/// is logged in.
pub unattended_password: String,
pub unattended_password_set_at: String,
/// `peer.managed` from the rendezvous identity table. `true` means the
/// agent-facing HTTP API will reject unsigned heartbeats/sysinfo for
/// this device — see `api::device_auth`. Joined LEFT so devices that
/// reported sysinfo before completing rendezvous render as `false`
/// rather than disappearing from the list.
pub managed: bool,
}
#[derive(Debug, Clone, Default)]
@@ -631,9 +637,11 @@ impl Database {
ds.payload AS payload, \
ds.conns AS conns, \
COALESCE(ds.unattended_password, '') AS u_pw, \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at, \
COALESCE(p.managed, 0) AS managed \
FROM device_sysinfo ds \
LEFT JOIN users u ON u.id = ds.user_id \
LEFT JOIN peer p ON p.id = ds.id \
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?",
)
.bind(limit)
@@ -651,6 +659,7 @@ impl Database {
conns_json: r.try_get("conns").unwrap_or_default(),
unattended_password: r.try_get("u_pw").unwrap_or_default(),
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
managed: r.try_get::<i64, _>("managed").unwrap_or(0) != 0,
})
.collect();
Ok((total, data))
@@ -672,9 +681,11 @@ impl Database {
ds.payload AS payload, \
ds.conns AS conns, \
COALESCE(ds.unattended_password, '') AS u_pw, \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at, \
COALESCE(p.managed, 0) AS managed \
FROM device_sysinfo ds \
LEFT JOIN users u ON u.id = ds.user_id \
LEFT JOIN peer p ON p.id = ds.id \
WHERE ds.id = ? LIMIT 1",
)
.bind(peer_id)
@@ -689,6 +700,7 @@ impl Database {
conns_json: r.try_get("conns").unwrap_or_default(),
unattended_password: r.try_get("u_pw").unwrap_or_default(),
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
managed: r.try_get::<i64, _>("managed").unwrap_or(0) != 0,
}))
}
@@ -3047,6 +3059,38 @@ impl Database {
.await?;
Ok(())
}
/// Read just the auth-relevant peer columns for the signed-API gate.
/// Untyped `sqlx::query` (not `query!`) because the `managed` column is
/// added by a soft ALTER at runtime — the compile-time check against the
/// dev DB would fail on fresh checkouts before the migration has run.
/// Returns `(pk, managed)`. `None` means no row for this id.
pub async fn peer_get_auth(&self, id: &str) -> ResultType<Option<(Vec<u8>, bool)>> {
use sqlx::Row;
let row = sqlx::query("select pk, managed from peer where id = ?")
.bind(id)
.fetch_optional(self.pool.get().await?.deref_mut())
.await?;
Ok(row.map(|r| {
let pk: Vec<u8> = r.try_get("pk").unwrap_or_default();
let managed: i64 = r.try_get("managed").unwrap_or(0);
(pk, managed != 0)
}))
}
/// Flip the `managed` flag. Called from two places:
/// 1. The TOFU promote inside the sig-verify helper on first valid sig.
/// 2. The admin endpoint PUT /api/peers/:id/managed.
/// Idempotent — calling with the current value is a no-op update.
pub async fn peer_set_managed(&self, id: &str, managed: bool) -> ResultType<()> {
let v: i64 = if managed { 1 } else { 0 };
sqlx::query("update peer set managed = ? where id = ?")
.bind(v)
.bind(id)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(())
}
}
/// Timing-safe equality for hash comparisons. Slightly paranoid given the
@@ -3268,6 +3312,13 @@ const M2_SOFT_ALTERS: &[&str] = &[
// displays it for the operator to read, and it rotates each boot.
"ALTER TABLE device_sysinfo ADD COLUMN unattended_password TEXT",
"ALTER TABLE device_sysinfo ADD COLUMN unattended_password_set_at DATETIME",
// Per-device flag that gates Ed25519 signature enforcement on the agent
// HTTP API (heartbeat, sysinfo). 0 = legacy/stock-rustdesk path, 1 =
// managed peer, signatures required. Set TOFU-style on the first valid
// signature we observe from the peer, or explicitly via the admin
// endpoint PUT /api/peers/:id/managed. Never written from request body
// — only the server flips it.
"ALTER TABLE peer ADD COLUMN managed INTEGER NOT NULL DEFAULT 0",
];
const M3_SCHEMA: &[&str] = &[