Files
dns-sync/dns_sync/plugins/adapters/npm/__init__.py
T

886 lines
24 KiB
Python

import os
from typing import Any
from ....adapters import (
CERTIFICATES,
PROXY_HOSTS,
ApplyResult,
Capability,
CapabilityOperation,
DiscoveredObject,
NormalizedObject,
ObjectReference,
PlanAction,
PlanOperation,
RetireRequest,
)
from ....adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from ....certificates.material import CertificateMaterial
from ....config import parse_endpoint_list
from ....normalizer import normalize_ipv4
from .client import NpmClient
from ....object_normalizer import normalize_discovered_object
def configured_endpoints(config):
try:
port = int(os.getenv("PROXY_API_PORT", "81"))
except ValueError as exc:
raise RuntimeError(
"PROXY_API_PORT must be an integer"
) from exc
if not 1 <= port <= 65535:
raise RuntimeError(
"PROXY_API_PORT must be between 1 and 65535"
)
raw_endpoints = os.getenv("NPM_ENDPOINTS")
if raw_endpoints is None:
raw_proxy_ip = os.getenv("PROXY_IP")
if not raw_proxy_ip:
raise RuntimeError(
"NPM_ENDPOINTS or legacy PROXY_IP is required"
)
raw_endpoints = normalize_ipv4(
raw_proxy_ip,
"PROXY_IP",
)
return parse_endpoint_list(
"npm",
raw_endpoints,
port,
)
_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):
require_endpoint_plugin(endpoint, self.name)
self.endpoints = configured_endpoints(config)
self.endpoint = endpoint
self.client = NpmClient(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.endpoints,
key=lambda item: item.identity,
):
if endpoint.identity == self.endpoint.identity:
continue
client = NpmClient(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 = require_desired(
action,
context="NPM apply",
)
require_action_endpoint(
action,
self.endpoint,
)
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}"
)