from typing import Any from ...adapters import ( CERTIFICATES, PROXY_HOSTS, ApplyResult, Capability, CapabilityOperation, DiscoveredObject, NormalizedObject, ObjectReference, PlanAction, PlanOperation, ) 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 @staticmethod def _proxy_host_matches( actual: NormalizedObject, desired: NormalizedObject, *, local_key: str | None = None, ) -> bool: if actual.object_type != PROXY_HOSTS: return False if desired.object_type != PROXY_HOSTS: return False if local_key is not None and actual.local_key != local_key: return False if actual.logical_key != desired.logical_key: return False if actual.payload != desired.payload: return False if actual.references != desired.references: return False return True @staticmethod def _proxy_host_certificate_id( desired: NormalizedObject, ) -> int: certificate_references = [] for reference in desired.references: if ( reference.relation != "certificate" or reference.object_type != CERTIFICATES ): raise RuntimeError( "NPM proxy host has unsupported object reference" ) certificate_references.append(reference) if not certificate_references: return 0 if len(certificate_references) != 1: raise RuntimeError( "NPM proxy host requires at most one " "certificate reference" ) reference = certificate_references[0] if reference.local_key is None: raise RuntimeError( "NPM proxy host certificate reference " "was not resolved to an endpoint-local key" ) try: certificate_id = int(reference.local_key) except (TypeError, ValueError) as exc: raise RuntimeError( "NPM proxy host certificate reference " "has invalid endpoint-local key" ) from exc if certificate_id < 1: raise RuntimeError( "NPM proxy host certificate reference " "has invalid endpoint-local key" ) return certificate_id def _proxy_host_api_payload( self, desired: NormalizedObject, ) -> dict[str, Any]: if desired.object_type != PROXY_HOSTS: raise RuntimeError( "Proxy-host API payload requires proxy-host state" ) payload = desired.payload domains = payload.get("domain_names") if ( not isinstance(domains, list) or not domains or not all( isinstance(value, str) and value for value in domains ) ): raise RuntimeError( "NPM proxy host requires domain_names" ) forward_scheme = payload.get("forward_scheme") if ( not isinstance(forward_scheme, str) or not forward_scheme ): raise RuntimeError( "NPM proxy host requires forward_scheme" ) forward_host = payload.get("forward_host") if ( not isinstance(forward_host, str) or not forward_host ): raise RuntimeError( "NPM proxy host requires forward_host" ) forward_port = payload.get("forward_port") if ( isinstance(forward_port, bool) or not isinstance(forward_port, int) or forward_port < 1 or forward_port > 65535 ): raise RuntimeError( "NPM proxy host requires valid forward_port" ) api_payload: dict[str, Any] = { "domain_names": list(domains), "forward_scheme": forward_scheme, "forward_host": forward_host, "forward_port": forward_port, "certificate_id": ( self._proxy_host_certificate_id(desired) ), } for field in _PROXY_HOST_FIELDS: if field in ( "domain_names", "forward_scheme", "forward_host", "forward_port", "certificate_id", ): continue if field in payload: api_payload[field] = payload[field] return api_payload def _create_proxy_host( self, desired: NormalizedObject, ) -> ApplyResult: payload = self._proxy_host_api_payload( desired ) created = self.client.create_proxy_host( payload ) local_key = self._local_key( created, "proxy host", ) try: row = self.client.proxy_host(local_key) actual = normalize_discovered_object( self._proxy_host_from_row(row) ) if not self._proxy_host_matches( actual, desired, local_key=local_key, ): raise RuntimeError( "Created NPM proxy host does not match " "desired state" ) except Exception: try: self.client.delete_proxy_host( local_key ) except Exception as rollback_error: raise RuntimeError( "NPM proxy-host creation failed and " "rollback also failed" ) from rollback_error raise return ApplyResult( object=actual, detail="created proxy host", ) def _update_proxy_host( self, action: PlanAction, ) -> ApplyResult: desired = action.desired current = action.current if desired is None: raise RuntimeError( "NPM proxy-host update requires desired state" ) if ( current is None or current.object_type != PROXY_HOSTS or current.local_key is None ): raise RuntimeError( "NPM proxy-host update requires " "current endpoint-local state" ) if current.logical_key != desired.logical_key: raise RuntimeError( "NPM proxy-host update identity changed" ) local_key = current.local_key payload = self._proxy_host_api_payload( desired ) self.client.update_proxy_host( local_key, payload, ) row = self.client.proxy_host(local_key) actual = normalize_discovered_object( self._proxy_host_from_row(row) ) if not self._proxy_host_matches( actual, desired, local_key=local_key, ): raise RuntimeError( "Updated NPM proxy host does not match " "desired state" ) return ApplyResult( object=actual, detail="updated proxy host", ) def apply( self, action: PlanAction, ) -> ApplyResult | None: desired = action.desired if desired is None: raise RuntimeError( "NPM apply requires desired state" ) if action.endpoint_id != self.endpoint.identity: raise RuntimeError( "NPM apply action endpoint does not match adapter" ) if ( desired.object_type == CERTIFICATES and action.operation == PlanOperation.CREATE ): return self._create_certificate(desired) if ( desired.object_type == PROXY_HOSTS and action.operation == PlanOperation.CREATE ): return self._create_proxy_host(desired) if ( desired.object_type == PROXY_HOSTS and action.operation == PlanOperation.UPDATE ): return self._update_proxy_host(action) raise RuntimeError( f"{self.endpoint.identity} does not support " f"{action.operation} for {desired.object_type}" ) def _create_certificate( self, desired: NormalizedObject, ) -> ApplyResult: material = self._certificate_material_source( desired ) domains = desired.payload.get("domain_names") if ( not isinstance(domains, list) or not domains or not all( isinstance(value, str) and value for value in domains ) ): raise RuntimeError( "NPM certificate create requires domain_names" ) nice_name = desired.payload.get("nice_name") if not isinstance(nice_name, str): nice_name = "" nice_name = nice_name.strip() if not nice_name: nice_name = ( "dns-sync: " + ", ".join(domains) ) created = self.client.create_certificate( { # This node is receiving existing certificate # material. It must not independently perform ACME # issuance. "provider": "other", "domain_names": list(domains), "nice_name": nice_name, "meta": { "cluster_managed": True, "acme_renew": False, "dns_challenge": False, }, } ) created_id = created.get("id") if ( isinstance(created_id, bool) or not isinstance(created_id, int) or created_id < 1 ): raise RuntimeError( "NPM certificate create returned invalid id" ) local_key = str(created_id) try: self.client.upload_certificate( local_key, certificate=material.certificate, certificate_key=material.certificate_key, intermediate_certificate=( material.intermediate_certificate ), ) row = self.client.certificate(local_key) actual = normalize_discovered_object( self._certificate_from_row(row) ) if actual.local_key != local_key: raise RuntimeError( "Created NPM certificate returned " "unexpected local id" ) if not self._portable_certificate_matches( actual, desired, ): raise RuntimeError( "Created NPM certificate does not match " "desired portable certificate state" ) except Exception: try: self.client.delete_certificate(local_key) except Exception as rollback_error: raise RuntimeError( "NPM certificate creation failed and " "rollback also failed" ) from rollback_error raise return ApplyResult( object=actual, detail="created certificate", ) 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 self._proxy_host_matches( actual, object, local_key=object.local_key, ) 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}" )