Allow dedicated Redis for OCSP publication

This commit is contained in:
2026-10-31 20:26:37 +00:00
parent ef64c4a532
commit a101182fdd
10 changed files with 93 additions and 33 deletions
+4 -2
View File
@@ -66,9 +66,11 @@ OTEL_EXCLUDED_PATHS=/api/health
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
# --- OCSP status publisher (optional) --------------------------------------
# When REDIS_URL is configured, KCA publishes one full CBOR status snapshot
# per CA to the same Redis instance and logical database used for sessions.
# KCA publishes one full CBOR status snapshot per CA. By default it uses the
# same Redis instance and logical database as sessions. Set KCA_OCSP_REDIS_URL
# to use a dedicated instance instead (also works when REDIS_URL is unset).
# Keys use a separate prefix and expire unless refreshed by the heartbeat.
#KCA_OCSP_REDIS_URL=redis://localhost:6380/0
KCA_OCSP_REDIS_PREFIX=ocsp
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
# Republish unchanged snapshots to renew their Redis TTL. Keep the heartbeat
@@ -167,6 +167,7 @@ class Settings:
app_host: str
app_port: int
redis_url: Optional[str]
ocsp_redis_url: Optional[str]
static_dir: str
logging_config: Optional[str]
#: AES-256-GCM master key protecting private keys at rest.
@@ -195,6 +196,11 @@ class Settings:
ocsp_heartbeat_interval_seconds: int
ocsp_redis_expiry_seconds: int
@property
def ocsp_effective_redis_url(self) -> Optional[str]:
"""Dedicated OCSP Redis when configured, otherwise the session Redis."""
return self.ocsp_redis_url or self.redis_url
@staticmethod
def from_env() -> "Settings":
raw_master = os.environ.get("KCA_MASTER_KEY")
@@ -226,6 +232,7 @@ class Settings:
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
redis_url=os.environ.get("REDIS_URL") or None,
ocsp_redis_url=os.environ.get("KCA_OCSP_REDIS_URL") or None,
static_dir=_env("STATIC_DIR", "web/dist"),
logging_config=os.environ.get("LOGGING_CONFIG") or None,
master_key=master_key,
+13 -11
View File
@@ -1,9 +1,8 @@
"""Per-CA OCSP status snapshots and Redis publication.
KCA is the source of truth for certificate status. This module builds a
compact CBOR 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.
compact CBOR document per CA and stores it in the selected Redis instance, so
the standalone OCSP responder never needs database access or a CA private key.
"""
from __future__ import annotations
@@ -97,9 +96,9 @@ async def publish_snapshot(
owned_client = None
if client is None:
if not settings.redis_url:
if not settings.ocsp_effective_redis_url:
raise RuntimeError("REDIS_URL is not configured")
client = Redis.from_url(settings.redis_url)
client = Redis.from_url(settings.ocsp_effective_redis_url)
owned_client = client
try:
await client.set(key, body, ex=settings.ocsp_redis_expiry_seconds)
@@ -112,7 +111,7 @@ async def publish_snapshot(
async def mark_ocsp_dirty(ca_id: UUID) -> None:
"""Mark one issuing CA's status snapshot as needing a republish."""
if not settings.redis_url:
if not settings.ocsp_effective_redis_url:
return
row = await OcspSnapshot.get_or_none(ca_id=ca_id)
if row is None:
@@ -126,7 +125,7 @@ async def mark_ocsp_dirty(ca_id: UUID) -> None:
async def mark_all_cas_dirty(vault: Vault) -> None:
if not settings.redis_url:
if not settings.ocsp_effective_redis_url:
return
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
await mark_ocsp_dirty(ca.id)
@@ -155,11 +154,14 @@ class OcspPublisherMixin(KayaMixin):
return None
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
if not settings.redis_url:
log.info("OCSP Redis publication disabled (REDIS_URL unset)")
if not settings.ocsp_effective_redis_url:
log.info(
"OCSP Redis publication disabled "
"(REDIS_URL and KCA_OCSP_REDIS_URL unset)"
)
return
if self._redis_client is None:
self._redis_client = Redis.from_url(settings.redis_url)
self._redis_client = Redis.from_url(settings.ocsp_effective_redis_url)
self._owns_redis_client = True
self._task = loop.create_task(self._poll())
log.info(
@@ -249,7 +251,7 @@ def publisher_mixin_from_settings(
settings_obj: Settings,
ensure_context: Callable[[], Awaitable[None]],
) -> Optional[OcspPublisherMixin]:
if not settings_obj.redis_url:
if not settings_obj.ocsp_effective_redis_url:
return None
return OcspPublisherMixin(
settings_obj.ocsp_publish_interval_seconds,
@@ -155,9 +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.redis_url),
"enabled": bool(settings.ocsp_effective_redis_url),
"prefix": settings.ocsp_redis_prefix,
"dirty": row.dirty if row else bool(settings.redis_url),
"dirty": row.dirty if row else bool(settings.ocsp_effective_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,
+39
View File
@@ -28,5 +28,44 @@ class OcspExpirySettingsTest(unittest.TestCase):
Settings.from_env()
class OcspRedisSettingsTest(unittest.TestCase):
def test_defaults_to_disabled(self) -> None:
with patch.dict(os.environ, {}, clear=True):
settings = Settings.from_env()
self.assertIsNone(settings.redis_url)
self.assertIsNone(settings.ocsp_redis_url)
self.assertIsNone(settings.ocsp_effective_redis_url)
def test_falls_back_to_session_redis(self) -> None:
with patch.dict(
os.environ, {"REDIS_URL": "redis://sessions:6379/0"}, clear=True
):
settings = Settings.from_env()
self.assertEqual(
"redis://sessions:6379/0", settings.ocsp_effective_redis_url
)
def test_uses_dedicated_ocsp_redis(self) -> None:
with patch.dict(
os.environ,
{
"REDIS_URL": "redis://sessions:6379/0",
"KCA_OCSP_REDIS_URL": "redis://ocsp:6379/0",
},
clear=True,
):
settings = Settings.from_env()
self.assertEqual("redis://sessions:6379/0", settings.redis_url)
self.assertEqual("redis://ocsp:6379/0", settings.ocsp_effective_redis_url)
def test_dedicated_ocsp_redis_without_session_redis(self) -> None:
with patch.dict(
os.environ, {"KCA_OCSP_REDIS_URL": "redis://ocsp:6379/0"}, clear=True
):
settings = Settings.from_env()
self.assertIsNone(settings.redis_url)
self.assertEqual("redis://ocsp:6379/0", settings.ocsp_effective_redis_url)
if __name__ == "__main__":
unittest.main()