Files
claudetools/projects/msp-tools/guru-rmm/agent/src/main.rs
Mike Swanson 07816eae46 docs: Add comprehensive project documentation from claude-projects scan
Added:
- PROJECTS_INDEX.md - Master catalog of 7 active projects
- GURURMM_API_ACCESS.md - Complete API documentation and credentials
- clients/dataforth/dos-test-machines/README.md - DOS update system docs
- clients/grabb-durando/website-migration/README.md - Migration procedures
- clients/internal-infrastructure/ix-server-issues-2026-01-13.md - Server issues
- projects/msp-tools/guru-connect/README.md - Remote desktop architecture
- projects/msp-tools/toolkit/README.md - MSP PowerShell tools
- projects/internal/acg-website-2025/README.md - Website rebuild docs
- test_gururmm_api.py - GuruRMM API testing script

Modified:
- credentials.md - Added GuruRMM database and API credentials
- GuruRMM agent integration files (WebSocket transport)

Total: 38,000+ words of comprehensive project documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 09:58:32 -07:00

704 lines
21 KiB
Rust

//! 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<Commands>,
}
#[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<String>,
/// API key for authentication
#[arg(long)]
api_key: Option<String>,
/// 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<bool>,
}
#[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<String>,
api_key: Option<String>,
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<String>,
api_key: Option<String>,
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(&current_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 <plist-path>"
));
}
}
/// 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 <plist-path>"
));
}
}
/// 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(())
}