849 lines
23 KiB
Python
849 lines
23 KiB
Python
from typing import Any
|
|
|
|
from ...adapters import (
|
|
CERTIFICATES,
|
|
PROXY_HOSTS,
|
|
ApplyResult,
|
|
Capability,
|
|
CapabilityOperation,
|
|
DiscoveredObject,
|
|
NormalizedObject,
|
|
ObjectReference,
|
|
PlanAction,
|
|
PlanOperation,
|
|
RetireRequest,
|
|
)
|
|
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.APPLY,
|
|
),
|
|
Capability(
|
|
PROXY_HOSTS,
|
|
CapabilityOperation.VERIFY,
|
|
),
|
|
Capability(
|
|
CERTIFICATES,
|
|
CapabilityOperation.DISCOVER,
|
|
),
|
|
Capability(
|
|
CERTIFICATES,
|
|
CapabilityOperation.APPLY,
|
|
),
|
|
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 == CERTIFICATES
|
|
and action.operation == PlanOperation.REPLACE
|
|
):
|
|
return self._replace_certificate(action)
|
|
|
|
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 _replace_certificate(
|
|
self,
|
|
action: PlanAction,
|
|
) -> ApplyResult:
|
|
desired = action.desired
|
|
current = action.current
|
|
|
|
if desired is None or desired.object_type != CERTIFICATES:
|
|
raise RuntimeError(
|
|
"NPM certificate replace requires desired certificate"
|
|
)
|
|
|
|
if current is None or current.object_type != CERTIFICATES:
|
|
raise RuntimeError(
|
|
"NPM certificate replace requires current certificate"
|
|
)
|
|
|
|
if current.local_key is None:
|
|
raise RuntimeError(
|
|
"NPM certificate replace requires current local id"
|
|
)
|
|
|
|
if current.logical_key != desired.logical_key:
|
|
raise RuntimeError(
|
|
"NPM certificate replace identity mismatch"
|
|
)
|
|
|
|
result = self._create_certificate(desired)
|
|
|
|
return ApplyResult(
|
|
object=result.object,
|
|
detail="replaced certificate",
|
|
retirements=(
|
|
RetireRequest(
|
|
object=current,
|
|
),
|
|
),
|
|
)
|
|
|
|
def retire(
|
|
self,
|
|
request: RetireRequest,
|
|
) -> None:
|
|
retired = request.object
|
|
|
|
if retired.object_type != CERTIFICATES:
|
|
raise RuntimeError(
|
|
"NPM deferred retirement only supports certificates"
|
|
)
|
|
|
|
if retired.local_key is None:
|
|
raise RuntimeError(
|
|
"NPM certificate retirement requires local id"
|
|
)
|
|
|
|
self.client.delete_certificate(
|
|
retired.local_key
|
|
)
|
|
|
|
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}"
|
|
)
|