""" Nginx Proxy Manager API Client Provides interface to NPM REST API for proxy host and SSL certificate management. """ import httpx from typing import Optional, Dict, List, Any from datetime import datetime, timedelta from src.shared.logging import get_logger from src.shared.config import get_settings logger = get_logger(__name__) settings = get_settings() class NPMClient: """ HTTP client for Nginx Proxy Manager API Uses JWT Bearer token authentication with automatic token refresh. Tokens expire after ~24 hours. """ def __init__( self, base_url: Optional[str] = None, email: Optional[str] = None, password: Optional[str] = None, timeout: int = 30 ): """ Initialize NPM client Args: base_url: NPM base URL (default from settings) email: NPM admin email (default from settings) password: NPM admin password (default from settings) timeout: Request timeout in seconds """ self.base_url = (base_url or settings.npm_url).rstrip("/") self.email = email or settings.npm_email self.password = password or settings.npm_password self.timeout = timeout self._token: Optional[str] = None self._token_expires: Optional[datetime] = None if not self.email or not self.password: logger.warning("NPM credentials not configured") async def _ensure_token(self): """Ensure we have a valid token, refresh if needed""" if self._token and self._token_expires: # If token expires in less than 1 hour, refresh it if datetime.now() + timedelta(hours=1) < self._token_expires: return # Get new token await self._refresh_token() async def _refresh_token(self): """Get a new authentication token""" try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( f"{self.base_url}/api/tokens", json={ "identity": self.email, "secret": self.password } ) response.raise_for_status() data = response.json() self._token = data.get("token") # Assume 23-hour expiration to be safe self._token_expires = datetime.now() + timedelta(hours=23) logger.info("NPM token refreshed successfully") except Exception as e: logger.error(f"Failed to refresh NPM token: {e}") raise def _get_headers(self) -> Dict[str, str]: """Get request headers with authentication""" if not self._token: raise RuntimeError("No NPM token available. Call _ensure_token() first.") return { "Authorization": f"Bearer {self._token}", "Content-Type": "application/json" } async def health_check(self) -> bool: """ Check if NPM API is accessible Returns: True if accessible, False otherwise """ try: async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client: response = await client.get(f"{self.base_url}/api") # Accept any successful response (2xx) or redirect (3xx) as healthy # A redirect indicates the service is up and responding return 200 <= response.status_code < 400 except Exception as e: logger.error(f"NPM health check failed: {e}") return False async def get_proxy_hosts(self) -> List[Dict[str, Any]]: """ List all proxy hosts Returns: List of proxy host configurations """ await self._ensure_token() async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( f"{self.base_url}/api/nginx/proxy-hosts", headers=self._get_headers() ) response.raise_for_status() return response.json() async def get_proxy_host(self, host_id: int) -> Dict[str, Any]: """ Get details of a specific proxy host Args: host_id: Proxy host identifier Returns: Proxy host configuration """ await self._ensure_token() async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( f"{self.base_url}/api/nginx/proxy-hosts/{host_id}", headers=self._get_headers() ) response.raise_for_status() return response.json() async def create_proxy_host( self, domain_names: List[str], forward_host: str, forward_port: int, forward_scheme: str = "http", certificate_id: int = 0, ssl_forced: bool = False, block_exploits: bool = True, caching_enabled: bool = True, websocket_upgrade: bool = True, http2_support: bool = True, hsts_enabled: bool = True, advanced_config: str = "" ) -> Dict[str, Any]: """ Create a new proxy host Args: domain_names: List of domain names for this proxy forward_host: Target host to proxy to forward_port: Target port to proxy to forward_scheme: http or https certificate_id: SSL certificate ID (0 for none) ssl_forced: Force HTTPS redirect block_exploits: Enable exploit blocking caching_enabled: Enable response caching websocket_upgrade: Allow WebSocket upgrades http2_support: Enable HTTP/2 hsts_enabled: Enable HSTS headers advanced_config: Custom nginx configuration Returns: Created proxy host details """ await self._ensure_token() payload = { "domain_names": domain_names, "forward_scheme": forward_scheme, "forward_host": forward_host, "forward_port": forward_port, "certificate_id": certificate_id, "ssl_forced": ssl_forced, "block_exploits": block_exploits, "caching_enabled": caching_enabled, "allow_websocket_upgrade": websocket_upgrade, "http2_support": http2_support, "hsts_enabled": hsts_enabled, "hsts_subdomains": False, "advanced_config": advanced_config, "access_list_id": 0, "meta": {} } async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( f"{self.base_url}/api/nginx/proxy-hosts", headers=self._get_headers(), json=payload ) response.raise_for_status() return response.json() async def update_proxy_host( self, proxy_id: int, config: Dict[str, Any] ) -> Dict[str, Any]: """ Update an existing proxy host configuration Args: proxy_id: Proxy host ID to update config: Full proxy host configuration (get from get_proxy_host, modify, then update) Returns: Updated proxy host details """ await self._ensure_token() async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.put( f"{self.base_url}/api/nginx/proxy-hosts/{proxy_id}", headers=self._get_headers(), json=config ) if not response.is_success: logger.error(f"Update failed: {response.status_code}") logger.error(f"Response: {response.text}") response.raise_for_status() return response.json() async def enable_authentik_forward_auth( self, proxy_id: int, authentik_url: str = "http://authentik-server:9000" ) -> Dict[str, Any]: """ Enable Authentik forward authentication on a proxy host Args: proxy_id: Proxy host ID to update authentik_url: Authentik server URL (default: http://authentik-server:9000) Returns: Updated proxy host details """ # Get current config proxy_host = await self.get_proxy_host(proxy_id) # Authentik forward auth configuration auth_config = f"""# Authentik Forward Authentication # Send authentication requests to Authentik auth_request /outpost.goauthentik.io/auth/nginx; # Preserve authentication cookies auth_request_set $auth_cookie $upstream_http_set_cookie; add_header Set-Cookie $auth_cookie; # Get user information from Authentik auth_request_set $authentik_username $upstream_http_x_authentik_username; auth_request_set $authentik_groups $upstream_http_x_authentik_groups; auth_request_set $authentik_email $upstream_http_x_authentik_email; auth_request_set $authentik_name $upstream_http_x_authentik_name; auth_request_set $authentik_uid $upstream_http_x_authentik_uid; # Pass user info to backend proxy_set_header X-authentik-username $authentik_username; proxy_set_header X-authentik-groups $authentik_groups; proxy_set_header X-authentik-email $authentik_email; proxy_set_header X-authentik-name $authentik_name; proxy_set_header X-authentik-uid $authentik_uid; # On authentication failure, redirect to Authentik login error_page 401 = @authentik_proxy_signin; location @authentik_proxy_signin {{ internal; add_header Set-Cookie $auth_cookie; return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri; }} # Authentik authentication endpoint location /outpost.goauthentik.io {{ proxy_pass {authentik_url}/outpost.goauthentik.io; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header Host $host; }} """ # Update the advanced config proxy_host["advanced_config"] = auth_config # Remove read-only fields that NPM doesn't accept in updates readonly_fields = [ "id", "created_on", "modified_on", "owner", "owner_user_id", "certificate", "use_default_location", "ipv6", "meta", "nginx_online", "nginx_err", "access_list", "certificate_id" ] clean_config = {k: v for k, v in proxy_host.items() if k not in readonly_fields} # Ensure locations is an array (required field) if "locations" not in clean_config or clean_config["locations"] is None: clean_config["locations"] = [] # Update the proxy host return await self.update_proxy_host(proxy_id, clean_config) async def get_certificates(self) -> List[Dict[str, Any]]: """ List all SSL certificates Returns: List of certificate details """ await self._ensure_token() async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( f"{self.base_url}/api/nginx/certificates", headers=self._get_headers() ) response.raise_for_status() return response.json() async def create_certificate( self, domain_names: List[str], provider: str = "letsencrypt" ) -> Dict[str, Any]: """ Request a new SSL certificate from Let's Encrypt Args: domain_names: List of domains for the certificate provider: Certificate provider (default: letsencrypt) Returns: Certificate details """ await self._ensure_token() payload = { "provider": provider, "domain_names": domain_names, "meta": { "dns_challenge": False } } async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( f"{self.base_url}/api/nginx/certificates", headers=self._get_headers(), json=payload ) response.raise_for_status() return response.json() # Singleton instance _npm_client: Optional[NPMClient] = None def get_npm_client() -> NPMClient: """Get singleton NPM client instance""" global _npm_client if _npm_client is None: _npm_client = NPMClient() return _npm_client