Add disconnect/uninstall for persistent sessions

- Server: Add DELETE /api/sessions/:id endpoint to disconnect agents
- Server: SessionManager.disconnect_session() sends Disconnect message
- Agent: Handle ADMIN_DISCONNECT to trigger uninstall
- Agent: Add startup::uninstall() to remove from startup and schedule exe deletion
- Dashboard: Add Disconnect button in Access tab machine details
- Dashboard: Add Chat button for persistent sessions

🤖 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-28 16:53:29 -07:00
parent aa03a87c7c
commit 4b29dbe6c8
7 changed files with 131 additions and 2 deletions

View File

@@ -73,6 +73,7 @@ windows = { version = "0.58", features = [
"Win32_System_Threading", "Win32_System_Threading",
"Win32_System_Registry", "Win32_System_Registry",
"Win32_Security", "Win32_Security",
"Win32_Storage_FileSystem",
]} ]}
# Windows service support # Windows service support

View File

@@ -249,6 +249,19 @@ async fn run_agent(config: config::Config) -> Result<()> {
return Ok(()); return Ok(());
} }
// Check if this is an admin disconnect (uninstall)
if error_msg.contains("ADMIN_DISCONNECT") {
info!("Session was disconnected by administrator - uninstalling");
if let Err(e) = startup::uninstall() {
warn!("Uninstall failed: {}", e);
}
show_message_box(
"Remote Session Ended",
"The remote support session has been ended by the administrator.\n\nThe agent will be removed from this computer.",
);
return Ok(());
}
error!("Session error: {}", e); error!("Session error: {}", e);
} }
} }

View File

@@ -340,10 +340,14 @@ impl SessionManager {
Some(message::Payload::Disconnect(disc)) => { Some(message::Payload::Disconnect(disc)) => {
tracing::info!("Disconnect requested: {}", disc.reason); tracing::info!("Disconnect requested: {}", disc.reason);
// Check if this is a cancellation // Check if this is a cancellation (support session)
if disc.reason.contains("cancelled") { if disc.reason.contains("cancelled") {
return Err(anyhow::anyhow!("SESSION_CANCELLED: {}", disc.reason)); return Err(anyhow::anyhow!("SESSION_CANCELLED: {}", disc.reason));
} }
// Check if this is an admin disconnect (persistent session)
if disc.reason.contains("administrator") || disc.reason.contains("Disconnected") {
return Err(anyhow::anyhow!("ADMIN_DISCONNECT: {}", disc.reason));
}
return Err(anyhow::anyhow!("Disconnect: {}", disc.reason)); return Err(anyhow::anyhow!("Disconnect: {}", disc.reason));
} }

View File

@@ -134,6 +134,49 @@ pub fn remove_from_startup() -> Result<()> {
Ok(()) Ok(())
} }
/// Full uninstall: remove from startup and delete the executable
#[cfg(windows)]
pub fn uninstall() -> Result<()> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
info!("Uninstalling agent");
// First remove from startup
let _ = remove_from_startup();
// Get the path to the current executable
let exe_path = std::env::current_exe()?;
let exe_path_str = exe_path.to_string_lossy();
info!("Scheduling deletion of: {}", exe_path_str);
// Convert path to wide string
let exe_wide: Vec<u16> = OsStr::new(&*exe_path_str)
.encode_wide()
.chain(std::iter::once(0))
.collect();
// Schedule the file for deletion on next reboot
// This is necessary because the executable is currently running
unsafe {
let result = MoveFileExW(
PCWSTR(exe_wide.as_ptr()),
PCWSTR::null(),
MOVEFILE_DELAY_UNTIL_REBOOT,
);
if result.is_err() {
warn!("Failed to schedule file deletion: {:?}. File may need manual removal.", result);
} else {
info!("Executable scheduled for deletion on reboot");
}
}
Ok(())
}
#[cfg(not(windows))] #[cfg(not(windows))]
pub fn add_to_startup() -> Result<()> { pub fn add_to_startup() -> Result<()> {
warn!("Startup persistence not implemented for this platform"); warn!("Startup persistence not implemented for this platform");
@@ -144,3 +187,9 @@ pub fn add_to_startup() -> Result<()> {
pub fn remove_from_startup() -> Result<()> { pub fn remove_from_startup() -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(not(windows))]
pub fn uninstall() -> Result<()> {
warn!("Uninstall not implemented for this platform");
Ok(())
}

View File

@@ -81,6 +81,7 @@ 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))
// HTML page routes (clean URLs) // HTML page routes (clean URLs)
.route("/login", get(serve_login)) .route("/login", get(serve_login))
@@ -178,6 +179,23 @@ async fn get_session(
Ok(Json(api::SessionInfo::from(session))) Ok(Json(api::SessionInfo::from(session)))
} }
async fn disconnect_session(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let session_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid session ID"),
};
if state.sessions.disconnect_session(session_id, "Disconnected by administrator").await {
info!("Session {} disconnected by admin", session_id);
(StatusCode::OK, "Session disconnected")
} else {
(StatusCode::NOT_FOUND, "Session not found")
}
}
// 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

