from __future__ import annotations import hashlib import ipaddress import re from dataclasses import dataclass from datetime import UTC, datetime from typing import Any from urllib.parse import unquote, urlparse import httpx from ..core.config import settings from ..core.redaction import redact_message from ..services.exposure_preferences import runtime_nginx_access_list_checks_enabled from .base import BaseConnector @dataclass class _NpmConfig: url: str identity: str secret: str verify_tls: bool timeout_seconds: float class _NpmForgeRoutePermissionDenied(RuntimeError): """Fixed marker for a failed route-only permission preflight.""" def __init__(self) -> None: super().__init__("npm_forge_route_permission_denied") class _ForgeRouteNpmSession: """Route-only transport pinned to one verified NPM endpoint/config snapshot.""" def __init__(self, connector: NginxProxyManagerConnector, config: _NpmConfig): self._connector = connector self._config_snapshot = config self._token: str | None = None self.verified_tls = config.verify_tls is True self.endpoint_sha256 = _npm_endpoint_sha256(config.url) def _assert_current(self) -> None: try: current = self._connector._config() except Exception: # noqa: BLE001 - never surface config diagnostics. current = None if current != self._config_snapshot: raise RuntimeError("npm_configuration_changed") from None def _login(self) -> str: if self._token: return self._token self._assert_current() config = self._config_snapshot try: with httpx.Client( timeout=config.timeout_seconds, verify=True, follow_redirects=False, ) as client: response = client.post( f"{config.url}/api/tokens", json={"identity": config.identity, "secret": config.secret}, headers={"Accept": "application/json"}, follow_redirects=False, ) except (httpx.RequestError, ValueError): raise RuntimeError("npm_request_failed") from None if type(response.status_code) is not int or response.status_code != 200: raise RuntimeError("npm_login_failed") from None try: payload = response.json() except (ValueError, TypeError): raise RuntimeError("npm_invalid_response") from None token = payload.get("token") if isinstance(payload, dict) else None if not isinstance(token, str) or not token: raise RuntimeError("npm_login_failed") from None self._assert_current() self._token = token return token def _request(self, method: str, path: str, **kwargs: Any) -> Any: if (method, path) not in { ("GET", "/users/me"), ("GET", "/nginx/proxy-hosts"), ("POST", "/nginx/proxy-hosts"), }: raise RuntimeError("npm_forge_route_request_invalid") from None if path == "/users/me": if kwargs != {"params": {"expand": "permissions"}}: raise RuntimeError("npm_forge_route_request_invalid") from None elif method == "GET": if kwargs: raise RuntimeError("npm_forge_route_request_invalid") from None elif set(kwargs) != {"json"} or type(kwargs.get("json")) is not dict: raise RuntimeError("npm_forge_route_request_invalid") from None self._assert_current() token = self._login() self._assert_current() config = self._config_snapshot try: with httpx.Client( timeout=config.timeout_seconds, verify=True, follow_redirects=False, ) as client: response = client.request( method, f"{config.url}/api{path}", headers={"Accept": "application/json", "Authorization": f"Bearer {token}"}, follow_redirects=False, **kwargs, ) except (httpx.RequestError, ValueError): raise RuntimeError("npm_request_failed") from None if type(response.status_code) is not int or not ( response.status_code == 200 if method == "GET" else response.status_code in {200, 201} ): raise RuntimeError("npm_api_error") from None try: return response.json() except (ValueError, TypeError): raise RuntimeError("npm_invalid_response") from None def _has_forge_route_permissions(self) -> bool: try: user = self._request("GET", "/users/me", params={"expand": "permissions"}) except RuntimeError as exc: if str(exc) == "npm_configuration_changed": raise return False except Exception: # noqa: BLE001 - permission response details stay private. return False if type(user) is not dict: return False user_id = user.get("id") disabled = user.get("is_disabled") permissions = user.get("permissions") return bool( type(user_id) is int and user_id > 0 and type(disabled) is bool and disabled is False and type(permissions) is dict and type(permissions.get("visibility")) is str and permissions["visibility"] == "all" and type(permissions.get("proxy_hosts")) is str and permissions["proxy_hosts"] == "manage" ) def _require_forge_route_permissions(self) -> None: if not self._has_forge_route_permissions(): raise _NpmForgeRoutePermissionDenied() from None def list_forge_route_hosts(self) -> list[dict[str, Any]]: self._require_forge_route_permissions() payload = self._request("GET", "/nginx/proxy-hosts") return self._connector._parse_forge_route_hosts(payload) def create_forge_disabled_proxy_host(self, ownership_id: str) -> dict[str, Any]: if not re.fullmatch(r"froute-[0-9a-f]{32}", ownership_id): raise RuntimeError("npm_forge_route_ownership_invalid") from None self._require_forge_route_permissions() payload = self._request( "POST", "/nginx/proxy-hosts", json={ "domain_names": ["forge.example.com"], "forward_scheme": "http", "forward_host": "itworx-forge", "forward_port": 8080, "access_list_id": 0, "certificate_id": 0, "enabled": False, "ssl_forced": False, "http2_support": False, "hsts_enabled": False, "hsts_subdomains": False, "caching_enabled": False, "block_exploits": True, "allow_websocket_upgrade": False, "trust_forwarded_proto": False, "locations": [], "meta": {"appops_forge_route_id": ownership_id}, "advanced_config": "", }, ) if not isinstance(payload, dict): raise RuntimeError("npm_invalid_response") from None return payload def _as_float(value: Any, default: float) -> float: try: return float(value) except (TypeError, ValueError): return default def _as_int(value: Any, default: int = 0) -> int: try: return int(float(value)) except (TypeError, ValueError): return default def _as_list(value: Any) -> list[Any]: if isinstance(value, list): return value if value in {None, ''}: return [] return [value] def _safe_url(value: Any = None) -> str: raw = value if value is not None else settings.apis.get('NPM_URL') try: url = _validated_npm_url(raw) _verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS')) return url except RuntimeError: return '' def _validated_npm_url(value: Any) -> str: if not isinstance(value, str) or not value or len(value) > 2048: raise RuntimeError('npm_url_invalid') if value != value.strip() or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value): raise RuntimeError('npm_url_invalid') if '\\' in value or '?' in value or '#' in value: raise RuntimeError('npm_url_invalid') try: parsed = urlparse(value) port = parsed.port except ValueError: raise RuntimeError('npm_url_invalid') from None if parsed.scheme.lower() == 'http': raise RuntimeError('npm_https_required') if parsed.scheme.lower() != 'https' or not parsed.netloc: raise RuntimeError('npm_url_invalid') if '@' in parsed.netloc or '%' in parsed.netloc or parsed.username is not None or parsed.password is not None: raise RuntimeError('npm_url_invalid') if parsed.netloc.endswith(':'): raise RuntimeError('npm_url_invalid') hostname = parsed.hostname if not hostname or hostname.endswith('.') or (port is not None and not 1 <= port <= 65535): raise RuntimeError('npm_url_invalid') try: host_ascii = hostname.encode('idna').decode('ascii').lower() except UnicodeError: raise RuntimeError('npm_url_invalid') from None if ':' in host_ascii: try: ipaddress.IPv6Address(host_ascii) except ValueError: raise RuntimeError('npm_url_invalid') from None authority_host = f'[{host_ascii}]' else: if len(host_ascii) > 253 or any( not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label) for label in host_ascii.split('.') ): raise RuntimeError('npm_url_invalid') authority_host = host_ascii authority = authority_host + (f':{port}' if port is not None else '') path = parsed.path if path and ( not path.startswith('/') or not re.fullmatch(r"/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*(?:/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*)*", path) or re.search(r'%(?![0-9A-Fa-f]{2})', path) ): raise RuntimeError('npm_url_invalid') decoded_path = unquote(path) if '\\' in decoded_path or any(segment in {'.', '..'} for segment in decoded_path.split('/')): raise RuntimeError('npm_url_invalid') return f'https://{authority}{path}'.rstrip('/') def _npm_endpoint_sha256(value: str) -> str: """Hash the normalized HTTPS API base without persisting its URL.""" normalized = _validated_npm_url(value) return hashlib.sha256(normalized.encode("ascii")).hexdigest() def _verified_tls_setting(value: Any) -> bool: if value is None: return True if isinstance(value, bool): enabled = value elif isinstance(value, str): normalized = value.strip().lower() if normalized in {'1', 'true', 'yes', 'on'}: enabled = True elif normalized in {'0', 'false', 'no', 'off'}: enabled = False else: raise RuntimeError('npm_config_invalid') else: raise RuntimeError('npm_config_invalid') # noqa: TRY004 - use a fixed public connector reason if not enabled: raise RuntimeError('npm_tls_verification_required') return True def _parse_date(value: Any) -> datetime | None: if not value: return None text = str(value).strip() if not text: return None for candidate in (text, text.replace('Z', '+00:00')): try: parsed = datetime.fromisoformat(candidate) return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) except ValueError: continue for fmt in ('%Y-%m-%d', '%Y-%m-%d %H:%M:%S'): try: return datetime.strptime(text, fmt).replace(tzinfo=UTC) except ValueError: continue return None def _days_until(value: Any) -> int | None: parsed = _parse_date(value) if not parsed: return None return (parsed - datetime.now(UTC)).days def _response_items(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, list): return [item for item in payload if isinstance(item, dict)] if isinstance(payload, dict): data = payload.get('data') if isinstance(data, list): return [item for item in data if isinstance(item, dict)] if isinstance(payload.get('items'), list): return [item for item in payload['items'] if isinstance(item, dict)] return [] class NginxProxyManagerConnector(BaseConnector): name = 'Nginx Proxy Manager' def __init__(self): super().__init__() self._runtime_token: str | None = None self._read_cache: dict[str, dict[str, Any]] = {} self._credential_binding: tuple[str, str, str, bool] | None = None def _clear_runtime_context(self) -> None: self._runtime_token = None self._credential_binding = None self._read_cache.clear() def _memo(self, key: str, factory: Any) -> dict[str, Any]: try: config = self._config() except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) if config is None: return factory() if key not in self._read_cache: self._read_cache[key] = factory() return self._read_cache[key] def is_configured(self) -> bool: configured = bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET')) if not configured: self._clear_runtime_context() return configured def _config(self) -> _NpmConfig | None: if not self.is_configured(): self._clear_runtime_context() return None try: url = _validated_npm_url(settings.apis.get('NPM_URL')) verify_tls = _verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS')) identity = settings.apis.get('NPM_IDENTITY') secret = settings.apis.get('NPM_SECRET') if not isinstance(identity, str) or not identity.strip() or not isinstance(secret, str) or not secret: raise RuntimeError('missing_config') config = _NpmConfig( url=url, identity=identity, secret=secret, verify_tls=verify_tls, timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0), ) except RuntimeError: self._clear_runtime_context() raise binding = (config.url, config.identity, config.secret, config.verify_tls) if binding != self._credential_binding: self._runtime_token = None self._read_cache.clear() self._credential_binding = binding return config def forge_route_session(self) -> _ForgeRouteNpmSession | None: """Capture one strict endpoint/config for the entire Forge route operation.""" try: config = self._config() if config is None or config.verify_tls is not True: return None if _validated_npm_url(config.url) != config.url: return None return _ForgeRouteNpmSession(self, config) except Exception: # noqa: BLE001 - never expose connector settings or diagnostics. return None def _login(self) -> str: config = self._config() if not config: raise RuntimeError('missing_config') if self._runtime_token: return self._runtime_token try: self.clear_error() with httpx.Client( timeout=config.timeout_seconds, verify=config.verify_tls, follow_redirects=False, ) as client: response = client.post( f'{config.url}/api/tokens', json={'identity': config.identity, 'secret': config.secret}, headers={'Accept': 'application/json'}, follow_redirects=False, ) except httpx.RequestError: self.set_error('npm_request_failed') raise RuntimeError('npm_request_failed') from None if 300 <= response.status_code < 400: self.set_error('npm_redirect_denied') raise RuntimeError('npm_redirect_denied') if response.status_code in {400, 401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: self.set_error('npm_api_error') raise RuntimeError('npm_api_error') try: payload = response.json() except ValueError: self.set_error('npm_invalid_response') raise RuntimeError('npm_invalid_response') from None token = payload.get('token') if isinstance(payload, dict) else None if not token: self.set_error('auth_failed') raise RuntimeError('auth_failed') self._runtime_token = str(token) return self._runtime_token def _request(self, method: str, path: str, **kwargs: Any) -> Any: config = self._config() if not config: raise RuntimeError('missing_config') token = self._login() if self._credential_binding != (config.url, config.identity, config.secret, config.verify_tls): self.set_error('npm_configuration_changed') raise RuntimeError('npm_configuration_changed') try: with httpx.Client( timeout=config.timeout_seconds, verify=config.verify_tls, follow_redirects=False, ) as client: kwargs['follow_redirects'] = False response = client.request( method, f'{config.url}/api{path}', headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'}, **kwargs, ) except httpx.RequestError: self.set_error('npm_request_failed') raise RuntimeError('npm_request_failed') from None if 300 <= response.status_code < 400: self.set_error('npm_redirect_denied') raise RuntimeError('npm_redirect_denied') if response.status_code in {401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: self.set_error('npm_api_error') raise RuntimeError('npm_api_error') try: return response.json() except ValueError: self.set_error('npm_invalid_response') raise RuntimeError('npm_invalid_response') from None def test_connection(self) -> dict[str, Any]: if not self.is_configured(): return {'status': 'missing_config', 'reason': 'missing_config'} try: self._login() hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100})) except RuntimeError as exc: reason = redact_message(str(exc)) if reason.startswith('auth_failed'): return {'status': 'auth_failed', 'reason': reason} if reason == 'missing_config': return {'status': 'missing_config', 'reason': reason} return {'status': 'unreachable', 'reason': reason} return {'status': 'connected', 'proxyHostCount': len(hosts)} @staticmethod def _parse_forge_route_hosts(payload: Any) -> list[dict[str, Any]]: if type(payload) is not list or len(payload) > 1000 or any(type(item) is not dict for item in payload): raise RuntimeError('npm_forge_route_inventory_invalid') from None return payload def health(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(proxyHostCount=0, certificateCount=0) result = self.test_connection() status = str(result.get('status', 'error')) certs = self.certificates() if status == 'connected' else {'items': []} return { 'success': status == 'connected', 'status': status, 'message': 'Nginx Proxy Manager connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), 'url': _safe_url(), 'proxyHostCount': _as_int(result.get('proxyHostCount')), 'certificateCount': len(certs.get('items', [])) if isinstance(certs, dict) else 0, 'data': {}, } def proxy_hosts(self) -> dict[str, Any]: return self._memo('proxy_hosts', self._proxy_hosts_uncached) def _proxy_hosts_uncached(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0) try: items = [self._map_proxy_host(item) for item in _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100}))] except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) return {'success': True, 'status': 'ok', 'message': 'proxy_hosts_ok', 'total': len(items), 'items': items, 'data': {'items': items}} def certificates(self) -> dict[str, Any]: return self._memo('certificates', self._certificates_uncached) def _certificates_uncached(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0) try: items = [self._map_certificate(item) for item in _response_items(self._request('GET', '/nginx/certificates', params={'limit': 100}))] except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) return {'success': True, 'status': 'ok', 'message': 'certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items}} def expiring_certificates(self, days: int = 30) -> dict[str, Any]: certs = self.certificates() items = [ item for item in certs.get('items', []) if item.get('isExpired') or (item.get('expiresInDays') is not None and int(item.get('expiresInDays')) <= days) ] return {**certs, 'message': 'expiring_certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items, 'days': days}} def access_lists(self) -> dict[str, Any]: return self._memo('access_lists', self._access_lists_uncached) def _access_lists_uncached(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0) try: items = [self._map_access_list(item) for item in _response_items(self._request('GET', '/nginx/access-lists', params={'limit': 100}))] except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) return {'success': True, 'status': 'ok', 'message': 'access_lists_ok', 'total': len(items), 'items': items, 'data': {'items': items}} def validate_managed_access_list(self, access_list_id: int) -> dict[str, Any]: """Read-only verification for a trusted managed-host access list. Expanded authorization entries are inspected in memory, then discarded; the result contains only the trusted numeric identifier and status. """ if not self.is_configured(): return self._missing(reason='missing_config') if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 1 <= access_list_id <= 2_147_483_647: return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'} try: access_lists = _response_items(self._request( 'GET', '/nginx/access-lists', params={'limit': 1000, 'expand': 'items,clients'}, )) except RuntimeError as exc: return self._error_payload(exc) matches = [item for item in access_lists if _as_int(item.get('id')) == access_list_id] if ( len(matches) != 1 or matches[0].get('satisfy_any', matches[0].get('satisfyAny')) is not False or not isinstance(matches[0].get('items'), list) or not matches[0]['items'] ): return {'success': False, 'status': 'blocked', 'message': 'required_access_list_unavailable'} return { 'success': True, 'status': 'verified', 'message': 'required_access_list_verified', 'accessListId': access_list_id, } def exposure_summary(self) -> dict[str, Any]: access_list_checks_enabled = runtime_nginx_access_list_checks_enabled() hosts = self.proxy_hosts() certs = self.certificates() access_lists = self.access_lists() if access_list_checks_enabled else {} if not hosts.get('success'): return {**hosts, 'data': {'warnings': []}} host_items = hosts.get('items', []) cert_items = certs.get('items', []) if certs.get('success') else [] cert_by_id = {item.get('id'): item for item in cert_items} warnings = self._build_warnings(host_items, cert_by_id, nginx_access_list_checks_enabled=access_list_checks_enabled) summary = { 'totalProxyHosts': len(host_items), 'enabledProxyHosts': sum(1 for item in host_items if item.get('enabled')), 'publicHosts': sum(1 for item in host_items if item.get('enabled')), 'hostsWithoutCertificate': sum(1 for item in host_items if item.get('enabled') and not item.get('certificateId')), 'hostsWithExpiredCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpired')), 'hostsWithExpiringCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpiringSoon')), 'hostsWithoutAccessList': sum(1 for item in host_items if item.get('enabled') and not item.get('accessListId')), 'hostsWithoutSslForce': sum(1 for item in host_items if item.get('enabled') and not item.get('sslForced')), 'hostsWithWebsocket': sum(1 for item in host_items if item.get('websocketSupport')), 'accessLists': access_lists.get('total', 0), 'nginxAccessListChecksEnabled': access_list_checks_enabled, 'warnings': warnings, } return {'success': True, 'status': 'ok', 'message': 'exposure_summary_ok', **summary, 'data': summary} def host_reachability(self) -> dict[str, Any]: hosts = self.proxy_hosts() if not hosts.get('success'): return {**hosts, 'data': {'checks': []}} checks = [ { 'hostId': item.get('id'), 'domains': item.get('domainNames', []), 'target': f"{item.get('forwardScheme')}://{item.get('forwardHost')}:{item.get('forwardPort')}", 'status': 'metadata_only', 'message': 'V1 does not perform active target scans by default.', } for item in hosts.get('items', []) ] return {'success': True, 'status': 'metadata_only', 'message': 'host_reachability_metadata_only', 'checks': checks, 'data': {'checks': checks}} def security_warnings(self) -> dict[str, Any]: hosts = self.proxy_hosts() certs = self.certificates() if not hosts.get('success'): return {**hosts, 'items': [], 'total': 0, 'data': {'items': []}} cert_by_id = {item.get('id'): item for item in certs.get('items', [])} if certs.get('success') else {} items = self._build_warnings(hosts.get('items', []), cert_by_id, nginx_access_list_checks_enabled=runtime_nginx_access_list_checks_enabled()) severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0} status = 'warning' if items else 'ok' return { 'success': True, 'status': status, 'message': 'security_warnings_detected' if items else 'security_warnings_ok', 'total': len(items), 'items': sorted(items, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True), 'data': {'items': items}, } def ensure_managed_proxy_host( self, *, hostname: str, forward_host: str, forward_port: int, certificate_mode: str, force_ssl: bool, http2: bool, upload_limit_mb: int, read_timeout_seconds: int, ownership_id: str | None = None, access_list_id: int = 0, ) -> dict[str, Any]: """Create or verify one deployment-owned proxy host. This is intentionally not a general NPM mutation method. Every value is derived from DeploymentPlanV1 and this refuses to take over a host that does not already point at the exact managed container. """ if not self.is_configured(): return self._missing(reason='missing_config') if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?', hostname): return {'success': False, 'status': 'invalid', 'message': 'invalid_hostname'} if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,62}', forward_host): return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_host'} if not 1 <= int(forward_port) <= 65535: return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_port'} if certificate_mode not in {'existing', 'managed'}: return {'success': False, 'status': 'invalid', 'message': 'invalid_certificate_mode'} if not force_ssl: return {'success': False, 'status': 'invalid', 'message': 'managed_proxy_requires_force_ssl'} if ownership_id is not None and not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id): return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'} if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 0 <= access_list_id <= 2_147_483_647: return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'} try: if access_list_id: access_list = self.validate_managed_access_list(access_list_id) if not access_list.get('success'): return access_list hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000})) exact = [ item for item in hosts if hostname in {str(value).strip().lower() for value in _as_list(item.get('domain_names') or item.get('domainNames'))} ] if len(exact) > 1: return {'success': False, 'status': 'conflict', 'message': 'multiple_hostname_owners'} if exact: existing = exact[0] same_target = ( str(existing.get('forward_host') or existing.get('forwardHost') or '') == forward_host and _as_int(existing.get('forward_port') or existing.get('forwardPort')) == int(forward_port) ) if not same_target: return {'success': False, 'status': 'conflict', 'message': 'hostname_owned_by_different_target'} metadata = existing.get('meta') if isinstance(existing.get('meta'), dict) else {} if ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id: return {'success': False, 'status': 'conflict', 'message': 'hostname_is_not_owned_by_this_enrollment'} hardened = ( bool(existing.get('ssl_forced') or existing.get('sslForced')) and bool(existing.get('http2_support') or existing.get('http2Support')) and bool(existing.get('block_exploits') or existing.get('blockExploits')) and not bool(existing.get('caching_enabled') or existing.get('cachingEnabled')) and not bool(existing.get('allow_websocket_upgrade') or existing.get('websocketSupport')) and bool(existing.get('certificate_id') or existing.get('certificateId')) and _as_int(existing.get('access_list_id') or existing.get('accessListId')) == access_list_id ) if not hardened: return {'success': False, 'status': 'conflict', 'message': 'existing_proxy_host_does_not_meet_managed_security_contract'} return { 'success': True, 'status': 'already_managed', 'message': 'proxy_host_already_matches_typed_plan', 'hostId': existing.get('id'), 'hostname': hostname, } certificate_id: Any = 0 metadata: dict[str, Any] = {} if certificate_mode == 'existing': certificates = _response_items(self._request('GET', '/nginx/certificates', params={'limit': 1000})) matching_certificate = next( ( item for item in certificates if hostname in { str(value).strip().lower() for value in _as_list(item.get('domain_names') or item.get('domainNames')) } ), None, ) if not matching_certificate: return {'success': False, 'status': 'blocked', 'message': 'existing_certificate_not_found'} certificate_id = matching_certificate.get('id') else: email = str(settings.apis.get('NPM_LETSENCRYPT_EMAIL') or '').strip() if not email or '@' not in email or len(email) > 254: return {'success': False, 'status': 'blocked', 'message': 'certificate_automation_not_configured'} certificate_id = 'new' metadata = {'letsencrypt_email': email, 'letsencrypt_agree': True} if ownership_id is not None: metadata = {**metadata, 'appops_enrollment_id': ownership_id} advanced = ( f'client_max_body_size {int(upload_limit_mb)}m;\n' f'proxy_read_timeout {int(read_timeout_seconds)}s;\n' f'proxy_send_timeout {int(read_timeout_seconds)}s;' ) created = self._request( 'POST', '/nginx/proxy-hosts', json={ 'domain_names': [hostname], 'forward_scheme': 'http', 'forward_host': forward_host, 'forward_port': int(forward_port), 'access_list_id': access_list_id, 'certificate_id': certificate_id, 'ssl_forced': bool(force_ssl), 'http2_support': bool(http2), 'hsts_enabled': False, 'hsts_subdomains': False, 'caching_enabled': False, 'block_exploits': True, 'allow_websocket_upgrade': False, 'meta': metadata, 'advanced_config': advanced, }, ) except RuntimeError as exc: return self._error_payload(exc) host_id = created.get('id') if isinstance(created, dict) else None if ownership_id is not None or access_list_id: if host_id is None: return { 'success': False, 'status': 'ownership_unverified', 'message': 'managed_proxy_creation_response_missing_identifier', } try: after_create = _response_items( self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000}) ) except RuntimeError as exc: return { 'success': False, 'status': 'ownership_unverified', 'message': redact_message(str(exc)), 'hostId': host_id, } verified = [item for item in after_create if str(item.get('id')) == str(host_id)] metadata = verified[0].get('meta') if len(verified) == 1 and isinstance(verified[0].get('meta'), dict) else {} same_target = bool( len(verified) == 1 and str(verified[0].get('forward_host') or verified[0].get('forwardHost') or '') == forward_host and _as_int(verified[0].get('forward_port') or verified[0].get('forwardPort')) == int(forward_port) and _as_int(verified[0].get('access_list_id') or verified[0].get('accessListId')) == access_list_id ) if not same_target or (ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id): return { 'success': False, 'status': 'ownership_unverified', 'message': 'managed_proxy_creation_ownership_not_verified', 'hostId': host_id, } result = { 'success': True, 'status': 'created', 'message': 'proxy_host_created_from_typed_plan', 'hostId': host_id, 'hostname': hostname, } if ownership_id is not None: result['enrollmentId'] = ownership_id return result def remove_managed_proxy_host(self, *, host_id: int | str, ownership_id: str) -> dict[str, Any]: """Compensate only a proxy host created by this exact enrollment.""" if not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id): return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'} if not self.is_configured(): return self._missing(reason='missing_config') try: hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000})) matching = [item for item in hosts if str(item.get('id')) == str(host_id)] if len(matching) != 1: return {'success': False, 'status': 'not_found', 'message': 'managed_proxy_host_not_found'} metadata = matching[0].get('meta') if isinstance(matching[0].get('meta'), dict) else {} if metadata.get('appops_enrollment_id') != ownership_id: return {'success': False, 'status': 'conflict', 'message': 'proxy_host_is_not_owned_by_this_enrollment'} self._request('DELETE', f'/nginx/proxy-hosts/{host_id}') except RuntimeError as exc: return self._error_payload(exc) return {'success': True, 'status': 'removed', 'hostId': host_id, 'enrollmentId': ownership_id} @staticmethod def _map_proxy_host(raw: dict[str, Any]) -> dict[str, Any]: return { 'id': raw.get('id'), 'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))], 'forwardHost': str(raw.get('forward_host') or raw.get('forwardHost') or ''), 'forwardPort': _as_int(raw.get('forward_port') or raw.get('forwardPort')), 'forwardScheme': str(raw.get('forward_scheme') or raw.get('forwardScheme') or 'http'), 'enabled': bool(raw.get('enabled')), 'sslForced': bool(raw.get('ssl_forced') or raw.get('sslForced')), 'http2Support': bool(raw.get('http2_support') or raw.get('http2Support')), 'hstsEnabled': bool(raw.get('hsts_enabled') or raw.get('hstsEnabled')), 'certificateId': raw.get('certificate_id') or raw.get('certificateId') or None, 'accessListId': raw.get('access_list_id') or raw.get('accessListId') or None, 'blockExploits': bool(raw.get('block_exploits') or raw.get('blockExploits')), 'cachingEnabled': bool(raw.get('caching_enabled') or raw.get('cachingEnabled')), 'websocketSupport': bool(raw.get('allow_websocket_upgrade') or raw.get('websocket_support') or raw.get('websocketSupport')), 'locationsCount': len(_as_list(raw.get('locations'))), 'meta': raw.get('meta') if isinstance(raw.get('meta'), dict) else {}, } @staticmethod def _map_certificate(raw: dict[str, Any]) -> dict[str, Any]: expires = raw.get('expires_on') or raw.get('expiresOn') or raw.get('expires') days = _days_until(expires) return { 'id': raw.get('id'), 'niceName': str(raw.get('nice_name') or raw.get('niceName') or raw.get('name') or ''), 'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))], 'provider': str(raw.get('provider') or raw.get('type') or 'unknown').lower(), 'expiresOn': expires, 'expiresInDays': days, 'isExpired': days is not None and days < 0, 'isExpiringSoon': days is not None and days <= 30, } @staticmethod def _map_access_list(raw: dict[str, Any]) -> dict[str, Any]: return { 'id': raw.get('id'), 'name': str(raw.get('name') or ''), 'authorizationType': str(raw.get('auth_type') or raw.get('authorizationType') or raw.get('authorization_type') or 'unknown'), 'satisfyAny': bool(raw.get('satisfy_any') or raw.get('satisfyAny')), 'itemsCount': len(_as_list(raw.get('items'))), 'clientsCount': len(_as_list(raw.get('clients'))), } @staticmethod def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]], *, nginx_access_list_checks_enabled: bool = True) -> list[dict[str, Any]]: warnings: list[dict[str, Any]] = [] for host in hosts: if not host.get('enabled'): continue domains = host.get('domainNames') or [] label = ', '.join(domains) or f"host-{host.get('id')}" cert = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None target = f"{host.get('forwardScheme')}://{host.get('forwardHost')}:{host.get('forwardPort')}" if not host.get('certificateId'): warnings.append({'type': 'host_without_certificate', 'severity': 'high', 'title': f'{label} has no certificate', 'evidence': [label, target], 'suggestedAction': 'review_certificate'}) elif cert and cert.get('isExpired'): warnings.append({'type': 'certificate_expired', 'severity': 'critical', 'title': f'{label} certificate is expired', 'evidence': [label, str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'}) elif cert and cert.get('isExpiringSoon'): warnings.append({'type': 'certificate_expiring_soon', 'severity': 'warning', 'title': f'{label} certificate expires soon', 'evidence': [label, f"{cert.get('expiresInDays')} days", str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'}) if nginx_access_list_checks_enabled and not host.get('accessListId'): warnings.append({'type': 'public_host_without_access_list', 'severity': 'high', 'title': f'{label} has no access list', 'evidence': [label, target], 'suggestedAction': 'review_access_protection'}) if not host.get('sslForced'): warnings.append({'type': 'ssl_not_forced', 'severity': 'warning', 'title': f'{label} does not force SSL', 'evidence': [label], 'suggestedAction': 'review_ssl_policy'}) if not host.get('hstsEnabled'): warnings.append({'type': 'hsts_disabled', 'severity': 'info', 'title': f'{label} has HSTS disabled', 'evidence': [label], 'suggestedAction': 'review_hsts_policy'}) if str(host.get('forwardHost') or '').lower() in {'unknown-app', 'unknown', ''}: warnings.append({'type': 'unknown_proxy_target', 'severity': 'warning', 'title': f'{label} forwards to an unknown target', 'evidence': [target], 'suggestedAction': 'map_proxy_host_target'}) return warnings @staticmethod def _missing(**extra: Any) -> dict[str, Any]: return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'], 'data': {}, **extra} @staticmethod def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: reason = redact_message(str(exc)) status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable') return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra}