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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
174
crates/client/src/service.rs
Normal file
174
crates/client/src/service.rs
Normal 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
|
||||
}
|
||||
@@ -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()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub enum MessageType {
|
||||
AssetChange = 0x05,
|
||||
UsbEvent = 0x06,
|
||||
AlertAck = 0x07,
|
||||
SoftwareAssetReport = 0x09,
|
||||
|
||||
// Server → Client (Core)
|
||||
RegisterResponse = 0x08,
|
||||
@@ -73,6 +74,7 @@ impl TryFrom<u8> for MessageType {
|
||||
0x06 => Ok(Self::UsbEvent),
|
||||
0x07 => Ok(Self::AlertAck),
|
||||
0x08 => Ok(Self::RegisterResponse),
|
||||
0x09 => Ok(Self::SoftwareAssetReport),
|
||||
0x10 => Ok(Self::PolicyUpdate),
|
||||
0x11 => Ok(Self::ConfigUpdate),
|
||||
0x12 => Ok(Self::TaskExecute),
|
||||
|
||||
@@ -109,17 +109,20 @@ pub async fn list_software(
|
||||
|
||||
pub async fn list_changes(
|
||||
State(state): State<AppState>,
|
||||
Query(page): Query<Pagination>,
|
||||
Query(params): Query<AssetListParams>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
let offset = page.offset();
|
||||
let limit = page.limit();
|
||||
let limit = params.page_size.unwrap_or(20).min(100);
|
||||
let offset = params.page.unwrap_or(1).saturating_sub(1) * limit;
|
||||
let device_uid = params.device_uid.as_deref().filter(|s| !s.is_empty()).map(String::from);
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, device_uid, change_type, change_detail, detected_at
|
||||
FROM asset_changes ORDER BY detected_at DESC LIMIT ? OFFSET ?"
|
||||
FROM asset_changes WHERE 1=1
|
||||
AND (? IS NULL OR device_uid = ?)
|
||||
ORDER BY detected_at DESC LIMIT ? OFFSET ?"
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(&device_uid).bind(&device_uid)
|
||||
.bind(limit).bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
|
||||
@@ -134,7 +137,7 @@ pub async fn list_changes(
|
||||
})).collect();
|
||||
Json(ApiResponse::ok(serde_json::json!({
|
||||
"changes": items,
|
||||
"page": page.page.unwrap_or(1),
|
||||
"page": params.page.unwrap_or(1),
|
||||
"page_size": limit,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::{extract::State, Json, http::StatusCode, extract::Request, middleware::Next, response::Response};
|
||||
use axum::{extract::State, Json, http::StatusCode, extract::Request, middleware::Next, response::Response, Extension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation};
|
||||
use std::sync::Arc;
|
||||
@@ -45,6 +45,12 @@ pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub old_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// In-memory rate limiter for login attempts
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LoginRateLimiter {
|
||||
@@ -293,3 +299,46 @@ pub async fn require_admin(
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
claims: axum::Extension<Claims>,
|
||||
Json(req): Json<ChangePasswordRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
if req.new_password.len() < 6 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("新密码至少6位"))));
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
let hash: String = sqlx::query_scalar::<_, String>(
|
||||
"SELECT password FROM users WHERE id = ?"
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if !bcrypt::verify(&req.old_password, &hash).unwrap_or(false) {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("当前密码错误"))));
|
||||
}
|
||||
|
||||
// Update password
|
||||
let new_hash = bcrypt::hash(&req.new_password, 12).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
sqlx::query("UPDATE users SET password = ? WHERE id = ?")
|
||||
.bind(&new_hash)
|
||||
.bind(claims.sub)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Audit log
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, 'change_password', ?)"
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(format!("User {} changed password", claims.username))
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
|
||||
139
crates/server/src/api/groups.rs
Normal file
139
crates/server/src/api/groups.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use axum::{extract::{State, Path, Json}, http::StatusCode};
|
||||
use serde::Deserialize;
|
||||
use sqlx::Row;
|
||||
use crate::AppState;
|
||||
use super::ApiResponse;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenameGroupRequest {
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MoveDeviceRequest {
|
||||
pub group_name: String,
|
||||
}
|
||||
|
||||
/// List all groups with device counts
|
||||
pub async fn list_groups(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<serde_json::Value>>), StatusCode> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT COALESCE(NULLIF(group_name, ''), 'default') as grp, COUNT(*) as cnt \
|
||||
FROM devices GROUP BY grp ORDER BY cnt DESC"
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let groups: Vec<serde_json::Value> = rows.iter().map(|r| serde_json::json!({
|
||||
"name": r.get::<String, _>("grp"),
|
||||
"count": r.get::<i64, _>("cnt"),
|
||||
})).collect();
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({
|
||||
"groups": groups,
|
||||
})))))
|
||||
}
|
||||
|
||||
/// Create a new group (validates name doesn't exist)
|
||||
pub async fn create_group(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateGroupRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
let name = req.name.trim().to_string();
|
||||
if name.is_empty() || name.len() > 50 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("分组名称无效"))));
|
||||
}
|
||||
|
||||
// Check if group already exists
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) > 0 FROM devices WHERE group_name = ?"
|
||||
)
|
||||
.bind(&name)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Ok((StatusCode::CONFLICT, Json(ApiResponse::error("分组已存在"))));
|
||||
}
|
||||
|
||||
// Groups are virtual — just return success. They materialize when devices are assigned.
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
|
||||
/// Rename a group (moves all devices in old group to new name)
|
||||
pub async fn rename_group(
|
||||
State(state): State<AppState>,
|
||||
Path(old_name): Path<String>,
|
||||
Json(req): Json<RenameGroupRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
let new_name = req.new_name.trim().to_string();
|
||||
if new_name.is_empty() || new_name.len() > 50 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("分组名称无效"))));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE devices SET group_name = ? WHERE group_name = ?"
|
||||
)
|
||||
.bind(&new_name)
|
||||
.bind(&old_name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok((StatusCode::NOT_FOUND, Json(ApiResponse::error("分组不存在或没有设备"))));
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
|
||||
/// Delete a group (moves all devices to default)
|
||||
pub async fn delete_group(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
let _result = sqlx::query(
|
||||
"UPDATE devices SET group_name = 'default' WHERE group_name = ?"
|
||||
)
|
||||
.bind(&name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
|
||||
/// Move a device to a group
|
||||
pub async fn move_device(
|
||||
State(state): State<AppState>,
|
||||
Path(uid): Path<String>,
|
||||
Json(req): Json<MoveDeviceRequest>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<()>>), StatusCode> {
|
||||
let group_name = req.group_name.trim().to_string();
|
||||
if group_name.is_empty() || group_name.len() > 50 {
|
||||
return Ok((StatusCode::BAD_REQUEST, Json(ApiResponse::error("分组名称无效"))));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE devices SET group_name = ? WHERE device_uid = ?"
|
||||
)
|
||||
.bind(&group_name)
|
||||
.bind(&uid)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok((StatusCode::NOT_FOUND, Json(ApiResponse::error("设备不存在"))));
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::ok(()))))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod assets;
|
||||
pub mod usb;
|
||||
pub mod alerts;
|
||||
pub mod plugins;
|
||||
pub mod groups;
|
||||
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
let public = Router::new()
|
||||
@@ -18,6 +19,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
|
||||
// Read-only routes (any authenticated user)
|
||||
let read_routes = Router::new()
|
||||
// Auth
|
||||
.route("/api/auth/change-password", put(auth::change_password))
|
||||
// Devices
|
||||
.route("/api/devices", get(devices::list))
|
||||
.route("/api/devices/:uid", get(devices::get_detail))
|
||||
@@ -27,6 +30,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.route("/api/assets/hardware", get(assets::list_hardware))
|
||||
.route("/api/assets/software", get(assets::list_software))
|
||||
.route("/api/assets/changes", get(assets::list_changes))
|
||||
// Groups (read)
|
||||
.route("/api/groups", get(groups::list_groups))
|
||||
// USB (read)
|
||||
.route("/api/usb/events", get(usb::list_events))
|
||||
.route("/api/usb/policies", get(usb::list_policies))
|
||||
@@ -41,6 +46,10 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
let write_routes = Router::new()
|
||||
// Devices
|
||||
.route("/api/devices/:uid", delete(devices::remove))
|
||||
// Groups (write)
|
||||
.route("/api/groups", post(groups::create_group))
|
||||
.route("/api/groups/:name", put(groups::rename_group).delete(groups::delete_group))
|
||||
.route("/api/devices/:uid/group", put(groups::move_device))
|
||||
// USB (write)
|
||||
.route("/api/usb/policies", post(usb::create_policy))
|
||||
.route("/api/usb/policies/:id", put(usb::update_policy).delete(usb::delete_policy))
|
||||
|
||||
@@ -108,7 +108,7 @@ fn default_audit_log_days() -> u32 { 365 }
|
||||
pub fn default_config() -> AppConfig {
|
||||
AppConfig {
|
||||
server: ServerConfig {
|
||||
http_addr: "0.0.0.0:8080".into(),
|
||||
http_addr: "0.0.0.0:9998".into(),
|
||||
tcp_addr: "0.0.0.0:9999".into(),
|
||||
cors_origins: vec![],
|
||||
tls: None,
|
||||
|
||||
@@ -115,4 +115,24 @@ impl DeviceRepo {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upsert_software(pool: &SqlitePool, asset: &csm_protocol::SoftwareAsset) -> Result<()> {
|
||||
// Use INSERT OR REPLACE to handle the UNIQUE(device_uid, name, version) constraint
|
||||
// where version can be NULL (treated as distinct by SQLite)
|
||||
let version = asset.version.as_deref().unwrap_or("");
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO software_assets (device_uid, name, version, publisher, install_date, install_path, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))"
|
||||
)
|
||||
.bind(&asset.device_uid)
|
||||
.bind(&asset.name)
|
||||
.bind(if version.is_empty() { None } else { Some(version) })
|
||||
.bind(&asset.publisher)
|
||||
.bind(&asset.install_date)
|
||||
.bind(&asset.install_path)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,6 +574,17 @@ async fn process_frame(
|
||||
crate::db::DeviceRepo::upsert_hardware(&state.db, &asset).await?;
|
||||
}
|
||||
|
||||
MessageType::SoftwareAssetReport => {
|
||||
let sw: csm_protocol::SoftwareAsset = frame.decode_payload()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid software asset report: {}", e))?;
|
||||
|
||||
if !verify_device_uid(device_uid, "SoftwareAssetReport", &sw.device_uid) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
crate::db::DeviceRepo::upsert_software(&state.db, &sw).await?;
|
||||
}
|
||||
|
||||
MessageType::UsageReport => {
|
||||
let report: csm_protocol::UsageDailyReport = frame.decode_payload()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid usage report: {}", e))?;
|
||||
|
||||
Reference in New Issue
Block a user