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>
206 lines
7.5 KiB
Go
206 lines
7.5 KiB
Go
package dnscheck
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/mixeme/selfpost/internal/health"
|
|
)
|
|
|
|
// checkDKIM compares the TXT record published at <selector>._domainkey.<domain>
|
|
// with the key this server signs with. A wrong or absent record means every
|
|
// message fails DKIM at the receiver, so both are errors.
|
|
func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
|
|
name := q.Selector + "._domainkey." + q.Name
|
|
txt, found, err := c.lookupTXT(ctx, name)
|
|
if err != nil {
|
|
return lookupFailed("the DKIM record", err)
|
|
}
|
|
|
|
expected := publicKeyTag(q.ExpectedDKIM)
|
|
if !found {
|
|
return Result{
|
|
Status: health.StatusError,
|
|
Detail: fmt.Sprintf("No TXT record is published at %s. Publish the record shown above — until then every message fails DKIM.", name),
|
|
}
|
|
}
|
|
|
|
for _, rec := range txt {
|
|
got := publicKeyTag(rec)
|
|
if got == "" {
|
|
continue
|
|
}
|
|
if got == expected {
|
|
return Result{
|
|
Status: health.StatusOK,
|
|
Detail: fmt.Sprintf("Published at %s and matching the key this server signs with.", name),
|
|
Records: txt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Something is published, but it is not our key. Separate the revoked case
|
|
// (empty p=), which reads as a deliberate act rather than a typo.
|
|
for _, rec := range txt {
|
|
if v, ok := tagValue(rec, "p"); ok && v == "" {
|
|
return Result{
|
|
Status: health.StatusError,
|
|
Detail: fmt.Sprintf("The record at %s has an empty p= tag, which revokes the key. Replace it with the record shown above.", name),
|
|
Records: txt,
|
|
}
|
|
}
|
|
}
|
|
return Result{
|
|
Status: health.StatusError,
|
|
Detail: fmt.Sprintf("A TXT record exists at %s but its public key is not the one this server signs with — mail will fail DKIM. Replace it with the record shown above (an old record from a previous server is the usual cause).", name),
|
|
Records: txt,
|
|
}
|
|
}
|
|
|
|
// 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, 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") {
|
|
records = append(records, rec)
|
|
}
|
|
}
|
|
if !found || len(records) == 0 {
|
|
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{
|
|
Status: health.StatusError,
|
|
Detail: fmt.Sprintf("More than one DMARC record is published at %s. Receivers treat that as no policy at all — keep exactly one.", name),
|
|
Records: records,
|
|
}
|
|
}
|
|
|
|
policy, ok := tagValue(records[0], "p")
|
|
if !ok || policy == "" {
|
|
return Result{
|
|
Status: health.StatusWarn,
|
|
Detail: "A DMARC record is published but has no p= policy tag, so receivers ignore it. Add p=none, p=quarantine or p=reject.",
|
|
Records: records,
|
|
}
|
|
}
|
|
detail := fmt.Sprintf("Published with policy p=%s.", policy)
|
|
if strings.EqualFold(policy, "none") {
|
|
detail += " That is monitoring only — tighten it to quarantine or reject once the reports look clean."
|
|
}
|
|
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.
|
|
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.
|
|
func publicKeyTag(record string) string {
|
|
v, ok := tagValue(record, "p")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return strings.Join(strings.Fields(v), "")
|
|
}
|
|
|
|
// tagValue reads one tag from a DKIM/DMARC-style "tag=value; tag=value" record.
|
|
// Tag names are case-sensitive per RFC 6376/7489, and values keep their case.
|
|
func tagValue(record, tag string) (string, bool) {
|
|
for _, part := range strings.Split(record, ";") {
|
|
part = strings.TrimSpace(part)
|
|
key, value, found := strings.Cut(part, "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(key) == tag {
|
|
return strings.TrimSpace(value), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|