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:
Andy
2025-11-25 20:14:48 +00:00
parent 2d4bf140fa
commit 965482a1e4
27 changed files with 6678 additions and 98 deletions

View 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")