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
@@ -1,6 +1,7 @@
import os
from typing import Any
from ...adapters import (
from ....adapters import (
CERTIFICATES,
PROXY_HOSTS,
ApplyResult,
@@ -13,18 +14,49 @@ from ...adapters import (
PlanOperation,
RetireRequest,
)
from ...adapter_support import (
from ....adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient
from ...object_normalizer import normalize_discovered_object
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):
return config.npm_endpoints
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 = (
@@ -77,9 +109,9 @@ class Plugin:
def __init__(self, config, endpoint):
require_endpoint_plugin(endpoint, self.name)
self.config = config
self.endpoints = configured_endpoints(config)
self.endpoint = endpoint
self.client = NpmClient(config, endpoint)
self.client = NpmClient(endpoint)
@staticmethod
def _local_key(row: dict[str, Any], label: str) -> str:
@@ -246,16 +278,13 @@ class Plugin:
source_errors: list[str] = []
for endpoint in sorted(
self.config.npm_endpoints,
self.endpoints,
key=lambda item: item.identity,
):
if endpoint.identity == self.endpoint.identity:
continue
client = NpmClient(
self.config,
endpoint,
)
client = NpmClient(endpoint)
try:
rows = client.certificates()
+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,
)