Files
claudetools/projects/msp-tools/guru-rmm/agent/src/main.rs
azcomputerguru 2e6d1a67dd Implement GuruRMM Phase 1: Real-time tunnel infrastructure
Complete bidirectional tunnel communication between server and agents,
enabling persistent secure channels for future command execution and
file operations. Agents transition from heartbeat mode to tunnel mode
on-demand while maintaining WebSocket connection.

Server Implementation:
- Database layer (db/tunnel.rs): Session CRUD, ownership validation,
  cleanup on disconnect (prevents orphaned sessions)
- API endpoints (api/tunnel.rs): POST /open, POST /close, GET /status
  with JWT auth, UUID validation, proper HTTP status codes
- Protocol extension (ws/mod.rs): TunnelOpen/Close/Data messages,
  agent response handlers (TunnelReady/Data/Error)
- Migration (006_tunnel_sessions.sql): tech_sessions table with
  partial unique constraint, foreign keys with CASCADE, audit table

Agent Implementation:
- State machine (tunnel/mod.rs): AgentMode (Heartbeat ↔ Tunnel),
  channel multiplexing, concurrent session prevention
- WebSocket handlers (transport/websocket.rs): Open/close tunnel,
  mode switching without dropping connection, cleanup on disconnect
- Protocol extension (transport/mod.rs): TunnelReady/Data/Error
  messages matching server definitions
- Unit tests: Lifecycle and channel management coverage

Key Features:
- Security: JWT auth, session ownership verification, SQL injection
  prevention, constraint-based duplicate session blocking
- Cleanup: Automatic session closure on agent disconnect (both sides),
  channel cleanup, graceful state transitions
- Error handling: Proper HTTP status codes (400/403/404/409/500),
  comprehensive Result types, detailed logging
- Extensibility: Channel types ready (Terminal/File/Registry/Service),
  TunnelDataPayload enum for Phase 2+ expansion

Phase 1 Scope (Implemented):
- Tunnel session lifecycle management
- Mode switching (heartbeat ↔ tunnel)
- Protocol message routing
- Database session tracking

Phase 2 Next Steps:
- Terminal command execution (tokio::process::Command)
- Client WebSocket connections for output streaming
- Command audit logging
- File transfer operations

Verification:
- Server compiles successfully (0 errors)
- Agent unit tests pass (tunnel lifecycle, channel management)
- Code review approved (protocol alignment verified)
- Database constraints enforce referential integrity
- Cleanup tested (session closure on disconnect)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-14 07:10:09 -07:00

705 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 tunnel;
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(())
}