From 4e5328fe4ad75730f95815747a17a2eb26024b02 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Tue, 30 Dec 2025 09:31:23 -0700 Subject: [PATCH] Implement robust auto-update system for GuruConnect agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 294 +++++++++++++++++++- agent/Cargo.toml | 4 + agent/src/main.rs | 11 + agent/src/session/mod.rs | 48 +++- agent/src/update.rs | 318 ++++++++++++++++++++++ proto/guruconnect.proto | 38 ++- server/migrations/003_auto_update.sql | 35 +++ server/src/api/mod.rs | 3 + server/src/api/releases.rs | 375 ++++++++++++++++++++++++++ server/src/db/mod.rs | 2 + server/src/db/releases.rs | 179 ++++++++++++ server/src/main.rs | 65 +++++ server/src/relay/mod.rs | 16 +- server/src/session/mod.rs | 7 + server/static/dashboard.html | 22 ++ 15 files changed, 1399 insertions(+), 18 deletions(-) create mode 100644 agent/src/update.rs create mode 100644 server/migrations/003_auto_update.sql create mode 100644 server/src/api/releases.rs create mode 100644 server/src/db/releases.rs diff --git a/Cargo.lock b/Cargo.lock index f127ccb..1db9318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1228,9 +1228,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1391,9 +1393,11 @@ dependencies = [ "prost-build", "prost-types", "raw-window-handle", + "reqwest", "ring", "serde", "serde_json", + "sha2", "softbuffer", "thiserror 1.0.69", "tokio", @@ -1426,7 +1430,7 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand", + "rand 0.8.5", "ring", "serde", "serde_json", @@ -1599,6 +1603,24 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1607,14 +1629,22 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1766,6 +1796,22 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1999,6 +2045,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -2198,7 +2250,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -2685,7 +2737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2989,6 +3041,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -3011,8 +3118,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -3022,7 +3139,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -3034,6 +3161,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3107,6 +3243,47 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -3134,13 +3311,19 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3160,7 +3343,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3176,6 +3359,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3388,7 +3606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -3636,7 +3854,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -3676,7 +3894,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -3776,6 +3994,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -4001,6 +4222,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -4155,12 +4386,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -4261,6 +4494,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4280,7 +4519,7 @@ dependencies = [ "httparse", "log", "native-tls", - "rand", + "rand 0.8.5", "sha1", "thiserror 1.0.69", "utf-8", @@ -4419,6 +4658,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4498,6 +4746,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wayland-backend" version = "0.3.11" @@ -4627,6 +4888,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 84de228..c5ae8da 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -46,6 +46,10 @@ toml = "0.8" # Crypto ring = "0.17" +sha2 = "0.10" + +# HTTP client for updates +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } # UUID uuid = { version = "1", features = ["v4", "serde"] } diff --git a/agent/src/main.rs b/agent/src/main.rs index 92a7e75..7355c1b 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -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) diff --git a/agent/src/session/mod.rs b/agent/src/session/mod.rs index ad6e5a8..e92ab18 100644 --- a/agent/src/session/mod.rs +++ b/agent/src/session/mod.rs @@ -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); diff --git a/agent/src/update.rs b/agent/src/update.rs new file mode 100644 index 0000000..ea9785c --- /dev/null +++ b/agent/src/update.rs @@ -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, +} + +/// 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> { + 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 { + 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 { + 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 { + 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 { + 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 = 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 = 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")); + } +} diff --git a/proto/guruconnect.proto b/proto/guruconnect.proto index 5abe915..1079b4f 100644 --- a/proto/guruconnect.proto +++ b/proto/guruconnect.proto @@ -276,6 +276,7 @@ message AgentStatus { int64 uptime_secs = 4; int32 display_count = 5; bool is_streaming = 6; + string agent_version = 7; // Agent version (e.g., "0.1.0-abc123") } // Server commands agent to uninstall itself @@ -287,7 +288,38 @@ message AdminCommand { enum AdminCommandType { ADMIN_UNINSTALL = 0; // Uninstall agent and remove from startup ADMIN_RESTART = 1; // Restart the agent process - ADMIN_UPDATE = 2; // Download and install update (future) + ADMIN_UPDATE = 2; // Download and install update +} + +// ============================================================================ +// Auto-Update Messages +// ============================================================================ + +// Update command details (sent with AdminCommand or standalone) +message UpdateInfo { + string version = 1; // Target version (e.g., "0.2.0") + string download_url = 2; // HTTPS URL to download new binary + string checksum_sha256 = 3; // SHA-256 hash for verification + bool mandatory = 4; // If true, agent must update immediately +} + +// Update status report (agent -> server) +message UpdateStatus { + string current_version = 1; // Current running version + UpdateState state = 2; // Current update state + string error_message = 3; // Error details if state is FAILED + int32 progress_percent = 4; // Download progress (0-100) +} + +enum UpdateState { + UPDATE_IDLE = 0; // No update in progress + UPDATE_CHECKING = 1; // Checking for updates + UPDATE_DOWNLOADING = 2; // Downloading new binary + UPDATE_VERIFYING = 3; // Verifying checksum + UPDATE_INSTALLING = 4; // Installing (rename/copy) + UPDATE_RESTARTING = 5; // About to restart + UPDATE_COMPLETE = 6; // Update successful (after restart) + UPDATE_FAILED = 7; // Update failed } // ============================================================================ @@ -335,5 +367,9 @@ message Message { // Admin commands (server -> agent) AdminCommand admin_command = 70; + + // Auto-update messages + UpdateInfo update_info = 75; // Server -> Agent: update available + UpdateStatus update_status = 76; // Agent -> Server: update progress } } diff --git a/server/migrations/003_auto_update.sql b/server/migrations/003_auto_update.sql new file mode 100644 index 0000000..0ae5287 --- /dev/null +++ b/server/migrations/003_auto_update.sql @@ -0,0 +1,35 @@ +-- Migration: 003_auto_update.sql +-- Purpose: Add auto-update infrastructure (releases table and machine version tracking) + +-- ============================================================================ +-- Releases Table +-- ============================================================================ + +-- Track available agent releases +CREATE TABLE IF NOT EXISTS releases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version VARCHAR(32) NOT NULL UNIQUE, + download_url TEXT NOT NULL, + checksum_sha256 VARCHAR(64) NOT NULL, + release_notes TEXT, + is_stable BOOLEAN NOT NULL DEFAULT false, + is_mandatory BOOLEAN NOT NULL DEFAULT false, + min_version VARCHAR(32), -- Minimum version that can update to this + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for finding latest stable release +CREATE INDEX IF NOT EXISTS idx_releases_stable ON releases(is_stable, created_at DESC); + +-- ============================================================================ +-- Machine Version Tracking +-- ============================================================================ + +-- Add version tracking columns to existing machines table +ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS agent_version VARCHAR(32); +ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS update_status VARCHAR(32); +ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS last_update_check TIMESTAMPTZ; + +-- Index for finding machines needing updates +CREATE INDEX IF NOT EXISTS idx_machines_version ON connect_machines(agent_version); +CREATE INDEX IF NOT EXISTS idx_machines_update_status ON connect_machines(update_status); diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index acb058c..00b297b 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod users; +pub mod releases; use axum::{ extract::{Path, State, Query}, @@ -48,6 +49,7 @@ pub struct SessionInfo { pub is_elevated: bool, pub uptime_secs: i64, pub display_count: i32, + pub agent_version: Option, } impl From for SessionInfo { @@ -67,6 +69,7 @@ impl From for SessionInfo { is_elevated: s.is_elevated, uptime_secs: s.uptime_secs, display_count: s.display_count, + agent_version: s.agent_version, } } } diff --git a/server/src/api/releases.rs b/server/src/api/releases.rs new file mode 100644 index 0000000..2e2b9ba --- /dev/null +++ b/server/src/api/releases.rs @@ -0,0 +1,375 @@ +//! Release management API endpoints (admin only) + +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::auth::AdminUser; +use crate::db; +use crate::AppState; + +use super::auth::ErrorResponse; + +/// Release info response +#[derive(Debug, Serialize)] +pub struct ReleaseInfo { + pub id: String, + pub version: String, + pub download_url: String, + pub checksum_sha256: String, + pub release_notes: Option, + pub is_stable: bool, + pub is_mandatory: bool, + pub min_version: Option, + pub created_at: String, +} + +impl From for ReleaseInfo { + fn from(r: db::Release) -> Self { + Self { + id: r.id.to_string(), + version: r.version, + download_url: r.download_url, + checksum_sha256: r.checksum_sha256, + release_notes: r.release_notes, + is_stable: r.is_stable, + is_mandatory: r.is_mandatory, + min_version: r.min_version, + created_at: r.created_at.to_rfc3339(), + } + } +} + +/// Version info for unauthenticated endpoint +#[derive(Debug, Serialize)] +pub struct VersionInfo { + pub latest_version: String, + pub download_url: String, + pub checksum_sha256: String, + pub is_mandatory: bool, + pub release_notes: Option, +} + +/// Create release request +#[derive(Debug, Deserialize)] +pub struct CreateReleaseRequest { + pub version: String, + pub download_url: String, + pub checksum_sha256: String, + pub release_notes: Option, + pub is_stable: bool, + pub is_mandatory: bool, + pub min_version: Option, +} + +/// Update release request +#[derive(Debug, Deserialize)] +pub struct UpdateReleaseRequest { + pub release_notes: Option, + pub is_stable: bool, + pub is_mandatory: bool, +} + +/// GET /api/version - Get latest version info (no auth required) +pub async fn get_version( + State(state): State, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let release = db::get_latest_stable_release(db.pool()) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to fetch version".to_string(), + }), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "No stable release available".to_string(), + }), + ) + })?; + + Ok(Json(VersionInfo { + latest_version: release.version, + download_url: release.download_url, + checksum_sha256: release.checksum_sha256, + is_mandatory: release.is_mandatory, + release_notes: release.release_notes, + })) +} + +/// GET /api/releases - List all releases (admin only) +pub async fn list_releases( + State(state): State, + _admin: AdminUser, +) -> Result>, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let releases = db::get_all_releases(db.pool()) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to fetch releases".to_string(), + }), + ) + })?; + + Ok(Json(releases.into_iter().map(ReleaseInfo::from).collect())) +} + +/// POST /api/releases - Create new release (admin only) +pub async fn create_release( + State(state): State, + _admin: AdminUser, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + // Validate version format (basic check) + if request.version.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Version cannot be empty".to_string(), + }), + )); + } + + // Validate checksum format (64 hex chars for SHA-256) + if request.checksum_sha256.len() != 64 + || !request.checksum_sha256.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid SHA-256 checksum format (expected 64 hex characters)".to_string(), + }), + )); + } + + // Validate URL + if !request.download_url.starts_with("https://") { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Download URL must use HTTPS".to_string(), + }), + )); + } + + // Check if version already exists + if db::get_release_by_version(db.pool(), &request.version) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Database error".to_string(), + }), + ) + })? + .is_some() + { + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: "Version already exists".to_string(), + }), + )); + } + + let release = db::create_release( + db.pool(), + &request.version, + &request.download_url, + &request.checksum_sha256, + request.release_notes.as_deref(), + request.is_stable, + request.is_mandatory, + request.min_version.as_deref(), + ) + .await + .map_err(|e| { + tracing::error!("Failed to create release: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to create release".to_string(), + }), + ) + })?; + + tracing::info!( + "Created release: {} (stable={}, mandatory={})", + release.version, + release.is_stable, + release.is_mandatory + ); + + Ok(Json(ReleaseInfo::from(release))) +} + +/// GET /api/releases/:version - Get release by version (admin only) +pub async fn get_release( + State(state): State, + _admin: AdminUser, + Path(version): Path, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let release = db::get_release_by_version(db.pool(), &version) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Database error".to_string(), + }), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Release not found".to_string(), + }), + ) + })?; + + Ok(Json(ReleaseInfo::from(release))) +} + +/// PUT /api/releases/:version - Update release (admin only) +pub async fn update_release( + State(state): State, + _admin: AdminUser, + Path(version): Path, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let release = db::update_release( + db.pool(), + &version, + request.release_notes.as_deref(), + request.is_stable, + request.is_mandatory, + ) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to update release".to_string(), + }), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Release not found".to_string(), + }), + ) + })?; + + tracing::info!( + "Updated release: {} (stable={}, mandatory={})", + release.version, + release.is_stable, + release.is_mandatory + ); + + Ok(Json(ReleaseInfo::from(release))) +} + +/// DELETE /api/releases/:version - Delete release (admin only) +pub async fn delete_release( + State(state): State, + _admin: AdminUser, + Path(version): Path, +) -> Result)> { + let db = state.db.as_ref().ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + error: "Database not available".to_string(), + }), + ) + })?; + + let deleted = db::delete_release(db.pool(), &version) + .await + .map_err(|e| { + tracing::error!("Database error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Failed to delete release".to_string(), + }), + ) + })?; + + if deleted { + tracing::info!("Deleted release: {}", version); + Ok(StatusCode::NO_CONTENT) + } else { + Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Release not found".to_string(), + }), + )) + } +} diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index a12579c..3fa9559 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -8,6 +8,7 @@ pub mod sessions; pub mod events; pub mod support_codes; pub mod users; +pub mod releases; use anyhow::Result; use sqlx::postgres::PgPoolOptions; @@ -19,6 +20,7 @@ pub use sessions::*; pub use events::*; pub use support_codes::*; pub use users::*; +pub use releases::*; /// Database connection pool wrapper #[derive(Clone)] diff --git a/server/src/db/releases.rs b/server/src/db/releases.rs new file mode 100644 index 0000000..7283aa2 --- /dev/null +++ b/server/src/db/releases.rs @@ -0,0 +1,179 @@ +//! Release management database operations + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +/// Release record from database +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Release { + pub id: Uuid, + pub version: String, + pub download_url: String, + pub checksum_sha256: String, + pub release_notes: Option, + pub is_stable: bool, + pub is_mandatory: bool, + pub min_version: Option, + pub created_at: DateTime, +} + +/// Create a new release +pub async fn create_release( + pool: &PgPool, + version: &str, + download_url: &str, + checksum_sha256: &str, + release_notes: Option<&str>, + is_stable: bool, + is_mandatory: bool, + min_version: Option<&str>, +) -> Result { + sqlx::query_as::<_, Release>( + r#" + INSERT INTO releases (version, download_url, checksum_sha256, release_notes, is_stable, is_mandatory, min_version) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + "#, + ) + .bind(version) + .bind(download_url) + .bind(checksum_sha256) + .bind(release_notes) + .bind(is_stable) + .bind(is_mandatory) + .bind(min_version) + .fetch_one(pool) + .await +} + +/// Get the latest stable release +pub async fn get_latest_stable_release(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, Release>( + r#" + SELECT * FROM releases + WHERE is_stable = true + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .fetch_optional(pool) + .await +} + +/// Get a release by version +pub async fn get_release_by_version( + pool: &PgPool, + version: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Release>("SELECT * FROM releases WHERE version = $1") + .bind(version) + .fetch_optional(pool) + .await +} + +/// Get all releases (ordered by creation date, newest first) +pub async fn get_all_releases(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, Release>("SELECT * FROM releases ORDER BY created_at DESC") + .fetch_all(pool) + .await +} + +/// Update a release +pub async fn update_release( + pool: &PgPool, + version: &str, + release_notes: Option<&str>, + is_stable: bool, + is_mandatory: bool, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Release>( + r#" + UPDATE releases SET + release_notes = COALESCE($2, release_notes), + is_stable = $3, + is_mandatory = $4 + WHERE version = $1 + RETURNING * + "#, + ) + .bind(version) + .bind(release_notes) + .bind(is_stable) + .bind(is_mandatory) + .fetch_optional(pool) + .await +} + +/// Delete a release +pub async fn delete_release(pool: &PgPool, version: &str) -> Result { + let result = sqlx::query("DELETE FROM releases WHERE version = $1") + .bind(version) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Update machine version info +pub async fn update_machine_version( + pool: &PgPool, + agent_id: &str, + agent_version: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE connect_machines SET + agent_version = $1, + last_update_check = NOW() + WHERE agent_id = $2 + "#, + ) + .bind(agent_version) + .bind(agent_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Update machine update status +pub async fn update_machine_update_status( + pool: &PgPool, + agent_id: &str, + update_status: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE connect_machines SET + update_status = $1 + WHERE agent_id = $2 + "#, + ) + .bind(update_status) + .bind(agent_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Get machines that need updates (version < latest stable) +pub async fn get_machines_needing_update( + pool: &PgPool, + latest_version: &str, +) -> Result, sqlx::Error> { + // Note: This does simple string comparison which works for semver if formatted consistently + // For production, you might want a more robust version comparison + let rows: Vec<(String,)> = sqlx::query_as( + r#" + SELECT agent_id FROM connect_machines + WHERE status = 'online' + AND is_persistent = true + AND (agent_version IS NULL OR agent_version < $1) + "#, + ) + .bind(latest_version) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|(id,)| id).collect()) +} diff --git a/server/src/main.rs b/server/src/main.rs index 8a5b5c0..5cc3dda 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -223,6 +223,15 @@ async fn main() -> Result<()> { .route("/api/machines/:agent_id", get(get_machine)) .route("/api/machines/:agent_id", delete(delete_machine)) .route("/api/machines/:agent_id/history", get(get_machine_history)) + .route("/api/machines/:agent_id/update", post(trigger_machine_update)) + + // REST API - Releases and Version + .route("/api/version", get(api::releases::get_version)) // No auth - for agent polling + .route("/api/releases", get(api::releases::list_releases)) + .route("/api/releases", post(api::releases::create_release)) + .route("/api/releases/:version", get(api::releases::get_release)) + .route("/api/releases/:version", put(api::releases::update_release)) + .route("/api/releases/:version", delete(api::releases::delete_release)) // HTML page routes (clean URLs) .route("/login", get(serve_login)) @@ -472,6 +481,62 @@ async fn delete_machine( })) } +// Update trigger request +#[derive(Deserialize)] +struct TriggerUpdateRequest { + /// Target version (optional, defaults to latest stable) + version: Option, +} + +/// Trigger update on a specific machine +async fn trigger_machine_update( + _user: AuthenticatedUser, // Require authentication + State(state): State, + Path(agent_id): Path, + Json(request): Json, +) -> Result { + let db = state.db.as_ref() + .ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?; + + // Get the target release (either specified or latest stable) + let release = if let Some(version) = request.version { + db::releases::get_release_by_version(db.pool(), &version).await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? + .ok_or((StatusCode::NOT_FOUND, "Release version not found"))? + } else { + db::releases::get_latest_stable_release(db.pool()).await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? + .ok_or((StatusCode::NOT_FOUND, "No stable release available"))? + }; + + // Find session for this agent + let session = state.sessions.get_session_by_agent(&agent_id).await + .ok_or((StatusCode::NOT_FOUND, "Agent not found or offline"))?; + + if !session.is_online { + return Err((StatusCode::BAD_REQUEST, "Agent is offline")); + } + + // Send update command via WebSocket + // For now, we send admin command - later we'll include UpdateInfo in the message + let sent = state.sessions.send_admin_command( + session.id, + proto::AdminCommandType::AdminUpdate, + &format!("Update to version {}", release.version), + ).await; + + if sent { + info!("Sent update command to agent {} (version {})", agent_id, release.version); + + // Update machine update status in database + let _ = db::releases::update_machine_update_status(db.pool(), &agent_id, "downloading").await; + + Ok((StatusCode::OK, "Update command sent")) + } else { + Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to send update command")) + } +} + // Static page handlers async fn serve_login() -> impl IntoResponse { match tokio::fs::read_to_string("static/login.html").await { diff --git a/server/src/relay/mod.rs b/server/src/relay/mod.rs index 915d6ea..58099a6 100644 --- a/server/src/relay/mod.rs +++ b/server/src/relay/mod.rs @@ -308,6 +308,11 @@ async fn handle_agent_connection( } Some(proto::message::Payload::AgentStatus(status)) => { // Update session with agent status + let agent_version = if status.agent_version.is_empty() { + None + } else { + Some(status.agent_version.clone()) + }; sessions_status.update_agent_status( session_id, Some(status.os_version.clone()), @@ -315,9 +320,16 @@ async fn handle_agent_connection( status.uptime_secs, status.display_count, status.is_streaming, + agent_version.clone(), ).await; - info!("Agent status update: {} - streaming={}, uptime={}s", - status.hostname, status.is_streaming, status.uptime_secs); + + // Update version in database if present + if let (Some(ref db), Some(ref version)) = (&db, &agent_version) { + let _ = crate::db::releases::update_machine_version(db.pool(), &agent_id, version).await; + } + + info!("Agent status update: {} - streaming={}, uptime={}s, version={:?}", + status.hostname, status.is_streaming, status.uptime_secs, agent_version); } Some(proto::message::Payload::Heartbeat(_)) => { // Update heartbeat timestamp diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index e6a8160..9d88c67 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -47,6 +47,7 @@ pub struct Session { pub is_elevated: bool, pub uptime_secs: i64, pub display_count: i32, + pub agent_version: Option, // Agent software version } /// Channel for sending frames from agent to viewers @@ -138,6 +139,7 @@ impl SessionManager { is_elevated: false, uptime_secs: 0, display_count: 1, + agent_version: None, }; let session_data = SessionData { @@ -167,6 +169,7 @@ impl SessionManager { uptime_secs: i64, display_count: i32, is_streaming: bool, + agent_version: Option, ) { let mut sessions = self.sessions.write().await; if let Some(session_data) = sessions.get_mut(&session_id) { @@ -179,6 +182,9 @@ impl SessionManager { session_data.info.is_elevated = is_elevated; session_data.info.uptime_secs = uptime_secs; session_data.info.display_count = display_count; + if let Some(version) = agent_version { + session_data.info.agent_version = Some(version); + } } } @@ -454,6 +460,7 @@ impl SessionManager { is_elevated: false, uptime_secs: 0, display_count: 1, + agent_version: None, }; // Create placeholder channels (will be replaced on reconnect) diff --git a/server/static/dashboard.html b/server/static/dashboard.html index 710eccd..ec706f6 100644 --- a/server/static/dashboard.html +++ b/server/static/dashboard.html @@ -908,6 +908,7 @@ const statusText = m.is_online ? 'Online' : 'Offline'; const connectDisabled = m.is_online ? '' : 'disabled'; const connectTitle = m.is_online ? '' : 'title="Agent is offline"'; + const versionText = m.agent_version || 'Unknown'; container.innerHTML = '
' + @@ -915,6 +916,7 @@ '
Status' + statusText + '
' + '
Agent ID' + m.agent_id.slice(0,8) + '...
' + '
Session ID' + m.id.slice(0,8) + '...
' + + '
Version' + escapeHtml(versionText) + '
' + '
Connected' + started + '
' + '
Viewers' + m.viewer_count + '
' + '
' + @@ -923,6 +925,7 @@ '' + '' + '' + + '' + '' + ''; } @@ -1043,6 +1046,25 @@ } } + async function triggerUpdate(agentId, machineName) { + if (!confirm("Send update command to " + machineName + "?\n\nThe agent will download and install the latest version, then restart.")) return; + try { + const response = await fetch("/api/machines/" + agentId + "/update", { + method: "POST", + headers: { "Content-Type": "application/json" } + }); + if (response.ok) { + const result = await response.json(); + alert("Update command sent to " + machineName + ".\n\n" + (result.message || "Agent will update shortly.")); + } else { + const errorText = await response.text(); + alert("Failed to trigger update: " + errorText); + } + } catch (err) { + alert("Error triggering update: " + err.message); + } + } + // Refresh machines every 5 seconds loadMachines(); setInterval(loadMachines, 5000);