use std::sync::Arc; use std::time::Duration; use chrono::{DateTime, Utc}; use once_cell::sync::Lazy; use parking_lot::Mutex; use serde::Serialize; use serde_json::json; use std::collections::HashMap; use sysinfo::{Networks, System}; use tauri::async_runtime::{self, JoinHandle}; use tokio::sync::Notify; #[derive(thiserror::Error, Debug)] pub enum AgentError { #[error("Falha ao obter hostname da dispositivo")] Hostname, #[error("Nenhum identificador de hardware disponível (MAC/serial)")] MissingIdentifiers, #[error("URL de API inválida")] InvalidApiUrl, #[error("Falha HTTP: {0}")] Http(#[from] reqwest::Error), } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MachineOs { pub name: String, pub version: Option, pub architecture: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MachineMetrics { pub collected_at: DateTime, pub cpu_logical_cores: usize, pub cpu_physical_cores: Option, pub cpu_usage_percent: f32, pub load_average_one: Option, pub load_average_five: Option, pub load_average_fifteen: Option, pub memory_total_bytes: u64, pub memory_used_bytes: u64, pub memory_used_percent: f32, pub uptime_seconds: u64, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MachineInventory { pub cpu_brand: Option, pub host_identifier: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MachineProfile { pub hostname: String, pub os: MachineOs, pub mac_addresses: Vec, pub serial_numbers: Vec, pub inventory: MachineInventory, pub metrics: MachineMetrics, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct HeartbeatPayload { machine_token: String, status: Option, hostname: Option, os: Option, metrics: Option, metadata: Option, } fn collect_mac_addresses() -> Vec { let mut macs = Vec::new(); let mut networks = Networks::new(); networks.refresh_list(); networks.refresh(); for (_, data) in networks.iter() { let bytes = data.mac_address().0; if bytes.iter().all(|byte| *byte == 0) { continue; } let formatted = bytes .iter() .map(|byte| format!("{:02x}", byte)) .collect::>() .join(":"); if !macs.contains(&formatted) { macs.push(formatted); } } macs } #[cfg(target_os = "linux")] fn collect_serials_platform() -> Vec { let mut out = Vec::new(); for path in [ "/sys/class/dmi/id/product_uuid", "/sys/class/dmi/id/product_serial", "/sys/class/dmi/id/board_serial", "/etc/machine-id", ] { if let Ok(raw) = std::fs::read_to_string(path) { let s = raw.trim().to_string(); if !s.is_empty() && !out.contains(&s) { out.push(s); } } } out } #[cfg(any(target_os = "windows", target_os = "macos"))] fn collect_serials_platform() -> Vec { // Fase 1: sem coleta nativa; será implementada via WMI/ioreg na fase 2. Vec::new() } fn collect_serials() -> Vec { collect_serials_platform() } fn collect_network_addrs() -> Vec { // Mapa name -> mac via sysinfo (mais estável que get_if_addrs para MAC) let mut mac_by_name: HashMap = HashMap::new(); let mut networks = Networks::new(); networks.refresh_list(); networks.refresh(); for (name, data) in networks.iter() { let bytes = data.mac_address().0; if bytes.iter().any(|b| *b != 0) { let mac = bytes .iter() .map(|b| format!("{:02x}", b)) .collect::>() .join(":"); mac_by_name.insert(name.to_string(), mac); } } let mut entries = Vec::new(); if let Ok(ifaces) = get_if_addrs::get_if_addrs() { for iface in ifaces { let name = iface.name.clone(); let addr = iface.ip(); let ip = addr.to_string(); let mac = mac_by_name.get(&name).cloned(); entries.push(json!({ "name": name, "mac": mac, "ip": ip, })); } } entries } fn collect_disks(_system: &System) -> Vec { let mut out = Vec::new(); let disks = sysinfo::Disks::new_with_refreshed_list(); for disk in disks.list() { let name = disk.name().to_string_lossy().to_string(); let mount = disk.mount_point().to_string_lossy().to_string(); let fs = disk.file_system().to_string_lossy().to_string(); let total = disk.total_space(); let avail = disk.available_space(); out.push(json!({ "name": if name.is_empty() { mount.clone() } else { name }, "mountPoint": mount, "fs": fs, "totalBytes": total, "availableBytes": avail, })); } out } fn parse_u64(value: &serde_json::Value) -> Option { if let Some(num) = value.as_u64() { return Some(num); } if let Some(num) = value.as_f64() { if num.is_finite() && num >= 0.0 { return Some(num as u64); } } if let Some(text) = value.as_str() { if let Ok(parsed) = text.trim().parse::() { return Some(parsed); } } None } fn push_gpu( list: &mut Vec, name: Option<&str>, memory: Option, vendor: Option<&str>, driver: Option<&str>, ) { if let Some(name) = name { if name.trim().is_empty() { return; } let mut obj = serde_json::Map::new(); obj.insert("name".into(), json!(name.trim())); if let Some(memory) = memory { obj.insert("memoryBytes".into(), json!(memory)); } if let Some(vendor) = vendor { if !vendor.trim().is_empty() { obj.insert("vendor".into(), json!(vendor.trim())); } } if let Some(driver) = driver { if !driver.trim().is_empty() { obj.insert("driver".into(), json!(driver.trim())); } } list.push(serde_json::Value::Object(obj)); } } fn build_inventory_metadata(system: &System) -> serde_json::Value { let cpu_brand = system .cpus() .first() .map(|cpu| cpu.brand().to_string()) .filter(|brand| !brand.trim().is_empty()); // sysinfo 0.31 já retorna bytes em total_memory/used_memory let mem_total_bytes = system.total_memory(); let network = collect_network_addrs(); let disks = collect_disks(system); let mut inventory = json!({ "cpu": { "brand": cpu_brand.clone() }, "memory": { "totalBytes": mem_total_bytes }, "network": network, "disks": disks, }); if let Some(obj) = inventory.as_object_mut() { let mut hardware = serde_json::Map::new(); if let Some(brand) = cpu_brand.clone() { if !brand.trim().is_empty() { hardware.insert("cpuType".into(), json!(brand.trim())); } } if let Some(physical) = system.physical_core_count() { hardware.insert("physicalCores".into(), json!(physical)); } hardware.insert("logicalCores".into(), json!(system.cpus().len())); if mem_total_bytes > 0 { hardware.insert("memoryBytes".into(), json!(mem_total_bytes)); hardware.insert("memory".into(), json!(mem_total_bytes)); } if !hardware.is_empty() { obj.insert("hardware".into(), serde_json::Value::Object(hardware)); } } #[cfg(target_os = "linux")] { // Softwares instalados (dpkg ou rpm) let software = collect_software_linux(); if let Some(obj) = inventory.as_object_mut() { obj.insert("software".into(), software); } // Serviços ativos (systemd) let services = collect_services_linux(); if let Some(obj) = inventory.as_object_mut() { obj.insert("services".into(), services); } // Informações estendidas (lsblk/lspci/lsusb/smartctl) let extended = collect_linux_extended(); if let Some(obj) = inventory.as_object_mut() { obj.insert("extended".into(), extended); } } #[cfg(target_os = "windows")] { let mut extended = collect_windows_extended(); // Fallback: se osInfo vier vazio, preenche com dados do sysinfo if let Some(win) = extended.get_mut("windows").and_then(|v| v.as_object_mut()) { let needs_os_info = match win.get("osInfo") { Some(v) => v.as_object().map(|m| m.is_empty()).unwrap_or(true), None => true, }; if needs_os_info { let mut osmap = serde_json::Map::new(); if let Some(name) = System::name() { osmap.insert("ProductName".into(), json!(name)); } if let Some(ver) = System::os_version() { osmap.insert("Version".into(), json!(ver)); } if let Some(build) = System::kernel_version() { osmap.insert("BuildNumber".into(), json!(build)); } win.insert("osInfo".into(), serde_json::Value::Object(osmap)); } } if let Some(obj) = inventory.as_object_mut() { obj.insert("extended".into(), extended); } } #[cfg(target_os = "macos")] { let extended = collect_macos_extended(); if let Some(obj) = inventory.as_object_mut() { obj.insert("extended".into(), extended); } } // Normalização de software/serviços no topo do inventário if let Some(obj) = inventory.as_object_mut() { let extended_snapshot = obj.get("extended").and_then(|v| v.as_object()).cloned(); // Merge software let mut software: Vec = Vec::new(); if let Some(existing) = obj.get("software").and_then(|v| v.as_array()) { software.extend(existing.iter().cloned()); } if let Some(ext) = extended_snapshot.as_ref() { // Windows normalize if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) { if let Some(ws) = win.get("software").and_then(|v| v.as_array()) { for item in ws { let name = item .get("DisplayName") .or_else(|| item.get("name")) .cloned() .unwrap_or(json!(null)); let version = item .get("DisplayVersion") .or_else(|| item.get("version")) .cloned() .unwrap_or(json!(null)); let publisher = item.get("Publisher").cloned().unwrap_or(json!(null)); software .push(json!({ "name": name, "version": version, "source": publisher })); } } } // macOS normalize if let Some(macos) = ext.get("macos").and_then(|v| v.as_object()) { if let Some(pkgs) = macos.get("packages").and_then(|v| v.as_array()) { for p in pkgs { software.push(json!({ "name": p, "version": null, "source": "pkgutil" })); } } } } if !software.is_empty() { obj.insert("software".into(), json!(software)); } // Merge services let mut services: Vec = Vec::new(); if let Some(existing) = obj.get("services").and_then(|v| v.as_array()) { services.extend(existing.iter().cloned()); } if let Some(ext) = extended_snapshot.as_ref() { if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) { if let Some(wsvc) = win.get("services").and_then(|v| v.as_array()) { for s in wsvc { let name = s.get("Name").cloned().unwrap_or(json!(null)); let status = s.get("Status").cloned().unwrap_or(json!(null)); let display = s.get("DisplayName").cloned().unwrap_or(json!(null)); services.push( json!({ "name": name, "status": status, "displayName": display }), ); } } } } if !services.is_empty() { obj.insert("services".into(), json!(services)); } let mut gpus: Vec = Vec::new(); if let Some(ext) = extended_snapshot.as_ref() { if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) { if let Some(video) = win.get("videoControllers").and_then(|v| v.as_array()) { for controller in video { let name = controller.get("Name").and_then(|v| v.as_str()); let memory = controller.get("AdapterRAM").and_then(parse_u64); let driver = controller.get("DriverVersion").and_then(|v| v.as_str()); push_gpu(&mut gpus, name, memory, None, driver); } } if obj .get("disks") .and_then(|v| v.as_array()) .map(|arr| arr.is_empty()) .unwrap_or(true) { if let Some(raw) = win.get("disks").and_then(|v| v.as_array()) { let mapped = raw .iter() .map(|disk| { let model = disk.get("Model").and_then(|v| v.as_str()).unwrap_or_default(); let serial = disk.get("SerialNumber").and_then(|v| v.as_str()).unwrap_or_default(); let size = parse_u64(disk.get("Size").unwrap_or(&serde_json::Value::Null)).unwrap_or(0); let interface = disk.get("InterfaceType").and_then(|v| v.as_str()).unwrap_or(""); let media = disk.get("MediaType").and_then(|v| v.as_str()).unwrap_or(""); json!({ "name": if !model.is_empty() { model } else { serial }, "mountPoint": "", "fs": if !media.is_empty() { media } else { "—" }, "interface": if !interface.is_empty() { serde_json::Value::String(interface.to_string()) } else { serde_json::Value::Null }, "serial": if !serial.is_empty() { serde_json::Value::String(serial.to_string()) } else { serde_json::Value::Null }, "totalBytes": size, "availableBytes": serde_json::Value::Null, }) }) .collect::>(); if !mapped.is_empty() { obj.insert("disks".into(), json!(mapped)); } } } } if let Some(linux) = ext.get("linux").and_then(|v| v.as_object()) { if let Some(pci) = linux.get("pciList").and_then(|v| v.as_array()) { for entry in pci { if let Some(text) = entry.get("text").and_then(|v| v.as_str()) { let lower = text.to_lowercase(); if lower.contains(" vga ") || lower.contains(" 3d controller") || lower.contains("display controller") { push_gpu(&mut gpus, Some(text), None, None, None); } } } } } if let Some(macos) = ext.get("macos").and_then(|v| v.as_object()) { if let Some(profiler) = macos.get("systemProfiler").and_then(|v| v.as_object()) { if let Some(displays) = profiler .get("SPDisplaysDataType") .and_then(|v| v.as_array()) { for display in displays { if let Some(d) = display.as_object() { let name = d.get("_name").and_then(|v| v.as_str()); let vram = d .get("spdisplays_vram") .and_then(|v| v.as_str()) .and_then(|s| { let digits = s.split_whitespace().next().unwrap_or(""); digits.parse::().ok().map(|n| { if s.to_lowercase().contains("gb") { n * 1024 * 1024 * 1024 } else if s.to_lowercase().contains("mb") { n * 1024 * 1024 } else { n } }) }); push_gpu(&mut gpus, name, vram, None, None); } } } } } } if !gpus.is_empty() { let entry = obj.entry("hardware").or_insert_with(|| json!({})); if let Some(hardware) = entry.as_object_mut() { hardware.insert("gpus".into(), json!(gpus.clone())); if let Some(primary) = gpus.first() { hardware.insert("primaryGpu".into(), primary.clone()); } } } } json!({ "inventory": inventory }) } pub fn collect_inventory_plain() -> serde_json::Value { let system = collect_system(); let meta = build_inventory_metadata(&system); match meta.get("inventory") { Some(value) => value.clone(), None => json!({}), } } #[cfg(target_os = "linux")] fn collect_software_linux() -> serde_json::Value { use std::process::Command; // Tenta dpkg-query primeiro let dpkg = Command::new("sh") .arg("-lc") .arg("dpkg-query -W -f='${binary:Package}\t${Version}\n' 2>/dev/null || true") .output(); if let Ok(out) = dpkg { if out.status.success() { let s = String::from_utf8_lossy(&out.stdout); let mut items = Vec::new(); for line in s.lines() { let mut parts = line.split('\t'); let name = parts.next().unwrap_or("").trim(); let version = parts.next().unwrap_or("").trim(); if !name.is_empty() { items.push(json!({"name": name, "version": version, "source": "dpkg"})); } } return json!(items); } } // Fallback rpm let rpm = std::process::Command::new("sh") .arg("-lc") .arg("rpm -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\n' 2>/dev/null || true") .output(); if let Ok(out) = rpm { if out.status.success() { let s = String::from_utf8_lossy(&out.stdout); let mut items = Vec::new(); for line in s.lines() { let mut parts = line.split('\t'); let name = parts.next().unwrap_or("").trim(); let version = parts.next().unwrap_or("").trim(); if !name.is_empty() { items.push(json!({"name": name, "version": version, "source": "rpm"})); } } return json!(items); } } json!([]) } #[cfg(target_os = "linux")] fn collect_services_linux() -> serde_json::Value { use std::process::Command; let out = Command::new("sh") .arg("-lc") .arg("systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null || true") .output(); if let Ok(out) = out { if out.status.success() { let s = String::from_utf8_lossy(&out.stdout); let mut items = Vec::new(); for line in s.lines() { // Typical format: UNIT LOAD ACTIVE SUB DESCRIPTION // We take UNIT and ACTIVE let cols: Vec<&str> = line.split_whitespace().collect(); if cols.is_empty() { continue; } let unit = cols.get(0).unwrap_or(&""); let active = cols.get(2).copied().unwrap_or(""); if !unit.is_empty() { items.push(json!({"name": unit, "status": active})); } } return json!(items); } } json!([]) } #[cfg(target_os = "linux")] fn collect_linux_extended() -> serde_json::Value { use std::process::Command; // lsblk em JSON (block devices) let block_json = Command::new("sh") .arg("-lc") .arg("lsblk -J -b 2>/dev/null || true") .output() .ok() .and_then(|out| { if out.status.success() { Some(out.stdout) } else { Some(out.stdout) } }) .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) .unwrap_or_else(|| json!({})); // lspci e lsusb — texto livre (depende de pacotes pciutils/usbutils) let lspci = Command::new("sh") .arg("-lc") .arg("lspci 2>/dev/null || true") .output() .ok() .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) .unwrap_or_default(); let lsusb = Command::new("sh") .arg("-lc") .arg("lsusb 2>/dev/null || true") .output() .ok() .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) .unwrap_or_default(); // Parse básico de lspci/lsusb em listas fn parse_lines_to_list(input: &str) -> Vec { input .lines() .map(|l| l.trim()) .filter(|l| !l.is_empty()) .map(|l| json!({ "text": l })) .collect::>() } let pci_list = parse_lines_to_list(&lspci); let usb_list = parse_lines_to_list(&lsusb); // smartctl (se disponível) por disco let mut smart: Vec = Vec::new(); if let Ok(out) = std::process::Command::new("sh") .arg("-lc") .arg("command -v smartctl >/dev/null 2>&1 && echo yes || echo no") .output() { if out.status.success() && String::from_utf8_lossy(&out.stdout).contains("yes") { if let Some(devices) = block_json.get("blockdevices").and_then(|v| v.as_array()) { for dev in devices { let t = dev.get("type").and_then(|v| v.as_str()).unwrap_or(""); let name = dev.get("name").and_then(|v| v.as_str()).unwrap_or(""); if t == "disk" && !name.is_empty() { let path = format!("/dev/{}", name); if let Ok(out) = Command::new("sh") .arg("-lc") .arg(format!("smartctl -H -j {} 2>/dev/null || true", path)) .output() { if out.status.success() || !out.stdout.is_empty() { if let Ok(val) = serde_json::from_slice::(&out.stdout) { smart.push(val); } } } } } } } } json!({ "linux": { "lsblk": block_json, "lspci": lspci, "lsusb": lsusb, "pciList": pci_list, "usbList": usb_list, "smart": smart, } }) } #[cfg(target_os = "windows")] fn collect_windows_extended() -> serde_json::Value { use base64::engine::general_purpose::STANDARD; use base64::Engine as _; use std::os::windows::process::CommandExt; use std::process::Command; const CREATE_NO_WINDOW: u32 = 0x08000000; fn decode_powershell_text(bytes: &[u8]) -> Option { if bytes.is_empty() { return None; } if bytes.starts_with(&[0xFF, 0xFE]) { return decode_utf16_le_to_string(&bytes[2..]); } if bytes.len() >= 2 && bytes[1] == 0 { if let Some(s) = decode_utf16_le_to_string(bytes) { return Some(s); } } if bytes.contains(&0) { if let Some(s) = decode_utf16_le_to_string(bytes) { return Some(s); } } let text = std::str::from_utf8(bytes).ok()?.trim().to_string(); if text.is_empty() { None } else { Some(text) } } fn decode_utf16_le_to_string(bytes: &[u8]) -> Option { if !bytes.len().is_multiple_of(2) { return None; } let utf16: Vec = bytes .chunks_exact(2) .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect(); let text = String::from_utf16(&utf16).ok()?; let trimmed = text.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn preview_base64(bytes: &[u8], max_len: usize) -> String { if bytes.is_empty() { return "".to_string(); } let prefix = if bytes.len() > max_len { &bytes[..max_len] } else { bytes }; format!("base64:{}...", STANDARD.encode(prefix)) } fn encode_ps_script(script: &str) -> String { let mut bytes = Vec::with_capacity(script.len() * 2); for unit in script.encode_utf16() { bytes.extend_from_slice(&unit.to_le_bytes()); } STANDARD.encode(bytes) } fn ps(cmd: &str) -> Option { let script = format!( "$ErrorActionPreference='SilentlyContinue';$ProgressPreference='SilentlyContinue';$result = & {{\n{}\n}};if ($null -eq $result) {{ return }};$json = $result | ConvertTo-Json -Depth 4 -Compress;if ([string]::IsNullOrWhiteSpace($json)) {{ return }};[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;$json;", cmd ); let encoded = encode_ps_script(&script); let out = Command::new("powershell") .creation_flags(CREATE_NO_WINDOW) .arg("-NoProfile") .arg("-NoLogo") .arg("-NonInteractive") .arg("-ExecutionPolicy") .arg("Bypass") .arg("-EncodedCommand") .arg(encoded) .output() .ok()?; let stdout_text = decode_powershell_text(&out.stdout); if cfg!(test) { if let Some(ref txt) = stdout_text { let preview = txt.chars().take(512).collect::(); eprintln!("[collect_windows_extended] stdout `{cmd}` => {preview}"); } else { let preview = preview_base64(&out.stdout, 512); eprintln!( "[collect_windows_extended] stdout `{cmd}` => " ); } if !out.stderr.is_empty() { if let Some(err) = decode_powershell_text(&out.stderr) { eprintln!("[collect_windows_extended] stderr `{cmd}` => {err}"); } else { let preview = preview_base64(&out.stderr, 512); eprintln!( "[collect_windows_extended] stderr `{cmd}` => " ); } } } stdout_text.and_then(|text| serde_json::from_str::(&text).ok()) } let software = ps(r#"@(Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; Get-ItemProperty 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*') | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion, Publisher"#) .unwrap_or_else(|| json!([])); let services = ps("@(Get-Service | Select-Object Name,Status,DisplayName)").unwrap_or_else(|| json!([])); let defender = ps("Get-MpComputerStatus | Select-Object AMRunningMode,AntivirusEnabled,RealTimeProtectionEnabled,AntispywareEnabled").unwrap_or_else(|| json!({})); let hotfix = ps("Get-HotFix | Select-Object HotFixID,InstalledOn").unwrap_or_else(|| json!([])); let bitlocker = ps( "@(if (Get-Command -Name Get-BitLockerVolume -ErrorAction SilentlyContinue) { Get-BitLockerVolume | Select-Object MountPoint,VolumeStatus,ProtectionStatus,LockStatus,EncryptionMethod,EncryptionPercentage,CapacityGB,KeyProtector } else { @() })", ) .unwrap_or_else(|| json!([])); let tpm = ps( "if (Get-Command -Name Get-Tpm -ErrorAction SilentlyContinue) { Get-Tpm | Select-Object TpmPresent,TpmReady,TpmEnabled,TpmActivated,ManagedAuthLevel,OwnerAuth,ManufacturerId,ManufacturerIdTxt,ManufacturerVersion,ManufacturerVersionFull20,SpecVersion } else { $null }", ) .unwrap_or_else(|| json!({})); let secure_boot = ps( r#" if (-not (Get-Command -Name Confirm-SecureBootUEFI -ErrorAction SilentlyContinue)) { [PSCustomObject]@{ Supported = $false; Enabled = $null; Error = 'Cmdlet Confirm-SecureBootUEFI indisponível' } } else { try { $enabled = Confirm-SecureBootUEFI [PSCustomObject]@{ Supported = $true; Enabled = [bool]$enabled; Error = $null } } catch { [PSCustomObject]@{ Supported = $true; Enabled = $null; Error = $_.Exception.Message } } } "#, ) .unwrap_or_else(|| json!({})); let device_guard = ps( "@(Get-CimInstance -ClassName Win32_DeviceGuard | Select-Object SecurityServicesConfigured,SecurityServicesRunning,RequiredSecurityProperties,AvailableSecurityProperties,VirtualizationBasedSecurityStatus)", ) .unwrap_or_else(|| json!([])); let firewall_profiles = ps( "@(Get-NetFirewallProfile | Select-Object Name,Enabled,DefaultInboundAction,DefaultOutboundAction,NotifyOnListen)", ) .unwrap_or_else(|| json!([])); let windows_update = ps( r#" $reg = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update' -ErrorAction SilentlyContinue if ($null -eq $reg) { return $null } $last = $null if ($reg.PSObject.Properties.Name -contains 'LastSuccessTime') { $raw = $reg.LastSuccessTime if ($raw) { try { if ($raw -is [DateTime]) { $last = ($raw.ToUniversalTime()).ToString('o') } elseif ($raw -is [string]) { $last = $raw } else { $last = [DateTime]::FromFileTimeUtc([long]$raw).ToString('o') } } catch { $last = $raw } } } [PSCustomObject]@{ AUOptions = $reg.AUOptions NoAutoUpdate = $reg.NoAutoUpdate ScheduledInstallDay = $reg.ScheduledInstallDay ScheduledInstallTime = $reg.ScheduledInstallTime DetectionFrequency = $reg.DetectionFrequencyEnabled LastSuccessTime = $last } "#, ) .unwrap_or_else(|| json!({})); let computer_system = ps( "Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer,Model,Domain,DomainRole,PartOfDomain,Workgroup,TotalPhysicalMemory,HypervisorPresent,PCSystemType,PCSystemTypeEx", ) .unwrap_or_else(|| json!({})); let device_join = ps( r#" $output = & dsregcmd.exe /status 2>$null if (-not $output) { return $null } $map = [ordered]@{} $current = $null foreach ($line in $output) { if ([string]::IsNullOrWhiteSpace($line)) { continue } if ($line -match '^\[(.+)\]$') { $current = $matches[1].Trim() if (-not $map.Contains($current)) { $map[$current] = [ordered]@{} } continue } if (-not $current) { continue } $parts = $line.Split(':', 2) if ($parts.Length -ne 2) { continue } $key = $parts[0].Trim() $value = $parts[1].Trim() if ($key) { ($map[$current])[$key] = $value } } if ($map.Count -eq 0) { return $null } $obj = [ordered]@{} foreach ($entry in $map.GetEnumerator()) { $obj[$entry.Key] = [PSCustomObject]$entry.Value } [PSCustomObject]$obj "#, ) .unwrap_or_else(|| json!({})); // Informações de build/edição e ativação let os_info = ps(r#" $cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'; $os = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue; $lsItems = Get-CimInstance -Query "SELECT Name, LicenseStatus, PartialProductKey FROM SoftwareLicensingProduct WHERE PartialProductKey IS NOT NULL" | Where-Object { $_.Name -like 'Windows*' }; $activatedItem = $lsItems | Where-Object { $_.LicenseStatus -eq 1 } | Select-Object -First 1; $primaryItem = if ($activatedItem) { $activatedItem } else { $lsItems | Select-Object -First 1 }; $lsCode = if ($primaryItem -and $primaryItem.LicenseStatus -ne $null) { [int]$primaryItem.LicenseStatus } else { 0 }; [PSCustomObject]@{ ProductName = $cv.ProductName CurrentBuild = $cv.CurrentBuild CurrentBuildNumber = $cv.CurrentBuildNumber DisplayVersion = $cv.DisplayVersion ReleaseId = $cv.ReleaseId EditionID = $cv.EditionID UBR = $cv.UBR CompositionEditionID = $cv.CompositionEditionID InstallationType = $cv.InstallationType InstallDate = $cv.InstallDate InstallationDate = $os.InstallDate InstalledOn = $os.InstallDate Version = $os.Version BuildNumber = $os.BuildNumber Caption = $os.Caption FeatureExperiencePack = $cv.FeatureExperiencePack LicenseStatus = $lsCode IsActivated = ($activatedItem -ne $null) } "#).unwrap_or_else(|| json!({})); // Hardware detalhado (CPU/Board/BIOS/Memória/Vídeo/Discos) let cpu = ps("Get-CimInstance Win32_Processor | Select-Object Name,Manufacturer,SocketDesignation,NumberOfCores,NumberOfLogicalProcessors,L2CacheSize,L3CacheSize,MaxClockSpeed").unwrap_or_else(|| json!({})); let baseboard = ps( "Get-CimInstance Win32_BaseBoard | Select-Object Product,Manufacturer,SerialNumber,Version", ) .unwrap_or_else(|| json!({})); let bios = ps("Get-CimInstance Win32_BIOS | Select-Object Manufacturer,SMBIOSBIOSVersion,ReleaseDate,Version").unwrap_or_else(|| json!({})); let memory = ps("@(Get-CimInstance Win32_PhysicalMemory | Select-Object BankLabel,Capacity,Manufacturer,PartNumber,SerialNumber,ConfiguredClockSpeed,Speed,ConfiguredVoltage)").unwrap_or_else(|| json!([])); // Coleta de GPU com VRAM correta (nvidia-smi para NVIDIA, registro como fallback para >4GB) let video = ps(r#" $gpus = @() $wmiGpus = Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,PNPDeviceID foreach ($gpu in $wmiGpus) { $vram = $gpu.AdapterRAM # Tenta nvidia-smi para GPUs NVIDIA (retorna valor correto para >4GB) if ($gpu.Name -match 'NVIDIA') { try { $nvidiaSmi = & 'nvidia-smi' '--query-gpu=memory.total' '--format=csv,noheader,nounits' 2>$null if ($nvidiaSmi) { $vramMB = [int64]($nvidiaSmi.Trim()) $vram = $vramMB * 1024 * 1024 } } catch {} } # Fallback: tenta registro do Windows (qwMemorySize é uint64) if ($vram -le 4294967296 -and $vram -gt 0) { try { $regPath = 'HKLM:\SYSTEM\ControlSet001\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}\0*' $regGpus = Get-ItemProperty $regPath -ErrorAction SilentlyContinue foreach ($reg in $regGpus) { if ($reg.DriverDesc -eq $gpu.Name -and $reg.'HardwareInformation.qwMemorySize') { $vram = [int64]$reg.'HardwareInformation.qwMemorySize' break } } } catch {} } $gpus += [PSCustomObject]@{ Name = $gpu.Name AdapterRAM = $vram DriverVersion = $gpu.DriverVersion PNPDeviceID = $gpu.PNPDeviceID } } @($gpus) "#).unwrap_or_else(|| json!([])); let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([])); // Bateria (notebooks/laptops) let battery = ps(r#" $batteries = @(Get-CimInstance Win32_Battery | Select-Object Name,DeviceID,Status,BatteryStatus,EstimatedChargeRemaining,EstimatedRunTime,DesignCapacity,FullChargeCapacity,DesignVoltage,Chemistry,BatteryRechargeTime) if ($batteries.Count -eq 0) { [PSCustomObject]@{ Present = $false; Batteries = @() } } else { # Mapeia status numérico para texto $statusMap = @{ 1 = 'Discharging' 2 = 'AC Power' 3 = 'Fully Charged' 4 = 'Low' 5 = 'Critical' 6 = 'Charging' 7 = 'Charging High' 8 = 'Charging Low' 9 = 'Charging Critical' 10 = 'Undefined' 11 = 'Partially Charged' } foreach ($b in $batteries) { if ($b.BatteryStatus) { $b | Add-Member -NotePropertyName 'BatteryStatusText' -NotePropertyValue ($statusMap[[int]$b.BatteryStatus] ?? 'Unknown') -Force } } [PSCustomObject]@{ Present = $true; Batteries = $batteries } } "#).unwrap_or_else(|| json!({ "Present": false, "Batteries": [] })); // Sensores térmicos (temperatura CPU/GPU quando disponível) let thermal = ps(r#" $temps = @() # Tenta WMI thermal zone (requer admin em alguns sistemas) try { $zones = Get-CimInstance -Namespace 'root/WMI' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue foreach ($z in $zones) { if ($z.CurrentTemperature) { $celsius = [math]::Round(($z.CurrentTemperature - 2732) / 10, 1) $temps += [PSCustomObject]@{ Source = 'ThermalZone' Name = $z.InstanceName TemperatureCelsius = $celsius CriticalTripPoint = if ($z.CriticalTripPoint) { [math]::Round(($z.CriticalTripPoint - 2732) / 10, 1) } else { $null } } } } } catch {} # CPU temp via Open Hardware Monitor WMI (se instalado) try { $ohm = Get-CimInstance -Namespace 'root/OpenHardwareMonitor' -ClassName Sensor -ErrorAction SilentlyContinue | Where-Object { $_.SensorType -eq 'Temperature' } foreach ($s in $ohm) { $temps += [PSCustomObject]@{ Source = 'OpenHardwareMonitor' Name = $s.Name TemperatureCelsius = $s.Value Parent = $s.Parent } } } catch {} @($temps) "#).unwrap_or_else(|| json!([])); // Adaptadores de rede (físicos e virtuais) let network_adapters = ps(r#" @(Get-CimInstance Win32_NetworkAdapter | Where-Object { $_.PhysicalAdapter -eq $true -or $_.NetConnectionStatus -ne $null } | Select-Object Name,Description,MACAddress,Speed,NetConnectionStatus,AdapterType,Manufacturer,NetConnectionID,PNPDeviceID | ForEach-Object { $statusMap = @{ 0 = 'Disconnected' 1 = 'Connecting' 2 = 'Connected' 3 = 'Disconnecting' 4 = 'Hardware not present' 5 = 'Hardware disabled' 6 = 'Hardware malfunction' 7 = 'Media disconnected' 8 = 'Authenticating' 9 = 'Authentication succeeded' 10 = 'Authentication failed' 11 = 'Invalid address' 12 = 'Credentials required' } $_ | Add-Member -NotePropertyName 'StatusText' -NotePropertyValue ($statusMap[[int]$_.NetConnectionStatus] ?? 'Unknown') -Force $_ }) "#).unwrap_or_else(|| json!([])); // Monitores conectados let monitors = ps(r#" @(Get-CimInstance WmiMonitorID -Namespace root/wmi -ErrorAction SilentlyContinue | ForEach-Object { $decode = { param($arr) if ($arr) { -join ($arr | Where-Object { $_ -ne 0 } | ForEach-Object { [char]$_ }) } else { $null } } [PSCustomObject]@{ ManufacturerName = & $decode $_.ManufacturerName ProductCodeID = & $decode $_.ProductCodeID SerialNumberID = & $decode $_.SerialNumberID UserFriendlyName = & $decode $_.UserFriendlyName YearOfManufacture = $_.YearOfManufacture WeekOfManufacture = $_.WeekOfManufacture } }) "#).unwrap_or_else(|| json!([])); // Fonte de alimentação / chassis let power_supply = ps(r#" $chassis = Get-CimInstance Win32_SystemEnclosure | Select-Object ChassisTypes,Manufacturer,SerialNumber,SMBIOSAssetTag $chassisTypeMap = @{ 1 = 'Other'; 2 = 'Unknown'; 3 = 'Desktop'; 4 = 'Low Profile Desktop' 5 = 'Pizza Box'; 6 = 'Mini Tower'; 7 = 'Tower'; 8 = 'Portable' 9 = 'Laptop'; 10 = 'Notebook'; 11 = 'Hand Held'; 12 = 'Docking Station' 13 = 'All in One'; 14 = 'Sub Notebook'; 15 = 'Space-Saving'; 16 = 'Lunch Box' 17 = 'Main Server Chassis'; 18 = 'Expansion Chassis'; 19 = 'SubChassis' 20 = 'Bus Expansion Chassis'; 21 = 'Peripheral Chassis'; 22 = 'RAID Chassis' 23 = 'Rack Mount Chassis'; 24 = 'Sealed-case PC'; 25 = 'Multi-system chassis' 30 = 'Tablet'; 31 = 'Convertible'; 32 = 'Detachable' } $types = @() if ($chassis.ChassisTypes) { foreach ($t in $chassis.ChassisTypes) { $types += $chassisTypeMap[[int]$t] ?? "Type$t" } } [PSCustomObject]@{ ChassisTypes = $chassis.ChassisTypes ChassisTypesText = $types Manufacturer = $chassis.Manufacturer SerialNumber = $chassis.SerialNumber SMBIOSAssetTag = $chassis.SMBIOSAssetTag } "#).unwrap_or_else(|| json!({})); // Último reinício e contagem de boots let boot_info = ps(r#" $os = Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime $lastBoot = $os.LastBootUpTime # Calcula uptime $uptime = if ($lastBoot) { (New-TimeSpan -Start $lastBoot -End (Get-Date)).TotalSeconds } else { 0 } # Conta eventos de boot (ID 6005) - últimos 30 dias para performance $startDate = (Get-Date).AddDays(-30) $bootEvents = @() $bootCount = 0 try { $events = Get-WinEvent -FilterHashtable @{ LogName = 'System' ID = 6005 StartTime = $startDate } -MaxEvents 50 -ErrorAction SilentlyContinue $bootCount = @($events).Count $bootEvents = @($events | Select-Object -First 10 | ForEach-Object { @{ TimeCreated = $_.TimeCreated.ToString('o') Computer = $_.MachineName } }) } catch {} [PSCustomObject]@{ LastBootTime = if ($lastBoot) { $lastBoot.ToString('o') } else { $null } UptimeSeconds = [math]::Round($uptime) BootCountLast30Days = $bootCount RecentBoots = $bootEvents } "#).unwrap_or_else(|| json!({ "LastBootTime": null, "UptimeSeconds": 0, "BootCountLast30Days": 0, "RecentBoots": [] })); json!({ "windows": { "software": software, "services": services, "defender": defender, "hotfix": hotfix, "osInfo": os_info, "cpu": cpu, "baseboard": baseboard, "bios": bios, "memoryModules": memory, "videoControllers": video, "disks": disks, "bitLocker": bitlocker, "tpm": tpm, "secureBoot": secure_boot, "deviceGuard": device_guard, "firewallProfiles": firewall_profiles, "windowsUpdate": windows_update, "computerSystem": computer_system, "azureAdStatus": device_join, "battery": battery, "thermal": thermal, "networkAdapters": network_adapters, "monitors": monitors, "chassis": power_supply, "bootInfo": boot_info, } }) } #[cfg(target_os = "macos")] fn collect_macos_extended() -> serde_json::Value { use std::process::Command; // system_profiler em JSON (pode ser pesado; limitar a alguns tipos) let profiler = Command::new("sh") .arg("-lc") .arg("system_profiler -json SPHardwareDataType SPSoftwareDataType SPNetworkDataType SPStorageDataType SPDisplaysDataType 2>/dev/null || true") .output() .ok() .and_then(|out| serde_json::from_slice::(&out.stdout).ok()) .unwrap_or_else(|| json!({})); let pkgs = Command::new("sh") .arg("-lc") .arg("pkgutil --pkgs 2>/dev/null || true") .output() .ok() .map(|out| { String::from_utf8_lossy(&out.stdout) .lines() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::>() }) .unwrap_or_default(); let services_text = Command::new("sh") .arg("-lc") .arg("launchctl list 2>/dev/null || true") .output() .ok() .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) .unwrap_or_default(); json!({ "macos": { "systemProfiler": profiler, "packages": pkgs, "launchctl": services_text, } }) } fn collect_system() -> System { let mut system = System::new_all(); system.refresh_all(); system } fn collect_metrics(system: &System) -> MachineMetrics { let collected_at = Utc::now(); let total_memory = system.total_memory(); let used_memory = system.used_memory(); // sysinfo 0.31: valores já em bytes let memory_total_bytes = total_memory; let memory_used_bytes = used_memory; let memory_used_percent = if total_memory > 0 { (used_memory as f32 / total_memory as f32) * 100.0 } else { 0.0 }; let load = System::load_average(); let cpu_usage_percent = system.global_cpu_usage(); let cpu_logical_cores = system.cpus().len(); let cpu_physical_cores = system.physical_core_count(); MachineMetrics { collected_at, cpu_logical_cores, cpu_physical_cores, cpu_usage_percent, load_average_one: Some(load.one), load_average_five: Some(load.five), load_average_fifteen: Some(load.fifteen), memory_total_bytes, memory_used_bytes, memory_used_percent, uptime_seconds: System::uptime(), } } pub fn collect_profile() -> Result { let hostname = hostname::get() .map_err(|_| AgentError::Hostname)? .to_string_lossy() .trim() .to_string(); let system = collect_system(); let os_name = System::name() .or_else(System::long_os_version) .unwrap_or_else(|| "desconhecido".to_string()); let os_version = System::os_version(); let architecture = std::env::consts::ARCH.to_string(); let mac_addresses = collect_mac_addresses(); let serials: Vec = collect_serials(); if mac_addresses.is_empty() && serials.is_empty() { return Err(AgentError::MissingIdentifiers); } let metrics = collect_metrics(&system); let cpu_brand = system .cpus() .first() .map(|cpu| cpu.brand().to_string()) .filter(|brand| !brand.trim().is_empty()); let inventory = MachineInventory { cpu_brand, host_identifier: serials.first().cloned(), }; Ok(MachineProfile { hostname, os: MachineOs { name: os_name, version: os_version, architecture: Some(architecture), }, mac_addresses, serial_numbers: serials, inventory, metrics, }) } static HTTP_CLIENT: Lazy = Lazy::new(|| { reqwest::Client::builder() .user_agent("sistema-de-chamados-agent/1.0") .timeout(Duration::from_secs(20)) .use_rustls_tls() .build() .expect("failed to build http client") }); async fn post_heartbeat( base_url: &str, token: &str, status: Option, ) -> Result<(), AgentError> { let system = collect_system(); let metrics = collect_metrics(&system); let hostname = hostname::get() .map_err(|_| AgentError::Hostname)? .to_string_lossy() .into_owned(); let os = MachineOs { name: System::name() .or_else(System::long_os_version) .unwrap_or_else(|| "desconhecido".to_string()), version: System::os_version(), architecture: Some(std::env::consts::ARCH.to_string()), }; let payload = HeartbeatPayload { machine_token: token.to_string(), status, hostname: Some(hostname), os: Some(os), metrics: Some(metrics), metadata: Some(build_inventory_metadata(&system)), }; let url = format!("{}/api/machines/heartbeat", base_url); HTTP_CLIENT.post(url).json(&payload).send().await?; Ok(()) } #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct UsbPolicyResponse { pending: bool, policy: Option, #[allow(dead_code)] applied_at: Option, } #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] struct UsbPolicyStatusReport { machine_token: String, status: String, error: Option, current_policy: Option, } async fn check_and_apply_usb_policy(base_url: &str, token: &str) { crate::log_info!("Verificando politica USB pendente..."); let url = format!("{}/api/machines/usb-policy?machineToken={}", base_url, token); let response = match HTTP_CLIENT.get(&url).send().await { Ok(resp) => { crate::log_info!("Resposta da verificacao de politica USB: status={}", resp.status()); resp } Err(e) => { crate::log_error!("Falha ao verificar politica USB: {e}"); return; } }; let policy_response: UsbPolicyResponse = match response.json().await { Ok(data) => data, Err(e) => { crate::log_error!("Falha ao parsear resposta de politica USB: {e}"); return; } }; if !policy_response.pending { crate::log_info!("Nenhuma politica USB pendente"); return; } let policy_str = match policy_response.policy { Some(p) => p, None => { crate::log_warn!("Politica USB pendente mas sem valor de policy"); return; } }; crate::log_info!("Politica USB pendente encontrada: {}", policy_str); #[cfg(target_os = "windows")] { use crate::usb_control::{get_current_policy, UsbPolicy}; use crate::service_client; let policy = match UsbPolicy::from_str(&policy_str) { Some(p) => p, None => { crate::log_error!("Politica USB invalida: {}", policy_str); report_usb_policy_status(base_url, token, "FAILED", Some(format!("Politica invalida: {}", policy_str)), None).await; return; } }; // Verifica se a politica ja esta aplicada localmente match get_current_policy() { Ok(current) if current == policy => { crate::log_info!("Politica USB ja esta aplicada localmente: {}", policy_str); let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await; if !reported { crate::log_error!("Falha ao reportar politica ja aplicada"); } return; } Ok(current) => { crate::log_info!("Politica atual: {:?}, esperada: {:?}", current, policy); } Err(e) => { crate::log_warn!("Nao foi possivel ler politica atual: {e}"); } } crate::log_info!("Aplicando politica USB: {}", policy_str); // Reporta APPLYING para progress bar real no frontend let _ = report_usb_policy_status(base_url, token, "APPLYING", None, None).await; // Tenta primeiro via RavenService (privilegiado) crate::log_info!("Tentando aplicar politica via RavenService..."); match service_client::apply_usb_policy(&policy_str) { Ok(result) => { if result.success { crate::log_info!("Politica USB aplicada com sucesso via RavenService: {:?}", result); let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await; if !reported { crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!"); let base_url = base_url.to_string(); let token = token.to_string(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(60)).await; crate::log_info!("Retry agendado: reportando politica USB..."); let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await; }); } return; } else { let err_msg = result.error.unwrap_or_else(|| "Erro desconhecido".to_string()); crate::log_error!("RavenService retornou erro: {}", err_msg); report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await; } } Err(service_client::ServiceClientError::ServiceUnavailable(msg)) => { crate::log_warn!("RavenService nao disponivel: {}", msg); // Tenta fallback direto (vai falhar se nao tiver privilegio) crate::log_info!("Tentando aplicar politica diretamente..."); match crate::usb_control::apply_usb_policy(policy) { Ok(result) => { crate::log_info!("Politica USB aplicada com sucesso (direto): {:?}", result); let reported = report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str.clone())).await; if !reported { crate::log_error!("CRITICO: Politica aplicada mas falha ao reportar ao servidor!"); let base_url = base_url.to_string(); let token = token.to_string(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(60)).await; crate::log_info!("Retry agendado: reportando politica USB..."); let _ = report_usb_policy_status(&base_url, &token, "APPLIED", None, Some(policy_str)).await; }); } } Err(e) => { let err_msg = format!("RavenService indisponivel e aplicacao direta falhou: {}. Instale ou inicie o RavenService.", e); crate::log_error!("{}", err_msg); report_usb_policy_status(base_url, token, "FAILED", Some(err_msg), None).await; } } } Err(e) => { crate::log_error!("Falha ao comunicar com RavenService: {e}"); report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await; } } } #[cfg(not(target_os = "windows"))] { crate::log_warn!("Controle de USB nao suportado neste sistema operacional"); report_usb_policy_status(base_url, token, "FAILED", Some("Sistema operacional nao suportado".to_string()), None).await; } } async fn report_usb_policy_status( base_url: &str, token: &str, status: &str, error: Option, current_policy: Option, ) -> bool { let url = format!("{}/api/machines/usb-policy", base_url); let report = UsbPolicyStatusReport { machine_token: token.to_string(), status: status.to_string(), error, current_policy, }; crate::log_info!("Reportando status de politica USB: status={}", status); // Retry simples: 1 tentativa imediata + 1 retry após 2s let delays = [2]; let mut last_error = None; for (attempt, delay_secs) in delays.iter().enumerate() { match HTTP_CLIENT.post(&url).json(&report).send().await { Ok(response) => { let status_code = response.status(); if status_code.is_success() { crate::log_info!( "Report de politica USB enviado com sucesso na tentativa {}", attempt + 1 ); return true; } else { let body = response.text().await.unwrap_or_default(); last_error = Some(format!("HTTP {} - {}", status_code, body)); crate::log_warn!( "Report de politica USB falhou (tentativa {}): HTTP {}", attempt + 1, status_code ); } } Err(e) => { last_error = Some(e.to_string()); crate::log_warn!( "Report de politica USB falhou (tentativa {}): {}", attempt + 1, e ); } } if attempt < delays.len() - 1 { crate::log_info!("Retentando report de politica USB em {}s...", delay_secs); tokio::time::sleep(Duration::from_secs(*delay_secs)).await; } } if let Some(err) = last_error { crate::log_error!( "Falha ao reportar status de politica USB apos {} tentativas: {err}", delays.len() ); } false } struct HeartbeatHandle { token: String, base_url: String, stop_signal: Arc, join_handle: JoinHandle<()>, } impl HeartbeatHandle { fn stop(self) { self.stop_signal.notify_waiters(); self.join_handle.abort(); } } #[derive(Default, Clone)] pub struct AgentRuntime { inner: Arc>>, } fn sanitize_base_url(input: &str) -> Result { let trimmed = input.trim().trim_end_matches('/'); if trimmed.is_empty() { return Err(AgentError::InvalidApiUrl); } Ok(trimmed.to_string()) } impl AgentRuntime { pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(None)), } } pub fn start_heartbeat( &self, base_url: String, token: String, status: Option, interval_seconds: Option, ) -> Result<(), AgentError> { let sanitized_base = sanitize_base_url(&base_url)?; let interval = interval_seconds.unwrap_or(300).max(60); { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { if handle.token == token && handle.base_url == sanitized_base { // Reuse existing heartbeat; keep running. *guard = Some(handle); return Ok(()); } handle.stop(); } } let stop_signal = Arc::new(Notify::new()); let stop_signal_clone = stop_signal.clone(); let token_clone = token.clone(); let base_clone = sanitized_base.clone(); let status_clone = status.clone(); let join_handle = async_runtime::spawn(async move { crate::log_info!("Loop de agente iniciado"); if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { crate::log_error!("Falha inicial ao enviar heartbeat: {error}"); } else { crate::log_info!("Heartbeat inicial enviado com sucesso"); } // Verifica politica USB apos heartbeat inicial check_and_apply_usb_policy(&base_clone, &token_clone).await; let mut heartbeat_ticker = tokio::time::interval(Duration::from_secs(interval)); heartbeat_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let mut usb_ticker = tokio::time::interval(Duration::from_secs(15)); usb_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { // Wait interval tokio::select! { _ = stop_signal_clone.notified() => { crate::log_info!("Loop de agente encerrado por sinal de parada"); break; } _ = heartbeat_ticker.tick() => {} _ = usb_ticker.tick() => { check_and_apply_usb_policy(&base_clone, &token_clone).await; continue; } } if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { crate::log_error!("Falha ao enviar heartbeat: {error}"); } // Verifica politica USB apos cada heartbeat check_and_apply_usb_policy(&base_clone, &token_clone).await; } }); let handle = HeartbeatHandle { token, base_url: sanitized_base, stop_signal, join_handle, }; let mut guard = self.inner.lock(); *guard = Some(handle); Ok(()) } pub fn stop(&self) { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { handle.stop(); } } } #[cfg(all(test, target_os = "windows"))] mod windows_tests { use super::collect_windows_extended; use serde_json::Value; fn expect_object<'a>(value: &'a Value, context: &str) -> &'a serde_json::Map { value .as_object() .unwrap_or_else(|| panic!("{context} não é um objeto JSON: {value:?}")) } #[test] fn collects_activation_and_defender_status() { let extended = collect_windows_extended(); let windows = extended.get("windows").unwrap_or_else(|| { panic!("payload windows ausente: {extended:?}"); }); let windows_obj = expect_object(windows, "windows"); let os_info = windows_obj .get("osInfo") .unwrap_or_else(|| panic!("windows.osInfo ausente: {windows_obj:?}")); let os_info_obj = expect_object(os_info, "windows.osInfo"); let is_activated = os_info_obj.get("IsActivated").unwrap_or_else(|| { panic!("campo IsActivated ausente em windows.osInfo: {os_info_obj:?}") }); assert!( is_activated.as_bool().is_some(), "esperava booleano em windows.osInfo.IsActivated, valor recebido: {is_activated:?}" ); let license_status = os_info_obj.get("LicenseStatus").unwrap_or_else(|| { panic!("campo LicenseStatus ausente em windows.osInfo: {os_info_obj:?}") }); assert!( license_status.as_i64().is_some(), "esperava número em windows.osInfo.LicenseStatus, valor recebido: {license_status:?}" ); let defender = windows_obj.get("defender").unwrap_or_else(|| { panic!("windows.defender ausente: {windows_obj:?}"); }); let defender_obj = expect_object(defender, "windows.defender"); let realtime = defender_obj .get("RealTimeProtectionEnabled") .unwrap_or_else(|| { panic!( "campo RealTimeProtectionEnabled ausente em windows.defender: {defender_obj:?}" ) }); assert!( realtime.as_bool().is_some(), "esperava booleano em windows.defender.RealTimeProtectionEnabled, valor recebido: {realtime:?}" ); } }