From 1b99573b65f28a0902280db841f2c7facee5960f Mon Sep 17 00:00:00 2001 From: Radek Date: Wed, 2 Sep 2026 11:51:19 +0100 Subject: [PATCH] Add FastAPI entropy service with simple API-key auth and Dockerfile api.py runs the camera producer in a background thread and serves the latest entropy blob over HTTP. Endpoints: GET / (info), GET /healthz (liveness, unauthenticated), GET /entropy (returns {hex, bits, ts}). Auth is a single shared secret via X-API-Key header, controlled by the ENTROPY_API_KEY env var; empty means no auth, so it is easy to start open and lock down later. All config is via env vars for containers. Dockerfile uses python:3.12-slim, installs OpenCV runtime libs, pins deps from requirements.txt, exposes 8000, and has a healthcheck against /healthz. Run with: docker build -t entropy-rng . && docker run -p 8000:8000 \ -e ENTROPY_CAMERA_URL=http://192.168.0.200/mjpg/video.mjpg entropy-rng Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- Dockerfile | 26 ++++++++++++ api.py | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 Dockerfile create mode 100644 api.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d533314 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +# OpenCV runtime libraries +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY entropy.py health.py api.py ./ + +ENV ENTROPY_BITS=256 \ + ENTROPY_INTERVAL=1.0 \ + ENTROPY_API_KEY="" \ + UVICORN_HOST=0.0.0.0 \ + UVICORN_PORT=8000 + +EXPOSE 8000 + +HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \ + CMD python3 -c "import urllib.request,sys; urllib.request.urlopen('http://localhost:8000/healthz').read(); sys.exit(0)" || exit 1 + +CMD ["sh", "-c", "uvicorn api:app --host ${UVICORN_HOST} --port ${UVICORN_PORT}"] diff --git a/api.py b/api.py new file mode 100644 index 0000000..a950a1f --- /dev/null +++ b/api.py @@ -0,0 +1,121 @@ +"""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 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 + +app = FastAPI(title="Entropy-RNG Service", version="1.0") + + +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) + + +@app.on_event("startup") +def _start_producer(): + t = threading.Thread(target=producer_loop, daemon=True) + t.start() + + +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(), + )