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:
318
projects/msp-tools/guru-connect/agent/src/update.rs
Normal file
318
projects/msp-tools/guru-connect/agent/src/update.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! Auto-update module for GuruConnect agent
|
||||
//!
|
||||
//! Handles checking for updates, downloading new versions, and performing
|
||||
//! in-place binary replacement with restart.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use sha2::{Sha256, Digest};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
use crate::build_info;
|
||||
|
||||
/// Version information from the server
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct VersionInfo {
|
||||
pub latest_version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub is_mandatory: bool,
|
||||
pub release_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Update state tracking
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum UpdateState {
|
||||
Idle,
|
||||
Checking,
|
||||
Downloading,
|
||||
Verifying,
|
||||
Installing,
|
||||
Restarting,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Check if an update is available
|
||||
pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInfo>> {
|
||||
let url = format!("{}/api/version", server_base_url.trim_end_matches('/'));
|
||||
info!("Checking for updates at {}", url);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true) // For self-signed certs in dev
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
info!("No stable release available on server");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Version check failed: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let version_info: VersionInfo = response.json().await?;
|
||||
|
||||
// Compare versions
|
||||
let current = build_info::VERSION;
|
||||
if is_newer_version(&version_info.latest_version, current) {
|
||||
info!(
|
||||
"Update available: {} -> {} (mandatory: {})",
|
||||
current, version_info.latest_version, version_info.is_mandatory
|
||||
);
|
||||
Ok(Some(version_info))
|
||||
} else {
|
||||
info!("Already running latest version: {}", current);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple semantic version comparison
|
||||
/// Returns true if `available` is newer than `current`
|
||||
fn is_newer_version(available: &str, current: &str) -> bool {
|
||||
// Strip any git hash suffix (e.g., "0.1.0-abc123" -> "0.1.0")
|
||||
let available_clean = available.split('-').next().unwrap_or(available);
|
||||
let current_clean = current.split('-').next().unwrap_or(current);
|
||||
|
||||
let parse_version = |s: &str| -> Vec<u32> {
|
||||
s.split('.')
|
||||
.filter_map(|p| p.parse().ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
let av = parse_version(available_clean);
|
||||
let cv = parse_version(current_clean);
|
||||
|
||||
// Compare component by component
|
||||
for i in 0..av.len().max(cv.len()) {
|
||||
let a = av.get(i).copied().unwrap_or(0);
|
||||
let c = cv.get(i).copied().unwrap_or(0);
|
||||
if a > c {
|
||||
return true;
|
||||
}
|
||||
if a < c {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Download update to temporary file
|
||||
pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
|
||||
info!("Downloading update from {}", version_info.download_url);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&version_info.download_url)
|
||||
.timeout(std::time::Duration::from_secs(300)) // 5 minutes for large files
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Download failed: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
// Get temp directory
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_path = temp_dir.join("guruconnect-update.exe");
|
||||
|
||||
// Download to file
|
||||
let bytes = response.bytes().await?;
|
||||
std::fs::write(&temp_path, &bytes)?;
|
||||
|
||||
info!("Downloaded {} bytes to {:?}", bytes.len(), temp_path);
|
||||
Ok(temp_path)
|
||||
}
|
||||
|
||||
/// Verify downloaded file checksum
|
||||
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
|
||||
info!("Verifying checksum...");
|
||||
|
||||
let contents = std::fs::read(file_path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&contents);
|
||||
let result = hasher.finalize();
|
||||
let computed = format!("{:x}", result);
|
||||
|
||||
let matches = computed.eq_ignore_ascii_case(expected_sha256);
|
||||
|
||||
if matches {
|
||||
info!("Checksum verified: {}", computed);
|
||||
} else {
|
||||
error!("Checksum mismatch! Expected: {}, Got: {}", expected_sha256, computed);
|
||||
}
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Perform the actual update installation
|
||||
/// This renames the current executable and copies the new one in place
|
||||
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
|
||||
info!("Installing update...");
|
||||
|
||||
// Get current executable path
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let exe_dir = current_exe.parent()
|
||||
.ok_or_else(|| anyhow!("Cannot get executable directory"))?;
|
||||
|
||||
// Create paths for backup and new executable
|
||||
let backup_path = exe_dir.join("guruconnect.exe.old");
|
||||
|
||||
// Delete any existing backup
|
||||
if backup_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&backup_path) {
|
||||
warn!("Could not remove old backup: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Rename current executable to .old (this works even while running)
|
||||
info!("Renaming current exe to backup: {:?}", backup_path);
|
||||
std::fs::rename(¤t_exe, &backup_path)?;
|
||||
|
||||
// Copy new executable to original location
|
||||
info!("Copying new exe to: {:?}", current_exe);
|
||||
std::fs::copy(temp_path, ¤t_exe)?;
|
||||
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
|
||||
info!("Update installed successfully");
|
||||
Ok(current_exe)
|
||||
}
|
||||
|
||||
/// Spawn new process and exit current one
|
||||
pub fn restart_with_new_version(exe_path: &PathBuf, args: &[String]) -> Result<()> {
|
||||
info!("Restarting with new version...");
|
||||
|
||||
// Build command with --post-update flag
|
||||
let mut cmd_args = vec!["--post-update".to_string()];
|
||||
cmd_args.extend(args.iter().cloned());
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
std::process::Command::new(exe_path)
|
||||
.args(&cmd_args)
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.spawn()?;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::process::Command::new(exe_path)
|
||||
.args(&cmd_args)
|
||||
.spawn()?;
|
||||
}
|
||||
|
||||
info!("New process spawned, exiting current process");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up old executable after successful update
|
||||
pub fn cleanup_post_update() {
|
||||
let current_exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("Could not get current exe path for cleanup: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let exe_dir = match current_exe.parent() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
warn!("Could not get executable directory for cleanup");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let backup_path = exe_dir.join("guruconnect.exe.old");
|
||||
|
||||
if backup_path.exists() {
|
||||
info!("Cleaning up old executable: {:?}", backup_path);
|
||||
match std::fs::remove_file(&backup_path) {
|
||||
Ok(_) => info!("Old executable removed successfully"),
|
||||
Err(e) => {
|
||||
warn!("Could not remove old executable (may be in use): {}", e);
|
||||
// On Windows, we might need to schedule deletion on reboot
|
||||
#[cfg(windows)]
|
||||
schedule_delete_on_reboot(&backup_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule file deletion on reboot (Windows)
|
||||
#[cfg(windows)]
|
||||
fn schedule_delete_on_reboot(path: &PathBuf) {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
let path_wide: Vec<u16> = path.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
let result = MoveFileExW(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
MOVEFILE_DELAY_UNTIL_REBOOT,
|
||||
);
|
||||
if result.is_ok() {
|
||||
info!("Scheduled {:?} for deletion on reboot", path);
|
||||
} else {
|
||||
warn!("Failed to schedule {:?} for deletion on reboot", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform complete update process
|
||||
pub async fn perform_update(version_info: &VersionInfo) -> Result<()> {
|
||||
// Download
|
||||
let temp_path = download_update(version_info).await?;
|
||||
|
||||
// Verify
|
||||
if !verify_checksum(&temp_path, &version_info.checksum_sha256)? {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(anyhow!("Update verification failed: checksum mismatch"));
|
||||
}
|
||||
|
||||
// Install
|
||||
let exe_path = install_update(&temp_path)?;
|
||||
|
||||
// Restart
|
||||
// Get current args (without the current executable name)
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
restart_with_new_version(&exe_path, &args)?;
|
||||
|
||||
// Exit current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_comparison() {
|
||||
assert!(is_newer_version("0.2.0", "0.1.0"));
|
||||
assert!(is_newer_version("1.0.0", "0.9.9"));
|
||||
assert!(is_newer_version("0.1.1", "0.1.0"));
|
||||
assert!(!is_newer_version("0.1.0", "0.1.0"));
|
||||
assert!(!is_newer_version("0.1.0", "0.2.0"));
|
||||
assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user