strings.Contains(nftStr, "dport 443") fand den Substring nicht, wenn
nftables mehrere Ports als Set ausgibt ("tcp dport { 80, 443 } accept" statt
"tcp dport 443 accept") — das ist bei archivmail-Installationen der
Normalfall (80+443 stehen zusammen in einer Regel). Dashboard zeigte
dadurch fälschlich "Kein HTTPS — Verbindungen unverschlüsselt", obwohl
Port 443 korrekt offen war (bestätigt auf 131 und 132).
Neue Helper-Funktion nftHasPort() per Regex erkennt beide Formen
(Einzelport und Set). Betrifft die Checks für HTTPS/443, Port 3000 und
Port 8080.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
268 lines
10 KiB
Go
268 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ── Security Audit ──────────────────────────────────────────────────────────
|
|
|
|
// nftDportRe matches "dport 443" as well as the set form nft uses for
|
|
// multiple ports on one rule, e.g. "dport { 80, 443 }".
|
|
var nftDportRe = regexp.MustCompile(`dport\s+(\{[^}]*\}|\d+)`)
|
|
|
|
// nftHasPort reports whether nft's "list ruleset" output opens the given
|
|
// port, either as a standalone "dport <port>" rule or as a member of a
|
|
// "dport { ... }" set. A plain strings.Contains(nftStr, "dport 443") misses
|
|
// the set form because "{ 80, " sits between "dport" and "443", producing a
|
|
// false "no HTTPS" warning even when port 443 is correctly whitelisted.
|
|
func nftHasPort(nftStr, port string) bool {
|
|
for _, m := range nftDportRe.FindAllStringSubmatch(nftStr, -1) {
|
|
group := m[1]
|
|
if group == port {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(group, "{") {
|
|
for _, p := range strings.Split(strings.Trim(group, "{} "), ",") {
|
|
if strings.TrimSpace(p) == port {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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 nftHasPort(nftStr, "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 nftHasPort(nftStr, "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 && nftHasPort(nftStr, "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)
|
|
}
|