feat: watchtower tracking, dev environment, security logs page
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m54s
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m54s
- watchtower: docker-compose with label filtering, email notifications - watchtower labels on navidrome, openclaw, immich services - dev env: docker-compose.dev.yml (port 4001), deploy-dev.yml CI workflow - dev.nas.jimmygan.com Caddy site block with Authelia forward_auth - security page: backend router parsing Authelia/Caddy container logs - security page: frontend with stats cards, timeline, top IPs, event log - wired security route into App.svelte and Sidebar
This commit is contained in:
@@ -5,7 +5,7 @@ from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary
|
||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security
|
||||
import auth as auth_module
|
||||
|
||||
import asyncio
|
||||
@@ -148,6 +148,7 @@ app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(auth
|
||||
app.include_router(files.router, prefix="/api/files", dependencies=[Depends(auth_module.get_current_user)])
|
||||
app.include_router(system.router, prefix="/api/system", dependencies=[Depends(auth_module.get_current_user)])
|
||||
app.include_router(chat_summary.router, prefix="/api/chat-summary", dependencies=[Depends(auth_module.get_current_user)])
|
||||
app.include_router(security.router, prefix="/api/security", dependencies=[Depends(auth_module.get_current_user)])
|
||||
|
||||
# WebSocket endpoint (auth handled inside handler via Query param)
|
||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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|/admin|/cgi-bin|/shell|/eval|"
|
||||
r"\.php|\.asp|/etc/passwd|/proc/self|\.sql|/actuator|/debug|/config\.json)",
|
||||
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: 4xx/5xx or suspicious path
|
||||
is_suspicious = status >= 400 or _SUSPICIOUS_PATHS.search(uri)
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user