509 lines
15 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|