The owner, 2026-10-17: "Показ времени отчёта по UTC принимаем. Это не проблема. Пояснения добавь. Все мелочи закрой." Designs9b33b80,2bf9056. DMARC: a report's day stays the reporter's, in UTC (RFC 7489 § 7.2); the report's Window now says so under its times (windowNote), and Settings' Time zone box and Help (#settings, #dmarc) name it as the one exception to the panel's zone. formatDMARCWindow counts a window that ends at midnight as ending the day before, so a daily report is one day, not "20 Sep - 21 Sep" (TestDMARCWindowIsTheReportersDay, in Moscow's zone). Health: with the inbound relay on, the inbound journal milter's socket is dialled (InboundJournalSocket through web and handlers Config), fail-open as the send log's, and Overview's card counts it; the send log's socket is named journal-milter, as the mockup names it. The confirmation link's sign-in state is drawn (confirm-email-signin.html); the panel already renders it. guide: /license and /server/components, the DMARC pages' paths and day, selfpost-backup -o / -i / -version, the Inbound log's retention field, PANEL_COOKIE_SECURE, the inbound journal's socket, the DMARC exception to the zone. CHANGELOG [Unreleased]. (The e2e case negative_level1_ratelimit, listed as able to pass falsely, was fixed on 2026-10-14 - the note was stale.) go vet + go test (26 packages) and make e2e on the test server for this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
7.9 KiB
Go
243 lines
7.9 KiB
Go
package handlers
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/mixeme/selfpost/internal/dmarc"
|
||
"github.com/mixeme/selfpost/internal/store"
|
||
"github.com/mixeme/selfpost/internal/web/auth"
|
||
"github.com/mixeme/selfpost/internal/web/view"
|
||
)
|
||
|
||
// dmarcRow is a report in a table of the pages.
|
||
func dmarcRow(rep store.DMARCReportSummary) view.DMARCReportRow {
|
||
return view.DMARCReportRow{
|
||
Received: view.FormatReceived(rep.ReceivedAt), Domain: rep.Domain, Reporter: rep.Reporter,
|
||
Window: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd), Pass: rep.PassCount, Fail: rep.FailCount,
|
||
Href: view.DMARCReportHref(rep.ID),
|
||
}
|
||
}
|
||
|
||
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 {
|
||
return p.CanAccessDomain(d.ID)
|
||
}
|
||
|
||
// HandleDMARCList is the DMARC reports hub. The global role sees every report
|
||
// and the ingest statistics of the instance; a domain administrator sees the
|
||
// reports of the sending domains assigned to them, and no ingest statistics
|
||
// (the box describes the whole instance: its mailbox, its failures).
|
||
func (h *Handlers) HandleDMARCList(w http.ResponseWriter, r *http.Request) {
|
||
p, ok := h.requireDMARC(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
assigned, err := h.assignedDomains(p)
|
||
if err != nil {
|
||
logf("panel: dmarc list domains: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
scope := store.DMARCReportScope{AllDomains: p.IsGlobal()}
|
||
domainIDs := make(map[string]int64, len(assigned))
|
||
for _, d := range assigned {
|
||
scope.Domains = append(scope.Domains, d.Name)
|
||
domainIDs[d.Name] = d.ID
|
||
}
|
||
reports, err := h.store.ListDMARCReports(scope, 100)
|
||
if err != nil {
|
||
logf("panel: dmarc list: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
rows := make([]view.DMARCReportRow, len(reports))
|
||
for i, rep := range reports {
|
||
rows[i] = dmarcRow(rep)
|
||
if id := domainIDs[rep.Domain]; id != 0 {
|
||
rows[i].DomainHref = view.DMARCDomainHref(id)
|
||
}
|
||
}
|
||
if !p.IsGlobal() {
|
||
page := view.NewDMARCHub(h.shellMeta(r), view.IngestInput{}).WithoutIngest().WithReports(rows)
|
||
h.view.Render(w, http.StatusOK, "dmarc", page)
|
||
return
|
||
}
|
||
stats, err := h.store.DMARCIngestStats()
|
||
if err != nil {
|
||
logf("panel: dmarc ingest stats: %v", err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
in := view.IngestInput{
|
||
OK: stats.IngestOK, KeptThisWeek: stats.KeptThisWeek, ParseFailures: stats.ParseFailures,
|
||
Hosted: dmarc.HostedReportAddress(h.cfg.Hostname, "<domain>"), Host: h.cfg.Hostname,
|
||
RetentionMax: store.DMARCReportsMaxKeep, RetentionDays: store.DMARCReportsMaxAgeDays,
|
||
}
|
||
if stats.LastReceivedAt != nil {
|
||
in.Last = *stats.LastReceivedAt
|
||
}
|
||
h.view.Render(w, http.StatusOK, "dmarc", view.NewDMARCHub(h.shellMeta(r), in).WithReports(rows))
|
||
}
|
||
|
||
// 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([]view.DMARCReportRow, len(reports))
|
||
for i, rep := range reports {
|
||
reportRows[i] = dmarcRow(rep)
|
||
}
|
||
relay := h.relayIPs()
|
||
hints := make([]dmarc.SourceHint, len(sources))
|
||
sourceRows := make([]view.DMARCSourceRow, len(sources))
|
||
for i, s := range sources {
|
||
hints[i] = dmarc.SourceHint{SourceIP: s.SourceIP, PassCount: s.PassCount, FailCount: s.FailCount, ThisRelay: relay[s.SourceIP]}
|
||
sourceRows[i] = view.DMARCSourceRow{
|
||
Source: s.SourceIP, ThisRelay: relay[s.SourceIP], Pass: s.PassCount, Fail: s.FailCount, Disposition: s.Disposition,
|
||
}
|
||
}
|
||
page := view.NewDMARCDomain(h.shellMeta(r), d.ID, d.Name, true, pass, fail, windowDays,
|
||
dmarc.TightenPolicyHint(pass, fail, hints)).
|
||
WithSources(sourceRows).WithReports(reportRows)
|
||
h.view.Render(w, http.StatusOK, "dmarc-domain", page)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
relay := h.relayIPs()
|
||
records := make([]view.DMARCRecordRow, len(rep.Records))
|
||
for i, rec := range rep.Records {
|
||
records[i] = view.DMARCRecordRow{
|
||
Source: rec.SourceIP, ThisRelay: relay[rec.SourceIP], Count: rec.Count, Disposition: rec.Disposition,
|
||
SPF: rec.SPFResult, DKIM: rec.DKIMResult, HeaderFrom: rec.HeaderFrom,
|
||
}
|
||
}
|
||
const stamp = "2 Jan 15:04"
|
||
page := view.NewDMARCReport(h.shellMeta(r), view.ReportInput{
|
||
ID: rep.ID, Reporter: rep.Reporter, ReportID: rep.ReportID, Domain: rep.Domain, DomainID: d.ID,
|
||
Contact: rep.ContactEmail, Pass: rep.PassCount, Fail: rep.FailCount,
|
||
Window: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
|
||
Period: rep.PeriodBegin.Local().Format(stamp) + " – " + rep.PeriodEnd.Local().Format(stamp),
|
||
Received: rep.ReceivedAt.Local().Format("2006-01-02 15:04"),
|
||
PolicyP: rep.PolicyP, PolicySP: rep.PolicySP, PolicyPct: rep.PolicyPct,
|
||
PolicyADKIM: rep.PolicyADKIM, PolicyASPF: rep.PolicyASPF, Recipient: rep.Recipient,
|
||
Hub: true,
|
||
}).WithRecords(records)
|
||
h.view.Render(w, http.StatusOK, "dmarc-report", page)
|
||
}
|
||
|
||
// relayIPs is the set of addresses this server sends from, so that a source in
|
||
// a report can be told to be this relay. Empty while the server's name or its
|
||
// addresses are unknown.
|
||
func (h *Handlers) relayIPs() map[string]bool {
|
||
if h.dns == nil || h.cfg.Hostname == "" {
|
||
return nil
|
||
}
|
||
ips := map[string]bool{}
|
||
for _, ip := range h.dns.Server(h.cfg.Hostname, false).IPs {
|
||
ips[ip] = true
|
||
}
|
||
return ips
|
||
}
|
||
|
||
// formatDMARCWindow is the day, or days, a report covers, as its reporter
|
||
// counts them: in UTC (RFC 7489 § 7.2, date-range in UTC seconds), which the
|
||
// panel keeps whatever its own zone (owner, 2026-10-17). A window that ends
|
||
// at midnight ends the day before: a daily report from 00:00 to 00:00 is one
|
||
// day, not two.
|
||
func formatDMARCWindow(begin, end time.Time) string {
|
||
if begin.IsZero() {
|
||
return ""
|
||
}
|
||
begin, last := begin.UTC(), end.UTC()
|
||
if last.After(begin) && last.Equal(last.Truncate(24*time.Hour)) {
|
||
last = last.Add(-time.Second)
|
||
}
|
||
if begin.Year() == last.Year() && begin.YearDay() == last.YearDay() {
|
||
return begin.Format("2 Jan")
|
||
}
|
||
return begin.Format("2 Jan") + " – " + last.Format("2 Jan")
|
||
}
|