From 5e69c29f16bc730c70329f6ccc3992b99c17d596 Mon Sep 17 00:00:00 2001
From: sysops
Date: Tue, 17 Mar 2026 15:23:31 +0100
Subject: [PATCH] feat: Security Audit Auto-Fix Buttons + manuelle
Dokumentation (docs/security-audit.md)
---
docs/security-audit.md | 270 +++++++++++++++++++++++++++++++++++++++++
internal/api/server.go | 121 ++++++++++++++++++
src/app/admin/page.tsx | 140 +++++++++++++++------
src/lib/api.ts | 7 ++
4 files changed, 498 insertions(+), 40 deletions(-)
create mode 100644 docs/security-audit.md
diff --git a/docs/security-audit.md b/docs/security-audit.md
new file mode 100644
index 0000000..d0c871f
--- /dev/null
+++ b/docs/security-audit.md
@@ -0,0 +1,270 @@
+# Security Audit — archivmail
+
+Dieses Dokument beschreibt alle Security-Checks die das System automatisch durchführt,
+welche Werte als sicher gelten und wie Probleme manuell behoben werden können.
+
+---
+
+## Übersicht der Checks
+
+| Check | OK-Kriterium | Auto-Fix verfügbar |
+|------------------------|----------------------------------------------|--------------------|
+| Firewall (nftables) | Input-Chain policy: drop | Ja |
+| Port 3000 (Next.js) | Nicht in Whitelist | Nein (via Firewall)|
+| Port 8080 (Go Backend) | Nicht in Whitelist | Nein (via Firewall)|
+| HTTPS (TLS) | Port 443 in Firewall + Zertifikat vorhanden | Nein (manuell) |
+| SSH Root-Login | `prohibit-password` oder `no` | Ja |
+| SSH Passwort-Auth | `PasswordAuthentication no` | Ja |
+| Fail2ban | Dienst aktiv | Ja |
+
+---
+
+## 1. Firewall (nftables)
+
+### Was wird geprüft
+Ob `nft list ruleset` eine Input-Chain mit `policy drop` zurückgibt.
+Whitelist-Modus: Nur explizit erlaubte Ports sind erreichbar.
+
+### Erlaubte externe Ports
+| Port | Dienst |
+|------|-------------------|
+| 22 | SSH |
+| 80 | nginx (Web-UI) |
+| 2525 | SMTP Einlieferung |
+
+### Manuell prüfen
+```bash
+ssh root@192.168.1.131
+
+# Aktuelle Regeln anzeigen
+nft list ruleset
+
+# Offene Ports prüfen
+ss -tlnp
+```
+
+### Manuell beheben
+```bash
+# Konfiguration neu laden
+nft -f /etc/nftables.conf
+
+# Beim Systemstart aktivieren
+systemctl enable nftables
+systemctl restart nftables
+
+# Prüfen ob Regeln aktiv sind
+nft list ruleset | grep "policy drop"
+```
+
+### Konfigurationsdatei
+`/etc/nftables.conf` — Änderungen hier sind persistent.
+
+---
+
+## 2. Port 3000 / Port 8080
+
+### Was wird geprüft
+Ob diese Ports explizit in der Firewall-Whitelist stehen.
+Beide Dienste sollen nur intern erreichbar sein (nginx proxied den Zugriff).
+
+### Warum gefährlich
+- **Port 3000**: Next.js Frontend — keine eigene Authentifizierung auf diesem Port
+- **Port 8080**: Go Backend REST API — direkte API-Zugriffe ohne nginx möglich
+
+### Manuell prüfen
+```bash
+# Lauscht Port 3000 auf allen Interfaces?
+ss -tlnp | grep 3000
+# => Wenn "0.0.0.0:3000" erscheint, ist er intern offen (Firewall muss blockieren)
+
+# Ist Port von außen erreichbar?
+nft list ruleset | grep "dport 3000"
+# => Wenn keine Zeile erscheint: gut (policy drop blockiert ihn)
+```
+
+### Manuell beheben (falls Firewall fehlt)
+```bash
+# Firewall neu laden — blockiert automatisch 3000 und 8080
+nft -f /etc/nftables.conf
+```
+
+---
+
+## 3. HTTPS (TLS)
+
+### Was wird geprüft
+Ob Port 443 in der Firewall freigegeben ist.
+Aktuell läuft archivmail nur über HTTP (Port 80) — Verbindungen sind unverschlüsselt.
+
+### Warum wichtig
+Ohne TLS werden Passwörter und E-Mail-Inhalte im Klartext übertragen.
+
+### Manuell einrichten (Let's Encrypt / certbot)
+```bash
+ssh root@192.168.1.131
+
+# certbot installieren
+apt-get install -y certbot python3-certbot-nginx
+
+# Zertifikat ausstellen (Domain muss auf den Server zeigen)
+certbot --nginx -d archivmail.example.com
+
+# Firewall: Port 443 freigeben
+# In /etc/nftables.conf folgende Zeile in chain input ergänzen:
+# tcp dport 443 accept
+nft -f /etc/nftables.conf
+
+# Automatische Erneuerung prüfen
+certbot renew --dry-run
+```
+
+### nginx nach certbot
+certbot passt die nginx-Konfiguration automatisch an und fügt SSL + Redirect HTTP→HTTPS hinzu.
+
+---
+
+## 4. SSH Root-Login
+
+### Was wird geprüft
+Den Wert von `PermitRootLogin` in `/etc/ssh/sshd_config`.
+
+| Wert | Bewertung |
+|--------------------|-----------|
+| `no` | OK |
+| `prohibit-password`| OK (Key-Auth für root erlaubt) |
+| `yes` | Warnung |
+| nicht gesetzt | Warnung (Default ist prohibit-password) |
+
+### Manuell prüfen
+```bash
+grep -i "PermitRootLogin" /etc/ssh/sshd_config
+```
+
+### Manuell beheben
+```bash
+# Datei bearbeiten
+nano /etc/ssh/sshd_config
+
+# Zeile setzen oder ändern:
+PermitRootLogin prohibit-password
+
+# SSH-Dienst neu starten
+systemctl restart ssh
+
+# Prüfen
+sshd -T | grep permitrootlogin
+```
+
+---
+
+## 5. SSH Passwort-Authentifizierung
+
+### Was wird geprüft
+Den Wert von `PasswordAuthentication` in `/etc/ssh/sshd_config`.
+Empfehlung: Nur SSH-Key-Authentifizierung erlauben.
+
+### Manuell prüfen
+```bash
+grep -i "PasswordAuthentication" /etc/ssh/sshd_config
+sshd -T | grep passwordauthentication
+```
+
+### Manuell beheben
+```bash
+# Sicherstellen dass ein SSH-Key hinterlegt ist, BEVOR Passwort-Auth deaktiviert wird
+cat ~/.ssh/authorized_keys # muss mindestens einen Eintrag enthalten
+
+# Dann in /etc/ssh/sshd_config:
+PasswordAuthentication no
+
+# SSH neu starten
+systemctl restart ssh
+```
+
+> **Warnung:** Passwort-Auth nur deaktivieren wenn ein SSH-Key in `~/.ssh/authorized_keys` vorhanden ist.
+> Sonst ist kein Login mehr möglich.
+
+---
+
+## 6. Fail2ban
+
+### Was wird geprüft
+Ob der `fail2ban`-Systemd-Dienst aktiv ist.
+
+### Was ist Fail2ban
+Fail2ban überwacht Log-Dateien und blockiert IPs automatisch bei zu vielen fehlgeschlagenen Login-Versuchen (Brute-Force-Schutz).
+
+### Manuell prüfen
+```bash
+systemctl status fail2ban
+
+# Aktive Jails anzeigen
+fail2ban-client status
+
+# SSH-Jail prüfen
+fail2ban-client status sshd
+
+# Gesperrte IPs anzeigen
+fail2ban-client status sshd | grep "Banned IP"
+```
+
+### Manuell installieren und konfigurieren
+```bash
+apt-get install -y fail2ban
+
+# Konfiguration erstellen
+cat > /etc/fail2ban/jail.local << 'EOF'
+[sshd]
+enabled = true
+maxretry = 5
+bantime = 3600
+findtime = 600
+
+[nginx-http-auth]
+enabled = true
+EOF
+
+systemctl enable fail2ban
+systemctl start fail2ban
+
+# Prüfen
+fail2ban-client status
+```
+
+### Gesperrte IP manuell entsperren
+```bash
+fail2ban-client set sshd unbanip
+```
+
+---
+
+## Kompletter manueller Audit (alle Checks auf einmal)
+
+```bash
+ssh root@192.168.1.131
+
+echo "=== Firewall ==="
+nft list ruleset | grep -E "policy|dport"
+
+echo "=== Offene Ports ==="
+ss -tlnp
+
+echo "=== SSH Config ==="
+sshd -T | grep -E "permitrootlogin|passwordauthentication"
+
+echo "=== Fail2ban ==="
+systemctl is-active fail2ban
+fail2ban-client status 2>/dev/null || echo "nicht installiert"
+
+echo "=== nftables Dienst ==="
+systemctl is-enabled nftables
+```
+
+---
+
+## Weiterführende Ressourcen
+
+- [Debian SSH Hardening](https://wiki.debian.org/SSH)
+- [nftables Dokumentation](https://wiki.nftables.org/)
+- [Fail2ban Dokumentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8)
+- [Let's Encrypt / certbot](https://certbot.eff.org/)
diff --git a/internal/api/server.go b/internal/api/server.go
index 5ffb2d1..ed91678 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -106,6 +106,7 @@ func (s *Server) routes() {
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)))
+ s.mux.HandleFunc("POST /api/admin/security/fix", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityFix)))
// Export routes
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF)))
@@ -1598,3 +1599,123 @@ func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) {
"run_at": time.Now().UTC().Format(time.RFC3339),
})
}
+
+// ── Security Fix ────────────────────────────────────────────────────────────
+
+// allowedFixActions is a strict whitelist — only these actions may be executed.
+var allowedFixActions = map[string]bool{
+ "install_fail2ban": true,
+ "enable_firewall": true,
+ "fix_ssh_password_auth": true,
+ "fix_ssh_root_login": true,
+}
+
+func (s *Server) handleSecurityFix(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Action string `json:"action"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+ if !allowedFixActions[body.Action] {
+ writeError(w, http.StatusBadRequest, "unknown action: "+body.Action)
+ return
+ }
+
+ ctx := r.Context()
+ var msg string
+ var fixErr error
+
+ switch body.Action {
+
+ case "install_fail2ban":
+ // Install fail2ban if not present
+ if _, err := exec.LookPath("fail2ban-client"); err != nil {
+ out, err := exec.CommandContext(ctx, "apt-get", "install", "-y", "fail2ban").CombinedOutput()
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "apt-get install fail2ban: "+string(out))
+ return
+ }
+ }
+ // Write a minimal jail.local if not already present
+ jailPath := "/etc/fail2ban/jail.local"
+ if _, err := os.Stat(jailPath); os.IsNotExist(err) {
+ jailConf := "[sshd]\nenabled = true\nmaxretry = 5\nbantime = 3600\nfindtime = 600\n"
+ if err := os.WriteFile(jailPath, []byte(jailConf), 0644); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not write jail.local: "+err.Error())
+ return
+ }
+ }
+ // Enable and start
+ exec.CommandContext(ctx, "systemctl", "enable", "fail2ban").Run()
+ out, err := exec.CommandContext(ctx, "systemctl", "restart", "fail2ban").CombinedOutput()
+ if err != nil {
+ fixErr = fmt.Errorf("systemctl restart fail2ban: %s", string(out))
+ } else {
+ msg = "Fail2ban installiert, SSH-Jail aktiviert und Dienst gestartet."
+ }
+
+ case "enable_firewall":
+ // Reload rules from /etc/nftables.conf and enable service
+ out, err := exec.CommandContext(ctx, "nft", "-f", "/etc/nftables.conf").CombinedOutput()
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "nft -f /etc/nftables.conf: "+string(out))
+ return
+ }
+ exec.CommandContext(ctx, "systemctl", "enable", "nftables").Run()
+ msg = "nftables-Regeln neu geladen und Dienst aktiviert."
+
+ case "fix_ssh_password_auth":
+ fixErr = sshConfigSet("PasswordAuthentication", "no")
+ if fixErr == nil {
+ out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput()
+ if err != nil {
+ fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out))
+ } else {
+ msg = "PasswordAuthentication auf 'no' gesetzt, SSH neu gestartet."
+ }
+ }
+
+ case "fix_ssh_root_login":
+ fixErr = sshConfigSet("PermitRootLogin", "prohibit-password")
+ if fixErr == nil {
+ out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput()
+ if err != nil {
+ fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out))
+ } else {
+ msg = "PermitRootLogin auf 'prohibit-password' gesetzt, SSH neu gestartet."
+ }
+ }
+ }
+
+ if fixErr != nil {
+ writeError(w, http.StatusInternalServerError, fixErr.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": msg})
+}
+
+// sshConfigSet sets or replaces a directive in /etc/ssh/sshd_config.
+// Commented-out lines are left untouched; the active directive is updated or appended.
+func sshConfigSet(key, value string) error {
+ const path = "/etc/ssh/sshd_config"
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("read sshd_config: %w", err)
+ }
+ lines := strings.Split(string(data), "\n")
+ keyLower := strings.ToLower(key)
+ found := false
+ for i, l := range lines {
+ tl := strings.ToLower(strings.TrimSpace(l))
+ if strings.HasPrefix(tl, keyLower) && !strings.HasPrefix(strings.TrimSpace(l), "#") {
+ lines[i] = key + " " + value
+ found = true
+ }
+ }
+ if !found {
+ lines = append(lines, key+" "+value)
+ }
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 7fc58af..eea899e 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -18,6 +18,7 @@ import {
uploadMailFiles,
getUploadProgress,
getSecurityAudit,
+ fixSecurityIssue,
type User,
type AuditEntry,
type SMTPStatus,
@@ -126,6 +127,8 @@ export default function AdminPage() {
const [securityAudit, setSecurityAudit] = useState(null);
const [securityLoading, setSecurityLoading] = useState(false);
const [securityError, setSecurityError] = useState("");
+ const [fixLoading, setFixLoading] = useState(null);
+ const [fixMessage, setFixMessage] = useState("");
// Upload state
const [uploadDragging, setUploadDragging] = useState(false);
@@ -352,6 +355,7 @@ export default function AdminPage() {
async function runSecurityAudit() {
setSecurityLoading(true);
setSecurityError("");
+ setFixMessage("");
try {
const result = await getSecurityAudit();
setSecurityAudit(result);
@@ -362,6 +366,22 @@ export default function AdminPage() {
}
}
+ async function runFix(action: string) {
+ setFixLoading(action);
+ setFixMessage("");
+ setSecurityError("");
+ try {
+ const res = await fixSecurityIssue(action);
+ setFixMessage(res.message);
+ // Re-run audit automatically to reflect new state
+ await runSecurityAudit();
+ } catch (e: unknown) {
+ setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen.");
+ } finally {
+ setFixLoading(null);
+ }
+ }
+
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
return (
@@ -1215,7 +1235,7 @@ export default function AdminPage() {
)}
-