27aeadc71d
test / test (push) Waiting to run
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>
241 lines
6.5 KiB
Go
241 lines
6.5 KiB
Go
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")
|
||
}
|