This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
# Agent API authentication
|
# Agent API authentication
|
||||||
|
|
||||||
Reference for the per-device signature gate on the agent-facing HTTP
|
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/heartbeat`
|
||||||
- `POST /api/sysinfo`
|
- `POST /api/sysinfo`
|
||||||
- `POST /api/unattended-password`
|
- `POST /api/unattended-password`
|
||||||
- `POST /api/agent/exec-result` — managed-only (no legacy/unsigned path)
|
- `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
|
For the operator workflow — turning it on, the dashboard toggle, what
|
||||||
happens when a managed agent is uninstalled — see the matching section
|
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).",
|
"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).",
|
"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" => (
|
"devices.serial_number" => (
|
||||||
"Serial number",
|
"Serial number",
|
||||||
"Seriennummer",
|
"Seriennummer",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::api::admin::i18n::{t, tf1, tf2, tf3, Lang};
|
|||||||
use crate::api::error::ApiError;
|
use crate::api::error::ApiError;
|
||||||
use crate::api::middleware::AuthedUser;
|
use crate::api::middleware::AuthedUser;
|
||||||
use crate::api::state::AppState;
|
use crate::api::state::AppState;
|
||||||
use crate::database::DashboardDeviceRow;
|
use crate::database::{DashboardDeviceRow, LoginEventRow};
|
||||||
use axum::extract::{Extension, Form, Path, Query};
|
use axum::extract::{Extension, Form, Path, Query};
|
||||||
use axum::response::Html;
|
use axum::response::Html;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -459,7 +459,19 @@ pub async fn detail(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
let html = match row {
|
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!(
|
None => format!(
|
||||||
r##"<div class="space-y-4">
|
r##"<div class="space-y-4">
|
||||||
{back}
|
{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 =
|
let parsed: serde_json::Value =
|
||||||
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
|
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
|
||||||
let pick = |k: &str| -> String {
|
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!(
|
format!(
|
||||||
r##"<div class="space-y-4">
|
r##"<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -1233,15 +1247,133 @@ fn render_detail(lang: Lang, d: &DashboardDeviceRow) -> String {
|
|||||||
{header}
|
{header}
|
||||||
<h3 class="text-sm font-semibold text-slate-300 mt-4">{inventory}</h3>
|
<h3 class="text-sm font-semibold text-slate-300 mt-4">{inventory}</h3>
|
||||||
{inv}
|
{inv}
|
||||||
|
<h3 class="text-sm font-semibold text-slate-300 mt-4">{login_history}</h3>
|
||||||
|
{login}
|
||||||
</div>"##,
|
</div>"##,
|
||||||
back = back_button(lang),
|
back = back_button(lang),
|
||||||
detail_view = t(lang, "devices.detail_view"),
|
detail_view = t(lang, "devices.detail_view"),
|
||||||
inventory = t(lang, "devices.inventory"),
|
inventory = t(lang, "devices.inventory"),
|
||||||
header = header,
|
header = header,
|
||||||
inv = inventory_section,
|
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 {
|
fn render_inventory_table(lang: Lang, inv: &serde_json::Value) -> String {
|
||||||
let row = |label: &str, key: &str| {
|
let row = |label: &str, key: &str| {
|
||||||
format!(
|
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 groups;
|
||||||
pub mod heartbeat;
|
pub mod heartbeat;
|
||||||
pub mod http_proxy;
|
pub mod http_proxy;
|
||||||
|
pub mod login_event;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod oidc;
|
pub mod oidc;
|
||||||
pub mod pagination;
|
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_ver", post(sysinfo::sysinfo_ver))
|
||||||
.route("/api/sysinfo", post(sysinfo::sysinfo))
|
.route("/api/sysinfo", post(sysinfo::sysinfo))
|
||||||
.route("/api/agent/exec-result", post(agent_exec::exec_result))
|
.route("/api/agent/exec-result", post(agent_exec::exec_result))
|
||||||
|
.route("/api/agent/login-event", post(login_event::login_event))
|
||||||
.route(
|
.route(
|
||||||
"/api/unattended-password",
|
"/api/unattended-password",
|
||||||
post(unattended::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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PeerListRow {
|
pub struct PeerListRow {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -450,6 +466,12 @@ impl Database {
|
|||||||
.execute(self.pool.get().await?.deref_mut())
|
.execute(self.pool.get().await?.deref_mut())
|
||||||
.await?;
|
.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
|
// Soft-ALTERs run after schema creation. SQLite < 3.35 lacks
|
||||||
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error
|
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error
|
||||||
// so re-runs are idempotent. Newly-added soft alters get appended
|
// so re-runs are idempotent. Newly-added soft alters get appended
|
||||||
@@ -3456,6 +3478,72 @@ impl Database {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(rows.into_iter().map(exec_row_from).collect())
|
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
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use hbb_common::tokio;
|
use hbb_common::tokio;
|
||||||
|
|||||||
Reference in New Issue
Block a user