Add read-only NPM certificate discovery

This commit is contained in:
Lyncoln
2026-09-19 15:05:54 -04:00
parent e2cf88f161
commit 9d571ada24
5 changed files with 171 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Certificate discovery and reconciliation support."""
+35
View File
@@ -0,0 +1,35 @@
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class CertificateIdentity:
"""
Cluster identity for a certificate.
NPM numeric IDs are intentionally excluded because they are node-local.
"""
domains: tuple[str, ...]
@property
def key(self) -> str:
return ",".join(self.domains)
@dataclass(frozen=True)
class CertificateState:
"""
Non-secret certificate state suitable for discovery, comparison, and logs.
"""
identity: CertificateIdentity
local_id: int
provider: str
nice_name: str
valid_from: datetime | None
expires_on: datetime | None
cluster_managed: bool
acme_renew: bool
dns_challenge: bool
source: str
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime, timezone
from ..normalizer import normalize_hostname
from .models import CertificateIdentity
def normalize_certificate_domains(values) -> CertificateIdentity:
domains: set[str] = set()
for raw in values or []:
hostname, _ = normalize_hostname(
str(raw),
allow_wildcard=True,
)
domains.add(hostname)
if not domains:
raise RuntimeError(
"Certificate has no usable domain names"
)
return CertificateIdentity(
domains=tuple(sorted(domains))
)
def parse_npm_datetime(value) -> datetime | None:
if not value:
return None
raw = str(value).strip()
# NPM normally emits ISO timestamps ending in Z.
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
result = datetime.fromisoformat(raw)
except ValueError as exc:
raise RuntimeError(
f"Invalid NPM certificate timestamp: {value!r}"
) from exc
if result.tzinfo is None:
result = result.replace(tzinfo=timezone.utc)
return result.astimezone(timezone.utc)
+79
View File
@@ -0,0 +1,79 @@
from typing import Any
from ..npm_client import NpmClient
from .models import CertificateState
from .normalizer import (
normalize_certificate_domains,
parse_npm_datetime,
)
class NpmCertificateRepository:
"""
Read-only NPM certificate repository.
This milestone intentionally discovers metadata only.
Certificate/private-key material is not requested or logged.
"""
def __init__(self, config):
self.config = config
self.client = NpmClient(config)
def _rows(self) -> list[dict[str, Any]]:
data = self.client.certificates()
if not isinstance(data, list):
raise RuntimeError(
"NPM certificate response was not a list"
)
return data
def discover(self) -> list[CertificateState]:
certificates: list[CertificateState] = []
for row in self._rows():
local_id = row.get("id")
if not isinstance(local_id, int):
raise RuntimeError(
f"NPM certificate has invalid id: {local_id!r}"
)
meta = row.get("meta")
if not isinstance(meta, dict):
meta = {}
certificates.append(
CertificateState(
identity=normalize_certificate_domains(
row.get("domain_names")
),
local_id=local_id,
provider=str(
row.get("provider") or ""
),
nice_name=str(
row.get("nice_name") or ""
),
valid_from=parse_npm_datetime(
row.get("valid_from")
),
expires_on=parse_npm_datetime(
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
),
source=self.config.proxy_url,
)
)
return certificates
+9
View File
@@ -126,6 +126,15 @@ class NpmClient:
return data 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 certificates(self) -> list[dict[str, Any]]: def certificates(self) -> list[dict[str, Any]]:
data = self.get_json("/nginx/certificates") data = self.get_json("/nginx/certificates")