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(),
}),
))
}
}