refactor: server.go in separate Handler-Dateien aufgeteilt

server.go (2357 -> 391 Zeilen) enthaelt nur noch Server-Struct,
Konstruktor, Router, Middleware und Hilfsfunktionen.

Neue Dateien:
- auth_handlers.go: Login, Logout, Me
- search_handlers.go: Suche, Mail-Anzeige, Anhaenge, Raw-Download
- admin_handlers.go: User-CRUD, SMTP/Storage-Stats, Services, Security
- import_handlers.go: IMAP + POP3 Account-Verwaltung und Import
- dashboard_handlers.go: System-Stats, Audit-Log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-18 11:55:21 +01:00
parent e7146fdbac
commit d79e334029
6 changed files with 2042 additions and 1984 deletions
+685
View File
@@ -0,0 +1,685 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/archivmail/internal/audit"
"github.com/archivmail/internal/auth"
"github.com/archivmail/internal/userstore"
)
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
tenantID := tenantFromCtx(r.Context())
var (
users []*userstore.User
err error
)
if tenantID != nil {
users, err = s.users.ListByTenant(r.Context(), *tenantID)
} else {
users, err = s.users.List("")
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list users")
return
}
type userResp struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
TenantID *int64 `json:"tenant_id,omitempty"`
}
resp := make([]userResp, 0, len(users))
for _, u := range users {
resp = append(resp, userResp{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role,
Active: u.Active,
TenantID: u.TenantID,
})
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// SEC-01: Privilege escalation check — caller must not assign a role
// at or above their own level.
sess := sessionFromCtx(r.Context())
if roleLevel(req.Role) >= roleLevel(sess.Role) {
writeError(w, http.StatusForbidden, "insufficient privileges to assign this role")
return
}
// SEC-02: Tenant isolation — non-superadmin users can only create users
// within their own tenant.
var tenantID *int64
if sess.TenantID != nil {
tenantID = sess.TenantID
}
user, err := s.users.Create(userstore.CreateUserRequest{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
TenantID: tenantID,
})
if err != nil {
s.logger.Error("create user failed", "err", err)
writeError(w, http.StatusBadRequest, "user creation failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: "created user: " + user.Username,
Success: true,
})
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
"active": user.Active,
})
}
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user id")
return
}
var req struct {
Email *string `json:"email"`
Role *string `json:"role"`
Active *bool `json:"active"`
Password *string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
sess := sessionFromCtx(r.Context())
// SEC-02: Tenant isolation — load target user and verify same tenant.
target, err := s.users.GetByID(id)
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
if sess.TenantID != nil {
if target.TenantID == nil || *target.TenantID != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// SEC-01: Privilege escalation check — caller must not assign a role
// at or above their own level, and must not modify users at or above
// their own level.
if roleLevel(target.Role) >= roleLevel(sess.Role) {
writeError(w, http.StatusForbidden, "insufficient privileges to modify this user")
return
}
if req.Role != nil && roleLevel(*req.Role) >= roleLevel(sess.Role) {
writeError(w, http.StatusForbidden, "insufficient privileges to assign this role")
return
}
updated, err := s.users.Update(id, userstore.UpdateUserRequest{
Email: req.Email,
Role: req.Role,
Active: req.Active,
Password: req.Password,
})
if err != nil {
s.logger.Error("update user failed", "err", err)
writeError(w, http.StatusBadRequest, "user update failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("updated user %d", id),
Success: true,
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": updated.ID,
"username": updated.Username,
"email": updated.Email,
"role": updated.Role,
"active": updated.Active,
})
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user id")
return
}
// Fetch user info before deletion for audit log and IMAP cleanup
target, err := s.users.GetByID(id)
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
// SEC-02: Tenant isolation — domain_admin can only delete users in their own tenant.
sess := sessionFromCtx(r.Context())
if sess.TenantID != nil {
if target.TenantID == nil || *target.TenantID != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// SEC-01: Cannot delete users at or above own privilege level.
if roleLevel(target.Role) >= roleLevel(sess.Role) {
writeError(w, http.StatusForbidden, "insufficient privileges to delete this user")
return
}
if err := s.users.DeleteSafe(id); err != nil {
if err.Error() == "userstore: cannot delete last admin" {
writeError(w, http.StatusConflict, "cannot delete the last active admin")
return
}
s.logger.Error("delete user failed", "err", err)
writeError(w, http.StatusInternalServerError, "user deletion failed")
return
}
// Remove all IMAP accounts that belonged to this user
imapDeleted := 0
if s.imapStore != nil {
if n, err := s.imapStore.DeleteByOwner(r.Context(), target.Username); err != nil {
s.logger.Warn("delete user: could not remove IMAP accounts", "user", target.Username, "err", err)
} else {
imapDeleted = n
}
}
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf(
"deleted user %d (%s, role=%s); %d IMAP account(s) removed; emails retained per GoBD",
id, target.Username, target.Role, imapDeleted,
),
Success: true,
})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
// domain_admin: return only their tenant's email statistics (no global daemon info)
if sess != nil && !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) {
stats, err := s.store.StatsByTenant(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read stats")
return
}
domains := []string{}
if tenantID != nil && s.tenantStore != nil {
if dd, derr := s.tenantStore.ListDomains(r.Context(), *tenantID); derr == nil {
for _, d := range dd {
domains = append(domains, d.Domain)
}
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"enabled": true,
"tenant_only": true,
"domains": domains,
"total_mails": stats["count"],
"total_bytes": stats["total_size"],
})
return
}
// superadmin: global daemon status
if s.smtpDaemon == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false, "running": false})
return
}
writeJSON(w, http.StatusOK, s.smtpDaemon.Status())
}
func (s *Server) handleStorageStats(w http.ResponseWriter, r *http.Request) {
tenantID := tenantFromCtx(r.Context())
stats, err := s.store.StatsByTenant(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read storage stats")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"total_mails": stats["count"],
"total_bytes": stats["total_size"],
})
}
// --- Service management ---
// allowedServices is the whitelist of systemd service names the admin may control.
var allowedServices = []string{
"archivmail",
"archivmail-web",
"postgresql@17-main",
"postfix",
"nginx",
}
type ServiceStatus struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Active string `json:"active"` // active, inactive, failed, unknown
Sub string `json:"sub"` // running, dead, exited, ...
Enabled string `json:"enabled"` // enabled, disabled, static, unknown
Description string `json:"description"`
ExternalBlocked *bool `json:"external_blocked,omitempty"` // only set for archivmail
}
func isAllowedService(name string) bool {
for _, s := range allowedServices {
if s == name {
return true
}
}
return false
}
func systemctlShow(name string) ServiceStatus {
svc := ServiceStatus{Name: name, DisplayName: name}
out, err := exec.Command("systemctl", "show", name+".service",
"--property=ActiveState,SubState,UnitFileState,Description",
"--no-pager").Output()
if err != nil {
svc.Active = "unknown"
svc.Sub = ""
svc.Enabled = "unknown"
} else {
for _, line := range strings.Split(string(out), "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "ActiveState":
svc.Active = v
case "SubState":
svc.Sub = v
case "UnitFileState":
svc.Enabled = v
case "Description":
svc.Description = v
}
}
}
if name == "archivmail" {
blocked := nftAPIBlocked()
svc.ExternalBlocked = &blocked
}
return svc
}
// nftAPIBlocked reports whether external access to port 8080 is currently blocked.
func nftAPIBlocked() bool {
out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", "status").Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "blocked"
}
func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) {
result := make([]ServiceStatus, 0, len(allowedServices))
for _, name := range allowedServices {
result = append(result, systemctlShow(name))
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) {
// Only superadmin may start/stop/restart services
sess := sessionFromCtx(r.Context())
if sess == nil || !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) {
writeError(w, http.StatusForbidden, "superadmin required")
return
}
name := r.PathValue("name")
if !isAllowedService(name) {
writeError(w, http.StatusBadRequest, "unknown service")
return
}
var body struct {
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
allowedActions := map[string]bool{
"start": true, "stop": true, "restart": true,
"enable": true, "disable": true,
}
nftActions := map[string]string{
"block_external": "block",
"allow_external": "unblock",
}
if nftArg, isNft := nftActions[body.Action]; isNft {
if name != "archivmail" {
writeError(w, http.StatusBadRequest, "external access control only available for archivmail")
return
}
out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", nftArg).CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out)))
return
}
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: "service." + body.Action,
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: name,
Success: true,
})
writeJSON(w, http.StatusOK, systemctlShow(name))
return
}
if !allowedActions[body.Action] {
writeError(w, http.StatusBadRequest, "unknown action")
return
}
out, err := exec.Command("sudo", "/usr/bin/systemctl", body.Action, name+".service").CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out)))
return
}
s.audlog.Log(audit.Entry{
EventType: "service." + body.Action,
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: name,
Success: true,
})
writeJSON(w, http.StatusOK, systemctlShow(name))
}
// ── 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)
}