Add machine deletion API with uninstall command support

- Add AdminCommand message to protobuf (uninstall, restart, update)
- Add DELETE /api/machines/:agent_id endpoint with options:
  - ?uninstall=true - send uninstall command to online agent
  - ?export=true - return session history before deletion
- Add GET /api/machines/:agent_id/history endpoint for history export
- Add GET /api/machines endpoint to list all machines
- Handle AdminCommand in agent session handler
- Handle ADMIN_UNINSTALL error in agent main loop to trigger uninstall

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AZ Computer Guru
2025-12-29 19:15:16 -07:00
parent 05ab8a8bf4
commit dc7b7427ce
8 changed files with 380 additions and 6 deletions

View File

@@ -416,7 +416,21 @@ async fn run_agent(config: config::Config) -> Result<()> {
return Ok(()); return Ok(());
} }
error!("Session error: {}", e); if error_msg.contains("ADMIN_UNINSTALL") {
info!("Uninstall command received from server - uninstalling");
if let Err(e) = startup::uninstall() {
warn!("Uninstall failed: {}", e);
}
show_message_box("GuruConnect Removed", "This computer has been removed from remote management.");
return Ok(());
}
if error_msg.contains("ADMIN_RESTART") {
info!("Restart command received - will reconnect");
// Don't exit, just let the loop continue to reconnect
} else {
error!("Session error: {}", e);
}
} }
} }
Err(e) => { Err(e) => {

View File

@@ -452,6 +452,30 @@ impl SessionManager {
} }
} }
Some(message::Payload::AdminCommand(cmd)) => {
use crate::proto::AdminCommandType;
tracing::info!("Admin command received: {:?} - {}", cmd.command, cmd.reason);
match AdminCommandType::try_from(cmd.command).ok() {
Some(AdminCommandType::AdminUninstall) => {
tracing::warn!("Uninstall command received from server");
// Return special error to trigger uninstall in main loop
return Err(anyhow::anyhow!("ADMIN_UNINSTALL: {}", cmd.reason));
}
Some(AdminCommandType::AdminRestart) => {
tracing::info!("Restart command received from server");
// For now, just disconnect - the auto-restart logic will handle it
return Err(anyhow::anyhow!("ADMIN_RESTART: {}", cmd.reason));
}
Some(AdminCommandType::AdminUpdate) => {
tracing::info!("Update command received (not implemented)");
}
None => {
tracing::warn!("Unknown admin command: {}", cmd.command);
}
}
}
Some(message::Payload::Disconnect(disc)) => { Some(message::Payload::Disconnect(disc)) => {
tracing::info!("Disconnect requested: {}", disc.reason); tracing::info!("Disconnect requested: {}", disc.reason);
if disc.reason.contains("cancelled") { if disc.reason.contains("cancelled") {

View File

@@ -278,6 +278,18 @@ message AgentStatus {
bool is_streaming = 6; bool is_streaming = 6;
} }
// Server commands agent to uninstall itself
message AdminCommand {
AdminCommandType command = 1;
string reason = 2; // Why the command was issued
}
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)
}
// ============================================================================ // ============================================================================
// Top-Level Message Wrapper // Top-Level Message Wrapper
// ============================================================================ // ============================================================================
@@ -320,5 +332,8 @@ message Message {
// Chat // Chat
ChatMessage chat_message = 60; ChatMessage chat_message = 60;
// Admin commands (server -> agent)
AdminCommand admin_command = 70;
} }
} }

View File

