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>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
@@ -38,6 +39,8 @@ type Config struct {
|
||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
|
||||
// routes exist only when this is true.
|
||||
InboundEnabled bool
|
||||
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
||||
DMARCEnabled bool
|
||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
|
||||
// as bootstrap and fallback when the settings row is missing or invalid.
|
||||
SendLogRetentionEnvDefault int
|
||||
@@ -49,6 +52,7 @@ type Handlers struct {
|
||||
domains *domain.Service
|
||||
apps *app.Service
|
||||
inbound *inbound.Service
|
||||
dmarc *dmarc.Service
|
||||
cfg Config
|
||||
view *view.Engine
|
||||
dns *dnscheck.Checker
|
||||
@@ -62,6 +66,7 @@ func New(
|
||||
domains *domain.Service,
|
||||
apps *app.Service,
|
||||
inboundSvc *inbound.Service,
|
||||
dmarcSvc *dmarc.Service,
|
||||
cfg Config,
|
||||
v *view.Engine,
|
||||
dns *dnscheck.Checker,
|
||||
@@ -73,6 +78,7 @@ func New(
|
||||
domains: domains,
|
||||
apps: apps,
|
||||
inbound: inboundSvc,
|
||||
dmarc: dmarcSvc,
|
||||
cfg: cfg,
|
||||
view: v,
|
||||
dns: dns,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
@@ -163,9 +164,15 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||
dmarcMode := "inherit"
|
||||
dmarcCustom := ""
|
||||
hostedAddr := ""
|
||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
||||
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
}
|
||||
if d.DMARCRua.Valid {
|
||||
if d.DMARCRua.String == "" {
|
||||
dmarcMode = "none"
|
||||
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
|
||||
dmarcMode = "hosted"
|
||||
} else {
|
||||
dmarcMode = "custom"
|
||||
dmarcCustom = d.DMARCRua.String
|
||||
@@ -173,6 +180,8 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
}
|
||||
dmarcSource := "policy"
|
||||
switch {
|
||||
case dmarcMode == "hosted":
|
||||
dmarcSource = "hosted"
|
||||
case dmarcMode == "custom":
|
||||
dmarcSource = "custom"
|
||||
case dmarcMode == "none":
|
||||
@@ -195,10 +204,13 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
data["ResolvedDMARCEmail"] = reportEmail
|
||||
data["DMARCRuaMode"] = dmarcMode
|
||||
data["DMARCRuaCustom"] = dmarcCustom
|
||||
data["HostedDMARCEmail"] = hostedAddr
|
||||
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
|
||||
data["ReportAuthName"] = reportAuthName
|
||||
data["ReportAuthValue"] = reportAuthValue
|
||||
data["NeedsReportAuth"] = needsReportAuth
|
||||
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
|
||||
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
|
||||
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname))
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
|
||||
data["Apps"] = appViews
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,13 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
rua = sql.NullString{Valid: true, String: email}
|
||||
case "hosted":
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
|
||||
return
|
||||
}
|
||||
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
rua = sql.NullString{Valid: true, String: addr}
|
||||
default:
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||
return
|
||||
@@ -52,6 +60,11 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||
return
|
||||
}
|
||||
if h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: domain %d: dmarc resync: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
h.dns.Forget(d.Name)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
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")
|
||||
}
|
||||
@@ -63,6 +63,10 @@ func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status
|
||||
data["Flash"] = settingsFlash(r)
|
||||
data["L1Messages"] = h.l1Messages()
|
||||
data["L1Window"] = h.l1Window()
|
||||
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
|
||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
||||
data["HostedReportAddress"] = h.dmarc.DefaultHostedSuggestion()
|
||||
}
|
||||
h.view.Render(w, status, "settings", data)
|
||||
}
|
||||
|
||||
@@ -220,6 +224,12 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if emailChanging && h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: settings: dmarc resync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if token, ok := h.auth.SessionToken(r); ok {
|
||||
if renaming {
|
||||
h.auth.RenameSession(token, username)
|
||||
|
||||
Reference in New Issue
Block a user