Files
claudetools/projects/msp-tools/guru-rmm/server/src/updates/scanner.rs
Mike Swanson 6c316aa701 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>
2026-01-18 11:51:47 -07:00

312 lines
10 KiB
Rust

//! Version scanner for available agent binaries
//!
//! Scans a downloads directory for agent binaries and parses version info
//! from filenames in the format: gururmm-agent-{os}-{arch}-{version}[.exe]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use semver::Version;
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
/// Information about an available agent version
#[derive(Debug, Clone)]
pub struct AvailableVersion {
/// Semantic version
pub version: Version,
/// Operating system (linux, windows)
pub os: String,
/// Architecture (amd64, arm64)
pub arch: String,
/// Filename on disk
pub filename: String,
/// Full download URL
pub download_url: String,
/// SHA256 checksum
pub checksum_sha256: String,
/// File size in bytes
pub file_size: u64,
}
/// Manages available agent versions
pub struct UpdateManager {
/// Directory containing agent binaries
downloads_dir: PathBuf,
/// Base URL for downloads
base_url: String,
/// Cached available versions, keyed by "os-arch"
versions: Arc<RwLock<HashMap<String, Vec<AvailableVersion>>>>,
/// Whether auto-updates are enabled
pub auto_update_enabled: bool,
/// Update timeout in seconds
pub update_timeout_secs: u64,
}
impl UpdateManager {
/// Create a new UpdateManager
pub fn new(
downloads_dir: PathBuf,
base_url: String,
auto_update_enabled: bool,
update_timeout_secs: u64,
) -> Self {
Self {
downloads_dir,
base_url,
versions: Arc::new(RwLock::new(HashMap::new())),
auto_update_enabled,
update_timeout_secs,
}
}
/// Scan the downloads directory for available agent binaries
pub async fn scan_versions(&self) -> Result<()> {
let mut versions: HashMap<String, Vec<AvailableVersion>> = HashMap::new();
if !self.downloads_dir.exists() {
warn!("Downloads directory does not exist: {:?}", self.downloads_dir);
return Ok(());
}
let entries = std::fs::read_dir(&self.downloads_dir)?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
// Skip checksum files
if filename.ends_with(".sha256") {
continue;
}
// Try to parse as agent binary
if let Some((os, arch, version)) = Self::parse_filename(&filename) {
// Read checksum from companion file
let checksum = self.read_checksum(&path).await.unwrap_or_default();
if checksum.is_empty() {
warn!("No checksum found for {}, skipping", filename);
continue;
}
// Get file size
let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let download_url = format!("{}/{}", self.base_url.trim_end_matches('/'), filename);
let available = AvailableVersion {
version,
os: os.clone(),
arch: arch.clone(),
filename: filename.clone(),
download_url,
checksum_sha256: checksum,
file_size,
};
let key = format!("{}-{}", os, arch);
debug!("Found agent binary: {} (v{})", filename, available.version);
versions.entry(key).or_default().push(available);
}
}
// Sort each list by version descending (newest first)
for list in versions.values_mut() {
list.sort_by(|a, b| b.version.cmp(&a.version));
}
let total: usize = versions.values().map(|v| v.len()).sum();
info!("Scanned {} agent binaries across {} platform/arch combinations", total, versions.len());
*self.versions.write().await = versions;
Ok(())
}
/// Parse a filename to extract OS, architecture, and version
///
/// Expected format: gururmm-agent-{os}-{arch}-{version}[.exe]
/// Examples:
/// - gururmm-agent-linux-amd64-0.2.0
/// - gururmm-agent-windows-amd64-0.2.0.exe
fn parse_filename(filename: &str) -> Option<(String, String, Version)> {
// Remove .exe extension if present
let name = filename.strip_suffix(".exe").unwrap_or(filename);
// Split by dashes
let parts: Vec<&str> = name.split('-').collect();
// Expected: ["gururmm", "agent", "linux", "amd64", "0", "2", "0"]
// or: ["gururmm", "agent", "linux", "amd64", "0.2.0"]
if parts.len() < 5 || parts[0] != "gururmm" || parts[1] != "agent" {
return None;
}
let os = parts[2].to_string();
let arch = parts[3].to_string();
// Version could be either:
// - A single part with dots: "0.2.0"
// - Multiple parts joined: "0", "2", "0"
let version_str = if parts.len() == 5 {
// Single part with dots
parts[4].to_string()
} else {
// Multiple parts, join with dots
parts[4..].join(".")
};
let version = Version::parse(&version_str).ok()?;
Some((os, arch, version))
}
/// Read checksum from companion .sha256 file
async fn read_checksum(&self, binary_path: &Path) -> Result<String> {
let checksum_path = PathBuf::from(format!("{}.sha256", binary_path.display()));
if !checksum_path.exists() {
return Err(anyhow::anyhow!("Checksum file not found"));
}
let content = tokio::fs::read_to_string(&checksum_path).await?;
// Checksum file format: "<hash> <filename>" or just "<hash>"
let checksum = content
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("Empty checksum file"))?
.to_lowercase();
// Validate it looks like a SHA256 hash (64 hex chars)
if checksum.len() != 64 || !checksum.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid checksum format"));
}
Ok(checksum)
}
/// Get the latest version available for a given OS/arch
pub async fn get_latest_version(&self, os: &str, arch: &str) -> Option<AvailableVersion> {
let versions = self.versions.read().await;
let key = format!("{}-{}", os, arch);
versions.get(&key).and_then(|list| list.first().cloned())
}
/// Check if an agent with the given version needs an update
/// Returns the available update if one exists
pub async fn needs_update(
&self,
current_version: &str,
os: &str,
arch: &str,
) -> Option<AvailableVersion> {
if !self.auto_update_enabled {
return None;
}
let current = match Version::parse(current_version) {
Ok(v) => v,
Err(e) => {
warn!("Failed to parse current version '{}': {}", current_version, e);
return None;
}
};
let latest = self.get_latest_version(os, arch).await?;
if latest.version > current {
info!(
"Agent needs update: {} -> {} ({}-{})",
current, latest.version, os, arch
);
Some(latest)
} else {
None
}
}
/// Get all available versions (for dashboard display)
pub async fn get_all_versions(&self) -> HashMap<String, Vec<AvailableVersion>> {
self.versions.read().await.clone()
}
/// Spawn a background task to periodically rescan versions
pub fn spawn_scanner(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> {
let downloads_dir = self.downloads_dir.clone();
let base_url = self.base_url.clone();
let versions = self.versions.clone();
let auto_update_enabled = self.auto_update_enabled;
let update_timeout_secs = self.update_timeout_secs;
tokio::spawn(async move {
let manager = UpdateManager {
downloads_dir,
base_url,
versions,
auto_update_enabled,
update_timeout_secs,
};
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
interval.tick().await;
if let Err(e) = manager.scan_versions().await {
error!("Failed to scan versions: {}", e);
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_filename_linux() {
let result = UpdateManager::parse_filename("gururmm-agent-linux-amd64-0.2.0");
assert!(result.is_some());
let (os, arch, version) = result.unwrap();
assert_eq!(os, "linux");
assert_eq!(arch, "amd64");
assert_eq!(version, Version::new(0, 2, 0));
}
#[test]
fn test_parse_filename_windows() {
let result = UpdateManager::parse_filename("gururmm-agent-windows-amd64-0.2.0.exe");
assert!(result.is_some());
let (os, arch, version) = result.unwrap();
assert_eq!(os, "windows");
assert_eq!(arch, "amd64");
assert_eq!(version, Version::new(0, 2, 0));
}
#[test]
fn test_parse_filename_arm64() {
let result = UpdateManager::parse_filename("gururmm-agent-linux-arm64-1.0.0");
assert!(result.is_some());
let (os, arch, version) = result.unwrap();
assert_eq!(os, "linux");
assert_eq!(arch, "arm64");
assert_eq!(version, Version::new(1, 0, 0));
}
#[test]
fn test_parse_filename_invalid() {
assert!(UpdateManager::parse_filename("random-file.txt").is_none());
assert!(UpdateManager::parse_filename("gururmm-server-linux-amd64-0.1.0").is_none());
assert!(UpdateManager::parse_filename("gururmm-agent-linux").is_none());
}
}