feat(agent): add rust telemetry agent v0.3

This commit is contained in:
igor04091968
2026-06-04 09:19:08 +03:00
parent 9f47dcaefa
commit 3f19efb7e3
23 changed files with 1949 additions and 4 deletions
@@ -0,0 +1,126 @@
use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;
use chrono::Utc;
use crate::config::AgentRole;
use crate::telemetry::{SecurityEventInfo, SessionInfo};
pub fn command_output(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).output().ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn hostname() -> String {
env::var("HOSTNAME")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| fs::read_to_string("/etc/hostname").ok())
.or_else(|| command_output("hostname", &[]))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "HOST-EXAMPLE".to_string())
}
pub fn username() -> String {
env::var("USER")
.or_else(|_| env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
pub fn domain() -> String {
env::var("USERDOMAIN")
.or_else(|_| env::var("DOMAIN"))
.unwrap_or_default()
}
pub fn agent_id(hostname: &str) -> String {
format!("awatch-{hostname}")
}
pub fn role_security_events(role: AgentRole) -> Vec<SecurityEventInfo> {
if role == AgentRole::Firewall {
vec![SecurityEventInfo {
event_id: "pfsense-mode-prototype".to_string(),
source: "awatch-agent-rs".to_string(),
severity: "INFO".to_string(),
summary: "pfSense/firewall mode enabled; counters are collected from platform-specific probes when available".to_string(),
timestamp: Utc::now(),
evidence: vec!["read-only mode".to_string()],
}]
} else {
Vec::new()
}
}
pub fn current_session(session_type: &str) -> SessionInfo {
SessionInfo {
session_id: format!("{}-{}", session_type, username()),
username: username(),
session_type: session_type.to_string(),
remote_addr: std::env::var("SSH_CLIENT")
.ok()
.and_then(|value| value.split_whitespace().next().map(str::to_string)),
started_at: None,
active: true,
}
}
pub fn read_trimmed(path: impl AsRef<Path>) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn parse_os_release(path: &Path) -> (String, String) {
let text = fs::read_to_string(path).unwrap_or_default();
let mut name = String::new();
let mut version = String::new();
for line in text.lines() {
if let Some(value) = line.strip_prefix("NAME=") {
name = value.trim_matches('"').to_string();
}
if let Some(value) = line.strip_prefix("VERSION_ID=") {
version = value.trim_matches('"').to_string();
}
}
if name.is_empty() {
name = "Linux".to_string();
}
(name, version)
}
pub fn parse_hex_ipv4(value: &str) -> Option<String> {
if value.len() != 8 {
return None;
}
let raw = u32::from_str_radix(value, 16).ok()?;
let bytes = raw.to_le_bytes();
Some(format!(
"{}.{}.{}.{}",
bytes[0], bytes[1], bytes[2], bytes[3]
))
}
pub fn tcp_state(value: &str) -> &'static str {
match value {
"01" => "ESTABLISHED",
"02" => "SYN_SENT",
"03" => "SYN_RECV",
"04" => "FIN_WAIT1",
"05" => "FIN_WAIT2",
"06" => "TIME_WAIT",
"07" => "CLOSE",
"08" => "CLOSE_WAIT",
"09" => "LAST_ACK",
"0A" => "LISTEN",
"0B" => "CLOSING",
_ => "UNKNOWN",
}
}
@@ -0,0 +1,49 @@
use anyhow::{Result, bail};
use crate::config::AgentRole;
use crate::telemetry::{
IdentityInfo, NetworkSnapshot, ProcessInfo, ResourceInfo, SecurityEventInfo, SessionSnapshot,
TelemetryCollector, WorkforceActivityInfo,
};
#[derive(Debug, Clone)]
pub struct FreeBsdCollector {
role: AgentRole,
}
impl FreeBsdCollector {
pub fn new(role: AgentRole) -> Self {
Self { role }
}
}
impl TelemetryCollector for FreeBsdCollector {
fn collect_identity(&self) -> Result<IdentityInfo> {
let _ = self.role;
unsupported()
}
fn collect_sessions(&self) -> Result<SessionSnapshot> {
unsupported()
}
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
unsupported()
}
fn collect_resources(&self) -> Result<ResourceInfo> {
unsupported()
}
fn collect_network(&self) -> Result<NetworkSnapshot> {
unsupported()
}
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
unsupported()
}
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
unsupported()
}
}
fn unsupported<T>() -> Result<T> {
bail!(
"FreeBSD/pfSense collector skeleton is present; sysctl/procstat/kvm probes are planned behind the same TelemetryRecord API"
)
}
@@ -0,0 +1,270 @@
use std::fs;
use std::path::Path;
use anyhow::Result;
use chrono::Utc;
use crate::collectors::common::{
agent_id, current_session, domain, hostname, parse_hex_ipv4, parse_os_release, read_trimmed,
role_security_events, tcp_state, username,
};
use crate::config::AgentRole;
use crate::telemetry::{
IdentityInfo, NetworkConnectionInfo, NetworkInterfaceInfo, NetworkSnapshot, ProcessInfo,
ResourceInfo, SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo,
empty_workforce_activity,
};
#[derive(Debug, Clone)]
pub struct LinuxCollector {
role: AgentRole,
}
impl LinuxCollector {
pub fn new(role: AgentRole) -> Self {
Self { role }
}
}
impl TelemetryCollector for LinuxCollector {
fn collect_identity(&self) -> Result<IdentityInfo> {
let host = hostname();
let (os_name, os_version) = parse_os_release(Path::new("/etc/os-release"));
Ok(IdentityInfo {
agent_id: agent_id(&host),
hostname: host,
os_name,
os_version,
platform: "linux".to_string(),
username: username(),
domain: domain(),
})
}
fn collect_sessions(&self) -> Result<SessionSnapshot> {
let mut active = vec![current_session("local")];
let mut ssh = Vec::new();
if std::env::var("SSH_CLIENT").is_ok() || std::env::var("SSH_TTY").is_ok() {
let session = current_session("ssh");
ssh.push(session.clone());
active.push(session);
}
Ok(SessionSnapshot {
active_sessions: active,
rdp_sessions: Vec::new(),
ssh_sessions: ssh,
})
}
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
Ok(read_processes(128))
}
fn collect_resources(&self) -> Result<ResourceInfo> {
let uptime_seconds = fs::read_to_string("/proc/uptime")
.ok()
.and_then(|text| text.split_whitespace().next()?.parse::<f64>().ok())
.map(|value| value as u64)
.unwrap_or(0);
let (memory_total, memory_available) = read_meminfo();
Ok(ResourceInfo {
uptime_seconds,
cpu_usage_percent: read_loadavg_percent(),
memory_total,
memory_used: memory_total.saturating_sub(memory_available),
})
}
fn collect_network(&self) -> Result<NetworkSnapshot> {
Ok(NetworkSnapshot {
interfaces: read_interfaces(),
connections: read_connections(),
})
}
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
let mut events = role_security_events(self.role);
if let Some(summary) = recent_syslog_summary() {
events.push(SecurityEventInfo {
event_id: "linux-syslog-summary".to_string(),
source: "syslog".to_string(),
severity: "INFO".to_string(),
summary,
timestamp: Utc::now(),
evidence: vec!["/var/log/syslog or /var/log/messages".to_string()],
});
}
Ok(events)
}
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
let mut activity = empty_workforce_activity();
activity.active_today = true;
activity.explanation = vec![
"Linux collector reports presence and process/network context; application weighting is calculated server-side".to_string(),
];
Ok(activity)
}
}
fn read_meminfo() -> (u64, u64) {
let mut total = 0;
let mut available = 0;
let text = fs::read_to_string("/proc/meminfo").unwrap_or_default();
for line in text.lines() {
if let Some(value) = line.strip_prefix("MemTotal:") {
total = parse_kib(value);
}
if let Some(value) = line.strip_prefix("MemAvailable:") {
available = parse_kib(value);
}
}
(total, available)
}
fn parse_kib(value: &str) -> u64 {
value
.split_whitespace()
.next()
.and_then(|item| item.parse::<u64>().ok())
.unwrap_or(0)
* 1024
}
fn read_loadavg_percent() -> f64 {
fs::read_to_string("/proc/loadavg")
.ok()
.and_then(|text| text.split_whitespace().next()?.parse::<f64>().ok())
.map(|load| (load * 100.0).clamp(0.0, 100.0))
.unwrap_or(0.0)
}
fn read_processes(limit: usize) -> Vec<ProcessInfo> {
let mut items = fs::read_dir("/proc")
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let pid = entry.file_name().to_string_lossy().parse::<u32>().ok()?;
let stat = fs::read_to_string(entry.path().join("stat")).ok()?;
let name = stat.split_once('(')?.1.split_once(')')?.0.to_string();
let exe = fs::read_link(entry.path().join("exe"))
.ok()
.map(|path| path.display().to_string());
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
let ppid = status
.lines()
.find_map(|line| line.strip_prefix("PPid:"))
.and_then(|value| value.trim().parse::<u32>().ok());
let memory_bytes = status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))
.map(parse_kib);
Some(ProcessInfo {
pid,
ppid,
name,
exe,
username: None,
cpu_percent: None,
memory_bytes,
started_at: None,
})
})
.collect::<Vec<_>>();
items.sort_by_key(|item| item.pid);
items.truncate(limit);
items
}
fn read_interfaces() -> Vec<NetworkInterfaceInfo> {
fs::read_dir("/sys/class/net")
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let up = read_trimmed(path.join("operstate")).is_some_and(|state| state == "up");
let rx_bytes =
read_trimmed(path.join("statistics/rx_bytes")).and_then(|v| v.parse().ok());
let tx_bytes =
read_trimmed(path.join("statistics/tx_bytes")).and_then(|v| v.parse().ok());
NetworkInterfaceInfo {
name,
mac: read_trimmed(path.join("address")),
addresses: Vec::new(),
up,
rx_bytes,
tx_bytes,
}
})
.collect()
}
fn read_connections() -> Vec<NetworkConnectionInfo> {
let mut items = Vec::new();
read_proc_net("/proc/net/tcp", "tcp", &mut items);
read_proc_net("/proc/net/udp", "udp", &mut items);
items.truncate(256);
items
}
fn read_proc_net(path: &str, protocol: &str, items: &mut Vec<NetworkConnectionInfo>) {
let text = fs::read_to_string(path).unwrap_or_default();
for line in text.lines().skip(1) {
let cols = line.split_whitespace().collect::<Vec<_>>();
if cols.len() < 4 {
continue;
}
let Some((local_addr, local_port)) = parse_addr(cols[1]) else {
continue;
};
let (remote_addr, remote_port) = parse_addr(cols[2]).unwrap_or_default();
items.push(NetworkConnectionInfo {
protocol: protocol.to_string(),
local_addr,
local_port,
remote_addr: if remote_addr == "0.0.0.0" {
None
} else {
Some(remote_addr)
},
remote_port: if remote_port == 0 {
None
} else {
Some(remote_port)
},
state: if protocol == "tcp" {
tcp_state(cols[3]).to_string()
} else {
"UDP".to_string()
},
pid: None,
});
}
}
fn parse_addr(value: &str) -> Option<(String, u16)> {
let (ip, port) = value.split_once(':')?;
Some((parse_hex_ipv4(ip)?, u16::from_str_radix(port, 16).ok()?))
}
fn recent_syslog_summary() -> Option<String> {
for path in ["/var/log/syslog", "/var/log/messages"] {
let Ok(text) = fs::read_to_string(path) else {
continue;
};
let count = text
.lines()
.rev()
.take(200)
.filter(|line| {
let lower = line.to_lowercase();
lower.contains("error") || lower.contains("fail") || lower.contains("denied")
})
.count();
return Some(format!("recent syslog warning/error lines: {count}"));
}
None
}
@@ -0,0 +1,22 @@
pub mod common;
pub mod freebsd;
pub mod linux;
pub mod windows;
use anyhow::{Result, bail};
use crate::config::AgentRole;
use crate::telemetry::TelemetryCollector;
pub fn platform_collector(role: AgentRole) -> Result<Box<dyn TelemetryCollector>> {
if cfg!(target_os = "linux") {
return Ok(Box::new(linux::LinuxCollector::new(role)));
}
if cfg!(target_os = "windows") {
return Ok(Box::new(windows::WindowsCollector::new(role)));
}
if cfg!(target_os = "freebsd") {
return Ok(Box::new(freebsd::FreeBsdCollector::new(role)));
}
bail!("unsupported platform for awatch-agent-rs")
}
@@ -0,0 +1,49 @@
use anyhow::{Result, bail};
use crate::config::AgentRole;
use crate::telemetry::{
IdentityInfo, NetworkSnapshot, ProcessInfo, ResourceInfo, SecurityEventInfo, SessionSnapshot,
TelemetryCollector, WorkforceActivityInfo,
};
#[derive(Debug, Clone)]
pub struct WindowsCollector {
role: AgentRole,
}
impl WindowsCollector {
pub fn new(role: AgentRole) -> Self {
Self { role }
}
}
impl TelemetryCollector for WindowsCollector {
fn collect_identity(&self) -> Result<IdentityInfo> {
let _ = self.role;
unsupported()
}
fn collect_sessions(&self) -> Result<SessionSnapshot> {
unsupported()
}
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
unsupported()
}
fn collect_resources(&self) -> Result<ResourceInfo> {
unsupported()
}
fn collect_network(&self) -> Result<NetworkSnapshot> {
unsupported()
}
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
unsupported()
}
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
unsupported()
}
}
fn unsupported<T>() -> Result<T> {
bail!(
"Windows collector requires target_os=windows WinAPI/WMI implementation; PowerShell is not a primary collector"
)
}
@@ -0,0 +1,143 @@
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct AgentConfig {
pub server_url: String,
pub api_key: String,
pub collect_interval_seconds: u64,
pub role: AgentRole,
pub enable_processes: bool,
pub enable_network: bool,
pub enable_security_events: bool,
pub enable_workforce_activity: bool,
pub spool_dir: PathBuf,
pub timeout_seconds: u64,
pub retry_attempts: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRole {
Workstation,
Server,
Firewall,
}
impl AgentRole {
pub fn parse(value: &str) -> Self {
match value.trim().to_lowercase().as_str() {
"firewall" | "pfsense" => Self::Firewall,
"server" => Self::Server,
_ => Self::Workstation,
}
}
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
server_url: "https://awatch.local/api/telemetry".to_string(),
api_key: "change-me".to_string(),
collect_interval_seconds: 60,
role: AgentRole::Workstation,
enable_processes: true,
enable_network: true,
enable_security_events: true,
enable_workforce_activity: true,
spool_dir: default_spool_dir(),
timeout_seconds: 10,
retry_attempts: 3,
}
}
}
pub fn default_config_path() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\ProgramData\AWatch\agent\awatch-agent.toml")
} else {
PathBuf::from("/etc/awatch-agent/awatch-agent.toml")
}
}
fn default_spool_dir() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\ProgramData\AWatch\agent\spool")
} else {
PathBuf::from("/var/lib/awatch-agent/spool")
}
}
impl AgentConfig {
pub fn load(path: &Path) -> Result<Self> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
Self::parse_toml_like(&text)
}
pub fn parse_toml_like(text: &str) -> Result<Self> {
let mut config = Self::default();
for raw in text.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"server_url" => config.server_url = value.to_string(),
"api_key" => config.api_key = value.to_string(),
"collect_interval_seconds" => {
config.collect_interval_seconds = value.parse().unwrap_or(60)
}
"role" => config.role = AgentRole::parse(value),
"enable_processes" => config.enable_processes = parse_bool(value, true),
"enable_network" => config.enable_network = parse_bool(value, true),
"enable_security_events" => config.enable_security_events = parse_bool(value, true),
"enable_workforce_activity" => {
config.enable_workforce_activity = parse_bool(value, true)
}
"spool_dir" => config.spool_dir = PathBuf::from(value),
"timeout_seconds" => config.timeout_seconds = value.parse().unwrap_or(10),
"retry_attempts" => config.retry_attempts = value.parse().unwrap_or(3),
_ => {}
}
}
Ok(config)
}
}
fn parse_bool(value: &str, fallback: bool) -> bool {
match value.trim().to_lowercase().as_str() {
"1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false,
_ => fallback,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_agent_config() {
let config = AgentConfig::parse_toml_like(
r#"
server_url = "https://awatch.local/api/telemetry"
api_key = "change-me"
collect_interval_seconds = 30
role = "firewall"
enable_processes = false
spool_dir = "/tmp/awatch-spool"
"#,
)
.unwrap();
assert_eq!(config.role, AgentRole::Firewall);
assert_eq!(config.collect_interval_seconds, 30);
assert!(!config.enable_processes);
assert_eq!(config.spool_dir, PathBuf::from("/tmp/awatch-spool"));
}
}
+105
View File
@@ -0,0 +1,105 @@
mod collectors;
mod config;
mod telemetry;
mod transport;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::Parser;
use config::{AgentConfig, AgentRole, default_config_path};
use transport::{TelemetryTransport, spool_health};
#[derive(Debug, Parser)]
#[command(about = "AWatch-rus Rust telemetry agent")]
struct Cli {
#[arg(long, env = "AWATCH_AGENT_CONFIG")]
config: Option<PathBuf>,
#[arg(long, env = "AWATCH_AGENT_SERVER_URL")]
server_url: Option<String>,
#[arg(long, env = "AWATCH_AGENT_API_KEY")]
api_key: Option<String>,
#[arg(long, env = "AWATCH_AGENT_ROLE")]
role: Option<String>,
#[arg(long)]
once: bool,
#[arg(long)]
print_json: bool,
#[arg(long)]
flush_spool: bool,
#[arg(long)]
spool_health: bool,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let mut config = load_config(cli.config.as_ref())?;
if let Some(server_url) = cli.server_url {
config.server_url = server_url;
}
if let Some(api_key) = cli.api_key {
config.api_key = api_key;
}
if let Some(role) = cli.role {
config.role = AgentRole::parse(&role);
}
if cli.spool_health {
println!(
"{}",
serde_json::to_string_pretty(&spool_health(&config.spool_dir))?
);
return Ok(0);
}
let transport = TelemetryTransport::new(&config);
if cli.flush_spool {
let flushed = transport.flush_spool()?;
println!("{}", serde_json::json!({"ok": true, "flushed": flushed}));
return Ok(0);
}
loop {
let collector = collectors::platform_collector(config.role)?;
let record = collector.collect_all()?;
if cli.print_json {
println!("{}", serde_json::to_string_pretty(&record)?);
} else if let Err(err) = transport.send_or_spool(&record) {
eprintln!("{err:#}");
}
if cli.once {
break;
}
thread::sleep(Duration::from_secs(config.collect_interval_seconds));
}
Ok(0)
}
fn load_config(path: Option<&PathBuf>) -> Result<AgentConfig> {
let path = path.cloned().unwrap_or_else(default_config_path);
if path.exists() {
AgentConfig::load(&path)
} else {
AgentConfig::parse_toml_like("")
.with_context(|| format!("load default config because {} is absent", path.display()))
}
}
@@ -0,0 +1,214 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const COLLECTOR_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TelemetryRecord {
pub agent_id: String,
pub hostname: String,
pub os_name: String,
pub os_version: String,
pub platform: String,
pub username: String,
pub domain: String,
pub timestamp: DateTime<Utc>,
pub uptime_seconds: u64,
pub cpu_usage_percent: f64,
pub memory_total: u64,
pub memory_used: u64,
pub active_sessions: Vec<SessionInfo>,
pub rdp_sessions: Vec<SessionInfo>,
pub ssh_sessions: Vec<SessionInfo>,
pub processes: Vec<ProcessInfo>,
pub network_interfaces: Vec<NetworkInterfaceInfo>,
pub network_connections: Vec<NetworkConnectionInfo>,
pub workforce_activity: WorkforceActivityInfo,
pub security_events: Vec<SecurityEventInfo>,
pub collector_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdentityInfo {
pub agent_id: String,
pub hostname: String,
pub os_name: String,
pub os_version: String,
pub platform: String,
pub username: String,
pub domain: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResourceInfo {
pub uptime_seconds: u64,
pub cpu_usage_percent: f64,
pub memory_total: u64,
pub memory_used: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionInfo {
pub session_id: String,
pub username: String,
pub session_type: String,
pub remote_addr: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProcessInfo {
pub pid: u32,
pub ppid: Option<u32>,
pub name: String,
pub exe: Option<String>,
pub username: Option<String>,
pub cpu_percent: Option<f64>,
pub memory_bytes: Option<u64>,
pub started_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkInterfaceInfo {
pub name: String,
pub mac: Option<String>,
pub addresses: Vec<String>,
pub up: bool,
pub rx_bytes: Option<u64>,
pub tx_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkConnectionInfo {
pub protocol: String,
pub local_addr: String,
pub local_port: u16,
pub remote_addr: Option<String>,
pub remote_port: Option<u16>,
pub state: String,
pub pid: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WorkforceActivityInfo {
pub active_today: bool,
pub activity_index: Option<u8>,
pub department: Option<String>,
pub owner: Option<String>,
pub work_applications: Vec<String>,
pub idle_seconds: Option<u64>,
pub explanation: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SecurityEventInfo {
pub event_id: String,
pub source: String,
pub severity: String,
pub summary: String,
pub timestamp: DateTime<Utc>,
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionSnapshot {
pub active_sessions: Vec<SessionInfo>,
pub rdp_sessions: Vec<SessionInfo>,
pub ssh_sessions: Vec<SessionInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkSnapshot {
pub interfaces: Vec<NetworkInterfaceInfo>,
pub connections: Vec<NetworkConnectionInfo>,
}
pub trait TelemetryCollector {
fn collect_identity(&self) -> Result<IdentityInfo>;
fn collect_sessions(&self) -> Result<SessionSnapshot>;
fn collect_processes(&self) -> Result<Vec<ProcessInfo>>;
fn collect_resources(&self) -> Result<ResourceInfo>;
fn collect_network(&self) -> Result<NetworkSnapshot>;
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>>;
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo>;
fn collect_all(&self) -> Result<TelemetryRecord> {
let identity = self.collect_identity()?;
let sessions = self.collect_sessions()?;
let resources = self.collect_resources()?;
let network = self.collect_network()?;
Ok(TelemetryRecord {
agent_id: identity.agent_id,
hostname: identity.hostname,
os_name: identity.os_name,
os_version: identity.os_version,
platform: identity.platform,
username: identity.username,
domain: identity.domain,
timestamp: Utc::now(),
uptime_seconds: resources.uptime_seconds,
cpu_usage_percent: resources.cpu_usage_percent,
memory_total: resources.memory_total,
memory_used: resources.memory_used,
active_sessions: sessions.active_sessions,
rdp_sessions: sessions.rdp_sessions,
ssh_sessions: sessions.ssh_sessions,
processes: self.collect_processes()?,
network_interfaces: network.interfaces,
network_connections: network.connections,
workforce_activity: self.collect_workforce_activity()?,
security_events: self.collect_security_events()?,
collector_version: COLLECTOR_VERSION.to_string(),
})
}
}
pub fn empty_workforce_activity() -> WorkforceActivityInfo {
WorkforceActivityInfo {
active_today: false,
activity_index: None,
department: None,
owner: None,
work_applications: Vec::new(),
idle_seconds: None,
explanation: vec!["activity scoring requires workstation activity events".to_string()],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn telemetry_record_serializes_required_fields() {
let record = TelemetryRecord {
agent_id: "agent-1".to_string(),
hostname: "HOST-EXAMPLE".to_string(),
os_name: "Linux".to_string(),
os_version: "test".to_string(),
platform: "linux".to_string(),
username: "user".to_string(),
domain: "".to_string(),
timestamp: Utc::now(),
uptime_seconds: 1,
cpu_usage_percent: 0.0,
memory_total: 10,
memory_used: 5,
active_sessions: Vec::new(),
rdp_sessions: Vec::new(),
ssh_sessions: Vec::new(),
processes: Vec::new(),
network_interfaces: Vec::new(),
network_connections: Vec::new(),
workforce_activity: empty_workforce_activity(),
security_events: Vec::new(),
collector_version: COLLECTOR_VERSION.to_string(),
};
let value = serde_json::to_value(record).unwrap();
assert_eq!(value["agent_id"], "agent-1");
assert!(value.get("network_connections").unwrap().is_array());
assert!(value.get("workforce_activity").is_some());
}
}
@@ -0,0 +1,205 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use crate::config::AgentConfig;
use crate::telemetry::TelemetryRecord;
#[derive(Debug, Clone)]
pub struct TelemetryTransport {
server_url: String,
api_key: String,
spool_dir: PathBuf,
timeout: Duration,
retry_attempts: u32,
}
impl TelemetryTransport {
pub fn new(config: &AgentConfig) -> Self {
Self {
server_url: config.server_url.clone(),
api_key: config.api_key.clone(),
spool_dir: config.spool_dir.clone(),
timeout: Duration::from_secs(config.timeout_seconds),
retry_attempts: config.retry_attempts,
}
}
pub fn send_or_spool(&self, record: &TelemetryRecord) -> Result<()> {
match self.send(record) {
Ok(()) => Ok(()),
Err(err) => {
self.spool(record)?;
Err(err)
}
}
}
pub fn send(&self, record: &TelemetryRecord) -> Result<()> {
let client = Client::builder()
.timeout(self.timeout)
.build()
.context("build telemetry HTTP client")?;
let mut headers = HeaderMap::new();
headers.insert(
"x-api-key",
HeaderValue::from_str(&self.api_key).context("invalid api key header")?,
);
let mut last_error = None;
for attempt in 0..self.retry_attempts.max(1) {
let result = client
.post(&self.server_url)
.headers(headers.clone())
.json(record)
.send()
.and_then(|response| response.error_for_status())
.map(|_| ());
match result {
Ok(()) => return Ok(()),
Err(err) => {
last_error = Some(err);
let backoff = Duration::from_millis(250 * u64::from(attempt + 1));
thread::sleep(backoff);
}
}
}
Err(anyhow!(
"telemetry POST failed: {}",
last_error
.map(|err| err.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
}
pub fn spool(&self, record: &TelemetryRecord) -> Result<PathBuf> {
fs::create_dir_all(&self.spool_dir)
.with_context(|| format!("create spool {}", self.spool_dir.display()))?;
let file_name = format!(
"{}-{}.json",
record.timestamp.format("%Y%m%dT%H%M%S%.3fZ"),
sanitize_file_part(&record.agent_id)
);
let path = self.spool_dir.join(file_name);
fs::write(&path, serde_json::to_vec(record)?)
.with_context(|| format!("write spool {}", path.display()))?;
Ok(path)
}
pub fn flush_spool(&self) -> Result<usize> {
flush_spool_dir(&self.spool_dir, |record| self.send(record))
}
}
pub fn flush_spool_dir<F>(spool_dir: &Path, mut sender: F) -> Result<usize>
where
F: FnMut(&TelemetryRecord) -> Result<()>,
{
if !spool_dir.exists() {
return Ok(0);
}
let mut sent = 0;
let mut entries = fs::read_dir(spool_dir)
.with_context(|| format!("read spool {}", spool_dir.display()))?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.collect::<Vec<_>>();
entries.sort();
for path in entries {
let data = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
let record: TelemetryRecord =
serde_json::from_slice(&data).with_context(|| format!("parse {}", path.display()))?;
sender(&record)?;
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
sent += 1;
}
Ok(sent)
}
fn sanitize_file_part(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect()
}
pub fn spool_health(spool_dir: &Path) -> serde_json::Value {
let queued = fs::read_dir(spool_dir)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
.count();
serde_json::json!({
"generated_at_utc": Utc::now(),
"spool_dir": spool_dir.display().to_string(),
"queued": queued,
})
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
use crate::telemetry::{TelemetryRecord, empty_workforce_activity};
fn record() -> TelemetryRecord {
TelemetryRecord {
agent_id: "agent/1".to_string(),
hostname: "HOST-EXAMPLE".to_string(),
os_name: "Linux".to_string(),
os_version: "test".to_string(),
platform: "linux".to_string(),
username: "user".to_string(),
domain: "".to_string(),
timestamp: Utc::now(),
uptime_seconds: 1,
cpu_usage_percent: 0.0,
memory_total: 1,
memory_used: 1,
active_sessions: Vec::new(),
rdp_sessions: Vec::new(),
ssh_sessions: Vec::new(),
processes: Vec::new(),
network_interfaces: Vec::new(),
network_connections: Vec::new(),
workforce_activity: empty_workforce_activity(),
security_events: Vec::new(),
collector_version: "test".to_string(),
}
}
#[test]
fn spools_and_flushes_records() {
let dir = tempdir().unwrap();
let config = AgentConfig {
spool_dir: dir.path().to_path_buf(),
..AgentConfig::default()
};
let transport = TelemetryTransport::new(&config);
let path = transport.spool(&record()).unwrap();
assert!(path.is_file());
let mut seen = 0;
let flushed = flush_spool_dir(dir.path(), |_| {
seen += 1;
Ok(())
})
.unwrap();
assert_eq!(flushed, 1);
assert_eq!(seen, 1);
assert!(!path.exists());
}
}