Files
archivmail/internal/api/admin_security_handlers.go
T
sysops 1d27dc2d8b refactor: große API-Handler-Dateien in fokussierte Module aufteilen
Reine Code-Verschiebung, keine Logikänderung. Betroffen:
- ldap_tenants.go (994 Zeilen) -> ldap_tenants.go (Routing) + ldap_handlers.go
  + tenant_handlers.go + tenant_domain_handlers.go + tenant_logo_handlers.go
  + tenant_helpers.go
- import_handlers.go (621 Zeilen) -> imap_handlers.go + pop3_handlers.go
  + import_helpers.go
- admin_handlers.go (702 Zeilen) -> admin_users_handlers.go +
  admin_status_handlers.go + admin_services_handlers.go +
  admin_security_handlers.go

Lokal kein Go-Build möglich — Verifikation manuell per Funktions- und
Import-Abgleich Alt/Neu (alle Symbole exakt einmal vorhanden). Build
muss vor Deploy auf 192.168.1.131/132 bestätigt werden.
2026-06-21 23:38:57 +02:00

241 lines
9.2 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"time"
)
// ── 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),
})
}
// ── 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 {
s.logger.Error("could not write jail.local", "err", err)
writeError(w, http.StatusInternalServerError, "security config update failed")
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)
}