diff --git a/app.py b/app.py index d985314..1714864 100644 --- a/app.py +++ b/app.py @@ -141,6 +141,15 @@ app = FastAPI(title="Thermograph", version="0.2.0", lifespan=_lifespan) app.add_middleware(GZipMiddleware, minimum_size=1024) +def _client_ip(request) -> "str | None": + """The real client IP: the left-most X-Forwarded-For hop when a proxy fronts us + (Caddy on prod sets it), otherwise the direct peer address (LAN dev).""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else None + + @app.middleware("http") async def revalidate_static(request, call_next): """Serve the frontend with no-cache (NOT no-store): browsers may keep a copy @@ -152,7 +161,13 @@ async def revalidate_static(request, call_next): response = await call_next(request) path = request.url.path try: - metrics.record_inbound(metrics.classify_inbound(path, BASE), response.status_code) + cat = metrics.classify_inbound(path, BASE) + metrics.record_inbound(cat, response.status_code) + # Retain per-request client IPs for later analysis; skip static assets and the + # dashboard's own metrics polling to keep the log to real, meaningful traffic. + if cat not in ("static", "metrics"): + audit.log_access({"ip": _client_ip(request), "method": request.method, + "path": path, "status": response.status_code, "cat": cat}) except Exception: # noqa: BLE001 - never let instrumentation break a response pass pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/compare", f"{BASE}/legend", f"{BASE}/alerts") diff --git a/audit.py b/audit.py index 29cadda..1c8fbcb 100644 --- a/audit.py +++ b/audit.py @@ -26,6 +26,7 @@ import uuid LOG_ROOT = os.path.join(os.path.dirname(__file__), "..", "logs") AUDIT_DIR = os.path.join(LOG_ROOT, "audit") ERROR_DIR = os.path.join(LOG_ROOT, "errors") +ACCESS_DIR = os.path.join(LOG_ROOT, "access") _lock = threading.Lock() @@ -65,6 +66,13 @@ def log_event(tag: str, record: dict) -> dict: return rec +def log_access(record: dict) -> None: + """Append one request line (client IP, method, path, status) to the daily access + log, so request/IP data is retained for later analysis. Best-effort; never raises + into the request path.""" + _append(os.path.join(ACCESS_DIR, f"access-{_today()}.jsonl"), {"ts": _now_iso(), **record}) + + class RunAudit: """Times a graded run and writes one audit line when the ``with`` block exits.