From 4b32929dccac57a3b63266dc8a12c8aad43f4525 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 1 Mar 2026 00:40:14 +0800 Subject: [PATCH] feat: watchtower tracking, dev environment, security logs page - 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 --- .gitea/workflows/deploy-dev.yml | 22 +++ dashboard/backend/main.py | 3 +- dashboard/backend/routers/security.py | 179 ++++++++++++++++++ dashboard/docker-compose.dev.yml | 70 +++++++ dashboard/frontend/src/App.svelte | 3 + .../frontend/src/components/Sidebar.svelte | 3 + dashboard/frontend/src/routes/Security.svelte | 148 +++++++++++++++ immich/docker-compose.yml | 6 + navidrome/docker-compose.yml | 2 + openclaw/docker-compose.yml | 2 + tmp_remote/Caddyfile_nas | 11 ++ watchtower/.env.example | 4 + watchtower/docker-compose.yml | 23 +++ 13 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy-dev.yml create mode 100644 dashboard/backend/routers/security.py create mode 100644 dashboard/docker-compose.dev.yml create mode 100644 dashboard/frontend/src/routes/Security.svelte create mode 100644 watchtower/.env.example create mode 100644 watchtower/docker-compose.yml diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml new file mode 100644 index 0000000..2234f9c --- /dev/null +++ b/.gitea/workflows/deploy-dev.yml @@ -0,0 +1,22 @@ +name: Deploy Dashboard (Dev) +on: + push: + branches: [dev] + paths: + - 'dashboard/**' + - '.gitea/workflows/**' + +jobs: + deploy-dev: + runs-on: ubuntu-latest + container: + volumes: + - /var/packages/ContainerManager/target/usr/bin/docker:/usr/bin/docker + - /volume1/docker/nas-dashboard:/nas-dashboard + steps: + - name: Checkout dev branch + run: git clone --depth 1 --branch dev http://gitea:3000/jimmy/nas-tools.git . + - name: Build dev image + run: DOCKER_BUILDKIT=0 docker build -t nas-dashboard-dev:latest dashboard/ + - name: Deploy dev + run: docker compose -f /nas-dashboard/docker-compose.dev.yml up -d --pull never diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index f4c6034..3470df5 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -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) diff --git a/dashboard/backend/routers/security.py b/dashboard/backend/routers/security.py new file mode 100644 index 0000000..c961afd --- /dev/null +++ b/dashboard/backend/routers/security.py @@ -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, + } diff --git a/dashboard/docker-compose.dev.yml b/dashboard/docker-compose.dev.yml new file mode 100644 index 0000000..ca79ea3 --- /dev/null +++ b/dashboard/docker-compose.dev.yml @@ -0,0 +1,70 @@ +services: + dashboard-dev: + image: nas-dashboard-dev:latest + container_name: nas-dashboard-dev + restart: unless-stopped + ports: + - "127.0.0.1:4001:4000" + environment: + - DOCKER_HOST=tcp://docker-socket-proxy:2375 + - GITEA_URL=http://gitea:3000 + - GITEA_TOKEN=${GITEA_TOKEN} + - VOLUME_ROOT=/volume1 + - SSH_HOST=host.docker.internal + - SSH_USER=zjgump + - VPS_SSH_HOST=158.101.140.85 + - VPS_SSH_USER=jimmyg + - SECRET_KEY=${SECRET_KEY} + - ADMIN_USER=jimmy + - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} + - TELEGRAM_PROXY=${TELEGRAM_PROXY:-} + - CORS_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443 + volumes: + - /volume1:/volume1 + - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro + - /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro + - /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro + - /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - dashboard_internal + - 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: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + dashboard_internal: + gitea_gitea: + external: true diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index 239fc08..76cea7a 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -8,6 +8,7 @@ import OpenClaw from "./routes/OpenClaw.svelte"; import Settings from "./routes/Settings.svelte"; import ChatSummary from "./routes/ChatSummary.svelte"; + import Security from "./routes/Security.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js"; @@ -112,6 +113,8 @@ {:else if page === "settings"} + {:else if page === "security"} + {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index a2a1079..e25c230 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -6,6 +6,7 @@ { id: "docker", label: "Docker", icon: "box" }, { id: "files", label: "Files", icon: "folder" }, { id: "terminal", label: "Terminal", icon: "terminal" }, + { id: "security", label: "Security", icon: "shield" }, ]; const LAN_IP = "192.168.31.222"; @@ -91,6 +92,8 @@ {:else if link.icon === "terminal"} + {:else if link.icon === "shield"} + {/if} {link.label} diff --git a/dashboard/frontend/src/routes/Security.svelte b/dashboard/frontend/src/routes/Security.svelte new file mode 100644 index 0000000..cea9ae9 --- /dev/null +++ b/dashboard/frontend/src/routes/Security.svelte @@ -0,0 +1,148 @@ + + +
+
+

