Files
archivmail/internal/api/server.go
T
sysops 46d7bfe608 fix(security): Kritische Sicherheitslücken beheben (SEC-01/02/03/05/08/17/22/26/28)
- SEC-01: Privilege Escalation verhindert — Rollenhierarchie in Create/Update/DeleteUser
- SEC-02: Tenant-Isolation in Update/DeleteUser — domain_admin nur eigene Nutzer
- SEC-03: IMAP/POP3 Owner-Check via auth.HasRole statt direktem String-Vergleich
- SEC-05: Export PDF/ZIP prüft Tenant-Zugehörigkeit vor Dateiausgabe
- SEC-08: HKDF-SHA256 trennt JWT-Secret von AES-Key (archivmail-jwt-v1 / archivmail-aes-v1)
- SEC-17: handleSecurityFix erfordert requireRole(superadmin)
- SEC-22: Mail-ID Regex [0-9a-f]{64} in allen Handlern (Path-Traversal-Schutz)
- SEC-26: SMTP Fail-Closed — leere AllowedIPs blockiert alles statt zu erlauben
- SEC-28: handleGetRaw — Parse-Fehler bricht ab statt Fallthrough zu Dateizugriff

BREAKING: IMAP/POP3/LDAP-Passwörter müssen nach Deploy einmalig neu eingegeben
werden (neuer AES-Key). JWT-Sessions laufen ab (einmaliges Re-Login nötig).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:05:47 +01:00

2200 lines
65 KiB
Go

