Add VPN configuration tools and agent documentation

Created comprehensive VPN setup tooling for Peaceful Spirit L2TP/IPsec connection
and enhanced agent documentation framework.

VPN Configuration (PST-NW-VPN):
- Setup-PST-L2TP-VPN.ps1: Automated L2TP/IPsec setup with split-tunnel and DNS
- Connect-PST-VPN.ps1: Connection helper with PPP adapter detection, DNS (192.168.0.2), and route config (192.168.0.0/24)
- Connect-PST-VPN-Standalone.ps1: Self-contained connection script for remote deployment
- Fix-PST-VPN-Auth.ps1: Authentication troubleshooting for CHAP/MSChapv2
- Diagnose-VPN-Interface.ps1: Comprehensive VPN interface and routing diagnostic
- Quick-Test-VPN.ps1: Fast connectivity verification (DNS/router/routes)
- Add-PST-VPN-Route-Manual.ps1: Manual route configuration helper
- vpn-connect.bat, vpn-disconnect.bat: Simple batch file shortcuts
- OpenVPN config files (Windows-compatible, abandoned for L2TP)

Key VPN Implementation Details:
- L2TP creates PPP adapter with connection name as interface description
- UniFi auto-configures DNS (192.168.0.2) but requires manual route to 192.168.0.0/24
- Split-tunnel enabled (only remote traffic through VPN)
- All-user connection for pre-login auto-connect via scheduled task
- Authentication: CHAP + MSChapv2 for UniFi compatibility

Agent Documentation:
- AGENT_QUICK_REFERENCE.md: Quick reference for all specialized agents
- documentation-squire.md: Documentation and task management specialist agent
- Updated all agent markdown files with standardized formatting

Project Organization:
- Moved conversation logs to dedicated directories (guru-connect-conversation-logs, guru-rmm-conversation-logs)
- Cleaned up old session JSONL files from projects/msp-tools/
- Added guru-connect infrastructure (agent, dashboard, proto, scripts, .gitea workflows)
- Added guru-rmm server components and deployment configs

Technical Notes:
- VPN IP pool: 192.168.4.x (client gets 192.168.4.6)
- Remote network: 192.168.0.0/24 (router at 192.168.0.10)
- PSK: rrClvnmUeXEFo90Ol+z7tfsAZHeSK6w7
- Credentials: pst-admin / 24Hearts$

Files: 15 VPN scripts, 2 agent docs, conversation log reorganization,
guru-connect/guru-rmm infrastructure additions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 11:51:47 -07:00
parent b0a68d89bf
commit 6c316aa701
272 changed files with 37068 additions and 2 deletions

View File