Security

+ +
+ + + {#if stats} +
+
+

Failed Logins (24h)

+

{stats.failed_logins_24h}

+
+
+

Suspicious Requests

+

{stats.suspicious_requests}

+
+
+

Unique IPs

+

{stats.unique_ips}

+
+
+ {/if} + + + {#if stats?.timeline} +
+

Activity (Last 24h)

+
+ {#each [...stats.timeline].reverse() as bar} +
+
+
+ {/each} +
+
+ 24h ago + now +
+
+ {/if} + + + {#if stats?.top_ips?.length} +
+

Top IPs

+
+ {#each stats.top_ips as entry} +
+ {entry.ip} +
+
+
+ {entry.count} +
+ {/each} +
+
+ {/if} + + +
+
+

Event Log

+
+ {#each ["all", "auth_failure", "suspicious_request"] as f} + + {/each} +
+
+ {#if filteredLogs.length > 0} +
+ {#each filteredLogs as e} +
+ {typeLabels[e.type] || e.type} + {e.timestamp} + {#if e.ip} + {e.ip} + {/if} + {#if e.username} + {e.username} + {/if} + {e.message} +
+ {/each} +
+ {:else if !loading} +

No security events found

+ {/if} +
+
diff --git a/immich/docker-compose.yml b/immich/docker-compose.yml index 952ec8f..588fa7b 100644 --- a/immich/docker-compose.yml +++ b/immich/docker-compose.yml @@ -3,6 +3,8 @@ services: image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} container_name: immich-server restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" ports: - "127.0.0.1:2283:2283" volumes: @@ -37,6 +39,8 @@ services: image: redis:7-alpine container_name: immich-redis restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" healthcheck: test: redis-cli ping || exit 1 networks: @@ -51,6 +55,8 @@ services: image: tensorchord/pgvecto-rs:pg16-v0.2.0 container_name: immich-db restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" environment: POSTGRES_USER: ${DB_USERNAME} POSTGRES_PASSWORD: ${DB_PASSWORD} diff --git a/navidrome/docker-compose.yml b/navidrome/docker-compose.yml index 63effe1..8045e6e 100644 --- a/navidrome/docker-compose.yml +++ b/navidrome/docker-compose.yml @@ -3,6 +3,8 @@ services: image: deluan/navidrome:latest container_name: navidrome restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" ports: - "127.0.0.1:4533:4533" environment: diff --git a/openclaw/docker-compose.yml b/openclaw/docker-compose.yml index 9487617..4550b11 100644 --- a/openclaw/docker-compose.yml +++ b/openclaw/docker-compose.yml @@ -3,6 +3,8 @@ services: image: ghcr.io/openclaw/openclaw:latest container_name: openclaw-gateway restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" ports: - "127.0.0.1:3100:18789" environment: diff --git a/tmp_remote/Caddyfile_nas b/tmp_remote/Caddyfile_nas index 53a963a..f4b4d09 100755 --- a/tmp_remote/Caddyfile_nas +++ b/tmp_remote/Caddyfile_nas @@ -61,6 +61,17 @@ ldap.jimmygan.com { reverse_proxy 127.0.0.1:17170 } +dev.nas.jimmygan.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + forward_auth 127.0.0.1:9092 { + uri /api/verify?rd=https://auth.jimmygan.com:8443 + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } + reverse_proxy 127.0.0.1:4001 +} + git.jimmygan.com { tls { dns cloudflare {env.CLOUDFLARE_API_TOKEN} diff --git a/watchtower/.env.example b/watchtower/.env.example new file mode 100644 index 0000000..e893737 --- /dev/null +++ b/watchtower/.env.example @@ -0,0 +1,4 @@ +SMTP_FROM=zjgump@gmail.com +SMTP_TO=zjgump@gmail.com +SMTP_USER=zjgump@gmail.com +SMTP_PASSWORD=your-gmail-app-password diff --git a/watchtower/docker-compose.yml b/watchtower/docker-compose.yml new file mode 100644 index 0000000..9cb2a56 --- /dev/null +++ b/watchtower/docker-compose.yml @@ -0,0 +1,23 @@ +services: + watchtower: + image: containrrr/watchtower + container_name: watchtower + restart: unless-stopped + environment: + WATCHTOWER_LABEL_ENABLE: "true" + WATCHTOWER_SCHEDULE: "0 0 4 * * *" + WATCHTOWER_CLEANUP: "true" + WATCHTOWER_NOTIFICATIONS: email + WATCHTOWER_NOTIFICATION_EMAIL_FROM: ${SMTP_FROM} + WATCHTOWER_NOTIFICATION_EMAIL_TO: ${SMTP_TO} + WATCHTOWER_NOTIFICATION_EMAIL_SERVER: smtp.gmail.com + WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT: "587" + WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER: ${SMTP_USER} + WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD: ${SMTP_PASSWORD} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3"