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:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user