feat: 全面重构前端UI及完善后端功能

前端重构:
- 重构Layout为左侧导航+顶栏的现代管理后台布局
- 重构设备管理页面(Devices.vue):左侧分组面板+右侧设备列表
- 重构设备详情(DeviceDetail.vue):集成硬件资产/软件资产/变更记录标签页
- 移除独立资产管理页面,功能合并至设备详情
- 重构Dashboard/登录/设置/告警/水印/上网管控等页面样式
- 新增全局CSS变量和统一样式系统
- 添加分组管理UI:新建/重命名/删除分组,移动设备到分组

后端完善:
- 新增分组CRUD API(groups.rs):创建/重命名/删除分组,设备分组移动
- 客户端硬件采集:完善GPU/主板/序列号/磁盘信息采集(Windows PowerShell)
- 客户端软件采集:通过Windows注册表读取已安装软件列表
- 新增SoftwareAssetReport消息类型(0x09)及处理链路
- 数据库新增upsert_software方法处理软件资产存储
- 服务端推送软件资产配置给新注册设备
- 修复密码修改功能,添加旧密码验证
This commit is contained in:
iven
2026-04-06 13:09:43 +08:00
parent fd6fb5cca0
commit e99ea53eba
30 changed files with 3493 additions and 856 deletions

View File

