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, pub architecture: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MachineMetrics { pub collected_at: DateTime, pub cpu_logical_cores: usize, pub cpu_physical_cores: Option, pub cpu_usage_percent: f32, pub load_average_one: Option, pub load_average_five: Option, pub load_average_fifteen: Option, pub memory_total_bytes: u64, pub memory_used_bytes: u64, pub memory_used_percent: f32, pub uptime_seconds: u64, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MachineInventory { pub cpu_brand: Option, pub host_identifier: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MachineProfile { pub hostname: String, pub os: MachineOs, pub mac_addresses: Vec, pub serial_numbers: Vec, pub inventory: MachineInventory, pub metrics: MachineMetrics, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct HeartbeatPayload { machine_token: String, status: Option, hostname: Option, os: Option, metrics: Option, metadata: Option, } fn collect_mac_addresses() -> Vec { let mut macs = Vec::new(); let mut networks = Networks::new(); networks.refresh_list(); networks.refresh(); for (_, data) in networks.iter() { let bytes = data.mac_address().0; if bytes.iter().all(|byte| *byte == 0) { continue; } let formatted = bytes .iter() .map(|byte| format!("{:02x}", byte)) .collect::>() .join(":"); if !macs.contains(&formatted) { macs.push(formatted); } } macs } #[cfg(target_os = "linux")] fn collect_serials_platform() -> Vec { let mut out = Vec::new(); for path in [ "/sys/class/dmi/id/product_uuid", "/sys/class/dmi/id/product_serial", "/sys/class/dmi/id/board_serial", "/etc/machine-id", ] { if let Ok(raw) = std::fs::read_to_string(path) { let s = raw.trim().to_string(); if !s.is_empty() && !out.contains(&s) { out.push(s); } } } out } #[cfg(any(target_os = "windows", target_os = "macos"))] fn collect_serials_platform() -> Vec { // Fase 1: sem coleta nativa; será implementada via WMI/ioreg na fase 2. Vec::new() } fn collect_serials() -> Vec { collect_serials_platform() } fn collect_network_addrs() -> Vec { 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 { 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 { let hostname = hostname::get() .map_err(|_| AgentError::Hostname)? .to_string_lossy() .trim() .to_string(); let system = collect_system(); let os_name = System::name() .or_else(|| System::long_os_version()) .unwrap_or_else(|| "desconhecido".to_string()); let os_version = System::os_version(); let architecture = std::env::consts::ARCH.to_string(); let mac_addresses = collect_mac_addresses(); let serials: Vec = collect_serials(); if mac_addresses.is_empty() && serials.is_empty() { return Err(AgentError::MissingIdentifiers); } let metrics = collect_metrics(&system); let cpu_brand = system .cpus() .first() .map(|cpu| cpu.brand().to_string()) .filter(|brand| !brand.trim().is_empty()); let inventory = MachineInventory { cpu_brand, host_identifier: serials.first().cloned(), }; Ok(MachineProfile { hostname, os: MachineOs { name: os_name, version: os_version, architecture: Some(architecture), }, mac_addresses, serial_numbers: serials, inventory, metrics, }) } static HTTP_CLIENT: Lazy = Lazy::new(|| { reqwest::Client::builder() .user_agent("sistema-de-chamados-agent/1.0") .timeout(Duration::from_secs(20)) .use_rustls_tls() .build() .expect("failed to build http client") }); async fn post_heartbeat(base_url: &str, token: &str, status: Option) -> Result<(), AgentError> { let system = collect_system(); let metrics = collect_metrics(&system); let hostname = hostname::get() .map_err(|_| AgentError::Hostname)? .to_string_lossy() .into_owned(); let os = MachineOs { name: System::name() .or_else(|| System::long_os_version()) .unwrap_or_else(|| "desconhecido".to_string()), version: System::os_version(), architecture: Some(std::env::consts::ARCH.to_string()), }; let payload = HeartbeatPayload { machine_token: token.to_string(), status, hostname: Some(hostname), os: Some(os), metrics: Some(metrics), metadata: Some(build_inventory_metadata(&system)), }; let url = format!("{}/api/machines/heartbeat", base_url); HTTP_CLIENT.post(url).json(&payload).send().await?; Ok(()) } struct HeartbeatHandle { token: String, base_url: String, status: Option, stop_signal: Arc, join_handle: JoinHandle<()>, } impl HeartbeatHandle { fn stop(self) { self.stop_signal.notify_waiters(); self.join_handle.abort(); } } #[derive(Default)] pub struct AgentRuntime { inner: Mutex>, } fn sanitize_base_url(input: &str) -> Result { let trimmed = input.trim().trim_end_matches('/'); if trimmed.is_empty() { return Err(AgentError::InvalidApiUrl); } Ok(trimmed.to_string()) } impl AgentRuntime { pub fn new() -> Self { Self { inner: Mutex::new(None), } } pub fn start_heartbeat( &self, base_url: String, token: String, status: Option, interval_seconds: Option, ) -> Result<(), AgentError> { let sanitized_base = sanitize_base_url(&base_url)?; let interval = interval_seconds.unwrap_or(300).max(60); { let mut guard = self.inner.lock(); if let Some(handle) = guard.take() { if handle.token == token && handle.base_url == sanitized_base { // Reuse existing heartbeat; keep running. *guard = Some(handle); return Ok(()); } handle.stop(); } } let stop_signal = Arc::new(Notify::new()); let stop_signal_clone = stop_signal.clone(); let token_clone = token.clone(); let base_clone = sanitized_base.clone(); let status_clone = status.clone(); let join_handle = async_runtime::spawn(async move { if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { eprintln!("[agent] Falha inicial ao enviar heartbeat: {error}"); } let mut ticker = tokio::time::interval(Duration::from_secs(interval)); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { // Wait interval tokio::select! { _ = stop_signal_clone.notified() => { break; } _ = ticker.tick() => {} } if let Err(error) = post_heartbeat(&base_clone, &token_clone, status_clone.clone()).await { eprintln!("[agent] Falha ao enviar heartbeat: {error}"); } } }); let handle = HeartbeatHandle { token, base_url: sanitized_base, 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(); } } }