Publish OCSP snapshots through Redis
This commit is contained in:
@@ -31,9 +31,6 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
s3 = [
|
||||
"aioboto3>=13.0",
|
||||
]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.7",
|
||||
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||
|
||||
@@ -190,14 +190,10 @@ class Settings:
|
||||
otel_excluded_paths: Tuple[str, ...]
|
||||
otel_metric_export_interval_millis: int
|
||||
otel_metrics_poll_interval_seconds: int
|
||||
ocsp_s3_bucket: Optional[str]
|
||||
ocsp_s3_prefix: str
|
||||
ocsp_s3_region: Optional[str]
|
||||
ocsp_s3_endpoint: Optional[str]
|
||||
ocsp_s3_force_path_style: bool
|
||||
ocsp_redis_prefix: str
|
||||
ocsp_publish_interval_seconds: int
|
||||
ocsp_heartbeat_interval_seconds: int
|
||||
ocsp_object_expiry_seconds: int
|
||||
ocsp_redis_expiry_seconds: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
@@ -207,12 +203,12 @@ class Settings:
|
||||
ocsp_heartbeat_interval_seconds = max(
|
||||
1, int(_env("KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS", "30"))
|
||||
)
|
||||
ocsp_object_expiry_seconds = max(
|
||||
1, int(_env("KCA_OCSP_OBJECT_EXPIRY_SECONDS", "60"))
|
||||
ocsp_redis_expiry_seconds = max(
|
||||
1, int(_env("KCA_OCSP_REDIS_EXPIRY_SECONDS", "60"))
|
||||
)
|
||||
if ocsp_object_expiry_seconds <= ocsp_heartbeat_interval_seconds:
|
||||
if ocsp_redis_expiry_seconds <= ocsp_heartbeat_interval_seconds:
|
||||
raise RuntimeError(
|
||||
"KCA_OCSP_OBJECT_EXPIRY_SECONDS must be greater than "
|
||||
"KCA_OCSP_REDIS_EXPIRY_SECONDS must be greater than "
|
||||
"KCA_OCSP_HEARTBEAT_INTERVAL_SECONDS"
|
||||
)
|
||||
return Settings(
|
||||
@@ -255,16 +251,12 @@ class Settings:
|
||||
otel_metrics_poll_interval_seconds=int(
|
||||
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
|
||||
),
|
||||
ocsp_s3_bucket=os.environ.get("KCA_OCSP_S3_BUCKET") or None,
|
||||
ocsp_s3_prefix=_env("KCA_OCSP_S3_PREFIX", "ocsp").strip("/"),
|
||||
ocsp_s3_region=os.environ.get("KCA_OCSP_S3_REGION") or None,
|
||||
ocsp_s3_endpoint=os.environ.get("KCA_OCSP_S3_ENDPOINT") or None,
|
||||
ocsp_s3_force_path_style=_env_bool("KCA_OCSP_S3_FORCE_PATH_STYLE"),
|
||||
ocsp_redis_prefix=_env("KCA_OCSP_REDIS_PREFIX", "ocsp").strip("/:"),
|
||||
ocsp_publish_interval_seconds=max(
|
||||
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
|
||||
),
|
||||
ocsp_heartbeat_interval_seconds=ocsp_heartbeat_interval_seconds,
|
||||
ocsp_object_expiry_seconds=ocsp_object_expiry_seconds,
|
||||
ocsp_redis_expiry_seconds=ocsp_redis_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Per-CA OCSP status snapshots and S3 publication.
|
||||
"""Per-CA OCSP status snapshots and Redis publication.
|
||||
|
||||
KCA is the source of truth for certificate status. This module builds a
|
||||
compact JSON document per CA and uploads it to an S3-compatible bucket so
|
||||
that the standalone OCSP responder never needs database access or a CA
|
||||
KCA is the source of truth for certificate status. This module builds a
|
||||
compact JSON document per CA and stores it in the same Redis instance used for
|
||||
sessions, so the standalone OCSP responder never needs database access or a CA
|
||||
private key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -15,6 +15,7 @@ from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from redis.asyncio import Redis
|
||||
from tortoise.expressions import Q
|
||||
|
||||
from .config import Settings, settings
|
||||
@@ -33,11 +34,11 @@ def _iso(value: Optional[datetime]) -> Optional[str]:
|
||||
return value.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def object_key(ca_id: str) -> str:
|
||||
"""The S3 key for one CA database row; the responder uses the same CA ID."""
|
||||
prefix = settings.ocsp_s3_prefix.strip("/")
|
||||
name = f"{ca_id.lower()}.json"
|
||||
return f"{prefix}/{name}" if prefix else name
|
||||
def redis_key(ca_id: str) -> str:
|
||||
"""The Redis key for one CA database row; the responder uses the same key."""
|
||||
prefix = settings.ocsp_redis_prefix.strip("/:")
|
||||
name = ca_id.lower()
|
||||
return f"{prefix}:{name}" if prefix else name
|
||||
|
||||
|
||||
async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
|
||||
@@ -83,61 +84,49 @@ def cert_subject(ca: Certificate) -> str:
|
||||
return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string()
|
||||
|
||||
|
||||
def _s3_client() -> Any:
|
||||
import aioboto3
|
||||
from botocore.config import Config
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"region_name": settings.ocsp_s3_region,
|
||||
"endpoint_url": settings.ocsp_s3_endpoint,
|
||||
}
|
||||
if settings.ocsp_s3_force_path_style:
|
||||
kwargs["config"] = Config(s3={"addressing_style": "path"})
|
||||
# Only pass region and endpoint values that are explicitly configured.
|
||||
kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
||||
return aioboto3.Session().client("s3", **kwargs)
|
||||
|
||||
|
||||
async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Upload one CA snapshot and return its S3 key."""
|
||||
if not settings.ocsp_s3_bucket:
|
||||
raise RuntimeError("KCA_OCSP_S3_BUCKET is not configured")
|
||||
async def publish_snapshot(
|
||||
ca: Certificate,
|
||||
snapshot: Optional[Dict[str, Any]] = None,
|
||||
client: Optional[Redis] = None,
|
||||
) -> str:
|
||||
"""Store one CA snapshot with an expiry and return its Redis key."""
|
||||
if snapshot is None:
|
||||
snapshot = await snapshot_for_ca(ca)
|
||||
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
|
||||
key = object_key(str(ca.id))
|
||||
key = redis_key(str(ca.id))
|
||||
|
||||
async with _s3_client() as client:
|
||||
await client.put_object(
|
||||
Bucket=settings.ocsp_s3_bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType="application/json",
|
||||
CacheControl="no-store",
|
||||
Expires=datetime.now(timezone.utc)
|
||||
+ timedelta(seconds=settings.ocsp_object_expiry_seconds),
|
||||
)
|
||||
owned_client = None
|
||||
if client is None:
|
||||
if not settings.redis_url:
|
||||
raise RuntimeError("REDIS_URL is not configured")
|
||||
client = Redis.from_url(settings.redis_url)
|
||||
owned_client = client
|
||||
try:
|
||||
await client.set(key, body, ex=settings.ocsp_redis_expiry_seconds)
|
||||
finally:
|
||||
if owned_client is not None:
|
||||
await owned_client.aclose()
|
||||
|
||||
return key
|
||||
|
||||
|
||||
async def mark_ocsp_dirty(ca_id: UUID) -> None:
|
||||
"""Mark one issuing CA's status object as needing a republish."""
|
||||
if not settings.ocsp_s3_bucket:
|
||||
"""Mark one issuing CA's status snapshot as needing a republish."""
|
||||
if not settings.redis_url:
|
||||
return
|
||||
row = await OcspSnapshot.get_or_none(ca_id=ca_id)
|
||||
if row is None:
|
||||
await OcspSnapshot.create(ca_id=ca_id, dirty=True)
|
||||
return
|
||||
# Always save, even when already dirty: updated_at is the publisher's
|
||||
# optimistic-concurrency token for changes made during an upload.
|
||||
# optimistic-concurrency token for changes made during a write.
|
||||
row.dirty = True
|
||||
row.last_error = None # type: ignore[assignment]
|
||||
await row.save(update_fields=["dirty", "last_error", "updated_at"])
|
||||
|
||||
|
||||
async def mark_all_cas_dirty(vault: Vault) -> None:
|
||||
if not settings.ocsp_s3_bucket:
|
||||
if not settings.redis_url:
|
||||
return
|
||||
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
|
||||
await mark_ocsp_dirty(ca.id)
|
||||
@@ -151,10 +140,13 @@ class OcspPublisherMixin(KayaMixin):
|
||||
interval_seconds: int,
|
||||
ensure_context: Callable[[], Awaitable[None]],
|
||||
heartbeat_interval_seconds: int = 30,
|
||||
redis_client: Optional[Redis] = None,
|
||||
) -> None:
|
||||
self._interval = max(1, interval_seconds)
|
||||
self._heartbeat_interval = max(1, heartbeat_interval_seconds)
|
||||
self._ensure_context = ensure_context
|
||||
self._redis_client = redis_client
|
||||
self._owns_redis_client = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._bootstrapped = False
|
||||
|
||||
@@ -163,21 +155,16 @@ class OcspPublisherMixin(KayaMixin):
|
||||
return None
|
||||
|
||||
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
if not settings.ocsp_s3_bucket:
|
||||
log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)")
|
||||
if not settings.redis_url:
|
||||
log.info("OCSP Redis publication disabled (REDIS_URL unset)")
|
||||
return
|
||||
try:
|
||||
import aioboto3 # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"KCA_OCSP_S3_BUCKET is set but aioboto3 is not installed; "
|
||||
"install kca-app with the 's3' extra"
|
||||
) from exc
|
||||
if self._redis_client is None:
|
||||
self._redis_client = Redis.from_url(settings.redis_url)
|
||||
self._owns_redis_client = True
|
||||
self._task = loop.create_task(self._poll())
|
||||
log.info(
|
||||
"OCSP S3 publication enabled: bucket=%s prefix=%s interval=%ss",
|
||||
settings.ocsp_s3_bucket,
|
||||
settings.ocsp_s3_prefix or "(root)",
|
||||
"OCSP Redis publication enabled: prefix=%s interval=%ss",
|
||||
settings.ocsp_redis_prefix or "(root)",
|
||||
self._interval,
|
||||
)
|
||||
|
||||
@@ -185,6 +172,10 @@ class OcspPublisherMixin(KayaMixin):
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
if self._owns_redis_client and self._redis_client is not None:
|
||||
loop.create_task(self._redis_client.aclose())
|
||||
self._redis_client = None
|
||||
self._owns_redis_client = False
|
||||
|
||||
async def _poll(self) -> None:
|
||||
# Let the Tortoise mixin establish its database context before the
|
||||
@@ -194,7 +185,7 @@ class OcspPublisherMixin(KayaMixin):
|
||||
try:
|
||||
if not self._bootstrapped:
|
||||
# Existing CAs may predate the publisher or have lost
|
||||
# their S3 objects; publish one snapshot for every CA on
|
||||
# their Redis keys; publish one snapshot for every CA on
|
||||
# the first successful tick.
|
||||
await self._ensure_context()
|
||||
for ca in await Certificate.filter(is_ca=True):
|
||||
@@ -228,7 +219,7 @@ class OcspPublisherMixin(KayaMixin):
|
||||
continue
|
||||
try:
|
||||
snapshot = await snapshot_for_ca(ca)
|
||||
key = await publish_snapshot(ca, snapshot)
|
||||
key = await publish_snapshot(ca, snapshot, client=self._redis_client)
|
||||
published_at = datetime.now(timezone.utc)
|
||||
cleared = await OcspSnapshot.filter(
|
||||
ca_id=row.ca_id,
|
||||
@@ -258,7 +249,7 @@ def publisher_mixin_from_settings(
|
||||
settings_obj: Settings,
|
||||
ensure_context: Callable[[], Awaitable[None]],
|
||||
) -> Optional[OcspPublisherMixin]:
|
||||
if not settings_obj.ocsp_s3_bucket:
|
||||
if not settings_obj.redis_url:
|
||||
return None
|
||||
return OcspPublisherMixin(
|
||||
settings_obj.ocsp_publish_interval_seconds,
|
||||
@@ -272,8 +263,8 @@ __all__ = [
|
||||
"SCHEMA",
|
||||
"mark_all_cas_dirty",
|
||||
"mark_ocsp_dirty",
|
||||
"object_key",
|
||||
"publish_snapshot",
|
||||
"publisher_mixin_from_settings",
|
||||
"redis_key",
|
||||
"snapshot_for_ca",
|
||||
]
|
||||
|
||||
@@ -155,10 +155,9 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
||||
row = await OcspSnapshot.get_or_none(ca_id=cert.id)
|
||||
snapshot = await snapshot_for_ca(cert)
|
||||
await send_json(ctx, 200, {
|
||||
"enabled": bool(settings.ocsp_s3_bucket),
|
||||
"bucket": settings.ocsp_s3_bucket,
|
||||
"prefix": settings.ocsp_s3_prefix,
|
||||
"dirty": row.dirty if row else bool(settings.ocsp_s3_bucket),
|
||||
"enabled": bool(settings.redis_url),
|
||||
"prefix": settings.ocsp_redis_prefix,
|
||||
"dirty": row.dirty if row else bool(settings.redis_url),
|
||||
"published_at": row.published_at.isoformat() if row and row.published_at else None,
|
||||
"last_error": row.last_error if row else None,
|
||||
"snapshot": snapshot,
|
||||
|
||||
Reference in New Issue
Block a user