Files
claudetools/projects/msp-tools/guru-rmm/agent/src/service.rs
azcomputerguru b298a8aa17 fix: Implement Phase 2 major fixes
Database:
- Add missing indexes for api_key_hash, status, metrics queries
- New migration: 005_add_missing_indexes.sql

Server:
- Fix WebSocket Ping/Pong protocol (RFC 6455 compliance)
- Use separate channel for Pong responses

Agent:
- Replace format!() path construction with PathBuf::join()
- Replace todo!() macros with proper errors for macOS support

Dashboard:
- Fix duplicate filter values in Agents page (__unassigned__ sentinel)
- Add onError handlers to all mutations in Agents, Clients, Sites pages

All changes reviewed and approved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:23:36 -07:00

778 lines
29 KiB
Rust

//! Windows Service implementation for GuruRMM Agent
//!
//! This module implements the Windows Service Control Manager (SCM) protocol,
//! allowing the agent to run as a native Windows service without third-party wrappers.
#[cfg(all(windows, feature = "native-service"))]
pub mod windows {
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{error, info, warn};
use windows_service::{
define_windows_service,
service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl,
ServiceExitCode, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus,
ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher, service_manager::{ServiceManager, ServiceManagerAccess},
};
pub const SERVICE_NAME: &str = "GuruRMMAgent";
pub const SERVICE_DISPLAY_NAME: &str = "GuruRMM Agent";
pub const SERVICE_DESCRIPTION: &str =
"GuruRMM Agent - Remote Monitoring and Management service";
pub const INSTALL_DIR: &str = r"C:\Program Files\GuruRMM";
pub const CONFIG_DIR: &str = r"C:\ProgramData\GuruRMM";
// Generate the Windows service boilerplate
define_windows_service!(ffi_service_main, service_main);
/// Entry point called by the Windows Service Control Manager
pub fn run_as_service() -> Result<()> {
// This function is called when Windows starts the service.
// It blocks until the service is stopped.
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
.context("Failed to start service dispatcher")?;
Ok(())
}
/// Main service function called by the SCM
fn service_main(arguments: Vec<OsString>) {
if let Err(e) = run_service(arguments) {
error!("Service error: {}", e);
}
}
/// The actual service implementation
fn run_service(_arguments: Vec<OsString>) -> Result<()> {
// Create a channel to receive stop events
let (shutdown_tx, shutdown_rx) = mpsc::channel();
// Create the service control handler
let event_handler = move |control_event| -> ServiceControlHandlerResult {
match control_event {
ServiceControl::Stop => {
info!("Received stop command from SCM");
let _ = shutdown_tx.send(());
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
ServiceControl::Shutdown => {
info!("Received shutdown command from SCM");
let _ = shutdown_tx.send(());
ServiceControlHandlerResult::NoError
}
_ => ServiceControlHandlerResult::NotImplemented,
}
};
// Register the service control handler
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)
.context("Failed to register service control handler")?;
// Report that we're starting
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StartPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(10),
process_id: None,
})
.context("Failed to set StartPending status")?;
// Determine config path
let config_path = PathBuf::from(CONFIG_DIR).join("agent.toml");
// Create the tokio runtime for the agent
let runtime = tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
// Start the agent in the runtime
let agent_result = runtime.block_on(async {
// Load configuration
let config = match crate::config::AgentConfig::load(&config_path) {
Ok(c) => c,
Err(e) => {
error!("Failed to load config from {:?}: {}", config_path, e);
return Err(anyhow::anyhow!("Config load failed: {}", e));
}
};
info!("GuruRMM Agent service starting...");
info!("Config loaded from {:?}", config_path);
info!("Server URL: {}", config.server.url);
// Initialize metrics collector
let metrics_collector = crate::metrics::MetricsCollector::new();
info!("Metrics collector initialized");
// Create shared state
let state = std::sync::Arc::new(crate::AppState {
config: config.clone(),
metrics_collector,
connected: tokio::sync::RwLock::new(false),
});
// Report that we're running
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.context("Failed to set Running status")?;
// Start WebSocket client task
let ws_state = std::sync::Arc::clone(&state);
let ws_handle = tokio::spawn(async move {
loop {
info!("Connecting to server...");
match crate::transport::WebSocketClient::connect_and_run(std::sync::Arc::clone(
&ws_state,
))
.await
{
Ok(_) => {
warn!("WebSocket connection closed normally, reconnecting...");
}
Err(e) => {
error!("WebSocket error: {}, reconnecting in 10 seconds...", e);
}
}
*ws_state.connected.write().await = false;
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
}
});
// Start metrics collection task
let metrics_state = std::sync::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;
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 from SCM
// We use a separate task to poll the channel since it's not async
let shutdown_handle = tokio::spawn(async move {
loop {
match shutdown_rx.try_recv() {
Ok(_) => {
info!("Shutdown signal received");
break;
}
Err(mpsc::TryRecvError::Empty) => {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
Err(mpsc::TryRecvError::Disconnected) => {
warn!("Shutdown channel disconnected");
break;
}
}
}
});
// Wait for shutdown
tokio::select! {
_ = shutdown_handle => {
info!("Service shutting down gracefully");
}
_ = ws_handle => {
error!("WebSocket task ended unexpectedly");
}
_ = metrics_handle => {
error!("Metrics task ended unexpectedly");
}
}
Ok::<(), anyhow::Error>(())
});
// Report that we're stopping
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StopPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(5),
process_id: None,
})
.ok();
// Report that we've stopped
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: match &agent_result {
Ok(_) => ServiceExitCode::Win32(0),
Err(_) => ServiceExitCode::Win32(1),
},
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.ok();
agent_result
}
/// Known legacy service names to check and remove
const LEGACY_SERVICE_NAMES: &[&str] = &[
"GuruRMM-Agent", // NSSM-based service name
"gururmm-agent", // Alternative casing
];
/// Detect and remove legacy service installations (e.g., NSSM-based)
fn cleanup_legacy_services() -> Result<()> {
let manager = match ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
) {
Ok(m) => m,
Err(_) => return Ok(()), // Can't connect, skip legacy cleanup
};
for legacy_name in LEGACY_SERVICE_NAMES {
if let Ok(service) = manager.open_service(
*legacy_name,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
) {
info!("Found legacy service '{}', removing...", legacy_name);
// Stop if running
if let Ok(status) = service.query_status() {
if status.current_state != ServiceState::Stopped {
info!("Stopping legacy service...");
let _ = service.stop();
std::thread::sleep(Duration::from_secs(3));
}
}
// Delete the service
match service.delete() {
Ok(_) => {
println!("** Removed legacy service: {}", legacy_name);
}
Err(e) => {
warn!("Failed to delete legacy service '{}': {}", legacy_name, e);
}
}
}
}
// Also check for NSSM in registry/service config
// NSSM services have specific registry keys under HKLM\SYSTEM\CurrentControlSet\Services\{name}\Parameters
for legacy_name in LEGACY_SERVICE_NAMES {
let params_key = format!(
r"SYSTEM\CurrentControlSet\Services\{}\Parameters",
legacy_name
);
// If this key exists, it was likely an NSSM service
if let Ok(output) = std::process::Command::new("reg")
.args(["query", &format!(r"HKLM\{}", params_key)])
.output()
{
if output.status.success() {
info!("Found NSSM registry keys for '{}', cleaning up...", legacy_name);
let _ = std::process::Command::new("reg")
.args(["delete", &format!(r"HKLM\{}", params_key), "/f"])
.output();
}
}
}
Ok(())
}
/// Install the agent as a Windows service using native APIs
pub fn install(
server_url: Option<String>,
api_key: Option<String>,
skip_legacy_check: bool,
) -> Result<()> {
info!("Installing GuruRMM Agent as Windows service...");
// Clean up legacy installations unless skipped
if !skip_legacy_check {
info!("Checking for legacy service installations...");
if let Err(e) = cleanup_legacy_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 = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe");
let config_dest = PathBuf::from(CONFIG_DIR).join("agent.toml");
// Create directories
info!("Creating directories...");
std::fs::create_dir_all(INSTALL_DIR).context("Failed to create install directory")?;
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")?;
// Handle configuration
let config_needs_manual_edit;
if !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")?;
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")?;
}
}
// Open the service manager
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
// Check if service already exists
if let Ok(service) = manager.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::DELETE | ServiceAccess::STOP,
) {
info!("Removing existing service...");
// Stop the service if running
if let Ok(status) = service.query_status() {
if status.current_state != ServiceState::Stopped {
let _ = service.stop();
std::thread::sleep(Duration::from_secs(2));
}
}
// Delete the service
service.delete().context("Failed to delete existing service")?;
drop(service);
// Wait for deletion to complete
std::thread::sleep(Duration::from_secs(2));
}
// Create the service
// The service binary is called with "service" subcommand when started by SCM
let service_binary_path = format!(r#""{}" service"#, binary_dest.display());
info!("Creating service with path: {}", service_binary_path);
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: binary_dest.clone(),
launch_arguments: vec![OsString::from("service")],
dependencies: vec![],
account_name: None, // LocalSystem
account_password: None,
};
let service = manager
.create_service(&service_info, ServiceAccess::CHANGE_CONFIG | ServiceAccess::START)
.context("Failed to create service")?;
// Set description
service
.set_description(SERVICE_DESCRIPTION)
.context("Failed to set service description")?;
// Configure recovery options using sc.exe (windows-service crate doesn't support this directly)
info!("Configuring recovery options...");
let _ = std::process::Command::new("sc")
.args([
"failure",
SERVICE_NAME,
"reset=86400",
"actions=restart/60000/restart/60000/restart/60000",
])
.output();
println!("\n** GuruRMM Agent installed successfully!");
println!("\nInstalled files:");
println!(" Binary: {:?}", binary_dest);
println!(" Config: {:?}", config_dest);
if config_needs_manual_edit {
println!("\n** 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:");
println!(" gururmm-agent start");
println!(" Or: sc start {}", SERVICE_NAME);
} else {
println!("\nStarting service...");
if let Err(e) = start() {
println!("** Failed to start service: {}. Start manually with:", e);
println!(" gururmm-agent start");
} else {
println!("** Service started successfully!");
}
}
println!("\nUseful commands:");
println!(" Status: gururmm-agent status");
println!(" Stop: gururmm-agent stop");
println!(" Start: gururmm-agent start");
Ok(())
}
/// Uninstall the Windows service
pub fn uninstall() -> Result<()> {
info!("Uninstalling GuruRMM Agent...");
let binary_path = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe");
// Open the service manager
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
// Open the service
match manager.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
) {
Ok(service) => {
// Stop if running
if let Ok(status) = service.query_status() {
if status.current_state != ServiceState::Stopped {
info!("Stopping service...");
let _ = service.stop();
std::thread::sleep(Duration::from_secs(3));
}
}
// Delete the service
info!("Deleting service...");
service.delete().context("Failed to delete service")?;
}
Err(_) => {
warn!("Service was not installed");
}
}
// Remove binary
if binary_path.exists() {
info!("Removing binary: {:?}", binary_path);
// Wait a bit for service to fully stop
std::thread::sleep(Duration::from_secs(1));
if let Err(e) = std::fs::remove_file(&binary_path) {
warn!("Failed to remove binary (may be in use): {}", e);
}
}
// Remove install directory if empty
let _ = std::fs::remove_dir(INSTALL_DIR);
println!("\n** GuruRMM Agent uninstalled successfully!");
println!(
"\nNote: Config directory {:?} was preserved.",
CONFIG_DIR
);
println!("Remove it manually if no longer needed.");
Ok(())
}
/// Start the installed service
pub fn start() -> Result<()> {
info!("Starting GuruRMM Agent service...");
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("Failed to connect to Service Control Manager")?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::START | ServiceAccess::QUERY_STATUS)
.context("Failed to open service. Is it installed?")?;
service
.start::<String>(&[])
.context("Failed to start service")?;
// Wait briefly and check status
std::thread::sleep(Duration::from_secs(2));
let status = service.query_status()?;
match status.current_state {
ServiceState::Running => {
println!("** Service started successfully");
println!("Check status: gururmm-agent status");
}
ServiceState::StartPending => {
println!("** Service is starting...");
println!("Check status: gururmm-agent status");
}
other => {
println!("Service state: {:?}", other);
}
}
Ok(())
}
/// Stop the installed service
pub fn stop() -> Result<()> {
info!("Stopping GuruRMM Agent service...");
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("Failed to connect to Service Control Manager")?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::STOP | ServiceAccess::QUERY_STATUS)
.context("Failed to open service. Is it installed?")?;
service.stop().context("Failed to stop service")?;
// Wait and verify
std::thread::sleep(Duration::from_secs(2));
let status = service.query_status()?;
match status.current_state {
ServiceState::Stopped => {
println!("** Service stopped successfully");
}
ServiceState::StopPending => {
println!("** Service is stopping...");
}
other => {
println!("Service state: {:?}", other);
}
}
Ok(())
}
/// Query service status
pub fn status() -> Result<()> {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("Failed to connect to Service Control Manager")?;
match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) {
Ok(service) => {
let status = service.query_status()?;
println!("GuruRMM Agent Service Status");
println!("============================");
println!("Service Name: {}", SERVICE_NAME);
println!("Display Name: {}", SERVICE_DISPLAY_NAME);
println!("State: {:?}", status.current_state);
println!(
"Binary: {}\\gururmm-agent.exe",
INSTALL_DIR
);
println!("Config: {}\\agent.toml", CONFIG_DIR);
}
Err(_) => {
println!("GuruRMM Agent Service Status");
println!("============================");
println!("Status: NOT INSTALLED");
println!("\nTo install: gururmm-agent install");
}
}
Ok(())
}
}
/// Legacy Windows stub module (when native-service is not enabled)
/// For legacy Windows (7, Server 2008 R2), use NSSM for service wrapper
#[cfg(all(windows, not(feature = "native-service")))]
pub mod windows {
use anyhow::{Result, bail};
pub const SERVICE_NAME: &str = "GuruRMMAgent";
pub const SERVICE_DISPLAY_NAME: &str = "GuruRMM Agent";
pub const SERVICE_DESCRIPTION: &str =
"GuruRMM Agent - Remote Monitoring and Management service";
pub const INSTALL_DIR: &str = r"C:\Program Files\GuruRMM";
pub const CONFIG_DIR: &str = r"C:\ProgramData\GuruRMM";
/// Legacy build doesn't support native service mode
pub fn run_as_service() -> Result<()> {
bail!("Native Windows service mode not available in legacy build. Use 'run' command with NSSM wrapper instead.")
}
/// Legacy install just copies binary and config, prints NSSM instructions
pub fn install(
server_url: Option<String>,
api_key: Option<String>,
_skip_legacy_check: bool,
) -> Result<()> {
use std::path::PathBuf;
use tracing::info;
info!("Installing GuruRMM Agent (legacy mode)...");
// Get the current executable path
let current_exe = std::env::current_exe()?;
let binary_dest = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe");
let config_dest = PathBuf::from(CONFIG_DIR).join("agent.toml");
// Create directories
std::fs::create_dir_all(INSTALL_DIR)?;
std::fs::create_dir_all(CONFIG_DIR)?;
// Copy binary
info!("Copying binary to: {:?}", binary_dest);
std::fs::copy(&current_exe, &binary_dest)?;
// Create config if needed
if !config_dest.exists() {
let mut config = crate::config::AgentConfig::sample();
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)?;
}
println!("\n** GuruRMM Agent installed (legacy mode)!");
println!("\nInstalled files:");
println!(" Binary: {:?}", binary_dest);
println!(" Config: {:?}", config_dest);
println!("\n** IMPORTANT: This is a legacy build for Windows 7/Server 2008 R2");
println!(" Use NSSM to install as a service:");
println!();
println!(" nssm install {} {:?} run --config {:?}", SERVICE_NAME, binary_dest, config_dest);
println!(" nssm start {}", SERVICE_NAME);
println!();
println!(" Download NSSM from: https://nssm.cc/download");
Ok(())
}
pub fn uninstall() -> Result<()> {
use std::path::PathBuf;
let binary_path = PathBuf::from(INSTALL_DIR).join("gururmm-agent.exe");
println!("** To uninstall legacy service, use NSSM:");
println!(" nssm stop {}", SERVICE_NAME);
println!(" nssm remove {} confirm", SERVICE_NAME);
println!();
if binary_path.exists() {
std::fs::remove_file(&binary_path)?;
println!("** Binary removed: {:?}", binary_path);
}
let _ = std::fs::remove_dir(INSTALL_DIR);
println!("\n** GuruRMM Agent uninstalled (legacy mode)!");
println!("Note: Config directory {} was preserved.", CONFIG_DIR);
Ok(())
}
pub fn start() -> Result<()> {
println!("** Legacy build: Use NSSM or sc.exe to start the service:");
println!(" nssm start {}", SERVICE_NAME);
println!(" -- OR --");
println!(" sc start {}", SERVICE_NAME);
Ok(())
}
pub fn stop() -> Result<()> {
println!("** Legacy build: Use NSSM or sc.exe to stop the service:");
println!(" nssm stop {}", SERVICE_NAME);
println!(" -- OR --");
println!(" sc stop {}", SERVICE_NAME);
Ok(())
}
pub fn status() -> Result<()> {
println!("GuruRMM Agent Service Status (Legacy Build)");
println!("==========================================");
println!("Service Name: {}", SERVICE_NAME);
println!();
println!("** Legacy build: Use sc.exe to query status:");
println!(" sc query {}", SERVICE_NAME);
println!();
println!("Binary: {}\\gururmm-agent.exe", INSTALL_DIR);
println!("Config: {}\\agent.toml", CONFIG_DIR);
Ok(())
}
}