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,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");
}
}