@@ -1,13 +1,14 @@
//! REST API endpoints //! REST API endpoints
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State, Query},
Json, Json,
}; };
use serde::Serialize; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::session::SessionManager; use crate::session::SessionManager;
use crate::db;
/// Viewer info returned by API /// Viewer info returned by API
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -88,3 +89,120 @@ pub async fn get_session(
Ok(Json(SessionInfo::from(session))) Ok(Json(SessionInfo::from(session)))
} }
// ============================================================================
// Machine API Types
// ============================================================================
/// Machine info returned by API
#[derive(Debug, Serialize)]
pub struct MachineInfo {
pub id: String,
pub agent_id: String,
pub hostname: String,
pub os_version: Option<String>,
pub is_elevated: bool,
pub is_persistent: bool,
pub first_seen: String,
pub last_seen: String,
pub status: String,
}
impl From<db::machines::Machine> for MachineInfo {
fn from(m: db::machines::Machine) -> Self {
Self {
id: m.id.to_string(),
agent_id: m.agent_id,
hostname: m.hostname,
os_version: m.os_version,
is_elevated: m.is_elevated,
is_persistent: m.is_persistent,
first_seen: m.first_seen.to_rfc3339(),
last_seen: m.last_seen.to_rfc3339(),
status: m.status,
}
}
}
/// Session record for history
#[derive(Debug, Serialize)]
pub struct SessionRecord {
pub id: String,
pub started_at: String,
pub ended_at: Option<String>,
pub duration_secs: Option<i32>,
pub is_support_session: bool,
pub support_code: Option<String>,
pub status: String,
}
impl From<db::sessions::DbSession> for SessionRecord {
fn from(s: db::sessions::DbSession) -> Self {
Self {
id: s.id.to_string(),
started_at: s.started_at.to_rfc3339(),
ended_at: s.ended_at.map(|t| t.to_rfc3339()),
duration_secs: s.duration_secs,
is_support_session: s.is_support_session,
support_code: s.support_code,
status: s.status,
}
}
}
/// Event record for history
#[derive(Debug, Serialize)]
pub struct EventRecord {
pub id: i64,
pub session_id: String,
pub event_type: String,
pub timestamp: String,
pub viewer_id: Option<String>,
pub viewer_name: Option<String>,
pub details: Option<serde_json::Value>,
pub ip_address: Option<String>,
}
impl From<db::events::SessionEvent> for EventRecord {
fn from(e: db::events::SessionEvent) -> Self {
Self {
id: e.id,
session_id: e.session_id.to_string(),
event_type: e.event_type,
timestamp: e.timestamp.to_rfc3339(),
viewer_id: e.viewer_id,
viewer_name: e.viewer_name,
details: e.details,
ip_address: e.ip_address,
}
}
}
/// Full machine history (for export)
#[derive(Debug, Serialize)]
pub struct MachineHistory {
pub machine: MachineInfo,
pub sessions: Vec<SessionRecord>,
pub events: Vec<EventRecord>,
pub exported_at: String,
}
/// Query parameters for machine deletion
#[derive(Debug, Deserialize)]
pub struct DeleteMachineParams {
/// If true, send uninstall command to agent (if online)
#[serde(default)]
pub uninstall: bool,
/// If true, include history in response before deletion
#[serde(default)]
pub export: bool,
}
/// Response for machine deletion
#[derive(Debug, Serialize)]
pub struct DeleteMachineResponse {
pub success: bool,
pub message: String,
pub uninstall_sent: bool,
pub history: Option<MachineHistory>,
}

View File

@@ -105,3 +105,22 @@ pub async fn get_events_by_type(
.fetch_all(pool) .fetch_all(pool)
.await .await
} }
/// Get all events for a machine (by joining through sessions)
pub async fn get_events_for_machine(
pool: &PgPool,
machine_id: Uuid,
) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>(
r#"
SELECT e.id, e.session_id, e.event_type, e.timestamp, e.viewer_id, e.viewer_name, e.details, e.ip_address::text as ip_address
FROM connect_session_events e
JOIN connect_sessions s ON e.session_id = s.id
WHERE s.machine_id = $1
ORDER BY e.timestamp DESC
"#
)
.bind(machine_id)
.fetch_all(pool)
.await
}

View File

@@ -96,3 +96,16 @@ pub async fn get_recent_sessions(
.fetch_all(pool) .fetch_all(pool)
.await .await
} }
/// Get all sessions for a machine (for history export)
pub async fn get_sessions_for_machine(
pool: &PgPool,
machine_id: Uuid,
) -> Result<Vec<DbSession>, sqlx::Error> {
sqlx::query_as::<_, DbSession>(
"SELECT * FROM connect_sessions WHERE machine_id = $1 ORDER BY started_at DESC"
)
.bind(machine_id)
.fetch_all(pool)
.await
}

View File

