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)
}
}
}
@@ -162,10 +162,11 @@ func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
return h, zone, mail
}
// publishMail puts both records of the server's own name into the zone.
// publishMail puts the three records of the server's own name into the zone.
func publishMail(zone *fakeZone, mail domain.ServerMail) {
zone.publishTXT(mail.Record.Name, mail.Record.Value)
zone.publishTXT(notifyHost, dnscheck.ServerMailSPF)
zone.publishTXT(dnscheck.DMARCRecordName(notifyHost), dnscheck.ServerMailDMARC)
}
// With both records in DNS, Settings gives each with the value to publish and
@@ -182,14 +183,16 @@ func TestSettingsMailRecordsPublished(t *testing.T) {
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`,
`For the server&#39;s own name, not for your domains`,
`value="v=spf1 a -all"`,
`<h3>DMARC</h3><span class="tag is-success is-light">published</span>`,
`value="_dmarc.mail.example.org"`, `value="v=DMARC1; p=reject"`,
`<span class="sp-mono">postmaster@mail.example.org</span>`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings page is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "may file them as spam") {
t.Error("the warning about a missing SPF record is shown while it is published")
if strings.Contains(out, "may file them as spam") || strings.Contains(out, "does not refuse mail forged") {
t.Error("the note about a missing SPF or DMARC record is shown while it is published")
}
}
@@ -203,7 +206,9 @@ func TestSettingsMailRecordsMissing(t *testing.T) {
for _, want := range []string{
`<h3>DKIM</h3><span class="tag is-warning is-light">missing</span>`,
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span>`,
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
`<h3>DMARC</h3><span class="tag is-warning is-light">missing</span>`,
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`, `value="v=DMARC1; p=reject"`,
`Without it a receiver does not refuse mail forged in the server&#39;s name`,
`Notifications are sent without it, and a receiver may file them as spam.`,
`No TXT record is published at sp2026._domainkey.mail.example.org.`,
} {
@@ -259,6 +264,7 @@ func TestHealthNotificationMailCard(t *testing.T) {
`id="notification-mail"`, `<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
`DMARC<br><span class="sp-muted sp-mono sp-small">_dmarc.mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
`href="/server/settings#notification-mail"`,
} {
@@ -296,8 +302,8 @@ func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) {
// the records of the server's own name again as well as for its address.
func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 2 {
t.Fatalf("before publishing, both records should be missing:\n%s", out)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 3 {
t.Fatalf("before publishing, the three records should be missing:\n%s", out)
}
publishMail(zone, mail)
@@ -309,8 +315,8 @@ func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /server/health/recheck = %d, want 303", rec.Code)
}
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 2 {
t.Errorf("after Re-check DNS both records should be published:\n%s", out)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 3 {
t.Errorf("after Re-check DNS the three records should be published:\n%s", out)
}
}
@@ -318,8 +324,8 @@ func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
// Settings, where a record that has just been published comes back folded.
func TestSettingsRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Count(out, `is-warning is-light">missing`) != 2 {
t.Fatalf("before publishing, both records should be missing:\n%s", out)
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Count(out, `is-warning is-light">missing`) != 3 {
t.Fatalf("before publishing, the three records should be missing:\n%s", out)
}
publishMail(zone, mail)
@@ -332,8 +338,8 @@ func TestSettingsRecheckAsksForNotificationMailAgain(t *testing.T) {
t.Fatalf("POST /server/settings/dns-recheck = %d to %q, want 303 to /server/settings?rechecked=1", rec.Code, rec.Header().Get("Location"))
}
out := getBody(t, h.HandleServerSettings, "/server/settings?rechecked=1")
if n := strings.Count(out, `<details class="sp-record">`); n != 2 {
t.Errorf("after Re-check DNS both records should be published and folded, %d are:\n%s", n, out)
if n := strings.Count(out, `<details class="sp-record">`); n != 3 {
t.Errorf("after Re-check DNS the three records should be published and folded, %d are:\n%s", n, out)
}
if !strings.Contains(out, "DNS re-checked.") || !strings.Contains(out, "· checked just now") {
t.Errorf("Settings does not say the records were just re-checked:\n%s", out)
+11 -4
View File
@@ -170,11 +170,17 @@ func (h *Handlers) serverMail(force bool) serverMailState {
spfOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
return view.ServerMailSPF(host, dnscheck.ServerMailSPF, c), view.NewMailRow("SPF", host, c)
}
dmarcHost := dnscheck.DMARCRecordName(host)
dmarcOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
return view.ServerMailDMARC(dmarcHost, dnscheck.ServerMailDMARC, c), view.NewMailRow("DMARC", dmarcHost, c)
}
mail, err := h.domains.ServerMail()
if err != nil {
logf("panel: server mail: %v", err)
rec, row := spfOf(view.DNSCheck{Status: string(health.StatusUnknown)})
return serverMailState{records: []view.Record{rec}, rows: []view.MailRow{row}, status: string(health.StatusUnknown)}
unknown := view.DNSCheck{Status: string(health.StatusUnknown)}
rec, row := spfOf(unknown)
drec, drow := dmarcOf(unknown)
return serverMailState{records: []view.Record{rec, drec}, rows: []view.MailRow{row, drow}, status: string(health.StatusUnknown)}
}
srv := h.dns.Server(host, false)
check := h.dns.ServerMail(dnscheck.Query{
@@ -183,13 +189,14 @@ func (h *Handlers) serverMail(force bool) serverMailState {
}, force)
dkim := dnsCheck(check.DKIM)
spfRec, spfRow := spfOf(dnsCheck(check.SPF))
dmarcRec, dmarcRow := dmarcOf(dnsCheck(check.DMARC))
status := check.Overall
if status == health.StatusError {
status = health.StatusWarn
}
return serverMailState{
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec},
rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow},
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec, dmarcRec},
rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow, dmarcRow},
status: string(status),
checked: check.CheckedAt,
}
+2
View File
@@ -139,6 +139,7 @@ func healthFixture(refresh bool) *Health {
h.WithNotificationMail("postmaster@mail.example.org", []MailRow{
NewMailRow("DKIM", mailFixtureDKIMHost, DNSCheck{Status: "ok"}),
NewMailRow("SPF", "mail.example.org", DNSCheck{Status: "error"}),
NewMailRow("DMARC", "_dmarc.mail.example.org", DNSCheck{Status: "warn"}),
}, "warn")
return h
}
@@ -171,6 +172,7 @@ func settingsFixture() *Settings {
WithMail([]Record{
ServerMailDKIM("sp2026", mailFixtureDKIMHost, key, DNSCheck{Status: "ok"}),
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "error", Detail: "No SPF record is published for mail.example.org."}),
ServerMailDMARC("_dmarc.mail.example.org", "v=DMARC1; p=reject", DNSCheck{Status: "warn", Detail: "No DMARC record at _dmarc.mail.example.org."}),
}, "3 min ago").
WithTimeZone("Europe/Moscow", msk, time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
}
+1 -1
View File
@@ -299,7 +299,7 @@ var serverTopics = []HelpTopic{
Br(), Br(),
"Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.",
Br(), Br(),
"For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, ", Code("v=spf1 a -all"), ". Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either."),
"For that mail to arrive, the server's own name needs DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, an SPF record, ", Code("v=spf1 a -all"), ", and a DMARC record, ", Code("v=DMARC1; p=reject"), ", which has receivers refuse mail forged in the server's name - a false password-reset message among it; only the panel sends as this name, signed, so reject is safe. Server › Settings gives all three with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains depends on none of them."),
}},
}
+15
View File
@@ -213,6 +213,21 @@ func ServerMailSPF(host, value string, c DNSCheck) Record {
return r
}
// ServerMailDMARC is the DMARC record of the server's own name
// (server-dmarc-hint). While it is not in place the note under the value says
// what it is for and why its strict policy is safe.
func ServerMailDMARC(host, value string, c DNSCheck) Record {
r := Record{Name: "DMARC", Note: Plain("For the server's own name, not for your domains"),
Host: host, Type: "TXT", Value: value}
var standing Text
if c.Status != "ok" {
standing = Plain("Without it a receiver does not refuse mail forged in the server's name - a false password-reset message included. Only the panel sends as this name, signed, so reject is safe.")
}
applyCheck(&r, c, standing, nil)
r.Status = mailTag(c)
return r
}
// DMARCRecordInput is what a DMARC record needs besides its check: where the
// reports go in words (Source: the address, or "no reports") and the page that changes it,
// the page of the reports themselves ("" when the panel has none), and whether