1299 lines
47 KiB
Rust
1299 lines
47 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() % 2 != 0 {
|
|
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!([]));
|
|
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,
|
|
"bitLocker": bitlocker,
|
|
"tpm": tpm,
|
|
"secureBoot": secure_boot,
|
|
"deviceGuard": device_guard,
|
|
"firewallProfiles": firewall_profiles,
|
|
"windowsUpdate": windows_update,
|
|
"computerSystem": computer_system,
|
|
"azureAdStatus": device_join,
|
|
}
|
|
})
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
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)]
|
|
pub struct AgentRuntime {
|
|
inner: 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: 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 {
|
|
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<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:?}"
|
|
);
|
|
}
|
|
}
|