Files
MigaduAdmin/internal/api/server.go
T
Squid a79cb61104 Add Migadu domain admin with shared lists, compaction, and safer apply.
Build the Go API and React UI for managing domain denylist/allowlist/recipient
deny and spam settings, plus shared lists that import/apply across domains with
wildcard compaction, entry verification, and Migadu-friendly list encoding
(including per-domain recipient filtering and rejection bisect on apply).
2026-07-26 21:50:26 +02:00

759 lines
23 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/squid/MigaduAdmin/internal/auth"
"github.com/squid/MigaduAdmin/internal/config"
"github.com/squid/MigaduAdmin/internal/lists"
"github.com/squid/MigaduAdmin/internal/migadu"
"github.com/squid/MigaduAdmin/internal/store"
)
type Server struct {
cfg *config.Config
auth *auth.Service
store *store.Store
migadu *migadu.Client
}
func New(cfg *config.Config, authService *auth.Service, st *store.Store, migaduClient *migadu.Client) *Server {
return &Server{
cfg: cfg,
auth: authService,
store: st,
migadu: migaduClient,
}
}
func (s *Server) Router(static http.Handler) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Do not use RealIP before auth: trusted-proxy checks need the immediate peer.
r.Use(s.auth.StripUntrustedIdentityHeaders)
r.Route("/api", func(api chi.Router) {
api.Get("/health", s.handleHealth)
api.Route("/auth", func(ar chi.Router) {
ar.Get("/status", s.handleAuthStatus)
ar.Post("/register", s.handleRegister)
ar.Post("/login", s.handleLogin)
ar.Post("/logout", s.handleLogout)
ar.With(s.auth.Middleware).Get("/me", s.handleMe)
})
api.Group(func(protected chi.Router) {
protected.Use(s.auth.Middleware)
protected.Use(s.auth.RequireAdmin)
protected.Get("/managed-domains", s.handleListManagedDomains)
protected.Post("/managed-domains", s.handleAddManagedDomain)
protected.Delete("/managed-domains/{domain}", s.handleDeleteManagedDomain)
protected.Get("/shared-lists", s.handleGetSharedLists)
protected.Put("/shared-lists", s.handlePutSharedLists)
protected.Post("/shared-lists/apply", s.handleApplySharedLists)
protected.Post("/shared-lists/import", s.handleImportSharedLists)
protected.Get("/domains/{domain}", s.handleGetDomain)
protected.Patch("/domains/{domain}", s.handleUpdateDomain)
protected.Get("/users", s.handleListUsers)
protected.Post("/users", s.handleCreateUser)
})
})
if static != nil {
r.Handle("/*", static)
}
return r
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
allowed, err := s.auth.RegistrationAllowed(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check registration status")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"registration_open": allowed,
"auth_mode": s.cfg.AuthMode,
})
}
type credentialsRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req credentialsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.auth.Register(r.Context(), req.Email, req.DisplayName, req.Password)
if err != nil {
switch {
case errors.Is(err, auth.ErrClosedReg):
writeError(w, http.StatusForbidden, "registration is closed")
case errors.Is(err, store.ErrEmailTaken):
writeError(w, http.StatusConflict, "email already registered")
case errors.Is(err, store.ErrInvalidInput):
writeError(w, http.StatusBadRequest, "invalid email or role")
default:
if strings.Contains(err.Error(), "password") {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "registration failed")
}
return
}
if err := s.auth.CreateSession(r.Context(), w, user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create session")
return
}
writeJSON(w, http.StatusCreated, user)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if s.cfg.AuthMode == config.AuthModePangolin {
writeError(w, http.StatusBadRequest, "local login disabled; use Pangolin SSO")
return
}
var req credentialsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.auth.AuthenticateLocal(r.Context(), req.Email, req.Password)
if err != nil {
if errors.Is(err, auth.ErrUnauthorized) {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if err := s.auth.CreateSession(r.Context(), w, user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create session")
return
}
writeJSON(w, http.StatusOK, user)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.auth.ClearSession(r.Context(), w, r)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
writeJSON(w, http.StatusOK, user)
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.ListUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list users")
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
type createUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Role store.Role `json:"role"`
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.auth.CreateUserAsAdmin(r.Context(), req.Email, req.DisplayName, req.Password, req.Role)
if err != nil {
switch {
case errors.Is(err, store.ErrEmailTaken):
writeError(w, http.StatusConflict, "email already registered")
case errors.Is(err, store.ErrInvalidInput):
writeError(w, http.StatusBadRequest, "invalid email or role")
default:
if strings.Contains(err.Error(), "password") {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
}
return
}
writeJSON(w, http.StatusCreated, user)
}
func (s *Server) handleListManagedDomains(w http.ResponseWriter, r *http.Request) {
managed, err := s.store.ListManagedDomains(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list managed domains")
return
}
type managedDomainResponse struct {
Name string `json:"name"`
AddedAt string `json:"added_at"`
AddedBy *int64 `json:"added_by,omitempty"`
State string `json:"state,omitempty"`
Description string `json:"description,omitempty"`
Accessible bool `json:"accessible"`
Error string `json:"error,omitempty"`
}
out := make([]managedDomainResponse, 0, len(managed))
for _, item := range managed {
entry := managedDomainResponse{
Name: item.Name,
AddedAt: item.AddedAt.UTC().Format(time.RFC3339Nano),
AddedBy: item.AddedBy,
}
domain, err := s.migadu.GetDomain(r.Context(), item.Name)
if err != nil {
entry.Accessible = false
var apiErr *migadu.APIError
if errors.As(err, &apiErr) {
entry.Error = fmt.Sprintf("migadu HTTP %d", apiErr.StatusCode)
} else {
entry.Error = "migadu unreachable"
}
} else {
entry.Accessible = true
entry.State = domain.State
entry.Description = domain.Description
}
out = append(out, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"domains": out})
}
type addManagedDomainRequest struct {
Name string `json:"name"`
}
func (s *Server) handleAddManagedDomain(w http.ResponseWriter, r *http.Request) {
var req addManagedDomainRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if name == "" {
writeError(w, http.StatusBadRequest, "domain name is required")
return
}
log.Printf("managed-domains: verifying access to %q via Migadu", name)
domain, err := s.migadu.GetDomain(r.Context(), name)
if err != nil {
var apiErr *migadu.APIError
if errors.As(err, &apiErr) {
log.Printf(
"managed-domains: access check failed for %q: HTTP %d body=%q",
name,
apiErr.StatusCode,
apiErr.Body,
)
// Migadu commonly returns 400 on failure; also treat auth/not-found as inaccessible.
if apiErr.StatusCode == http.StatusBadRequest ||
apiErr.StatusCode == http.StatusNotFound ||
apiErr.StatusCode == http.StatusForbidden ||
apiErr.StatusCode == http.StatusUnauthorized {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "cannot access domain with configured API key",
"migadu_status": apiErr.StatusCode,
"migadu_body": apiErr.Body,
"domain": name,
})
return
}
writeMigaduError(w, err)
return
}
log.Printf("managed-domains: access check failed for %q: %v", name, err)
writeError(w, http.StatusBadGateway, "cannot verify domain with Migadu: "+err.Error())
return
}
log.Printf("managed-domains: access ok for %q (state=%q)", domain.Name, domain.State)
user := auth.UserFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
managed, err := s.store.AddManagedDomain(r.Context(), domain.Name, user.ID)
if err != nil {
switch {
case errors.Is(err, store.ErrDomainExists):
writeError(w, http.StatusConflict, "domain already managed")
case errors.Is(err, store.ErrInvalidInput):
writeError(w, http.StatusBadRequest, "invalid domain name")
default:
writeError(w, http.StatusInternalServerError, "failed to save domain")
}
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"name": managed.Name,
"added_at": managed.AddedAt.UTC().Format(time.RFC3339Nano),
"added_by": managed.AddedBy,
"state": domain.State,
"description": domain.Description,
"accessible": true,
})
}
func (s *Server) handleDeleteManagedDomain(w http.ResponseWriter, r *http.Request) {
domainName := chi.URLParam(r, "domain")
if err := s.store.DeleteManagedDomain(r.Context(), domainName); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "domain not found in registry")
return
}
writeError(w, http.StatusInternalServerError, "failed to remove domain")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetSharedLists(w http.ResponseWriter, r *http.Request) {
lists, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handlePutSharedLists(w http.ResponseWriter, r *http.Request) {
var req store.SharedLists
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.SenderDenylist == nil {
req.SenderDenylist = []string{}
}
if req.SenderAllowlist == nil {
req.SenderAllowlist = []string{}
}
if req.RecipientDenylist == nil {
req.RecipientDenylist = []string{}
}
if err := s.store.PutSharedLists(r.Context(), req); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save shared lists")
return
}
lists, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
writeJSON(w, http.StatusOK, lists)
}
type sharedListsTargetsRequest struct {
All bool `json:"all"`
Domains []string `json:"domains"`
Replace bool `json:"replace"`
}
type sharedListsDomainResult struct {
Domain string `json:"domain"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Rejected []string `json:"rejected,omitempty"`
}
func (s *Server) handleApplySharedLists(w http.ResponseWriter, r *http.Request) {
var req sharedListsTargetsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if !req.All && len(req.Domains) == 0 {
writeError(w, http.StatusBadRequest, "provide all=true or a non-empty domains list")
return
}
shared, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
targets, err := s.resolveSharedListTargets(r, req)
if err != nil {
if strings.Contains(err.Error(), "failed to") {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
sharedDenylist := lists.Compact(shared.SenderDenylist)
sharedAllowlist := lists.Compact(shared.SenderAllowlist)
sharedRecipient := lists.Compact(shared.RecipientDenylist)
// Persist normalized/compacted shared lists (repairs typos, drops covered entries).
if err := s.store.PutSharedLists(r.Context(), store.SharedLists{
SenderDenylist: sharedDenylist,
SenderAllowlist: sharedAllowlist,
RecipientDenylist: sharedRecipient,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save compacted shared lists")
return
}
log.Printf(
"shared-lists apply: replace=%v targets=%d denylist=%d allowlist=%d recipient=%d",
req.Replace,
len(targets),
len(sharedDenylist),
len(sharedAllowlist),
len(sharedRecipient),
)
results := make([]sharedListsDomainResult, 0, len(targets))
for _, domainName := range targets {
result := sharedListsDomainResult{Domain: domainName}
var denylist, allowlist, recipient []string
if req.Replace {
// Always use non-nil slices so JSON encodes [] not null (Migadu 400s on null).
denylist = append([]string{}, sharedDenylist...)
allowlist = append([]string{}, sharedAllowlist...)
// Recipient deny is domain-scoped: only addresses for this domain.
recipient = lists.FilterRecipientEntriesForDomain(domainName, sharedRecipient)
} else {
domain, err := s.migadu.GetDomain(r.Context(), domainName)
if err != nil {
result.Error = migaduErrorMessage(err)
results = append(results, result)
continue
}
denylist = unionStringLists([]string(domain.SenderDenylist), sharedDenylist)
allowlist = unionStringLists([]string(domain.SenderAllowlist), sharedAllowlist)
recipient = unionStringLists(
[]string(domain.RecipientDenylist),
lists.FilterRecipientEntriesForDomain(domainName, sharedRecipient),
)
}
writeResult, err := s.migadu.UpdateDomainLists(r.Context(), domainName, denylist, allowlist, recipient)
if err != nil {
result.Error = migaduErrorMessage(err)
results = append(results, result)
continue
}
result.OK = true
result.Rejected = writeResult.Rejected
results = append(results, result)
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
}
func (s *Server) handleImportSharedLists(w http.ResponseWriter, r *http.Request) {
var req sharedListsTargetsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if !req.All && len(req.Domains) == 0 {
writeError(w, http.StatusBadRequest, "provide all=true or a non-empty domains list")
return
}
shared, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
targets, err := s.resolveSharedListTargets(r, req)
if err != nil {
if strings.Contains(err.Error(), "failed to") {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
denylist := append([]string(nil), shared.SenderDenylist...)
allowlist := append([]string(nil), shared.SenderAllowlist...)
recipient := append([]string(nil), shared.RecipientDenylist...)
results := make([]sharedListsDomainResult, 0, len(targets))
for _, domainName := range targets {
result := sharedListsDomainResult{Domain: domainName}
domain, err := s.migadu.GetDomain(r.Context(), domainName)
if err != nil {
result.Error = migaduErrorMessage(err)
results = append(results, result)
continue
}
denylist = append(denylist, []string(domain.SenderDenylist)...)
allowlist = append(allowlist, []string(domain.SenderAllowlist)...)
recipient = append(recipient, []string(domain.RecipientDenylist)...)
result.OK = true
results = append(results, result)
}
merged := store.SharedLists{
SenderDenylist: lists.Compact(denylist),
SenderAllowlist: lists.Compact(allowlist),
RecipientDenylist: lists.Compact(recipient),
}
if err := s.store.PutSharedLists(r.Context(), merged); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save shared lists")
return
}
saved, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"lists": saved,
"results": results,
})
}
func (s *Server) resolveSharedListTargets(r *http.Request, req sharedListsTargetsRequest) ([]string, error) {
if req.All {
managed, err := s.store.ListManagedDomains(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to list managed domains")
}
names := make([]string, 0, len(managed))
for _, item := range managed {
names = append(names, item.Name)
}
return names, nil
}
seen := make(map[string]struct{}, len(req.Domains))
names := make([]string, 0, len(req.Domains))
for _, raw := range req.Domains {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
ok, err := s.store.IsManagedDomain(r.Context(), name)
if err != nil {
return nil, fmt.Errorf("failed to check managed domain")
}
if !ok {
return nil, fmt.Errorf("domain is not in the administered registry: %s", name)
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return nil, fmt.Errorf("provide all=true or a non-empty domains list")
}
return names, nil
}
func unionStringLists(existing, shared []string) []string {
out := make([]string, 0, len(existing)+len(shared))
out = append(out, existing...)
out = append(out, shared...)
return lists.Compact(out)
}
func migaduErrorMessage(err error) string {
var apiErr *migadu.APIError
if errors.As(err, &apiErr) {
if apiErr.Body != "" {
return fmt.Sprintf("migadu HTTP %d: %s", apiErr.StatusCode, apiErr.Body)
}
return fmt.Sprintf("migadu HTTP %d", apiErr.StatusCode)
}
return err.Error()
}
func (s *Server) requireManagedDomain(w http.ResponseWriter, r *http.Request, domainName string) bool {
ok, err := s.store.IsManagedDomain(r.Context(), domainName)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check managed domain")
return false
}
if !ok {
writeError(w, http.StatusForbidden, "domain is not in the administered registry")
return false
}
return true
}
func (s *Server) handleGetDomain(w http.ResponseWriter, r *http.Request) {
domainName := chi.URLParam(r, "domain")
if !s.requireManagedDomain(w, r, domainName) {
return
}
domain, err := s.migadu.GetDomain(r.Context(), domainName)
if err != nil {
writeMigaduError(w, err)
return
}
writeJSON(w, http.StatusOK, domain)
}
func (s *Server) handleUpdateDomain(w http.ResponseWriter, r *http.Request) {
domainName := chi.URLParam(r, "domain")
if !s.requireManagedDomain(w, r, domainName) {
return
}
var raw map[string]json.RawMessage
if err := decodeJSON(r, &raw); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
allowed := map[string]struct{}{
"sender_denylist": {},
"sender_allowlist": {},
"recipient_denylist": {},
"spam_aggressiveness": {},
"junk_subject_keyword_spam": {},
"subject_rewriting_enabled": {},
}
for key := range raw {
if _, ok := allowed[key]; !ok {
writeError(w, http.StatusBadRequest, "unsupported field: "+key)
return
}
}
var update migadu.DomainUpdate
if value, ok := raw["sender_denylist"]; ok {
var list []string
if err := json.Unmarshal(value, &list); err != nil {
writeError(w, http.StatusBadRequest, "sender_denylist must be a string array")
return
}
list = lists.Compact(list)
update.SenderDenylist = &list
}
if value, ok := raw["sender_allowlist"]; ok {
var list []string
if err := json.Unmarshal(value, &list); err != nil {
writeError(w, http.StatusBadRequest, "sender_allowlist must be a string array")
return
}
list = lists.Compact(list)
update.SenderAllowlist = &list
}
if value, ok := raw["recipient_denylist"]; ok {
var list []string
if err := json.Unmarshal(value, &list); err != nil {
writeError(w, http.StatusBadRequest, "recipient_denylist must be a string array")
return
}
list = lists.Compact(list)
update.RecipientDenylist = &list
}
if value, ok := raw["spam_aggressiveness"]; ok {
var aggressiveness string
if err := json.Unmarshal(value, &aggressiveness); err != nil {
writeError(w, http.StatusBadRequest, "spam_aggressiveness must be a string")
return
}
update.SpamAggressiveness = &aggressiveness
}
if value, ok := raw["junk_subject_keyword_spam"]; ok {
var flag bool
if err := json.Unmarshal(value, &flag); err != nil {
writeError(w, http.StatusBadRequest, "junk_subject_keyword_spam must be a boolean")
return
}
update.JunkSubjectKeywordSpam = &flag
}
if value, ok := raw["subject_rewriting_enabled"]; ok {
var flag bool
if err := json.Unmarshal(value, &flag); err != nil {
writeError(w, http.StatusBadRequest, "subject_rewriting_enabled must be a boolean")
return
}
update.SubjectRewritingEnabled = &flag
}
domain, err := s.migadu.UpdateDomain(r.Context(), domainName, update)
if err != nil {
writeMigaduError(w, err)
return
}
writeJSON(w, http.StatusOK, domain)
}
func decodeJSON(r *http.Request, dest any) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(dest)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeMigaduError(w http.ResponseWriter, err error) {
var apiErr *migadu.APIError
if errors.As(err, &apiErr) {
status := http.StatusBadGateway
if apiErr.StatusCode == http.StatusNotFound {
status = http.StatusNotFound
} else if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
status = apiErr.StatusCode
}
writeJSON(w, status, map[string]any{
"error": "migadu request failed",
"migadu_status": apiErr.StatusCode,
"migadu_body": apiErr.Body,
})
return
}
writeError(w, http.StatusBadGateway, "migadu request failed")
}