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:
554
projects/msp-tools/guru-rmm/agent/src/updater/mod.rs
Normal file
554
projects/msp-tools/guru-rmm/agent/src/updater/mod.rs
Normal file
@@ -0,0 +1,554 @@
|
||||
//! Agent self-update module
|
||||
//!
|
||||
//! Handles downloading, verifying, and installing agent updates.
|
||||
//! Features:
|
||||
//! - Download new binary via HTTPS
|
||||
//! - SHA256 checksum verification
|
||||
//! - Atomic binary replacement
|
||||
//! - Auto-rollback if agent fails to restart
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sha2::{Sha256, Digest};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::transport::{UpdatePayload, UpdateResultPayload, UpdateStatus};
|
||||
|
||||
/// Configuration for the updater
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdaterConfig {
|
||||
/// Path to the current agent binary
|
||||
pub binary_path: PathBuf,
|
||||
/// Directory for config and backup files
|
||||
pub config_dir: PathBuf,
|
||||
/// Rollback timeout in seconds
|
||||
pub rollback_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for UpdaterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
binary_path: Self::detect_binary_path(),
|
||||
config_dir: Self::detect_config_dir(),
|
||||
rollback_timeout_secs: 180,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdaterConfig {
|
||||
/// Detect the path to the currently running binary
|
||||
fn detect_binary_path() -> PathBuf {
|
||||
std::env::current_exe().unwrap_or_else(|_| {
|
||||
#[cfg(windows)]
|
||||
{ PathBuf::from(r"C:\Program Files\GuruRMM\gururmm-agent.exe") }
|
||||
#[cfg(not(windows))]
|
||||
{ PathBuf::from("/usr/local/bin/gururmm-agent") }
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the config directory
|
||||
fn detect_config_dir() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{ PathBuf::from(r"C:\ProgramData\GuruRMM") }
|
||||
#[cfg(not(windows))]
|
||||
{ PathBuf::from("/etc/gururmm") }
|
||||
}
|
||||
|
||||
/// Get the backup binary path
|
||||
pub fn backup_path(&self) -> PathBuf {
|
||||
self.config_dir.join("gururmm-agent.backup")
|
||||
}
|
||||
|
||||
/// Get the pending update info path (stores update_id for reconnection)
|
||||
pub fn pending_update_path(&self) -> PathBuf {
|
||||
self.config_dir.join("pending-update.json")
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending update information (persisted to disk before restart)
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PendingUpdateInfo {
|
||||
pub update_id: Uuid,
|
||||
pub old_version: String,
|
||||
pub target_version: String,
|
||||
}
|
||||
|
||||
/// Agent updater
|
||||
pub struct AgentUpdater {
|
||||
config: UpdaterConfig,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl AgentUpdater {
|
||||
/// Create a new updater
|
||||
pub fn new(config: UpdaterConfig) -> Self {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self { config, http_client }
|
||||
}
|
||||
|
||||
/// Perform an update
|
||||
///
|
||||
/// Returns UpdateResultPayload to send back to server
|
||||
pub async fn perform_update(&self, payload: UpdatePayload) -> UpdateResultPayload {
|
||||
let old_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
|
||||
info!(
|
||||
"Starting update: {} -> {} (update_id: {})",
|
||||
old_version, payload.target_version, payload.update_id
|
||||
);
|
||||
|
||||
match self.do_update(&payload, &old_version).await {
|
||||
Ok(()) => {
|
||||
// If we get here, something went wrong - we should have restarted
|
||||
// This means the update completed but restart failed
|
||||
UpdateResultPayload {
|
||||
update_id: payload.update_id,
|
||||
status: UpdateStatus::Failed,
|
||||
old_version,
|
||||
new_version: None,
|
||||
error: Some("Update installed but restart failed".into()),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Update failed: {}", e);
|
||||
UpdateResultPayload {
|
||||
update_id: payload.update_id,
|
||||
status: UpdateStatus::Failed,
|
||||
old_version,
|
||||
new_version: None,
|
||||
error: Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal update implementation
|
||||
async fn do_update(&self, payload: &UpdatePayload, old_version: &str) -> Result<()> {
|
||||
// Step 1: Download to temp file
|
||||
info!("Downloading new binary from {}", payload.download_url);
|
||||
let temp_path = self.download_binary(&payload.download_url).await
|
||||
.context("Failed to download binary")?;
|
||||
|
||||
// Step 2: Verify checksum
|
||||
info!("Verifying checksum...");
|
||||
self.verify_checksum(&temp_path, &payload.checksum_sha256).await
|
||||
.context("Checksum verification failed")?;
|
||||
info!("Checksum verified");
|
||||
|
||||
// Step 3: Backup current binary
|
||||
info!("Backing up current binary...");
|
||||
self.backup_current_binary().await
|
||||
.context("Failed to backup current binary")?;
|
||||
|
||||
// Step 4: Save pending update info (for reconnection after restart)
|
||||
info!("Saving pending update info...");
|
||||
self.save_pending_update(PendingUpdateInfo {
|
||||
update_id: payload.update_id,
|
||||
old_version: old_version.to_string(),
|
||||
target_version: payload.target_version.clone(),
|
||||
}).await
|
||||
.context("Failed to save pending update info")?;
|
||||
|
||||
// Step 5: Create rollback watchdog
|
||||
info!("Creating rollback watchdog...");
|
||||
self.create_rollback_watchdog().await
|
||||
.context("Failed to create rollback watchdog")?;
|
||||
|
||||
// Step 6: Replace binary
|
||||
info!("Replacing binary...");
|
||||
self.replace_binary(&temp_path).await
|
||||
.context("Failed to replace binary")?;
|
||||
|
||||
// Step 7: Restart service
|
||||
info!("Restarting service...");
|
||||
self.restart_service().await
|
||||
.context("Failed to restart service")?;
|
||||
|
||||
// We should never reach here - the restart should terminate this process
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the new binary to a temp file
|
||||
async fn download_binary(&self, url: &str) -> Result<PathBuf> {
|
||||
let response = self.http_client.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP request failed")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("Download failed with status: {}", response.status());
|
||||
}
|
||||
|
||||
let temp_path = std::env::temp_dir().join(format!("gururmm-update-{}", Uuid::new_v4()));
|
||||
let mut file = fs::File::create(&temp_path).await
|
||||
.context("Failed to create temp file")?;
|
||||
|
||||
let bytes = response.bytes().await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
file.write_all(&bytes).await
|
||||
.context("Failed to write to temp file")?;
|
||||
file.flush().await?;
|
||||
|
||||
debug!("Downloaded {} bytes to {:?}", bytes.len(), temp_path);
|
||||
Ok(temp_path)
|
||||
}
|
||||
|
||||
/// Verify SHA256 checksum of downloaded file
|
||||
async fn verify_checksum(&self, path: &Path, expected: &str) -> Result<()> {
|
||||
let bytes = fs::read(path).await
|
||||
.context("Failed to read file for checksum")?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
|
||||
if actual.to_lowercase() != expected.to_lowercase() {
|
||||
anyhow::bail!(
|
||||
"Checksum mismatch: expected {}, got {}",
|
||||
expected.to_lowercase(),
|
||||
actual.to_lowercase()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Backup the current binary
|
||||
async fn backup_current_binary(&self) -> Result<()> {
|
||||
let backup_path = self.config.backup_path();
|
||||
|
||||
// Ensure config directory exists
|
||||
if let Some(parent) = backup_path.parent() {
|
||||
fs::create_dir_all(parent).await.ok();
|
||||
}
|
||||
|
||||
// Copy current binary to backup location
|
||||
fs::copy(&self.config.binary_path, &backup_path).await
|
||||
.context("Failed to copy binary to backup")?;
|
||||
|
||||
debug!("Backed up to {:?}", backup_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save pending update info to disk
|
||||
async fn save_pending_update(&self, info: PendingUpdateInfo) -> Result<()> {
|
||||
let path = self.config.pending_update_path();
|
||||
let json = serde_json::to_string(&info)?;
|
||||
fs::write(&path, json).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load pending update info from disk (called on startup)
|
||||
pub async fn load_pending_update(config: &UpdaterConfig) -> Option<PendingUpdateInfo> {
|
||||
let path = config.pending_update_path();
|
||||
if let Ok(json) = fs::read_to_string(&path).await {
|
||||
if let Ok(info) = serde_json::from_str(&json) {
|
||||
// Clear the file after loading
|
||||
let _ = fs::remove_file(&path).await;
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a rollback watchdog that will restore the backup if agent fails to start
|
||||
async fn create_rollback_watchdog(&self) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
self.create_unix_rollback_watchdog().await?;
|
||||
|
||||
#[cfg(windows)]
|
||||
self.create_windows_rollback_watchdog().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn create_unix_rollback_watchdog(&self) -> Result<()> {
|
||||
let backup_path = self.config.backup_path();
|
||||
let binary_path = &self.config.binary_path;
|
||||
let timeout = self.config.rollback_timeout_secs;
|
||||
|
||||
let script = format!(r#"#!/bin/bash
|
||||
# GuruRMM Rollback Watchdog
|
||||
# Auto-generated - will be deleted after successful update
|
||||
|
||||
BACKUP="{backup}"
|
||||
BINARY="{binary}"
|
||||
TIMEOUT={timeout}
|
||||
|
||||
sleep $TIMEOUT
|
||||
|
||||
# Check if agent service is running
|
||||
if ! systemctl is-active --quiet gururmm-agent 2>/dev/null; then
|
||||
echo "Agent not running after update, rolling back..."
|
||||
if [ -f "$BACKUP" ]; then
|
||||
cp "$BACKUP" "$BINARY"
|
||||
chmod +x "$BINARY"
|
||||
systemctl start gururmm-agent
|
||||
echo "Rollback completed"
|
||||
else
|
||||
echo "No backup file found!"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up this script
|
||||
rm -f /tmp/gururmm-rollback.sh
|
||||
"#,
|
||||
backup = backup_path.display(),
|
||||
binary = binary_path.display(),
|
||||
timeout = timeout
|
||||
);
|
||||
|
||||
let script_path = PathBuf::from("/tmp/gururmm-rollback.sh");
|
||||
fs::write(&script_path, script).await?;
|
||||
|
||||
// Make executable and run in background
|
||||
tokio::process::Command::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(&script_path)
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
// Spawn as detached background process
|
||||
tokio::process::Command::new("nohup")
|
||||
.arg("bash")
|
||||
.arg(&script_path)
|
||||
.arg("&")
|
||||
.spawn()
|
||||
.context("Failed to spawn rollback watchdog")?;
|
||||
|
||||
info!("Rollback watchdog started (timeout: {}s)", timeout);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn create_windows_rollback_watchdog(&self) -> Result<()> {
|
||||
let backup_path = self.config.backup_path();
|
||||
let binary_path = &self.config.binary_path;
|
||||
let timeout = self.config.rollback_timeout_secs;
|
||||
|
||||
// Create a PowerShell script for rollback
|
||||
let script = format!(r#"
|
||||
# GuruRMM Rollback Watchdog
|
||||
# Auto-generated - will be deleted after successful update
|
||||
|
||||
$Backup = "{backup}"
|
||||
$Binary = "{binary}"
|
||||
$Timeout = {timeout}
|
||||
|
||||
Start-Sleep -Seconds $Timeout
|
||||
|
||||
# Check if agent service is running
|
||||
$service = Get-Service -Name "gururmm-agent" -ErrorAction SilentlyContinue
|
||||
if ($service -and $service.Status -ne 'Running') {{
|
||||
Write-Host "Agent not running after update, rolling back..."
|
||||
if (Test-Path $Backup) {{
|
||||
Stop-Service -Name "gururmm-agent" -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item -Path $Backup -Destination $Binary -Force
|
||||
Start-Service -Name "gururmm-agent"
|
||||
Write-Host "Rollback completed"
|
||||
}} else {{
|
||||
Write-Host "No backup file found!"
|
||||
}}
|
||||
}}
|
||||
|
||||
# Clean up
|
||||
Remove-Item -Path $MyInvocation.MyCommand.Path -Force
|
||||
"#,
|
||||
backup = backup_path.display().to_string().replace('\\', "\\\\"),
|
||||
binary = binary_path.display().to_string().replace('\\', "\\\\"),
|
||||
timeout = timeout
|
||||
);
|
||||
|
||||
let script_path = std::env::temp_dir().join("gururmm-rollback.ps1");
|
||||
fs::write(&script_path, script).await?;
|
||||
|
||||
// Schedule a task to run the rollback script
|
||||
tokio::process::Command::new("schtasks")
|
||||
.args([
|
||||
"/Create",
|
||||
"/TN", "GuruRMM-Rollback",
|
||||
"/TR", &format!("powershell.exe -ExecutionPolicy Bypass -File \"{}\"", script_path.display()),
|
||||
"/SC", "ONCE",
|
||||
"/ST", &Self::get_scheduled_time(timeout),
|
||||
"/F",
|
||||
])
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
info!("Rollback watchdog scheduled (timeout: {}s)", timeout);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_scheduled_time(seconds_from_now: u64) -> String {
|
||||
use chrono::Local;
|
||||
let now = Local::now();
|
||||
let scheduled = now + chrono::Duration::seconds(seconds_from_now as i64);
|
||||
scheduled.format("%H:%M").to_string()
|
||||
}
|
||||
|
||||
/// Replace the binary with the new one
|
||||
async fn replace_binary(&self, new_binary: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
info!(
|
||||
"Replacing binary: source={:?}, dest={:?}",
|
||||
new_binary, self.config.binary_path
|
||||
);
|
||||
|
||||
// Verify source exists
|
||||
if !new_binary.exists() {
|
||||
anyhow::bail!("Source binary does not exist: {:?}", new_binary);
|
||||
}
|
||||
|
||||
let source_meta = fs::metadata(new_binary).await
|
||||
.context("Failed to read source binary metadata")?;
|
||||
info!("Source binary size: {} bytes", source_meta.len());
|
||||
|
||||
// Check destination directory
|
||||
if let Some(parent) = self.config.binary_path.parent() {
|
||||
if !parent.exists() {
|
||||
anyhow::bail!("Destination directory does not exist: {:?}", parent);
|
||||
}
|
||||
}
|
||||
|
||||
// On Unix, we cannot overwrite a running binary directly.
|
||||
// We need to remove/rename the old file first, then copy the new one.
|
||||
let old_path = self.config.binary_path.with_extension("old");
|
||||
|
||||
// Rename current binary (works even while running)
|
||||
if self.config.binary_path.exists() {
|
||||
info!("Renaming current binary to {:?}", old_path);
|
||||
fs::rename(&self.config.binary_path, &old_path).await
|
||||
.with_context(|| format!(
|
||||
"Failed to rename {:?} to {:?}",
|
||||
self.config.binary_path, old_path
|
||||
))?;
|
||||
}
|
||||
|
||||
// Copy new binary to destination
|
||||
fs::copy(new_binary, &self.config.binary_path).await
|
||||
.with_context(|| format!(
|
||||
"Failed to copy {:?} to {:?}",
|
||||
new_binary, self.config.binary_path
|
||||
))?;
|
||||
|
||||
info!("Binary copied successfully, setting executable permissions");
|
||||
|
||||
// Make executable
|
||||
let chmod_status = tokio::process::Command::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(&self.config.binary_path)
|
||||
.status()
|
||||
.await
|
||||
.context("Failed to run chmod")?;
|
||||
|
||||
if !chmod_status.success() {
|
||||
warn!("chmod returned non-zero exit code: {:?}", chmod_status.code());
|
||||
}
|
||||
|
||||
// Clean up old binary
|
||||
fs::remove_file(&old_path).await.ok();
|
||||
info!("Old binary cleaned up");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// On Windows, rename the current binary first
|
||||
let old_path = self.config.binary_path.with_extension("old");
|
||||
fs::rename(&self.config.binary_path, &old_path).await.ok();
|
||||
fs::copy(new_binary, &self.config.binary_path).await
|
||||
.context("Failed to copy new binary")?;
|
||||
fs::remove_file(&old_path).await.ok();
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
fs::remove_file(new_binary).await.ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restart the agent service
|
||||
async fn restart_service(&self) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Try systemctl first
|
||||
let status = tokio::process::Command::new("systemctl")
|
||||
.args(["restart", "gururmm-agent"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
if status.is_err() || !status.unwrap().success() {
|
||||
// Fallback: exec the new binary directly
|
||||
warn!("systemctl restart failed, attempting direct restart");
|
||||
std::process::Command::new(&self.config.binary_path)
|
||||
.spawn()
|
||||
.context("Failed to spawn new agent")?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Restart Windows service
|
||||
tokio::process::Command::new("sc.exe")
|
||||
.args(["stop", "gururmm-agent"])
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
tokio::process::Command::new("sc.exe")
|
||||
.args(["start", "gururmm-agent"])
|
||||
.status()
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Give the new process a moment to start
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
// Exit this process - the new version should be running now
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
/// Cancel the rollback watchdog (called when update is confirmed successful)
|
||||
pub async fn cancel_rollback_watchdog(&self) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Kill the watchdog script
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", "gururmm-rollback.sh"])
|
||||
.status()
|
||||
.await;
|
||||
let _ = fs::remove_file("/tmp/gururmm-rollback.sh").await;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Delete the scheduled task
|
||||
let _ = tokio::process::Command::new("schtasks")
|
||||
.args(["/Delete", "/TN", "GuruRMM-Rollback", "/F"])
|
||||
.status()
|
||||
.await;
|
||||
let script_path = std::env::temp_dir().join("gururmm-rollback.ps1");
|
||||
let _ = fs::remove_file(script_path).await;
|
||||
}
|
||||
|
||||
info!("Rollback watchdog cancelled");
|
||||
}
|
||||
|
||||
/// Clean up backup files after successful update confirmation
|
||||
pub async fn cleanup_backup(&self) {
|
||||
let _ = fs::remove_file(self.config.backup_path()).await;
|
||||
info!("Backup file cleaned up");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user