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.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user