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/docker-compose.yml b/dashboard/docker-compose.yml
index d2d2cc0..b87159a 100644
--- a/dashboard/docker-compose.yml
+++ b/dashboard/docker-compose.yml
@@ -3,6 +3,8 @@ services:
image: tecnativa/docker-socket-proxy
container_name: docker-socket-proxy
restart: unless-stopped
+ labels:
+ - "com.centurylinklabs.watchtower.enable=true"
environment:
CONTAINERS: 1
IMAGES: 1
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 @@
Failed Logins (24h)
+{stats.failed_logins_24h}
+Suspicious Requests
+{stats.suspicious_requests}
+Unique IPs
+{stats.unique_ips}
+{entry.ip}
+ No security events found
+ {/if} +