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

294
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"] }

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

View File

@@ -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
}
}

View File

@@ -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);

View File

@@ -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<String>,
}
impl From<crate::session::Session> for SessionInfo {
@@ -67,6 +69,7 @@ impl From<crate::session::Session> for SessionInfo {
is_elevated: s.is_elevated,
uptime_secs: s.uptime_secs,
display_count: s.display_count,
agent_version: s.agent_version,
}
}
}

375
server/src/api/releases.rs Normal file
View File

@@ -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<String>,
pub is_stable: bool,
pub is_mandatory: bool,
pub min_version: Option<String>,
pub created_at: String,
}
impl From<db::Release> 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<String>,
}
/// Create release request
#[derive(Debug, Deserialize)]
pub struct CreateReleaseRequest {
pub version: String,
pub download_url: String,
pub checksum_sha256: String,
pub release_notes: Option<String>,
pub is_stable: bool,
pub is_mandatory: bool,
pub min_version: Option<String>,
}
/// Update release request
#[derive(Debug, Deserialize)]
pub struct UpdateReleaseRequest {
pub release_notes: Option<String>,
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<AppState>,
) -> Result<Json<VersionInfo>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
) -> Result<Json<Vec<ReleaseInfo>>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
Json(request): Json<CreateReleaseRequest>,
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
Path(version): Path<String>,
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
Path(version): Path<String>,
Json(request): Json<UpdateReleaseRequest>,
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
_admin: AdminUser,
Path(version): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
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(),
}),
))
}
}

View File

@@ -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)]

179
server/src/db/releases.rs Normal file
View File

@@ -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<String>,
pub is_stable: bool,
pub is_mandatory: bool,
pub min_version: Option<String>,
pub created_at: DateTime<Utc>,
}
/// 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<Release, sqlx::Error> {
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<Option<Release>, 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<Option<Release>, 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<Vec<Release>, 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<Option<Release>, 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<bool, sqlx::Error> {
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<Vec<String>, 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())
}

View File

@@ -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<String>,
}
/// Trigger update on a specific machine
async fn trigger_machine_update(
_user: AuthenticatedUser, // Require authentication
State(state): State<AppState>,
Path(agent_id): Path<String>,
Json(request): Json<TriggerUpdateRequest>,
) -> Result<impl IntoResponse, (StatusCode, &'static str)> {
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 {

View File

@@ -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

View File

@@ -47,6 +47,7 @@ pub struct Session {
pub is_elevated: bool,
pub uptime_secs: i64,
pub display_count: i32,
pub agent_version: Option<String>, // 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<String>,
) {
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)

View File

@@ -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 =
'<div class="detail-section">' +
@@ -915,6 +916,7 @@
'<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value" style="color: ' + statusColor + ';">' + statusText + '</span></div>' +
'<div class="detail-row"><span class="detail-label">Agent ID</span><span class="detail-value">' + m.agent_id.slice(0,8) + '...</span></div>' +
'<div class="detail-row"><span class="detail-label">Session ID</span><span class="detail-value">' + m.id.slice(0,8) + '...</span></div>' +
'<div class="detail-row"><span class="detail-label">Version</span><span class="detail-value">' + escapeHtml(versionText) + '</span></div>' +
'<div class="detail-row"><span class="detail-label">Connected</span><span class="detail-value">' + started + '</span></div>' +
'<div class="detail-row"><span class="detail-label">Viewers</span><span class="detail-value">' + m.viewer_count + '</span></div>' +
'</div>' +
@@ -923,6 +925,7 @@
'<button class="btn btn-primary" style="width: 100%; margin-bottom: 8px;" onclick="connectToMachine(\'' + m.id + '\')" ' + connectDisabled + ' ' + connectTitle + '>Connect</button>' +
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="openChat(\'' + m.id + '\', \'' + (m.agent_name || 'Client').replace(/'/g, "\\'") + '\')" ' + connectDisabled + '>Chat</button>' +
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" disabled>Transfer Files</button>' +
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="triggerUpdate(\'' + m.agent_id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')" ' + connectDisabled + '>Update Agent</button>' +
'<button class="btn btn-outline" style="width: 100%; color: hsl(0, 62.8%, 50%);" onclick="disconnectMachine(\'' + m.id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')">Disconnect</button>' +
'</div>';
}
@@ -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);