from collections.abc import Iterable from datetime import datetime, timezone from typing import Any from .adapters import ( CERTIFICATES, DNS_RECORDS, PROXY_HOSTS, DiscoveredObject, NormalizedObject, Observation, ) from .certificates.normalizer import ( normalize_certificate_domains, parse_npm_datetime, ) from .normalizer import normalize_hostname, normalize_ipv4 _PROXY_BOOLEAN_FIELDS = ( "ssl_forced", "caching_enabled", "block_exploits", "allow_websocket_upgrade", "http2_support", "hsts_enabled", "hsts_subdomains", "enabled", ) def _normalized_domains(values: Any, label: str) -> tuple[str, ...]: if not isinstance(values, (list, tuple)): raise RuntimeError(f"{label} domain_names must be a list") domains: set[str] = set() for raw_domain in values: hostname, _ = normalize_hostname( str(raw_domain), allow_wildcard=True, ) domains.add(hostname) if not domains: raise RuntimeError(f"{label} has no usable domain names") return tuple(sorted(domains)) def _normalized_bool(value: Any) -> bool: return value is True or value == 1 def _normalized_port(value: Any, label: str) -> int: try: port = int(value) except (TypeError, ValueError) as exc: raise RuntimeError(f"{label} has invalid forward_port") from exc if not 1 <= port <= 65535: raise RuntimeError( f"{label} forward_port must be between 1 and 65535" ) return port def _iso_datetime(value: Any) -> str | None: parsed = parse_npm_datetime(value) return parsed.isoformat() if parsed is not None else None def _normalize_dns_record( discovered: DiscoveredObject, ) -> NormalizedObject: payload = discovered.payload hostname, wildcard = normalize_hostname( str(payload.get("hostname") or ""), allow_wildcard=True, ) record_type = str(payload.get("record_type") or "A").upper() if record_type != "A": raise RuntimeError( f"{hostname}: unsupported DNS record type {record_type}" ) normalized_payload = { "hostname": hostname, "ip": normalize_ipv4(str(payload.get("ip") or ""), hostname), "record_type": record_type, "wildcard": wildcard, } return NormalizedObject( object_type=DNS_RECORDS, logical_key=hostname, local_key=discovered.local_key, payload=normalized_payload, references=discovered.references, ) def _normalize_proxy_host( discovered: DiscoveredObject, ) -> NormalizedObject: payload = discovered.payload label = f"NPM proxy host {discovered.local_key}" domains = _normalized_domains( payload.get("domain_names"), label, ) forward_scheme = str( payload.get("forward_scheme") or "" ).strip().lower() forward_host = str( payload.get("forward_host") or "" ).strip() if not forward_scheme: raise RuntimeError(f"{label} has no forward_scheme") if not forward_host: raise RuntimeError(f"{label} has no forward_host") normalized_payload: dict[str, Any] = { "domain_names": list(domains), "forward_scheme": forward_scheme, "forward_host": forward_host, "forward_port": _normalized_port( payload.get("forward_port"), label, ), } for field in _PROXY_BOOLEAN_FIELDS: normalized_payload[field] = _normalized_bool( payload.get(field) ) return NormalizedObject( object_type=PROXY_HOSTS, logical_key=",".join(domains), local_key=discovered.local_key, payload=normalized_payload, references=discovered.references, ) def _normalize_certificate( discovered: DiscoveredObject, ) -> NormalizedObject: payload = discovered.payload identity = normalize_certificate_domains( payload.get("domain_names") ) normalized_payload = { "domain_names": list(identity.domains), "provider": str(payload.get("provider") or ""), "nice_name": str(payload.get("nice_name") or ""), "valid_from": _iso_datetime(payload.get("valid_from")), "expires_on": _iso_datetime(payload.get("expires_on")), "cluster_managed": _normalized_bool( payload.get("cluster_managed") ), "acme_renew": _normalized_bool(payload.get("acme_renew")), "dns_challenge": _normalized_bool( payload.get("dns_challenge") ), } return NormalizedObject( object_type=CERTIFICATES, logical_key=identity.key, local_key=discovered.local_key, payload=normalized_payload, references=discovered.references, ) def normalize_discovered_object( discovered: DiscoveredObject, ) -> NormalizedObject: if discovered.object_type == DNS_RECORDS: return _normalize_dns_record(discovered) if discovered.object_type == PROXY_HOSTS: return _normalize_proxy_host(discovered) if discovered.object_type == CERTIFICATES: return _normalize_certificate(discovered) raise RuntimeError( f"No normalizer for object type {discovered.object_type!r}" ) def normalize_observations( endpoint_id: str, discovered: Iterable[DiscoveredObject], observed_at: datetime | None = None, ) -> list[Observation]: if not endpoint_id: raise RuntimeError("Observation endpoint_id cannot be empty") timestamp = observed_at or datetime.now(timezone.utc) if timestamp.tzinfo is None: raise RuntimeError("Observation timestamp must be timezone-aware") return [ Observation( endpoint_id=endpoint_id, object=normalize_discovered_object(item), observed_at=timestamp.astimezone(timezone.utc), ) for item in discovered ]