//! GuruRMM Agent - Cross-platform Remote Monitoring and Management Agent //! //! This agent connects to the GuruRMM server, reports system metrics, //! monitors services (watchdog), and executes remote commands. mod claude; mod config; mod device_id; mod metrics; mod service; mod transport; mod updater; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; use tracing::{error, info, warn}; use crate::config::AgentConfig; use crate::metrics::MetricsCollector; use crate::transport::WebSocketClient; /// GuruRMM Agent - Remote Monitoring and Management #[derive(Parser)] #[command(name = "gururmm-agent")] #[command(author, version, about, long_about = None)] struct Cli { /// Path to configuration file #[arg(short, long, default_value = "agent.toml")] config: PathBuf, /// Subcommand to run #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Commands { /// Run the agent (default) Run, /// Install as a system service Install { /// Server WebSocket URL (e.g., wss://rmm-api.example.com/ws) #[arg(long)] server_url: Option, /// API key for authentication #[arg(long)] api_key: Option, /// Skip legacy service detection and cleanup #[arg(long, default_value = "false")] skip_legacy_check: bool, }, /// Uninstall the system service Uninstall, /// Start the installed service Start, /// Stop the installed service Stop, /// Show agent status Status, /// Generate a sample configuration file GenerateConfig { /// Output path for config file #[arg(short, long, default_value = "agent.toml")] output: PathBuf, }, /// Run as Windows service (called by SCM, not for manual use) #[command(hide = true)] Service, } /// Shared application state pub struct AppState { pub config: AgentConfig, pub metrics_collector: MetricsCollector, pub connected: RwLock, } #[tokio::main] async fn main() -> Result<()> { // Initialize logging tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::from_default_env() .add_directive("gururmm_agent=info".parse()?) .add_directive("info".parse()?), ) .init(); let cli = Cli::parse(); match cli.command.unwrap_or(Commands::Run) { Commands::Run => run_agent(cli.config).await, Commands::Install { server_url, api_key, skip_legacy_check } => { install_service(server_url, api_key, skip_legacy_check).await } Commands::Uninstall => uninstall_service().await, Commands::Start => start_service().await, Commands::Stop => stop_service().await, Commands::Status => show_status(cli.config).await, Commands::GenerateConfig { output } => generate_config(output).await, Commands::Service => run_as_windows_service(), } } /// Run as a Windows service (called by SCM) fn run_as_windows_service() -> Result<()> { #[cfg(windows)] { service::windows::run_as_service() } #[cfg(not(windows))] { anyhow::bail!("Windows service mode is only available on Windows"); } } /// Main agent runtime loop async fn run_agent(config_path: PathBuf) -> Result<()> { info!("GuruRMM Agent starting..."); // Load configuration let config = AgentConfig::load(&config_path)?; info!("Loaded configuration from {:?}", config_path); info!("Server URL: {}", config.server.url); // Initialize metrics collector let metrics_collector = MetricsCollector::new(); info!("Metrics collector initialized"); // Create shared state let state = Arc::new(AppState { config: config.clone(), metrics_collector, connected: RwLock::new(false), }); // Start the WebSocket client with auto-reconnect let ws_state = Arc::clone(&state); let ws_handle = tokio::spawn(async move { loop { info!("Connecting to server..."); match WebSocketClient::connect_and_run(Arc::clone(&ws_state)).await { Ok(_) => { warn!("WebSocket connection closed normally, reconnecting..."); } Err(e) => { error!("WebSocket error: {}, reconnecting in 10 seconds...", e); } } // Mark as disconnected *ws_state.connected.write().await = false; // Wait before reconnecting tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; } }); // Start metrics collection loop let metrics_state = Arc::clone(&state); let metrics_handle = tokio::spawn(async move { let interval = metrics_state.config.metrics.interval_seconds; let mut interval_timer = tokio::time::interval(tokio::time::Duration::from_secs(interval)); loop { interval_timer.tick().await; // Collect metrics (they'll be sent via WebSocket if connected) let metrics = metrics_state.metrics_collector.collect().await; if *metrics_state.connected.read().await { info!( "Metrics: CPU={:.1}%, Mem={:.1}%, Disk={:.1}%", metrics.cpu_percent, metrics.memory_percent, metrics.disk_percent ); } } }); // Wait for shutdown signal tokio::select! { _ = tokio::signal::ctrl_c() => { info!("Received shutdown signal"); } _ = ws_handle => { error!("WebSocket task ended unexpectedly"); } _ = metrics_handle => { error!("Metrics task ended unexpectedly"); } } info!("GuruRMM Agent shutting down"); Ok(()) } /// Install the agent as a system service async fn install_service( server_url: Option, api_key: Option, skip_legacy_check: bool, ) -> Result<()> { #[cfg(windows)] { service::windows::install(server_url, api_key, skip_legacy_check) } #[cfg(target_os = "linux")] { install_systemd_service(server_url, api_key, skip_legacy_check).await } #[cfg(target_os = "macos")] { let _ = (server_url, api_key, skip_legacy_check); // Suppress unused warnings return Err(anyhow::anyhow!( "macOS launchd service installation is not yet implemented.\n\ For now, you can run the agent manually or create a launchd plist.\n\ See: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" )); } } /// Legacy service names to check for and clean up (Linux) #[cfg(target_os = "linux")] const LINUX_LEGACY_SERVICE_NAMES: &[&str] = &[ "gururmm", // Old name without -agent suffix "guru-rmm-agent", // Alternative naming "GuruRMM-Agent", // Case variant ]; /// Clean up legacy Linux service installations #[cfg(target_os = "linux")] fn cleanup_legacy_linux_services() -> Result<()> { use std::process::Command; info!("Checking for legacy service installations..."); for legacy_name in LINUX_LEGACY_SERVICE_NAMES { // Check if service exists let status = Command::new("systemctl") .args(["status", legacy_name]) .output(); if let Ok(output) = status { if output.status.success() || String::from_utf8_lossy(&output.stderr).contains("Loaded:") { info!("Found legacy service '{}', removing...", legacy_name); // Stop the service let _ = Command::new("systemctl") .args(["stop", legacy_name]) .status(); // Disable the service let _ = Command::new("systemctl") .args(["disable", legacy_name]) .status(); // Remove unit file let unit_file = format!("/etc/systemd/system/{}.service", legacy_name); if std::path::Path::new(&unit_file).exists() { info!("Removing legacy unit file: {}", unit_file); let _ = std::fs::remove_file(&unit_file); } } } } // Check for legacy binaries in common locations let legacy_binary_locations = [ "/usr/local/bin/gururmm", "/usr/bin/gururmm", "/opt/gururmm/gururmm", "/opt/gururmm/agent", ]; for legacy_path in legacy_binary_locations { if std::path::Path::new(legacy_path).exists() { info!("Found legacy binary at '{}', removing...", legacy_path); let _ = std::fs::remove_file(legacy_path); } } // Reload systemd to pick up removed unit files let _ = Command::new("systemctl") .args(["daemon-reload"]) .status(); Ok(()) } /// Install as a systemd service (Linux) #[cfg(target_os = "linux")] async fn install_systemd_service( server_url: Option, api_key: Option, skip_legacy_check: bool, ) -> Result<()> { use std::process::Command; const SERVICE_NAME: &str = "gururmm-agent"; const INSTALL_DIR: &str = "/usr/local/bin"; const CONFIG_DIR: &str = "/etc/gururmm"; const SYSTEMD_DIR: &str = "/etc/systemd/system"; info!("Installing GuruRMM Agent as systemd service..."); // Check if running as root if !nix::unistd::geteuid().is_root() { anyhow::bail!("Installation requires root privileges. Please run with sudo."); } // Clean up legacy installations unless skipped if !skip_legacy_check { if let Err(e) = cleanup_legacy_linux_services() { warn!("Legacy cleanup warning: {}", e); } } // Get the current executable path let current_exe = std::env::current_exe() .context("Failed to get current executable path")?; let binary_dest = format!("{}/{}", INSTALL_DIR, SERVICE_NAME); let config_dest = format!("{}/agent.toml", CONFIG_DIR); let unit_file = format!("{}/{}.service", SYSTEMD_DIR, SERVICE_NAME); // Create config directory info!("Creating config directory: {}", CONFIG_DIR); std::fs::create_dir_all(CONFIG_DIR) .context("Failed to create config directory")?; // Copy binary info!("Copying binary to: {}", binary_dest); std::fs::copy(¤t_exe, &binary_dest) .context("Failed to copy binary")?; // Make binary executable Command::new("chmod") .args(["+x", &binary_dest]) .status() .context("Failed to set binary permissions")?; // Handle configuration let config_needs_manual_edit; if !std::path::Path::new(&config_dest).exists() { info!("Creating config: {}", config_dest); // Start with sample config let mut config = crate::config::AgentConfig::sample(); // Apply provided values if let Some(url) = &server_url { config.server.url = url.clone(); } if let Some(key) = &api_key { config.server.api_key = key.clone(); } let toml_str = toml::to_string_pretty(&config)?; std::fs::write(&config_dest, toml_str) .context("Failed to write config file")?; // Set restrictive permissions on config (contains API key) Command::new("chmod") .args(["600", &config_dest]) .status() .context("Failed to set config permissions")?; config_needs_manual_edit = server_url.is_none() || api_key.is_none(); } else { info!("Config already exists: {}", config_dest); config_needs_manual_edit = false; // If server_url or api_key provided, update existing config if server_url.is_some() || api_key.is_some() { info!("Updating existing configuration..."); let config_content = std::fs::read_to_string(&config_dest)?; let mut config: crate::config::AgentConfig = toml::from_str(&config_content) .context("Failed to parse existing config")?; if let Some(url) = &server_url { config.server.url = url.clone(); } if let Some(key) = &api_key { config.server.api_key = key.clone(); } let toml_str = toml::to_string_pretty(&config)?; std::fs::write(&config_dest, toml_str) .context("Failed to update config file")?; } } // Create systemd unit file let unit_content = format!(r#"[Unit] Description=GuruRMM Agent - Remote Monitoring and Management Documentation=https://github.com/azcomputerguru/gururmm After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart={binary} --config {config} run Restart=always RestartSec=10 StandardOutput=journal StandardError=journal SyslogIdentifier={service} # Security hardening NoNewPrivileges=true ProtectSystem=strict ProtectHome=read-only PrivateTmp=true ReadWritePaths=/var/log [Install] WantedBy=multi-user.target "#, binary = binary_dest, config = config_dest, service = SERVICE_NAME ); info!("Creating systemd unit file: {}", unit_file); std::fs::write(&unit_file, unit_content) .context("Failed to write systemd unit file")?; // Reload systemd daemon info!("Reloading systemd daemon..."); let status = Command::new("systemctl") .args(["daemon-reload"]) .status() .context("Failed to reload systemd")?; if !status.success() { anyhow::bail!("systemctl daemon-reload failed"); } // Enable the service info!("Enabling service..."); let status = Command::new("systemctl") .args(["enable", SERVICE_NAME]) .status() .context("Failed to enable service")?; if !status.success() { anyhow::bail!("systemctl enable failed"); } println!("\n[OK] GuruRMM Agent installed successfully!"); println!("\nInstalled files:"); println!(" Binary: {}", binary_dest); println!(" Config: {}", config_dest); println!(" Service: {}", unit_file); if config_needs_manual_edit { println!("\n[WARNING] IMPORTANT: Edit {} with your server URL and API key!", config_dest); println!("\nNext steps:"); println!(" 1. Edit {} with your server URL and API key", config_dest); println!(" 2. Start the service: sudo systemctl start {}", SERVICE_NAME); } else { println!("\nStarting service..."); let status = Command::new("systemctl") .args(["start", SERVICE_NAME]) .status(); if status.is_ok() && status.unwrap().success() { println!("[OK] Service started successfully!"); } else { println!("[WARNING] Failed to start service. Check logs: sudo journalctl -u {} -f", SERVICE_NAME); } } println!("\nUseful commands:"); println!(" Status: sudo systemctl status {}", SERVICE_NAME); println!(" Logs: sudo journalctl -u {} -f", SERVICE_NAME); println!(" Stop: sudo systemctl stop {}", SERVICE_NAME); println!(" Start: sudo systemctl start {}", SERVICE_NAME); Ok(()) } /// Uninstall the system service async fn uninstall_service() -> Result<()> { #[cfg(windows)] { service::windows::uninstall() } #[cfg(target_os = "linux")] { uninstall_systemd_service().await } #[cfg(target_os = "macos")] { return Err(anyhow::anyhow!( "macOS launchd service uninstallation is not yet implemented.\n\ If you created a launchd plist manually, remove it from ~/Library/LaunchAgents/ or /Library/LaunchDaemons/" )); } } /// Uninstall systemd service (Linux) #[cfg(target_os = "linux")] async fn uninstall_systemd_service() -> Result<()> { use std::process::Command; const SERVICE_NAME: &str = "gururmm-agent"; const INSTALL_DIR: &str = "/usr/local/bin"; const CONFIG_DIR: &str = "/etc/gururmm"; const SYSTEMD_DIR: &str = "/etc/systemd/system"; info!("Uninstalling GuruRMM Agent..."); if !nix::unistd::geteuid().is_root() { anyhow::bail!("Uninstallation requires root privileges. Please run with sudo."); } let binary_path = format!("{}/{}", INSTALL_DIR, SERVICE_NAME); let unit_file = format!("{}/{}.service", SYSTEMD_DIR, SERVICE_NAME); // Stop the service if running info!("Stopping service..."); let _ = Command::new("systemctl") .args(["stop", SERVICE_NAME]) .status(); // Disable the service info!("Disabling service..."); let _ = Command::new("systemctl") .args(["disable", SERVICE_NAME]) .status(); // Remove unit file if std::path::Path::new(&unit_file).exists() { info!("Removing unit file: {}", unit_file); std::fs::remove_file(&unit_file)?; } // Remove binary if std::path::Path::new(&binary_path).exists() { info!("Removing binary: {}", binary_path); std::fs::remove_file(&binary_path)?; } // Reload systemd let _ = Command::new("systemctl") .args(["daemon-reload"]) .status(); println!("\n[OK] GuruRMM Agent uninstalled successfully!"); println!("\nNote: Config directory {} was preserved.", CONFIG_DIR); println!("Remove it manually if no longer needed: sudo rm -rf {}", CONFIG_DIR); Ok(()) } /// Start the installed service async fn start_service() -> Result<()> { #[cfg(windows)] { service::windows::start() } #[cfg(target_os = "linux")] { use std::process::Command; info!("Starting GuruRMM Agent service..."); let status = Command::new("systemctl") .args(["start", "gururmm-agent"]) .status() .context("Failed to start service")?; if status.success() { println!("[OK] Service started successfully"); println!("Check status: sudo systemctl status gururmm-agent"); } else { anyhow::bail!("Failed to start service. Check: sudo journalctl -u gururmm-agent -n 50"); } Ok(()) } #[cfg(target_os = "macos")] { return Err(anyhow::anyhow!( "macOS launchd service start is not yet implemented.\n\ If you created a launchd plist manually, use: launchctl load " )); } } /// Stop the installed service async fn stop_service() -> Result<()> { #[cfg(windows)] { service::windows::stop() } #[cfg(target_os = "linux")] { use std::process::Command; info!("Stopping GuruRMM Agent service..."); let status = Command::new("systemctl") .args(["stop", "gururmm-agent"]) .status() .context("Failed to stop service")?; if status.success() { println!("[OK] Service stopped successfully"); } else { anyhow::bail!("Failed to stop service"); } Ok(()) } #[cfg(target_os = "macos")] { return Err(anyhow::anyhow!( "macOS launchd service stop is not yet implemented.\n\ If you created a launchd plist manually, use: launchctl unload " )); } } /// Show agent status async fn show_status(config_path: PathBuf) -> Result<()> { // On Windows, show service status #[cfg(windows)] { service::windows::status()?; println!(); } // Try to load config for additional info match AgentConfig::load(&config_path) { Ok(config) => { println!("Configuration"); println!("============="); println!("Config file: {:?}", config_path); println!("Server URL: {}", config.server.url); println!("Metrics interval: {} seconds", config.metrics.interval_seconds); println!("Watchdog enabled: {}", config.watchdog.enabled); // Collect current metrics let collector = MetricsCollector::new(); let metrics = collector.collect().await; println!("\nCurrent System Metrics:"); println!(" CPU Usage: {:.1}%", metrics.cpu_percent); println!(" Memory Usage: {:.1}%", metrics.memory_percent); println!( " Memory Used: {:.2} GB", metrics.memory_used_bytes as f64 / 1_073_741_824.0 ); println!(" Disk Usage: {:.1}%", metrics.disk_percent); println!( " Disk Used: {:.2} GB", metrics.disk_used_bytes as f64 / 1_073_741_824.0 ); } Err(_) => { println!("\nConfig file {:?} not found or invalid.", config_path); #[cfg(windows)] println!("Service config location: {}\\agent.toml", service::windows::CONFIG_DIR); } } Ok(()) } /// Generate a sample configuration file async fn generate_config(output: PathBuf) -> Result<()> { let sample_config = AgentConfig::sample(); let toml_str = toml::to_string_pretty(&sample_config)?; std::fs::write(&output, toml_str)?; println!("Sample configuration written to {:?}", output); println!("\nEdit this file with your server URL and API key, then run:"); println!(" gururmm-agent --config {:?} run", output); Ok(()) }