Move NPM configuration and client behind plugin boundary
This commit is contained in:
@@ -0,0 +1,885 @@
|
||||
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}"
|
||||
)
|
||||
@@ -0,0 +1,478 @@
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
from ....adapters import Endpoint
|
||||
from ....certificates.material import CertificateMaterial
|
||||
from ....config import env_bool
|
||||
|
||||
|
||||
class NpmClient:
|
||||
"""Authenticated client for the Nginx Proxy Manager API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: Endpoint,
|
||||
):
|
||||
self.endpoint = endpoint
|
||||
|
||||
if self.endpoint.plugin != "npm":
|
||||
raise RuntimeError("NpmClient requires an npm endpoint")
|
||||
|
||||
self.identity = os.getenv("NPM_IDENTITY")
|
||||
self.secret = os.getenv("NPM_SECRET")
|
||||
|
||||
if not self.identity:
|
||||
raise RuntimeError("NPM_IDENTITY is required")
|
||||
|
||||
if not self.secret:
|
||||
raise RuntimeError("NPM_SECRET is required")
|
||||
|
||||
self.api_scheme = os.getenv(
|
||||
"PROXY_API_SCHEME",
|
||||
"http",
|
||||
).strip().lower()
|
||||
|
||||
if self.api_scheme not in {"http", "https"}:
|
||||
raise RuntimeError(
|
||||
"PROXY_API_SCHEME must be http or https"
|
||||
)
|
||||
|
||||
self.verify_tls = env_bool(
|
||||
"PROXY_VERIFY_TLS",
|
||||
True,
|
||||
)
|
||||
|
||||
self.session = requests.Session()
|
||||
self.token: str | None = None
|
||||
|
||||
if not self.verify_tls:
|
||||
urllib3.disable_warnings(
|
||||
urllib3.exceptions.InsecureRequestWarning
|
||||
)
|
||||
|
||||
@property
|
||||
def api_url(self) -> str:
|
||||
return (
|
||||
f"{self.api_scheme}://"
|
||||
f"{self.endpoint.address}/api"
|
||||
)
|
||||
|
||||
def login(self) -> None:
|
||||
response = self.session.post(
|
||||
f"{self.api_url}/tokens",
|
||||
json={
|
||||
"identity": self.identity,
|
||||
"secret": self.secret,
|
||||
},
|
||||
timeout=15,
|
||||
verify=self.verify_tls,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
"NPM login failed: "
|
||||
f"HTTP {response.status_code}: "
|
||||
f"{response.text[:300]}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
token = data.get("token")
|
||||
|
||||
if not token:
|
||||
raise RuntimeError(
|
||||
"NPM login succeeded but no token was returned"
|
||||
)
|
||||
|
||||
self.token = str(token)
|
||||
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _ensure_login(self) -> None:
|
||||
if not self.token:
|
||||
self.login()
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
expected_status: int | tuple[int, ...] = 200,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
self._ensure_login()
|
||||
|
||||
if isinstance(expected_status, int):
|
||||
expected = (expected_status,)
|
||||
else:
|
||||
expected = expected_status
|
||||
|
||||
response = self.session.request(
|
||||
method,
|
||||
f"{self.api_url}{path}",
|
||||
timeout=kwargs.pop("timeout", 15),
|
||||
verify=self.verify_tls,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if response.status_code not in expected:
|
||||
raise RuntimeError(
|
||||
f"NPM {method.upper()} {path} failed: "
|
||||
f"HTTP {response.status_code}: "
|
||||
f"{response.text[:300]}"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def get_json(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
expected_status: int | tuple[int, ...] = 200,
|
||||
) -> Any:
|
||||
response = self.request(
|
||||
"GET",
|
||||
path,
|
||||
expected_status=expected_status,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def _json_object(
|
||||
response: requests.Response,
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(
|
||||
f"NPM {label} response was not an object"
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _local_id(value: str | int, label: str) -> str:
|
||||
if isinstance(value, bool):
|
||||
raise RuntimeError(f"NPM {label} id is invalid")
|
||||
|
||||
local_id = str(value).strip()
|
||||
if not local_id or not local_id.isdigit():
|
||||
raise RuntimeError(f"NPM {label} id is invalid")
|
||||
|
||||
return local_id
|
||||
|
||||
def proxy_hosts(self) -> list[dict[str, Any]]:
|
||||
data = self.get_json("/nginx/proxy-hosts")
|
||||
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError(
|
||||
"NPM proxy-host response was not a list"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def certificates(self) -> list[dict[str, Any]]:
|
||||
data = self.get_json("/nginx/certificates")
|
||||
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError(
|
||||
"NPM certificate response was not a list"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def proxy_host(
|
||||
self,
|
||||
local_id: str | int,
|
||||
) -> dict[str, Any]:
|
||||
host_id = self._local_id(local_id, "proxy host")
|
||||
data = self.get_json(
|
||||
f"/nginx/proxy-hosts/{host_id}"
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(
|
||||
"NPM proxy-host response was not an object"
|
||||
)
|
||||
return data
|
||||
|
||||
def create_proxy_host(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
response = self.request(
|
||||
"POST",
|
||||
"/nginx/proxy-hosts",
|
||||
expected_status=201,
|
||||
json=payload,
|
||||
)
|
||||
return self._json_object(
|
||||
response,
|
||||
"proxy-host create",
|
||||
)
|
||||
|
||||
def update_proxy_host(
|
||||
self,
|
||||
local_id: str | int,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
host_id = self._local_id(local_id, "proxy host")
|
||||
response = self.request(
|
||||
"PUT",
|
||||
f"/nginx/proxy-hosts/{host_id}",
|
||||
expected_status=200,
|
||||
json=payload,
|
||||
)
|
||||
return self._json_object(
|
||||
response,
|
||||
"proxy-host update",
|
||||
)
|
||||
|
||||
def delete_proxy_host(
|
||||
self,
|
||||
local_id: str | int,
|
||||
) -> None:
|
||||
proxy_host_id = self._local_id(
|
||||
local_id,
|
||||
"proxy host",
|
||||
)
|
||||
|
||||
self.request(
|
||||
"DELETE",
|
||||
f"/nginx/proxy-hosts/{proxy_host_id}",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
def certificate(
|
||||
self,
|
||||
local_id: str | int,
|
||||
) -> dict[str, Any]:
|
||||
certificate_id = self._local_id(
|
||||
local_id,
|
||||
"certificate",
|
||||
)
|
||||
data = self.get_json(
|
||||
f"/nginx/certificates/{certificate_id}"
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(
|
||||
"NPM certificate response was not an object"
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _pem_bytes(value: Any, label: str) -> bytes:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RuntimeError(
|
||||
f"NPM certificate is missing {label}"
|
||||
)
|
||||
return value.encode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _zip_pem_member(
|
||||
archive: ZipFile,
|
||||
prefix: str,
|
||||
) -> bytes:
|
||||
matches = []
|
||||
|
||||
for info in archive.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
|
||||
basename = PurePosixPath(info.filename).name
|
||||
|
||||
if not basename.endswith(".pem"):
|
||||
continue
|
||||
|
||||
stem = basename[:-4]
|
||||
|
||||
if not stem.startswith(prefix):
|
||||
continue
|
||||
|
||||
suffix = stem[len(prefix):]
|
||||
|
||||
if not suffix.isdigit():
|
||||
continue
|
||||
|
||||
matches.append(info)
|
||||
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(
|
||||
"NPM certificate download must contain exactly one "
|
||||
f"{prefix}N.pem member"
|
||||
)
|
||||
|
||||
data = archive.read(matches[0])
|
||||
|
||||
if not data:
|
||||
raise RuntimeError(
|
||||
f"NPM certificate download {prefix} member is empty"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def certificate_material(
|
||||
self,
|
||||
local_id: str | int,
|
||||
) -> CertificateMaterial:
|
||||
certificate_id = self._local_id(
|
||||
local_id,
|
||||
"certificate",
|
||||
)
|
||||
|
||||
detail = self.certificate(certificate_id)
|
||||
provider = str(
|
||||
detail.get("provider") or ""
|
||||
).strip().lower()
|
||||
|
||||
if provider == "other":
|
||||
meta = detail.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
raise RuntimeError(
|
||||
"NPM custom certificate has invalid metadata"
|
||||
)
|
||||
|
||||
intermediate = meta.get(
|
||||
"intermediate_certificate"
|
||||
)
|
||||
|
||||
return CertificateMaterial(
|
||||
certificate=self._pem_bytes(
|
||||
meta.get("certificate"),
|
||||
"certificate",
|
||||
),
|
||||
certificate_key=self._pem_bytes(
|
||||
meta.get("certificate_key"),
|
||||
"certificate_key",
|
||||
),
|
||||
intermediate_certificate=(
|
||||
None
|
||||
if intermediate in (None, "")
|
||||
else self._pem_bytes(
|
||||
intermediate,
|
||||
"intermediate_certificate",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if provider == "letsencrypt":
|
||||
response = self.request(
|
||||
"GET",
|
||||
(
|
||||
"/nginx/certificates/"
|
||||
f"{certificate_id}/download"
|
||||
),
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
try:
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
return CertificateMaterial(
|
||||
certificate=self._zip_pem_member(
|
||||
archive,
|
||||
"cert",
|
||||
),
|
||||
certificate_key=self._zip_pem_member(
|
||||
archive,
|
||||
"privkey",
|
||||
),
|
||||
intermediate_certificate=(
|
||||
self._zip_pem_member(
|
||||
archive,
|
||||
"chain",
|
||||
)
|
||||
),
|
||||
)
|
||||
except BadZipFile as exc:
|
||||
raise RuntimeError(
|
||||
"NPM certificate download was not a valid ZIP archive"
|
||||
) from exc
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unsupported NPM certificate provider: {provider!r}"
|
||||
)
|
||||
|
||||
def delete_certificate(
|
||||
self,
|
||||
local_id: str | int,
|
||||
) -> None:
|
||||
certificate_id = self._local_id(
|
||||
local_id,
|
||||
"certificate",
|
||||
)
|
||||
|
||||
self.request(
|
||||
"DELETE",
|
||||
f"/nginx/certificates/{certificate_id}",
|
||||
expected_status=200,
|
||||
)
|
||||
|
||||
def create_certificate(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
response = self.request(
|
||||
"POST",
|
||||
"/nginx/certificates",
|
||||
expected_status=201,
|
||||
json=payload,
|
||||
)
|
||||
return self._json_object(
|
||||
response,
|
||||
"certificate create",
|
||||
)
|
||||
|
||||
def upload_certificate(
|
||||
self,
|
||||
local_id: str | int,
|
||||
*,
|
||||
certificate: bytes,
|
||||
certificate_key: bytes,
|
||||
intermediate_certificate: bytes | None = None,
|
||||
) -> None:
|
||||
certificate_id = self._local_id(
|
||||
local_id,
|
||||
"certificate",
|
||||
)
|
||||
|
||||
if not certificate:
|
||||
raise RuntimeError(
|
||||
"NPM certificate upload requires certificate data"
|
||||
)
|
||||
if not certificate_key:
|
||||
raise RuntimeError(
|
||||
"NPM certificate upload requires certificate key data"
|
||||
)
|
||||
|
||||
files: dict[str, tuple[str, bytes]] = {
|
||||
"certificate": (
|
||||
"certificate.pem",
|
||||
certificate,
|
||||
),
|
||||
"certificate_key": (
|
||||
"certificate_key.pem",
|
||||
certificate_key,
|
||||
),
|
||||
}
|
||||
|
||||
if intermediate_certificate:
|
||||
files["intermediate_certificate"] = (
|
||||
"intermediate_certificate.pem",
|
||||
intermediate_certificate,
|
||||
)
|
||||
|
||||
self.request(
|
||||
"POST",
|
||||
f"/nginx/certificates/{certificate_id}/upload",
|
||||
expected_status=200,
|
||||
files=files,
|
||||
)
|
||||
Reference in New Issue
Block a user