Merge pull request 'fix: security page - logfmt parser and disable Caddy log fetching' (#3) from dev into main
Deploy Dashboard / deploy (push) Successful in 32s

This commit was merged in pull request #3.
This commit is contained in:
2026-03-01 13:00:21 +08:00
2 changed files with 40 additions and 50 deletions
+37 -27
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Query
from config import DOCKER_HOST from config import DOCKER_HOST
router = APIRouter() router = APIRouter()
client = docker.DockerClient(base_url=DOCKER_HOST) client = docker.DockerClient(base_url=DOCKER_HOST, timeout=10)
_tz_cst = timezone(timedelta(hours=8)) _tz_cst = timezone(timedelta(hours=8))
@@ -26,25 +26,47 @@ _INTERNAL_PATHS = re.compile(
) )
_LOGFMT_RE = re.compile(r'(\w+)="([^"]*)"|\b(\w+)=(\S+)')
def _parse_logfmt(line: str) -> dict:
"""Parse a logfmt line into a dict."""
result = {}
for m in _LOGFMT_RE.finditer(line):
if m.group(1):
result[m.group(1)] = m.group(2)
elif m.group(3):
result[m.group(3)] = m.group(4)
return result
def _parse_authelia_logs(lines: str, limit: int) -> list[dict]: def _parse_authelia_logs(lines: str, limit: int) -> list[dict]:
"""Extract failed auth attempts from Authelia JSON logs.""" """Extract failed auth attempts from Authelia logs (logfmt or JSON)."""
entries = [] entries = []
for line in lines.strip().splitlines(): for line in lines.strip().splitlines():
# Authelia outputs JSON log lines line = line.strip()
try: if not line:
obj = json.loads(line.strip())
except (json.JSONDecodeError, ValueError):
continue continue
# Try JSON first, fall back to logfmt
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
obj = _parse_logfmt(line)
if not obj:
continue
level = obj.get("level", "") level = obj.get("level", "")
msg = obj.get("msg", "") msg = obj.get("msg", "")
# Failed auth: level=error or warning with auth-related messages
is_failed = ( is_failed = (
level in ("error", "warning") level in ("error", "warning")
and any(kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied")) and any(kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied"))
) )
if not is_failed: # Also catch "not authorized" for anonymous access attempts
is_unauthorized = "is not authorized" in msg.lower()
if not is_failed and not is_unauthorized:
continue continue
ts_raw = obj.get("time", "") ts_raw = obj.get("time", "")
@@ -54,7 +76,7 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]:
ts = ts_raw[:19] if ts_raw else "" ts = ts_raw[:19] if ts_raw else ""
entries.append({ entries.append({
"type": "auth_failure", "type": "auth_failure" if is_failed else "unauthorized",
"timestamp": ts, "timestamp": ts,
"message": msg, "message": msg,
"username": obj.get("username", obj.get("user", "")), "username": obj.get("username", obj.get("user", "")),
@@ -110,8 +132,8 @@ def _parse_caddy_logs(lines: str, limit: int) -> list[dict]:
@router.get("/logs") @router.get("/logs")
def security_logs( def security_logs(
tail: int = Query(500, le=5000), tail: int = Query(200, le=2000),
limit: int = Query(200, le=1000), limit: int = Query(100, le=500),
): ):
"""Fetch and parse security-relevant logs from Authelia and Caddy.""" """Fetch and parse security-relevant logs from Authelia and Caddy."""
results = [] results = []
@@ -126,15 +148,8 @@ def security_logs(
except Exception as e: except Exception as e:
results.append({"type": "error", "message": f"Authelia log error: {e}", "timestamp": "", "ip": ""}) results.append({"type": "error", "message": f"Authelia log error: {e}", "timestamp": "", "ip": ""})
# Caddy logs # Caddy logs disabled — Caddy outputs massive logs that timeout the Docker API
try: # TODO: re-enable when Caddy is configured with structured access logging to a file
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 # Sort by timestamp descending
results.sort(key=lambda x: x.get("timestamp", ""), reverse=True) results.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
@@ -142,7 +157,7 @@ def security_logs(
@router.get("/stats") @router.get("/stats")
def security_stats(tail: int = Query(2000, le=10000)): def security_stats(tail: int = Query(500, le=2000)):
"""Aggregated security stats: failed logins, top IPs, hourly timeline.""" """Aggregated security stats: failed logins, top IPs, hourly timeline."""
auth_entries = [] auth_entries = []
caddy_entries = [] caddy_entries = []
@@ -154,12 +169,7 @@ def security_stats(tail: int = Query(2000, le=10000)):
except Exception: except Exception:
pass pass
try: # Caddy logs disabled — too large, causes Docker API timeouts
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 all_entries = auth_entries + caddy_entries
+3 -23
View File
@@ -35,29 +35,8 @@ services:
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
networks: networks:
- dashboard_internal - nas-dashboard_internal
- gitea_gitea - gitea_gitea
depends_on:
- docker-socket-proxy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
docker-socket-proxy:
image: tecnativa/docker-socket-proxy
container_name: docker-socket-proxy-dev
restart: unless-stopped
environment:
CONTAINERS: 1
IMAGES: 1
INFO: 1
POST: 1
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- dashboard_internal
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -65,6 +44,7 @@ services:
max-file: "3" max-file: "3"
networks: networks:
dashboard_internal: nas-dashboard_internal:
external: true
gitea_gitea: gitea_gitea:
external: true external: true