@@ -0,0 +1,605 @@
//! System metrics collection module
//!
//! Uses the `sysinfo` crate for cross-platform system metrics collection.
//! Collects CPU, memory, disk, and network statistics.
//! Uses `local-ip-address` for network interface enumeration.
use chrono::{DateTime, Utc};
use local_ip_address::list_afinet_netifas;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, Networks, RefreshKind, System, Users};
/// System metrics data structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemMetrics {
/// Timestamp when metrics were collected
pub timestamp: DateTime<Utc>,
/// CPU usage percentage (0-100)
pub cpu_percent: f32,
/// Memory usage percentage (0-100)
pub memory_percent: f32,
/// Memory used in bytes
pub memory_used_bytes: u64,
/// Total memory in bytes
pub memory_total_bytes: u64,
/// Disk usage percentage (0-100) - primary disk
pub disk_percent: f32,
/// Disk used in bytes - primary disk
pub disk_used_bytes: u64,
/// Total disk space in bytes - primary disk
pub disk_total_bytes: u64,
/// Network bytes received since last collection
pub network_rx_bytes: u64,
/// Network bytes transmitted since last collection
pub network_tx_bytes: u64,
/// Operating system type
pub os_type: String,
/// Operating system version
pub os_version: String,
/// System hostname
pub hostname: String,
/// System uptime in seconds
#[serde(default)]
pub uptime_seconds: u64,
/// Boot time as Unix timestamp
#[serde(default)]
pub boot_time: i64,
/// Logged in username (if available)
#[serde(default)]
pub logged_in_user: Option<String>,
/// User idle time in seconds (time since last input)
#[serde(default)]
pub user_idle_seconds: Option<u64>,
/// Public/WAN IP address (fetched periodically)
#[serde(default)]
pub public_ip: Option<String>,
}
/// Metrics collector using sysinfo
pub struct MetricsCollector {
/// System info instance (needs to be refreshed for each collection)
system: Mutex<System>,
/// Previous network stats for delta calculation
prev_network_rx: Mutex<u64>,
prev_network_tx: Mutex<u64>,
/// Cached public IP (refreshed less frequently)
cached_public_ip: Mutex<Option<String>>,
/// Last time public IP was fetched
last_public_ip_fetch: Mutex<Option<std::time::Instant>>,
}
impl MetricsCollector {
/// Create a new metrics collector
pub fn new() -> Self {
// Create system with minimal initial refresh
let system = System::new_with_specifics(
RefreshKind::new()
.with_cpu(CpuRefreshKind::everything())
.with_memory(MemoryRefreshKind::everything()),
);
Self {
system: Mutex::new(system),
prev_network_rx: Mutex::new(0),
prev_network_tx: Mutex::new(0),
cached_public_ip: Mutex::new(None),
last_public_ip_fetch: Mutex::new(None),
}
}
/// Collect current system metrics
pub async fn collect(&self) -> SystemMetrics {
// Collect CPU - need to do two refreshes with delay for accurate reading
// We release the lock between operations to avoid holding MutexGuard across await
{
let mut system = self.system.lock().unwrap();
system.refresh_cpu_all();
}
// Small delay for CPU measurement accuracy
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
// Collect all synchronous metrics first, in a block that releases all locks
let (
cpu_percent,
memory_percent,
memory_used,
memory_total,
disk_percent,
disk_used,
disk_total,
delta_rx,
delta_tx,
os_type,
os_version,
hostname,
uptime_seconds,
boot_time,
logged_in_user,
user_idle_seconds,
) = {
// Acquire system lock
let mut system = self.system.lock().unwrap();
system.refresh_cpu_all();
system.refresh_memory();
// Calculate CPU usage (average across all cores)
let cpu_percent = system.global_cpu_usage();
// Memory metrics
let memory_used = system.used_memory();
let memory_total = system.total_memory();
let memory_percent = if memory_total > 0 {
(memory_used as f32 / memory_total as f32) * 100.0
} else {
0.0
};
// Disk metrics (use first/primary disk)
let disks = Disks::new_with_refreshed_list();
let (disk_used, disk_total, disk_percent) = disks
.iter()
.next()
.map(|d| {
let total = d.total_space();
let available = d.available_space();
let used = total.saturating_sub(available);
let percent = if total > 0 {
(used as f32 / total as f32) * 100.0
} else {
0.0
};
(used, total, percent)
})
.unwrap_or((0, 0, 0.0));
// Network metrics (sum all interfaces)
let networks = Networks::new_with_refreshed_list();
let (total_rx, total_tx): (u64, u64) = networks
.iter()
.map(|(_, data)| (data.total_received(), data.total_transmitted()))
.fold((0, 0), |(acc_rx, acc_tx), (rx, tx)| {
(acc_rx + rx, acc_tx + tx)
});
// Calculate delta from previous collection
let (delta_rx, delta_tx) = {
let mut prev_rx = self.prev_network_rx.lock().unwrap();
let mut prev_tx = self.prev_network_tx.lock().unwrap();
let delta_rx = total_rx.saturating_sub(*prev_rx);
let delta_tx = total_tx.saturating_sub(*prev_tx);
*prev_rx = total_rx;
*prev_tx = total_tx;
(delta_rx, delta_tx)
};
// Get OS info
let os_type = std::env::consts::OS.to_string();
let os_version = System::os_version().unwrap_or_else(|| "unknown".to_string());
let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
// Get uptime and boot time
let uptime_seconds = System::uptime();
let boot_time = System::boot_time() as i64;
// Get logged in user
let logged_in_user = self.get_logged_in_user();
// Get user idle time (platform-specific)
let user_idle_seconds = self.get_user_idle_time();
// Return all values - locks are dropped at end of this block
(
cpu_percent,
memory_percent,
memory_used,
memory_total,
disk_percent,
disk_used,
disk_total,
delta_rx,
delta_tx,
os_type,
os_version,
hostname,
uptime_seconds,
boot_time,
logged_in_user,
user_idle_seconds,
)
};
// All locks are now released - safe to do async work
// Get public IP (cached, refreshed every 5 minutes)
let public_ip = self.get_public_ip().await;
SystemMetrics {
timestamp: Utc::now(),
cpu_percent,
memory_percent,
memory_used_bytes: memory_used,
memory_total_bytes: memory_total,
disk_percent,
disk_used_bytes: disk_used,
disk_total_bytes: disk_total,
network_rx_bytes: delta_rx,
network_tx_bytes: delta_tx,
os_type,
os_version,
hostname,
uptime_seconds,
boot_time,
logged_in_user,
user_idle_seconds,
public_ip,
}
}
/// Get the currently logged in user
fn get_logged_in_user(&self) -> Option<String> {
let users = Users::new_with_refreshed_list();
// Return the first user found (typically the console user)
users.iter().next().map(|u| u.name().to_string())
}
/// Get user idle time in seconds (time since last keyboard/mouse input)
#[cfg(target_os = "windows")]
fn get_user_idle_time(&self) -> Option<u64> {
// Windows: Use GetLastInputInfo API
use std::mem;
#[repr(C)]
struct LASTINPUTINFO {
cb_size: u32,
dw_time: u32,
}
extern "system" {
fn GetLastInputInfo(plii: *mut LASTINPUTINFO) -> i32;
fn GetTickCount() -> u32;
}
unsafe {
let mut lii = LASTINPUTINFO {
cb_size: mem::size_of::<LASTINPUTINFO>() as u32,
dw_time: 0,
};
if GetLastInputInfo(&mut lii) != 0 {
let idle_ms = GetTickCount().wrapping_sub(lii.dw_time);
Some((idle_ms / 1000) as u64)
} else {
None
}
}
}
/// Get user idle time in seconds (Unix/macOS)
#[cfg(not(target_os = "windows"))]
fn get_user_idle_time(&self) -> Option<u64> {
// Unix: Check /dev/tty* or use platform-specific APIs
// For now, return None - can be enhanced with X11/Wayland idle detection
None
}
/// Get public IP address (cached for 5 minutes)
async fn get_public_ip(&self) -> Option<String> {
use std::time::{Duration, Instant};
const REFRESH_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes
// Check if we have a cached value that's still fresh
{
let last_fetch = self.last_public_ip_fetch.lock().unwrap();
let cached_ip = self.cached_public_ip.lock().unwrap();
if let Some(last) = *last_fetch {
if last.elapsed() < REFRESH_INTERVAL {
return cached_ip.clone();
}
}
}
// Fetch new public IP
let new_ip = self.fetch_public_ip().await;
// Update cache
{
let mut last_fetch = self.last_public_ip_fetch.lock().unwrap();
let mut cached_ip = self.cached_public_ip.lock().unwrap();
*last_fetch = Some(Instant::now());
*cached_ip = new_ip.clone();
}
new_ip
}
/// Fetch public IP from external service
async fn fetch_public_ip(&self) -> Option<String> {
// Try multiple services for reliability
let services = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
];
for service in &services {
match reqwest::get(*service).await {
Ok(resp) if resp.status().is_success() => {
if let Ok(ip) = resp.text().await {
let ip = ip.trim().to_string();
// Basic validation: should look like an IP
if ip.parse::<std::net::IpAddr>().is_ok() {
return Some(ip);
}
}
}
_ => continue,
}
}
None
}
/// Get basic system info (for registration)
pub fn get_system_info(&self) -> SystemInfo {
let system = self.system.lock().unwrap();
SystemInfo {
os_type: std::env::consts::OS.to_string(),
os_version: System::os_version().unwrap_or_else(|| "unknown".to_string()),
hostname: System::host_name().unwrap_or_else(|| "unknown".to_string()),
cpu_count: system.cpus().len() as u32,
total_memory_bytes: system.total_memory(),
}
}
}
impl Default for MetricsCollector {
fn default() -> Self {
Self::new()
}
}
/// Basic system information (for agent registration)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemInfo {
/// Operating system type (windows, linux, macos)
pub os_type: String,
/// Operating system version
pub os_version: String,
/// System hostname
pub hostname: String,
/// Number of CPU cores
pub cpu_count: u32,
/// Total memory in bytes
pub total_memory_bytes: u64,
}
/// Network interface information
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetworkInterface {
/// Interface name (e.g., "eth0", "Wi-Fi", "Ethernet")
pub name: String,
/// MAC address (if available from sysinfo)
pub mac_address: Option<String>,
/// IPv4 addresses assigned to this interface
pub ipv4_addresses: Vec<String>,
/// IPv6 addresses assigned to this interface
pub ipv6_addresses: Vec<String>,
}
/// Complete network state (sent on connect and on change)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetworkState {
/// Timestamp when network state was collected
pub timestamp: DateTime<Utc>,
/// All network interfaces with their addresses
pub interfaces: Vec<NetworkInterface>,
/// Hash of the network state for quick change detection
pub state_hash: String,
}
impl NetworkState {
/// Collect current network state from the system
pub fn collect() -> Self {
let mut interface_map: HashMap<String, NetworkInterface> = HashMap::new();
// Get IP addresses from local-ip-address crate
if let Ok(netifas) = list_afinet_netifas() {
for (name, ip) in netifas {
let entry = interface_map.entry(name.clone()).or_insert_with(|| {
NetworkInterface {
name: name.clone(),
mac_address: None,
ipv4_addresses: Vec::new(),
ipv6_addresses: Vec::new(),
}
});
match ip {
IpAddr::V4(addr) => {
let addr_str = addr.to_string();
if !entry.ipv4_addresses.contains(&addr_str) {
entry.ipv4_addresses.push(addr_str);
}
}
IpAddr::V6(addr) => {
let addr_str = addr.to_string();
if !entry.ipv6_addresses.contains(&addr_str) {
entry.ipv6_addresses.push(addr_str);
}
}
}
}
}
// Get MAC addresses from sysinfo
let networks = Networks::new_with_refreshed_list();
for (name, data) in &networks {
if let Some(entry) = interface_map.get_mut(name) {
let mac = data.mac_address();
let mac_str = format!(
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
mac.0[0], mac.0[1], mac.0[2], mac.0[3], mac.0[4], mac.0[5]
);
// Don't store empty/null MACs
if mac_str != "00:00:00:00:00:00" {
entry.mac_address = Some(mac_str);
}
}
}
// Convert to sorted vec for consistent ordering
let mut interfaces: Vec<NetworkInterface> = interface_map.into_values().collect();
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
// Filter out loopback and link-local only interfaces
interfaces.retain(|iface| {
// Keep if has any non-loopback IPv4
let has_real_ipv4 = iface.ipv4_addresses.iter().any(|ip| {
!ip.starts_with("127.") && !ip.starts_with("169.254.")
});
// Keep if has any non-link-local IPv6
let has_real_ipv6 = iface.ipv6_addresses.iter().any(|ip| {
!ip.starts_with("fe80:") && !ip.starts_with("::1")
});
has_real_ipv4 || has_real_ipv6
});
// Generate hash for change detection
let state_hash = Self::compute_hash(&interfaces);
NetworkState {
timestamp: Utc::now(),
interfaces,
state_hash,
}
}
/// Compute a simple hash of the network state for change detection
fn compute_hash(interfaces: &[NetworkInterface]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
for iface in interfaces {
iface.name.hash(&mut hasher);
iface.mac_address.hash(&mut hasher);
for ip in &iface.ipv4_addresses {
ip.hash(&mut hasher);
}
for ip in &iface.ipv6_addresses {
ip.hash(&mut hasher);
}
}
format!("{:016x}", hasher.finish())
}
/// Check if network state has changed compared to another state
pub fn has_changed(&self, other: &NetworkState) -> bool {
self.state_hash != other.state_hash
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_metrics_collection() {
let collector = MetricsCollector::new();
let metrics = collector.collect().await;
// Basic sanity checks
assert!(metrics.cpu_percent >= 0.0 && metrics.cpu_percent <= 100.0);
assert!(metrics.memory_percent >= 0.0 && metrics.memory_percent <= 100.0);
assert!(metrics.memory_total_bytes > 0);
assert!(!metrics.os_type.is_empty());
assert!(!metrics.hostname.is_empty());
}
#[test]
fn test_system_info() {
let collector = MetricsCollector::new();
let info = collector.get_system_info();
assert!(!info.os_type.is_empty());
assert!(!info.hostname.is_empty());
assert!(info.cpu_count > 0);
assert!(info.total_memory_bytes > 0);
}
#[test]
fn test_network_state_collection() {
let state = NetworkState::collect();
// Should have a valid timestamp
assert!(state.timestamp <= Utc::now());
// Should have a hash
assert!(!state.state_hash.is_empty());
assert_eq!(state.state_hash.len(), 16); // 64-bit hash as hex
// Print for debugging
println!("Network state collected:");
for iface in &state.interfaces {
println!(" {}: IPv4={:?}, IPv6={:?}, MAC={:?}",
iface.name, iface.ipv4_addresses, iface.ipv6_addresses, iface.mac_address);
}
}
#[test]
fn test_network_state_change_detection() {
let state1 = NetworkState::collect();
let state2 = NetworkState::collect();
// Same state should have same hash
assert!(!state1.has_changed(&state2));
// Create a modified state
let mut modified = state1.clone();
if let Some(iface) = modified.interfaces.first_mut() {
iface.ipv4_addresses.push("10.99.99.99".to_string());
}
modified.state_hash = NetworkState::compute_hash(&modified.interfaces);
// Modified state should be detected as changed
assert!(state1.has_changed(&modified));
}
}