Files
Unraid-AppOps-Gateway-Public/backend/app/connectors/nginx_proxy_manager.py
T

955 lines
44 KiB
Python

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}