use std::time::Duration; use tokio::sync::watch; use tracing::{info, debug, warn}; use csm_protocol::{Frame, MessageType, DiskEncryptionStatusPayload, DriveEncryptionInfo, DiskEncryptionConfigPayload}; /// Disk encryption configuration pushed from server #[derive(Debug, Clone, Default)] pub struct DiskEncryptionConfig { pub enabled: bool, pub report_interval_secs: u64, } impl From for DiskEncryptionConfig { fn from(payload: DiskEncryptionConfigPayload) -> Self { Self { enabled: payload.enabled, report_interval_secs: payload.report_interval_secs, } } } /// Start the disk encryption detection plugin. /// On startup and periodically, collects BitLocker volume status via PowerShell /// and sends results to the server. pub async fn start( mut config_rx: watch::Receiver, data_tx: tokio::sync::mpsc::Sender, device_uid: String, ) { info!("Disk encryption plugin started"); let mut config = DiskEncryptionConfig::default(); let default_interval_secs: u64 = 3600; let mut report_interval = tokio::time::interval(Duration::from_secs(default_interval_secs)); report_interval.tick().await; // Collect and report once on startup if enabled if config.enabled { collect_and_report(&data_tx, &device_uid).await; } loop { tokio::select! { result = config_rx.changed() => { if result.is_err() { break; } let new_config = config_rx.borrow_and_update().clone(); if new_config.enabled != config.enabled { info!("Disk encryption enabled: {}", new_config.enabled); } config = new_config; if config.enabled { let secs = if config.report_interval_secs > 0 { config.report_interval_secs } else { default_interval_secs }; report_interval = tokio::time::interval(Duration::from_secs(secs)); report_interval.tick().await; } } _ = report_interval.tick() => { if !config.enabled { continue; } collect_and_report(&data_tx, &device_uid).await; } } } } async fn collect_and_report( data_tx: &tokio::sync::mpsc::Sender, device_uid: &str, ) { let uid = device_uid.to_string(); match tokio::task::spawn_blocking(move || collect_bitlocker_status()).await { Ok(drives) => { if drives.is_empty() { debug!("No BitLocker volumes found for device {}", uid); return; } let payload = DiskEncryptionStatusPayload { device_uid: uid, drives, }; if let Ok(frame) = Frame::new_json(MessageType::DiskEncryptionStatus, &payload) { if data_tx.send(frame).await.is_err() { warn!("Failed to send disk encryption status: channel closed"); } } } Err(e) => { warn!("Failed to collect disk encryption status: {}", e); } } } /// Collect BitLocker volume information via PowerShell. /// Runs: Get-BitLockerVolume | ConvertTo-Json fn collect_bitlocker_status() -> Vec { #[cfg(target_os = "windows")] { let output = std::process::Command::new("powershell") .args([ "-NoProfile", "-NonInteractive", "-Command", "Get-BitLockerVolume | Select-Object MountPoint, VolumeName, EncryptionMethod, ProtectionStatus, EncryptionPercentage, LockStatus | ConvertTo-Json -Compress", ]) .output(); match output { Ok(out) if out.status.success() => { let stdout = String::from_utf8_lossy(&out.stdout); let trimmed = stdout.trim(); if trimmed.is_empty() { return Vec::new(); } // PowerShell returns a single object (not array) when there is exactly one volume let json_str = if trimmed.starts_with('{') { format!("[{}]", trimmed) } else { trimmed.to_string() }; match serde_json::from_str::>(&json_str) { Ok(entries) => entries.into_iter().map(|e| parse_bitlocker_entry(&e)).collect(), Err(e) => { warn!("Failed to parse BitLocker output: {}", e); Vec::new() } } } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); warn!("PowerShell BitLocker query failed: {}", stderr); Vec::new() } Err(e) => { warn!("Failed to run PowerShell for BitLocker status: {}", e); Vec::new() } } } #[cfg(not(target_os = "windows"))] { Vec::new() } } fn parse_bitlocker_entry(entry: &serde_json::Value) -> DriveEncryptionInfo { let mount_point = entry.get("MountPoint") .and_then(|v| v.as_str()) .unwrap_or("Unknown:") .to_string(); let volume_name = entry.get("VolumeName") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(String::from); let encryption_method = entry.get("EncryptionMethod") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty() && *s != "None") .map(String::from); let protection_status = match entry.get("ProtectionStatus") { Some(v) if v.is_number() => match v.as_i64().unwrap_or(0) { 1 => "On".to_string(), 0 => "Off".to_string(), _ => "Unknown".to_string(), }, Some(v) if v.is_string() => v.as_str().unwrap_or("Unknown").to_string(), _ => "Unknown".to_string(), }; let encryption_percentage = entry.get("EncryptionPercentage") .and_then(|v| v.as_f64()) .unwrap_or(0.0); let lock_status = match entry.get("LockStatus") { Some(v) if v.is_number() => match v.as_i64().unwrap_or(0) { 1 => "Locked".to_string(), 0 => "Unlocked".to_string(), _ => "Unknown".to_string(), }, Some(v) if v.is_string() => v.as_str().unwrap_or("Unknown").to_string(), _ => "Unknown".to_string(), }; DriveEncryptionInfo { drive_letter: mount_point, volume_name, encryption_method, protection_status, encryption_percentage, lock_status, } }