Security audit 2026-10-17, finding A5/W2 (low), stage 1; the mockup is
7effc98. dmarc_reports belong to a domain by name, outlive the domain (no
FK), and a domain deleted and added again handed its old reports to
whoever it was assigned to then - the logs were moved to ids in 2.5.0 for
exactly this case, reports were not.
The cheap form of the plan: store.DeleteDomain deletes the domain's
reports (their records by the table's cascade) in the same transaction as
the domain. The confirmation page says so when the domain has reports
("delete the 12 DMARC reports received for it;"), counted by the new
store.CountDMARCReports; NewOutDomainDelete takes the count.
Tests: TestDeletingADomainDeletesItsReports (the domain added again has
none, another domain keeps its report and record, a second delete is
ErrDomainNotFound); TestOutDomainDeleteNamesWhatGoesWithIt with 12, 1 and
0 reports.
Docs: security.md § Roles; CHANGELOG; plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
8.3 KiB
Go
252 lines
8.3 KiB
Go
package handlers
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/mixeme/selfpost/internal/domain"
|
||
"github.com/mixeme/selfpost/internal/health"
|
||
"github.com/mixeme/selfpost/internal/store"
|
||
"github.com/mixeme/selfpost/internal/web/validate"
|
||
"github.com/mixeme/selfpost/internal/web/view"
|
||
)
|
||
|
||
// HandleDashboard is the list of sending domains with the verdict of each DNS
|
||
// check, their application counts and the mail of the statistics window, plus —
|
||
// for the global role — the add-domain form (product.md).
|
||
func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||
h.renderDashboard(w, r, http.StatusOK, "", "")
|
||
}
|
||
|
||
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
||
p, ok := h.principal(r)
|
||
if !ok {
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
domains, err := h.assignedDomains(p)
|
||
if err != nil {
|
||
logf("panel: domains: list domains: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
retention := h.sendLogRetentionDays()
|
||
_, _, days := store.StatsWindow(retention, time.Now())
|
||
page := view.NewOutDomains(h.shellMeta(r), p.IsGlobal(), days).
|
||
WithRows(h.outDomainRows(domains, retention, p.IsGlobal())).
|
||
WithResult(dashboardFlash(r), formErr, formName)
|
||
h.view.Render(w, status, "out-domains", page)
|
||
}
|
||
|
||
// outDomainRows is one row per domain: the verdict of each DNS check (cached for
|
||
// a few minutes, as on the domain page), the selector, the applications and the
|
||
// mail of the statistics window. The checks run side by side.
|
||
func (h *Handlers) outDomainRows(domains []store.Domain, retention int, canDelete bool) []view.OutDomainRow {
|
||
rows := make([]view.OutDomainRow, len(domains))
|
||
var wg sync.WaitGroup
|
||
for i, d := range domains {
|
||
rows[i] = view.OutDomainRow{
|
||
Name: d.Name, Href: view.DomainHref(d.ID), Selector: d.DKIMSelector, Apps: d.AppCount,
|
||
DNS: dnsTags(health.StatusUnknown, health.StatusUnknown, health.StatusUnknown), Activity: "—",
|
||
}
|
||
if canDelete {
|
||
rows[i].DeleteHref = view.DomainHref(d.ID) + "/delete"
|
||
}
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
if stats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt); err != nil {
|
||
logf("panel: domains: domain %d: send stats: %v", d.ID, err)
|
||
} else {
|
||
rows[i].Activity = view.FormatActivity(stats.Total, stats.PeakPerHour)
|
||
}
|
||
record, err := h.domains.DKIMRecord(d)
|
||
if err != nil {
|
||
logf("panel: domains: domain %d: dkim record: %v", d.ID, err)
|
||
return
|
||
}
|
||
dns, _ := h.domainDNS(d, record, d.DMARCRua, false)
|
||
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
return rows
|
||
}
|
||
|
||
func dashboardFlash(r *http.Request) string {
|
||
if r.URL.Query().Get("deleted") != "" {
|
||
return "Domain deleted."
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
|
||
// selector table), and redirects to the domain's page so the DNS record to
|
||
// publish is shown (product.md).
|
||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||
_, ok := h.requireGlobal(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||
return
|
||
}
|
||
raw := r.PostFormValue("name")
|
||
name := validate.NormalizeDomain(raw)
|
||
if err := validate.Domain(name); err != nil {
|
||
h.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
||
return
|
||
}
|
||
|
||
d, err := h.domains.Add(name)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrDomainExists) {
|
||
h.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
||
return
|
||
}
|
||
logf("panel: add domain %q: %v", name, err)
|
||
h.renderDashboard(w, r, http.StatusInternalServerError,
|
||
"Could not add the domain. Please check the logs and try again.", raw)
|
||
return
|
||
}
|
||
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d", d.ID), http.StatusSeeOther)
|
||
}
|
||
|
||
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := h.requireGlobal(w, r); !ok {
|
||
return
|
||
}
|
||
d, ok := h.lookupDomain(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
apps, err := h.store.ListApplicationsByDomain(d.ID)
|
||
if err != nil {
|
||
logf("panel: delete domain %d: list applications: %v", d.ID, err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
logins := make([]string, len(apps))
|
||
for i, a := range apps {
|
||
logins[i] = a.Login
|
||
}
|
||
reports, err := h.store.CountDMARCReports(d.Name)
|
||
if err != nil {
|
||
logf("panel: delete domain %d: %v", d.ID, err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
h.view.Render(w, http.StatusOK, "out-domain-delete", view.NewOutDomainDelete(h.shellMeta(r), d.ID, d.Name, logins, reports))
|
||
}
|
||
|
||
// HandleDeleteDomain performs the deletion and returns to the domain list.
|
||
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := h.requireGlobal(w, r); !ok {
|
||
return
|
||
}
|
||
id, ok := parseDomainID(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
name := ""
|
||
if d, err := h.domains.Get(id); err == nil {
|
||
name = d.Name
|
||
defer h.dns.Forget(d.Name)
|
||
}
|
||
if err := h.domains.Delete(id); err != nil {
|
||
if errors.Is(err, store.ErrDomainNotFound) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
logf("panel: delete domain %d: %v", id, err)
|
||
if errors.Is(err, domain.ErrDeletedIncomplete) {
|
||
h.renderDashboard(w, r, http.StatusInternalServerError,
|
||
"Domain "+name+" deleted, but the mail configuration was not fully rewritten after it. Run Reload configuration on Server › Health; the panel's log says what failed.", "")
|
||
return
|
||
}
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/outbound/domains?deleted=1", http.StatusSeeOther)
|
||
}
|
||
|
||
// HandleReload writes again, from the database, every file the panel keeps
|
||
// for the mail path and has Postfix re-read them - what the first start after
|
||
// a restore does (cmd/panel mailStack.Resync): the DKIM selector table (and
|
||
// the keys of deleted domains removed), the sender map, with the inbound
|
||
// relay on its maps and the filter
|
||
// lists, with DMARC reports on their maps (architecture.md § Panel HTTP
|
||
// surface). Before 2.5.4 it left the last two out, and said "regenerated"
|
||
// over them (audit 2026-10-12, L13). A failure is a message on Health that
|
||
// names the step; the log has the error.
|
||
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := h.requireGlobal(w, r); !ok {
|
||
return
|
||
}
|
||
type step struct {
|
||
name string
|
||
run func() error
|
||
}
|
||
steps := []step{
|
||
{"the DKIM selector table", h.domains.Resync},
|
||
{"the sender map", h.apps.Resync},
|
||
}
|
||
if h.cfg.InboundEnabled && h.inbound != nil {
|
||
steps = append(steps, step{"the inbound relay maps", h.inbound.Resync})
|
||
}
|
||
if h.cfg.InboundEnabled && h.lists != nil {
|
||
steps = append(steps, step{"the filter lists", h.lists.Sync})
|
||
}
|
||
if h.dmarc != nil && h.dmarc.Enabled() {
|
||
steps = append(steps, step{"the DMARC report maps", h.dmarc.Resync})
|
||
}
|
||
for _, s := range steps {
|
||
if err := s.run(); err != nil {
|
||
logf("panel: manual reload (%s): %v", s.name, err)
|
||
page := h.healthView(r, "")
|
||
page.Flash = &view.Flash{Error: true, Text: view.Plain("Reload failed at " + s.name + "; what came before it was written and re-read. The panel's log says why.")}
|
||
h.view.Render(w, http.StatusInternalServerError, "health", page)
|
||
return
|
||
}
|
||
}
|
||
http.Redirect(w, r, "/server/health?reloaded=1", http.StatusSeeOther)
|
||
}
|
||
|
||
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
||
id, ok := parseDomainID(w, r)
|
||
if !ok {
|
||
return store.Domain{}, false
|
||
}
|
||
d, err := h.domains.Get(id)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrDomainNotFound) {
|
||
http.NotFound(w, r)
|
||
return store.Domain{}, false
|
||
}
|
||
logf("panel: get domain %d: %v", id, err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return store.Domain{}, false
|
||
}
|
||
p, ok := h.principal(r)
|
||
if !ok || !p.CanAccessDomain(d.ID) {
|
||
http.NotFound(w, r)
|
||
return store.Domain{}, false
|
||
}
|
||
return d, true
|
||
}
|
||
|
||
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
http.NotFound(w, r)
|
||
return 0, false
|
||
}
|
||
return id, true
|
||
}
|