feat: Security Audit Tab im Admin-Panel mit nftables/SSH/Fail2ban-Checks

This commit is contained in:
sysops
2026-03-17 15:09:01 +01:00
parent b6fa668002
commit 4668150727
3 changed files with 227 additions and 0 deletions
+109
View File
@@ -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),
})
}