@@ -1,20 +1,17 @@
use csm_protocol::{Frame, MessageType, HardwareAsset};
use csm_protocol::{Frame, MessageType, HardwareAsset, SoftwareAsset};
use std::time::Duration;
use tokio::sync::mpsc::Sender;
use tracing::{info, error};
use sysinfo::System;
pub async fn start_collecting(tx: Sender<Frame>, device_uid: String) {
let interval = Duration::from_secs(86400); // Once per day
let interval = Duration::from_secs(86400);
// Initial collection on startup
if let Err(e) = collect_and_send(&tx, &device_uid).await {
error!("Initial asset collection failed: {}", e);
}
loop {
tokio::time::sleep(interval).await;
if let Err(e) = collect_and_send(&tx, &device_uid).await {
error!("Asset collection failed: {}", e);
}
@@ -22,33 +19,163 @@ pub async fn start_collecting(tx: Sender<Frame>, device_uid: String) {
}
async fn collect_and_send(tx: &Sender<Frame>, device_uid: &str) -> anyhow::Result<()> {
// Collect & send hardware
let hardware = collect_hardware(device_uid)?;
let frame = Frame::new_json(MessageType::AssetReport, &hardware)?;
tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?;
info!("Asset report sent for {}", device_uid);
info!("Hardware asset report sent for {}", device_uid);
// Collect & send software list
let software_list = collect_software(device_uid);
let sw_count = software_list.len();
for sw in &software_list {
let frame = Frame::new_json(MessageType::SoftwareAssetReport, sw)?;
tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?;
}
info!("Software asset reports sent ({} items) for {}", sw_count, device_uid);
Ok(())
}
fn collect_hardware(device_uid: &str) -> anyhow::Result<HardwareAsset> {
let mut sys = System::new_all();
let mut sys = sysinfo::System::new_all();
sys.refresh_all();
// CPU
let cpu_model = sys.cpus().first()
.map(|c| c.brand().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let cpu_cores = sys.cpus().len() as u32;
let memory_total_mb = sys.total_memory() / 1024 / 1024; // bytes to MB (sysinfo 0.30)
// Memory
let memory_total_mb = sys.total_memory() / 1024 / 1024;
// Disk — pick the largest non-removable disk
let disks = sysinfo::Disks::new_with_refreshed_list();
let (disk_model, disk_total_mb) = disks.iter()
.max_by_key(|d| d.total_space())
.map(|d| {
let name = d.name().to_string_lossy().to_string();
let total = d.total_space() / 1024 / 1024;
(if name.is_empty() { "Unknown".to_string() } else { name }, total)
})
.unwrap_or_else(|| ("Unknown".to_string(), 0));
// GPU, motherboard, serial — Windows-specific via PowerShell
let (gpu_model, motherboard, serial_number) = collect_system_details();
Ok(HardwareAsset {
device_uid: device_uid.to_string(),
cpu_model,
cpu_cores,
memory_total_mb: memory_total_mb as u64,
disk_model: "Unknown".to_string(),
disk_total_mb: 0,
gpu_model: None,
motherboard: None,
serial_number: None,
memory_total_mb,
disk_model,
disk_total_mb,
gpu_model,
motherboard,
serial_number,
})
}
#[cfg(target_os = "windows")]
fn collect_system_details() -> (Option<String>, Option<String>, Option<String>) {
let gpu = powershell_first("Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Caption");
let mb_manufacturer = powershell_first("Get-CimInstance Win32_BaseBoard | Select-Object -ExpandProperty Manufacturer");
let mb_product = powershell_first("Get-CimInstance Win32_BaseBoard | Select-Object -ExpandProperty Product");
let motherboard = match (mb_manufacturer, mb_product) {
(Some(m), Some(p)) => Some(format!("{} {}", m, p)),
(single @ Some(_), _) => single,
(_, single @ Some(_)) => single,
_ => None,
};
let serial_number = powershell_first("Get-CimInstance Win32_BIOS | Select-Object -ExpandProperty SerialNumber");
(gpu, motherboard, serial_number)
}
#[cfg(not(target_os = "windows"))]
fn collect_system_details() -> (Option<String>, Option<String>, Option<String>) {
(None, None, None)
}
/// Run a PowerShell command and return the first non-empty line of output.
#[cfg(target_os = "windows")]
fn powershell_first(command: &str) -> Option<String> {
use std::process::Command;
let output = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command",
&format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}", command)])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.lines()
.map(|l| l.trim().to_string())
.find(|l| !l.is_empty())
}
/// Collect installed software from Windows Registry via PowerShell.
fn collect_software(device_uid: &str) -> Vec<SoftwareAsset> {
#[cfg(target_os = "windows")]
{
collect_windows_software(device_uid)
}
#[cfg(not(target_os = "windows"))]
{
let _ = device_uid;
Vec::new()
}
}
#[cfg(target_os = "windows")]
fn collect_windows_software(device_uid: &str) -> Vec<SoftwareAsset> {
use std::process::Command;
let ps_cmd = r#"
$paths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
Get-ItemProperty $paths -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -and $_.SystemComponent -ne 1 -and $null -eq $_.ParentDisplayName } |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation |
ConvertTo-Json -Compress
"#;
let output = match Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
return Vec::new();
}
// Single item -> object, multiple -> array
let items: Vec<serde_json::Value> = if stdout.starts_with('[') {
serde_json::from_str(&stdout).unwrap_or_default()
} else {
serde_json::from_str::<serde_json::Value>(&stdout)
.map(|v| vec![v])
.unwrap_or_default()
};
items.iter().filter_map(|item| {
let name = item.get("DisplayName")?.as_str()?.to_string();
if name.is_empty() {
return None;
}
Some(SoftwareAsset {
device_uid: device_uid.to_string(),
name,
version: item.get("DisplayVersion").and_then(|v| v.as_str()).map(String::from),
publisher: item.get("Publisher").and_then(|v| v.as_str()).map(String::from),
install_date: item.get("InstallDate").and_then(|v| v.as_str()).map(String::from),
install_path: item.get("InstallLocation").and_then(|v| v.as_str()).map(String::from),
})
}).collect()
}

View File

