diff --git a/CHANGELOG.md b/CHANGELOG.md index e24dee1..c7dec15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Added + +- **A DMARC record for the server's own name** (server-dmarc-hint; the + security audit's S3): Server › Settings › Notification mail gives + `v=DMARC1; p=reject` at `_dmarc.` beside the DKIM and SPF + records, checks it, and Health's card shows it. Without one a receiver + does not refuse mail forged in the server's name, a false password-reset + message among it; `p=none` is shown as weak. + ## [2.14.5] - 2026-10-18 A line less in the mail log. No migration; schema 13. diff --git a/docs/guide.md b/docs/guide.md index 17e6edb..b6028ac 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -679,18 +679,24 @@ the Outbound log and count against no rate limit. They name the page of the panel to open and carry no link: the panel does not know the address its reverse proxy serves it under. -**Two DNS records make them arrive.** The server's own name is not one of your +**Three DNS records make them arrive.** The server's own name is not one of your sending domains, so it needs records of its own, shown with the values to publish under **Server › Settings › Notification mail**: - a **DKIM** key for the hostname, which the panel generates and signs notifications with (`._domainkey.`, TXT); -- an **SPF** record for the hostname: `v=spf1 a -all` (TXT). +- an **SPF** record for the hostname: `v=spf1 a -all` (TXT); +- a **DMARC** record for the hostname: `v=DMARC1; p=reject` at + `_dmarc.` (TXT). It has receivers refuse mail forged in the + server's name - a false "reset your password" message among it. Only the + panel sends as this name, signed with its key, from the address its SPF + names, so reject costs nothing; `p=quarantine` is accepted too, `p=none` + is shown as weak, since it refuses nothing. **Server › Health** shows whether each is in place in the *Notification mail* card. Without them notifications are still sent, and a receiver may file them as spam or, if the parent domain publishes a strict DMARC policy, refuse them. -Mail of your sending domains depends on neither. If the hostname is itself one +Mail of your sending domains depends on none of them. If the hostname is itself one of your sending domains, the DKIM record is that domain's and is already published - and whoever administers that domain can then send mail that looks like the panel's; a hostname that is nobody's sending domain is the diff --git a/docs/roadmap.md b/docs/roadmap.md index cce5652..c42f83f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,7 +30,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). |---|---|---|---|---| | security-audit-2026-10-17 | Follow-ups of the 2026-10-17 security audit: A2 (high), seven mediums, the lows | stages 0-6 shipped `2.14.4` (2026-10-17); stage H (the host, S1-S3, S5) the owner's | 27/29 | [security-audit-2026-10-17.md](security-audit-2026-10-17.md) | | upstream-tls-verify | Inbound: offer "Verified" TLS to the upstream (Postfix `secure`, the upstream's name checked), beside Required, which checks no certificate (audit M7) | proposed | — | — | -| server-dmarc-hint | Server › Settings lists a DMARC record for the server's own name beside its DKIM and SPF (audit S3, product side) | proposed | — | — | +| server-dmarc-hint | Server › Settings lists a DMARC record for the server's own name beside its DKIM and SPF (audit S3, product side) | built 2026-10-18 (owner: "Оба предложения делаем") | — | — | | inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | shipped `2.10.0` | 7/7 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) | | health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | shipped `2.11.0` | — | — | | test-email | Send a test message from the panel (was preflight) | shipped `2.12.0` | — | — | diff --git a/internal/dnscheck/dnscheck.go b/internal/dnscheck/dnscheck.go index 0aecda3..bf1c33a 100644 --- a/internal/dnscheck/dnscheck.go +++ b/internal/dnscheck/dnscheck.go @@ -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)} diff --git a/internal/dnscheck/records.go b/internal/dnscheck/records.go index fe68255..98bb7e0 100644 --- a/internal/dnscheck/records.go +++ b/internal/dnscheck/records.go @@ -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. diff --git a/internal/dnscheck/servermail_test.go b/internal/dnscheck/servermail_test.go index 05170f1..f1e746f 100644 --- a/internal/dnscheck/servermail_test.go +++ b/internal/dnscheck/servermail_test.go @@ -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) + } + } +} diff --git a/internal/web/handlers/handlers_notifications_test.go b/internal/web/handlers/handlers_notifications_test.go index 76b5651..f40d45a 100644 --- a/internal/web/handlers/handlers_notifications_test.go +++ b/internal/web/handlers/handlers_notifications_test.go @@ -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) { `

SPF

published`, `For the server's own name, not for your domains`, `value="v=spf1 a -all"`, + `

DMARC

published`, + `value="_dmarc.mail.example.org"`, `value="v=DMARC1; p=reject"`, `postmaster@mail.example.org`, } { 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{ `

DKIM

missing`, `

SPF

missing`, - `value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`, + `

DMARC

missing`, + `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"`, `

Notification mail

warn
`, `DKIM
sp2026._domainkey.mail.example.orgpublished`, `SPF
mail.example.orgmissing`, + `DMARC
_dmarc.mail.example.orgmissing`, `Sent from postmaster@mail.example.org`, `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, `
`); 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, `
`); 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) diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go index 5e487aa..e327ead 100644 --- a/internal/web/handlers/handlers_settings.go +++ b/internal/web/handlers/handlers_settings.go @@ -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, } diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index 2e52732..fd13cda 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -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)) } diff --git a/internal/web/view/help.go b/internal/web/view/help.go index 6a4f0be..6ae24ed 100644 --- a/internal/web/view/help.go +++ b/internal/web/view/help.go @@ -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."), }}, } diff --git a/internal/web/view/outbound.go b/internal/web/view/outbound.go index 1808e15..3a8bae6 100644 --- a/internal/web/view/outbound.go +++ b/internal/web/view/outbound.go @@ -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