panel: show the SPF and DMARC records the domain page expects

The domain page generated and displayed the DKIM record but said only
"also configure SPF and DMARC for the domain (see the documentation)".
The concrete example existed — buried in the check's remediation text,
and only visible once the check had already failed. Show both records up
front, host and value with a Copy button, the way the DKIM record is
shown, plus the two things that actually bite: a domain may carry only
one SPF record (add the mechanism to the existing one rather than
publishing a second), and p=none is safe to publish immediately.

The SPF value names the addresses SELFPOST_HOSTNAME resolves to, taken
from the hostname check the page already runs, and falls back to an "a:"
mechanism when it does not resolve. New dnscheck.SPFExample/DMARCExample
are the single source for both the page and the checks' advice, so the
two cannot drift into recommending different records; dnscheck.Query
gains Hostname for the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 16:08:48 +03:00
parent dab7fc1609
commit 9cac9450d6
8 changed files with 199 additions and 15 deletions
+3 -2
View File
@@ -66,12 +66,13 @@ type Domain struct {
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
// tells the operator to publish (domain.DKIMRecord.Value), so the check
// compares DNS against the key this server actually signs with. ServerIPs comes
// from a preceding Server check.
// compares DNS against the key this server actually signs with. Hostname and
// ServerIPs identify this server and come from a preceding Server check.
type Query struct {
Name string
Selector string
ExpectedDKIM string
Hostname string
ServerIPs []string
}
+49
View File
@@ -0,0 +1,49 @@
package dnscheck
import (
"net"
"strings"
)
// SPF and DMARC are the two records SelfPost cannot generate for the operator:
// unlike the DKIM record they are policy, not a key, and a domain may already
// publish one for other senders. The panel still has to say what "correct"
// looks like, and the checks below have to suggest the same thing when a record
// is missing — so both take their example from here rather than each spelling
// out its own.
// DMARCRecordName is the name a DMARC record is published at. (SPF has no such
// helper: it is published at the domain itself.)
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
// SPFExample is the SPF record this server expects for a sending domain: the
// addresses its mail actually leaves from, and "-all" to say that nothing else
// is authorised. When the server's own addresses are not known (its hostname
// does not resolve) it falls back to an "a:" mechanism naming the host, so the
// panel always has something concrete to show.
func SPFExample(hostname string, serverIPs []string) string {
var mechanisms []string
for _, s := range serverIPs {
ip := net.ParseIP(strings.TrimSpace(s))
switch {
case ip == nil:
continue
case ip.To4() != nil:
mechanisms = append(mechanisms, "ip4:"+ip.String())
default:
mechanisms = append(mechanisms, "ip6:"+ip.String())
}
}
if len(mechanisms) == 0 {
mechanisms = []string{"a:" + hostname}
}
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
}
// DMARCExample is the least a domain should publish: monitoring only, with an
// address the aggregate reports go to. p=none is deliberate — it changes
// nothing about delivery, so it is safe to publish before the reports have
// shown that DKIM and SPF pass everywhere.
func DMARCExample(domainName string) string {
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
}
+60
View File
@@ -0,0 +1,60 @@
package dnscheck
import (
"context"
"strings"
"testing"
"codeberg.org/mix/selfpost/internal/health"
)
func TestSPFExample(t *testing.T) {
cases := []struct {
name string
hostname string
ips []string
want string
}{
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
// The hostname does not resolve, so there is no address to name; an "a:"
// mechanism still gives the operator a publishable record.
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := SPFExample(c.hostname, c.ips); got != c.want {
t.Errorf("SPFExample = %q, want %q", got, c.want)
}
})
}
}
// The record the panel shows and the one a failed check suggests must be the
// same string, or the operator is told two different things on one page.
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
f := &fakeResolver{}
c := newTestChecker(f)
spf := c.checkSPF(context.Background(), Query{
Name: "example.com",
Hostname: "mail.example.com",
ServerIPs: []string{"203.0.113.10"},
})
if spf.Status != health.StatusError {
t.Fatalf("SPF status = %q, want error (%s)", spf.Status, spf.Detail)
}
if want := SPFExample("mail.example.com", []string{"203.0.113.10"}); !strings.Contains(spf.Detail, want) {
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
}
dmarc := c.checkDMARC(context.Background(), "example.com")
if dmarc.Status != health.StatusWarn {
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
}
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
}
}
+2 -2
View File
@@ -61,7 +61,7 @@ func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
// 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, domainName string) Result {
name := "_dmarc." + domainName
name := DMARCRecordName(domainName)
txt, found, err := c.lookupTXT(ctx, name)
if err != nil {
return lookupFailed("the DMARC record", err)
@@ -76,7 +76,7 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
if !found || len(records) == 0 {
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least \"v=DMARC1; p=none; rua=mailto:you@%s\" tells receivers what to do with mail that fails DKIM and gets you reports.", name, domainName),
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
}
}
if len(records) > 1 {
+1 -1
View File
@@ -47,7 +47,7 @@ func (c *Checker) checkSPF(ctx context.Context, q Query) Result {
case !found || len(records) == 0:
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No SPF record is published for %s. Publish a TXT record such as \"v=spf1 ip4:%s -all\" — without it receivers have nothing authorising this server to send as the domain.", q.Name, ips[0]),
Detail: fmt.Sprintf("No SPF record is published for %s. Publish a TXT record such as %q — without it receivers have nothing authorising this server to send as the domain.", q.Name, SPFExample(q.Hostname, q.ServerIPs)),
}
case len(records) > 1:
return Result{