Implement robust auto-update system for GuruConnect agent

Features:
- Agent checks for updates periodically (hourly) during idle
- Admin can trigger immediate updates via dashboard "Update Agent" button
- Silent updates with in-place binary replacement (no reboot required)
- SHA-256 checksum verification before installation
- Semantic version comparison

Server changes:
- New releases table for tracking available versions
- GET /api/version endpoint for agent polling (unauthenticated)
- POST /api/machines/:id/update endpoint for admin push updates
- Release management API (/api/releases CRUD)
- Track agent_version in machine status

Agent changes:
- New update.rs module with download/verify/install/restart logic
- Handle ADMIN_UPDATE WebSocket command for push updates
- --post-update flag for cleanup after successful update
- Periodic update check in idle loop (persistent agents only)
- agent_version included in AgentStatus messages

Dashboard changes:
- Version display in machine detail panel
- "Update Agent" button for each connected machine

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 09:31:23 -07:00
parent 7df824c2ca
commit 4e5328fe4a
15 changed files with 1399 additions and 18 deletions

View File

@@ -23,6 +23,7 @@ mod session;
mod startup;
mod transport;
mod tray;
mod update;
mod viewer;
pub mod proto {
@@ -118,6 +119,10 @@ struct Cli {
/// Enable verbose logging
#[arg(short, long, global = true)]
verbose: bool,
/// Internal flag: set after auto-update to trigger cleanup
#[arg(long, hide = true)]
post_update: bool,
}
#[derive(Subcommand)]
@@ -182,6 +187,12 @@ fn main() -> Result<()> {
info!("GuruConnect {} ({})", build_info::short_version(), build_info::BUILD_TARGET);
info!("Built: {} | Commit: {}", build_info::BUILD_TIMESTAMP, build_info::GIT_COMMIT_DATE);
// Handle post-update cleanup
if cli.post_update {
info!("Post-update mode: cleaning up old executable");
update::cleanup_post_update();
}
match cli.command {
Some(Commands::Agent { code }) => {
run_agent_mode(code)

View File

@@ -47,6 +47,8 @@ use std::time::{Duration, Instant};
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
// Status report interval (60 seconds)
const STATUS_INTERVAL: Duration = Duration::from_secs(60);
// Update check interval (1 hour)
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(3600);
/// Session manager handles the remote control session
pub struct SessionManager {
@@ -172,6 +174,7 @@ impl SessionManager {
uptime_secs: self.start_time.elapsed().as_secs() as i64,
display_count: self.get_display_count(),
is_streaming: self.state == SessionState::Streaming,
agent_version: crate::build_info::short_version(),
};
let msg = Message {
@@ -215,6 +218,7 @@ impl SessionManager {
let mut last_heartbeat = Instant::now();
let mut last_status = Instant::now();
let mut last_frame_time = Instant::now();
let mut last_update_check = Instant::now();
let frame_interval = Duration::from_millis(1000 / self.config.capture.fps as u64);
// Main loop
@@ -309,7 +313,7 @@ impl SessionManager {
}
// Handle other messages (input events, disconnect, etc.)
self.handle_message(msg)?;
self.handle_message(msg).await?;
}
// Check for outgoing chat messages
@@ -347,6 +351,26 @@ impl SessionManager {
}
}
// Periodic update check (only for persistent agents, not support sessions)
if self.config.support_code.is_none() && last_update_check.elapsed() >= UPDATE_CHECK_INTERVAL {
last_update_check = Instant::now();
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
match crate::update::check_for_update(&server_url).await {
Ok(Some(version_info)) => {
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
if let Err(e) = crate::update::perform_update(&version_info).await {
tracing::error!("Auto-update failed: {}", e);
}
}
Ok(None) => {
tracing::debug!("No update available");
}
Err(e) => {
tracing::debug!("Update check failed: {}", e);
}
}
}
// Longer sleep in idle mode to reduce CPU usage
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -401,7 +425,7 @@ impl SessionManager {
}
/// Handle incoming message from server
fn handle_message(&mut self, msg: Message) -> Result<()> {
async fn handle_message(&mut self, msg: Message) -> Result<()> {
match msg.payload {
Some(message::Payload::MouseEvent(mouse)) => {
if let Some(input) = self.input.as_mut() {
@@ -468,7 +492,25 @@ impl SessionManager {
return Err(anyhow::anyhow!("ADMIN_RESTART: {}", cmd.reason));
}
Some(AdminCommandType::AdminUpdate) => {
tracing::info!("Update command received (not implemented)");
tracing::info!("Update command received from server: {}", cmd.reason);
// Trigger update check and perform update if available
// The server URL is derived from the config
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
match crate::update::check_for_update(&server_url).await {
Ok(Some(version_info)) => {
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
if let Err(e) = crate::update::perform_update(&version_info).await {
tracing::error!("Update failed: {}", e);
}
// If we get here, the update failed (perform_update exits on success)
}
Ok(None) => {
tracing::info!("Already running latest version");
}
Err(e) => {
tracing::error!("Failed to check for updates: {}", e);
}
}
}
None => {
tracing::warn!("Unknown admin command: {}", cmd.command);

318
agent/src/update.rs Normal file
View 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(&current_exe, &backup_path)?;
// Copy new executable to original location
info!("Copying new exe to: {:?}", current_exe);
std::fs::copy(temp_path, &current_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"));
}
}