feat: watchtower, dev environment, security logs, SMTP tunnel #1
@@ -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
|
||||||
@@ -5,7 +5,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from slowapi.errors import RateLimitExceeded
|
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 auth as auth_module
|
||||||
|
|
||||||
import asyncio
|
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(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(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(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)
|
# WebSocket endpoint (auth handled inside handler via Query param)
|
||||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
image: tecnativa/docker-socket-proxy
|
image: tecnativa/docker-socket-proxy
|
||||||
container_name: docker-socket-proxy
|
container_name: docker-socket-proxy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
environment:
|
environment:
|
||||||
CONTAINERS: 1
|
CONTAINERS: 1
|
||||||
IMAGES: 1
|
IMAGES: 1
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import OpenClaw from "./routes/OpenClaw.svelte";
|
import OpenClaw from "./routes/OpenClaw.svelte";
|
||||||
import Settings from "./routes/Settings.svelte";
|
import Settings from "./routes/Settings.svelte";
|
||||||
import ChatSummary from "./routes/ChatSummary.svelte";
|
import ChatSummary from "./routes/ChatSummary.svelte";
|
||||||
|
import Security from "./routes/Security.svelte";
|
||||||
import Login from "./routes/Login.svelte";
|
import Login from "./routes/Login.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js";
|
import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js";
|
||||||
@@ -112,6 +113,8 @@
|
|||||||
<ChatSummary />
|
<ChatSummary />
|
||||||
{:else if page === "settings"}
|
{:else if page === "settings"}
|
||||||
<Settings />
|
<Settings />
|
||||||
|
{:else if page === "security"}
|
||||||
|
<Security />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
{ id: "docker", label: "Docker", icon: "box" },
|
{ id: "docker", label: "Docker", icon: "box" },
|
||||||
{ id: "files", label: "Files", icon: "folder" },
|
{ id: "files", label: "Files", icon: "folder" },
|
||||||
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
||||||
|
{ id: "security", label: "Security", icon: "shield" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LAN_IP = "192.168.31.222";
|
const LAN_IP = "192.168.31.222";
|
||||||
@@ -91,6 +92,8 @@
|
|||||||
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
||||||
{:else if link.icon === "terminal"}
|
{:else if link.icon === "terminal"}
|
||||||
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||||
|
{:else if link.icon === "shield"}
|
||||||
|
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{link.label}
|
{link.label}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script>
|
||||||
|
import { get } from "../lib/api.js";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
let logs = $state([]);
|
||||||
|
let stats = $state(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let filter = $state("all");
|
||||||
|
|
||||||
|
onMount(loadAll);
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading = true;
|
||||||
|
await Promise.all([loadStats(), loadLogs()]);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
stats = await get("/security/stats");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load security stats:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
try {
|
||||||
|
const res = await get("/security/logs?limit=300");
|
||||||
|
logs = res.logs;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load security logs:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filteredLogs = $derived(
|
||||||
|
filter === "all" ? logs : logs.filter(l => l.type === filter)
|
||||||
|
);
|
||||||
|
|
||||||
|
let maxTimelineCount = $derived(
|
||||||
|
stats ? Math.max(1, ...stats.timeline.map(t => t.count)) : 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", error: "Error" };
|
||||||
|
const typeColors = { auth_failure: "text-red-400", suspicious_request: "text-amber-400", error: "text-surface-500" };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-surface-800 dark:text-surface-100">Security</h2>
|
||||||
|
<button onclick={loadAll} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
|
||||||
|
{loading ? "Loading..." : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats cards -->
|
||||||
|
{#if stats}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Failed Logins (24h)</p>
|
||||||
|
<p class="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">{stats.failed_logins_24h}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Suspicious Requests</p>
|
||||||
|
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400 mt-1">{stats.suspicious_requests}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Unique IPs</p>
|
||||||
|
<p class="text-2xl font-bold text-surface-700 dark:text-surface-200 mt-1">{stats.unique_ips}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Timeline chart -->
|
||||||
|
{#if stats?.timeline}
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Activity (Last 24h)</h3>
|
||||||
|
<div class="flex items-end gap-1 h-24">
|
||||||
|
{#each [...stats.timeline].reverse() as bar}
|
||||||
|
<div class="flex-1 flex flex-col items-center justify-end h-full">
|
||||||
|
<div
|
||||||
|
class="w-full rounded-t transition-all {bar.count > 0 ? 'bg-primary-500 dark:bg-primary-400' : 'bg-surface-100 dark:bg-surface-700'}"
|
||||||
|
style="height: {bar.count > 0 ? Math.max(8, (bar.count / maxTimelineCount) * 100) : 4}%"
|
||||||
|
title="{bar.hours_ago}h ago: {bar.count} events"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mt-1">
|
||||||
|
<span class="text-[10px] text-surface-400">24h ago</span>
|
||||||
|
<span class="text-[10px] text-surface-400">now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Top IPs -->
|
||||||
|
{#if stats?.top_ips?.length}
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top IPs</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each stats.top_ips as entry}
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<code class="text-xs font-mono text-surface-600 dark:text-surface-300 w-36 shrink-0">{entry.ip}</code>
|
||||||
|
<div class="flex-1 bg-surface-100 dark:bg-surface-700 rounded-full h-2 overflow-hidden">
|
||||||
|
<div class="bg-red-500 dark:bg-red-400 h-full rounded-full" style="width: {(entry.count / stats.top_ips[0].count) * 100}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-surface-500 w-8 text-right">{entry.count}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Log table -->
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
{#each ["all", "auth_failure", "suspicious_request"] as f}
|
||||||
|
<button onclick={() => filter = f} class="px-2.5 py-1 text-xs rounded-md transition-colors {filter === f ? 'bg-surface-800 text-white dark:bg-surface-200 dark:text-surface-900' : 'bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:hover:bg-surface-600'}">
|
||||||
|
{f === "all" ? "All" : f === "auth_failure" ? "Auth" : "Requests"}
|
||||||
|
{#if f !== "all"}
|
||||||
|
<span class="ml-1 opacity-60">{logs.filter(l => l.type === f).length}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if filteredLogs.length > 0}
|
||||||
|
<div class="max-h-96 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
|
||||||
|
{#each filteredLogs as e}
|
||||||
|
<div class="text-xs font-mono flex gap-2 leading-5">
|
||||||
|
<span class="{typeColors[e.type] || 'text-surface-500'} w-12 shrink-0">{typeLabels[e.type] || e.type}</span>
|
||||||
|
<span class="text-surface-500 shrink-0">{e.timestamp}</span>
|
||||||
|
{#if e.ip}
|
||||||
|
<span class="text-surface-400 shrink-0 w-28">{e.ip}</span>
|
||||||
|
{/if}
|
||||||
|
{#if e.username}
|
||||||
|
<span class="text-red-400 shrink-0">{e.username}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-surface-300 truncate">{e.message}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if !loading}
|
||||||
|
<p class="text-sm text-surface-400">No security events found</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||||
container_name: immich-server
|
container_name: immich-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:2283:2283"
|
- "127.0.0.1:2283:2283"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -37,6 +39,8 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: immich-redis
|
container_name: immich-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
networks:
|
networks:
|
||||||
@@ -51,6 +55,8 @@ services:
|
|||||||
image: tensorchord/pgvecto-rs:pg16-v0.2.0
|
image: tensorchord/pgvecto-rs:pg16-v0.2.0
|
||||||
container_name: immich-db
|
container_name: immich-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${DB_USERNAME}
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
image: deluan/navidrome:latest
|
image: deluan/navidrome:latest
|
||||||
container_name: navidrome
|
container_name: navidrome
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:4533:4533"
|
- "127.0.0.1:4533:4533"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
image: ghcr.io/openclaw/openclaw:latest
|
image: ghcr.io/openclaw/openclaw:latest
|
||||||
container_name: openclaw-gateway
|
container_name: openclaw-gateway
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3100:18789"
|
- "127.0.0.1:3100:18789"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ ldap.jimmygan.com {
|
|||||||
reverse_proxy 127.0.0.1:17170
|
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 {
|
git.jimmygan.com {
|
||||||
tls {
|
tls {
|
||||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
SMTP_FROM=your-email@gmail.com
|
||||||
|
SMTP_TO=your-email@gmail.com
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASSWORD=your-gmail-app-password
|
||||||
|
SMTP_USER_ENCODED=your-email%40gmail.com
|
||||||
|
SMTP_PASSWORD_ENCODED=your-gmail-app-password
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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: shoutrrr
|
||||||
|
WATCHTOWER_NOTIFICATION_URL: "smtp://${SMTP_USER_ENCODED}:${SMTP_PASSWORD_ENCODED}@smtp.gmail.com:1587/?from=${SMTP_FROM}&to=${SMTP_TO}"
|
||||||
|
extra_hosts:
|
||||||
|
- "smtp.gmail.com:192.168.31.222"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
Reference in New Issue
Block a user