sistema-de-chamados/apps/desktop/src-tauri/src/agent.rs
2025-10-09 20:38:53 -03:00

509 lines
15 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 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<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> {
let mut entries = Vec::new();
if let Ok(ifaces) = get_if_addrs::get_if_addrs() {
for iface in ifaces {
let name = iface.name;
let mac = iface.mac.map(|m| m.to_string());
let addr = iface.ip();
let ip = addr.to_string();
entries.push(json!({
"name": name,
"mac": mac,
"ip": ip,
}));
}
}
entries
}
fn collect_disks(system: &System) -> Vec<serde_json::Value> {
let mut out = Vec::new();
for d in system.disks() {
let name = d.name().to_string_lossy().to_string();
let mount = d.mount_point().to_string_lossy().to_string();
let fs = String::from_utf8_lossy(d.file_system()).to_string();
let total = d.total_space();
let avail = d.available_space();
out.push(json!({
"name": name,
"mountPoint": mount,
"fs": fs,
"totalBytes": total,
"availableBytes": avail,
}));
}
out
}
fn build_inventory_metadata(system: &System) -> serde_json::Value {
let cpu_brand = system
.cpus()
.first()
.map(|cpu| cpu.brand().to_string())
.unwrap_or_default();
let mem_total_bytes = system.total_memory().saturating_mul(1024);
let network = collect_network_addrs();
let disks = collect_disks(system);
let mut inventory = json!({
"cpu": { "brand": cpu_brand },
"memory": { "totalBytes": mem_total_bytes },
"network": network,
"disks": disks,
});
#[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);
}
}
json!({ "inventory": inventory })
}
#[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!([])
}
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();
let memory_total_bytes = total_memory.saturating_mul(1024);
let memory_used_bytes = used_memory.saturating_mul(1024);
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,
status: Option<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,
status,
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();
}
}
}