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) }