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:
@@ -416,7 +416,21 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
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) => {
|
||||
|
||||
@@ -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)) => {
|
||||
tracing::info!("Disconnect requested: {}", disc.reason);
|
||||
if disc.reason.contains("cancelled") {
|
||||
|
||||
@@ -278,6 +278,18 @@ message AgentStatus {
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -320,5 +332,8 @@ message Message {
|
||||
|
||||
// Chat
|
||||
ChatMessage chat_message = 60;
|
||||
|
||||
// Admin commands (server -> agent)
|
||||
AdminCommand admin_command = 70;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! REST API endpoints
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, State, Query},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::SessionManager;
|
||||
use crate::db;
|
||||
|
||||
/// Viewer info returned by API
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -88,3 +89,120 @@ pub async fn get_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>,
|
||||
}
|
||||
|
||||
@@ -105,3 +105,22 @@ pub async fn get_events_by_type(
|
||||
.fetch_all(pool)
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -96,3 +96,16 @@ pub async fn get_recent_sessions(
|
||||
.fetch_all(pool)
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ pub mod proto {
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
extract::{Path, State, Json},
|
||||
routing::{get, post, delete},
|
||||
extract::{Path, State, Json, Query},
|
||||
response::{Html, IntoResponse},
|
||||
http::StatusCode,
|
||||
};
|
||||
@@ -122,7 +122,13 @@ async fn main() -> Result<()> {
|
||||
// REST API - Sessions
|
||||
.route("/api/sessions", get(list_sessions))
|
||||
.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)
|
||||
.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
|
||||
async fn serve_login() -> impl IntoResponse {
|
||||
match tokio::fs::read_to_string("static/login.html").await {
|
||||
|
||||
@@ -383,6 +383,48 @@ impl SessionManager {
|
||||
let sessions = self.sessions.read().await;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user