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
+13
View File
@@ -283,6 +283,19 @@ dependencies = [
"urlencoding",
]
[[package]]
name = "awatch-agent-rs"
version = "0.3.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"reqwest",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "base64"
version = "0.22.1"
+1
View File
@@ -1,6 +1,7 @@
[workspace]
resolver = "3"
members = [
"crates/awatch-agent-rs",
"crates/detmir-auto",
"crates/detmir-aw-client",
"crates/aw-db-health",
@@ -0,0 +1,22 @@
[package]
name = "awatch-agent-rs"
version = "0.3.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
[features]
default = []
legacy-powershell = []
@@ -0,0 +1,13 @@
server_url = "https://awatch.local/api/telemetry"
api_key = "change-me"
collect_interval_seconds = 60
role = "workstation"
enable_processes = true
enable_network = true
enable_security_events = true
enable_workforce_activity = true
spool_dir = "/var/lib/awatch-agent/spool"
timeout_seconds = 10
retry_attempts = 3
@@ -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());
}
}
+217
View File
@@ -138,6 +138,20 @@ struct Cli {
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN")]
evidence_upload_token: Option<String>,
#[arg(
long,
default_value = "change-me",
env = "DETMIR_PORTAL_TELEMETRY_API_KEY"
)]
telemetry_api_key: String,
#[arg(
long,
default_value = "/var/lib/detmir-portal/telemetry.jsonl",
env = "DETMIR_PORTAL_TELEMETRY_STORE_PATH"
)]
telemetry_store_path: PathBuf,
}
#[derive(Clone, Debug, Serialize)]
@@ -607,6 +621,9 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
if method == Method::Post && path == "/api/incidents/action" {
return handle_incident_action(request, args);
}
if method == Method::Post && path == "/api/telemetry" {
return handle_telemetry_ingest(request, args);
}
if method != Method::Get {
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
}
@@ -3282,6 +3299,119 @@ fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> {
}
}
fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> {
if !telemetry_authorized(&request, args) {
return respond_json_status(
request,
StatusCode(401),
&json!({
"ok": false,
"error": "telemetry api key is missing or invalid"
}),
);
}
let mut body = String::new();
request
.as_reader()
.take(1024 * 1024)
.read_to_string(&mut body)?;
let response = apply_telemetry_ingest(args, &body);
match response {
Ok(response) => respond_json(request, &response),
Err(err) => respond_json_status(
request,
StatusCode(400),
&json!({
"ok": false,
"error": err.to_string()
}),
),
}
}
fn apply_telemetry_ingest(args: &Cli, body: &str) -> Result<Value> {
let payload: Value =
serde_json::from_str(body).map_err(|err| anyhow!("invalid telemetry JSON: {err}"))?;
validate_telemetry_payload(&payload)?;
let received_at_utc = now();
let envelope = json!({
"received_at_utc": received_at_utc,
"prototype": true,
"record": payload,
});
if let Some(parent) = args.telemetry_store_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&args.telemetry_store_path)
.with_context(|| format!("open {}", args.telemetry_store_path.display()))?;
writeln!(file, "{}", serde_json::to_string(&envelope)?)
.with_context(|| format!("append {}", args.telemetry_store_path.display()))?;
Ok(json!({
"ok": true,
"prototype": true,
"stored": "file-backed-jsonl",
"received_at_utc": received_at_utc,
}))
}
fn validate_telemetry_payload(payload: &Value) -> Result<()> {
let Some(object) = payload.as_object() else {
return Err(anyhow!("telemetry payload must be a JSON object"));
};
for field in [
"agent_id",
"hostname",
"os_name",
"os_version",
"platform",
"username",
"timestamp",
"uptime_seconds",
"cpu_usage_percent",
"memory_total",
"memory_used",
"active_sessions",
"rdp_sessions",
"ssh_sessions",
"processes",
"network_interfaces",
"network_connections",
"workforce_activity",
"security_events",
"collector_version",
] {
if !object.contains_key(field) {
return Err(anyhow!("telemetry field is missing: {field}"));
}
}
for field in [
"active_sessions",
"rdp_sessions",
"ssh_sessions",
"processes",
"network_interfaces",
"network_connections",
"security_events",
] {
if payload.get(field).and_then(Value::as_array).is_none() {
return Err(anyhow!("telemetry field must be an array: {field}"));
}
}
if payload
.get("workforce_activity")
.and_then(Value::as_object)
.is_none()
{
return Err(anyhow!(
"telemetry field must be an object: workforce_activity"
));
}
Ok(())
}
fn apply_incident_action(args: &Cli, actor: &str, body: &str) -> Result<IncidentActionResponse> {
let action: IncidentActionRequest =
serde_json::from_str(body).map_err(|err| anyhow!("invalid incident action JSON: {err}"))?;
@@ -4059,6 +4189,24 @@ fn upload_authorized(request: &Request, args: &Cli) -> bool {
constant_time_eq(actual.as_bytes(), expected.as_bytes())
}
fn telemetry_authorized(request: &Request, args: &Cli) -> bool {
let expected = args.telemetry_api_key.trim();
if expected.is_empty() || expected == "change-me" {
return false;
}
let actual = request
.headers()
.iter()
.find(|header| header.field.equiv("x-api-key"))
.map(|header| header.value.as_str().trim().to_string())
.or_else(|| bearer_token(request));
actual
.as_deref()
.filter(|value| !value.is_empty())
.map(|value| constant_time_eq(value.as_bytes(), expected.as_bytes()))
.unwrap_or(false)
}
fn bearer_token(request: &Request) -> Option<String> {
request
.headers()
@@ -4444,6 +4592,15 @@ fn respond_json<T: Serialize>(request: Request, value: &T) -> Result<()> {
)
}
fn respond_json_status<T: Serialize>(
request: Request,
status: StatusCode,
value: &T,
) -> Result<()> {
let body = serde_json::to_string_pretty(value)?;
respond_text(request, status, &body, "application/json; charset=utf-8")
}
fn respond_text(
request: Request,
status: StatusCode,
@@ -4610,6 +4767,8 @@ mod tests {
json_smoke: false,
evidence_only: false,
evidence_upload_token: None,
telemetry_api_key: "test-key".to_string(),
telemetry_store_path: dir.path().join("telemetry.jsonl"),
};
let found = resolve_screenshot_file(&args, &None, &Some(digest.clone()))
.unwrap()
@@ -4651,6 +4810,64 @@ mod tests {
assert!(!constant_time_eq(b"secret", b"secret2"));
}
#[test]
fn telemetry_ingest_validates_and_appends_jsonl() {
let dir = tempfile::tempdir().unwrap();
let args = Cli {
bind: "127.0.0.1:0".to_string(),
status_cmd: "true".to_string(),
check_cmd: "true".to_string(),
failed_units_cmd: "true".to_string(),
worktime_url: "http://127.0.0.1".to_string(),
one_c_url: "http://127.0.0.1".to_string(),
workforce_policy_path: dir.path().join("workforce-policy.json"),
ueba_policy_path: dir.path().join("ueba-policy.yaml"),
timeout_seconds: 1,
state_dir: dir.path().join("state"),
dlp_db_path: dir.path().join("dlp.sqlite"),
evidence_root: dir.path().to_path_buf(),
readiness_bundle_dir: dir.path().join("readiness-bundle"),
evidence_limit: 10,
evidence_max_bytes: 1024,
json_smoke: false,
evidence_only: false,
evidence_upload_token: None,
telemetry_api_key: "test-key".to_string(),
telemetry_store_path: dir.path().join("telemetry/telemetry.jsonl"),
};
let payload = json!({
"agent_id": "agent-1",
"hostname": "HOST-EXAMPLE",
"os_name": "Linux",
"os_version": "test",
"platform": "linux",
"username": "user",
"domain": "",
"timestamp": "2026-06-04T00:00:00Z",
"uptime_seconds": 1,
"cpu_usage_percent": 0.0,
"memory_total": 1,
"memory_used": 1,
"active_sessions": [],
"rdp_sessions": [],
"ssh_sessions": [],
"processes": [],
"network_interfaces": [],
"network_connections": [],
"workforce_activity": {"active_today": true, "explanation": []},
"security_events": [],
"collector_version": "0.3.0"
});
let response = apply_telemetry_ingest(&args, &serde_json::to_string(&payload).unwrap())
.expect("valid telemetry should be accepted");
assert_eq!(response["ok"], true);
assert_eq!(response["stored"], "file-backed-jsonl");
let stored = fs::read_to_string(&args.telemetry_store_path).unwrap();
assert!(stored.contains("\"prototype\":true"));
assert!(stored.contains("HOST-EXAMPLE"));
assert!(apply_telemetry_ingest(&args, r#"{"agent_id":"only"}"#).is_err());
}
#[test]
fn human_duration_formats_hhmm() {
assert_eq!(human_duration(0), "00:00");
@@ -721,14 +721,16 @@ h1 {
margin-top: 14px;
}
.leader-grid {
.leader-grid,
.investigation-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 12px 0;
}
.leader-grid > div {
.leader-grid > div,
.investigation-grid > div {
display: grid;
gap: 4px;
min-height: 64px;
@@ -1179,10 +1179,80 @@ function renderDlpEvidence(evidence) {
`).join("")}</div>`;
}
function buildAutoInvestigation(report) {
report = periodReport(report || state.reports || {});
const rows = departmentRows(report);
const risky = [...rows].sort((a, b) => statusWeight(b.status) - statusWeight(a.status) || (a.activity ?? 101) - (b.activity ?? 101))[0];
const reasons = Array.isArray(report?.ueba_risk?.reasons) ? report.ueba_risk.reasons : [];
const risk = reasons[0] || {};
const status = risky?.status || risk.status || report?.ueba_risk?.status || "INFO";
const department = risky?.label || "Портфель";
const owner = risky?.responsible || "ответственный не назначен";
const generated = report?.generated_at_utc || new Date().toISOString();
const summary = risk.label || risk.code || risky?.reason || "Система сформировала риск-сигнал для ручной проверки";
return {
incident_id: `auto-${String(department).toLowerCase().replace(/[^a-zа-я0-9]+/gi, "-").replace(/^-|-$/g, "") || "risk"}`,
risk_id: risk.code || risk.label || "workforce-ueba-risk",
department,
owner,
activity_index: risky?.activityText || "нет данных",
deviation: risky?.deviation || "нет данных",
status,
summary,
why_it_is_risk: risky?.reason || risk.value || "риск может указывать на просадку активности, отклонение от нормы или событие безопасности",
what_to_check: risky?.check || risk.recommendation || "проверить первичные события ActivityWatch, RDP/1C активность, процессы и сетевые сигналы",
recommended_actions: [
"назначить ответственного за ручную проверку",
"сопоставить риск с журналами активности и бизнес-задачей",
"зафиксировать вывод в отчете по инциденту"
],
evidence: [
"RDP activity: проверяется по событиям рабочего времени",
"process activity: проверяется по процессам и приложениям",
"network activity: проверяется по сетевым сигналам",
"proxy activity: подключается как внешний источник",
"pfSense events: учитываются при наличии интеграционного слоя"
],
generated_at: generated
};
}
function renderAutoInvestigationCard(report) {
const card = buildAutoInvestigation(report);
return `
<section class="card investigation-card">
<div class="section-head">
<div>
<h3>Автоматическая карточка расследования</h3>
<p class="muted">Расследование сформировано автоматически. Решение принимает ответственный сотрудник.</p>
</div>
<span class="badge ${statusClass(card.status)}">${ui(card.status)}</span>
</div>
<div class="investigation-grid">
<div><span class="muted">incident_id</span><strong>${ui(card.incident_id)}</strong></div>
<div><span class="muted">risk_id</span><strong>${ui(card.risk_id)}</strong></div>
<div><span class="muted">department</span><strong>${ui(card.department)}</strong></div>
<div><span class="muted">owner</span><strong>${ui(card.owner)}</strong></div>
<div><span class="muted">activity_index</span><strong>${ui(card.activity_index)}</strong></div>
<div><span class="muted">deviation</span><strong>${ui(card.deviation)}</strong></div>
<div><span class="muted">generated_at</span><strong>${ui(card.generated_at)}</strong></div>
</div>
<div class="list compact-list">
<div class="row compact-row"><strong>summary</strong><span class="muted">${ui(card.summary)}</span><span></span></div>
<div class="row compact-row"><strong>why_it_is_risk</strong><span class="muted">${ui(card.why_it_is_risk)}</span><span></span></div>
<div class="row compact-row"><strong>what_to_check</strong><span class="muted">${ui(card.what_to_check)}</span><span></span></div>
<div class="row compact-row"><strong>recommended_actions</strong><span class="muted">${ui(card.recommended_actions.join("; "))}</span><span></span></div>
<div class="row compact-row"><strong>evidence</strong><span class="muted">${ui(card.evidence.join("; "))}</span><span></span></div>
</div>
</section>
`;
}
function renderIncidents(data) {
const links = state.links || {};
const incidents = Array.isArray(data) ? data : data.incidents;
const evidence = Array.isArray(data) ? null : data.evidence;
const reports = Array.isArray(data) ? state.reports : data.reports;
return `
<div class="page-head">
<div>
@@ -1201,6 +1271,7 @@ function renderIncidents(data) {
${renderDlpLinks(links)}
</section>
</div>
${renderAutoInvestigationCard(reports)}
<section class="card evidence-card">
<h3>Материалы: скриншоты, хеши, файлы</h3>
${renderDlpEvidence(evidence)}
@@ -1391,7 +1462,9 @@ async function refresh() {
if (state.tab === "incidents") {
const data = await loadJson("/incidents");
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
content.innerHTML = renderIncidents({ incidents: data, evidence });
const reports = await loadJson("/reports").catch(() => state.reports || {});
state.reports = reports;
content.innerHTML = renderIncidents({ incidents: data, evidence, reports });
}
if (state.tab === "perimeter") {
const data = await loadJson("/owner");
@@ -1450,6 +1523,7 @@ document.addEventListener("click", event => {
document.addEventListener("click", event => {
const button = event.target.closest("[data-open-investigation]");
if (!button) return;
state.investigationRequested = true;
setTab("incidents");
});
+97
View File
@@ -0,0 +1,97 @@
# AWatch-rus Rust Agent Architecture
Документ описывает агентный слой AWatch-rus v0.3.
## Назначение
`awatch-agent-rs` является собственным Rust-агентом телеметрии для контура:
```text
Agent -> Telemetry -> Analytics -> Risk -> Investigation -> Report
```
Агент собирает техническую и операционную телеметрию рабочего места или сервера и отправляет ее в серверный endpoint `POST /api/telemetry`.
## Границы реализации v0.3
- Linux collector собирает реальные данные через `/proc`, `/sys`, окружение сессии и системные журналы.
- Windows collector имеет стабильный публичный интерфейс и подготовлен под WinAPI, ETW, Event Log API и WMI-библиотеки Rust.
- FreeBSD collector имеет стабильный публичный интерфейс и подготовлен под `sysctl`, `procstat`, `kvm` и стандартные интерфейсы FreeBSD.
- PowerShell не является основным механизмом сбора. Он допускается только как будущий `legacy` fallback под feature flag.
- Агент не содержит скрытых функций, драйверов ядра, кейлоггера, записи экрана, перехвата документов и контентного DLP-анализа.
## Crate
```text
adk-rust/crates/awatch-agent-rs
```
Структура:
```text
src/main.rs
src/config.rs
src/telemetry.rs
src/transport.rs
src/collectors/mod.rs
src/collectors/common.rs
src/collectors/linux.rs
src/collectors/windows.rs
src/collectors/freebsd.rs
```
## Единая модель TelemetryRecord
Все платформы должны отдавать один JSON-контракт:
- `agent_id`
- `hostname`
- `os_name`
- `os_version`
- `platform`
- `username`
- `domain`
- `timestamp`
- `uptime_seconds`
- `cpu_usage_percent`
- `memory_total`
- `memory_used`
- `active_sessions`
- `rdp_sessions`
- `ssh_sessions`
- `processes`
- `network_interfaces`
- `network_connections`
- `workforce_activity`
- `security_events`
- `collector_version`
Дополнительные структуры: `SessionInfo`, `ProcessInfo`, `NetworkInterfaceInfo`, `NetworkConnectionInfo`, `WorkforceActivityInfo`, `SecurityEventInfo`.
## Collector trait
`TelemetryCollector` задает единый контракт:
```text
collect_identity()
collect_sessions()
collect_processes()
collect_resources()
collect_network()
collect_security_events()
collect_workforce_activity()
collect_all()
```
Такой интерфейс позволяет расширять Windows, Linux, FreeBSD и pfSense mode без изменения серверного API.
## Транспорт
Транспорт использует JSON over HTTPS:
```text
POST /api/telemetry
x-api-key: <api-key>
```
При недоступности сервера запись не теряется: агент сохраняет JSON в spool и повторно отправляет после восстановления связи.
+44
View File
@@ -0,0 +1,44 @@
# AWatch-rus BSD Support
## Цель
BSD-слой нужен для будущей поддержки FreeBSD и pfSense mode без изменения публичного `TelemetryRecord`.
## FreeBSD collector
В v0.3 FreeBSD collector имеет стабильный интерфейс и честно возвращает ограничение реализации, если бинарник собран под FreeBSD до включения нативных источников.
Планируемые источники:
- `sysctl`;
- `procstat`;
- `kvm`;
- сетевые интерфейсы ОС;
- системный syslog;
- сведения об SSH-сессиях.
## pfSense mode
Роль:
```bash
awatch-agent-rs --role firewall
```
Назначение: read-only сбор телеметрии сетевого периметра.
Будущие сигналы:
- interfaces;
- gateway status;
- VPN sessions;
- firewall counters;
- pf statistics;
- NAT counters;
- DNS statistics;
- Suricata summary, если установлен;
- Unbound summary, если установлен.
## Ограничение безопасности
pfSense mode не должен менять правила firewall, NAT, DNS, VPN или маршрутизацию. В рамках v0.3 это только наблюдение.
+91
View File
@@ -0,0 +1,91 @@
# AWatch-rus Agent Deployment
## Конфигурация
Пример:
```toml
server_url = "https://awatch.local/api/telemetry"
api_key = "change-me"
collect_interval_seconds = 60
role = "workstation"
enable_processes = true
enable_network = true
enable_security_events = true
enable_workforce_activity = true
spool_dir = "/var/lib/awatch-agent/spool"
timeout_seconds = 10
retry_attempts = 3
```
Linux/BSD путь по умолчанию:
```text
/etc/awatch-agent/awatch-agent.toml
```
Windows путь по умолчанию:
```text
C:\ProgramData\AWatch\agent\awatch-agent.toml
```
## Запуск
Однократный сбор и печать JSON:
```bash
awatch-agent-rs --once --print-json
```
Однократная отправка:
```bash
awatch-agent-rs --once --config /etc/awatch-agent/awatch-agent.toml
```
Проверка очереди:
```bash
awatch-agent-rs --spool-health
```
Отправка накопленной очереди:
```bash
awatch-agent-rs --flush-spool
```
## Server API
Портал принимает телеметрию через:
```text
POST /api/telemetry
```
Требования:
- JSON object;
- заголовок `x-api-key` или `Authorization: Bearer`;
- обязательные поля `TelemetryRecord`;
- в v0.3 хранение является prototype file-backed JSONL.
Серверные переменные:
```text
DETMIR_PORTAL_TELEMETRY_API_KEY
DETMIR_PORTAL_TELEMETRY_STORE_PATH
```
По умолчанию `change-me` не авторизует прием телеметрии. Для пилота ключ нужно задать явно.
## Проверка после установки
1. Запустить агент с `--once --print-json`.
2. Проверить наличие `TelemetryRecord` и `collector_version`.
3. Запустить агент с реальным `server_url`.
4. Проверить HTTP-ответ `{ "ok": true, "stored": "file-backed-jsonl" }`.
5. Проверить отсутствие файлов в spool после успешной отправки.
+35
View File
@@ -0,0 +1,35 @@
# pfSense Integration
pfSense рассматривается как интеграционный слой сетевого периметра, а не обязательная часть продукта.
## Режим v0.3
- read-only;
- без изменения правил;
- без автоматического карантина;
- без управления маршрутизацией;
- без зависимости портала от pfSense.
## Место в архитектуре
```text
pfSense / firewall telemetry
|
awatch-agent-rs --role firewall
|
POST /api/telemetry
|
Workforce/UEBA risk context
```
## Коммерческая ценность
Интеграция позволяет объяснять риски не только по активности рабочего места, но и по сетевому контексту: unusual destinations, VPN sessions, gateway status, proxy/DNS signals.
## Не реализуется в v0.3
- NAC;
- SOAR-автоматизация;
- блокировка VLAN;
- изменение firewall rules;
- управление VPN-доступом.
+69
View File
@@ -0,0 +1,69 @@
# AWatch-rus Portal
## Назначение
Портал является рабочим кабинетом для трех ролей:
- руководитель: пульс организации, активность, подразделения, риски;
- ИБ: риск-сигналы, события безопасности, evidence;
- расследователь: карточка инцидента, причины, проверяемые источники, отчет.
## Главные экраны
- `Обзор` - Executive Dashboard.
- `Сотрудники` - карточки сотрудников и объяснение индекса.
- `Подразделения` - сравнение групп, тренды и ответственные.
- `Риски` - read-only контроль безопасности.
- `Расследования` - инциденты, evidence и автоматическая read-only карточка расследования.
- `Сетевой периметр` - read-only контекст внешних сетевых сигналов.
- `Отчеты` - Markdown/PDF/JSON и управленческий текст.
- `Настройки` - read-only параметры расчета.
## Executive Dashboard
Показывает:
- сотрудников в работе;
- средний индекс активности;
- WARN/FAIL подразделения;
- критические риски;
- недельный тренд;
- топ-5 лучших и проблемных подразделений;
- Heat Map;
- блок `Требует внимания`.
## Risk -> Investigation
Кнопка `Открыть расследование` переводит пользователя в read-only карточку.
Карточка содержит:
- `incident_id`;
- `risk_id`;
- `department`;
- `owner`;
- `activity_index`;
- `deviation`;
- `status`;
- `summary`;
- `why_it_is_risk`;
- `what_to_check`;
- `recommended_actions`;
- `evidence`;
- `generated_at`.
Предупреждение обязательно:
```text
Расследование сформировано автоматически. Решение принимает ответственный сотрудник.
```
## Проверка
Smoke-тест:
```bash
node scripts/detmir-portal-tabs-smoke.mjs
```
Тест проверяет все вкладки, read-only настройки и переход Risk -> Investigation.
+79
View File
@@ -0,0 +1,79 @@
# Workforce / UEBA Model
## Позиционирование
AWatch-rus v0.3 использует Workforce-first подход:
```text
Телеметрия -> Активность -> Риск -> Расследование -> Отчет
```
Это не сертифицированная СЗИ, не промышленная SIEM и не полнофункциональная DLP. Корректная формулировка для v0.3:
```text
UEBA-compatible rule-based risk scoring v1.
```
## Workforce scoring
Расчетные поля:
- `activity_index`;
- `active_today`;
- `department_activity_index`;
- `owner_activity_index`;
- `trend_status`;
- `anomaly_status`;
- `risk_level`.
Базовая proxy-формула:
```text
Индекс активности = active_seconds / planned_seconds * 100
```
Взвешенный индекс использует роли и веса приложений, если задан workforce policy.
## Статусы
- `OK` - состояние в норме;
- `WARN` - требуется внимание;
- `FAIL` - требуется действие.
Каждый риск обязан объяснять:
- что произошло;
- почему это риск;
- что проверить;
- рекомендуемое действие.
## UEBA risk v1
Риск рассчитывается по правилам, а не по скрытой ML-модели.
Поля объяснимости:
- `confidence`;
- `risk_sources`;
- `baseline_status`;
- `policy_version`;
- `calculated_from`;
- `baseline_window_days`;
- `user_baseline_available`;
- `department_baseline_available`;
- `deviation_score`;
- `baseline_samples`.
## Evidence
Evidence повышает достоверность вывода, но само по себе не является фактором риска. Риск должен исходить из события, отклонения, тренда или правила.
## Не реализуется
- перехват содержимого документов;
- запись экрана;
- кейлоггер;
- скрытый агент;
- контентный DLP-анализ;
- автоматическое редактирование инцидентов;
- автоматическое наказание или блокировка сотрудника.
+6 -1
View File
@@ -163,9 +163,14 @@ async function main() {
{ timeout },
);
const activeTab = await page.locator(".tab.is-active").innerText({ timeout });
const incidentText = await page.locator("#content").innerText({ timeout });
checks.push({
name: "risk_open_investigation_readonly_navigation",
ok: activeTab.trim() === "Расследования",
ok: activeTab.trim() === "Расследования"
&& containsText(incidentText, "Расследование сформировано автоматически")
&& containsText(incidentText, "incident_id")
&& containsText(incidentText, "risk_id")
&& containsText(incidentText, "evidence"),
buttons: investigationButtons,
});
} else {