Files
mix c0d9aa7518 chore/docs: move to GitHub as the single home; drop archived-spec references
Codeberg is being retired as the project's public site, so every reference now
points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost →
github.com/mixeme/selfpost): leaving an import path on a host that is going
away would break `go get` and `go install`, so this is not only a docs change.
Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags
version stamp in build/Dockerfile and docs/development.md, the licence headers
in the SVG/HTML assets, and README (no more primary/mirror pair).

Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1"
and friends pointed into docs/archive/specification-v1.0.md, which is marked as
not a source of truth; each is now a reference to the live document that owns
the subject — architecture.md (with section), product.md, security.md or the
README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/
8/9 had the same defect, so they went too. Comments only, no behaviour change.

Also closes the remaining review items: architecture.md gained a Code layers
section with the layer diagram (A2), and TestParseDelivery gained the exotic
mail.log cases (§ 3).

Fixes a bug that last test found: the delivery-line pattern matched status=
greedily, taking the *last* occurrence on the line. Postfix appends the remote
server's reply verbatim, so a rejection whose reply quoted "status=sent" was
filed as a delivered message in the send log. It now takes the first status=
after the recipient, which is the real field.

R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow,
so the file would have no audience yet. R1 (compose image tag) and the git tag
stay in roadmap § v1.x as the release-commit steps.

gofmt/go vet clean on both modules; go test ./... green except the three known
Windows-only failures (file perms, backslash paths, renaming an open file).
Not exercised on the dev server — no Docker locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:14:13 +03:00

286 lines
8.3 KiB
Go

