Dashboard: accounts totals only; log client IPs per request (#140)

Dashboard ACCOUNTS section now shows only totals (users, active/total subscriptions,
notifications, push subs) — the per-user and per-subscription lists are gone, and the
dashboard no longer queries that PII at all.

Backend: add a per-request access log (logs/access/access-<date>.jsonl) recording the
client IP (real IP via the left-most X-Forwarded-For hop when proxied behind Caddy,
else the peer address), method, path, status, and category. Static assets and the
dashboard's own metrics polling are skipped. Best-effort, never raises into the request
path — retained for later traffic/IP analysis.
This commit is contained in:
Emi Griffith 2026-07-16 14:58:40 -07:00 committed by GitHub
parent 19f4965258
commit 5222ae3067
2 changed files with 24 additions and 1 deletions

17
app.py
View file

@ -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")

View file

@ -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.