Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
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}