@@ -18,8 +18,8 @@ pub mod proto {
use anyhow::Result; use anyhow::Result;
use axum::{ use axum::{
Router, Router,
routing::{get, post}, routing::{get, post, delete},
extract::{Path, State, Json}, extract::{Path, State, Json, Query},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
http::StatusCode, http::StatusCode,
}; };
@@ -122,7 +122,13 @@ async fn main() -> Result<()> {
// REST API - Sessions // REST API - Sessions
.route("/api/sessions", get(list_sessions)) .route("/api/sessions", get(list_sessions))
.route("/api/sessions/:id", get(get_session)) .route("/api/sessions/:id", get(get_session))
.route("/api/sessions/:id", axum::routing::delete(disconnect_session)) .route("/api/sessions/:id", delete(disconnect_session))
// REST API - Machines
.route("/api/machines", get(list_machines))
.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))
// HTML page routes (clean URLs) // HTML page routes (clean URLs)
.route("/login", get(serve_login)) .route("/login", get(serve_login))
@@ -237,6 +243,129 @@ async fn disconnect_session(
} }
} }
// Machine API handlers
async fn list_machines(
State(state): State<AppState>,
) -> Result<Json<Vec<api::MachineInfo>>, (StatusCode, &'static str)> {
let db = state.db.as_ref()
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
let machines = db::machines::get_all_machines(db.pool()).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
Ok(Json(machines.into_iter().map(api::MachineInfo::from).collect()))
}
async fn get_machine(
State(state): State<AppState>,
Path(agent_id): Path<String>,
) -> Result<Json<api::MachineInfo>, (StatusCode, &'static str)> {
let db = state.db.as_ref()
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
Ok(Json(api::MachineInfo::from(machine)))
}
async fn get_machine_history(
State(state): State<AppState>,
Path(agent_id): Path<String>,
) -> Result<Json<api::MachineHistory>, (StatusCode, &'static str)> {
let db = state.db.as_ref()
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
// Get machine
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
// Get sessions for this machine
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
// Get events for this machine
let events = db::events::get_events_for_machine(db.pool(), machine.id).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
let history = api::MachineHistory {
machine: api::MachineInfo::from(machine),
sessions: sessions.into_iter().map(api::SessionRecord::from).collect(),
events: events.into_iter().map(api::EventRecord::from).collect(),
exported_at: chrono::Utc::now().to_rfc3339(),
};
Ok(Json(history))
}
async fn delete_machine(
State(state): State<AppState>,
Path(agent_id): Path<String>,
Query(params): Query<api::DeleteMachineParams>,
) -> Result<Json<api::DeleteMachineResponse>, (StatusCode, &'static str)> {
let db = state.db.as_ref()
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
// Get machine first
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
// Export history if requested
let history = if params.export {
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
let events = db::events::get_events_for_machine(db.pool(), machine.id).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
Some(api::MachineHistory {
machine: api::MachineInfo::from(machine.clone()),
sessions: sessions.into_iter().map(api::SessionRecord::from).collect(),
events: events.into_iter().map(api::EventRecord::from).collect(),
exported_at: chrono::Utc::now().to_rfc3339(),
})
} else {
None
};
// Send uninstall command if requested and agent is online
let mut uninstall_sent = false;
if params.uninstall {
// Find session for this agent
if let Some(session) = state.sessions.get_session_by_agent(&agent_id).await {
if session.is_online {
uninstall_sent = state.sessions.send_admin_command(
session.id,
proto::AdminCommandType::AdminUninstall,
"Deleted by administrator",
).await;
if uninstall_sent {
info!("Sent uninstall command to agent {}", agent_id);
}
}
}
}
// Remove from session manager
state.sessions.remove_agent(&agent_id).await;
// Delete from database (cascades to sessions and events)
db::machines::delete_machine(db.pool(), &agent_id).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete machine"))?;
info!("Deleted machine {} (uninstall_sent: {})", agent_id, uninstall_sent);
Ok(Json(api::DeleteMachineResponse {
success: true,
message: format!("Machine {} deleted", machine.hostname),
uninstall_sent,
history,
}))
}
// Static page handlers // Static page handlers
async fn serve_login() -> impl IntoResponse { async fn serve_login() -> impl IntoResponse {
match tokio::fs::read_to_string("static/login.html").await { match tokio::fs::read_to_string("static/login.html").await {

View File

@@ -383,6 +383,48 @@ impl SessionManager {
let sessions = self.sessions.read().await; let sessions = self.sessions.read().await;
sessions.values().map(|s| s.info.clone()).collect() sessions.values().map(|s| s.info.clone()).collect()
} }
/// Send an admin command to an agent (uninstall, restart, etc.)
/// Returns true if the message was sent successfully
pub async fn send_admin_command(&self, session_id: SessionId, command: crate::proto::AdminCommandType, reason: &str) -> bool {
let sessions = self.sessions.read().await;
if let Some(session_data) = sessions.get(&session_id) {
if !session_data.info.is_online {
tracing::warn!("Cannot send admin command to offline agent");
return false;
}
use crate::proto;
use prost::Message;
let admin_cmd = proto::Message {
payload: Some(proto::message::Payload::AdminCommand(proto::AdminCommand {
command: command as i32,
reason: reason.to_string(),
})),
};
let mut buf = Vec::new();
if admin_cmd.encode(&mut buf).is_ok() {
if session_data.input_tx.send(buf).await.is_ok() {
tracing::info!("Sent admin command {:?} to session {}", command, session_id);
return true;
}
}
}
false
}
/// Remove an agent/machine from the session manager (for deletion)
/// Returns the agent_id if found
pub async fn remove_agent(&self, agent_id: &str) -> Option<SessionId> {
let agents = self.agents.read().await;
let session_id = agents.get(agent_id).copied()?;
drop(agents);
self.remove_session(session_id).await;
Some(session_id)
}
} }
impl Default for SessionManager { impl Default for SessionManager {