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:
35
server/migrations/003_auto_update.sql
Normal file
35
server/migrations/003_auto_update.sql
Normal 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);
|
||||
@@ -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
375
server/src/api/releases.rs
Normal 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(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
179
server/src/db/releases.rs
Normal 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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user