Files
mqtt-ingester/api/main.py
T
2026-08-30 11:30:09 +02:00

176 lines
5.8 KiB
Python

"""FastAPI service exposing telemetry data from TimescaleDB over HTTPS.
Requires mutual TLS: clients must present a certificate signed by the CA.
Requests are served from a pool of database connections, so each request
reuses an established session instead of paying connect/auth costs.
"""
import os
from contextlib import asynccontextmanager
from typing import Any, Final, Optional
import psycopg
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from psycopg import sql
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from shared.access import COOKIE_NAME, Identity, hash_token, load_identity, topic_conditions
_SELECT_SQL: Final = "SELECT time, topic, device_id, payload FROM telemetry"
# Connections use row_factory=dict_row, so each row is a dict.
_DictConnection = psycopg.Connection[dict[str, Any]]
pool: Optional[ConnectionPool[_DictConnection]] = None
def _connect_kwargs() -> dict[str, Any]:
return {
"host": os.getenv("DB_HOST", "timescaledb"),
"port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "telemetry"),
"user": os.getenv("DB_USER", "ingester"),
"password": os.getenv("DB_PASSWORD", "changeme"),
"row_factory": dict_row,
}
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
# Subscripting solves the pool's invariant connection type; row_factory
# in kwargs makes every pooled connection return dict rows.
server_pool = ConnectionPool[_DictConnection](
kwargs=_connect_kwargs(),
min_size=int(os.getenv("DB_POOL_MIN_SIZE", "2")),
max_size=int(os.getenv("DB_POOL_MAX_SIZE", "10")),
)
server_pool.open(wait=False)
pool = server_pool
try:
yield
finally:
pool = None
server_pool.close()
app = FastAPI(title="MQTT Telemetry API", lifespan=lifespan)
def get_pool() -> ConnectionPool[_DictConnection]:
if pool is None:
raise RuntimeError("Database pool not initialized")
return pool
def _request_identity(request: Request) -> Optional[Identity]:
"""Resolve the request principal: mTLS cert, session cookie, or None."""
# uvicorn populates the client cert into the transport when mTLS is enabled.
cert = getattr(request.scope.get("transport"), "get_extra_info", lambda _: None)(
"peercert"
)
if cert is not None:
return Identity.service(cert)
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
conn = get_pool().getconn()
try:
return load_identity(conn, hash_token(token))
finally:
get_pool().putconn(conn)
@app.middleware("http")
async def require_client_cert(request: Request, call_next):
# Return a response directly: HTTPException raised from middleware is
# not handled by the (inner) exception handlers and would surface
# as a 500.
identity = _request_identity(request)
if identity is None:
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
request.state.identity = identity
return await call_next(request)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/whoami")
def whoami(request: Request) -> dict[str, Any]:
"""Show the authenticated principal and effective access rules.
Useful to verify which auth path a request took (client certificate
vs session cookie) and which topics the effective rules allow.
"""
identity: Identity = request.state.identity
return {
"username": identity.username,
"role": identity.role,
"rules": {"allow": list(identity.rules.allow), "deny": list(identity.rules.deny)},
}
def _telemetry_query(
identity: Identity,
topic: Optional[str],
device_id: Optional[str],
limit: int,
) -> tuple[sql.Composed, list[Any]]:
"""Build the SELECT for the requested filters as composed SQL.
The statement is composed with psycopg.sql, and all values are bound
as parameters, so nothing is interpolated into the query string.
Access rules are folded into the WHERE clause (deny wins over allow),
so filtering happens in the database and LIMIT stays correct.
"""
conditions, param_groups = topic_conditions(identity)
params: list[Any] = []
if topic:
conditions.append(sql.SQL("topic = %s"))
params.append(topic)
if device_id:
conditions.append(sql.SQL("device_id = %s"))
params.append(device_id)
params.extend(p for group in param_groups for p in group)
query = sql.SQL(_SELECT_SQL)
if conditions:
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
query += sql.SQL(" ORDER BY time DESC LIMIT %s")
params.append(limit)
return query, params
@app.get("/telemetry")
def list_telemetry(
request: Request,
topic: Optional[str] = Query(default=None),
device_id: Optional[str] = Query(default=None),
limit: int = Query(default=100, le=1000),
) -> list[dict[str, Any]]:
identity: Identity = request.state.identity
query, params = _telemetry_query(identity, topic, device_id, limit)
with get_pool().connection() as conn, conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
@app.get("/telemetry/latest")
def latest_telemetry(
request: Request,
topic: Optional[str] = Query(default=None),
device_id: Optional[str] = Query(default=None),
) -> Optional[dict[str, Any]]:
identity: Identity = request.state.identity
query, params = _telemetry_query(identity, topic, device_id, 1)
with get_pool().connection() as conn, conn.cursor() as cur:
cur.execute(query, params)
row = cur.fetchone()
if row is None:
raise HTTPException(status_code=404, detail="No data found")
return row