from typing import Any from ...adapters import ( CERTIFICATES, PROXY_HOSTS, Capability, CapabilityOperation, DiscoveredObject, NormalizedObject, ObjectReference, ) from ...certificates.material import CertificateMaterial from ...npm_client import NpmClient from ...object_normalizer import normalize_discovered_object _PROXY_HOST_FIELDS = ( "domain_names", "forward_scheme", "forward_host", "forward_port", "certificate_id", "ssl_forced", "caching_enabled", "block_exploits", "allow_websocket_upgrade", "http2_support", "hsts_enabled", "hsts_subdomains", "enabled", ) class Plugin: name = "npm" capabilities = frozenset( { Capability( PROXY_HOSTS, CapabilityOperation.DISCOVER, ), Capability( PROXY_HOSTS, CapabilityOperation.VERIFY, ), Capability( CERTIFICATES, CapabilityOperation.DISCOVER, ), Capability( CERTIFICATES, CapabilityOperation.VERIFY, ), } ) def __init__(self, config, endpoint): self.config = config self.endpoint = endpoint self.client = NpmClient(config, endpoint) @staticmethod def _local_key(row: dict[str, Any], label: str) -> str: local_id = row.get("id") if isinstance(local_id, bool) or not isinstance(local_id, int): raise RuntimeError( f"NPM {label} has invalid id: {local_id!r}" ) return str(local_id) def connect(self) -> None: self.client.login() def discover( self, object_type: str, ) -> list[DiscoveredObject]: if object_type == PROXY_HOSTS: return self._discover_proxy_hosts() if object_type == CERTIFICATES: return self._discover_certificates() raise RuntimeError( f"{self.endpoint.identity} cannot discover " f"object type {object_type!r}" ) def _proxy_host_from_row( self, row: dict[str, Any], ) -> DiscoveredObject: payload = { field: row.get(field) for field in _PROXY_HOST_FIELDS } certificate_id = row.get("certificate_id") if certificate_id in (None, "", 0, "0"): references = () else: if isinstance(certificate_id, bool): raise RuntimeError( "NPM proxy host has invalid certificate_id" ) certificate_local_key = str( certificate_id ).strip() if not certificate_local_key: raise RuntimeError( "NPM proxy host has invalid certificate_id" ) references = ( ObjectReference( relation="certificate", object_type=CERTIFICATES, local_key=certificate_local_key, ), ) return DiscoveredObject( object_type=PROXY_HOSTS, local_key=self._local_key( row, "proxy host", ), payload=payload, references=references, ) def _certificate_from_row( self, row: dict[str, Any], ) -> DiscoveredObject: meta = row.get("meta") if not isinstance(meta, dict): meta = {} return DiscoveredObject( object_type=CERTIFICATES, local_key=self._local_key( row, "certificate", ), payload={ "domain_names": list( row.get("domain_names") or [] ), "provider": str( row.get("provider") or "" ), "nice_name": str( row.get("nice_name") or "" ), "valid_from": row.get("valid_from"), "expires_on": row.get("expires_on"), "cluster_managed": ( meta.get("cluster_managed") is True ), "acme_renew": ( meta.get("acme_renew") is True ), "dns_challenge": ( meta.get("dns_challenge") is True ), }, ) def _discover_proxy_hosts(self) -> list[DiscoveredObject]: return [ self._proxy_host_from_row(row) for row in self.client.proxy_hosts() ] def _discover_certificates(self) -> list[DiscoveredObject]: return [ self._certificate_from_row(row) for row in self.client.certificates() ] @staticmethod def _portable_certificate_matches( actual: NormalizedObject, desired: NormalizedObject, ) -> bool: if actual.object_type != CERTIFICATES: return False if desired.object_type != CERTIFICATES: return False if actual.logical_key != desired.logical_key: return False for field in ( "domain_names", "valid_from", "expires_on", ): if ( actual.payload.get(field) != desired.payload.get(field) ): return False return True def _certificate_material_source( self, desired: NormalizedObject, ) -> CertificateMaterial: if desired.object_type != CERTIFICATES: raise RuntimeError( "Certificate material source requires " "a certificate object" ) matches: list[ tuple[str, str, CertificateMaterial] ] = [] source_errors: list[str] = [] for endpoint in sorted( self.config.npm_endpoints, key=lambda item: item.identity, ): if endpoint.identity == self.endpoint.identity: continue client = NpmClient( self.config, endpoint, ) try: rows = client.certificates() except Exception as exc: source_errors.append( f"{endpoint.identity}: " f"{type(exc).__name__}" ) continue for row in rows: candidate = normalize_discovered_object( self._certificate_from_row(row) ) if not self._portable_certificate_matches( candidate, desired, ): continue if candidate.local_key is None: raise RuntimeError( "Matching NPM certificate has no " "endpoint-local key" ) material = client.certificate_material( candidate.local_key ) matches.append( ( endpoint.identity, candidate.local_key, material, ) ) if not matches: detail = "" if source_errors: detail = ( "; unavailable sources: " + ", ".join(source_errors) ) raise RuntimeError( "No NPM source certificate material found for " f"{desired.logical_key!r}" f"{detail}" ) baseline = matches[0][2] for _, _, material in matches[1:]: if material != baseline: endpoints = ", ".join( sorted( { endpoint_id for ( endpoint_id, _, _, ) in matches } ) ) raise RuntimeError( "Conflicting NPM certificate material " f"sources for {desired.logical_key!r}: " f"{endpoints}" ) return baseline def apply(self, action): raise RuntimeError( f"{self.endpoint.identity} does not support apply yet" ) def verify(self, object: NormalizedObject) -> bool: if object.local_key is None: return False if object.object_type == PROXY_HOSTS: row = self.client.proxy_host( object.local_key ) actual = normalize_discovered_object( self._proxy_host_from_row(row) ) return actual == object if object.object_type == CERTIFICATES: row = self.client.certificate( object.local_key ) actual = normalize_discovered_object( self._certificate_from_row(row) ) if actual.local_key != object.local_key: return False # Provider, display name, and renewal flags describe how # this NPM endpoint manages the certificate. They are not # portable certificate identity. A copied Let's Encrypt # certificate becomes provider="other" on the recipient. return self._portable_certificate_matches( actual, object, ) raise RuntimeError( f"{self.endpoint.identity} cannot verify " f"object type {object.object_type!r}" )