package dnscheck
import (
"context"
"fmt"
"net"
"strings"
"github.com/mixeme/selfpost/internal/health"
)
// spfLookupBudget caps how many DNS lookups one SPF evaluation may spend on
// a/mx mechanisms. RFC 7208 allows a receiver ten; staying under the same
// ceiling keeps a hostile or careless record from turning a page view into a
// long chain of queries.
const spfLookupBudget = 10
// checkSPF reports whether the domain's SPF record authorises this server.
//
// This is deliberately a shallow check (documented as such in the README): it
// looks for a mechanism that literally covers the server's address —
// ip4:/ip6:, or a/mx resolving to it — and does not recurse into include: or
// redirect=, nor evaluate the record the way a receiver would. That is why a
// record which does not obviously cover us but does use include: is reported as
// a warning ("cannot tell") rather than a failure.
func (c *Checker) checkSPF(ctx context.Context, q Query) Result {
ips := parseIPs(q.ServerIPs)
if len(ips) == 0 {
return Result{
Status: health.StatusUnknown,
Detail: "The server's own address is not known (its hostname does not resolve), so SPF coverage cannot be checked. Fix the hostname/PTR check first.",
}
}
txt, found, err := c.lookupTXT(ctx, q.Name)
if err != nil {
return lookupFailed("the SPF record", err)
}
var records []string
for _, rec := range txt {
if isSPF(rec) {
records = append(records, strings.TrimSpace(rec))
}
}
switch {
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 %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{
Status: health.StatusError,
Detail: fmt.Sprintf("More than one SPF record is published for %s. RFC 7208 allows exactly one; receivers treat several as a permanent error and the domain gets no SPF pass at all. Merge them into a single record.", q.Name),
Records: records,
}
}
matched, unfollowed := c.evaluateSPF(ctx, records[0], q.Name, ips)
switch {
case matched == "+all" || matched == "all":
return Result{
Status: health.StatusWarn,
Detail: "The SPF record ends with \"+all\", which authorises every server on the internet to send as this domain. Replace it with an explicit ip4:/ip6: or a mechanism plus \"-all\".",
Records: records,
}
case matched != "":
return Result{
Status: health.StatusOK,
Detail: fmt.Sprintf("The SPF record authorises this server through its %q mechanism.", matched),
Records: records,
}
case len(unfollowed) > 0:
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No mechanism in the SPF record lists %s directly, but the record uses %s, which this check does not follow — the server may still be authorised through it. Verify with an external SPF validator, or add \"ip4:%s\" to be sure.",
ips[0], strings.Join(unfollowed, ", "), ips[0]),
Records: records,
}
default:
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("The SPF record does not authorise %s, so mail sent from this server fails SPF. Add \"ip4:%s\" (or an \"a\" mechanism resolving here) to the record.", ips[0], ips[0]),
Records: records,
}
}
}
// evaluateSPF walks the record's mechanisms, returning the first one that
// covers one of the server's addresses, plus the mechanisms this shallow check
// cannot resolve (include:/redirect=/exists:/ptr and anything past the lookup
// budget) so the caller can say "cannot tell" instead of "fails".
func (c *Checker) evaluateSPF(ctx context.Context, record, domainName string, ips []net.IP) (matched string, unfollowed []string) {
budget := spfLookupBudget
seenUnfollowed := make(map[string]bool)
note := func(kind string) {
if !seenUnfollowed[kind] {
seenUnfollowed[kind] = true
unfollowed = append(unfollowed, kind)
}
}
terms := strings.Fields(record)
if len(terms) > 0 {
terms = terms[1:] // drop the v=spf1 version token
}
for _, term := range terms {
qualifier, mech := splitQualifier(term)
lower := strings.ToLower(mech)
name, hasArg := mechanismArg(mech)
switch {
case strings.HasPrefix(lower, "ip4:"), strings.HasPrefix(lower, "ip6:"):
if qualifier != '+' {
continue
}
if coversAny(mech[4:], ips) {
return term, unfollowed
}
case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"):
if strings.Contains(mech, "/") { // prefix-length form: not evaluated
note("a/<prefix>")
continue
}
target := domainName
if hasArg {
target = name
}
if budget <= 0 {
note("further lookups")
continue
}
budget--
if qualifier == '+' && c.resolvesTo(ctx, target, ips) {
return term, unfollowed
}
case lower == "mx" || strings.HasPrefix(lower, "mx:") || strings.HasPrefix(lower, "mx/"):
if strings.Contains(mech, "/") {
note("mx/<prefix>")
continue
}
target := domainName
if hasArg {
target = name
}
if budget <= 0 {
note("further lookups")
continue
}
budget--
if qualifier == '+' && c.mxResolvesTo(ctx, target, ips, &budget) {
return term, unfollowed
}
case strings.HasPrefix(lower, "include:"):
note("include:")
case strings.HasPrefix(lower, "redirect="):
note("redirect=")
case strings.HasPrefix(lower, "exists:"):
note("exists:")
case lower == "ptr" || strings.HasPrefix(lower, "ptr:"):
note("ptr")
case lower == "all":
if qualifier == '+' {
return "+all", unfollowed
}
// "-all"/"~all"/"?all" terminates the record: nothing after it is
// evaluated by a receiver either.
return "", unfollowed
}
}
return "", unfollowed
}
// resolvesTo reports whether name resolves to one of the server's addresses.
func (c *Checker) resolvesTo(ctx context.Context, name string, ips []net.IP) bool {
addrs, err := c.resolver.LookupIPAddr(ctx, name)
if err != nil {
return false
}
for _, a := range addrs {
for _, ip := range ips {
if a.IP.Equal(ip) {
return true
}
}
}
return false
}
// mxResolvesTo reports whether any of name's MX hosts resolves to one of the
// server's addresses, spending at most the remaining lookup budget.
func (c *Checker) mxResolvesTo(ctx context.Context, name string, ips []net.IP, budget *int) bool {
mxs, err := c.resolver.LookupMX(ctx, name)
if err != nil {
return false
}
for _, mx := range mxs {
if *budget <= 0 {
return false
}
*budget--
if c.resolvesTo(ctx, strings.TrimSuffix(mx.Host, "."), ips) {
return true
}
}
return false
}
// coversAny reports whether an ip4:/ip6: value — a bare address or a CIDR —
// contains one of the server's addresses.
func coversAny(value string, ips []net.IP) bool {
value = strings.TrimSpace(value)
if strings.Contains(value, "/") {
_, network, err := net.ParseCIDR(value)
if err != nil {
return false
}
for _, ip := range ips {
if network.Contains(ip) {
return true
}
}
return false
}
listed := net.ParseIP(value)
if listed == nil {
return false
}
for _, ip := range ips {
if listed.Equal(ip) {
return true
}
}
return false
}
// splitQualifier peels the optional +/-/~/? qualifier off a mechanism,
// defaulting to "+" (pass) as RFC 7208 does.
func splitQualifier(term string) (byte, string) {
if term == "" {
return '+', ""
}
switch term[0] {
case '+', '-', '~', '?':
return term[0], term[1:]
default:
return '+', term
}
}
// mechanismArg returns the ":" argument of a mechanism, if it has one.
func mechanismArg(mech string) (string, bool) {
_, arg, found := strings.Cut(mech, ":")
if !found || arg == "" {
return "", false
}
return arg, true
}
// isSPF reports whether a TXT record is an SPF record (the version token must
// be the whole first term, so "v=spf10" is not one).
func isSPF(record string) bool {
rec := strings.TrimSpace(record)
if len(rec) < 6 || !strings.EqualFold(rec[:6], "v=spf1") {
return false
}
return len(rec) == 6 || rec[6] == ' ' || rec[6] == '\t'
}
// parseIPs converts the string addresses carried on a Query back into net.IPs,
// dropping anything unparsable.
func parseIPs(in []string) []net.IP {
var ips []net.IP
for _, s := range in {
if ip := net.ParseIP(s); ip != nil {
ips = append(ips, ip)
}
}
return ips
}