c6eeb30258
Adds application accounts bound to domains: a SASL login/password in sasldb2, a per-application address mode (wildcard @domain or an explicit list), and matching smtpd_sender_login_maps bindings — with create, list, edit-mode, delete and password regeneration (spec 4.1, 5.1, 7.2.5-9). Generated passwords are shown exactly once and never stored in plaintext (7.6.1). - internal/store/applications.go: transactional CRUD; globally unique login; ListBindings (address->login) as the map source; logins-by- domain for pre-cascade SASL cleanup. - internal/app: saslpasswd2 wrapper (password via stdin, login as a whitelisted argv element, no shell — 7.6.3); strong base64url password; address validation that enforces domain ownership before any config write (7.6.2); service orchestrating store + sasldb2 + map with full rollback on partial failure. - internal/postfix: sender_login_maps regenerated as a pure function of the registry (many-to-one logins merged per address), atomic write, injection backstop (7.6.4). - Postfix reload, corrected: `postfix start-fg` forks a separate master, so signalling the supervised process never reaches it. Reload now runs the canonical `postfix reload` via a one-shot supervisord program the unprivileged panel triggers over the group control socket. Verified in mail.log. - domain.Service.Delete purges the domain's SASL accounts, then cascades, then rebuilds the sender map and reloads; manual reload now covers both OpenDKIM and Postfix. - web: application management in the domain page, one-time credential shown inline; postfix joins the selfpost group and entrypoint normalises /data/sasl and /data/postfix (setgid, group-readable) with self-heal. Verified on the dev server: gofmt/vet/test green, image builds, and a container e2e covers the full application lifecycle, domain-delete cascade, restart persistence, and a real postfix reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
241 lines
7.8 KiB
Go
241 lines
7.8 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"codeberg.org/mix/selfpost/internal/store"
|
|
)
|
|
|
|
// newCred carries a freshly generated login/password to the template so it can
|
|
// be shown exactly once (spec 7.6.1). It is never read back from storage.
|
|
type newCred struct {
|
|
Login string
|
|
Password string
|
|
}
|
|
|
|
// detailView holds the one-shot, request-specific extras layered on top of a
|
|
// domain's persistent state when rendering its page: an application-form error,
|
|
// the values to repopulate that form, and any just-issued credential to show
|
|
// once.
|
|
type detailView struct {
|
|
FormErr string
|
|
FormLogin string
|
|
FormMode string
|
|
FormAddrs string
|
|
NewCred *newCred
|
|
}
|
|
|
|
// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10)
|
|
// and its applications with the controls to add, edit, delete and re-issue
|
|
// credentials (spec 7.2.5-9).
|
|
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
|
d, ok := s.lookupDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
|
|
}
|
|
|
|
// renderDomainDetail renders the domain page. view supplies request-specific
|
|
// extras (form error/values, a one-time credential); everything else is loaded
|
|
// fresh from the stores so the page always reflects committed state.
|
|
func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
|
|
record, err := s.domains.DKIMRecord(d)
|
|
if err != nil {
|
|
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
apps, err := s.apps.List(d.ID)
|
|
if err != nil {
|
|
logf("panel: domain %d: list applications: %v", d.ID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.render(w, status, "domain_detail", map[string]any{
|
|
"Title": "SelfPost — " + d.Name,
|
|
"User": currentUser(r),
|
|
"Domain": d,
|
|
"Record": record,
|
|
"Apps": apps,
|
|
"Error": view.FormErr,
|
|
"FormLogin": view.FormLogin,
|
|
"FormMode": view.FormMode,
|
|
"FormAddrs": view.FormAddrs,
|
|
"NewCred": view.NewCred,
|
|
"Flash": detailFlash(r),
|
|
"Wildcard": store.AddressModeWildcard,
|
|
"List": store.AddressModeList,
|
|
})
|
|
}
|
|
|
|
// detailFlash maps a fixed redirect flag to a fixed message, so status text
|
|
// after a redirect is never attacker-influenced.
|
|
func detailFlash(r *http.Request) string {
|
|
switch {
|
|
case r.URL.Query().Get("appdeleted") != "":
|
|
return "Application deleted."
|
|
case r.URL.Query().Get("modeupdated") != "":
|
|
return "Application address mode updated."
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// handleAddApplication creates an application on a domain and renders the page
|
|
// back with the generated password shown once (spec 7.2.5, 7.6.1). Because the
|
|
// password cannot be recovered later, this deliberately renders inline rather
|
|
// than redirecting.
|
|
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
|
|
d, ok := s.lookupDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderDomainDetail(w, r, http.StatusBadRequest, d,
|
|
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
|
|
return
|
|
}
|
|
login := strings.TrimSpace(r.PostFormValue("login"))
|
|
mode := r.PostFormValue("mode")
|
|
addrs := splitAddresses(r.PostFormValue("addresses"))
|
|
|
|
repopulate := detailView{
|
|
FormLogin: login,
|
|
FormMode: mode,
|
|
FormAddrs: r.PostFormValue("addresses"),
|
|
}
|
|
|
|
a, password, err := s.apps.Create(d.ID, login, mode, addrs)
|
|
if err != nil {
|
|
repopulate.FormErr = applicationErrorMessage(err)
|
|
status := http.StatusBadRequest
|
|
if errors.Is(err, store.ErrLoginExists) {
|
|
status = http.StatusConflict
|
|
}
|
|
s.renderDomainDetail(w, r, status, d, repopulate)
|
|
return
|
|
}
|
|
s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
|
|
FormMode: store.AddressModeWildcard,
|
|
NewCred: &newCred{Login: a.Login, Password: password},
|
|
})
|
|
}
|
|
|
|
// handleUpdateAppMode switches an application's address mode / list (spec 7.2.7).
|
|
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := s.lookupApplication(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
mode := r.PostFormValue("mode")
|
|
addrs := splitAddresses(r.PostFormValue("addresses"))
|
|
|
|
if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil {
|
|
d, derr := s.domains.Get(a.DomainID)
|
|
if derr != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
|
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
|
|
FormMode: store.AddressModeWildcard,
|
|
})
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
|
|
}
|
|
|
|
// handleRegenPassword issues a new password for an application and shows it once
|
|
// (spec 7.2.9, 7.6.1). Rendered inline, like creation, so the password is visible.
|
|
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := s.lookupApplication(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
d, err := s.domains.Get(a.DomainID)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
password, err := s.apps.RegeneratePassword(a.ID)
|
|
if err != nil {
|
|
logf("panel: regenerate password for application %d: %v", a.ID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{
|
|
FormMode: store.AddressModeWildcard,
|
|
NewCred: &newCred{Login: a.Login, Password: password},
|
|
})
|
|
}
|
|
|
|
// handleDeleteApplication removes an application and returns to its domain page
|
|
// (spec 7.2.8).
|
|
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := s.lookupApplication(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.apps.Delete(a.ID); err != nil {
|
|
logf("panel: delete application %d: %v", a.ID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
|
|
}
|
|
|
|
// lookupApplication resolves the {aid} path value to an application, writing a
|
|
// 404 for a bad id or missing application.
|
|
func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
http.NotFound(w, r)
|
|
return store.Application{}, false
|
|
}
|
|
a, err := s.apps.Get(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrApplicationNotFound) {
|
|
http.NotFound(w, r)
|
|
return store.Application{}, false
|
|
}
|
|
logf("panel: get application %d: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return store.Application{}, false
|
|
}
|
|
return a, true
|
|
}
|
|
|
|
// splitAddresses turns the textarea/field input (addresses separated by
|
|
// newlines, commas or whitespace) into a raw slice. Normalisation and
|
|
// validation happen in the app service (spec 7.6.2).
|
|
func splitAddresses(s string) []string {
|
|
return strings.FieldsFunc(s, func(r rune) bool {
|
|
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
|
})
|
|
}
|
|
|
|
// applicationErrorMessage turns a service error into a user-facing message,
|
|
// passing through the validation errors (which are safe, fixed strings) and
|
|
// masking anything unexpected.
|
|
func applicationErrorMessage(err error) string {
|
|
switch {
|
|
case errors.Is(err, store.ErrLoginExists):
|
|
return "That login is already in use. Choose another."
|
|
case errors.Is(err, store.ErrDomainNotFound), errors.Is(err, store.ErrApplicationNotFound):
|
|
return "The item no longer exists."
|
|
default:
|
|
// Validation errors from the app service are safe to surface verbatim;
|
|
// they describe what the admin must fix (login/address rules).
|
|
return err.Error()
|
|
}
|
|
}
|