package cli import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net" "net/http" "os" "path/filepath" "time" "postern/internal/model" ) // HTTPService implements the cli.Service interface for serving HTTP endpoints. type HTTPService struct { cmd *Command socketPath string srv *http.Server } // NewHTTPService creates a new HTTP service that serves REST endpoints. func NewHTTPService(socketPath string, cmd *Command) *HTTPService { return &HTTPService{ cmd: cmd, socketPath: socketPath, } } // Start starts the HTTP server. func (h *HTTPService) Start() error { mux := h.HTTPMux() // Ensure parent dir exists if err := os.MkdirAll(filepath.Dir(h.socketPath), 0700); err != nil { return fmt.Errorf("creating socket dir: %w", err) } // Remove stale socket from ungraceful shutdown _ = os.Remove(h.socketPath) listener, err := net.Listen("unix", h.socketPath) if err != nil { return fmt.Errorf("listening on unix socket: %w", err) } if err := os.Chmod(h.socketPath, 0660); err != nil { return fmt.Errorf("setting socket permissions: %w", err) } h.srv = &http.Server{ Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } slog.Info("starting HTTP server on unix socket", "path", h.socketPath) err = h.srv.Serve(listener) if err != nil && !errors.Is(err, http.ErrServerClosed) { return err } return nil } // Stop stops the HTTP server gracefully. func (h *HTTPService) Stop() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() slog.Info("shutting down HTTP server") return h.srv.Shutdown(ctx) } // Status returns the status of the HTTP service. func (h *HTTPService) Status() string { if h.srv != nil { return "running" } return "stopped" } // HTTPMux creates and returns the HTTP ServeMux with all routes. func (h *HTTPService) HTTPMux() *http.ServeMux { mux := http.NewServeMux() // User endpoints mux.HandleFunc("GET /users", h.listUsers) mux.HandleFunc("POST /users", h.createUser) mux.HandleFunc("DELETE /users/{username}", h.deleteUser) mux.HandleFunc("POST /users/{username}/addresses", h.addUserAddress) mux.HandleFunc("POST /users/{username}/rotate-password", h.rotateUserPassword) // SMTP auth endpoints mux.HandleFunc("GET /smtp-auth", h.listSMTPAuth) mux.HandleFunc("POST /smtp-auth", h.createSMTPAuth) mux.HandleFunc("DELETE /smtp-auth/{name}", h.deleteSMTPAuth) // DKIM endpoints mux.HandleFunc("GET /dkim", h.listDKIM) mux.HandleFunc("POST /dkim", h.addDKIM) mux.HandleFunc("GET /dkim/{selector}/{domain}", h.getDKIM) mux.HandleFunc("POST /dkim/{selector}/{domain}/enable", h.enableDKIM) mux.HandleFunc("POST /dkim/{selector}/{domain}/disable", h.disableDKIM) mux.HandleFunc("DELETE /dkim/{selector}/{domain}", h.deleteDKIM) // Prune endpoint mux.HandleFunc("POST /prune", h.pruneMails) // KV endpoints mux.HandleFunc("GET /kv", h.kvList) mux.HandleFunc("GET /kv/{key...}", h.kvGet) mux.HandleFunc("DELETE /kv/{key...}", h.kvPop) return mux } // Helper to write JSON responses func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { slog.Error("failed to encode JSON response", "error", err) } } // Helper to write error responses func writeError(w http.ResponseWriter, err error) { switch { case errors.Is(err, model.ErrAlreadyExists): writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) case errors.Is(err, model.ErrNotFound): writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) case errors.Is(err, model.ErrInvalidInput): writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) default: writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } func (h *HTTPService) listUsers(w http.ResponseWriter, r *http.Request) { users, err := h.cmd.ListUsers(r.Context()) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, users) } func (h *HTTPService) createUser(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } if req.Username == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) return } user, err := h.cmd.CreateUser(r.Context(), req.Username) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusCreated, user) } func (h *HTTPService) deleteUser(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") if username == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) return } err := h.cmd.RemoveUser(r.Context(), username) if err != nil { writeError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *HTTPService) addUserAddress(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") if username == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) return } var req struct { Address string `json:"address"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } if req.Address == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "address is required"}) return } err := h.cmd.AddUserAddress(r.Context(), username, req.Address) if err != nil { writeError(w, err) return } w.WriteHeader(http.StatusCreated) } func (h *HTTPService) rotateUserPassword(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") if username == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) return } user, err := h.cmd.RotateUserPassword(r.Context(), username) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, user) } func (h *HTTPService) listSMTPAuth(w http.ResponseWriter, r *http.Request) { auths, err := h.cmd.ListSMTPAuth(r.Context()) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, auths) } func (h *HTTPService) createSMTPAuth(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } if req.Name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } auth, err := h.cmd.CreateSMTPAuth(r.Context(), req.Name) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusCreated, auth) } func (h *HTTPService) deleteSMTPAuth(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } err := h.cmd.RemoveSMTPAuth(r.Context(), name) if err != nil { writeError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *HTTPService) listDKIM(w http.ResponseWriter, r *http.Request) { dkims, err := h.cmd.ListDKIM(r.Context()) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, dkims) } func (h *HTTPService) addDKIM(w http.ResponseWriter, r *http.Request) { var req struct { Selector string `json:"selector"` Domain string `json:"domain"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } if req.Selector == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) return } if req.Domain == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) return } record, err := h.cmd.AddDKIM(r.Context(), req.Selector, req.Domain) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"dns_record": record}) } func (h *HTTPService) getDKIM(w http.ResponseWriter, r *http.Request) { selector := r.PathValue("selector") domain := r.PathValue("domain") if selector == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) return } if domain == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) return } record, err := h.cmd.GetDKIM(r.Context(), selector, domain) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"dns_record": record}) } func (h *HTTPService) enableDKIM(w http.ResponseWriter, r *http.Request) { selector := r.PathValue("selector") domain := r.PathValue("domain") if selector == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) return } if domain == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) return } // Parse force query parameter forceStr := r.URL.Query().Get("force") force := forceStr != "false" // Default to true if not explicitly set to "false" err := h.cmd.SetDKIMEnabled(r.Context(), selector, domain, true, force) if err != nil { writeError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *HTTPService) disableDKIM(w http.ResponseWriter, r *http.Request) { selector := r.PathValue("selector") domain := r.PathValue("domain") if selector == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) return } if domain == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) return } err := h.cmd.SetDKIMEnabled(r.Context(), selector, domain, false, true) if err != nil { writeError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *HTTPService) deleteDKIM(w http.ResponseWriter, r *http.Request) { selector := r.PathValue("selector") domain := r.PathValue("domain") if selector == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) return } if domain == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) return } err := h.cmd.RemoveDKIM(r.Context(), selector, domain) if err != nil { writeError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *HTTPService) pruneMails(w http.ResponseWriter, r *http.Request) { count, err := h.cmd.PruneMails(r.Context()) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, map[string]int{"pruned": count}) } func (h *HTTPService) kvList(w http.ResponseWriter, r *http.Request) { pairs, err := h.cmd.KVList(r.Context()) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, pairs) } func (h *HTTPService) kvGet(w http.ResponseWriter, r *http.Request) { key := r.PathValue("key") if key == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key is required"}) return } val, err := h.cmd.KVGet(r.Context(), key) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"value": val}) } func (h *HTTPService) kvPop(w http.ResponseWriter, r *http.Request) { key := r.PathValue("key") if key == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key is required"}) return } val, err := h.cmd.KVPop(r.Context(), key) if err != nil { writeError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"value": val}) }