server-dmarc-hint: the server's own name gets a DMARC record

Owner, 2026-10-18: "Оба предложения делаем"; the mockup is 8b2dc8f. The
security audit's S3 left a product side: the panel showed the DKIM and SPF
records of the server's own name, not a DMARC one, and without it a
receiver does not refuse mail forged in that name - a false password-reset
message among it.

dnscheck.ServerMail checks a third record (Mail.DMARC, checkServerDMARC):
the sending-domain DMARC check held to more - missing is an error, as a
missing DKIM or SPF record of this name is (the page draws it as a
warning: notifications still go), p=none a warning ("weak": it refuses
nothing), quarantine or reject ok - and Overall includes it. The record to
publish is ServerMailDMARC, v=DMARC1; p=reject: only the panel sends as
the name, signed with its key, from the address its SPF names, so reject
costs nothing. Settings › Notification mail draws it after SPF
(view.ServerMailDMARC, folded when published), Health's card has the row,
Re-check DNS re-reads it with the others.

Tests: TestServerMailDMARC (missing, p=none, quarantine, reject); the
Settings and Health tests publish and expect three records. Help word for
word with the mockup; guide § Notifications; roadmap; CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-18 10:05:39 +03:00
co-authored by Claude Opus 5.5
parent 8b2dc8f40f
commit 554e007f22
11 changed files with 135 additions and 28 deletions
+15 -5
View File
@@ -188,6 +188,7 @@ type Mail struct {
Name string
DKIM Result
SPF Result
DMARC Result
Overall health.Status
CheckedAt time.Time
}
@@ -196,9 +197,17 @@ type Mail struct {
// address record is the one sender.
const ServerMailSPF = "v=spf1 a -all"
// ServerMail checks the DKIM and SPF records of the server's own name. q is as
// for a domain, with Name the hostname. Cached like a domain; force skips the
// cache.
// ServerMailDMARC is the DMARC record to publish for the server's own name
// (server-dmarc-hint; security audit 2026-10-17, S3). Only the panel sends as
// that name - from postmaster@ it, signed with the name's own key, from the
// address its SPF names - so every genuine message aligns and reject costs
// nothing; without a record a receiver does not refuse mail forged in the
// server's name, a false password-reset message among it.
const ServerMailDMARC = "v=DMARC1; p=reject"
// ServerMail checks the DKIM, SPF and DMARC records of the server's own name.
// q is as for a domain, with Name the hostname. Cached like a domain; force
// skips the cache.
func (c *Checker) ServerMail(q Query, force bool) Mail {
key := "\x00mail\x00" + q.Name
if !force {
@@ -214,16 +223,17 @@ func (c *Checker) ServerMail(q Query, force bool) Mail {
defer cancel()
m := Mail{Name: q.Name, CheckedAt: time.Now()}
var wg sync.WaitGroup
wg.Add(2)
wg.Add(3)
go func() { defer wg.Done(); m.DKIM = c.checkDKIM(ctx, q) }()
go func() { defer wg.Done(); m.SPF = c.checkSPF(ctx, q) }()
go func() { defer wg.Done(); m.DMARC = c.checkServerDMARC(ctx, q.Name) }()
wg.Wait()
// checkSPF words a missing record for a sending domain; for the server's
// own name the record to publish is a different one.
if m.SPF.Status == health.StatusError && len(m.SPF.Records) == 0 {
m.SPF.Detail = fmt.Sprintf("No SPF record is published for %s. Publish the TXT record %q - without it a receiver has nothing authorising this server to send as its own name.", q.Name, ServerMailSPF)
}
m.Overall = health.Worst(m.DKIM.Status, m.SPF.Status)
m.Overall = health.Worst(m.DKIM.Status, m.SPF.Status, m.DMARC.Status)
c.mu.Lock()
c.mail[key] = cached[Mail]{value: m, expires: m.CheckedAt.Add(c.domainTTL)}
+26
View File
@@ -105,6 +105,32 @@ func (c *Checker) checkDMARC(ctx context.Context, q Query) Result {
return Result{Status: health.StatusOK, Detail: detail, Records: records}
}
// checkServerDMARC checks the DMARC record of the server's own name. It is a
// sending domain's check held to more: a record missing is an error, as a
// missing DKIM or SPF record of this name is, and p=none, which refuses
// nothing, a warning - either leaves forged mail in the server's name
// deliverable, and that name sends nothing a stricter policy could hurt
// (ServerMailDMARC). quarantine is as good as reject here.
func (c *Checker) checkServerDMARC(ctx context.Context, name string) Result {
r := c.checkDMARC(ctx, Query{Name: name})
recordName := DMARCRecordName(name)
switch {
case r.Status == health.StatusWarn && len(r.Records) == 0:
// Missing, as a missing DKIM or SPF record of this name is: the page
// draws it as a warning all the same (notifications still go).
r.Status = health.StatusError
r.Detail = fmt.Sprintf("No DMARC record at %s. Publish the TXT record %q - without it a receiver does not refuse mail forged in this server's name.", recordName, ServerMailDMARC)
case r.Status == health.StatusOK:
if policy, _ := tagValue(r.Records[0], "p"); strings.EqualFold(policy, "none") {
r.Status = health.StatusWarn
r.Detail = fmt.Sprintf("Published with p=none, which refuses nothing: mail forged in this server's name is still delivered. Publish %q - only the panel sends as this name, signed.", ServerMailDMARC)
} else {
r.Detail = fmt.Sprintf("Published with policy p=%s.", policy)
}
}
return r
}
// 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.
+28 -2
View File
@@ -27,14 +27,15 @@ func TestServerMailPublished(t *testing.T) {
txt: map[string][]string{
"sp._domainkey.mail.example.org": {serverDKIM},
"mail.example.org": {ServerMailSPF},
"_dmarc.mail.example.org": {ServerMailDMARC},
},
addr: map[string][]net.IPAddr{"mail.example.org": ipAddrs("203.0.113.25")},
}
c := newTestChecker(f)
m := c.ServerMail(serverMailQuery(), false)
if m.DKIM.Status != health.StatusOK || m.SPF.Status != health.StatusOK || m.Overall != health.StatusOK {
t.Fatalf("DKIM %s (%s), SPF %s (%s), overall %s", m.DKIM.Status, m.DKIM.Detail, m.SPF.Status, m.SPF.Detail, m.Overall)
if m.DKIM.Status != health.StatusOK || m.SPF.Status != health.StatusOK || m.DMARC.Status != health.StatusOK || m.Overall != health.StatusOK {
t.Fatalf("DKIM %s (%s), SPF %s (%s), DMARC %s (%s), overall %s", m.DKIM.Status, m.DKIM.Detail, m.SPF.Status, m.SPF.Detail, m.DMARC.Status, m.DMARC.Detail, m.Overall)
}
// Cached like a domain, and re-read when forced.
@@ -102,3 +103,28 @@ func TestSPFLookupThatDidNotAnswerIsNotAMismatch(t *testing.T) {
t.Errorf("the name does not exist: SPF is %s (%s), want an error", got.Status, got.Detail)
}
}
// The server's own name's DMARC record (server-dmarc-hint): missing is an
// error and p=none a warning, each naming the record to publish; quarantine or
// reject is ok.
func TestServerMailDMARC(t *testing.T) {
for _, c := range []struct {
name, record string
want health.Status
says string
}{
{"missing", "", health.StatusError, `Publish the TXT record "v=DMARC1; p=reject"`},
{"p=none", "v=DMARC1; p=none; rua=mailto:x@example.org", health.StatusWarn, "refuses nothing"},
{"p=quarantine", "v=DMARC1; p=quarantine", health.StatusOK, "p=quarantine"},
{"p=reject", ServerMailDMARC, health.StatusOK, "p=reject"},
} {
f := &fakeResolver{txt: map[string][]string{}}
if c.record != "" {
f.txt["_dmarc.mail.example.org"] = []string{c.record}
}
m := newTestChecker(f).ServerMail(serverMailQuery(), false)
if m.DMARC.Status != c.want || !strings.Contains(m.DMARC.Detail, c.says) {
t.Errorf("%s: %s (%s), want %s saying %q", c.name, m.DMARC.Status, m.DMARC.Detail, c.want, c.says)
}
}
}