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>
312 lines
10 KiB
Rust
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());
|
|
}
|
|
}
|