612 lines
22 KiB
Python
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) |