feat: merge upstream dev branch
- 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
This commit is contained in:
245
unshackle/core/remote_services.py
Normal file
245
unshackle/core/remote_services.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user