From 46681507279cd3fe159462c87b3cbfbe3ff30313 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 17 Mar 2026 15:09:01 +0100 Subject: [PATCH] feat: Security Audit Tab im Admin-Panel mit nftables/SSH/Fail2ban-Checks --- internal/api/server.go | 109 +++++++++++++++++++++++++++++++++++++++++ src/app/admin/page.tsx | 101 ++++++++++++++++++++++++++++++++++++++ src/lib/api.ts | 17 +++++++ 3 files changed, 227 insertions(+) diff --git a/internal/api/server.go b/internal/api/server.go index d746c1b..5ffb2d1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -105,6 +105,7 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleServiceAction))) s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats))) + s.mux.HandleFunc("GET /api/admin/security/audit", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityAudit))) // Export routes s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF))) @@ -1489,3 +1490,111 @@ func mailRefToInfo(store *storage.Store, ref *storage.MailRef) *mailInfo { Subject: pm.Subject, } } + +// ── Security Audit ────────────────────────────────────────────────────────── + +type securityCheck struct { + Name string `json:"name"` + Status string `json:"status"` // "ok" | "warning" | "error" + Message string `json:"message"` +} + +func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) { + var checks []securityCheck + + // 1. Firewall (nftables) aktiv? + nftOut, err := exec.CommandContext(r.Context(), "nft", "list", "ruleset").Output() + nftStr := string(nftOut) + firewallActive := err == nil + + if !firewallActive { + checks = append(checks, securityCheck{ + Name: "Firewall (nftables)", + Status: "error", + Message: "nft konnte nicht ausgeführt werden — Firewall möglicherweise inaktiv", + }) + } else if strings.Contains(nftStr, "policy drop") { + checks = append(checks, securityCheck{ + Name: "Firewall (nftables)", + Status: "ok", + Message: "Aktiv — Input-Chain policy: drop (Whitelist-Modus)", + }) + } else { + checks = append(checks, securityCheck{ + Name: "Firewall (nftables)", + Status: "warning", + Message: "nftables aktiv, aber Input-Chain policy ist nicht 'drop'", + }) + } + + // 2. Port 3000 (Next.js) extern erreichbar? + if !firewallActive { + checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"}) + } else if strings.Contains(nftStr, "dport 3000") { + checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "warning", Message: "Port 3000 explizit in Firewall-Regeln — prüfen ob gewollt"}) + } else { + checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"}) + } + + // 3. Port 8080 (Go Backend) extern erreichbar? + if !firewallActive { + checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"}) + } else if strings.Contains(nftStr, "dport 8080") { + checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "warning", Message: "Port 8080 explizit in Firewall-Regeln — prüfen ob gewollt"}) + } else { + checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"}) + } + + // 4. HTTPS aktiv? + if firewallActive && strings.Contains(nftStr, "dport 443") { + checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "ok", Message: "Port 443 in Firewall freigegeben"}) + } else { + checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "warning", Message: "Kein HTTPS — Verbindungen unverschlüsselt (certbot empfohlen)"}) + } + + // 5. SSH PermitRootLogin + PasswordAuthentication + sshConf, err := os.ReadFile("/etc/ssh/sshd_config") + if err == nil { + lines := strings.Split(string(sshConf), "\n") + rootLogin := "" + passAuth := "" + for _, l := range lines { + tl := strings.ToLower(strings.TrimSpace(l)) + if strings.HasPrefix(tl, "permitrootlogin") && !strings.HasPrefix(tl, "#") { + rootLogin = tl + } + if strings.HasPrefix(tl, "passwordauthentication") && !strings.HasPrefix(tl, "#") { + passAuth = tl + } + } + if strings.Contains(rootLogin, "no") || strings.Contains(rootLogin, "prohibit-password") { + checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "ok", Message: rootLogin}) + } else if rootLogin == "" { + checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: "Nicht explizit gesetzt (Standard: prohibit-password)"}) + } else { + checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: rootLogin + " — Passwort-Login für root möglich"}) + } + if strings.Contains(passAuth, "no") { + checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "ok", Message: "Nur Key-basierte Authentifizierung"}) + } else if passAuth == "" { + checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: "Nicht explizit deaktiviert — SSH-Keys empfohlen"}) + } else { + checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: passAuth + " — Brute-Force-Risiko"}) + } + } else { + checks = append(checks, securityCheck{Name: "SSH Konfiguration", Status: "warning", Message: "/etc/ssh/sshd_config nicht lesbar"}) + } + + // 6. Fail2ban + f2bOut, err := exec.CommandContext(r.Context(), "systemctl", "is-active", "fail2ban").Output() + if err == nil && strings.TrimSpace(string(f2bOut)) == "active" { + checks = append(checks, securityCheck{Name: "Fail2ban", Status: "ok", Message: "Aktiv"}) + } else { + checks = append(checks, securityCheck{Name: "Fail2ban", Status: "warning", Message: "Nicht aktiv — kein Brute-Force-Schutz (apt install fail2ban)"}) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "checks": checks, + "run_at": time.Now().UTC().Format(time.RFC3339), + }) +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 7222056..7fc58af 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -17,6 +17,7 @@ import { getSystemStats, uploadMailFiles, getUploadProgress, + getSecurityAudit, type User, type AuditEntry, type SMTPStatus, @@ -24,6 +25,8 @@ import { type ServiceStatus, type SystemStats, type UploadJob, + type SecurityCheck, + type SecurityAuditResult, } from "@/lib/api"; import { Navbar } from "@/components/navbar"; import { Button } from "@/components/ui/button"; @@ -119,6 +122,11 @@ export default function AdminPage() { const [auditPage, setAuditPage] = useState(1); const [auditLoading, setAuditLoading] = useState(false); + // Security audit state + const [securityAudit, setSecurityAudit] = useState(null); + const [securityLoading, setSecurityLoading] = useState(false); + const [securityError, setSecurityError] = useState(""); + // Upload state const [uploadDragging, setUploadDragging] = useState(false); const [uploadJob, setUploadJob] = useState(null); @@ -341,6 +349,19 @@ export default function AdminPage() { } } + async function runSecurityAudit() { + setSecurityLoading(true); + setSecurityError(""); + try { + const result = await getSecurityAudit(); + setSecurityAudit(result); + } catch { + setSecurityError("Security-Audit konnte nicht ausgeführt werden."); + } finally { + setSecurityLoading(false); + } + } + const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE); return ( @@ -363,6 +384,7 @@ export default function AdminPage() { Benutzer Audit-Log Import + Security Module @@ -1182,6 +1204,85 @@ export default function AdminPage() { )} + {/* ── Security Audit ── */} + +
+
+

