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:
@@ -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)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'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'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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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."),
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user