From 62a8870ea2de744dbdce48bd0035060ba76ef46a Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 22 May 2026 20:10:11 +0200 Subject: [PATCH] Implement user login logging --- docs/AGENT-API-AUTH.md | 6 +- src/api/admin/i18n.rs | 56 +++++++++++ src/api/admin/pages/devices.rs | 138 +++++++++++++++++++++++++++- src/api/login_event.rs | 163 +++++++++++++++++++++++++++++++++ src/api/mod.rs | 2 + src/database.rs | 120 ++++++++++++++++++++++++ 6 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 src/api/login_event.rs diff --git a/docs/AGENT-API-AUTH.md b/docs/AGENT-API-AUTH.md index a477980..57b4788 100644 --- a/docs/AGENT-API-AUTH.md +++ b/docs/AGENT-API-AUTH.md @@ -1,12 +1,16 @@ # Agent API authentication Reference for the per-device signature gate on the agent-facing HTTP -API. Four endpoints are gated: +API. Five endpoints are gated: - `POST /api/heartbeat` - `POST /api/sysinfo` - `POST /api/unattended-password` - `POST /api/agent/exec-result` — managed-only (no legacy/unsigned path) +- `POST /api/agent/login-event` — user-logon / logoff events observed + by the agent. Same TOFU lifecycle as heartbeat / sysinfo: stock + RustDesk doesn't post here at all, so in practice every caller is a + managed agent; the legacy/unsigned path is kept for symmetry. For the operator workflow — turning it on, the dashboard toggle, what happens when a managed agent is uninstalled — see the matching section diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs index cf535e4..ee89296 100644 --- a/src/api/admin/i18n.rs +++ b/src/api/admin/i18n.rs @@ -1217,6 +1217,62 @@ pub fn t(lang: Lang, key: &str) -> &'static str { "Datele de inventar nu au fost încă raportate. Agentul le colectează la pornire și le încarcă la următorul ciclu sysinfo (≤120 s).", "Aún no se han reportado datos de inventario. El agente los recopila al iniciar y los envía en el próximo ciclo sysinfo (≤120 s).", ), + "devices.login_history" => ( + "Login history", + "Anmeldeverlauf", + "Historique de connexion", + "Istoric autentificări", + "Historial de inicio de sesión", + ), + "devices.login_none" => ( + "No login events recorded yet. The agent reports logons and logoffs as it observes them.", + "Noch keine Anmeldeereignisse aufgezeichnet. Der Agent meldet An- und Abmeldungen, sobald er sie beobachtet.", + "Aucun événement de connexion enregistré pour l'instant. L'agent signale les connexions et déconnexions au fur et à mesure qu'il les observe.", + "Niciun eveniment de autentificare înregistrat încă. Agentul raportează autentificările și deconectările pe măsură ce le observă.", + "Aún no hay eventos de inicio de sesión registrados. El agente informa los inicios y cierres de sesión a medida que los observa.", + ), + "devices.login_col_when" => ( + "When (UTC)", + "Wann (UTC)", + "Quand (UTC)", + "Când (UTC)", + "Cuándo (UTC)", + ), + "devices.login_col_event" => ( + "Event", + "Ereignis", + "Événement", + "Eveniment", + "Evento", + ), + "devices.login_col_user" => ( + "User", + "Benutzer", + "Utilisateur", + "Utilizator", + "Usuario", + ), + "devices.login_col_session" => ( + "Session", + "Sitzung", + "Session", + "Sesiune", + "Sesión", + ), + "devices.login_kind_logon" => ( + "Logon", + "Anmeldung", + "Connexion", + "Autentificare", + "Inicio de sesión", + ), + "devices.login_kind_logoff" => ( + "Logoff", + "Abmeldung", + "Déconnexion", + "Deconectare", + "Cierre de sesión", + ), "devices.serial_number" => ( "Serial number", "Seriennummer", diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index 4713de0..5440f35 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -6,7 +6,7 @@ use crate::api::admin::i18n::{t, tf1, tf2, tf3, Lang}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use crate::api::state::AppState; -use crate::database::DashboardDeviceRow; +use crate::database::{DashboardDeviceRow, LoginEventRow}; use axum::extract::{Extension, Form, Path, Query}; use axum::response::Html; use serde::Deserialize; @@ -459,7 +459,19 @@ pub async fn detail( .await .map_err(|e| ApiError::Internal(e.to_string()))?; let html = match row { - Some(d) => render_detail(lang, &d), + Some(d) => { + // Login events are surfaced on the detail page below the + // inventory. We cap at 50 — that's a few weeks of typical + // logon/logoff activity on a single-user box and stays + // skimmable. Deeper history can be added later behind a + // pagination control if anyone asks for it. + let events = state + .db + .login_events_for_peer(&d.id, 50) + .await + .unwrap_or_default(); + render_detail(lang, &d, &events) + } None => format!( r##"
{back} @@ -1130,7 +1142,7 @@ fn fmt_inv_value(v: Option<&serde_json::Value>) -> String { } } -fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String { +fn render_detail(lang: Lang, d: &DashboardDeviceRow, login_events: &[LoginEventRow]) -> String { let parsed: serde_json::Value = serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); let pick = |k: &str| -> String { @@ -1224,6 +1236,8 @@ fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String { ), }; + let login_section = render_login_events(lang, login_events); + format!( r##"
@@ -1233,15 +1247,133 @@ fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String { {header}

{inventory}

{inv} +

{login_history}

+ {login}
"##, back = back_button(lang), detail_view = t(lang, "devices.detail_view"), inventory = t(lang, "devices.inventory"), header = header, inv = inventory_section, + login_history = t(lang, "devices.login_history"), + login = login_section, ) } +/// Render the per-device login history table. Empty input → a neutral +/// "no events yet" panel so the heading still has something under it. +/// We render the agent-reported `at` in the standard SQLite UTC format +/// (no per-locale fiddling) and only flag a "received_at differs from at" +/// case as a tooltip — clock skew on the agent side is the only place that +/// matters operationally. +fn render_login_events(lang: Lang, events: &[LoginEventRow]) -> String { + if events.is_empty() { + return format!( + r##"
+ {msg} +
"##, + msg = t(lang, "devices.login_none"), + ); + } + let mut s = format!( + r##"
+ + + + + + + + + + "##, + c_when = t(lang, "devices.login_col_when"), + c_event = t(lang, "devices.login_col_event"), + c_user = t(lang, "devices.login_col_user"), + c_session = t(lang, "devices.login_col_session"), + ); + for ev in events { + let when = fmt_unix_utc(ev.at); + // Surface clock-skew >5 min as a tooltip — that's the threshold the + // signed-API gate also uses, so anything above that is already on + // the operator's radar via 401s. + let skew = ev.received_at - ev.at; + let when_attr = if skew.abs() > 300 { + format!( + r##" title="received {recv} UTC (clock skew {skew:+}s)""##, + recv = html_escape(&fmt_unix_utc(ev.received_at)), + skew = skew, + ) + } else { + String::new() + }; + let (badge_class, badge_label) = match ev.kind.as_str() { + "logon" => ( + "bg-emerald-900/40 text-emerald-300 border-emerald-800", + t(lang, "devices.login_kind_logon"), + ), + "logoff" => ( + "bg-slate-800 text-slate-300 border-slate-700", + t(lang, "devices.login_kind_logoff"), + ), + _ => ( + "bg-amber-900/40 text-amber-300 border-amber-800", + // Unknown kind — display the raw string so an operator + // running a newer agent against an older server can still + // see what was reported. + "", + ), + }; + let badge_text = if badge_label.is_empty() { + html_escape(&ev.kind) + } else { + badge_label.to_string() + }; + let user_display = if ev.domain.is_empty() { + html_escape(if ev.username.is_empty() { "—" } else { &ev.username }) + } else if ev.username.is_empty() { + html_escape(&ev.domain) + } else { + html_escape(&format!("{}\\{}", ev.domain, ev.username)) + }; + let session_kind = if ev.session_kind.is_empty() { + "—".to_string() + } else { + html_escape(&ev.session_kind) + }; + let _ = write!( + s, + r##" + + + + +"##, + when_attr = when_attr, + when = html_escape(&when), + bc = badge_class, + bt = badge_text, + user = user_display, + sk = session_kind, + sid = ev.session_id, + ); + } + s.push_str("
{c_when}{c_event}{c_user}{c_session}
{when}{bt}{user}{sk} #{sid}
"); + s +} + +/// Format a unix epoch as `YYYY-MM-DD HH:MM:SS` UTC. Matches the format +/// SQLite's `current_timestamp` produces, so all the other timestamps on +/// the device detail page line up visually with login-event rows. +fn fmt_unix_utc(ts: i64) -> String { + use chrono::TimeZone; + chrono::Utc + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| ts.to_string()) +} + fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String { let row = |label: &str, key: &str| { format!( diff --git a/src/api/login_event.rs b/src/api/login_event.rs new file mode 100644 index 0000000..aae2b2c --- /dev/null +++ b/src/api/login_event.rs @@ -0,0 +1,163 @@ +//! `POST /api/agent/login-event` — agent-side reporting of user logon / +//! logoff events observed on the controlled machine. Surfaces a per-device +//! login history on the admin Devices detail page. +//! +//! Auth: same per-peer signed-API gate as `/api/sysinfo` / +//! `/api/heartbeat` / `/api/unattended-password` — see +//! [`crate::api::device_auth`]. Stock RustDesk doesn't post here at all, +//! so in practice every caller is a managed agent; we still keep the +//! `LegacyUnsigned → enforce_managed_for_id` path for symmetry with the +//! other agent endpoints. +//! +//! Body shape (events batched so an agent that was offline can catch up +//! on reconnect): +//! +//! ```json +//! { +//! "id": "", +//! "uuid": "", +//! "events": [ +//! { +//! "at": 1717920000, +//! "kind": "logon", // or "logoff" +//! "username": "alice", +//! "domain": "CORP", +//! "session_id": 2, +//! "session_kind": "rdp" // or "console" +//! } +//! ] +//! } +//! ``` +//! +//! Response: `"OK"` on success, `"ID_NOT_FOUND"` for an unregistered peer +//! (same shape as `/api/unattended-password` so the agent can use a single +//! retry helper for both). + +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::http::HeaderMap; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +pub struct LoginEventIn { + pub at: i64, + pub kind: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub domain: String, + #[serde(default)] + pub session_id: i64, + #[serde(default)] + pub session_kind: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginEventBody { + pub id: String, + pub uuid: String, + pub events: Vec, +} + +/// Cap per-request to bound DB cost from a misbehaving / catching-up agent. +const MAX_EVENTS_PER_POST: usize = 256; + +pub async fn login_event( + Extension(state): Extension>, + headers: HeaderMap, + body: Bytes, +) -> Result { + let outcome = + device_auth::verify(&state, "POST", "/api/agent/login-event", &headers, &body).await?; + + let payload: LoginEventBody = serde_json::from_slice(&body) + .map_err(|_| ApiError::BadRequest("invalid json".into()))?; + + if payload.id.is_empty() || payload.uuid.is_empty() { + return Err(ApiError::BadRequest("id and uuid are required".into())); + } + if payload.events.is_empty() { + return Ok("OK".to_string()); + } + if payload.events.len() > MAX_EVENTS_PER_POST { + return Err(ApiError::BadRequest(format!( + "too many events in one POST (max {MAX_EVENTS_PER_POST})" + ))); + } + + // Bind the trusted identity to the body. Same rule as the other agent + // endpoints: signed → header id must equal body id; unsigned → peer + // must not be `managed=1`. + let id = match outcome { + AuthOutcome::Verified { id: signed_id } => { + if payload.id != signed_id { + return Err(ApiError::Unauthorized); + } + signed_id + } + AuthOutcome::LegacyUnsigned => { + device_auth::enforce_managed_for_id(&state, &payload.id).await?; + payload.id.clone() + } + }; + + let peer = state + .db + .get_peer(&id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + if peer.is_none() { + // Same shape as /api/unattended-password — agent treats this as + // "retry later, rendezvous hasn't registered me yet". + return Ok("ID_NOT_FOUND".to_string()); + } + + let mut accepted = 0usize; + for ev in &payload.events { + let kind = ev.kind.trim(); + if kind != "logon" && kind != "logoff" { + // Unknown kinds are ignored rather than 400ing the whole batch; + // a future agent build that adds e.g. "lock" should be able to + // post a mixed batch against an older server without losing + // the known-kind rows. + continue; + } + if let Err(e) = state + .db + .login_event_insert( + &id, + &payload.uuid, + ev.at, + kind, + ev.username.trim(), + ev.domain.trim(), + ev.session_id, + ev.session_kind.trim(), + ) + .await + { + // Don't fail the whole batch on a single insert error — the + // agent's retry loop will resend the events that didn't land, + // and we'd rather record what we can than reject everything. + hbb_common::log::warn!( + "login_event_insert for peer {} failed: {}", + id, + e + ); + continue; + } + accepted += 1; + } + + hbb_common::log::debug!( + "login-event: peer={} accepted={}/{}", + id, + accepted, + payload.events.len() + ); + Ok("OK".to_string()) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 99f0399..47ab53d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -15,6 +15,7 @@ pub mod error; pub mod groups; pub mod heartbeat; pub mod http_proxy; +pub mod login_event; pub mod middleware; pub mod oidc; pub mod pagination; @@ -51,6 +52,7 @@ pub fn router(state: Arc) -> Router { .route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver)) .route("/api/sysinfo", post(sysinfo::sysinfo)) .route("/api/agent/exec-result", post(agent_exec::exec_result)) + .route("/api/agent/login-event", post(login_event::login_event)) .route( "/api/unattended-password", post(unattended::unattended_password), diff --git a/src/database.rs b/src/database.rs index 648b788..2119640 100644 --- a/src/database.rs +++ b/src/database.rs @@ -279,6 +279,22 @@ fn exec_row_from(r: sqlx::sqlite::SqliteRow) -> ExecRow { } } +/// One agent-reported user-login event, surfaced on the device detail +/// page. `at` is when the agent says it happened (unix seconds); +/// `received_at` is when the row landed in the DB. `kind` is currently +/// `"logon"` or `"logoff"` — anything else is treated as an unknown kind +/// by the renderer and shown verbatim. +#[derive(Debug, Clone, Default)] +pub struct LoginEventRow { + pub at: i64, + pub kind: String, + pub username: String, + pub domain: String, + pub session_id: i64, + pub session_kind: String, + pub received_at: i64, +} + #[derive(Debug, Clone, Default)] pub struct PeerListRow { pub id: String, @@ -450,6 +466,12 @@ impl Database { .execute(self.pool.get().await?.deref_mut()) .await?; } + // M7 schema: agent-reported user-login events. + for stmt in M7_SCHEMA { + sqlx::query(stmt) + .execute(self.pool.get().await?.deref_mut()) + .await?; + } // Soft-ALTERs run after schema creation. SQLite < 3.35 lacks // `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error // so re-runs are idempotent. Newly-added soft alters get appended @@ -3456,6 +3478,72 @@ impl Database { .await?; Ok(rows.into_iter().map(exec_row_from).collect()) } + + // ─────────────────────── device_login_events (M7) ────────────────────── + // + // The agent reports user-login events as it observes them, batched into + // one POST per flush (so a backed-up agent that comes back online can + // catch the server up). All inserts are untyped `sqlx::query` so a + // dev-DB that hasn't been migrated still compiles. + + /// Insert a single login event. Caller is expected to have already + /// validated the peer id against `peer` (same gate as sysinfo / unattended). + pub async fn login_event_insert( + &self, + peer_id: &str, + peer_uuid: &str, + at: i64, + kind: &str, + username: &str, + domain: &str, + session_id: i64, + session_kind: &str, + ) -> ResultType<()> { + sqlx::query( + "insert or ignore into device_login_events \ + (peer_id, peer_uuid, at, kind, username, domain, session_id, session_kind) \ + values (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(peer_id) + .bind(peer_uuid) + .bind(at) + .bind(kind) + .bind(username) + .bind(domain) + .bind(session_id) + .bind(session_kind) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + + /// Most-recent-first list for the device detail page. + pub async fn login_events_for_peer( + &self, + peer_id: &str, + limit: i64, + ) -> ResultType> { + let rows = sqlx::query( + "select at, kind, username, domain, session_id, session_kind, received_at \ + from device_login_events where peer_id = ? order by at desc, id desc limit ?", + ) + .bind(peer_id) + .bind(limit) + .fetch_all(self.pool.get().await?.deref_mut()) + .await?; + Ok(rows + .into_iter() + .map(|r| LoginEventRow { + at: r.try_get("at").unwrap_or(0), + kind: r.try_get("kind").unwrap_or_default(), + username: r.try_get("username").unwrap_or_default(), + domain: r.try_get("domain").unwrap_or_default(), + session_id: r.try_get("session_id").unwrap_or(0), + session_kind: r.try_get("session_kind").unwrap_or_default(), + received_at: r.try_get("received_at").unwrap_or(0), + }) + .collect()) + } } /// Timing-safe equality for hash comparisons. Slightly paranoid given the @@ -3869,6 +3957,38 @@ const M6_SCHEMA: &[&str] = &[ )", ]; +/// M7: agent-reported user-login events. Each row is one logon or logoff +/// observed by the agent on the controlled machine. `at` is the agent's +/// claimed event time (unix epoch seconds); `received_at` is when the +/// server stored the row, so the operator can spot agents whose clock is +/// drifting. The agent batches events on the wire (one POST may carry +/// many), which is why this is a separate table rather than columns on +/// `device_sysinfo` — one logical device produces an unbounded number of +/// events over time. +const M7_SCHEMA: &[&str] = &[ + "CREATE TABLE IF NOT EXISTS device_login_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + peer_id TEXT NOT NULL, + peer_uuid TEXT NOT NULL, + at INTEGER NOT NULL, + kind TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + domain TEXT NOT NULL DEFAULT '', + session_id INTEGER NOT NULL DEFAULT 0, + session_kind TEXT NOT NULL DEFAULT '', + received_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + )", + "CREATE INDEX IF NOT EXISTS idx_device_login_events_peer \ + ON device_login_events(peer_id, at DESC)", + // Dedup the agent's "report-on-every-boot, server-side dedup" model: + // when the agent restarts it re-observes already-active sessions and + // re-emits their logon events with the same `WTSConnectTime`. The + // unique tuple below + `INSERT OR IGNORE` lets that be a no-op + // server-side instead of piling up duplicate rows. + "CREATE UNIQUE INDEX IF NOT EXISTS uq_device_login_events \ + ON device_login_events(peer_id, kind, session_id, at, username)", +]; + #[cfg(test)] mod tests { use hbb_common::tokio;