This commit is contained in:
Jonas Bengtsson
2026-08-31 22:39:20 +02:00
parent 26a027d1e8
commit ac52671b10
8 changed files with 367 additions and 60 deletions
+5
View File
@@ -13,6 +13,11 @@ RUN pip install --no-cache-dir -r /app/api/requirements.txt
COPY api/ /app/api/
# The /configs endpoints share the IMEI map validator with the listener, so
# the listener package is included too (validate_imei_map in
# listener/process.py is the single source of truth for both services).
COPY listener/ /app/listener/
# Run as an unprivileged user.
RUN useradd --system --create-home --uid 10001 appuser
USER appuser
+159 -5
View File
@@ -1,4 +1,5 @@
"""FastAPI application exposing read access to the mqtt_messages table.
"""FastAPI application exposing read access to the mqtt_messages table and
management of the stack's JSON config files.
Authentication: static app tokens from a JSON config file, passed either as
the `X-App-Token` header or as an `app_token` query parameter. Tokens are
@@ -11,6 +12,7 @@ import hmac
import json
import logging
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -19,9 +21,29 @@ import psycopg
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
from psycopg import sql
from listener.process import validate_imei_map
logger = logging.getLogger("api")
def _atomic_write(path: Path, content: str) -> None:
"""Write content to path atomically: tempfile in the same directory, then
os.replace, so concurrent readers never observe a torn or empty file."""
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_name, path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
class AppConfig:
"""Runtime configuration loaded once at startup."""
@@ -33,15 +55,18 @@ class AppConfig:
def load_tokens(path: str) -> dict[str, str]:
"""Load {"tokens": {"app-name": "secret"}} from a JSON file."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
return _extract_tokens(json.loads(Path(path).read_text(encoding="utf-8")), path)
def _extract_tokens(data: object, source: str) -> dict[str, str]:
if not isinstance(data, dict) or not isinstance(data.get("tokens"), dict):
raise ValueError(f"{path}: expected JSON on the form {{\"tokens\": {{name: secret}}}}")
raise ValueError(f"{source}: expected JSON on the form {{\"tokens\": {{name: secret}}}}")
tokens = data["tokens"]
for name, secret in tokens.items():
if not isinstance(secret, str) or not secret:
raise ValueError(f"{path}: token {name!r} must be a non-empty string")
raise ValueError(f"{source}: token {name!r} must be a non-empty string")
if not tokens:
logger.warning("%s contains no tokens; the API will reject all requests", path)
logger.warning("%s contains no tokens; the API will reject all requests", source)
return dict(tokens)
@@ -71,6 +96,19 @@ def create_app() -> FastAPI:
max_limit = int(_env("API_MAX_LIMIT", "10000"))
config = AppConfig(dsn=dsn, tokens=load_tokens(tokens_path), max_limit=max_limit)
# Whitelisted config files the /configs endpoints may read and write. Only
# these names resolve; anything else 404s so the API cannot touch files
# outside the mounted config directories. Validators enforce each file's
# schema on write (a JSON object plus per-config rules).
config_files: dict[str, Path] = {
"app_tokens": Path(tokens_path),
"imei_map": Path(_env("IMEI_MAP_FILE", "/app/config/imei_map.json")),
}
config_validators: dict[str, Any] = {
"app_tokens": lambda body: _extract_tokens(body, "request body"),
"imei_map": lambda body: validate_imei_map(body, "request body"),
}
def authenticate(request: Request) -> None:
token = request.headers.get("X-App-Token")
if not token:
@@ -91,6 +129,122 @@ def create_app() -> FastAPI:
app.state.config = config
app.state.authenticate = authenticate
@app.get("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate)])
def get_config(name: str) -> dict[str, Any]:
path = config_files.get(name)
if path is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"unknown config {name!r}; available: {', '.join(sorted(config_files))}",
)
try:
content = path.read_text(encoding="utf-8")
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"config file {name!r} not found on disk",
)
try:
return json.loads(content)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"config file {name!r} contains invalid JSON: {exc}",
)
def _set_config(name: str, body: dict[str, Any]) -> dict[str, Any]:
path = config_files.get(name)
if path is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"unknown config {name!r}; available: {', '.join(sorted(config_files))}",
)
validator = config_validators.get(name)
if validator is not None:
try:
validator(body)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
try:
_atomic_write(path, json.dumps(body, indent=2, ensure_ascii=False) + "\n")
except OSError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"failed to write config {name!r}: {exc}",
)
# app_tokens backs this API's own authentication: reload immediately
# so callers are not stuck with the old tokens after an update.
if name == "app_tokens":
config.tokens = load_tokens(str(path))
logger.info("app tokens reloaded after config update")
return {"status": "updated", "config": name}
@app.put("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate)])
@app.post("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate)])
def set_config(name: str, body: dict[str, Any]) -> dict[str, Any]:
return _set_config(name, body)
@app.get("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate)])
def get_config(name: str) -> dict[str, Any]:
path = config_files.get(name)
if path is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"unknown config {name!r}; available: {', '.join(sorted(config_files))}",
)
try:
content = path.read_text(encoding="utf-8")
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"config file {name!r} not found on disk",
)
try:
return json.loads(content)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"config file {name!r} contains invalid JSON: {exc}",
)
def _set_config(name: str, body: dict[str, Any]) -> dict[str, Any]:
path = config_files.get(name)
if path is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"unknown config {name!r}; available: {', '.join(sorted(config_files))}",
)
validator = config_validators.get(name)
if validator is not None:
try:
validator(body)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
try:
_atomic_write(path, json.dumps(body, indent=2, ensure_ascii=False) + "\n")
except OSError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"failed to write config {name!r}: {exc}",
)
# app_tokens backs this API's own authentication: reload immediately
# so callers are not stuck with the old tokens after an update.
if name == "app_tokens":
config.tokens = load_tokens(str(path))
logger.info("app tokens reloaded after config update")
return {"status": "updated", "config": name}
@app.put("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate)])
@app.post("/configs/{name}", tags=["config"], dependencies=[Depends(authenticate)])
def set_config(name: str, body: dict[str, Any]) -> dict[str, Any]:
return _set_config(name, body)
@app.get("/health", tags=["meta"])
def health() -> dict[str, Any]:
return {"status": "ok"}