package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"regexp"
"github.com/archivmail/config"
"github.com/archivmail/internal/audit"
"github.com/archivmail/internal/auth"
imapstore "github.com/archivmail/internal/imap"
"github.com/archivmail/internal/index"
ldapcfg "github.com/archivmail/internal/ldapconfig"
pop3store "github.com/archivmail/internal/pop3"
"github.com/archivmail/internal/smtpd"
"github.com/archivmail/internal/storage"
"github.com/archivmail/internal/tenantstore"
"github.com/archivmail/internal/userstore"
"github.com/archivmail/pkg/mailparser"
)
// SEC-22: Compiled regex for mail ID validation to prevent path traversal.
var mailIDRegex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// isValidMailID validates that a mail ID matches the expected hex format.
func isValidMailID(id string) bool {
return mailIDRegex.MatchString(id)
}
// roleLevel returns the privilege level for a role string.
// Hierarchy: superadmin=5 > admin=4 > domain_admin=3 > auditor=2 > user=1
func roleLevel(role string) int {
levels := map[string]int{
userstore.RoleUser: 1,
userstore.RoleAuditor: 2,
userstore.RoleDomainAdmin: 3,
userstore.RoleAdmin: 4,
userstore.RoleSuperAdmin: 5,
}
return levels[role]
}
type contextKey string
const (
sessionKey contextKey = "session"
tenantKey contextKey = "tenant_id"
)
// Server is the archivmail HTTP API server.
type Server struct {
cfg config.APIConfig
store *storage.Store
idx index.Indexer
authMgr *auth.Manager
users *userstore.Store
audlog *audit.Logger
logger *slog.Logger
mux *http.ServeMux
smtpDaemon *smtpd.Daemon
imapStore *imapstore.Store
imapImporter *imapstore.Importer
imapScheduler *imapstore.Scheduler
pop3Store *pop3store.Store
pop3Importer *pop3store.Importer
uploadJobs sync.Map // jobID → *UploadJob
ldapStore *ldapcfg.Store
tenantStore *tenantstore.Store
}
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
func (s *Server) SetSMTPDaemon(d *smtpd.Daemon) {
s.smtpDaemon = d
}
// SetImap wires the IMAP store, importer, and scheduler into the API server after construction.
func (s *Server) SetImap(store *imapstore.Store, importer *imapstore.Importer, scheduler *imapstore.Scheduler) {
s.imapStore = store
s.imapImporter = importer
s.imapScheduler = scheduler
}
// SetPop3 wires the POP3 store and importer into the API server after construction.
func (s *Server) SetPop3(store *pop3store.Store, importer *pop3store.Importer) {
s.pop3Store = store
s.pop3Importer = importer
}
// New creates and wires up a new API server.
func New(
cfg config.APIConfig,
store *storage.Store,
idx index.Indexer,
authMgr *auth.Manager,
users *userstore.Store,
audlog *audit.Logger,
logger *slog.Logger,
) *Server {
s := &Server{
cfg: cfg,
store: store,
idx: idx,
authMgr: authMgr,
users: users,
audlog: audlog,
logger: logger,
mux: http.NewServeMux(),
}
s.routes()
return s
}
// auth wraps a handler with authentication + tenant context propagation.
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
return s.authMiddleware(s.tenantMiddleware(h))
}
// authAdmin wraps a handler requiring at least admin role.
func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc {
return s.authMiddleware(s.tenantMiddleware(s.requireRole(userstore.RoleDomainAdmin, h)))
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe))
s.mux.HandleFunc("POST /api/auth/logout", s.auth(s.handleLogout))
s.mux.HandleFunc("GET /api/users", s.authAdmin(s.handleListUsers))
s.mux.HandleFunc("POST /api/users", s.authAdmin(s.handleCreateUser))
s.mux.HandleFunc("PATCH /api/users/{id}", s.authAdmin(s.handleUpdateUser))
s.mux.HandleFunc("DELETE /api/users/{id}", s.authAdmin(s.handleDeleteUser))
s.mux.HandleFunc("GET /api/search", s.auth(s.handleSearch))
s.mux.HandleFunc("GET /api/audit", s.auth(s.requireRole(userstore.RoleAuditor, s.handleAuditLog)))
s.mux.HandleFunc("GET /api/admin/smtp/status", s.authAdmin(s.handleSMTPStatus))
s.mux.HandleFunc("GET /api/admin/storage/stats", s.authAdmin(s.handleStorageStats))
s.mux.HandleFunc("GET /api/mails/{id}", s.auth(s.requireMailAccess(s.handleGetMail)))
s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.auth(s.requireMailAccess(s.handleGetAttachment)))
s.mux.HandleFunc("GET /api/mails/{id}/raw", s.auth(s.requireMailAccess(s.handleGetRaw)))
s.mux.HandleFunc("GET /api/admin/services", s.authAdmin(s.handleListServices))
s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authAdmin(s.handleServiceAction))
s.mux.HandleFunc("GET /api/admin/system/stats", s.authAdmin(s.handleSystemStats))
s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit))
// SEC-17: Security fix actions require superadmin, not just domain_admin.
s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix)))
// Export routes
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF)))
s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP)))
// Upload routes (admin only)
s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload))
s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authAdmin(s.handleUploadProgress))
// Upload routes (all authenticated users)
s.mux.HandleFunc("POST /api/upload", s.auth(s.handleUpload))
s.mux.HandleFunc("GET /api/upload/{jobID}/progress", s.auth(s.handleUploadProgress))
// IMAP routes (accessible to all authenticated users)
s.mux.HandleFunc("GET /api/imap", s.auth(s.handleListImap))
s.mux.HandleFunc("POST /api/imap", s.auth(s.handleCreateImap))
s.mux.HandleFunc("DELETE /api/imap/{id}", s.auth(s.handleDeleteImap))
s.mux.HandleFunc("PATCH /api/imap/{id}", s.auth(s.handleUpdateImapInterval))
s.mux.HandleFunc("POST /api/imap/test", s.auth(s.handleTestImap))
s.mux.HandleFunc("POST /api/imap/{id}/import", s.auth(s.handleStartImport))
s.mux.HandleFunc("GET /api/imap/{id}/progress", s.auth(s.handleImapProgress))
s.mux.HandleFunc("POST /api/imap/{id}/sync", s.auth(s.handleSyncNow))
// POP3 routes (accessible to all authenticated users)
s.mux.HandleFunc("GET /api/pop3", s.auth(s.handleListPop3))
s.mux.HandleFunc("POST /api/pop3", s.auth(s.handleCreatePop3))
s.mux.HandleFunc("DELETE /api/pop3/{id}", s.auth(s.handleDeletePop3))
s.mux.HandleFunc("POST /api/pop3/test", s.auth(s.handleTestPop3))
s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import))
s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.auth(s.handlePop3Progress))
}
// ServeHTTP implements http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
// --- handlers ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
const (
loginMaxFailures = 5
loginWindow = 15 * time.Minute
)
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Rate-limiting: block after too many recent failures
failures, err := s.users.CountRecentFailures(req.Username, loginWindow)
if err == nil && failures >= loginMaxFailures {
s.audlog.Log(audit.Entry{
EventType: audit.EventLogin,
Username: req.Username,
IPAddress: remoteIP(r),
Success: false,
Detail: "rate limited",
})
writeError(w, http.StatusTooManyRequests, "too many failed login attempts, try again later")
return
}
token, user, err := s.authMgr.Login(req.Username, req.Password)
if err != nil {
_ = s.users.RecordLoginAttempt(req.Username, remoteIP(r))
s.audlog.Log(audit.Entry{
EventType: audit.EventLogin,
Username: req.Username,
IPAddress: remoteIP(r),
Success: false,
Detail: err.Error(),
})
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
_ = s.users.UpdateLastLogin(user.ID)
s.audlog.Log(audit.Entry{
EventType: audit.EventLogin,
Username: user.Username,
IPAddress: remoteIP(r),
Success: true,
})
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: 8 * 3600,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
// Secure: true — enable when TLS is terminated at this server
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
},
})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
user, err := s.users.GetByUsername(sess.Username)
if err != nil {
writeError(w, http.StatusInternalServerError, "user lookup failed")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"username": user.Username,
"email": user.Email,
"role": user.Role,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
// Read token from cookie first, then Bearer header
token := ""
if c, err := r.Cookie(sessionCookieName); err == nil {
token = c.Value
}
if token == "" {
token = extractBearerToken(r)
}
if token != "" {
_ = s.authMgr.Logout(token)
}
// Clear the session cookie
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: audit.EventLogout,
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
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 {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
IPAddress: 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 {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
IPAddress: 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
}
writeError(w, http.StatusInternalServerError, err.Error())
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: 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) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
fromFilter := r.URL.Query().Get("from")
toFilter := r.URL.Query().Get("to")
dateFromStr := r.URL.Query().Get("date_from")
dateToStr := r.URL.Query().Get("date_to")
sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc"
hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false"
pageStr := r.URL.Query().Get("page")
pageSizeStr := r.URL.Query().Get("page_size")
page, _ := strconv.Atoi(pageStr)
pageSize, _ := strconv.Atoi(pageSizeStr)
if pageSize <= 0 {
pageSize = 25
}
req := index.SearchRequest{
Query: q,
Sort: sortParam,
PageSize: pageSize,
Page: page,
}
if hasAttachStr == "true" {
v := true
req.HasAttachment = &v
} else if hasAttachStr == "false" {
v := false
req.HasAttachment = &v
}
// Domain search: @domain.de matches both From AND To fields.
// A value starting with '@' triggers OR-search across XF and XT prefixes.
if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") {
domain := fromFilter
if domain == "" {
domain = toFilter
}
req.OwnEmail = domain
} else {
req.From = fromFilter
req.To = toFilter
}
if dateFromStr != "" {
if t, err := time.Parse(time.RFC3339, dateFromStr); err == nil {
req.DateFrom = &t
} else if t, err := time.Parse(time.DateOnly, dateFromStr); err == nil {
req.DateFrom = &t
}
}
if dateToStr != "" {
if t, err := time.Parse(time.RFC3339, dateToStr); err == nil {
req.DateTo = &t
} else if t, err := time.Parse(time.DateOnly, dateToStr); err == nil {
// end of day for date_to
t = t.Add(24*time.Hour - time.Second)
req.DateTo = &t
}
}
result, err := s.idx.Search(req)
if err != nil {
writeError(w, http.StatusInternalServerError, "search failed")
return
}
// Tenant isolation: filter results to only this tenant's emails.
tenantID := tenantFromCtx(r.Context())
if tenantID != nil && len(result.Hits) > 0 {
allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID)
if idErr == nil {
allowed := make(map[string]struct{}, len(allowedIDs))
for _, id := range allowedIDs {
allowed[id] = struct{}{}
}
filtered := result.Hits[:0]
for _, h := range result.Hits {
if _, ok := allowed[h.ID]; ok {
filtered = append(filtered, h)
}
}
result.Hits = filtered
result.Total = len(filtered)
}
}
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: audit.EventSearch,
Username: sess.Username,
IPAddress: remoteIP(r),
Query: q,
Success: true,
})
// Enrich hits with metadata (from, subject, date, size, attachments).
type enrichedHit struct {
ID string `json:"id"`
Score float64 `json:"score"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Subject string `json:"subject,omitempty"`
Date string `json:"date,omitempty"`
Size int64 `json:"size,omitempty"`
HasAttachments bool `json:"has_attachments"`
}
enriched := make([]enrichedHit, 0, len(result.Hits))
for _, h := range result.Hits {
eh := enrichedHit{ID: h.ID, Score: h.Score}
if raw, err := s.store.Load(h.ID); err == nil {
eh.Size = int64(len(raw))
if pm, err := mailparser.Parse(raw); err == nil {
eh.From = pm.From
if len(pm.To) > 0 {
eh.To = strings.Join(pm.To, ", ")
}
eh.Subject = pm.Subject
if !pm.Date.IsZero() {
eh.Date = pm.Date.UTC().Format(time.RFC3339)
}
eh.HasAttachments = len(pm.Attachments) > 0
}
}
enriched = append(enriched, eh)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"total": result.Total,
"hits": enriched,
})
}
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"],
})
}
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
pageStr := r.URL.Query().Get("page")
pageSizeStr := r.URL.Query().Get("page_size")
username := r.URL.Query().Get("username")
eventType := r.URL.Query().Get("event_type")
page, _ := strconv.Atoi(pageStr)
pageSize, _ := strconv.Atoi(pageSizeStr)
if pageSize <= 0 {
pageSize = 50
}
entries, total, err := s.audlog.Query(audit.QueryFilter{
Username: username,
EventType: eventType,
PageSize: pageSize,
Page: page,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "audit query failed")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"total": total,
"entries": entries,
})
}
// --- middleware ---
const sessionCookieName = "archivmail_session"
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Prefer httpOnly cookie; fall back to Bearer token for CLI/API clients.
token := ""
if c, err := r.Cookie(sessionCookieName); err == nil {
token = c.Value
}
if token == "" {
token = extractBearerToken(r)
}
if token == "" {
writeError(w, http.StatusUnauthorized, "missing authorization")
return
}
sess, err := s.authMgr.ValidateToken(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), sessionKey, sess)
next(w, r.WithContext(ctx))
}
}
func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess == nil || !auth.HasRole(sess.Role, role) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
next(w, r)
}
}
// --- helpers ---
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func extractBearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
func sessionFromCtx(ctx context.Context) *auth.Session {
v := ctx.Value(sessionKey)
if v == nil {
return &auth.Session{}
}
if s, ok := v.(*auth.Session); ok {
return s
}
return &auth.Session{}
}
// tenantMiddleware extracts the tenant_id from the session and stores it in
// the request context, making it available to all downstream handlers.
func (s *Server) tenantMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := sessionFromCtx(r.Context())
if session != nil && session.TenantID != nil {
ctx := context.WithValue(r.Context(), tenantKey, session.TenantID)
next(w, r.WithContext(ctx))
return
}
next(w, r)
}
}
// tenantFromCtx extracts the tenant_id from context. Returns nil for global (superadmin) context.
func tenantFromCtx(ctx context.Context) *int64 {
v, _ := ctx.Value(tenantKey).(*int64)
return v
}
func remoteIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.Split(fwd, ",")[0]
}
return r.RemoteAddr
}
// ── Mail access middleware ────────────────────────────────────────────────
// requireMailAccess checks that the caller may read mail content.
// superadmin and domain_admin have read access (tenant-scoped via handleGetMail).
// Auditor and user have access to their own mails.
// The old "admin" role (now domain_admin) previously had no mail access — that
// restriction is removed; domain_admin now needs to be able to read archived mails.
func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
next(w, r)
}
}
// ── Mail handlers ─────────────────────────────────────────────────────────
func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SEC-22: Validate mail ID format to prevent path traversal.
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
raw, err := s.store.Load(id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
pm, err := mailparser.Parse(raw)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to parse mail")
return
}
sess := sessionFromCtx(r.Context())
// Tenant isolation: domain_admin sees only own tenant's mail
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// user role: only own mailbox
if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
type attachMeta struct {
Index int `json:"index"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int `json:"size"`
}
attachments := make([]attachMeta, len(pm.Attachments))
for i, a := range pm.Attachments {
attachments[i] = attachMeta{
Index: i,
Filename: a.Filename,
ContentType: a.ContentType,
Size: a.Size,
}
}
var dateStr string
if !pm.Date.IsZero() {
dateStr = pm.Date.UTC().Format(time.RFC3339)
}
// Verify status
vs, _ := s.store.GetVerifyStatus(r.Context(), id)
var verifyOK interface{} = nil
var verifiedAt interface{} = nil
if vs.VerifyOK != nil {
verifyOK = *vs.VerifyOK
}
if vs.VerifiedAt != nil {
verifiedAt = vs.VerifiedAt.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": id,
"from": pm.From,
"to": strings.Join(pm.To, ", "),
"cc": strings.Join(pm.CC, ", "),
"subject": pm.Subject,
"date": dateStr,
"size": len(raw),
"body_html": pm.HTMLBody,
"body_plain": pm.TextBody,
"raw_headers": extractRawHeaders(raw),
"attachments": attachments,
"verify_ok": verifyOK,
"verified_at": verifiedAt,
})
}
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SEC-22: Validate mail ID format to prevent path traversal.
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
indexStr := r.PathValue("index")
idx, err := strconv.Atoi(indexStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid attachment index")
return
}
raw, err := s.store.Load(id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
pm, err := mailparser.Parse(raw)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to parse mail")
return
}
sess := sessionFromCtx(r.Context())
// Tenant isolation
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
if idx < 0 || idx >= len(pm.Attachments) {
writeError(w, http.StatusNotFound, "attachment not found")
return
}
a := pm.Attachments[idx]
filename := a.Filename
if filename == "" {
filename = fmt.Sprintf("attachment-%d", idx)
}
w.Header().Set("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Content-Length", strconv.Itoa(len(a.Data)))
w.WriteHeader(http.StatusOK)
w.Write(a.Data)
}
func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SEC-22: Validate mail ID format to prevent path traversal.
if !isValidMailID(id) {
writeError(w, http.StatusBadRequest, "invalid mail id")
return
}
raw, err := s.store.Load(id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
sess := sessionFromCtx(r.Context())
// Tenant isolation
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// SEC-28: Access check for user role — parse failure must NOT grant access.
if sess.Role == userstore.RoleUser {
pm, err := mailparser.Parse(raw)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to parse mail")
return
}
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
w.Header().Set("Content-Type", "message/rfc822")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.eml"`, id[:16]))
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
w.WriteHeader(http.StatusOK)
w.Write(raw)
}
// ── Helpers ───────────────────────────────────────────────────────────────
// mailBelongsToUser checks if the user's email appears in To or CC.
func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool {
email := strings.ToLower(userEmail)
for _, to := range pm.To {
if strings.ToLower(to) == email {
return true
}
}
for _, cc := range pm.CC {
if strings.ToLower(cc) == email {
return true
}
}
return false
}
// extractRawHeaders returns the header section of a raw RFC 2822 email.
func extractRawHeaders(raw []byte) string {
for i := 0; i < len(raw)-3; i++ {
if raw[i] == '\r' && raw[i+1] == '\n' && raw[i+2] == '\r' && raw[i+3] == '\n' {
return string(raw[:i])
}
if raw[i] == '\n' && raw[i+1] == '\n' {
return string(raw[:i])
}
}
return string(raw)
}
// --- 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: 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: remoteIP(r),
Detail: name,
Success: true,
})
writeJSON(w, http.StatusOK, systemctlShow(name))
}
// ── IMAP handlers ─────────────────────────────────────────────────────────
func (s *Server) handleListImap(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
sess := sessionFromCtx(r.Context())
// SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin).
isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin)
accounts, err := s.imapStore.List(r.Context(), sess.Username, isAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list IMAP accounts")
return
}
if accounts == nil {
accounts = []imapstore.Account{}
}
writeJSON(w, http.StatusOK, accounts)
}
func (s *Server) handleCreateImap(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
var req struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
TLS string `json:"tls"`
Username string `json:"username"`
Password string `json:"password"`
ExcludedFolders []string `json:"excluded_folders"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.Host == "" || req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "name, host, username and password are required")
return
}
if req.Port <= 0 {
req.Port = 993
}
if req.TLS == "" {
req.TLS = "ssl"
}
if req.ExcludedFolders == nil {
req.ExcludedFolders = []string{}
}
sess := sessionFromCtx(r.Context())
acc := imapstore.Account{
Owner: sess.Username,
Name: req.Name,
Host: req.Host,
Port: req.Port,
TLS: req.TLS,
Username: req.Username,
ExcludedFolders: req.ExcludedFolders,
}
created, err := s.imapStore.Create(r.Context(), acc, req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create IMAP account")
return
}
writeJSON(w, http.StatusCreated, created)
}
func (s *Server) handleDeleteImap(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.imapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
writeError(w, http.StatusForbidden, "access denied")
return
}
if err := s.imapStore.Delete(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete account")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleTestImap(w http.ResponseWriter, r *http.Request) {
var req struct {
Host string `json:"host"`
Port int `json:"port"`
TLS string `json:"tls"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Host == "" || req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "host, username and password are required")
return
}
if req.Port <= 0 {
req.Port = 993
}
if req.TLS == "" {
req.TLS = "ssl"
}
c, err := imapstore.Connect(req.Host, req.Port, req.TLS)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": false,
"error": fmt.Sprintf("Verbindung fehlgeschlagen: %v", err),
})
return
}
defer c.Close()
if err := c.Login(req.Username, req.Password).Wait(); err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": false,
"error": fmt.Sprintf("Anmeldung fehlgeschlagen: %v", err),
})
return
}
folders, err := imapstore.ListFolders(c)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": false,
"error": fmt.Sprintf("Ordner konnten nicht gelesen werden: %v", err),
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"folders": folders,
})
}
func (s *Server) handleStartImport(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil || s.imapImporter == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.imapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
writeError(w, http.StatusForbidden, "access denied")
return
}
if acc.Status == "running" {
writeError(w, http.StatusConflict, "import already running")
return
}
go s.imapImporter.Run(context.Background(), id)
// Return current account state (status will switch to "running" shortly)
acc.Status = "running"
writeJSON(w, http.StatusOK, acc)
}
func (s *Server) handleImapProgress(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.imapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
writeError(w, http.StatusForbidden, "access denied")
return
}
writeJSON(w, http.StatusOK, acc)
}
// handleSyncNow triggers an immediate incremental sync for a single IMAP account.
func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil || s.imapScheduler == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.imapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
writeError(w, http.StatusForbidden, "access denied")
return
}
if err := s.imapScheduler.TriggerSync(r.Context(), id); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
// Return the account with the updated sync_running flag reflected immediately.
acc.SyncRunning = true
writeJSON(w, http.StatusOK, acc)
}
// handleUpdateImapInterval updates the automatic sync interval for an IMAP account.
func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.imapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
writeError(w, http.StatusForbidden, "access denied")
return
}
var req struct {
SyncIntervalMin int `json:"sync_interval_min"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// 0 = disabled; otherwise must be between 5 and 1440 minutes.
if req.SyncIntervalMin != 0 && (req.SyncIntervalMin < 5 || req.SyncIntervalMin > 1440) {
writeError(w, http.StatusBadRequest, "sync_interval_min must be 0 (disabled) or between 5 and 1440")
return
}
if err := s.imapStore.UpdateSyncInterval(r.Context(), id, req.SyncIntervalMin); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update sync interval")
return
}
acc.SyncIntervalMin = req.SyncIntervalMin
writeJSON(w, http.StatusOK, acc)
}
// ── System stats handler ─────────────────────────────────────────────────
type diskStat struct {
Mount string `json:"mount"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPct float64 `json:"used_pct"`
FSType string `json:"fstype"`
}
type mailInfo struct {
ID string `json:"id"`
Date string `json:"date"`
From string `json:"from"`
Subject string `json:"subject"`
}
var excludedFSTypes = map[string]bool{
"tmpfs": true, "proc": true, "sysfs": true, "devtmpfs": true,
"cgroup": true, "cgroup2": true, "overlay": true, "squashfs": true,
"debugfs": true, "tracefs": true, "securityfs": true, "pstore": true,
"efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true,
"ramfs": true, "devpts": true, "fusectl": true, "configfs": true,
"autofs": true, "nsfs": true, "rpc_pipefs": true,
"fuse.lxcfs": true, "fuse": true,
}
func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
// CPU: /proc/loadavg
cpuResp := map[string]interface{}{"load1": 0.0, "load5": 0.0, "load15": 0.0, "num_cpu": runtime.NumCPU()}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
parts := strings.Fields(string(data))
if len(parts) >= 3 {
l1, _ := strconv.ParseFloat(parts[0], 64)
l5, _ := strconv.ParseFloat(parts[1], 64)
l15, _ := strconv.ParseFloat(parts[2], 64)
cpuResp = map[string]interface{}{"load1": l1, "load5": l5, "load15": l15, "num_cpu": runtime.NumCPU()}
}
}
// RAM: /proc/meminfo
ramResp := map[string]interface{}{"total_bytes": uint64(0), "used_bytes": uint64(0), "free_bytes": uint64(0), "used_pct": 0.0}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
kv := parseMeminfo(string(data))
total := kv["MemTotal"] * 1024
available := kv["MemAvailable"] * 1024
used := total - available
var usedPct float64
if total > 0 {
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
}
ramResp = map[string]interface{}{
"total_bytes": total,
"used_bytes": used,
"free_bytes": available,
"used_pct": usedPct,
}
}
// Disks: /proc/mounts + syscall.Statfs
var disks []diskStat
seenMounts := map[string]bool{} // deduplicate by mountpoint
seenDevices := map[string]bool{} // deduplicate by device (catches ZFS bind-mounts)
if data, err := os.ReadFile("/proc/mounts"); err == nil {
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
device := fields[0]
mount := fields[1]
fstype := fields[2]
if excludedFSTypes[fstype] || seenMounts[mount] || seenDevices[device] {
continue
}
seenMounts[mount] = true
var stat syscall.Statfs_t
if err := syscall.Statfs(mount, &stat); err != nil {
continue
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bavail * uint64(stat.Bsize)
used := total - free
if total == 0 {
continue // skip pseudo-mounts with no storage (e.g. lxcfs overlays)
}
seenDevices[device] = true
var usedPct float64
if total > 0 {
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
}
disks = append(disks, diskStat{
Mount: mount,
TotalBytes: total,
UsedBytes: used,
FreeBytes: free,
UsedPct: usedPct,
FSType: fstype,
})
}
}
if disks == nil {
disks = []diskStat{}
}
// Archive: first & last mail
archiveResp := map[string]interface{}{"first_mail": nil, "last_mail": nil}
first, last, err := s.store.FirstAndLastMail()
if err == nil {
if first != nil {
archiveResp["first_mail"] = mailRefToInfo(s.store, first)
}
if last != nil {
archiveResp["last_mail"] = mailRefToInfo(s.store, last)
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"cpu": cpuResp,
"ram": ramResp,
"disks": disks,
"archive": archiveResp,
})
}
func parseMeminfo(content string) map[string]uint64 {
result := make(map[string]uint64)
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
k, v, ok := strings.Cut(scanner.Text(), ":")
if !ok {
continue
}
fields := strings.Fields(strings.TrimSpace(v))
if len(fields) == 0 {
continue
}
val, err := strconv.ParseUint(fields[0], 10, 64)
if err == nil {
result[k] = val
}
}
return result
}
func mailRefToInfo(store *storage.Store, ref *storage.MailRef) *mailInfo {
dateStr := ref.ModTime.UTC().Format(time.RFC3339)
raw, err := store.Load(ref.ID)
if err != nil {
return &mailInfo{ID: ref.ID, Date: dateStr}
}
pm, err := mailparser.Parse(raw)
if err != nil {
return &mailInfo{ID: ref.ID, Date: dateStr}
}
if !pm.Date.IsZero() {
dateStr = pm.Date.UTC().Format(time.RFC3339)
}
return &mailInfo{
ID: ref.ID,
Date: dateStr,
From: pm.From,
Subject: pm.Subject,
}
}
// ── 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 {
writeError(w, http.StatusInternalServerError, "could not write jail.local: "+err.Error())
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})
}
// ── POP3 handlers ──────────────────────────────────────────────────────────
func (s *Server) handleListPop3(w http.ResponseWriter, r *http.Request) {
if s.pop3Store == nil {
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
return
}
sess := sessionFromCtx(r.Context())
// SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin).
isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin)
accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list POP3 accounts")
return
}
if accounts == nil {
accounts = []pop3store.Account{}
}
writeJSON(w, http.StatusOK, accounts)
}
func (s *Server) handleCreatePop3(w http.ResponseWriter, r *http.Request) {
if s.pop3Store == nil {
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
return
}
var req struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
TLS string `json:"tls"`
TLSSkipVerify bool `json:"tls_skip_verify"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.Host == "" || req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "name, host, username and password are required")
return
}
if req.Port <= 0 {
req.Port = 110
}
if req.TLS == "" {
req.TLS = "none"
}
sess := sessionFromCtx(r.Context())
acc := pop3store.Account{
Owner: sess.Username,
Name: req.Name,
Host: req.Host,
Port: req.Port,
TLS: req.TLS,
TLSSkipVerify: req.TLSSkipVerify,
Username: req.Username,
}
created, err := s.pop3Store.Create(r.Context(), acc, req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create POP3 account")
return
}
writeJSON(w, http.StatusCreated, created)
}
func (s *Server) handleDeletePop3(w http.ResponseWriter, r *http.Request) {
if s.pop3Store == nil {
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.pop3Store.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
writeError(w, http.StatusForbidden, "access denied")
return
}
if err := s.pop3Store.Delete(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete account")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleTestPop3(w http.ResponseWriter, r *http.Request) {
var req struct {
Host string `json:"host"`
Port int `json:"port"`
TLS string `json:"tls"`
TLSSkipVerify bool `json:"tls_skip_verify"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Host == "" || req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "host, username and password are required")
return
}
if req.Port <= 0 {
req.Port = 110
}
if req.TLS == "" {
req.TLS = "none"
}
c, err := pop3store.Dial(req.Host, req.Port, req.TLS, req.TLSSkipVerify)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": false,
"message": fmt.Sprintf("Verbindung fehlgeschlagen: %v", err),
})
return
}
defer c.Close()
if err := c.Login(req.Username, req.Password); err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": false,
"message": fmt.Sprintf("Anmeldung fehlgeschlagen: %v", err),
})
return
}
count, totalSize, err := c.Stat()
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": false,
"message": fmt.Sprintf("STAT fehlgeschlagen: %v", err),
})
return
}
_ = c.Quit()
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"message": fmt.Sprintf("Verbindung erfolgreich: %d E-Mails", count),
"message_count": count,
"total_size_bytes": totalSize,
})
}
func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) {
if s.pop3Store == nil || s.pop3Importer == nil {
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.pop3Store.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
writeError(w, http.StatusForbidden, "access denied")
return
}
if acc.Status == "running" {
writeError(w, http.StatusConflict, "import already running")
return
}
go s.pop3Importer.Run(context.Background(), id)
// Return current account state (status will switch to "running" shortly)
acc.Status = "running"
writeJSON(w, http.StatusOK, acc)
}
func (s *Server) handlePop3Progress(w http.ResponseWriter, r *http.Request) {
if s.pop3Store == nil {
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.pop3Store.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
writeError(w, http.StatusForbidden, "access denied")
return
}
writeJSON(w, http.StatusOK, acc)
}
// 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)
}