Implement user login logging
build / build-linux-amd64 (push) Successful in 1m54s

This commit is contained in:
2026-05-22 20:10:11 +02:00
parent ac058d31c2
commit 62a8870ea2
6 changed files with 481 additions and 4 deletions
+5 -1
View File
@@ -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
+56
View File
@@ -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",
+135 -3
View File
@@ -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##"<div class="space-y-4">
{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##"<div class="space-y-4">
<div class="flex items-center justify-between">
@@ -1233,15 +1247,133 @@ fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String {
{header}
<h3 class="text-sm font-semibold text-slate-300 mt-4">{inventory}</h3>
{inv}
<h3 class="text-sm font-semibold text-slate-300 mt-4">{login_history}</h3>
{login}
</div>"##,
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##"<div class="rounded-md border border-slate-700 bg-slate-900 p-3 text-sm text-slate-400">
{msg}
</div>"##,
msg = t(lang, "devices.login_none"),
);
}
let mut s = format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950">
<tr>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_event}</th>
<th class="text-left font-medium px-3 py-2">{c_user}</th>
<th class="text-left font-medium px-3 py-2">{c_session}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
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##"<tr class="hover:bg-slate-800/40">
<td class="px-3 py-2 font-mono text-xs text-slate-300 whitespace-nowrap"{when_attr}>{when}</td>
<td class="px-3 py-2"><span class="inline-block text-[11px] px-1.5 py-0.5 rounded border {bc}">{bt}</span></td>
<td class="px-3 py-2 text-slate-200">{user}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{sk} #{sid}</td>
</tr>"##,
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("</tbody></table></div>");
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!(
+163
View File
@@ -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": "<peer id>",
//! "uuid": "<peer 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<LoginEventIn>,
}
/// 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<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
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())
}
+2
View File
@@ -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<AppState>) -> 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),
+120
View File
@@ -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<Vec<LoginEventRow>> {
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;