Move NPM configuration and client behind plugin boundary

This commit is contained in:
Lyncoln
2026-09-20 22:50:37 -04:00
parent 332f994c26
commit c9a3898055
8 changed files with 289 additions and 256 deletions
+885
View File
@@ -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}"
)
+478
View File
@@ -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,
)