@@ -134,6 +134,32 @@ impl SessionManager {
} }
} }
/// Disconnect a session by sending a disconnect message to the agent
/// Returns true if the message was sent successfully
pub async fn disconnect_session(&self, session_id: SessionId, reason: &str) -> bool {
let sessions = self.sessions.read().await;
if let Some(session_data) = sessions.get(&session_id) {
// Create disconnect message
use crate::proto;
use prost::Message;
let disconnect_msg = proto::Message {
payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
reason: reason.to_string(),
})),
};
let mut buf = Vec::new();
if disconnect_msg.encode(&mut buf).is_ok() {
// Send via input channel (will be forwarded to agent's WebSocket)
if session_data.input_tx.send(buf).await.is_ok() {
return true;
}
}
}
false
}
/// List all active sessions /// List all active sessions
pub async fn list_sessions(&self) -> Vec<Session> { pub async fn list_sessions(&self) -> Vec<Session> {
let sessions = self.sessions.read().await; let sessions = self.sessions.read().await;

View File

@@ -709,7 +709,9 @@
'<div class="detail-section">' + '<div class="detail-section">' +
'<div class="detail-section-title">Actions</div>' + '<div class="detail-section-title">Actions</div>' +
'<button class="btn btn-primary" style="width: 100%; margin-bottom: 8px;" onclick="connectToMachine(\'' + m.id + '\')">Connect</button>' + '<button class="btn btn-primary" style="width: 100%; margin-bottom: 8px;" onclick="connectToMachine(\'' + m.id + '\')">Connect</button>' +
'<button class="btn btn-outline" style="width: 100%;" disabled>Transfer Files</button>' + '<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="openChat(\'' + m.id + '\', \'' + (m.agent_name || 'Client').replace(/'/g, "\\'") + '\')">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%; color: hsl(0, 62.8%, 50%);" onclick="disconnectMachine(\'' + m.id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')">Disconnect</button>' +
'</div>'; '</div>';
} }
@@ -718,6 +720,22 @@
alert("Viewer not yet implemented.\\n\\nSession ID: " + sessionId + "\\n\\nWebSocket: wss://connect.azcomputerguru.com/ws/viewer?session_id=" + sessionId); alert("Viewer not yet implemented.\\n\\nSession ID: " + sessionId + "\\n\\nWebSocket: wss://connect.azcomputerguru.com/ws/viewer?session_id=" + sessionId);
} }
async function disconnectMachine(sessionId, machineName) {
if (!confirm("Disconnect " + machineName + "?\\n\\nThis will end the remote session.")) return;
try {
const response = await fetch("/api/sessions/" + sessionId, { method: "DELETE" });
if (response.ok) {
selectedMachine = null;
renderMachineDetail();
loadMachines();
} else {
alert("Failed to disconnect: " + await response.text());
}
} catch (err) {
alert("Error disconnecting machine");
}
}
// Refresh machines every 5 seconds // Refresh machines every 5 seconds
loadMachines(); loadMachines();
setInterval(loadMachines, 5000); setInterval(loadMachines, 5000);