Files
selfpost/internal/web/handlers/handlers_dmarc_reports.go
T
mix 27aeadc71d
test / test (push) Waiting to run
release: 1.7.0
Receive DMARC aggregate reports on port 25 and show parsed summaries in the panel. Close Unreleased; pin compose and docs to 1.7.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:53:36 +03:00

241 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
type dmarcListRow struct {
store.DMARCReportSummary
DomainID int64
ReceivedLabel string
PeriodLabel string
}
func (h *Handlers) requireDMARC(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
if !h.cfg.DMARCEnabled || h.dmarc == nil {
http.NotFound(w, r)
return auth.Principal{}, false
}
p, ok := h.principal(r)
if !ok {
http.NotFound(w, r)
return auth.Principal{}, false
}
return p, true
}
func (h *Handlers) canViewDMARCDomain(p auth.Principal, d store.Domain) bool {
if p.IsGlobal() {
return true
}
for _, id := range p.Domains {
if id == d.ID {
return true
}
}
return false
}
// HandleDMARCList is the global DMARC reports index.
func (h *Handlers) HandleDMARCList(w http.ResponseWriter, r *http.Request) {
p, ok := h.requireDMARC(w, r)
if !ok || !p.IsGlobal() {
if ok && !p.IsGlobal() {
http.NotFound(w, r)
}
return
}
assigned, err := h.assignedDomains(p)
if err != nil {
logf("panel: dmarc list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
names := make([]string, 0, len(assigned))
domainIDs := make(map[string]int64, len(assigned))
for _, d := range assigned {
names = append(names, d.Name)
domainIDs[d.Name] = d.ID
}
reports, err := h.store.ListDMARCReports(names, 100)
if err != nil {
logf("panel: dmarc list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
stats, err := h.store.DMARCIngestStats()
if err != nil {
logf("panel: dmarc ingest stats: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
rows := make([]dmarcListRow, len(reports))
for i, rep := range reports {
rows[i] = dmarcListRow{
DMARCReportSummary: rep,
DomainID: domainIDs[rep.Domain],
ReceivedLabel: rep.ReceivedAt.UTC().Format("2006-01-02 15:04"),
PeriodLabel: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
}
}
data := h.pageBase(r)
data["Title"] = "SelfPost — DMARC reports"
data["Active"] = "dmarc"
data["Reports"] = rows
data["IngestStats"] = stats
data["HostedAddress"] = h.dmarc.DefaultHostedSuggestion()
data["RetentionMax"] = store.DMARCReportsMaxKeep
data["RetentionDays"] = store.DMARCReportsMaxAgeDays
if stats.LastReceivedAt != nil {
data["LastReceivedLabel"] = stats.LastReceivedAt.UTC().Format("2006-01-02 15:04")
}
h.view.Render(w, http.StatusOK, "dmarc", data)
}
// HandleDMARCDomain shows roll-ups for one sending domain.
func (h *Handlers) HandleDMARCDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireDMARC(w, r); !ok {
return
}
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
p, _ := h.principal(r)
if !h.canViewDMARCDomain(p, d) {
http.NotFound(w, r)
return
}
const windowDays = 7
pass, fail, err := h.store.DMARCDomainRollup(d.Name, windowDays)
if err != nil {
logf("panel: dmarc domain rollup %s: %v", d.Name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
sources, err := h.store.DMARCSourceRollups(d.Name, windowDays)
if err != nil {
logf("panel: dmarc source rollups %s: %v", d.Name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reports, err := h.store.ListDMARCReportsForDomain(d.Name, 50)
if err != nil {
logf("panel: dmarc domain reports %s: %v", d.Name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reportRows := make([]dmarcListRow, len(reports))
for i, rep := range reports {
reportRows[i] = dmarcListRow{
DMARCReportSummary: rep,
ReceivedLabel: rep.ReceivedAt.UTC().Format("2006-01-02 15:04"),
PeriodLabel: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
}
}
hints := make([]dmarc.SourceHint, len(sources))
for i, s := range sources {
hints[i] = dmarc.SourceHint{
SourceIP: s.SourceIP,
PassCount: s.PassCount,
FailCount: s.FailCount,
ThisRelay: h.sourceIsThisRelay(s.SourceIP),
}
}
data := h.pageBase(r)
data["Title"] = "SelfPost — " + d.Name + " DMARC"
data["Active"] = "dmarc"
data["Domain"] = d
data["Reports"] = reportRows
data["Pass7d"] = pass
data["Fail7d"] = fail
data["Sources"] = hints
data["PolicyHint"] = dmarc.TightenPolicyHint(pass, fail, hints)
data["WindowDays"] = windowDays
h.view.Render(w, http.StatusOK, "dmarc_domain", data)
}
// HandleDMARCReport shows one parsed aggregate report.
func (h *Handlers) HandleDMARCReport(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireDMARC(w, r); !ok {
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
rep, err := h.store.GetDMARCReport(id)
if errors.Is(err, store.ErrDMARCReportNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
logf("panel: dmarc report %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
p, _ := h.principal(r)
domains, err := h.assignedDomains(p)
if err != nil {
logf("panel: dmarc report authz: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var d store.Domain
found := false
for _, cand := range domains {
if cand.Name == rep.Domain {
d = cand
found = true
break
}
}
if !found {
http.NotFound(w, r)
return
}
data := h.pageBase(r)
data["Title"] = fmt.Sprintf("SelfPost — %s report", rep.Reporter)
data["Active"] = "dmarc"
data["Report"] = rep
data["Domain"] = d
data["Hostname"] = h.cfg.Hostname
data["WindowLabel"] = formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd)
data["ReceivedLabel"] = rep.ReceivedAt.UTC().Format("2006-01-02 15:04")
data["PeriodBeginLabel"] = rep.PeriodBegin.UTC().Format("2006-01-02 15:04")
data["PeriodEndLabel"] = rep.PeriodEnd.UTC().Format("2006-01-02 15:04")
h.view.Render(w, http.StatusOK, "dmarc_report", data)
}
func (h *Handlers) sourceIsThisRelay(ip string) bool {
if ip == "" || h.dns == nil || h.cfg.Hostname == "" {
return false
}
srv := h.dns.Server(h.cfg.Hostname, false)
for _, s := range srv.IPs {
if ip == s {
return true
}
}
return false
}
func formatDMARCWindow(begin, end time.Time) string {
if begin.IsZero() {
return ""
}
if begin.Year() == end.Year() && begin.YearDay() == end.YearDay() {
return begin.UTC().Format("2 Jan")
}
return begin.UTC().Format("2 Jan") + " " + end.UTC().Format("2 Jan")
}