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 tenantLdapStore *ldapcfg.TenantStore // PROJ-23: per-tenant LDAP config idxMgr *index.TenantIndexManager // PROJ-21 Phase 4: per-tenant Xapian index } // 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 } // SetIndexManager wires the per-tenant index manager into the API server (PROJ-21 Phase 4). func (s *Server) SetIndexManager(mgr *index.TenantIndexManager) { s.idxMgr = mgr } // 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)) // PROJ-24: TOTP 2FA routes s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet)) s.mux.HandleFunc("POST /api/auth/totp/setup", s.auth(s.handleTOTPSetupPost)) s.mux.HandleFunc("DELETE /api/auth/totp", s.auth(s.handleTOTPDisable)) s.mux.HandleFunc("POST /api/auth/totp", s.handleTOTPLogin) // no auth middleware — uses pending token s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset)) } // 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, totpRequired, 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: classifyLoginError(err), }) writeError(w, http.StatusUnauthorized, "invalid credentials") return } // PROJ-24: If TOTP is enabled, return a pending token instead of a full session. if totpRequired { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: user.Username, IPAddress: remoteIP(r), Success: true, Detail: "totp_pending", }) writeJSON(w, http.StatusAccepted, map[string]interface{}{ "totp_required": true, "session_token": token, }) 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 { 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: 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: 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: 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 } } // PROJ-21 Phase 4: Use per-tenant index when available; fall back to // global index + post-filter when the tenant index manager is not wired. tenantID := tenantFromCtx(r.Context()) searchIdx := s.idx usedTenantIndex := false if s.idxMgr != nil && tenantID != nil { searchIdx = s.idxMgr.ForTenant(tenantID) usedTenantIndex = true } result, err := searchIdx.Search(req) if err != nil { writeError(w, http.StatusInternalServerError, "search failed") return } // Fallback tenant isolation: post-filter when we used the global index // but the user belongs to a tenant. This is the legacy path; the per-tenant // index path above makes this unnecessary. if tenantID != nil && !usedTenantIndex && 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 } // sanitizeFilename strips characters that could be used for HTTP header injection // (quotes, newlines, control chars) from attachment filenames coming from parsed // e-mails. Only alphanumerics, spaces, dots, hyphens, and underscores are kept. func sanitizeFilename(name string) string { var b strings.Builder for _, r := range name { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == ' ' { b.WriteRune(r) } } return b.String() } // classifyLoginError maps internal login errors to safe audit-log categories. // Raw error messages must not be stored in audit logs since auditor-role // users can read them via GET /api/audit and internal details (LDAP hostnames, // port numbers, etc.) would be exposed. func classifyLoginError(err error) string { if err == nil { return "" } msg := err.Error() switch { case strings.Contains(msg, "not found"), strings.Contains(msg, "invalid password"), strings.Contains(msg, "invalid credentials"): return "invalid_password" case strings.Contains(msg, "ldap"), strings.Contains(msg, "LDAP"): return "ldap_error" case strings.Contains(msg, "disabled"), strings.Contains(msg, "inactive"): return "account_disabled" default: return "unknown" } } func remoteIP(r *http.Request) string { if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { return strings.TrimSpace(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 := sanitizeFilename(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 { s.logger.Error("trigger sync failed", "err", err) writeError(w, http.StatusConflict, "sync already running or failed to start") 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 { 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}) } // ── 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) }