Files
selfpost/internal/web/handlers/handlers_dmarc_reports.go
T
mix 7ef7f75246 release: 1.9.2: Review pass: port-25 milters, DMARC validation, rate-limit fix
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump.
Added:
  - SQLite migration `0010_send_log_app_login_index.sql` —
  - DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
  - e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
Changed:
  - README hero line and a **Positioning** block in `docs/product.md` record the
Fixed:
  - The setup account's password is hashed at bcrypt cost `12` like every other
  - Login/setup rate limiter: protecting blocked buckets from eviction is now
  - `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
  - [schema-migrations.md](docs/schema-migrations.md): the schema section was
  - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
  … (11 more in CHANGELOG)
Changed:
  - [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
  - Extracted `internal/configsafe` — the empty-value and forbidden-character
  - Build, deploy and migration comments no longer cite the archived
  - Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
  - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
  … (7 more in CHANGELOG)
Removed:
  - Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
  - Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,

Full detail: CHANGELOG.md (1.9.2).
2026-09-22 15:24:53 +03:00

241 lines
6.5 KiB
Go
Raw Blame History

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