Implement signed API communication to improve security
build / build-linux-amd64 (push) Successful in 1m50s
build / build-linux-amd64 (push) Successful in 1m50s
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,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)),
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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] = &[
|
||||
|
||||
Reference in New Issue
Block a user