Unify agent and viewer into single guruconnect binary
- Renamed package from guruconnect-agent to guruconnect - Added CLI subcommands: agent, view, install, uninstall, launch - Moved viewer code into agent/src/viewer module - Added install module with: - UAC elevation attempt with user-install fallback - Protocol handler registration (guruconnect://) - System-wide install to Program Files or user install to LocalAppData - Single binary now handles both receiving and initiating connections - Protocol URL format: guruconnect://view/SESSION_ID?token=API_KEY Usage: guruconnect agent - Run as background agent guruconnect view <session_id> - View a remote session guruconnect install - Install and register protocol guruconnect launch <url> - Handle guruconnect:// URL 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
//! GuruConnect Agent - Remote Desktop Agent for Windows
|
||||
//! GuruConnect - Remote Desktop Agent and Viewer
|
||||
//!
|
||||
//! Provides screen capture, input injection, and remote control capabilities.
|
||||
//! Single binary for both agent (receiving connections) and viewer (initiating connections).
|
||||
//!
|
||||
//! Usage:
|
||||
//! guruconnect-agent.exe [support_code]
|
||||
//!
|
||||
//! If a support code is provided, the agent will connect using that code
|
||||
//! for a one-time support session.
|
||||
//! guruconnect agent - Run as background agent
|
||||
//! guruconnect view <session_id> - View a remote session
|
||||
//! guruconnect install - Install and register protocol handler
|
||||
//! guruconnect launch <url> - Handle guruconnect:// URL
|
||||
//! guruconnect [support_code] - Legacy: run agent with support code
|
||||
|
||||
// Hide console window by default on Windows (release builds)
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
@@ -16,42 +17,227 @@ mod chat;
|
||||
mod config;
|
||||
mod encoder;
|
||||
mod input;
|
||||
mod install;
|
||||
mod sas_client;
|
||||
mod session;
|
||||
mod startup;
|
||||
mod transport;
|
||||
mod tray;
|
||||
mod viewer;
|
||||
|
||||
pub mod proto {
|
||||
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
|
||||
}
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use tracing::{info, error, warn, Level};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, MB_ICONERROR};
|
||||
#[cfg(windows)]
|
||||
use windows::core::PCWSTR;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY};
|
||||
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Console::{AllocConsole, FreeConsole, GetConsoleWindow};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW, SW_HIDE};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
|
||||
|
||||
/// Extract a 6-digit support code from the executable's filename.
|
||||
/// Looks for patterns like "GuruConnect-123456.exe" or "123456.exe"
|
||||
/// GuruConnect Remote Desktop
|
||||
#[derive(Parser)]
|
||||
#[command(name = "guruconnect")]
|
||||
#[command(version, about = "Remote desktop agent and viewer")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
/// Support code for legacy mode (runs agent with code)
|
||||
#[arg(value_name = "SUPPORT_CODE")]
|
||||
support_code: Option<String>,
|
||||
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long, global = true)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Run as background agent (receive remote connections)
|
||||
Agent {
|
||||
/// Support code for one-time session
|
||||
#[arg(short, long)]
|
||||
code: Option<String>,
|
||||
},
|
||||
|
||||
/// View a remote session (connect to an agent)
|
||||
View {
|
||||
/// Session ID to connect to
|
||||
session_id: String,
|
||||
|
||||
/// Server URL
|
||||
#[arg(short, long, default_value = "wss://connect.azcomputerguru.com/ws/viewer")]
|
||||
server: String,
|
||||
|
||||
/// API key for authentication
|
||||
#[arg(short, long, default_value = "")]
|
||||
api_key: String,
|
||||
},
|
||||
|
||||
/// Install GuruConnect and register protocol handler
|
||||
Install {
|
||||
/// Skip UAC elevation, install for current user only
|
||||
#[arg(long)]
|
||||
user_only: bool,
|
||||
|
||||
/// Called internally when running elevated
|
||||
#[arg(long, hide = true)]
|
||||
elevated: bool,
|
||||
},
|
||||
|
||||
/// Uninstall GuruConnect
|
||||
Uninstall,
|
||||
|
||||
/// Handle a guruconnect:// protocol URL
|
||||
Launch {
|
||||
/// The guruconnect:// URL to handle
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize logging
|
||||
let level = if cli.verbose { Level::DEBUG } else { Level::INFO };
|
||||
FmtSubscriber::builder()
|
||||
.with_max_level(level)
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.init();
|
||||
|
||||
info!("GuruConnect v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Agent { code }) => {
|
||||
run_agent_mode(code)
|
||||
}
|
||||
Some(Commands::View { session_id, server, api_key }) => {
|
||||
run_viewer_mode(&server, &session_id, &api_key)
|
||||
}
|
||||
Some(Commands::Install { user_only, elevated }) => {
|
||||
run_install(user_only || elevated)
|
||||
}
|
||||
Some(Commands::Uninstall) => {
|
||||
run_uninstall()
|
||||
}
|
||||
Some(Commands::Launch { url }) => {
|
||||
run_launch(&url)
|
||||
}
|
||||
None => {
|
||||
// Legacy mode: if a support code was provided, run as agent
|
||||
if let Some(code) = cli.support_code {
|
||||
run_agent_mode(Some(code))
|
||||
} else {
|
||||
// No args: check if we should auto-detect mode
|
||||
// For now, default to agent mode
|
||||
run_agent_mode(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run in agent mode (receive remote connections)
|
||||
fn run_agent_mode(support_code: Option<String>) -> Result<()> {
|
||||
info!("Running in agent mode");
|
||||
|
||||
// Check elevation status
|
||||
if install::is_elevated() {
|
||||
info!("Running with elevated (administrator) privileges");
|
||||
} else {
|
||||
info!("Running with standard user privileges");
|
||||
}
|
||||
|
||||
// Also check for support code in filename (legacy compatibility)
|
||||
let code = support_code.or_else(extract_code_from_filename);
|
||||
if let Some(ref c) = code {
|
||||
info!("Support code: {}", c);
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
let mut config = config::Config::load()?;
|
||||
config.support_code = code;
|
||||
info!("Server: {}", config.server_url);
|
||||
|
||||
// Run the agent
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(run_agent(config))
|
||||
}
|
||||
|
||||
/// Run in viewer mode (connect to remote session)
|
||||
fn run_viewer_mode(server: &str, session_id: &str, api_key: &str) -> Result<()> {
|
||||
info!("Running in viewer mode");
|
||||
info!("Connecting to session: {}", session_id);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(viewer::run(server, session_id, api_key))
|
||||
}
|
||||
|
||||
/// Handle guruconnect:// URL launch
|
||||
fn run_launch(url: &str) -> Result<()> {
|
||||
info!("Handling protocol URL: {}", url);
|
||||
|
||||
match install::parse_protocol_url(url) {
|
||||
Ok((server, session_id, token)) => {
|
||||
let api_key = token.unwrap_or_default();
|
||||
run_viewer_mode(&server, &session_id, &api_key)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to parse URL: {}", e);
|
||||
show_error_box("GuruConnect", &format!("Invalid URL: {}", e));
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install GuruConnect
|
||||
fn run_install(force_user_install: bool) -> Result<()> {
|
||||
info!("Installing GuruConnect...");
|
||||
|
||||
match install::install(force_user_install) {
|
||||
Ok(()) => {
|
||||
show_message_box("GuruConnect", "Installation complete!\n\nYou can now use guruconnect:// links.");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Installation failed: {}", e);
|
||||
show_error_box("GuruConnect", &format!("Installation failed: {}", e));
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall GuruConnect
|
||||
fn run_uninstall() -> Result<()> {
|
||||
info!("Uninstalling GuruConnect...");
|
||||
|
||||
// Remove from startup
|
||||
if let Err(e) = startup::remove_from_startup() {
|
||||
warn!("Failed to remove from startup: {}", e);
|
||||
}
|
||||
|
||||
// TODO: Remove registry keys for protocol handler
|
||||
// TODO: Remove install directory
|
||||
|
||||
show_message_box("GuruConnect", "Uninstall complete.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract a 6-digit support code from the executable's filename
|
||||
fn extract_code_from_filename() -> Option<String> {
|
||||
// Get the path to the current executable
|
||||
let exe_path = std::env::current_exe().ok()?;
|
||||
let filename = exe_path.file_stem()?.to_str()?;
|
||||
|
||||
// Look for a 6-digit number in the filename
|
||||
// Try common patterns: "Name-123456", "Name_123456", "123456"
|
||||
for part in filename.split(|c| c == '-' || c == '_' || c == '.') {
|
||||
let trimmed = part.trim();
|
||||
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
|
||||
@@ -59,7 +245,7 @@ fn extract_code_from_filename() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if the last 6 characters are digits (e.g., "GuruConnect123456")
|
||||
// Check if the last 6 characters are digits
|
||||
if filename.len() >= 6 {
|
||||
let last_six = &filename[filename.len() - 6..];
|
||||
if last_six.chars().all(|c| c.is_ascii_digit()) {
|
||||
@@ -70,45 +256,12 @@ fn extract_code_from_filename() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if the process is running with elevated privileges (Windows only)
|
||||
#[cfg(windows)]
|
||||
fn is_elevated() -> bool {
|
||||
unsafe {
|
||||
let mut token_handle = windows::Win32::Foundation::HANDLE::default();
|
||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut elevation = TOKEN_ELEVATION::default();
|
||||
let mut size = std::mem::size_of::<TOKEN_ELEVATION>() as u32;
|
||||
|
||||
let result = GetTokenInformation(
|
||||
token_handle,
|
||||
TokenElevation,
|
||||
Some(&mut elevation as *mut _ as *mut _),
|
||||
size,
|
||||
&mut size,
|
||||
);
|
||||
|
||||
let _ = windows::Win32::Foundation::CloseHandle(token_handle);
|
||||
|
||||
result.is_ok() && elevation.TokenIsElevated != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_elevated() -> bool {
|
||||
// On non-Windows, check if running as root
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
|
||||
/// Show a message box to the user (Windows only)
|
||||
/// Show a message box (Windows only)
|
||||
#[cfg(windows)]
|
||||
fn show_message_box(title: &str, message: &str) {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
// Convert strings to wide strings for Windows API
|
||||
let title_wide: Vec<u16> = OsStr::new(title)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
@@ -129,74 +282,59 @@ fn show_message_box(title: &str, message: &str) {
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_message_box(_title: &str, _message: &str) {
|
||||
// No-op on non-Windows platforms
|
||||
fn show_message_box(_title: &str, message: &str) {
|
||||
println!("{}", message);
|
||||
}
|
||||
|
||||
/// Show the debug console window (Windows only)
|
||||
/// Show an error message box (Windows only)
|
||||
#[cfg(windows)]
|
||||
fn show_error_box(title: &str, message: &str) {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
let title_wide: Vec<u16> = OsStr::new(title)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let message_wide: Vec<u16> = OsStr::new(message)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
MessageBoxW(
|
||||
None,
|
||||
PCWSTR(message_wide.as_ptr()),
|
||||
PCWSTR(title_wide.as_ptr()),
|
||||
MB_OK | MB_ICONERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_error_box(_title: &str, message: &str) {
|
||||
eprintln!("ERROR: {}", message);
|
||||
}
|
||||
|
||||
/// Show debug console window (Windows only)
|
||||
#[cfg(windows)]
|
||||
#[allow(dead_code)]
|
||||
fn show_debug_console() {
|
||||
unsafe {
|
||||
// Check if we already have a console
|
||||
let hwnd = GetConsoleWindow();
|
||||
if hwnd.0 == std::ptr::null_mut() {
|
||||
// No console, allocate one
|
||||
let _ = AllocConsole();
|
||||
info!("Debug console window opened");
|
||||
} else {
|
||||
// Console exists, make sure it's visible
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
info!("Debug console window shown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_debug_console() {
|
||||
// No-op on non-Windows platforms
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn show_debug_console() {}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize logging
|
||||
let subscriber = FmtSubscriber::builder()
|
||||
.with_max_level(Level::INFO)
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.init();
|
||||
|
||||
info!("GuruConnect Agent v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Check and log elevation status
|
||||
if is_elevated() {
|
||||
info!("Running with elevated (administrator) privileges");
|
||||
} else {
|
||||
info!("Running with standard user privileges");
|
||||
}
|
||||
|
||||
// Extract support code from executable filename
|
||||
// e.g., GuruConnect-123456.exe -> 123456
|
||||
let support_code = extract_code_from_filename();
|
||||
if let Some(ref code) = support_code {
|
||||
info!("Support code from filename: {}", code);
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
let mut config = config::Config::load()?;
|
||||
config.support_code = support_code;
|
||||
info!("Loaded configuration for server: {}", config.server_url);
|
||||
|
||||
// Run the agent
|
||||
if let Err(e) = run_agent(config).await {
|
||||
error!("Agent error: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up before exiting (remove from startup, etc.)
|
||||
/// Called when user explicitly ends session or support session completes
|
||||
/// Clean up before exiting
|
||||
fn cleanup_on_exit() {
|
||||
info!("Cleaning up before exit");
|
||||
if let Err(e) = startup::remove_from_startup() {
|
||||
@@ -204,44 +342,37 @@ fn cleanup_on_exit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the agent main loop
|
||||
async fn run_agent(config: config::Config) -> Result<()> {
|
||||
// Create session manager with elevation status
|
||||
let elevated = is_elevated();
|
||||
let elevated = install::is_elevated();
|
||||
let mut session = session::SessionManager::new(config.clone(), elevated);
|
||||
let is_support_session = config.support_code.is_some();
|
||||
let hostname = config.hostname();
|
||||
|
||||
// Add to startup so we reconnect after reboot
|
||||
// Persistent agents (no support code) should ALWAYS be in startup
|
||||
// Support sessions only need startup temporarily while active
|
||||
// Add to startup
|
||||
if let Err(e) = startup::add_to_startup() {
|
||||
warn!("Failed to add to startup: {}. Agent won't persist through reboot.", e);
|
||||
warn!("Failed to add to startup: {}", e);
|
||||
}
|
||||
|
||||
// Create tray icon
|
||||
// Only support sessions can be ended by user - persistent agents are admin-managed
|
||||
let tray = match tray::TrayController::new(&hostname, config.support_code.as_deref(), is_support_session) {
|
||||
Ok(t) => {
|
||||
info!("Tray icon created");
|
||||
Some(t)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create tray icon: {}. Continuing without tray.", e);
|
||||
warn!("Failed to create tray icon: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Create chat controller
|
||||
let chat_ctrl = chat::ChatController::new();
|
||||
if chat_ctrl.is_some() {
|
||||
info!("Chat controller created");
|
||||
}
|
||||
|
||||
// Connect to server and run main loop
|
||||
loop {
|
||||
info!("Connecting to server...");
|
||||
|
||||
// Check if user requested exit via tray before connecting (support sessions only)
|
||||
if is_support_session {
|
||||
if let Some(ref t) = tray {
|
||||
if t.exit_requested() {
|
||||
@@ -256,44 +387,32 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
Ok(_) => {
|
||||
info!("Connected to server");
|
||||
|
||||
// Update tray status
|
||||
if let Some(ref t) = tray {
|
||||
t.update_status("Status: Connected");
|
||||
}
|
||||
|
||||
// Run session until disconnect, passing tray and chat for event processing
|
||||
if let Err(e) = session.run_with_tray(tray.as_ref(), chat_ctrl.as_ref()).await {
|
||||
let error_msg = e.to_string();
|
||||
|
||||
// Check if this is a user-initiated exit
|
||||
if error_msg.contains("USER_EXIT") {
|
||||
info!("Session ended by user");
|
||||
cleanup_on_exit();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if this is a cancellation
|
||||
if error_msg.contains("SESSION_CANCELLED") {
|
||||
info!("Session was cancelled by technician");
|
||||
cleanup_on_exit();
|
||||
show_message_box(
|
||||
"Support Session Ended",
|
||||
"The support session was cancelled by the technician.\n\nThis window will close automatically.",
|
||||
);
|
||||
// Exit cleanly without reconnecting
|
||||
show_message_box("Support Session Ended", "The support session was cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if this is an admin disconnect (uninstall)
|
||||
if error_msg.contains("ADMIN_DISCONNECT") {
|
||||
info!("Session was disconnected by administrator - uninstalling");
|
||||
info!("Session 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.",
|
||||
);
|
||||
show_message_box("Remote Session Ended", "The session was ended by the administrator.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -303,15 +422,10 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
|
||||
// Check if connection was rejected due to cancelled code
|
||||
if error_msg.contains("cancelled") {
|
||||
info!("Support code was cancelled before connection");
|
||||
info!("Support code was cancelled");
|
||||
cleanup_on_exit();
|
||||
show_message_box(
|
||||
"Support Session Cancelled",
|
||||
"This support session has been cancelled.\n\nPlease contact your technician for a new support code.",
|
||||
);
|
||||
// Exit cleanly without reconnecting
|
||||
show_message_box("Support Session Cancelled", "This support session has been cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -319,14 +433,12 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// For support sessions, don't reconnect if something goes wrong
|
||||
if is_support_session {
|
||||
info!("Support session ended, not reconnecting");
|
||||
cleanup_on_exit();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Wait before reconnecting (persistent agents only - support sessions already exited above)
|
||||
info!("Reconnecting in 5 seconds...");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user