fix: security page - logfmt parser and disable Caddy log fetching #3
@@ -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()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try JSON first, fall back to logfmt
|
||||||
try:
|
try:
|
||||||
obj = json.loads(line.strip())
|
obj = json.loads(line)
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
obj = _parse_logfmt(line)
|
||||||
|
if not obj:
|
||||||
continue
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user