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 máquina")] 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 parse_utf16_le_bytes(bytes: &[u8]) -> Option { if bytes.len() % 2 != 0 { 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() { return None; } serde_json::from_str(trimmed).ok() } fn parse_powershell_json(bytes: &[u8]) -> Option { let text = decode_powershell_text(bytes)?; if text.is_empty() { return None; } match serde_json::from_str::(&text) { Ok(value) => Some(value), Err(err) => { if cfg!(test) { let preview = text.chars().take(512).collect::(); eprintln!( "[collect_windows_extended] falha ao interpretar JSON: {err}; amostra: {preview}" ); } None } } } 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() % 2 != 0 { 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 = ({});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!([])); // 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!([])); let video = ps("@(Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,PNPDeviceID)").unwrap_or_else(|| json!([])); let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([])); 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, } }) } #[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(()) } 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)] pub struct AgentRuntime { inner: Mutex>, } 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: 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 { if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { eprintln!("[agent] Falha inicial ao enviar heartbeat: {error}"); } let mut ticker = tokio::time::interval(Duration::from_secs(interval)); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { // Wait interval tokio::select! { _ = stop_signal_clone.notified() => { break; } _ = ticker.tick() => {} } if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { eprintln!("[agent] Falha ao enviar heartbeat: {error}"); } } }); 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:?}" ); } }