This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user