Fix DMARC guidance for send-only relays with optional rua= settings.
test / test (push) Has been cancelled
test / test (push) Has been cancelled
The panel now suggests policy-only DMARC by default, lets operators configure a default and per-domain report address, and DNS-checks hub _report._dmarc records. Future in-panel report ingestion is tracked as dmarc-reports in the roadmap. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -56,12 +56,13 @@ type Server struct {
|
||||
|
||||
// Domain is the published-DNS state of one sending domain.
|
||||
type Domain struct {
|
||||
Name string
|
||||
DKIM Result
|
||||
SPF Result
|
||||
DMARC Result
|
||||
Overall health.Status
|
||||
CheckedAt time.Time
|
||||
Name string
|
||||
DKIM Result
|
||||
SPF Result
|
||||
DMARC Result
|
||||
DMARCReportAuth Result // zero when external rua= is not used
|
||||
Overall health.Status
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
|
||||
@@ -69,11 +70,12 @@ type Domain struct {
|
||||
// compares DNS against the key this server actually signs with. Hostname and
|
||||
// ServerIPs identify this server and come from a preceding Server check.
|
||||
type Query struct {
|
||||
Name string
|
||||
Selector string
|
||||
ExpectedDKIM string
|
||||
Hostname string
|
||||
ServerIPs []string
|
||||
Name string
|
||||
Selector string
|
||||
ExpectedDKIM string
|
||||
Hostname string
|
||||
ServerIPs []string
|
||||
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
|
||||
}
|
||||
|
||||
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
||||
@@ -178,12 +180,19 @@ func (c *Checker) Forget(domainName string) {
|
||||
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
||||
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
wg.Add(4)
|
||||
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
|
||||
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
|
||||
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q.Name) }()
|
||||
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
hub := EmailDomain(q.DMARCReportEmail)
|
||||
if hub != "" && !strings.EqualFold(hub, q.Name) {
|
||||
d.DMARCReportAuth = c.checkReportAuth(ctx, hub)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status)
|
||||
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status, d.DMARCReportAuth.Status)
|
||||
return d
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ func TestDMARC(t *testing.T) {
|
||||
txt["_dmarc.example.com"] = c.record
|
||||
}
|
||||
f := &fakeResolver{txt: txt}
|
||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
||||
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||
if got.Status != c.want {
|
||||
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func TestDMARC(t *testing.T) {
|
||||
|
||||
func TestDMARCNonePolicyIsExplained(t *testing.T) {
|
||||
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
|
||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
||||
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||
if !strings.Contains(got.Detail, "monitoring only") {
|
||||
t.Errorf("p=none is not explained: %s", got.Detail)
|
||||
}
|
||||
@@ -357,3 +357,20 @@ func TestForgetDropsTheCachedDomain(t *testing.T) {
|
||||
t.Error("Forget did not drop the cached result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportAuth(t *testing.T) {
|
||||
f := &fakeResolver{txt: map[string][]string{"_report._dmarc.hub.example": {"v=DMARC1;"}}}
|
||||
got := newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
|
||||
if got.Status != health.StatusOK {
|
||||
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
|
||||
f = &fakeResolver{}
|
||||
got = newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
|
||||
if got.Status != health.StatusWarn {
|
||||
t.Fatalf("missing = %q, want warn", got.Status)
|
||||
}
|
||||
if !strings.Contains(got.Detail, ReportAuthExample()) {
|
||||
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
@@ -16,6 +17,13 @@ import (
|
||||
// helper: it is published at the domain itself.)
|
||||
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
||||
|
||||
// ReportAuthRecordName is where a report-receiving domain authorises external
|
||||
// DMARC aggregate destinations (RFC 7489 §7.1).
|
||||
func ReportAuthRecordName(hubDomain string) string { return "_report._dmarc." + hubDomain }
|
||||
|
||||
// ReportAuthExample is the TXT value a hub domain publishes to accept reports.
|
||||
func ReportAuthExample() string { return "v=DMARC1;" }
|
||||
|
||||
// SPFExample is the SPF record this server expects for a sending domain: the
|
||||
// addresses its mail actually leaves from, and "-all" to say that nothing else
|
||||
// is authorised. When the server's own addresses are not known (its hostname
|
||||
@@ -40,10 +48,44 @@ func SPFExample(hostname string, serverIPs []string) string {
|
||||
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
||||
}
|
||||
|
||||
// DMARCExample is the least a domain should publish: monitoring only, with an
|
||||
// address the aggregate reports go to. p=none is deliberate — it changes
|
||||
// nothing about delivery, so it is safe to publish before the reports have
|
||||
// shown that DKIM and SPF pass everywhere.
|
||||
func DMARCExample(domainName string) string {
|
||||
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
|
||||
// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain
|
||||
// override wins, then the administrator profile, then policy-only (empty).
|
||||
func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string {
|
||||
if domainRua.Valid {
|
||||
return domainRua.String
|
||||
}
|
||||
return profileEmail
|
||||
}
|
||||
|
||||
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
|
||||
func EmailDomain(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
at := strings.LastIndex(addr, "@")
|
||||
if at < 0 || at == len(addr)-1 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(addr[at+1:]))
|
||||
}
|
||||
|
||||
// DMARCExample is the DMARC TXT record this server suggests for a sending
|
||||
// domain. p=none is deliberate — it changes nothing about delivery. rua= is
|
||||
// included only when reportEmail is set; SelfPost is send-only and most
|
||||
// operators have no inbox on the sending domain itself.
|
||||
func DMARCExample(reportEmail string) string {
|
||||
base := "v=DMARC1; p=none"
|
||||
if reportEmail == "" {
|
||||
return base
|
||||
}
|
||||
return base + "; rua=mailto:" + reportEmail
|
||||
}
|
||||
|
||||
// ExternalReportAuth reports whether the hub domain must publish a
|
||||
// _report._dmarc authorisation for aggregate reports sent to reportEmail from
|
||||
// sendingDomain.
|
||||
func ExternalReportAuth(sendingDomain, reportEmail string) (name, value string, ok bool) {
|
||||
hub := EmailDomain(reportEmail)
|
||||
if hub == "" || strings.EqualFold(hub, sendingDomain) {
|
||||
return "", "", false
|
||||
}
|
||||
return ReportAuthRecordName(hub), ReportAuthExample(), true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -18,8 +19,6 @@ func TestSPFExample(t *testing.T) {
|
||||
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
|
||||
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
|
||||
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
|
||||
// The hostname does not resolve, so there is no address to name; an "a:"
|
||||
// mechanism still gives the operator a publishable record.
|
||||
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
|
||||
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
|
||||
}
|
||||
@@ -32,6 +31,41 @@ func TestSPFExample(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDMARCExample(t *testing.T) {
|
||||
if got := DMARCExample(""); got != "v=DMARC1; p=none" {
|
||||
t.Errorf("empty = %q", got)
|
||||
}
|
||||
want := "v=DMARC1; p=none; rua=mailto:reports@hub.example"
|
||||
if got := DMARCExample("reports@hub.example"); got != want {
|
||||
t.Errorf("with rua = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDMARCRua(t *testing.T) {
|
||||
inherit := sql.NullString{}
|
||||
if got := ResolveDMARCRua(inherit, "a@b.com"); got != "a@b.com" {
|
||||
t.Errorf("inherit profile = %q", got)
|
||||
}
|
||||
none := sql.NullString{Valid: true}
|
||||
if got := ResolveDMARCRua(none, "a@b.com"); got != "" {
|
||||
t.Errorf("explicit none = %q", got)
|
||||
}
|
||||
custom := sql.NullString{Valid: true, String: "x@y.com"}
|
||||
if got := ResolveDMARCRua(custom, "a@b.com"); got != "x@y.com" {
|
||||
t.Errorf("custom = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalReportAuth(t *testing.T) {
|
||||
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
|
||||
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
|
||||
t.Fatalf("external = (%q, %q, %v)", name, value, ok)
|
||||
}
|
||||
if _, _, ok := ExternalReportAuth("shop.com", "dmarc@shop.com"); ok {
|
||||
t.Fatal("same domain should not need external auth")
|
||||
}
|
||||
}
|
||||
|
||||
// The record the panel shows and the one a failed check suggests must be the
|
||||
// same string, or the operator is told two different things on one page.
|
||||
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
||||
@@ -50,11 +84,11 @@ func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
||||
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
|
||||
}
|
||||
|
||||
dmarc := c.checkDMARC(context.Background(), "example.com")
|
||||
dmarc := c.checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||
if dmarc.Status != health.StatusWarn {
|
||||
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
|
||||
}
|
||||
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
|
||||
if want := DMARCExample(""); !strings.Contains(dmarc.Detail, want) {
|
||||
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +60,15 @@ func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
|
||||
|
||||
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
|
||||
// required for delivery, so its absence is advice (warn), not a fault.
|
||||
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
||||
name := DMARCRecordName(domainName)
|
||||
func (c *Checker) checkDMARC(ctx context.Context, q Query) Result {
|
||||
name := DMARCRecordName(q.Name)
|
||||
txt, found, err := c.lookupTXT(ctx, name)
|
||||
if err != nil {
|
||||
return lookupFailed("the DMARC record", err)
|
||||
}
|
||||
|
||||
example := DMARCExample(q.DMARCReportEmail)
|
||||
|
||||
var records []string
|
||||
for _, rec := range txt {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||
@@ -74,10 +76,11 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
||||
}
|
||||
}
|
||||
if !found || len(records) == 0 {
|
||||
return Result{
|
||||
Status: health.StatusWarn,
|
||||
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
|
||||
detail := fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing %q tells receivers what to do with mail that fails authentication.", name, example)
|
||||
if q.DMARCReportEmail == "" {
|
||||
detail += " Aggregate reports (rua=) are optional on a send-only relay — omit rua= unless a mailbox that receives inbound mail is configured."
|
||||
}
|
||||
return Result{Status: health.StatusWarn, Detail: detail}
|
||||
}
|
||||
if len(records) > 1 {
|
||||
return Result{
|
||||
@@ -102,6 +105,52 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
||||
return Result{Status: health.StatusOK, Detail: detail, Records: records}
|
||||
}
|
||||
|
||||
// checkReportAuth verifies the hub domain publishes _report._dmarc for external
|
||||
// aggregate-report destinations. Missing authorisation does not affect outbound
|
||||
// delivery, only whether reports reach the rua= mailbox.
|
||||
func (c *Checker) checkReportAuth(ctx context.Context, hubDomain string) Result {
|
||||
name := ReportAuthRecordName(hubDomain)
|
||||
expected := ReportAuthExample()
|
||||
txt, found, err := c.lookupTXT(ctx, name)
|
||||
if err != nil {
|
||||
return lookupFailed("the DMARC report-authorisation record", err)
|
||||
}
|
||||
|
||||
var records []string
|
||||
for _, rec := range txt {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||
records = append(records, rec)
|
||||
}
|
||||
}
|
||||
if !found || len(records) == 0 {
|
||||
return Result{
|
||||
Status: health.StatusWarn,
|
||||
Detail: fmt.Sprintf("No report-authorisation record at %s. Aggregate DMARC reports sent to a mailbox on %s will not be delivered until %q is published there.", name, hubDomain, expected),
|
||||
}
|
||||
}
|
||||
if len(records) > 1 {
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("More than one DMARC report-authorisation record is published at %s. Keep exactly one.", name),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
Status: health.StatusOK,
|
||||
Detail: fmt.Sprintf("Published at %s — aggregate reports addressed to %s are authorised.", name, hubDomain),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
|
||||
// ReportAuth checks whether hubDomain authorises external DMARC aggregate
|
||||
// reports. It is used on the settings page for the administrator profile.
|
||||
func (c *Checker) ReportAuth(ctx context.Context, hubDomain string) Result {
|
||||
if hubDomain == "" {
|
||||
return Result{}
|
||||
}
|
||||
return c.checkReportAuth(ctx, hubDomain)
|
||||
}
|
||||
|
||||
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
|
||||
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
|
||||
// line breaks into the base64, none of which are part of the key.
|
||||
|
||||
Reference in New Issue
Block a user