feat: Security Audit Tab im Admin-Panel mit nftables/SSH/Fail2ban-Checks
This commit is contained in:
@@ -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("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/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
|
// Export routes
|
||||||
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF)))
|
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,
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getSystemStats,
|
getSystemStats,
|
||||||
uploadMailFiles,
|
uploadMailFiles,
|
||||||
getUploadProgress,
|
getUploadProgress,
|
||||||
|
getSecurityAudit,
|
||||||
type User,
|
type User,
|
||||||
type AuditEntry,
|
type AuditEntry,
|
||||||
type SMTPStatus,
|
type SMTPStatus,
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
type ServiceStatus,
|
type ServiceStatus,
|
||||||
type SystemStats,
|
type SystemStats,
|
||||||
type UploadJob,
|
type UploadJob,
|
||||||
|
type SecurityCheck,
|
||||||
|
type SecurityAuditResult,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -119,6 +122,11 @@ export default function AdminPage() {
|
|||||||
const [auditPage, setAuditPage] = useState(1);
|
const [auditPage, setAuditPage] = useState(1);
|
||||||
const [auditLoading, setAuditLoading] = useState(false);
|
const [auditLoading, setAuditLoading] = useState(false);
|
||||||
|
|
||||||
|
// Security audit state
|
||||||
|
const [securityAudit, setSecurityAudit] = useState<SecurityAuditResult | null>(null);
|
||||||
|
const [securityLoading, setSecurityLoading] = useState(false);
|
||||||
|
const [securityError, setSecurityError] = useState("");
|
||||||
|
|
||||||
// Upload state
|
// Upload state
|
||||||
const [uploadDragging, setUploadDragging] = useState(false);
|
const [uploadDragging, setUploadDragging] = useState(false);
|
||||||
const [uploadJob, setUploadJob] = useState<UploadJob | null>(null);
|
const [uploadJob, setUploadJob] = useState<UploadJob | null>(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);
|
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -363,6 +384,7 @@ export default function AdminPage() {
|
|||||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||||
<TabsTrigger value="import">Import</TabsTrigger>
|
<TabsTrigger value="import">Import</TabsTrigger>
|
||||||
|
<TabsTrigger value="security">Security</TabsTrigger>
|
||||||
<TabsTrigger value="modules">Module</TabsTrigger>
|
<TabsTrigger value="modules">Module</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -1182,6 +1204,85 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Security Audit ── */}
|
||||||
|
<TabsContent value="security" className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Security Audit</h2>
|
||||||
|
{securityAudit && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Zuletzt geprüft: {new Date(securityAudit.run_at).toLocaleString("de-DE")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={runSecurityAudit} disabled={securityLoading} size="sm">
|
||||||
|
{securityLoading ? "Prüfe..." : "Jetzt prüfen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{securityError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{securityError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!securityAudit && !securityLoading && !securityError && (
|
||||||
|
<div className="rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground">
|
||||||
|
Klicke auf “Jetzt prüfen” um den Security-Audit zu starten.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{securityLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{securityAudit && !securityLoading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{securityAudit.checks.map((check: SecurityCheck, i: number) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="p-4 flex items-start gap-3">
|
||||||
|
<span className={`mt-0.5 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
|
||||||
|
check.status === "ok" ? "bg-green-500" :
|
||||||
|
check.status === "warning" ? "bg-yellow-400" :
|
||||||
|
"bg-red-500"
|
||||||
|
}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{check.name}</span>
|
||||||
|
<Badge
|
||||||
|
variant={check.status === "ok" ? "default" : check.status === "warning" ? "secondary" : "destructive"}
|
||||||
|
className={`text-xs ${check.status === "ok" ? "bg-green-100 text-green-800 hover:bg-green-100" : check.status === "warning" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-100" : ""}`}
|
||||||
|
>
|
||||||
|
{check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{check.message}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<div key={s.label} className={`rounded p-3 ${s.color}`}>
|
||||||
|
<p className="text-2xl font-bold">{s.count}</p>
|
||||||
|
<p className="text-xs">{s.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="modules" className="mt-4">
|
<TabsContent value="modules" className="mt-4">
|
||||||
<ModulesTab />
|
<ModulesTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -503,3 +503,20 @@ export async function uploadMailFilesUser(files: File[]): Promise<{ job_id: stri
|
|||||||
export async function getUploadProgressUser(jobID: string): Promise<UploadJob> {
|
export async function getUploadProgressUser(jobID: string): Promise<UploadJob> {
|
||||||
return request<UploadJob>(`/api/upload/${jobID}/progress`);
|
return request<UploadJob>(`/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<SecurityAuditResult> {
|
||||||
|
return request<SecurityAuditResult>("/api/admin/security/audit");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user