Files
scientific-surfing/scientific_surfing/service_manager.py

612 lines
22 KiB
Python

"""
Cross-platform service manager for installing, uninstalling, and restarting services.
This module provides a unified interface for managing system services across
Windows, Linux, and macOS operating systems.
"""
from dataclasses import asdict
import os
import platform
import subprocess
import ctypes
import sys
from pathlib import Path
from typing import Optional, Protocol
from pydantic import BaseModel, Field, field_validator
from scientific_surfing.storage import StorageManager
class ServiceConfig(BaseModel):
"""Configuration model for service installation."""
name: str = Field(..., description="Name of the service")
executable_path: Path = Field(..., description="Path to the service executable")
description: Optional[str] = Field(None, description="Service description")
args: Optional[str] = Field(None, description="Command line arguments for the service")
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate service name format."""
if not v or not v.strip():
raise ValueError("Service name cannot be empty")
if ' ' in v.strip():
raise ValueError("Service name cannot contain spaces")
return v.strip()
@field_validator('executable_path')
@classmethod
def validate_executable_path(cls, v: Path) -> Path:
"""Validate executable path exists and is executable."""
if not v.exists():
raise ValueError(f"Executable path does not exist: {v}")
if not v.is_file():
raise ValueError(f"Path is not a file: {v}")
if not os.access(v, os.X_OK):
raise ValueError(f"File is not executable: {v}")
return v.resolve()
class ServiceManagerProtocol(Protocol):
"""Protocol defining the interface for service managers."""
config_dir: str
def __init__(self, config_dir: str):
self.config_dir = config_dir
def install(self, config: ServiceConfig) -> None:
"""Install a service with the given configuration."""
...
def uninstall(self, name: str) -> None:
"""Uninstall a service by name."""
...
def start(self, name: str) -> None:
"""Start a service by name."""
...
def stop(self, name: str) -> None:
"""Stop a service by name."""
...
def restart(self, name: str) -> None:
"""Restart a service by name."""
...
class WindowsServiceManager(ServiceManagerProtocol):
"""Windows-specific service manager using sc.exe."""
@staticmethod
def _is_admin() -> bool:
"""Check if the current process has administrator privileges."""
try:
return ctypes.windll.shell32.IsUserAnAdmin()
except:
return False
@staticmethod
def _format_error_message(operation: str, service_name: str, error: str) -> str:
"""Format a user-friendly error message for service operations."""
if "access is denied" in error.lower() or "5" in error:
return (
f"Failed to {operation} service '{service_name}': Access denied.\n\n"
f"Administrator privileges are required to {operation} Windows services.\n\n"
f"Solutions:\n"
f"• Run this script as administrator (right-click → 'Run as administrator')\n"
f"• Open an elevated Command Prompt and run the command manually\n"
f"• Ensure User Account Control (UAC) is enabled and accept the prompt"
)
elif "1060" in error:
return (
f"Failed to {operation} service '{service_name}': Service not found.\n\n"
f"The specified service does not exist. Check the service name and try again."
)
elif "1062" in error and operation in ["stop", "restart"]:
return (
f"Service '{service_name}' is not currently running.\n\n"
f"This is not an error - the service was already stopped."
)
else:
return f"Failed to {operation} service '{service_name}': {error}"
@staticmethod
def _run_as_admin(cmd: list[str], description: str = "Service Management") -> None:
"""Run a command with administrator privileges using UAC elevation."""
if WindowsServiceManager._is_admin():
# Already running as admin, execute directly
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to {description.lower()}: {result.stderr}")
else:
# Provide clear instructions for manual elevation
command_str = " ".join(cmd)
raise RuntimeError(
f"Administrator privileges required to {description.lower()}.\n\n"
f"Command: {command_str}\n\n"
f"Please do one of the following:\n"
f"1. Run this script as administrator (right-click → 'Run as administrator')\n"
f"2. Open an elevated Command Prompt and run: {command_str}\n"
f"3. Accept the UAC prompt when it appears"
)
def install(self, config: ServiceConfig) -> None:
"""Install a Windows service using Python service wrapper."""
import tempfile
import json
from pathlib import Path
from .windows_service_wrapper import WindowServiceConfig
# Check for pywin32 dependency
try:
import win32serviceutil
import win32service
import win32event
import servicemanager
except ImportError:
raise RuntimeError(
"pywin32 package is required for Windows service support. "
"Install it with: pip install pywin32"
)
# Create service configuration for the wrapper
windows_service_config = WindowServiceConfig(
name = "mihomo",
display_name = "Scentific Surfing Service",
description = "Surfing the Internal scientifically",
working_dir = str(config.executable_path.parent),
bin_path = str(config.executable_path),
args = config.args or '',
)
# Create permanent config file in a stable location
config_dir = self.config_dir
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / f"{config.name}_config.json"
with open(config_file, 'w') as f:
json.dump(asdict(windows_service_config), f, indent=2)
# Path to the wrapper script
wrapper_script = Path(__file__).parent / "windows_service_wrapper.py"
if not wrapper_script.exists():
raise RuntimeError(f"Windows service wrapper not found: {wrapper_script}")
# Build the command to run the Python service wrapper using the proper service class
python_exe = sys.executable
service_cmd = [
python_exe, str(wrapper_script)
]
# Quote the path to handle spaces properly
escaped_cmd = " ".join(service_cmd)
if ' ' in escaped_cmd:
escaped_cmd = f'"{escaped_cmd}"'
# Create service using sc.exe with the Python wrapper - using proper command format
python_exe = sys.executable
wrapper_script = Path(__file__).parent / "windows_service_wrapper.py"
# Build the command with proper quoting for Windows
service_cmd = f'"{python_exe}" "{wrapper_script}" "{config_file}"'
# Create service using sc.exe
cmd = [
"sc", "create", config.name,
"binPath=", service_cmd,
"start=", "auto"
]
if config.description:
cmd.extend(["DisplayName=", config.description])
try:
self._run_as_admin(cmd, f"install service '{config.name}'")
except RuntimeError as e:
# Clean up config file on failure
try:
config_file.unlink(missing_ok=True)
except:
pass
raise RuntimeError(self._format_error_message("install", config.name, str(e)))
def uninstall(self, name: str) -> None:
"""Uninstall a Windows service."""
import json
from pathlib import Path
try:
# Stop the service first
try:
self._run_as_admin(["sc", "stop", name], f"stop service '{name}'")
except:
# Ignore if service is not running
pass
# Delete the service
self._run_as_admin(["sc", "delete", name], f"uninstall service '{name}'")
# Clean up configuration file
config_dir = Path.home() / ".scientific_surfing" / "service_configs"
config_file = config_dir / f"{name}_config.json"
try:
config_file.unlink(missing_ok=True)
# Remove directory if empty
try:
config_dir.rmdir()
except OSError:
pass # Directory not empty
except:
pass # Ignore cleanup errors
except RuntimeError as e:
raise RuntimeError(self._format_error_message("uninstall", name, str(e)))
def start(self, name: str) -> None:
"""Start a Windows service."""
try:
self._run_as_admin(["sc", "start", name], f"start service '{name}'")
except RuntimeError as e:
raise RuntimeError(self._format_error_message("start", name, str(e)))
def stop(self, name: str) -> None:
"""Stop a Windows service."""
try:
self._run_as_admin(["sc", "stop", name], f"stop service '{name}'")
except RuntimeError as e:
raise RuntimeError(self._format_error_message("stop", name, str(e)))
def restart(self, name: str) -> None:
"""Restart a Windows service."""
try:
try:
self._run_as_admin(["sc", "stop", name], f"stop service '{name}'")
except RuntimeError as e:
# Ignore if service is not running (error 1062)
if "1062" not in str(e).lower():
raise
self._run_as_admin(["sc", "start", name], f"start service '{name}'")
except RuntimeError as e:
raise RuntimeError(self._format_error_message("restart", name, str(e)))
class LinuxServiceManager(ServiceManagerProtocol):
"""Linux-specific service manager using systemd."""
def _get_service_file_path(self, name: str) -> Path:
"""Get the path to the systemd service file."""
return Path("/etc/systemd/system") / f"{name}.service"
def _create_service_file(self, config: ServiceConfig) -> None:
"""Create a systemd service file."""
exec_start = f"{config.executable_path}"
if config.args:
exec_start += f" {config.args}"
service_content = f"""[Unit]
Description={config.description or config.name}
After=network.target
[Service]
Type=simple
ExecStart={exec_start}
Restart=always
RestartSec=10
User=root
[Install]
WantedBy=multi-user.target
"""
service_path = self._get_service_file_path(config.name)
try:
with open(service_path, 'w') as f:
f.write(service_content)
# Set appropriate permissions
os.chmod(service_path, 0o644)
except OSError as e:
raise RuntimeError(f"Failed to create service file: {e}")
def install(self, config: ServiceConfig) -> None:
"""Install a Linux service using systemd."""
self._create_service_file(config)
try:
# Reload systemd
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True, check=True)
# Enable and start the service
subprocess.run(["systemctl", "enable", config.name], capture_output=True, text=True, check=True)
subprocess.run(["systemctl", "start", config.name], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to install service: {e.stderr}")
def uninstall(self, name: str) -> None:
"""Uninstall a Linux service."""
try:
# Stop and disable the service
subprocess.run(["systemctl", "stop", name], capture_output=True)
subprocess.run(["systemctl", "disable", name], capture_output=True, text=True, check=True)
# Remove service file
service_path = self._get_service_file_path(name)
if service_path.exists():
service_path.unlink()
# Reload systemd
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to uninstall service: {e.stderr}")
def start(self, name: str) -> None:
"""Start a Linux service."""
try:
result = subprocess.run(["systemctl", "start", name], capture_output=True, text=True, check=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to start service: {result.stderr}")
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to start service: {e.stderr}")
def stop(self, name: str) -> None:
"""Stop a Linux service."""
try:
result = subprocess.run(["systemctl", "stop", name], capture_output=True, text=True, check=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to stop service: {result.stderr}")
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to stop service: {e.stderr}")
def restart(self, name: str) -> None:
"""Restart a Linux service."""
try:
result = subprocess.run(["systemctl", "restart", name], capture_output=True, text=True, check=True)
if result.returncode != 0:
raise RuntimeError(f"Failed to restart service: {result.stderr}")
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to restart service: {e.stderr}")
class MacOSServiceManager(ServiceManagerProtocol):
"""macOS-specific service manager using launchd."""
def _get_launchd_path(self, name: str) -> Path:
"""Get the path to the launchd plist file."""
return Path("/Library/LaunchDaemons") / f"com.{name}.plist"
def _create_launchd_plist(self, config: ServiceConfig) -> None:
"""Create a launchd plist file."""
program_args = [str(config.executable_path)]
if config.args:
program_args.extend(config.args.split())
program_args_xml = "\n".join([f" <string>{arg}</string>" for arg in program_args])
plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.{config.name}</string>
<key>ProgramArguments</key>
<array>
{program_args_xml}
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/{config.name}.log</string>
<key>StandardErrorPath</key>
<string>/var/log/{config.name}.error.log</string>
</dict>
</plist>
"""
plist_path = self._get_launchd_path(config.name)
try:
with open(plist_path, 'w') as f:
f.write(plist_content)
# Set appropriate permissions
os.chmod(plist_path, 0o644)
except OSError as e:
raise RuntimeError(f"Failed to create launchd plist: {e}")
def install(self, config: ServiceConfig) -> None:
"""Install a macOS service using launchd."""
self._create_launchd_plist(config)
try:
# Load the service
plist_path = self._get_launchd_path(config.name)
subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, text=True, check=True)
# Start the service
subprocess.run(["launchctl", "start", f"com.{config.name}"], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to install service: {e.stderr}")
def uninstall(self, name: str) -> None:
"""Uninstall a macOS service."""
try:
# Stop and unload the service
subprocess.run(["launchctl", "stop", f"com.{name}"], capture_output=True)
plist_path = self._get_launchd_path(name)
if plist_path.exists():
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True, check=True)
plist_path.unlink()
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to uninstall service: {e.stderr}")
def start(self, name: str) -> None:
"""Start a macOS service."""
try:
subprocess.run(["launchctl", "start", f"com.{name}"], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to start service: {e.stderr}")
def stop(self, name: str) -> None:
"""Stop a macOS service."""
try:
subprocess.run(["launchctl", "stop", f"com.{name}"], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to stop service: {e.stderr}")
def restart(self, name: str) -> None:
"""Restart a macOS service."""
try:
subprocess.run(["launchctl", "stop", f"com.{name}"], capture_output=True)
subprocess.run(["launchctl", "start", f"com.{name}"], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to restart service: {e.stderr}")
class ServiceManager:
"""Main service manager that delegates to platform-specific implementations."""
def __init__(self, config_dir: str) -> None:
"""Initialize the service manager with the appropriate platform implementation."""
system = platform.system().lower()
if system == "windows":
self._manager: ServiceManagerProtocol = WindowsServiceManager(config_dir)
elif system == "linux":
self._manager = LinuxServiceManager(config_dir)
elif system == "darwin":
self._manager = MacOSServiceManager(config_dir)
else:
raise RuntimeError(f"Unsupported operating system: {system}")
def install(self, name: str, executable_path: str, description: Optional[str] = None, args: Optional[str] = None) -> None:
"""
Install a service with the given name and executable path.
Args:
name: Name of the service to install
executable_path: Path to the service executable
description: Optional description for the service
args: Optional command line arguments for the service
Raises:
ValueError: If parameters are invalid
RuntimeError: If installation fails
"""
config = ServiceConfig(
name=name,
executable_path=Path(executable_path),
description=description,
args=args
)
self._manager.install(config)
def uninstall(self, name: str) -> None:
"""
Uninstall a service by name.
Args:
name: Name of the service to uninstall
Raises:
RuntimeError: If uninstallation fails
"""
if not name or not name.strip():
raise ValueError("Service name cannot be empty")
self._manager.uninstall(name.strip())
def start(self, name: str) -> None:
"""
Start a service by name.
Args:
name: Name of the service to start
Raises:
ValueError: If service name is invalid
RuntimeError: If start fails
"""
if not name or not name.strip():
raise ValueError("Service name cannot be empty")
self._manager.start(name.strip())
def stop(self, name: str) -> None:
"""
Stop a service by name.
Args:
name: Name of the service to stop
Raises:
ValueError: If service name is invalid
RuntimeError: If stop fails
"""
if not name or not name.strip():
raise ValueError("Service name cannot be empty")
self._manager.stop(name.strip())
def restart(self, name: str) -> None:
"""
Restart a service by name.
Args:
name: Name of the service to restart
Raises:
ValueError: If service name is invalid
RuntimeError: If restart fails
"""
if not name or not name.strip():
raise ValueError("Service name cannot be empty")
self._manager.restart(name.strip())
if __name__ == "__main__":
# Example usage
import sys
if len(sys.argv) < 3:
print("Usage: python service_manager.py <install|uninstall|start|stop|restart> <service_name> [executable_path] [description] [args]")
sys.exit(1)
action = sys.argv[1]
service_name = sys.argv[2]
storage = StorageManager()
service_manager = ServiceManager(storage.config_dir)
try:
if action == "install":
if len(sys.argv) < 4:
print("Error: install requires executable_path")
sys.exit(1)
executable_path = sys.argv[3]
description = sys.argv[4] if len(sys.argv) > 4 else None
args = sys.argv[5] if len(sys.argv) > 5 else None
service_manager.install(service_name, executable_path, description, args)
print(f"Service '{service_name}' installed successfully")
elif action == "uninstall":
service_manager.uninstall(service_name)
print(f"Service '{service_name}' uninstalled successfully")
elif action == "start":
service_manager.start(service_name)
print(f"Service '{service_name}' started successfully")
elif action == "stop":
service_manager.stop(service_name)
print(f"Service '{service_name}' stopped successfully")
elif action == "restart":
service_manager.restart(service_name)
print(f"Service '{service_name}' restarted successfully")
else:
print(f"Error: Unknown action '{action}'")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)