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 } // PROJ-29: Enforce max_users quota before creating a new user. if tenantID != nil && s.tenantStore != nil { quota, qErr := s.tenantStore.GetQuota(r.Context(), *tenantID) if qErr == nil && quota.MaxUsers != nil { usage, uErr := s.tenantStore.GetUsage(r.Context(), *tenantID) if uErr == nil && int(usage.UserCount) >= *quota.MaxUsers { writeError(w, http.StatusPaymentRequired, "user quota exceeded") return } } } 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) }