@@ -14,34 +14,49 @@ mod usb_audit;
mod popup_blocker;
mod software_blocker;
mod web_filter;
#[cfg(target_os = "windows")]
mod service;
/// Shared shutdown flag
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
/// Shared shutdown flag — checked by the main reconnect loop and set by
/// Ctrl+C (console mode) or SERVICE_CONTROL_STOP (service mode).
pub static SHUTDOWN: AtomicBool = AtomicBool::new(false);
/// Client configuration
struct ClientState {
device_uid: String,
server_addr: String,
config: ClientConfig,
device_secret: Option<String>,
registration_token: String,
/// Whether to use TLS when connecting to the server
use_tls: bool,
pub struct ClientState {
pub device_uid: String,
pub server_addr: String,
pub config: ClientConfig,
pub device_secret: Option<String>,
pub registration_token: String,
pub use_tls: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
// ---- Service management commands (Windows only) ----
#[cfg(target_os = "windows")]
{
if args.len() > 1 {
match args[1].as_str() {
"--install" => return service::install(),
"--uninstall" => return service::uninstall(),
"--service" => return service::run_service_mode(),
_ => {}
}
}
}
// ---- Console (development) mode ----
tracing_subscriber::fmt()
.with_env_filter("csm_client=info")
.init();
info!("CSM Client starting...");
info!("CSM Client starting (console mode)...");
// Load or generate device identity
let device_uid = load_or_create_device_uid()?;
info!("Device UID: {}", device_uid);
// Load server address
let server_addr = std::env::var("CSM_SERVER")
.unwrap_or_else(|_| "127.0.0.1:9999".to_string());
@@ -54,17 +69,14 @@ async fn main() -> Result<()> {
use_tls: std::env::var("CSM_USE_TLS").as_deref() == Ok("true"),
};
// TODO: Register as Windows Service on Windows
// For development, run directly
// Main event loop
run(state).await
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(run(state))
}
async fn run(state: ClientState) -> Result<()> {
pub async fn run(state: ClientState) -> Result<()> {
let (data_tx, mut data_rx) = tokio::sync::mpsc::channel::<Frame>(1024);
// Spawn Ctrl+C handler
// Spawn Ctrl+C handler (harmless in service mode — no console to receive it)
tokio::spawn(async {
tokio::signal::ctrl_c().await.ok();
info!("Received Ctrl+C, initiating graceful shutdown...");
@@ -153,8 +165,6 @@ async fn run(state: ClientState) -> Result<()> {
match network::connect_and_run(&state, &mut data_rx, &plugins).await {
Ok(()) => {
warn!("Disconnected from server, reconnecting...");
// Use a short fixed delay for clean disconnects (server-initiated),
// but don't reset to zero to prevent connection storms
tokio::time::sleep(Duration::from_secs(2)).await;
}
Err(e) => {
@@ -177,8 +187,6 @@ async fn run(state: ClientState) -> Result<()> {
}
fn load_or_create_device_uid() -> Result<String> {
// In production, store in Windows Credential Store or local config
// For now, use a simple file
let uid_file = "device_uid.txt";
if std::path::Path::new(uid_file).exists() {
let uid = std::fs::read_to_string(uid_file)?;

View File

@@ -0,0 +1,174 @@
use std::ffi::OsString;
use std::time::Duration;
use tracing::{info, error};
use windows_service::define_windows_service;
use windows_service::service::{
ServiceAccess, ServiceControl, ServiceErrorControl, ServiceExitCode,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const SERVICE_NAME: &str = "CSMClient";
const SERVICE_DISPLAY_NAME: &str = "CSM Client Service";
const SERVICE_DESCRIPTION: &str = "Client Security Management - endpoint monitoring and policy enforcement";
/// Install the CSM client as a Windows service (auto-start, LocalSystem).
pub fn install() -> anyhow::Result<()> {
let exe_path = std::env::current_exe()?;
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)?;
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe_path,
launch_arguments: vec![OsString::from("--service")],
dependencies: vec![],
account_name: None,
account_password: None,
};
let service = manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG)?;
service.set_description(SERVICE_DESCRIPTION)?;
println!("Service '{}' installed successfully.", SERVICE_NAME);
println!("Use 'sc start {}' or restart to launch.", SERVICE_NAME);
Ok(())
}
/// Uninstall the CSM client Windows service.
pub fn uninstall() -> anyhow::Result<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service = manager.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
)?;
let status = service.query_status()?;
if status.current_state == ServiceState::Running {
println!("Stopping service '{}'...", SERVICE_NAME);
service.stop()?;
for _ in 0..30 {
std::thread::sleep(Duration::from_secs(1));
if service.query_status()?.current_state != ServiceState::Running {
break;
}
}
}
service.delete()?;
println!("Service '{}' uninstalled successfully.", SERVICE_NAME);
Ok(())
}
/// Entry point for Windows Service mode.
pub fn run_service_mode() -> anyhow::Result<()> {
// Log to file when running as a service (no console available)
let log_file = std::fs::File::create("csm_client_service.log")?;
tracing_subscriber::fmt()
.with_env_filter("csm_client=info")
.with_writer(std::sync::Mutex::new(log_file))
.with_ansi(false)
.init();
info!("CSM Client service mode starting...");
windows_service::service_dispatcher::start(SERVICE_NAME, ffi_service_main)?;
Ok(())
}
define_windows_service!(ffi_service_main, service_main);
fn service_main(_arguments: Vec<OsString>) {
if let Err(e) = run_service_inner() {
error!("Service exited with error: {}", e);
}
}
fn run_service_inner() -> anyhow::Result<()> {
let status_handle = service_control_handler::register(SERVICE_NAME, |event| {
match event {
ServiceControl::Stop | ServiceControl::Preshutdown => {
crate::SHUTDOWN.store(true, std::sync::atomic::Ordering::SeqCst);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
})?;
// Report StartPending
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StartPending,
controls_accepted: windows_service::service::ServiceControlAccept::STOP,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(10),
process_id: None,
})?;
// Build ClientState
let device_uid = crate::load_or_create_device_uid()?;
info!("Device UID: {}", device_uid);
let state = crate::ClientState {
device_uid,
server_addr: std::env::var("CSM_SERVER").unwrap_or_else(|_| "127.0.0.1:9999".to_string()),
config: csm_protocol::ClientConfig::default(),
device_secret: crate::load_device_secret(),
registration_token: std::env::var("CSM_REGISTRATION_TOKEN").unwrap_or_default(),
use_tls: std::env::var("CSM_USE_TLS").as_deref() == Ok("true"),
};
// Report Running
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: windows_service::service::ServiceControlAccept::STOP,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
info!("CSM Client service is running");
// Run the same async loop as console mode.
// The loop checks SHUTDOWN, so the control handler will cause a graceful exit.
let runtime = tokio::runtime::Runtime::new()?;
let result = runtime.block_on(crate::run(state));
// Report StopPending
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StopPending,
controls_accepted: windows_service::service::ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(10),
process_id: None,
})?;
drop(runtime);
// Report Stopped
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: windows_service::service::ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
info!("CSM Client service stopped");
result
}

View File

@@ -64,7 +64,7 @@ fn apply_hosts_rules(rules: &[WebFilterRule]) -> io::Result<()> {
// Build new block with block rules
let block_rules: Vec<&WebFilterRule> = rules.iter()
.filter(|r| r.rule_type == "block")
.filter(|r| r.rule_type == "blacklist")
.filter(|r| is_valid_hosts_entry(&r.pattern))
.collect();
@@ -75,8 +75,11 @@ fn apply_hosts_rules(rules: &[WebFilterRule]) -> io::Result<()> {
let mut new_block = format!("{}\n", HOSTS_MARKER_START);
for rule in &block_rules {
// Redirect blocked domains to 127.0.0.1
new_block.push_str(&format!("127.0.0.1 {}\n", rule.pattern));
// Expand wildcard patterns into concrete host entries
// Hosts file does NOT support wildcards like *.example.com
for host in expand_wildcard(&rule.pattern) {
new_block.push_str(&format!("127.0.0.1 {}\n", host));
}
}
new_block.push_str(HOSTS_MARKER_END);
new_block.push('\n');
@@ -167,3 +170,14 @@ fn is_valid_hosts_entry(pattern: &str) -> bool {
// Must look like a hostname (alphanumeric, dots, hyphens, underscores, asterisks)
pattern.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '*')
}
/// Expand a wildcard pattern into concrete host entries for the hosts file.
/// `*.example.com` → `["example.com", "www.example.com"]`
/// `example.com` → `["example.com"]`
fn expand_wildcard(pattern: &str) -> Vec<String> {
if let Some(domain) = pattern.strip_prefix("*.") {
vec![domain.to_string(), format!("www.{}", domain)]
} else {
vec![pattern.to_string()]
}
}