feat(protocol): 添加补丁管理和行为指标协议类型 feat(client): 实现补丁管理插件采集功能 feat(server): 添加补丁管理和异常检测API feat(database): 新增补丁状态和异常检测相关表 feat(web): 添加补丁管理和异常检测前端页面 fix(security): 增强输入验证和防注入保护 refactor(auth): 重构认证检查逻辑 perf(service): 优化Windows服务恢复策略 style: 统一健康评分显示样式 docs: 更新知识库文档
308 lines
10 KiB
Rust
308 lines
10 KiB
Rust
use axum::{extract::{State, Path, Query}, Json};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::Row;
|
|
use crate::AppState;
|
|
use super::{ApiResponse, Pagination};
|
|
|
|
/// GET /api/devices/:uid/health-score
|
|
pub async fn get_health_score(
|
|
State(state): State<AppState>,
|
|
Path(uid): Path<String>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
match crate::health::get_device_score(&state.db, &uid).await {
|
|
Ok(Some(score)) => Json(ApiResponse::ok(score)),
|
|
Ok(None) => Json(ApiResponse::error("No health score available")),
|
|
Err(e) => Json(ApiResponse::internal_error("health score", e)),
|
|
}
|
|
}
|
|
|
|
/// GET /api/dashboard/health-overview
|
|
pub async fn health_overview(
|
|
State(state): State<AppState>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
match crate::health::get_health_overview(&state.db).await {
|
|
Ok(overview) => Json(ApiResponse::ok(overview)),
|
|
Err(e) => Json(ApiResponse::internal_error("health overview", e)),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct DeviceListParams {
|
|
pub status: Option<String>,
|
|
pub group: Option<String>,
|
|
pub search: Option<String>,
|
|
pub page: Option<u32>,
|
|
pub page_size: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
|
pub struct DeviceRow {
|
|
pub id: i64,
|
|
pub device_uid: String,
|
|
pub hostname: String,
|
|
pub ip_address: String,
|
|
pub mac_address: Option<String>,
|
|
pub os_version: Option<String>,
|
|
pub client_version: Option<String>,
|
|
pub status: String,
|
|
pub last_heartbeat: Option<String>,
|
|
pub registered_at: Option<String>,
|
|
pub group_name: Option<String>,
|
|
#[sqlx(default)]
|
|
pub health_score: Option<i32>,
|
|
#[sqlx(default)]
|
|
pub health_level: Option<String>,
|
|
}
|
|
|
|
pub async fn list(
|
|
State(state): State<AppState>,
|
|
Query(params): Query<DeviceListParams>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
let limit = params.page_size.unwrap_or(20).min(100);
|
|
let offset = params.page.unwrap_or(1).saturating_sub(1) * limit;
|
|
|
|
// Normalize empty strings to None (Axum deserializes `status=` as Some(""))
|
|
let status = params.status.as_deref().filter(|s| !s.is_empty()).map(String::from);
|
|
let group = params.group.as_deref().filter(|s| !s.is_empty()).map(String::from);
|
|
let search = params.search.as_deref().filter(|s| !s.is_empty()).map(String::from);
|
|
|
|
let devices = sqlx::query_as::<_, DeviceRow>(
|
|
"SELECT d.id, d.device_uid, d.hostname, d.ip_address, d.mac_address, d.os_version, d.client_version,
|
|
d.status, d.last_heartbeat, d.registered_at, d.group_name,
|
|
h.score as health_score, h.level as health_level
|
|
FROM devices d
|
|
LEFT JOIN device_health_scores h ON h.device_uid = d.device_uid
|
|
WHERE 1=1
|
|
AND (? IS NULL OR d.status = ?)
|
|
AND (? IS NULL OR d.group_name = ?)
|
|
AND (? IS NULL OR d.hostname LIKE '%' || ? || '%' OR d.ip_address LIKE '%' || ? || '%')
|
|
ORDER BY d.registered_at DESC LIMIT ? OFFSET ?"
|
|
)
|
|
.bind(&status).bind(&status)
|
|
.bind(&group).bind(&group)
|
|
.bind(&search).bind(&search).bind(&search)
|
|
.bind(limit).bind(offset)
|
|
.fetch_all(&state.db)
|
|
.await;
|
|
|
|
let total: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM devices WHERE 1=1
|
|
AND (? IS NULL OR status = ?)
|
|
AND (? IS NULL OR group_name = ?)
|
|
AND (? IS NULL OR hostname LIKE '%' || ? || '%' OR ip_address LIKE '%' || ? || '%')"
|
|
)
|
|
.bind(&status).bind(&status)
|
|
.bind(&group).bind(&group)
|
|
.bind(&search).bind(&search).bind(&search)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.unwrap_or(0);
|
|
|
|
match devices {
|
|
Ok(rows) => Json(ApiResponse::ok(serde_json::json!({
|
|
"devices": rows,
|
|
"total": total,
|
|
"page": params.page.unwrap_or(1),
|
|
"page_size": limit,
|
|
}))),
|
|
Err(e) => Json(ApiResponse::internal_error("query devices", e)),
|
|
}
|
|
}
|
|
|
|
pub async fn get_detail(
|
|
State(state): State<AppState>,
|
|
Path(uid): Path<String>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
let device = sqlx::query_as::<_, DeviceRow>(
|
|
"SELECT id, device_uid, hostname, ip_address, mac_address, os_version, client_version,
|
|
status, last_heartbeat, registered_at, group_name
|
|
FROM devices WHERE device_uid = ?"
|
|
)
|
|
.bind(&uid)
|
|
.fetch_optional(&state.db)
|
|
.await;
|
|
|
|
match device {
|
|
Ok(Some(d)) => Json(ApiResponse::ok(serde_json::to_value(d).unwrap_or_default())),
|
|
Ok(None) => Json(ApiResponse::error("Device not found")),
|
|
Err(e) => Json(ApiResponse::internal_error("query devices", e)),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
|
struct StatusRow {
|
|
pub cpu_usage: f64,
|
|
pub memory_usage: f64,
|
|
pub memory_total_mb: i64,
|
|
pub disk_usage: f64,
|
|
pub disk_total_mb: i64,
|
|
pub network_rx_rate: i64,
|
|
pub network_tx_rate: i64,
|
|
pub running_procs: i32,
|
|
pub top_processes: Option<String>,
|
|
pub reported_at: String,
|
|
}
|
|
|
|
pub async fn get_status(
|
|
State(state): State<AppState>,
|
|
Path(uid): Path<String>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
let status = sqlx::query_as::<_, StatusRow>(
|
|
"SELECT cpu_usage, memory_usage, memory_total_mb, disk_usage, disk_total_mb,
|
|
network_rx_rate, network_tx_rate, running_procs, top_processes, reported_at
|
|
FROM device_status WHERE device_uid = ?"
|
|
)
|
|
.bind(&uid)
|
|
.fetch_optional(&state.db)
|
|
.await;
|
|
|
|
match status {
|
|
Ok(Some(s)) => {
|
|
let mut val = serde_json::to_value(&s).unwrap_or_default();
|
|
// Parse top_processes JSON string back to array
|
|
if let Some(tp_str) = &s.top_processes {
|
|
if let Ok(tp) = serde_json::from_str::<serde_json::Value>(tp_str) {
|
|
val["top_processes"] = tp;
|
|
}
|
|
}
|
|
Json(ApiResponse::ok(val))
|
|
}
|
|
Ok(None) => Json(ApiResponse::error("No status data found")),
|
|
Err(e) => Json(ApiResponse::internal_error("query devices", e)),
|
|
}
|
|
}
|
|
|
|
pub async fn get_history(
|
|
State(state): State<AppState>,
|
|
Path(uid): Path<String>,
|
|
Query(page): Query<Pagination>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
let offset = page.offset();
|
|
let limit = page.limit();
|
|
|
|
let rows = sqlx::query(
|
|
"SELECT cpu_usage, memory_usage, disk_usage, running_procs, reported_at
|
|
FROM device_status_history WHERE device_uid = ?
|
|
ORDER BY reported_at DESC LIMIT ? OFFSET ?"
|
|
)
|
|
.bind(&uid)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(&state.db)
|
|
.await;
|
|
|
|
match rows {
|
|
Ok(records) => {
|
|
let items: Vec<serde_json::Value> = records.iter().map(|r| {
|
|
serde_json::json!({
|
|
"cpu_usage": r.get::<f64, _>("cpu_usage"),
|
|
"memory_usage": r.get::<f64, _>("memory_usage"),
|
|
"disk_usage": r.get::<f64, _>("disk_usage"),
|
|
"running_procs": r.get::<i32, _>("running_procs"),
|
|
"reported_at": r.get::<String, _>("reported_at"),
|
|
})
|
|
}).collect();
|
|
Json(ApiResponse::ok(serde_json::json!({
|
|
"history": items,
|
|
"page": page.page.unwrap_or(1),
|
|
"page_size": limit,
|
|
})))
|
|
}
|
|
Err(e) => Json(ApiResponse::internal_error("query devices", e)),
|
|
}
|
|
}
|
|
|
|
pub async fn remove(
|
|
State(state): State<AppState>,
|
|
Path(uid): Path<String>,
|
|
) -> Json<ApiResponse<()>> {
|
|
// Delete device and all associated data in a transaction
|
|
let mut tx = match state.db.begin().await {
|
|
Ok(tx) => tx,
|
|
Err(e) => return Json(ApiResponse::internal_error("begin transaction", e)),
|
|
};
|
|
|
|
// Delete status history
|
|
if let Err(e) = sqlx::query("DELETE FROM device_status_history WHERE device_uid = ?")
|
|
.bind(&uid)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
return Json(ApiResponse::internal_error("remove device history", e));
|
|
}
|
|
|
|
// Delete current status
|
|
if let Err(e) = sqlx::query("DELETE FROM device_status WHERE device_uid = ?")
|
|
.bind(&uid)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
return Json(ApiResponse::internal_error("remove device status", e));
|
|
}
|
|
|
|
// Delete plugin-related data
|
|
let cleanup_tables = [
|
|
"hardware_assets",
|
|
"software_assets",
|
|
"asset_changes",
|
|
"usb_events",
|
|
"usb_file_operations",
|
|
"usage_daily",
|
|
"app_usage_daily",
|
|
"software_violations",
|
|
"web_access_log",
|
|
"popup_block_stats",
|
|
"disk_encryption_status",
|
|
"disk_encryption_alerts",
|
|
"print_events",
|
|
"clipboard_violations",
|
|
"behavior_metrics",
|
|
"anomaly_alerts",
|
|
"device_health_scores",
|
|
"patch_status",
|
|
];
|
|
for table in &cleanup_tables {
|
|
// Safety: table names are hardcoded constants above, not user input.
|
|
// Parameterized ? is used for device_uid.
|
|
debug_assert!(table.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
|
"BUG: table name contains unexpected characters: {}", table);
|
|
if let Err(e) = sqlx::query(&format!("DELETE FROM {} WHERE device_uid = ?", table))
|
|
.bind(&uid)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
tracing::warn!("Failed to clean {} for device {}: {}", table, uid, e);
|
|
}
|
|
}
|
|
|
|
// Finally delete the device itself
|
|
let delete_result = sqlx::query("DELETE FROM devices WHERE device_uid = ?")
|
|
.bind(&uid)
|
|
.execute(&mut *tx)
|
|
.await;
|
|
|
|
match delete_result {
|
|
Ok(r) if r.rows_affected() > 0 => {
|
|
if let Err(e) = tx.commit().await {
|
|
return Json(ApiResponse::internal_error("commit device deletion", e));
|
|
}
|
|
|
|
// Send self-destruct command AFTER successful commit
|
|
let frame = csm_protocol::Frame::new_json(
|
|
csm_protocol::MessageType::ConfigUpdate,
|
|
&serde_json::json!({"type": "SelfDestruct"}),
|
|
).ok();
|
|
|
|
if let Some(frame) = frame {
|
|
state.clients.send_to(&uid, frame.encode()).await;
|
|
}
|
|
|
|
state.clients.unregister(&uid).await;
|
|
tracing::info!(device_uid = %uid, "Device and all associated data deleted");
|
|
Json(ApiResponse::ok(()))
|
|
}
|
|
Ok(_) => Json(ApiResponse::error("Device not found")),
|
|
Err(e) => Json(ApiResponse::internal_error("remove device", e)),
|
|
}
|
|
}
|