Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,645 @@
|
||||
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 .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]:
|
||||
hosts = self.proxy_hosts()
|
||||
certs = self.certificates()
|
||||
access_lists = self.access_lists()
|
||||
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)
|
||||
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),
|
||||
'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)
|
||||
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]]) -> 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 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}
|
||||
Reference in new issue
Block a user