agent: service.rs — Linux systemd + Windows service management (install/uninstall/start/stop/status/restart)

This commit is contained in:
Butterfly Dev 2026-04-07 05:34:56 +00:00
parent 920ea0ee9e
commit d0e8bf5569

645
agent/src/service.rs Normal file
View File

@ -0,0 +1,645 @@
//! System service management for the Butterfly agent.
//!
//! Provides a cross-platform abstraction for installing, uninstalling, starting,
//! stopping, and querying the status of the agent as a background service.
//!
//! ## Platform Support
//!
//! - **Linux**: Uses `systemd` unit files. The service runs under the system
//! manager with configurable user and display environment variables.
//! - **Windows**: Uses the Windows Service Control Manager (SCM). The service
//! is registered via `sc.exe` commands and runs as a Windows Service with
//! proper lifecycle management (START/STOP/SHUTDOWN).
//!
//! ## Linux Systemd Unit File
//!
//! The generated unit file includes:
//! - Automatic restart on failure (with 5s delay)
//! - Display environment variable capture (DISPLAY, WAYLAND_DISPLAY, etc.)
//! - Configurable user and working directory
//! - `network-online.target` dependency for proper boot ordering
use anyhow::{Context, Result};
use log::{info, warn};
use crate::cli::ServiceAction;
use crate::config::RunOptions;
/// Dispatch a service management action to the appropriate platform handler.
pub fn handle_service_action(action: ServiceAction) -> Result<()> {
match action {
ServiceAction::Install {
opts,
name,
display_name,
description,
start,
#[cfg(unix)]
user,
working_directory,
log_file,
} => install_service(
&name,
&display_name,
&description,
&opts,
#[cfg(unix)]
user.as_deref(),
working_directory.as_deref(),
log_file.as_deref(),
start,
),
ServiceAction::Uninstall { name } => uninstall_service(&name),
ServiceAction::Start { name } => start_service(&name),
ServiceAction::Stop { name } => stop_service(&name),
ServiceAction::Status { name } => status_service(&name),
ServiceAction::Restart { name } => restart_service(&name),
}
}
// ── Install ────────────────────────────────────────────────────────────────────
/// Install the agent as a system service.
///
/// On Linux, this creates a systemd unit file and enables it.
/// On Windows, this registers with the Service Control Manager.
#[allow(clippy::too_many_arguments)]
fn install_service(
name: &str,
display_name: &str,
description: &str,
opts: &RunOptions,
#[cfg(unix)] user: Option<&str>,
working_directory: Option<&str>,
log_file: Option<&str>,
auto_start: bool,
) -> Result<()> {
#[cfg(unix)]
{
install_service_unix(name, display_name, description, opts, user, working_directory, log_file, auto_start)
}
#[cfg(windows)]
{
let _ = (user, working_directory, log_file);
install_service_windows(name, display_name, description, opts, auto_start)
}
}
/// Install as a systemd service on Linux.
#[cfg(unix)]
#[allow(clippy::too_many_arguments)]
fn install_service_unix(
name: &str,
_display_name: &str,
description: &str,
opts: &RunOptions,
user: Option<&str>,
working_directory: Option<&str>,
log_file: Option<&str>,
auto_start: bool,
) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
// Resolve the absolute path to the current binary.
let exe_path = std::env::current_exe()
.context("failed to resolve current executable path")?;
let exe_str = exe_path.to_str()
.context("executable path is not valid UTF-8")?;
info!("installing systemd service '{}'", name);
info!(" binary: {}", exe_str);
info!(" server: {}", opts.server);
info!(" encoder: {}", opts.encoder);
info!(" fps: {}", opts.fps);
// Build the ExecStart command line.
let args = opts.to_service_args();
let exec_args: Vec<String> = args.iter()
.flat_map(|a| {
// Quote arguments that contain spaces or special characters.
if a.contains(' ') || a.contains('"') || a.contains('\'') || a.contains('$') {
vec![format!("'{}'", a.replace('\'', "'\\''"))]
} else {
vec![a.clone()]
}
})
.collect();
let exec_start = format!("{} {}", exe_str, exec_args.join(" "));
// Detect display-related environment variables to capture.
let display_envs = detect_display_env();
// Build the systemd unit file.
let mut unit = String::new();
// [Unit] section.
unit.push_str(&format!("[Unit]\n"));
unit.push_str(&format!("Description={}\n", description));
unit.push_str("After=network-online.target\n");
unit.push_str("Wants=network-online.target\n");
// On systems with a graphical target, wait for it.
// This is optional — if the target doesn't exist, systemd ignores it.
unit.push_str("After=graphical.target\n");
unit.push_str("\n");
// [Service] section.
unit.push_str("[Service]\n");
unit.push_str("Type=simple\n");
unit.push_str(&format!("ExecStart={}\n", exec_start));
unit.push_str("Restart=always\n");
unit.push_str("RestartSec=5\n");
// User.
if let Some(u) = user {
unit.push_str(&format!("User={}\n", u));
unit.push_str(&format!("Group={}\n", u));
}
// Working directory.
if let Some(wd) = working_directory {
unit.push_str(&format!("WorkingDirectory={}\n", wd));
}
// Environment variables.
unit.push_str("Environment=RUST_LOG=info\n");
for (key, value) in &display_envs {
unit.push_str(&format!("Environment={}={}\n", key, value));
}
// Logging to file.
if let Some(log_path) = log_file {
unit.push_str(&format!("StandardOutput=append:{}", log_path));
unit.push_str(&format!("StandardError=append:{}", log_path));
}
// Security: don't kill the service when the user logs out.
// Important for headless VMs accessed via RDP/SSH.
unit.push_str("\n# Prevent service from being killed on user logout\n");
unit.push_str("KillMode=process\n");
unit.push_str("\n[Install]\n");
unit.push_str("WantedBy=multi-user.target\n");
// Write the unit file.
let unit_path = format!("/etc/systemd/system/{}.service", name);
info!("writing unit file: {}", unit_path);
std::fs::write(&unit_path, &unit)
.context(format!("failed to write unit file to '{}'. Do you need sudo?", unit_path))?;
// Set permissions (644).
let perms = std::fs::Permissions::from_mode(0o644);
std::fs::set_permissions(&unit_path, perms)?;
// Reload systemd daemon.
info!("reloading systemd daemon...");
let status = std::process::Command::new("systemctl")
.arg("daemon-reload")
.status()
.context("failed to run systemctl daemon-reload")?;
if !status.success() {
warn!("systemctl daemon-reload returned non-zero exit code");
}
// Enable and optionally start the service.
if auto_start {
info!("enabling and starting service '{}'...", name);
let status = std::process::Command::new("systemctl")
.args(["enable", "--now", name])
.status()
.context(format!("failed to enable/start service '{}'", name))?;
if !status.success() {
anyhow::bail!(
"systemctl enable --now {} failed. Check 'systemctl status {}' for details.",
name, name
);
}
} else {
info!("enabling service '{}' (not starting)...", name);
let status = std::process::Command::new("systemctl")
.args(["enable", name])
.status()
.context(format!("failed to enable service '{}'", name))?;
if !status.success() {
anyhow::bail!("systemctl enable {} failed", name);
}
}
println!();
println!("✅ Service '{}' installed successfully!", name);
println!(" Unit file: {}", unit_path);
println!();
println!(" Commands:");
println!(" sudo systemctl start {} — start the service", name);
println!(" sudo systemctl stop {} — stop the service", name);
println!(" sudo systemctl restart {} — restart the service", name);
println!(" sudo systemctl status {} — check status", name);
println!(" sudo journalctl -u {} -f — follow logs", name);
println!();
Ok(())
}
/// Install as a Windows Service.
#[cfg(windows)]
fn install_service_windows(
name: &str,
display_name: &str,
description: &str,
opts: &RunOptions,
auto_start: bool,
) -> Result<()> {
use std::process::Command;
// Resolve the absolute path to the current binary.
let exe_path = std::env::current_exe()
.context("failed to resolve current executable path")?;
let exe_str = exe_path.to_str()
.context("executable path is not valid UTF-8")?;
info!("installing Windows service '{}'", name);
info!(" binary: {}", exe_str);
info!(" server: {}", opts.server);
info!(" encoder: {}", opts.encoder);
// Build the full command line for the service binary.
let args = opts.to_service_args();
// On Windows, add the --windows-service flag so the binary knows to
// connect to the SCM dispatcher instead of running in foreground mode.
let mut all_args: Vec<String> = args;
all_args.push("--windows-service".to_string());
// Quote the binary path (important if it contains spaces).
let bin_path = format!("\"{}\" {}", exe_str, all_args.join(" "));
// Use sc.exe to create the service.
// The sc.exe command has unusual argument parsing: key=value pairs where
// the value extends until the next recognized key. The space after '=' is
// significant and required.
let sc_args = [
"create",
name,
&format!("binPath= \"{}\"", bin_path),
&format!("DisplayName= \"{}\"", display_name),
"start= auto",
"obj= LocalSystem",
"type= own",
];
info!("running sc create {}...", name);
let result = Command::new("sc")
.args(&sc_args)
.output()
.context("failed to execute sc.exe. Are you running as Administrator?")?;
if !result.status.success() {
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
anyhow::bail!(
"sc create failed:\n stdout: {}\n stderr: {}",
stdout.trim(),
stderr.trim()
);
}
// Set the service description.
let desc_result = Command::new("sc")
.args(["description", name, description])
.output();
match desc_result {
Ok(r) if r.status.success() => {}
Ok(r) => warn!("failed to set service description: {}", String::from_utf8_lossy(&r.stderr).trim()),
Err(e) => warn!("failed to run sc description: {}", e),
}
// Set failure recovery options: restart after 5 seconds, reset after 1 day.
let failure_result = Command::new("sc")
.args([
"failure",
name,
"reset= 86400",
"actions= restart/5000",
])
.output();
match failure_result {
Ok(r) if r.status.success() => info!("configured failure recovery (restart after 5s)"),
Ok(r) => warn!("failed to set failure actions: {}", String::from_utf8_lossy(&r.stderr).trim()),
Err(e) => warn!("failed to set failure actions: {}", e),
}
// Optionally start the service.
if auto_start {
info!("starting service '{}'...", name);
let start_result = Command::new("sc")
.args(["start", name])
.output()
.context("failed to start service")?;
if !start_result.status.success() {
let stdout = String::from_utf8_lossy(&start_result.stdout);
let stderr = String::from_utf8_lossy(&start_result.stderr);
warn!(
"service may not have started:\n stdout: {}\n stderr: {}",
stdout.trim(),
stderr.trim()
);
}
}
println!();
println!("✅ Service '{}' installed successfully!", name);
println!();
println!(" Commands:");
println!(" sc start {} — start the service", name);
println!(" sc stop {} — stop the service", name);
println!(" sc query {} — check status", name);
println!(" sc delete {} — uninstall the service", name);
println!();
println!(" Note: The service runs as LocalSystem. If screen capture");
println!(" doesn't work, you may need to configure it to run as your");
println!(" user account: sc config {} obj= \".\\USERNAME\" pwd= PASSWORD", name);
println!();
Ok(())
}
// ── Uninstall ──────────────────────────────────────────────────────────────────
/// Uninstall the system service.
fn uninstall_service(name: &str) -> Result<()> {
#[cfg(unix)]
{
uninstall_service_unix(name)
}
#[cfg(windows)]
{
uninstall_service_windows(name)
}
}
#[cfg(unix)]
fn uninstall_service_unix(name: &str) -> Result<()> {
let unit_path = format!("/etc/systemd/system/{}.service", name);
// Check if the unit file exists.
if !std::path::Path::new(&unit_path).exists() {
anyhow::bail!("service '{}' not found (no unit file at {})", name, unit_path);
}
info!("stopping service '{}'...", name);
let _ = std::process::Command::new("systemctl")
.args(["stop", name])
.status();
info!("disabling service '{}'...", name);
let _ = std::process::Command::new("systemctl")
.args(["disable", name])
.status();
info!("removing unit file '{}'...", unit_path);
std::fs::remove_file(&unit_path)
.context(format!("failed to remove unit file '{}'. Do you need sudo?", unit_path))?;
info!("reloading systemd daemon...");
let _ = std::process::Command::new("systemctl")
.arg("daemon-reload")
.status();
// Reset failed state if any.
let _ = std::process::Command::new("systemctl")
.args(["reset-failed", name])
.status();
println!("✅ Service '{}' uninstalled successfully!", name);
Ok(())
}
#[cfg(windows)]
fn uninstall_service_windows(name: &str) -> Result<()> {
use std::process::Command;
// Stop the service first.
info!("stopping service '{}'...", name);
let _ = Command::new("sc").args(["stop", name]).output();
// Delete the service.
info!("deleting service '{}'...", name);
let result = Command::new("sc")
.args(["delete", name])
.output()
.context("failed to execute sc delete. Are you running as Administrator?")?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
anyhow::bail!("sc delete failed: {}", stderr.trim());
}
println!("✅ Service '{}' uninstalled successfully!", name);
Ok(())
}
// ── Start ──────────────────────────────────────────────────────────────────────
/// Start the installed service.
fn start_service(name: &str) -> Result<()> {
#[cfg(unix)]
{
let result = std::process::Command::new("systemctl")
.args(["start", name])
.status()
.context(format!("failed to start service '{}'. Do you need sudo?", name))?;
if result.success() {
println!("✅ Service '{}' started.", name);
} else {
anyhow::bail!("failed to start service '{}'", name);
}
}
#[cfg(windows)]
{
let result = std::process::Command::new("sc")
.args(["start", name])
.output()
.context("failed to start service. Are you running as Administrator?")?;
if result.status.success() {
println!("✅ Service '{}' started.", name);
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
anyhow::bail!("failed to start service '{}': {}", name, stderr.trim());
}
}
Ok(())
}
// ── Stop ───────────────────────────────────────────────────────────────────────
/// Stop the running service.
fn stop_service(name: &str) -> Result<()> {
#[cfg(unix)]
{
let result = std::process::Command::new("systemctl")
.args(["stop", name])
.status()
.context(format!("failed to stop service '{}'. Do you need sudo?", name))?;
if result.success() {
println!("✅ Service '{}' stopped.", name);
} else {
anyhow::bail!("failed to stop service '{}'", name);
}
}
#[cfg(windows)]
{
let result = std::process::Command::new("sc")
.args(["stop", name])
.output()
.context("failed to stop service. Are you running as Administrator?")?;
if result.status.success() {
println!("✅ Service '{}' stopped.", name);
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
anyhow::bail!("failed to stop service '{}': {}", name, stderr.trim());
}
}
Ok(())
}
// ── Restart ────────────────────────────────────────────────────────────────────
/// Restart the running service.
fn restart_service(name: &str) -> Result<()> {
#[cfg(unix)]
{
let result = std::process::Command::new("systemctl")
.args(["restart", name])
.status()
.context(format!("failed to restart service '{}'. Do you need sudo?", name))?;
if result.success() {
println!("✅ Service '{}' restarted.", name);
} else {
anyhow::bail!("failed to restart service '{}'", name);
}
}
#[cfg(windows)]
{
let _ = stop_service(name);
// Brief pause to let the service fully stop.
std::thread::sleep(std::time::Duration::from_secs(2));
start_service(name)?;
}
Ok(())
}
// ── Status ─────────────────────────────────────────────────────────────────────
/// Check and display the service status.
fn status_service(name: &str) -> Result<()> {
#[cfg(unix)]
{
let result = std::process::Command::new("systemctl")
.args(["status", name])
.status();
match result {
Ok(status) => {
// systemctl status returns 0 for active, 3 for inactive, etc.
// Let's also run is-active for a cleaner check.
let active = std::process::Command::new("systemctl")
.args(["is-active", name])
.output();
match active {
Ok(output) => {
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if state == "active" {
println!("✅ Service '{}' is running.", name);
} else if state == "inactive" {
println!("⏹ Service '{}' is stopped.", name);
} else if state == "failed" {
println!("❌ Service '{}' has failed.", name);
println!(" Run 'systemctl status {}' for details.", name);
} else {
println!("❓ Service '{}' status: {}", name, state);
}
}
Err(_) => {
println!("❓ Could not determine service status (exit code: {:?})", status.code());
}
}
}
Err(e) => {
anyhow::bail!("failed to query service '{}': {}", name, e);
}
}
}
#[cfg(windows)]
{
let result = std::process::Command::new("sc")
.args(["query", name])
.output()
.context("failed to query service")?;
if result.status.success() {
let stdout = String::from_utf8_lossy(&result.stdout);
println!("{}", stdout.trim());
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
anyhow::bail!("failed to query service '{}': {}", name, stderr.trim());
}
}
Ok(())
}
// ── Helpers ────────────────────────────────────────────────────────────────────
/// Detect display-related environment variables for embedding in the systemd unit.
///
/// These variables are needed for screen capture and input injection to work
/// from within a systemd service (which otherwise runs with a minimal environment).
#[cfg(unix)]
fn detect_display_env() -> Vec<(String, String)> {
let mut envs = Vec::new();
// X11 display server.
if let Ok(val) = std::env::var("DISPLAY") {
envs.push(("DISPLAY".into(), val));
}
// Xauthority file for X11 authentication.
if let Ok(val) = std::env::var("XAUTHORITY") {
envs.push(("XAUTHORITY".into(), val));
}
// Wayland display server.
if let Ok(val) = std::env::var("WAYLAND_DISPLAY") {
envs.push(("WAYLAND_DISPLAY".into(), val));
}
// Wayland runtime directory (needed for Wayland socket access).
if let Ok(val) = std::env::var("XDG_RUNTIME_DIR") {
envs.push(("XDG_RUNTIME_DIR".into(), val));
}
// D-Bus session bus (needed for some desktop interactions).
if let Ok(val) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
envs.push(("DBUS_SESSION_BUS_ADDRESS".into(), val));
}
if envs.is_empty() {
log::warn!("no display environment variables detected — the service may not be able to capture the screen");
log::warn!("run this from a desktop session, or manually set DISPLAY=:0 in the unit file");
} else {
info!("captured display environment: {:?}", envs.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>());
}
envs
}