Security Audit

+ {securityAudit && ( +

+ Zuletzt geprüft: {new Date(securityAudit.run_at).toLocaleString("de-DE")} +

+ )} +
+ +
+ + {securityError && ( + + {securityError} + + )} + + {!securityAudit && !securityLoading && !securityError && ( +
+ Klicke auf “Jetzt prüfen” um den Security-Audit zu starten. +
+ )} + + {securityLoading && ( +
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+ )} + + {securityAudit && !securityLoading && ( +
+ {securityAudit.checks.map((check: SecurityCheck, i: number) => ( + + + +
+
+ {check.name} + + {check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"} + +
+

{check.message}

+
+
+
+ ))} + + {/* Summary */} +
+ {[ + { label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length }, + { label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length }, + { label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length }, + ].map((s) => ( +
+

{s.count}

+

{s.label}

+
+ ))} +
+
+ )} +
+ diff --git a/src/lib/api.ts b/src/lib/api.ts index 164aba7..18e3bdd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -503,3 +503,20 @@ export async function uploadMailFilesUser(files: File[]): Promise<{ job_id: stri export async function getUploadProgressUser(jobID: string): Promise { return request(`/api/upload/${jobID}/progress`); } + +// ── Security Audit ──────────────────────────────────────────────────────── + +export interface SecurityCheck { + name: string; + status: "ok" | "warning" | "error"; + message: string; +} + +export interface SecurityAuditResult { + checks: SecurityCheck[]; + run_at: string; +} + +export async function getSecurityAudit(): Promise { + return request("/api/admin/security/audit"); +}