- Add Gluetun dynamic VPN-to-HTTP proxy provider - Add remote services and authentication system - Add country code utilities - Add Docker binary detection - Update proxy providers
246 lines
8.2 KiB
Python
246 lines
8.2 KiB
Python
"""Remote service discovery and management."""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import requests
|
|
|
|
from unshackle.core.config import config
|
|
from unshackle.core.remote_service import RemoteService
|
|
|
|
log = logging.getLogger("RemoteServices")
|
|
|
|
|
|
class RemoteServiceManager:
|
|
"""
|
|
Manages discovery and registration of remote services.
|
|
|
|
This class connects to configured remote unshackle servers,
|
|
discovers available services, and creates RemoteService instances
|
|
that can be used like local services.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the remote service manager."""
|
|
self.remote_services: Dict[str, type] = {}
|
|
self.remote_configs: List[Dict[str, Any]] = []
|
|
|
|
def discover_services(self) -> None:
|
|
"""
|
|
Discover services from all configured remote servers.
|
|
|
|
Reads the remote_services configuration, connects to each server,
|
|
retrieves available services, and creates RemoteService classes
|
|
for each discovered service.
|
|
"""
|
|
if not config.remote_services:
|
|
log.debug("No remote services configured")
|
|
return
|
|
|
|
log.info(f"Discovering services from {len(config.remote_services)} remote server(s)...")
|
|
|
|
for remote_config in config.remote_services:
|
|
try:
|
|
self._discover_from_server(remote_config)
|
|
except Exception as e:
|
|
log.error(f"Failed to discover services from {remote_config.get('url')}: {e}")
|
|
continue
|
|
|
|
log.info(f"Discovered {len(self.remote_services)} remote service(s)")
|
|
|
|
def _discover_from_server(self, remote_config: Dict[str, Any]) -> None:
|
|
"""
|
|
Discover services from a single remote server.
|
|
|
|
Args:
|
|
remote_config: Configuration for the remote server
|
|
(must contain 'url' and 'api_key')
|
|
"""
|
|
url = remote_config.get("url", "").rstrip("/")
|
|
api_key = remote_config.get("api_key", "")
|
|
server_name = remote_config.get("name", url)
|
|
|
|
if not url:
|
|
log.warning("Remote service configuration missing 'url', skipping")
|
|
return
|
|
|
|
if not api_key:
|
|
log.warning(f"Remote service {url} missing 'api_key', skipping")
|
|
return
|
|
|
|
log.info(f"Connecting to remote server: {server_name}")
|
|
|
|
try:
|
|
# Query the remote server for available services
|
|
response = requests.get(
|
|
f"{url}/api/remote/services",
|
|
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
|
timeout=10,
|
|
)
|
|
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
if data.get("status") != "success" or "services" not in data:
|
|
log.error(f"Invalid response from {url}: {data}")
|
|
return
|
|
|
|
services = data["services"]
|
|
log.info(f"Found {len(services)} service(s) on {server_name}")
|
|
|
|
# Create RemoteService classes for each service
|
|
for service_info in services:
|
|
self._register_remote_service(url, api_key, service_info, server_name)
|
|
|
|
except requests.RequestException as e:
|
|
log.error(f"Failed to connect to remote server {url}: {e}")
|
|
raise
|
|
|
|
def _register_remote_service(
|
|
self, remote_url: str, api_key: str, service_info: Dict[str, Any], server_name: str
|
|
) -> None:
|
|
"""
|
|
Register a remote service as a local service class.
|
|
|
|
Args:
|
|
remote_url: Base URL of the remote server
|
|
api_key: API key for authentication
|
|
service_info: Service metadata from the remote server
|
|
server_name: Friendly name of the remote server
|
|
"""
|
|
service_tag = service_info.get("tag")
|
|
if not service_tag:
|
|
log.warning(f"Service info missing 'tag': {service_info}")
|
|
return
|
|
|
|
# Create a unique tag for the remote service
|
|
# Use "remote_" prefix to distinguish from local services
|
|
remote_tag = f"remote_{service_tag}"
|
|
|
|
# Check if this remote service is already registered
|
|
if remote_tag in self.remote_services:
|
|
log.debug(f"Remote service {remote_tag} already registered, skipping")
|
|
return
|
|
|
|
log.info(f"Registering remote service: {remote_tag} from {server_name}")
|
|
|
|
# Create a dynamic class that inherits from RemoteService
|
|
# This allows us to create instances with the cli() method for Click integration
|
|
class DynamicRemoteService(RemoteService):
|
|
"""Dynamically created remote service class."""
|
|
|
|
def __init__(self, ctx, **kwargs):
|
|
super().__init__(
|
|
ctx=ctx,
|
|
remote_url=remote_url,
|
|
api_key=api_key,
|
|
service_tag=service_tag,
|
|
service_metadata=service_info,
|
|
**kwargs,
|
|
)
|
|
|
|
@staticmethod
|
|
def cli():
|
|
"""CLI method for Click integration."""
|
|
import click
|
|
|
|
# Create a dynamic Click command for this service
|
|
@click.command(
|
|
name=remote_tag,
|
|
short_help=f"Remote: {service_info.get('help', service_tag)}",
|
|
help=service_info.get("help", f"Remote service for {service_tag}"),
|
|
)
|
|
@click.argument("title", type=str, required=False)
|
|
@click.option("-q", "--query", type=str, help="Search query")
|
|
@click.pass_context
|
|
def remote_service_cli(ctx, title=None, query=None, **kwargs):
|
|
# Combine title and kwargs
|
|
params = {**kwargs}
|
|
if title:
|
|
params["title"] = title
|
|
if query:
|
|
params["query"] = query
|
|
|
|
return DynamicRemoteService(ctx, **params)
|
|
|
|
return remote_service_cli
|
|
|
|
# Set class name for better debugging
|
|
DynamicRemoteService.__name__ = remote_tag
|
|
DynamicRemoteService.__module__ = "unshackle.remote_services"
|
|
|
|
# Set GEOFENCE and ALIASES
|
|
if "geofence" in service_info:
|
|
DynamicRemoteService.GEOFENCE = tuple(service_info["geofence"])
|
|
if "aliases" in service_info:
|
|
# Add "remote_" prefix to aliases too
|
|
DynamicRemoteService.ALIASES = tuple(f"remote_{alias}" for alias in service_info["aliases"])
|
|
|
|
# Register the service
|
|
self.remote_services[remote_tag] = DynamicRemoteService
|
|
|
|
def get_service(self, tag: str) -> Optional[type]:
|
|
"""
|
|
Get a remote service class by tag.
|
|
|
|
Args:
|
|
tag: Service tag (e.g., "remote_DSNP")
|
|
|
|
Returns:
|
|
RemoteService class or None if not found
|
|
"""
|
|
return self.remote_services.get(tag)
|
|
|
|
def get_all_services(self) -> Dict[str, type]:
|
|
"""
|
|
Get all registered remote services.
|
|
|
|
Returns:
|
|
Dictionary mapping service tags to RemoteService classes
|
|
"""
|
|
return self.remote_services.copy()
|
|
|
|
def get_service_path(self, tag: str) -> Optional[Path]:
|
|
"""
|
|
Get the path for a remote service.
|
|
|
|
Remote services don't have local paths, so this returns None.
|
|
This method exists for compatibility with the Services interface.
|
|
|
|
Args:
|
|
tag: Service tag
|
|
|
|
Returns:
|
|
None (remote services have no local path)
|
|
"""
|
|
return None
|
|
|
|
|
|
# Global instance
|
|
_remote_service_manager: Optional[RemoteServiceManager] = None
|
|
|
|
|
|
def get_remote_service_manager() -> RemoteServiceManager:
|
|
"""
|
|
Get the global RemoteServiceManager instance.
|
|
|
|
Creates the instance on first call and discovers services.
|
|
|
|
Returns:
|
|
RemoteServiceManager instance
|
|
"""
|
|
global _remote_service_manager
|
|
|
|
if _remote_service_manager is None:
|
|
_remote_service_manager = RemoteServiceManager()
|
|
try:
|
|
_remote_service_manager.discover_services()
|
|
except Exception as e:
|
|
log.error(f"Failed to discover remote services: {e}")
|
|
|
|
return _remote_service_manager
|
|
|
|
|
|
__all__ = ("RemoteServiceManager", "get_remote_service_manager")
|