diff --git a/crates/client/src/asset/mod.rs b/crates/client/src/asset/mod.rs index 4fcd417..4927262 100644 --- a/crates/client/src/asset/mod.rs +++ b/crates/client/src/asset/mod.rs @@ -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, 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, device_uid: String) { } async fn collect_and_send(tx: &Sender, 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 { - 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, Option, Option) { + 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, Option, Option) { + (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 { + 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 { + #[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 { + 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 = if stdout.starts_with('[') { + serde_json::from_str(&stdout).unwrap_or_default() + } else { + serde_json::from_str::(&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() +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 7bd3f9d..42eb74e 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -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, - 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, + pub registration_token: String, + pub use_tls: bool, } -#[tokio::main] -async fn main() -> Result<()> { +fn main() -> Result<()> { + let args: Vec = 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::(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 { - // 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)?; diff --git a/crates/client/src/service.rs b/crates/client/src/service.rs new file mode 100644 index 0000000..b1be0aa --- /dev/null +++ b/crates/client/src/service.rs @@ -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) { + 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 +} diff --git a/crates/client/src/web_filter/mod.rs b/crates/client/src/web_filter/mod.rs index 1a8ae35..450eef5 100644 --- a/crates/client/src/web_filter/mod.rs +++ b/crates/client/src/web_filter/mod.rs @@ -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 { + if let Some(domain) = pattern.strip_prefix("*.") { + vec![domain.to_string(), format!("www.{}", domain)] + } else { + vec![pattern.to_string()] + } +} diff --git a/crates/protocol/src/message.rs b/crates/protocol/src/message.rs index 2988a4a..b916763 100644 --- a/crates/protocol/src/message.rs +++ b/crates/protocol/src/message.rs @@ -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 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), diff --git a/crates/server/src/api/assets.rs b/crates/server/src/api/assets.rs index 2d86be3..4178eff 100644 --- a/crates/server/src/api/assets.rs +++ b/crates/server/src/api/assets.rs @@ -109,17 +109,20 @@ pub async fn list_software( pub async fn list_changes( State(state): State, - Query(page): Query, + Query(params): Query, ) -> Json> { - 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, }))) } diff --git a/crates/server/src/api/auth.rs b/crates/server/src/api/auth.rs index ed95a9e..22308c8 100644 --- a/crates/server/src/api/auth.rs +++ b/crates/server/src/api/auth.rs @@ -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, + claims: axum::Extension, + Json(req): Json, +) -> Result<(StatusCode, Json>), 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(())))) +} diff --git a/crates/server/src/api/groups.rs b/crates/server/src/api/groups.rs new file mode 100644 index 0000000..23d4d21 --- /dev/null +++ b/crates/server/src/api/groups.rs @@ -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, +) -> Result<(StatusCode, Json>), 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 = rows.iter().map(|r| serde_json::json!({ + "name": r.get::("grp"), + "count": r.get::("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, + Json(req): Json, +) -> Result<(StatusCode, Json>), 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, + Path(old_name): Path, + Json(req): Json, +) -> Result<(StatusCode, Json>), 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, + Path(name): Path, +) -> Result<(StatusCode, Json>), 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, + Path(uid): Path, + Json(req): Json, +) -> Result<(StatusCode, Json>), 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(())))) +} diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs index 083f014..4c4c02c 100644 --- a/crates/server/src/api/mod.rs +++ b/crates/server/src/api/mod.rs @@ -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 { let public = Router::new() @@ -18,6 +19,8 @@ pub fn routes(state: AppState) -> Router { // 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 { .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 { 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)) diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 6d80bb6..ed065ee 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -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, diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index df8b073..c0656d1 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -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(()) + } } diff --git a/crates/server/src/tcp.rs b/crates/server/src/tcp.rs index 3607ca5..ec98dbf 100644 --- a/crates/server/src/tcp.rs +++ b/crates/server/src/tcp.rs @@ -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))?; diff --git a/web/components.d.ts b/web/components.d.ts index 5fc9519..86646c6 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -10,13 +10,11 @@ declare module 'vue' { ElAside: typeof import('element-plus/es')['ElAside'] ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] - ElCard: typeof import('element-plus/es')['ElCard'] ElCol: typeof import('element-plus/es')['ElCol'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElContainer: typeof import('element-plus/es')['ElContainer'] - ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] - ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] @@ -44,6 +42,7 @@ declare module 'vue' { ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/web/src/App.vue b/web/src/App.vue index 11d5421..98240ae 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,8 +1,3 @@ - - diff --git a/web/src/assets/styles/global.css b/web/src/assets/styles/global.css new file mode 100644 index 0000000..e9fa7c0 --- /dev/null +++ b/web/src/assets/styles/global.css @@ -0,0 +1,205 @@ +/* ======================================== + CSM Global Styles + ======================================== */ +@import './variables.css'; + +/* ---- Reset & Base ---- */ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 14px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html, body, #app { + height: 100%; + font-family: var(--csm-font-family); + color: var(--csm-text-primary); + background: var(--csm-bg-page); +} + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #9ca3af; +} + +/* ---- Card Styles ---- */ +.csm-card { + background: var(--csm-bg-card); + border-radius: var(--csm-border-radius-lg); + border: 1px solid var(--csm-border-color); +} + +.csm-card-header { + font-weight: 600; + font-size: 14px; + color: var(--csm-text-primary); + padding: 14px 20px; + border-bottom: 1px solid var(--csm-border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.csm-card-body { + padding: var(--csm-card-padding); +} + +/* ---- Stat Cards ---- */ +.stat-card-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.stat-card { + background: var(--csm-bg-card); + border-radius: var(--csm-border-radius); + padding: 20px; + display: flex; + align-items: center; + gap: 14px; + border: 1px solid var(--csm-border-color); + transition: box-shadow var(--csm-transition-fast); +} + +.stat-card:hover { + box-shadow: var(--csm-shadow-md); +} + +.stat-icon { + width: 48px; + height: 48px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; +} + +.stat-icon.online { background: #16a34a; } +.stat-icon.offline { background: #64748b; } +.stat-icon.warning { background: #d97706; } +.stat-icon.usb { background: #2563eb; } + +.stat-value { + font-size: 26px; + font-weight: 700; + color: var(--csm-text-primary); + line-height: 1; +} + +.stat-label { + font-size: 13px; + color: var(--csm-text-secondary); + margin-top: 2px; +} + +/* ---- Page Container ---- */ +.page-container { + padding: var(--csm-page-padding); +} + +/* ---- Toolbar ---- */ +.page-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.page-toolbar-right { + margin-left: auto; +} + +/* ---- Table ---- */ +.el-table th.el-table__cell { + font-weight: 600; + color: var(--csm-text-secondary); + font-size: 13px; +} + +/* ---- Element Plus Overrides ---- */ +.el-card { + border-radius: var(--csm-border-radius-lg) !important; + border-color: var(--csm-border-color) !important; +} + +.el-button--primary { + background: var(--csm-primary); + border-color: var(--csm-primary); +} + +.el-button--primary:hover { + background: var(--csm-primary-light); + border-color: var(--csm-primary-light); +} + +.el-tag { + border-radius: 4px; + font-weight: 500; +} + +.el-dialog { + border-radius: var(--csm-border-radius-xl) !important; +} + +.el-dialog__header { + border-bottom: 1px solid var(--csm-border-color); + padding-bottom: 16px; +} + +.el-dialog__footer { + border-top: 1px solid var(--csm-border-color); + padding-top: 16px; +} + +/* ---- Page Transition ---- */ +.page-enter-active, +.page-leave-active { + transition: opacity 0.15s ease; +} + +.page-enter-from, +.page-leave-to { + opacity: 0; +} + +/* ---- Responsive ---- */ +@media (max-width: 1200px) { + .stat-card-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .stat-card-grid { + grid-template-columns: 1fr; + } + :root { + --csm-page-padding: 16px; + } +} diff --git a/web/src/assets/styles/variables.css b/web/src/assets/styles/variables.css new file mode 100644 index 0000000..3765691 --- /dev/null +++ b/web/src/assets/styles/variables.css @@ -0,0 +1,78 @@ +/* ======================================== + CSM Design System - CSS Variables + Enterprise Terminal Management System + ======================================== */ + +:root { + /* ---- Brand Colors ---- */ + --csm-primary: #1d4ed8; + --csm-primary-light: #3b6de0; + --csm-primary-dark: #1e40af; + --csm-primary-bg: rgba(29, 78, 216, 0.06); + + /* ---- Sidebar ---- */ + --csm-sidebar-bg: #1e293b; + --csm-sidebar-hover: #263548; + --csm-sidebar-active: rgba(29, 78, 216, 0.15); + --csm-sidebar-text: #94a3b8; + --csm-sidebar-text-active: #e2e8f0; + --csm-sidebar-width: 240px; + + /* ---- Surface & Background ---- */ + --csm-bg-page: #f5f6fa; + --csm-bg-card: #ffffff; + --csm-bg-header: #ffffff; + + /* ---- Text Colors ---- */ + --csm-text-primary: #1e293b; + --csm-text-secondary: #64748b; + --csm-text-tertiary: #94a3b8; + --csm-text-inverse: #ffffff; + + /* ---- Status Colors ---- */ + --csm-success: #16a34a; + --csm-success-bg: rgba(22, 163, 74, 0.06); + --csm-warning: #d97706; + --csm-warning-bg: rgba(217, 119, 6, 0.06); + --csm-danger: #dc2626; + --csm-danger-bg: rgba(220, 38, 38, 0.06); + --csm-info: #2563eb; + --csm-info-bg: rgba(37, 99, 235, 0.06); + + /* ---- Border ---- */ + --csm-border-color: #e5e7eb; + --csm-border-radius: 8px; + --csm-border-radius-lg: 12px; + --csm-border-radius-xl: 16px; + + /* ---- Shadow ---- */ + --csm-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.04); + --csm-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); + --csm-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.06); + + /* ---- Spacing ---- */ + --csm-page-padding: 24px; + --csm-card-padding: 20px; + + /* ---- Transitions ---- */ + --csm-transition-fast: 150ms ease; + --csm-transition: 250ms ease; + + /* ---- Typography ---- */ + --csm-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --csm-font-mono: 'Menlo', 'Consolas', monospace; +} + +/* Element Plus Theme Overrides */ +:root { + --el-color-primary: var(--csm-primary); + --el-color-primary-light-3: var(--csm-primary-light); + --el-color-primary-dark-2: var(--csm-primary-dark); + --el-color-success: var(--csm-success); + --el-color-warning: var(--csm-warning); + --el-color-danger: var(--csm-danger); + --el-color-info: #64748b; + --el-border-radius-base: 6px; + --el-font-family: var(--csm-font-family); + --el-bg-color-page: var(--csm-bg-page); +} diff --git a/web/src/main.ts b/web/src/main.ts index e5876d1..b2888c5 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,6 +1,7 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import 'element-plus/dist/index.css' +import './assets/styles/global.css' import App from './App.vue' import router from './router' diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 846827d..224b2ce 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -13,7 +13,6 @@ const router = createRouter({ { path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') }, { path: 'devices', name: 'Devices', component: () => import('../views/Devices.vue') }, { path: 'devices/:uid', name: 'DeviceDetail', component: () => import('../views/DeviceDetail.vue') }, - { path: 'assets', name: 'Assets', component: () => import('../views/Assets.vue') }, { path: 'usb', name: 'UsbPolicy', component: () => import('../views/UsbPolicy.vue') }, { path: 'alerts', name: 'Alerts', component: () => import('../views/Alerts.vue') }, { path: 'settings', name: 'Settings', component: () => import('../views/Settings.vue') }, diff --git a/web/src/views/Alerts.vue b/web/src/views/Alerts.vue index aedf780..ad5cfc5 100644 --- a/web/src/views/Alerts.vue +++ b/web/src/views/Alerts.vue @@ -1,8 +1,8 @@