955 lines
44 KiB
Python
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}
|