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:
@@ -0,0 +1,31 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultLocalPart is the mailbox local-part for the panel-wide hosted address.
|
||||
const DefaultLocalPart = "dmarc-reports"
|
||||
|
||||
// HostedReportAddress is the per-domain SelfPost-hosted rua= destination.
|
||||
func HostedReportAddress(hostname, domain string) string {
|
||||
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
return DefaultLocalPart + "+" + domain + "@" + hostname
|
||||
}
|
||||
|
||||
// DefaultHostedReportAddress is the settings-level hosted rua= when ingest is on.
|
||||
func DefaultHostedReportAddress(hostname string) string {
|
||||
return DefaultLocalPart + "@" + strings.ToLower(strings.TrimSpace(hostname))
|
||||
}
|
||||
|
||||
// IsHostedOnHostname reports whether addr is delivered locally on hostname.
|
||||
func IsHostedOnHostname(addr, hostname string) bool {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||
if hostname == "" || !strings.Contains(addr, "@") {
|
||||
return false
|
||||
}
|
||||
at := strings.LastIndex(addr, "@")
|
||||
return addr[at+1:] == hostname
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
|
||||
// aggregate attachment it finds.
|
||||
func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error {
|
||||
raw, err := io.ReadAll(io.LimitReader(r, 12<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
payload, err := extractAggregatePayload(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := ParseAggregate(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep := store.DMARCReport{
|
||||
Domain: parsed.Domain,
|
||||
Reporter: parsed.Reporter,
|
||||
ReportID: parsed.ReportID,
|
||||
PeriodBegin: parsed.PeriodBegin,
|
||||
PeriodEnd: parsed.PeriodEnd,
|
||||
ReceivedAt: receivedAt.UTC(),
|
||||
ContactEmail: parsed.ContactEmail,
|
||||
PolicyP: parsed.PolicyP,
|
||||
PolicySP: parsed.PolicySP,
|
||||
PolicyPct: parsed.PolicyPct,
|
||||
PolicyADKIM: parsed.PolicyADKIM,
|
||||
PolicyASPF: parsed.PolicyASPF,
|
||||
PassCount: parsed.PassCount,
|
||||
FailCount: parsed.FailCount,
|
||||
Recipient: strings.ToLower(strings.TrimSpace(recipient)),
|
||||
}
|
||||
for _, rec := range parsed.Records {
|
||||
rep.Records = append(rep.Records, store.DMARCReportRecord{
|
||||
SourceIP: rec.SourceIP,
|
||||
Count: rec.Count,
|
||||
Disposition: rec.Disposition,
|
||||
SPFResult: rec.SPFResult,
|
||||
DKIMResult: rec.DKIMResult,
|
||||
HeaderFrom: rec.HeaderFrom,
|
||||
})
|
||||
}
|
||||
if _, err := st.InsertDMARCReport(rep); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.PruneDMARCReports()
|
||||
}
|
||||
|
||||
func extractAggregatePayload(raw []byte) ([]byte, error) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse message: %w", err)
|
||||
}
|
||||
ct := msg.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
return io.ReadAll(io.LimitReader(msg.Body, 8<<20))
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("content-type: %w", err)
|
||||
}
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return nil, fmt.Errorf("multipart without boundary")
|
||||
}
|
||||
mr := multipart.NewReader(msg.Body, boundary)
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("multipart: %w", err)
|
||||
}
|
||||
name := strings.ToLower(part.FileName())
|
||||
if name == "" {
|
||||
name = strings.ToLower(part.Header.Get("Content-Type"))
|
||||
}
|
||||
if !looksLikeAggregate(name) {
|
||||
continue
|
||||
}
|
||||
data, err := readEncodedBody(textproto.MIMEHeader(part.Header), part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > 0 {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no aggregate attachment found")
|
||||
}
|
||||
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
|
||||
}
|
||||
|
||||
func looksLikeAggregate(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
|
||||
}
|
||||
|
||||
func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
|
||||
encoding := strings.ToLower(hdr.Get("Content-Transfer-Encoding"))
|
||||
var body io.Reader = r
|
||||
switch encoding {
|
||||
case "base64":
|
||||
body = base64.NewDecoder(base64.StdEncoding, r)
|
||||
case "quoted-printable":
|
||||
body = quotedprintable.NewReader(r)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read part body: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParsedReport is the structured form of a DMARC aggregate XML payload.
|
||||
type ParsedReport struct {
|
||||
Reporter string
|
||||
ReportID string
|
||||
ContactEmail string
|
||||
PeriodBegin time.Time
|
||||
PeriodEnd time.Time
|
||||
Domain string
|
||||
PolicyP string
|
||||
PolicySP string
|
||||
PolicyPct int
|
||||
PolicyADKIM string
|
||||
PolicyASPF string
|
||||
Records []ParsedRecord
|
||||
PassCount int
|
||||
FailCount int
|
||||
}
|
||||
|
||||
// ParsedRecord is one <record> row from the aggregate XML.
|
||||
type ParsedRecord struct {
|
||||
SourceIP string
|
||||
Count int
|
||||
Disposition string
|
||||
SPFResult string
|
||||
DKIMResult string
|
||||
HeaderFrom string
|
||||
}
|
||||
|
||||
type feedbackXML struct {
|
||||
XMLName xml.Name `xml:"feedback"`
|
||||
Metadata struct {
|
||||
OrgName string `xml:"org_name"`
|
||||
Email string `xml:"email"`
|
||||
ReportID string `xml:"report_id"`
|
||||
DateRange struct {
|
||||
Begin int64 `xml:"begin"`
|
||||
End int64 `xml:"end"`
|
||||
} `xml:"date_range"`
|
||||
} `xml:"report_metadata"`
|
||||
Policy struct {
|
||||
Domain string `xml:"domain"`
|
||||
P string `xml:"p"`
|
||||
SP string `xml:"sp"`
|
||||
Pct int `xml:"pct"`
|
||||
ADKIM string `xml:"adkim"`
|
||||
ASPF string `xml:"aspf"`
|
||||
} `xml:"policy_published"`
|
||||
Records []struct {
|
||||
Row struct {
|
||||
SourceIP string `xml:"source_ip"`
|
||||
Count int `xml:"count"`
|
||||
Policy struct {
|
||||
Disposition string `xml:"disposition"`
|
||||
DKIM string `xml:"dkim"`
|
||||
SPF string `xml:"spf"`
|
||||
} `xml:"policy_evaluated"`
|
||||
} `xml:"row"`
|
||||
Identifiers struct {
|
||||
HeaderFrom string `xml:"header_from"`
|
||||
} `xml:"identifiers"`
|
||||
} `xml:"record"`
|
||||
}
|
||||
|
||||
// ParseAggregate decodes gzip-compressed or raw DMARC aggregate XML.
|
||||
func ParseAggregate(raw []byte) (ParsedReport, error) {
|
||||
data, err := maybeGunzip(raw)
|
||||
if err != nil {
|
||||
return ParsedReport{}, err
|
||||
}
|
||||
var doc feedbackXML
|
||||
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||
return ParsedReport{}, fmt.Errorf("dmarc xml: %w", err)
|
||||
}
|
||||
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
|
||||
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
|
||||
}
|
||||
out := ParsedReport{
|
||||
Reporter: strings.TrimSpace(doc.Metadata.OrgName),
|
||||
ReportID: strings.TrimSpace(doc.Metadata.ReportID),
|
||||
ContactEmail: strings.TrimSpace(doc.Metadata.Email),
|
||||
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
|
||||
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
|
||||
Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)),
|
||||
PolicyP: strings.TrimSpace(doc.Policy.P),
|
||||
PolicySP: strings.TrimSpace(doc.Policy.SP),
|
||||
PolicyPct: doc.Policy.Pct,
|
||||
PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM),
|
||||
PolicyASPF: strings.TrimSpace(doc.Policy.ASPF),
|
||||
}
|
||||
if out.PolicyPct == 0 {
|
||||
out.PolicyPct = 100
|
||||
}
|
||||
for _, rec := range doc.Records {
|
||||
row := ParsedRecord{
|
||||
SourceIP: strings.TrimSpace(rec.Row.SourceIP),
|
||||
Count: rec.Row.Count,
|
||||
Disposition: strings.TrimSpace(rec.Row.Policy.Disposition),
|
||||
SPFResult: strings.TrimSpace(rec.Row.Policy.SPF),
|
||||
DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM),
|
||||
HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom),
|
||||
}
|
||||
if row.Count <= 0 {
|
||||
row.Count = 1
|
||||
}
|
||||
if dmarcRecordPasses(row) {
|
||||
out.PassCount += row.Count
|
||||
} else {
|
||||
out.FailCount += row.Count
|
||||
}
|
||||
out.Records = append(out.Records, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dmarcRecordPasses(r ParsedRecord) bool {
|
||||
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
|
||||
}
|
||||
|
||||
func maybeGunzip(raw []byte) ([]byte, error) {
|
||||
if len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b {
|
||||
zr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc gzip: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(zr, 8<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc gzip read: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// TightenPolicyHint summarises whether raising p= looks reasonable.
|
||||
func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
|
||||
total := pass + fail
|
||||
if total == 0 {
|
||||
return "No messages in the reporting window yet."
|
||||
}
|
||||
failPct := float64(fail) * 100 / float64(total)
|
||||
if fail > 0 {
|
||||
for _, s := range sources {
|
||||
if s.FailCount > 0 && !s.ThisRelay {
|
||||
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
|
||||
}
|
||||
}
|
||||
}
|
||||
if failPct <= 2 && pass > 0 {
|
||||
return "Alignment looks strong. Tightening p= may be reasonable."
|
||||
}
|
||||
if fail > 0 {
|
||||
return "Some failures remain. Review sources before tightening p=."
|
||||
}
|
||||
return "Alignment looks clean for this window."
|
||||
}
|
||||
|
||||
// SourceHint is a panel-facing rollup row with relay detection.
|
||||
type SourceHint struct {
|
||||
SourceIP string
|
||||
PassCount int
|
||||
FailCount int
|
||||
ThisRelay bool
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const sampleXML = `<?xml version="1.0"?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<email>noreply@google.com</email>
|
||||
<report_id>12345</report_id>
|
||||
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<p>none</p><sp>none</sp><pct>100</pct>
|
||||
<adkim>r</adkim><aspf>r</aspf>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.10</source_ip>
|
||||
<count>10</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim><spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>example.com</header_from></identifiers>
|
||||
</record>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>198.51.100.1</source_ip>
|
||||
<count>2</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>fail</dkim><spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>example.com</header_from></identifiers>
|
||||
</record>
|
||||
</feedback>`
|
||||
|
||||
func TestParseAggregateGzip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
if _, err := zw.Write([]byte(sampleXML)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseAggregate(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAggregate: %v", err)
|
||||
}
|
||||
if got.Domain != "example.com" || got.Reporter != "google.com" {
|
||||
t.Fatalf("metadata = %+v", got)
|
||||
}
|
||||
if got.PassCount != 10 || got.FailCount != 2 {
|
||||
t.Fatalf("counts = %d pass %d fail", got.PassCount, got.FailCount)
|
||||
}
|
||||
if !got.PeriodBegin.Equal(time.Unix(1723593600, 0).UTC()) {
|
||||
t.Fatalf("period begin = %v", got.PeriodBegin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestMessageStoresReport(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var gz bytes.Buffer
|
||||
zw := gzip.NewWriter(&gz)
|
||||
_, _ = zw.Write([]byte(sampleXML))
|
||||
_ = zw.Close()
|
||||
|
||||
msg := bytes.NewBufferString(
|
||||
"From: noreply@google.com\r\n" +
|
||||
"To: dmarc-reports@mail.example.com\r\n" +
|
||||
"Subject: Report\r\n" +
|
||||
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
|
||||
"\r\n",
|
||||
)
|
||||
// append raw gzip for simple single-part test
|
||||
msg.Write(gz.Bytes())
|
||||
|
||||
if err := IngestMessage(st, msg, "dmarc-reports@mail.example.com", time.Now().UTC()); err != nil {
|
||||
t.Fatalf("IngestMessage: %v", err)
|
||||
}
|
||||
list, err := st.ListDMARCReports(nil, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("reports = %d", len(list))
|
||||
}
|
||||
if list[0].PassCount != 10 {
|
||||
t.Fatalf("pass = %d", list[0].PassCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostedReportAddress(t *testing.T) {
|
||||
got := HostedReportAddress("mail.example.com", "Example.COM")
|
||||
want := "dmarc-reports+example.com@mail.example.com"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package dmarc receives DMARC aggregate reports: Postfix pipes messages here,
|
||||
// gzip/XML is parsed, and summaries land in SQLite for the panel.
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// Maps is the Postfix manager surface the DMARC service needs.
|
||||
type Maps interface {
|
||||
RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error
|
||||
}
|
||||
|
||||
// Service coordinates DMARC ingest allow-lists across SQLite and Postfix.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
maps Maps
|
||||
hostname string
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewService builds the DMARC ingest service.
|
||||
func NewService(st *store.Store, maps Maps, hostname string, enabled bool) *Service {
|
||||
return &Service{store: st, maps: maps, hostname: strings.ToLower(strings.TrimSpace(hostname)), enabled: enabled}
|
||||
}
|
||||
|
||||
// Enabled reports whether DMARC ingest is active in this deployment.
|
||||
func (s *Service) Enabled() bool { return s.enabled }
|
||||
|
||||
// Resync rebuilds the Postfix allow-list and transport maps from SQLite.
|
||||
func (s *Service) Resync() error {
|
||||
if !s.enabled {
|
||||
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{})
|
||||
}
|
||||
addrs, err := s.AllowedRecipients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
domains := recipientDomains(addrs)
|
||||
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{
|
||||
Recipients: addrs,
|
||||
Domains: domains,
|
||||
})
|
||||
}
|
||||
|
||||
// AllowedRecipients returns every address Postfix may accept for DMARC ingest.
|
||||
func (s *Service) AllowedRecipients() ([]string, error) {
|
||||
if !s.enabled || s.hostname == "" {
|
||||
return nil, nil
|
||||
}
|
||||
profile, err := s.store.GlobalDMARCReportEmail()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
add := func(addr string) {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
if addr == "" || !IsHostedOnHostname(addr, s.hostname) || seen[addr] {
|
||||
return
|
||||
}
|
||||
seen[addr] = true
|
||||
out = append(out, addr)
|
||||
}
|
||||
add(profile)
|
||||
if profile == "" {
|
||||
add(DefaultHostedReportAddress(s.hostname))
|
||||
}
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range domains {
|
||||
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
|
||||
if rua == "" {
|
||||
continue
|
||||
}
|
||||
if IsHostedOnHostname(rua, s.hostname) {
|
||||
add(rua)
|
||||
continue
|
||||
}
|
||||
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
|
||||
continue
|
||||
}
|
||||
hosted := HostedReportAddress(s.hostname, d.Name)
|
||||
if strings.EqualFold(rua, hosted) {
|
||||
add(rua)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func recipientDomains(addrs []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, addr := range addrs {
|
||||
d := dnscheck.EmailDomain(addr)
|
||||
if d == "" || seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// HostedSuggestion returns the address the panel should suggest for a domain.
|
||||
func (s *Service) HostedSuggestion(domain string) string {
|
||||
return HostedReportAddress(s.hostname, domain)
|
||||
}
|
||||
|
||||
// DefaultHostedSuggestion is the settings-level hosted address.
|
||||
func (s *Service) DefaultHostedSuggestion() string {
|
||||
return DefaultHostedReportAddress(s.hostname)
|
||||
}
|
||||
|
||||
// ValidateHostedAddress ensures addr is on this hostname before saving.
|
||||
func (s *Service) ValidateHostedAddress(addr string) error {
|
||||
if !IsHostedOnHostname(addr, s.hostname) {
|
||||
return fmt.Errorf("hosted report address must be on %s", s.hostname)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DMARCMapsConfig is the allow-list material for DMARC aggregate ingest.
|
||||
type DMARCMapsConfig struct {
|
||||
Recipients []string
|
||||
Domains []string
|
||||
}
|
||||
|
||||
func (p *Postfix) dmarcMapPaths() (recipients, transport, relayDomains string) {
|
||||
dir := filepath.Dir(p.senderLoginMapsPath)
|
||||
return filepath.Join(dir, "dmarc_recipients"),
|
||||
filepath.Join(dir, "dmarc_transport"),
|
||||
filepath.Join(dir, "dmarc_relay_domains")
|
||||
}
|
||||
|
||||
// RebuildDMARCMaps regenerates DMARC ingest lookup tables. Empty config clears
|
||||
// the files so port-25 ingest is off even if the listener stays up for inbound
|
||||
// relay.
|
||||
func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
|
||||
recip, transport, relay, err := renderDMARCMaps(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, tr, rd := p.dmarcMapPaths()
|
||||
if err := writeFileAtomic(rc, recip, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(tr, transport, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(rd, relay, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.reload()
|
||||
}
|
||||
|
||||
func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) {
|
||||
recipients = []byte{}
|
||||
transport = []byte{}
|
||||
relayDomains = []byte{}
|
||||
if len(cfg.Recipients) == 0 {
|
||||
return recipients, transport, relayDomains, nil
|
||||
}
|
||||
addrs := append([]string(nil), cfg.Recipients...)
|
||||
sort.Strings(addrs)
|
||||
var recipB, transportB strings.Builder
|
||||
for _, addr := range addrs {
|
||||
if err := assertMapToken(addr, "recipient"); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
fmt.Fprintf(&recipB, "%s OK\n", addr)
|
||||
fmt.Fprintf(&transportB, "%s dmarc-ingest:\n", addr)
|
||||
}
|
||||
domains := append([]string(nil), cfg.Domains...)
|
||||
sort.Strings(domains)
|
||||
var relayB strings.Builder
|
||||
for _, d := range domains {
|
||||
if err := assertMapToken(d, "domain"); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
fmt.Fprintf(&relayB, "%s OK\n", d)
|
||||
}
|
||||
return []byte(recipB.String()), []byte(transportB.String()), []byte(relayB.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderDMARCMaps(t *testing.T) {
|
||||
recip, transport, relay, err := renderDMARCMaps(DMARCMapsConfig{
|
||||
Recipients: []string{"dmarc-reports@mail.example.com"},
|
||||
Domains: []string{"mail.example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(recip), "dmarc-reports@mail.example.com OK") {
|
||||
t.Fatalf("recipients:\n%s", recip)
|
||||
}
|
||||
if !strings.Contains(string(transport), "dmarc-ingest:") {
|
||||
t.Fatalf("transport:\n%s", transport)
|
||||
}
|
||||
if !strings.Contains(string(relay), "mail.example.com OK") {
|
||||
t.Fatalf("relay:\n%s", relay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDMARCMapsEmpty(t *testing.T) {
|
||||
recip, transport, relay, err := renderDMARCMaps(DMARCMapsConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recip)+len(transport)+len(relay) != 0 {
|
||||
t.Fatalf("expected empty maps")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DMARCReportsMaxKeep is how many parsed reports are kept before pruning.
|
||||
DMARCReportsMaxKeep = 500
|
||||
// DMARCReportsMaxAgeDays drops reports older than this window.
|
||||
DMARCReportsMaxAgeDays = 90
|
||||
// DMARCParseFailuresKey counts ingest failures (settings).
|
||||
DMARCParseFailuresKey = "dmarc_parse_failures_total"
|
||||
)
|
||||
|
||||
// ErrDMARCReportNotFound is returned when a report id does not exist.
|
||||
var ErrDMARCReportNotFound = errors.New("dmarc report not found")
|
||||
|
||||
// DMARCReport is one parsed aggregate summary.
|
||||
type DMARCReport struct {
|
||||
ID int64
|
||||
Domain string
|
||||
Reporter string
|
||||
ReportID string
|
||||
PeriodBegin time.Time
|
||||
PeriodEnd time.Time
|
||||
ReceivedAt time.Time
|
||||
ContactEmail string
|
||||
PolicyP string
|
||||
PolicySP string
|
||||
PolicyPct int
|
||||
PolicyADKIM string
|
||||
PolicyASPF string
|
||||
PassCount int
|
||||
FailCount int
|
||||
Recipient string
|
||||
Records []DMARCReportRecord
|
||||
}
|
||||
|
||||
// DMARCReportRecord is one source row inside a report.
|
||||
type DMARCReportRecord struct {
|
||||
SourceIP string
|
||||
Count int
|
||||
Disposition string
|
||||
SPFResult string
|
||||
DKIMResult string
|
||||
HeaderFrom string
|
||||
}
|
||||
|
||||
// DMARCReportSummary is a list-row without per-record detail.
|
||||
type DMARCReportSummary struct {
|
||||
ID int64
|
||||
Domain string
|
||||
Reporter string
|
||||
PeriodBegin time.Time
|
||||
PeriodEnd time.Time
|
||||
ReceivedAt time.Time
|
||||
PassCount int
|
||||
FailCount int
|
||||
}
|
||||
|
||||
// DMARCSourceRollup aggregates pass/fail per source over a window.
|
||||
type DMARCSourceRollup struct {
|
||||
SourceIP string
|
||||
PassCount int
|
||||
FailCount int
|
||||
Disposition string
|
||||
}
|
||||
|
||||
// DMARCIngestStats is panel-facing ingest health.
|
||||
type DMARCIngestStats struct {
|
||||
LastReceivedAt *time.Time
|
||||
KeptThisWeek int
|
||||
ParseFailures int
|
||||
IngestOK bool
|
||||
}
|
||||
|
||||
// InsertDMARCReport stores a parsed report and its records, replacing any prior
|
||||
// row with the same reporter/report_id/domain triple.
|
||||
func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin insert dmarc report: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM dmarc_reports WHERE reporter = ? AND report_id = ? AND domain = ?`,
|
||||
rep.Reporter, rep.ReportID, rep.Domain,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("delete prior dmarc report: %w", err)
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`
|
||||
INSERT INTO dmarc_reports (
|
||||
domain, reporter, report_id, period_begin, period_end, received_at,
|
||||
contact_email, policy_p, policy_sp, policy_pct, policy_adkim, policy_aspf,
|
||||
pass_count, fail_count, recipient
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
rep.Domain, rep.Reporter, rep.ReportID,
|
||||
rep.PeriodBegin.UTC().Format(time.RFC3339),
|
||||
rep.PeriodEnd.UTC().Format(time.RFC3339),
|
||||
rep.ReceivedAt.UTC().Format(time.RFC3339),
|
||||
rep.ContactEmail, rep.PolicyP, rep.PolicySP, rep.PolicyPct,
|
||||
rep.PolicyADKIM, rep.PolicyASPF, rep.PassCount, rep.FailCount, rep.Recipient,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert dmarc report: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("dmarc report id: %w", err)
|
||||
}
|
||||
for _, rec := range rep.Records {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO dmarc_report_records (
|
||||
report_row_id, source_ip, count, disposition, spf_result, dkim_result, header_from
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, rec.SourceIP, rec.Count, rec.Disposition, rec.SPFResult, rec.DKIMResult, rec.HeaderFrom,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("insert dmarc record: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit dmarc report: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PruneDMARCReports enforces count and age caps.
|
||||
func (s *Store) PruneDMARCReports() error {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -DMARCReportsMaxAgeDays).Format(time.RFC3339)
|
||||
if _, err := s.db.Exec(`DELETE FROM dmarc_reports WHERE received_at < ?`, cutoff); err != nil {
|
||||
return fmt.Errorf("prune dmarc by age: %w", err)
|
||||
}
|
||||
var count int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count dmarc reports: %w", err)
|
||||
}
|
||||
if count <= DMARCReportsMaxKeep {
|
||||
return nil
|
||||
}
|
||||
excess := count - DMARCReportsMaxKeep
|
||||
_, err := s.db.Exec(`
|
||||
DELETE FROM dmarc_reports WHERE id IN (
|
||||
SELECT id FROM dmarc_reports ORDER BY received_at ASC LIMIT ?
|
||||
)`, excess)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune dmarc by count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrDMARCParseFailures bumps the failure counter in settings.
|
||||
func (s *Store) IncrDMARCParseFailures() error {
|
||||
raw, err := s.GetSetting(DMARCParseFailuresKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n := 0
|
||||
if raw != "" {
|
||||
fmt.Sscanf(raw, "%d", &n)
|
||||
}
|
||||
return s.SetSetting(DMARCParseFailuresKey, fmt.Sprintf("%d", n+1))
|
||||
}
|
||||
|
||||
// ListDMARCReports returns recent summaries, optionally limited to domains.
|
||||
func (s *Store) ListDMARCReports(domains []string, limit int) ([]DMARCReportSummary, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if len(domains) == 0 {
|
||||
rows, err = s.db.Query(`
|
||||
SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count
|
||||
FROM dmarc_reports ORDER BY received_at DESC LIMIT ?`, limit)
|
||||
} else {
|
||||
placeholders := make([]any, 0, len(domains)+1)
|
||||
q := `SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count
|
||||
FROM dmarc_reports WHERE domain IN (`
|
||||
for i, d := range domains {
|
||||
if i > 0 {
|
||||
q += ","
|
||||
}
|
||||
q += "?"
|
||||
placeholders = append(placeholders, d)
|
||||
}
|
||||
q += `) ORDER BY received_at DESC LIMIT ?`
|
||||
placeholders = append(placeholders, limit)
|
||||
rows, err = s.db.Query(q, placeholders...)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dmarc reports: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []DMARCReportSummary
|
||||
for rows.Next() {
|
||||
var (
|
||||
summary DMARCReportSummary
|
||||
begin string
|
||||
end string
|
||||
recv string
|
||||
)
|
||||
if err := rows.Scan(&summary.ID, &summary.Domain, &summary.Reporter, &begin, &end, &recv, &summary.PassCount, &summary.FailCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.PeriodBegin, _ = time.Parse(time.RFC3339, begin)
|
||||
summary.PeriodEnd, _ = time.Parse(time.RFC3339, end)
|
||||
summary.ReceivedAt, _ = time.Parse(time.RFC3339, recv)
|
||||
out = append(out, summary)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetDMARCReport loads one report with records.
|
||||
func (s *Store) GetDMARCReport(id int64) (DMARCReport, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, domain, reporter, report_id, period_begin, period_end, received_at,
|
||||
contact_email, policy_p, policy_sp, policy_pct, policy_adkim, policy_aspf,
|
||||
pass_count, fail_count, recipient
|
||||
FROM dmarc_reports WHERE id = ?`, id)
|
||||
rep, err := scanDMARCReport(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DMARCReport{}, ErrDMARCReportNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return DMARCReport{}, err
|
||||
}
|
||||
recs, err := s.listDMARCReportRecords(id)
|
||||
if err != nil {
|
||||
return DMARCReport{}, err
|
||||
}
|
||||
rep.Records = recs
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ListDMARCReportsForDomain returns summaries for one sending domain.
|
||||
func (s *Store) ListDMARCReportsForDomain(domain string, limit int) ([]DMARCReportSummary, error) {
|
||||
return s.ListDMARCReports([]string{domain}, limit)
|
||||
}
|
||||
|
||||
// DMARCDomainRollup summarises pass/fail for a domain over the last windowDays.
|
||||
func (s *Store) DMARCDomainRollup(domain string, windowDays int) (pass, fail int, err error) {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -windowDays).Format(time.RFC3339)
|
||||
err = s.db.QueryRow(`
|
||||
SELECT COALESCE(SUM(pass_count), 0), COALESCE(SUM(fail_count), 0)
|
||||
FROM dmarc_reports WHERE domain = ? AND received_at >= ?`,
|
||||
domain, cutoff,
|
||||
).Scan(&pass, &fail)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("dmarc domain rollup: %w", err)
|
||||
}
|
||||
return pass, fail, nil
|
||||
}
|
||||
|
||||
// DMARCSourceRollups aggregates per-source rows for a domain over windowDays.
|
||||
func (s *Store) DMARCSourceRollups(domain string, windowDays int) ([]DMARCSourceRollup, error) {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -windowDays).Format(time.RFC3339)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT r.source_ip,
|
||||
SUM(CASE WHEN r.spf_result = 'pass' OR r.dkim_result = 'pass' THEN r.count ELSE 0 END),
|
||||
SUM(CASE WHEN r.spf_result != 'pass' AND r.dkim_result != 'pass' THEN r.count ELSE 0 END),
|
||||
MAX(r.disposition)
|
||||
FROM dmarc_report_records r
|
||||
INNER JOIN dmarc_reports d ON d.id = r.report_row_id
|
||||
WHERE d.domain = ? AND d.received_at >= ?
|
||||
GROUP BY r.source_ip
|
||||
ORDER BY 2 DESC, 3 DESC`, domain, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc source rollups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []DMARCSourceRollup
|
||||
for rows.Next() {
|
||||
var rollup DMARCSourceRollup
|
||||
if err := rows.Scan(&rollup.SourceIP, &rollup.PassCount, &rollup.FailCount, &rollup.Disposition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rollup)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DMARCIngestStats returns ingest health for the panel.
|
||||
func (s *Store) DMARCIngestStats() (DMARCIngestStats, error) {
|
||||
var stats DMARCIngestStats
|
||||
var last sql.NullString
|
||||
err := s.db.QueryRow(`SELECT MAX(received_at) FROM dmarc_reports`).Scan(&last)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("dmarc last received: %w", err)
|
||||
}
|
||||
if last.Valid && last.String != "" {
|
||||
t, _ := time.Parse(time.RFC3339, last.String)
|
||||
stats.LastReceivedAt = &t
|
||||
stats.IngestOK = time.Since(t) < 8*24*time.Hour
|
||||
}
|
||||
weekCutoff := time.Now().UTC().AddDate(0, 0, -7).Format(time.RFC3339)
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports WHERE received_at >= ?`, weekCutoff).Scan(&stats.KeptThisWeek); err != nil {
|
||||
return stats, fmt.Errorf("dmarc week count: %w", err)
|
||||
}
|
||||
raw, err := s.GetSetting(DMARCParseFailuresKey)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if raw != "" {
|
||||
fmt.Sscanf(raw, "%d", &stats.ParseFailures)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Store) listDMARCReportRecords(reportID int64) ([]DMARCReportRecord, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT source_ip, count, disposition, spf_result, dkim_result, header_from
|
||||
FROM dmarc_report_records WHERE report_row_id = ? ORDER BY count DESC`, reportID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dmarc records: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []DMARCReportRecord
|
||||
for rows.Next() {
|
||||
var rec DMARCReportRecord
|
||||
if err := rows.Scan(&rec.SourceIP, &rec.Count, &rec.Disposition, &rec.SPFResult, &rec.DKIMResult, &rec.HeaderFrom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanDMARCReport(r scanRow) (DMARCReport, error) {
|
||||
var (
|
||||
rep DMARCReport
|
||||
begin string
|
||||
end string
|
||||
recv string
|
||||
)
|
||||
if err := r.Scan(
|
||||
&rep.ID, &rep.Domain, &rep.Reporter, &rep.ReportID, &begin, &end, &recv,
|
||||
&rep.ContactEmail, &rep.PolicyP, &rep.PolicySP, &rep.PolicyPct,
|
||||
&rep.PolicyADKIM, &rep.PolicyASPF, &rep.PassCount, &rep.FailCount, &rep.Recipient,
|
||||
); err != nil {
|
||||
return DMARCReport{}, err
|
||||
}
|
||||
rep.PeriodBegin, _ = time.Parse(time.RFC3339, begin)
|
||||
rep.PeriodEnd, _ = time.Parse(time.RFC3339, end)
|
||||
rep.ReceivedAt, _ = time.Parse(time.RFC3339, recv)
|
||||
return rep, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDMARCReportRoundTrip(t *testing.T) {
|
||||
st, err := Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
_, err = st.InsertDMARCReport(DMARCReport{
|
||||
Domain: "example.com",
|
||||
Reporter: "google.com",
|
||||
ReportID: "abc",
|
||||
PeriodBegin: now.Add(-24 * time.Hour),
|
||||
PeriodEnd: now,
|
||||
ReceivedAt: now,
|
||||
PassCount: 5,
|
||||
FailCount: 1,
|
||||
Records: []DMARCReportRecord{{
|
||||
SourceIP: "203.0.113.1", Count: 5, SPFResult: "pass", DKIMResult: "pass",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := st.ListDMARCReports([]string{"example.com"}, 10)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v err=%v", list, err)
|
||||
}
|
||||
got, err := st.GetDMARCReport(list[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PassCount != 5 || len(got.Records) != 1 {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDMARCReports(t *testing.T) {
|
||||
st, err := Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
old := time.Now().UTC().AddDate(0, 0, -120)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := st.InsertDMARCReport(DMARCReport{
|
||||
Domain: "example.com", Reporter: "r", ReportID: string(rune('a' + i)),
|
||||
PeriodBegin: old, PeriodEnd: old, ReceivedAt: old,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.PruneDMARCReports(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := st.ListDMARCReports(nil, 100)
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected prune by age, got %d", len(list))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
-- DMARC aggregate report summaries (plans/dmarc-reports.md). One row per parsed
|
||||
-- report; per-source rows hang off it. Forensic (ruf=) payloads are not stored.
|
||||
|
||||
CREATE TABLE dmarc_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL,
|
||||
reporter TEXT NOT NULL,
|
||||
report_id TEXT NOT NULL,
|
||||
period_begin TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL DEFAULT '',
|
||||
policy_p TEXT NOT NULL DEFAULT '',
|
||||
policy_sp TEXT NOT NULL DEFAULT '',
|
||||
policy_pct INTEGER NOT NULL DEFAULT 100,
|
||||
policy_adkim TEXT NOT NULL DEFAULT '',
|
||||
policy_aspf TEXT NOT NULL DEFAULT '',
|
||||
pass_count INTEGER NOT NULL DEFAULT 0,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
recipient TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (reporter, report_id, domain)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain);
|
||||
CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at);
|
||||
|
||||
CREATE TABLE dmarc_report_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
count INTEGER NOT NULL,
|
||||
disposition TEXT NOT NULL DEFAULT '',
|
||||
spf_result TEXT NOT NULL DEFAULT '',
|
||||
dkim_result TEXT NOT NULL DEFAULT '',
|
||||
header_from TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id);
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>DMARC reports</h1>
|
||||
|
||||
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>.
|
||||
Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed
|
||||
XML — not a dashboard.</p>
|
||||
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Ingest {{if .IngestStats.IngestOK}}<span class="st st-ok">ok</span>{{else}}<span class="st st-warn">quiet</span>{{end}}</h2>
|
||||
{{if .LastReceivedLabel}}
|
||||
<p>Last report {{.LastReceivedLabel}} UTC. Port 25 accepts only configured report addresses.</p>
|
||||
{{else}}
|
||||
<p>No reports received yet. Point <code>rua=</code> at
|
||||
<code>{{.HostedAddress}}</code> (or a per-domain hosted address) and publish
|
||||
MX for {{.HostedAddress}} on this server.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>This week</h2>
|
||||
<p>{{.IngestStats.KeptThisWeek}} kept{{if .IngestStats.ParseFailures}}, {{.IngestStats.ParseFailures}} parse failures{{end}}.
|
||||
Older summaries are pruned ({{.RetentionMax}} max, {{.RetentionDays}} days).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Recent reports</h2>
|
||||
{{if .Reports}}
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Reports}}
|
||||
<tr>
|
||||
<td class="time muted">{{.ReceivedLabel}}</td>
|
||||
<td>{{if .DomainID}}<a href="/dmarc/domains/{{.DomainID}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td>
|
||||
<td class="muted">{{.Reporter}}</td>
|
||||
<td>{{.PeriodLabel}}</td>
|
||||
<td>{{.PassCount}}</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td class="actions"><a href="/dmarc/reports/{{.ID}}">View</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="phone-list phone-only">
|
||||
{{range .Reports}}
|
||||
<li>
|
||||
<a href="/dmarc/reports/{{.ID}}">
|
||||
<span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.PassCount}} pass</span>
|
||||
<span class="when">{{.ReceivedLabel}}</span>
|
||||
<span class="meta">{{.Domain}} · {{.Reporter}}</span>
|
||||
<span class="subj">{{.FailCount}} fail</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">No reports yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,59 @@
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
{{template "back_link" (back "/dmarc" "All DMARC reports")}}
|
||||
{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Last {{.WindowDays}} days {{if and (gt .Pass7d 0) (eq .Fail7d 0)}}<span class="st st-ok">pass</span>{{else if gt .Fail7d 0}}<span class="st st-warn">review</span>{{end}}</h2>
|
||||
<p>{{.PolicyHint}}</p>
|
||||
<p class="muted"><a href="/domains/{{.Domain.ID}}#dmarc">Domain DNS and rua=</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Totals</h2>
|
||||
<p>{{.Pass7d}} pass · {{.Fail7d}} fail in aggregate summaries.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Reports</h2>
|
||||
{{if .Reports}}
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Reports}}
|
||||
<tr>
|
||||
<td class="time muted">{{.ReceivedLabel}}</td>
|
||||
<td class="muted">{{.Reporter}}</td>
|
||||
<td>{{.PeriodLabel}}</td>
|
||||
<td>{{.PassCount}}</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td class="actions"><a href="/dmarc/reports/{{.ID}}">View</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No reports for this domain yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Sources}}
|
||||
<div class="card">
|
||||
<h2>Sources · last {{.WindowDays}} days</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sources}}
|
||||
<tr>
|
||||
<td class="muted">{{.SourceIP}}{{if .ThisRelay}} (this relay){{end}}</td>
|
||||
<td>{{.PassCount}}</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td><span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.Disposition}}</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{define "content"}}
|
||||
<h1 class="subject">{{.Report.Reporter}} · {{.WindowLabel}}</h1>
|
||||
<p class="route">
|
||||
<span class="addr">{{.Report.Domain}}</span>
|
||||
<span class="st st-ok">{{.Report.PassCount}} pass</span>
|
||||
{{if gt .Report.FailCount 0}}<span class="st st-warn">{{.Report.FailCount}} fail</span>{{end}}
|
||||
</p>
|
||||
{{template "back_link" (back (printf "/dmarc/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Report</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">{{.Report.Reporter}}</span></div>
|
||||
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">{{.Report.ReportID}}</span></div>
|
||||
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">{{.PeriodBeginLabel}} – {{.PeriodEndLabel}} UTC</span></div>
|
||||
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">{{.ReceivedLabel}} UTC</span></div>
|
||||
{{if .Report.ContactEmail}}<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">{{.Report.ContactEmail}}</span></div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card{{if gt .Report.FailCount 0}} attn{{end}}">
|
||||
<h2>Published policy</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">{{.Report.Domain}}</span></div>
|
||||
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">{{.Report.PolicyP}} / {{.Report.PolicySP}} / {{.Report.PolicyPct}}</span></div>
|
||||
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">{{.Report.PolicyADKIM}} / {{.Report.PolicyASPF}}</span></div>
|
||||
{{if .Report.Recipient}}<div class="fact"><span class="fact-label">rua</span><span class="fact-value">{{.Report.Recipient}}</span></div>{{end}}
|
||||
</div>
|
||||
<p class="muted"><code>p={{.Report.PolicyP}}</code> is what receivers enforced in this window.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Records</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Report.Records}}
|
||||
<tr>
|
||||
<td class="muted">{{.SourceIP}}</td>
|
||||
<td>{{.Count}}</td>
|
||||
<td><span class="st {{if or (eq .SPFResult "pass") (eq .DKIMResult "pass")}}st-ok{{else}}st-warn{{end}}">{{.Disposition}}</span></td>
|
||||
<td><span class="st {{if eq .SPFResult "pass"}}st-ok{{else}}st-error{{end}}">{{.SPFResult}}</span></td>
|
||||
<td><span class="st {{if eq .DKIMResult "pass"}}st-ok{{else}}st-error{{end}}">{{.DKIMResult}}</span></td>
|
||||
<td>{{.HeaderFrom}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -170,8 +170,11 @@
|
||||
</div>
|
||||
|
||||
{{if .SameDomainRUA}}
|
||||
<p class="error">The report address is on this sending domain. SelfPost does
|
||||
not receive inbound mail — use a mailbox on another domain.</p>
|
||||
<p class="error">The report address is on this sending domain.{{if not .DMARCIngestEnabled}}
|
||||
SelfPost does not receive inbound mail — use a mailbox on another domain.{{end}}</p>
|
||||
{{end}}
|
||||
{{if and .DMARCIngestEnabled .ResolvedDMARCEmail}}
|
||||
<p class="muted"><a href="/dmarc/domains/{{.Domain.ID}}">View DMARC reports</a> for this domain.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .NeedsReportAuth}}
|
||||
@@ -385,6 +388,7 @@
|
||||
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
||||
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
|
||||
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||
{{if .DMARCIngestEnabled}}<option value="hosted"{{if eq .DMARCRuaMode "hosted"}} selected{{end}}>SelfPost hosted ({{.HostedDMARCEmail}})</option>{{end}}
|
||||
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
||||
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||
</select>
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
{{if and .IsGlobal .InboundEnabled}}
|
||||
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
|
||||
{{end}}
|
||||
{{if .DMARCEnabled}}
|
||||
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||
@@ -116,6 +119,7 @@
|
||||
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
|
||||
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
|
||||
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
|
||||
{{define "icon-dmarc"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11v7H2.5z"/><path d="M5 7.5h6M5 9.5h4"/></svg>{{end}}
|
||||
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
|
||||
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
|
||||
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
|
||||
|
||||
@@ -41,14 +41,15 @@
|
||||
<div class="card">
|
||||
<h2>DMARC aggregate reports</h2>
|
||||
<p class="muted">Optional default <code>rua=</code> address for every sending
|
||||
domain (can be overridden per domain). Use a mailbox on a domain that
|
||||
receives inbound mail. SelfPost does not receive inbound mail — point
|
||||
<code>rua=</code> at a mailbox elsewhere.</p>
|
||||
domain (can be overridden per domain).{{if .DMARCIngestEnabled}} When set to
|
||||
an address on <code>{{.HostedReportAddress}}</code>, SelfPost receives and
|
||||
parses aggregate reports on port 25.{{else}} Use a mailbox on a domain
|
||||
that receives inbound mail elsewhere.{{end}}</p>
|
||||
|
||||
<label for="dmarc_report_email">Default report address</label>
|
||||
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
||||
autocomplete="email" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormDMARCEmail}}" placeholder="reports@your-mail-domain.com">
|
||||
value="{{.FormDMARCEmail}}" placeholder="{{if .DMARCIngestEnabled}}{{.HostedReportAddress}}{{else}}reports@your-mail-domain.com{{end}}">
|
||||
|
||||
{{if .FormDMARCEmail}}
|
||||
<p class="muted">When <code>rua=</code> points at another domain, that hub
|
||||
|
||||
@@ -222,7 +222,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
wide := map[string]bool{
|
||||
"settings": true, "deliveries": true, "delivery": true, "mail_queue": true,
|
||||
"status": true, "system_log": true, "domain_detail": true,
|
||||
"inbound": true, "inbound_domain": true,
|
||||
"inbound": true, "inbound_domain": true, "dmarc": true,
|
||||
}
|
||||
for name, page := range engine.Pages() {
|
||||
var buf bytes.Buffer
|
||||
@@ -324,6 +324,8 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
"domain_delete.html": true,
|
||||
"inbound_domain.html": true,
|
||||
"inbound_delete.html": true,
|
||||
"dmarc_domain.html": true,
|
||||
"dmarc_report.html": true,
|
||||
"delivery.html": true,
|
||||
}
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
|
||||
@@ -22,6 +22,7 @@ type Engine struct {
|
||||
fragments map[string]*template.Template
|
||||
version string
|
||||
inboundEnabled bool
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template files. Every page
|
||||
@@ -45,6 +46,9 @@ var pageFiles = map[string][]string{
|
||||
"inbound": {"templates/inbound.html"},
|
||||
"inbound_domain": {"templates/inbound_domain.html"},
|
||||
"inbound_delete": {"templates/inbound_delete.html"},
|
||||
"dmarc": {"templates/dmarc.html"},
|
||||
"dmarc_domain": {"templates/dmarc_domain.html"},
|
||||
"dmarc_report": {"templates/dmarc_report.html"},
|
||||
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
||||
"delivery": {"templates/delivery.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
@@ -92,6 +96,11 @@ func (e *Engine) SetInboundEnabled(v bool) {
|
||||
e.inboundEnabled = v
|
||||
}
|
||||
|
||||
// SetDMARCEnabled controls whether the DMARC nav item is shown.
|
||||
func (e *Engine) SetDMARCEnabled(v bool) {
|
||||
e.dmarcEnabled = v
|
||||
}
|
||||
|
||||
// templateFuncs supplies helpers shared across page templates.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
@@ -135,6 +144,7 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
m["DMARCEnabled"] = e.dmarcEnabled
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
|
||||
+13
-2
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/legal"
|
||||
@@ -85,6 +86,8 @@ type Config struct {
|
||||
RetryPolicy postfix.RetryPolicy
|
||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
|
||||
InboundEnabled bool
|
||||
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
||||
DMARCEnabled bool
|
||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
|
||||
SendLogRetentionEnvDefault int
|
||||
}
|
||||
@@ -101,19 +104,20 @@ type Server struct {
|
||||
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
|
||||
// apps owns application SASL accounts and the Postfix sender map
|
||||
// (architecture.md § Mail path).
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
v, err := view.New(cfg.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.SetInboundEnabled(cfg.InboundEnabled)
|
||||
v.SetDMARCEnabled(cfg.DMARCEnabled)
|
||||
a := auth.New(st, auth.Config{
|
||||
CookieSecure: cfg.CookieSecure,
|
||||
Hostname: cfg.Hostname,
|
||||
SessionIdleDays: cfg.SessionIdleDays,
|
||||
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||
}, v, setupTokenPath)
|
||||
h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{
|
||||
h := handlers.New(st, domains, apps, inboundSvc, dmarcSvc, handlers.Config{
|
||||
Hostname: cfg.Hostname,
|
||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||
MailLogPath: cfg.MailLogPath,
|
||||
@@ -128,6 +132,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||
RetryPolicy: cfg.RetryPolicy,
|
||||
InboundEnabled: cfg.InboundEnabled,
|
||||
DMARCEnabled: cfg.DMARCEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
|
||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
||||
@@ -188,6 +193,12 @@ func (s *Server) Handler() http.Handler {
|
||||
authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete)
|
||||
}
|
||||
|
||||
if s.cfg.DMARCEnabled {
|
||||
authed.HandleFunc("GET /dmarc", h.HandleDMARCList)
|
||||
authed.HandleFunc("GET /dmarc/reports/{id}", h.HandleDMARCReport)
|
||||
authed.HandleFunc("GET /dmarc/domains/{id}", h.HandleDMARCDomain)
|
||||
}
|
||||
|
||||
authed.HandleFunc("/settings", h.HandleSettings)
|
||||
authed.HandleFunc("/account", redirectSettings)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user