Implement robust auto-update system for GuruConnect agent

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

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

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

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

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

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

View File

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