import docker import re import json from datetime import datetime, timezone, timedelta from collections import Counter from fastapi import APIRouter, Query from config import DOCKER_HOST router = APIRouter() client = docker.DockerClient(base_url=DOCKER_HOST) _tz_cst = timezone(timedelta(hours=8)) # Suspicious path patterns (scanners, exploits, etc.) _SUSPICIOUS_PATHS = re.compile( r"(\.env|\.git/|wp-login|wp-admin|phpmy|/cgi-bin|/shell|/eval|" r"\.asp|/etc/passwd|/proc/self|\.sql|/actuator|/debug|/config\.json|" r"xmlrpc\.php|wp-cron\.php|wp-includes|/\.well-known/security)", re.IGNORECASE, ) # Known internal API paths that should not be flagged _INTERNAL_PATHS = re.compile( r"^/(api/|rest/|backend/|_next/|static/|assets/|favicon)", re.IGNORECASE, ) def _parse_authelia_logs(lines: str, limit: int) -> list[dict]: """Extract failed auth attempts from Authelia JSON logs.""" entries = [] for line in lines.strip().splitlines(): # Authelia outputs JSON log lines try: obj = json.loads(line.strip()) except (json.JSONDecodeError, ValueError): continue level = obj.get("level", "") msg = obj.get("msg", "") # Failed auth: level=error or warning with auth-related messages is_failed = ( level in ("error", "warning") and any(kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied")) ) if not is_failed: continue ts_raw = obj.get("time", "") try: ts = datetime.fromisoformat(ts_raw.replace("Z", "+00:00")).astimezone(_tz_cst).strftime("%Y-%m-%d %H:%M:%S") except Exception: ts = ts_raw[:19] if ts_raw else "" entries.append({ "type": "auth_failure", "timestamp": ts, "message": msg, "username": obj.get("username", obj.get("user", "")), "ip": obj.get("remote_ip", obj.get("ip", "")), "level": level, }) return entries[-limit:] def _parse_caddy_logs(lines: str, limit: int) -> list[dict]: """Extract suspicious requests from Caddy JSON access logs.""" entries = [] for line in lines.strip().splitlines(): try: obj = json.loads(line.strip()) except (json.JSONDecodeError, ValueError): continue status = obj.get("status", 0) uri = obj.get("uri", obj.get("request", {}).get("uri", "")) remote_ip = obj.get("remote_ip", obj.get("request", {}).get("remote_ip", "")) # Flag: suspicious path (skip known internal paths), or 4xx on unknown paths is_suspicious = False if _SUSPICIOUS_PATHS.search(uri): is_suspicious = not _INTERNAL_PATHS.match(uri) elif status >= 400 and not _INTERNAL_PATHS.match(uri): is_suspicious = True if not is_suspicious: continue ts_raw = obj.get("ts", obj.get("timestamp", "")) try: if isinstance(ts_raw, (int, float)): ts = datetime.fromtimestamp(ts_raw, tz=_tz_cst).strftime("%Y-%m-%d %H:%M:%S") else: ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")).astimezone(_tz_cst).strftime("%Y-%m-%d %H:%M:%S") except Exception: ts = str(ts_raw)[:19] entries.append({ "type": "suspicious_request", "timestamp": ts, "message": f"{status} {obj.get('request', {}).get('method', 'GET')} {uri}", "ip": remote_ip, "status": status, "path": uri, }) return entries[-limit:] @router.get("/logs") def security_logs( tail: int = Query(500, le=5000), limit: int = Query(200, le=1000), ): """Fetch and parse security-relevant logs from Authelia and Caddy.""" results = [] # Authelia logs try: authelia = client.containers.get("authelia") raw = authelia.logs(tail=tail, timestamps=False).decode(errors="replace") results.extend(_parse_authelia_logs(raw, limit)) except docker.errors.NotFound: pass except Exception as e: results.append({"type": "error", "message": f"Authelia log error: {e}", "timestamp": "", "ip": ""}) # Caddy logs try: caddy = client.containers.get("caddy") raw = caddy.logs(tail=tail, timestamps=False).decode(errors="replace") results.extend(_parse_caddy_logs(raw, limit)) except docker.errors.NotFound: pass except Exception as e: results.append({"type": "error", "message": f"Caddy log error: {e}", "timestamp": "", "ip": ""}) # Sort by timestamp descending results.sort(key=lambda x: x.get("timestamp", ""), reverse=True) return {"logs": results[:limit]} @router.get("/stats") def security_stats(tail: int = Query(2000, le=10000)): """Aggregated security stats: failed logins, top IPs, hourly timeline.""" auth_entries = [] caddy_entries = [] try: authelia = client.containers.get("authelia") raw = authelia.logs(tail=tail, timestamps=False).decode(errors="replace") auth_entries = _parse_authelia_logs(raw, 1000) except Exception: pass try: caddy = client.containers.get("caddy") raw = caddy.logs(tail=tail, timestamps=False).decode(errors="replace") caddy_entries = _parse_caddy_logs(raw, 1000) except Exception: pass all_entries = auth_entries + caddy_entries # Count by IP ip_counter = Counter(e.get("ip", "unknown") for e in all_entries if e.get("ip")) top_ips = ip_counter.most_common(10) # Hourly timeline (last 24 hours) now = datetime.now(_tz_cst) hourly = Counter() for e in all_entries: try: ts = datetime.strptime(e["timestamp"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=_tz_cst) hours_ago = int((now - ts).total_seconds() / 3600) if 0 <= hours_ago < 24: hourly[hours_ago] += 1 except Exception: continue timeline = [{"hours_ago": h, "count": hourly.get(h, 0)} for h in range(24)] return { "failed_logins_24h": len(auth_entries), "suspicious_requests": len(caddy_entries), "unique_ips": len(ip_counter), "top_ips": [{"ip": ip, "count": c} for ip, c in top_ips], "timeline": timeline, }