🚧 WIP
This commit is contained in:
@@ -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
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user