"""FastAPI entropy service. Runs the camera entropy producer in a background thread and serves the latest generated blob over HTTP. Designed for container deployment: the producer and API share one process, so there is no FIFO to manage and no cross-container IPC. Endpoints: GET /healthz -> liveness probe GET /entropy -> { hex, bits, ts } (requires X-API-Key if configured) GET / -> service info """ import os import threading import time from contextlib import asynccontextmanager from datetime import datetime, timezone from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel import entropy CAMERA_URL = os.environ.get("ENTROPY_CAMERA_URL", "http://192.168.0.200/mjpg/video.mjpg") BITS = int(os.environ.get("ENTROPY_BITS", "256")) INTERVAL = float(os.environ.get("ENTROPY_INTERVAL", "1.0")) NO_HEALTH = os.environ.get("ENTROPY_NO_HEALTH", "") == "1" # Simple shared-secret auth. Empty = no auth (for the first iteration). API_KEY = os.environ.get("ENTROPY_API_KEY", "") NUM_BYTES = BITS // 8 class LatestBlob: """Thread-safe holder for the most recent entropy blob.""" def __init__(self): self._lock = threading.Lock() self._blob = None self._ts = None self._ok = False def set(self, blob): with self._lock: self._blob = blob self._ts = time.time() self._ok = True def get(self): with self._lock: return self._blob, self._ts, self._ok def mark_failed(self): with self._lock: self._ok = False latest = LatestBlob() def producer_loop(): """Continuously generate entropy blobs and stash the latest one.""" while True: try: frame = entropy.fetch_frame_opencv(CAMERA_URL) raw = entropy.frame_raw_bytes(frame) if not NO_HEALTH: from health import check as health_check health_check(raw) digest = entropy.HMACDRBG(raw).generate(NUM_BYTES) latest.set(digest) except Exception as e: print(f"[producer] error: {e}", flush=True) latest.mark_failed() time.sleep(INTERVAL) @asynccontextmanager async def lifespan(app): t = threading.Thread(target=producer_loop, daemon=True) t.start() yield app = FastAPI(title="Entropy-RNG Service", version="1.0", lifespan=lifespan) class EntropyResponse(BaseModel): hex: str bits: int ts: str @app.get("/") def root(): blob, ts, ok = latest.get() return { "service": "entropy-rng", "bits": BITS, "healthy": ok, "last_update": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() if ts else None, } @app.get("/healthz") def healthz(): blob, ts, ok = latest.get() if not ok or blob is None: raise HTTPException(status_code=503, detail="no entropy available") return {"status": "ok"} @app.get("/entropy") def get_entropy(x_api_key: str | None = Header(default=None)): if API_KEY and x_api_key != API_KEY: raise HTTPException(status_code=401, detail="invalid or missing API key") blob, ts, ok = latest.get() if blob is None: raise HTTPException(status_code=503, detail="no entropy available yet") return EntropyResponse( hex=blob.hex(), bits=BITS, ts=datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(), )