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} |
+ {c_event} |
+ {c_user} |
+ {c_session} |
+
+
+ "##,
+ 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} |
+ {bt} |
+ {user} |
+ {sk} #{sid} |
+
"##,
+ 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("
");
+ 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;