649 lines
32 KiB
Python
649 lines
32 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from urllib.parse import 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
|
|
|
|
|
|
def _as_bool(value: Any, default: bool = False) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if value is None:
|
|
return default
|
|
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
|
|
|
|
|
|
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 = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/')
|
|
if not raw:
|
|
return ''
|
|
parsed = urlparse(raw)
|
|
if not parsed.username and not parsed.password:
|
|
return raw
|
|
host = parsed.hostname or ''
|
|
port = f':{parsed.port}' if parsed.port else ''
|
|
return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/')
|
|
|
|
|
|
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]] = {}
|
|
|
|
def _memo(self, key: str, factory: Any) -> dict[str, Any]:
|
|
if key not in self._read_cache:
|
|
self._read_cache[key] = factory()
|
|
return self._read_cache[key]
|
|
|
|
def is_configured(self) -> bool:
|
|
return bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET'))
|
|
|
|
def _config(self) -> _NpmConfig | None:
|
|
if not self.is_configured():
|
|
return None
|
|
return _NpmConfig(
|
|
url=str(settings.apis['NPM_URL']).rstrip('/'),
|
|
identity=str(settings.apis['NPM_IDENTITY']),
|
|
secret=str(settings.apis['NPM_SECRET']),
|
|
verify_tls=_as_bool(settings.apis.get('NPM_VERIFY_TLS'), False),
|
|
timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0),
|
|
)
|
|
|
|
def _login(self) -> str:
|
|
if self._runtime_token:
|
|
return self._runtime_token
|
|
config = self._config()
|
|
if not config:
|
|
raise RuntimeError('missing_config')
|
|
try:
|
|
self.clear_error()
|
|
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
|
|
response = client.post(
|
|
f'{config.url}/api/tokens',
|
|
json={'identity': config.identity, 'secret': config.secret},
|
|
headers={'Accept': 'application/json'},
|
|
)
|
|
except httpx.RequestError as exc:
|
|
message = redact_message(str(exc))
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
if response.status_code in {400, 401, 403}:
|
|
self.set_error('auth_failed')
|
|
raise RuntimeError('auth_failed')
|
|
if response.status_code >= 400:
|
|
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}')
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
try:
|
|
payload = response.json()
|
|
except ValueError as exc:
|
|
message = redact_message(str(exc))
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
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()
|
|
try:
|
|
with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client:
|
|
response = client.request(
|
|
method,
|
|
f'{config.url}/api{path}',
|
|
headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'},
|
|
**kwargs,
|
|
)
|
|
except httpx.RequestError as exc:
|
|
message = redact_message(str(exc))
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
if response.status_code in {401, 403}:
|
|
self.set_error('auth_failed')
|
|
raise RuntimeError('auth_failed')
|
|
if response.status_code >= 400:
|
|
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}')
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
try:
|
|
return response.json()
|
|
except ValueError as exc:
|
|
message = redact_message(str(exc))
|
|
self.set_error(message)
|
|
raise RuntimeError(message)
|
|
|
|
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)}
|
|
|
|
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}
|