diff --git a/agent/src/main.rs b/agent/src/main.rs index 980f445..2f2f614 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -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) => { diff --git a/agent/src/session/mod.rs b/agent/src/session/mod.rs index 6ffdf37..ad6e5a8 100644 --- a/agent/src/session/mod.rs +++ b/agent/src/session/mod.rs @@ -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") { diff --git a/proto/guruconnect.proto b/proto/guruconnect.proto index c399e0d..5abe915 100644 --- a/proto/guruconnect.proto +++ b/proto/guruconnect.proto @@ -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; } } diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 44ad661..0f0aed8 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -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, + pub is_elevated: bool, + pub is_persistent: bool, + pub first_seen: String, + pub last_seen: String, + pub status: String, +} + +impl From 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, + pub duration_secs: Option, + pub is_support_session: bool, + pub support_code: Option, + pub status: String, +} + +impl From 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, + pub viewer_name: Option, + pub details: Option, + pub ip_address: Option, +} + +impl From 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, + pub events: Vec, + 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, +} diff --git a/server/src/db/events.rs b/server/src/db/events.rs index fa1d6b7..409122b 100644 --- a/server/src/db/events.rs +++ b/server/src/db/events.rs @@ -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, 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 +} diff --git a/server/src/db/sessions.rs b/server/src/db/sessions.rs index 2eafe1c..488af14 100644 --- a/server/src/db/sessions.rs +++ b/server/src/db/sessions.rs @@ -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, 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 +} diff --git a/server/src/main.rs b/server/src/main.rs index 2014133..05674ba 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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, +) -> Result>, (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, + Path(agent_id): Path, +) -> Result, (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, + Path(agent_id): Path, +) -> Result, (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, + Path(agent_id): Path, + Query(params): Query, +) -> Result, (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 { diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index c8c32cf..e6a8160 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -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 { + 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 {