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 <vibe@mistral.ai>
This commit is contained in:
+26
@@ -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}"]
|
||||
@@ -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(),
|
||||
)
|
||||
Reference in New Issue
Block a user