sistema-de-chamados/apps/desktop/src-tauri/src/agent.rs
rever-tecnologia f39bd46c2b
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m0s
Quality Checks / Lint, Test and Build (push) Successful in 3m29s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m24s
feat: adiciona informacoes de reinicio e melhora SLA global
- Agente Rust: captura LastBootTime, uptime e contagem de boots
- Backend: extrai campos do extended (bootInfo, discos, RAM, etc) antes de salvar
- Frontend /devices: exibe secao de ultimo reinicio
- SLA global: adiciona campos de modo, threshold de alerta e status de pausa
- Corrige acento em "destinatario" -> "destinatario" em automations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 09:38:58 -03:00

1748 lines
66 KiB
Rust

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<String>,
pub architecture: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MachineMetrics {
pub collected_at: DateTime<Utc>,
pub cpu_logical_cores: usize,
pub cpu_physical_cores: Option<usize>,
pub cpu_usage_percent: f32,
pub load_average_one: Option<f64>,
pub load_average_five: Option<f64>,
pub load_average_fifteen: Option<f64>,
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<String>,
pub host_identifier: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MachineProfile {
pub hostname: String,
pub os: MachineOs,
pub mac_addresses: Vec<String>,
pub serial_numbers: Vec<String>,
pub inventory: MachineInventory,
pub metrics: MachineMetrics,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct HeartbeatPayload {
machine_token: String,
status: Option<String>,
hostname: Option<String>,
os: Option<MachineOs>,
metrics: Option<MachineMetrics>,
metadata: Option<serde_json::Value>,
}
fn collect_mac_addresses() -> Vec<String> {
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::<Vec<_>>()
.join(":");
if !macs.contains(&formatted) {
macs.push(formatted);
}
}
macs
}
#[cfg(target_os = "linux")]
fn collect_serials_platform() -> Vec<String> {
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<String> {
// Fase 1: sem coleta nativa; será implementada via WMI/ioreg na fase 2.
Vec::new()
}
fn collect_serials() -> Vec<String> {
collect_serials_platform()
}
fn collect_network_addrs() -> Vec<serde_json::Value> {
// Mapa name -> mac via sysinfo (mais estável que get_if_addrs para MAC)
let mut mac_by_name: HashMap<String, String> = 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::<Vec<_>>()
.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<serde_json::Value> {
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<u64> {
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::<u64>() {
return Some(parsed);
}
}
None
}
fn push_gpu(
list: &mut Vec<serde_json::Value>,
name: Option<&str>,
memory: Option<u64>,
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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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::<Vec<_>>();
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::<u64>().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::<serde_json::Value>(&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<serde_json::Value> {
input
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|l| json!({ "text": l }))
.collect::<Vec<_>>()
}
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<serde_json::Value> = 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::<serde_json::Value>(&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<String> {
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<String> {
if !bytes.len().is_multiple_of(2) {
return None;
}
let utf16: Vec<u16> = 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 "<empty>".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<serde_json::Value> {
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::<String>();
eprintln!("[collect_windows_extended] stdout `{cmd}` => {preview}");
} else {
let preview = preview_base64(&out.stdout, 512);
eprintln!(
"[collect_windows_extended] stdout `{cmd}` => <não decodificado {preview}>"
);
}
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}` => <não decodificado {preview}>"
);
}
}
}
stdout_text.and_then(|text| serde_json::from_str::<serde_json::Value>(&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::<serde_json::Value>(&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::<Vec<_>>()
})
.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<MachineProfile, AgentError> {
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<String> = 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<reqwest::Client> = 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<String>,
) -> 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<String>,
#[allow(dead_code)]
applied_at: Option<i64>,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct UsbPolicyStatusReport {
machine_token: String,
status: String,
error: Option<String>,
current_policy: Option<String>,
}
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<String>,
current_policy: Option<String>,
) -> 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<Notify>,
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<Mutex<Option<HeartbeatHandle>>>,
}
fn sanitize_base_url(input: &str) -> Result<String, AgentError> {
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<String>,
interval_seconds: Option<u64>,
) -> 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<String, Value> {
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:?}"
);
}
}