Files
selfpost/internal/dnscheck/resolver.go
T
mixandClaude Opus 5.5 006bed7022
test / test (push) Canceled after 0s
audit 4: DNS outages unknown, DMARC limits said, typed Health, one TLS mode per upstream
M14, M15: queryEach gives each resolver 2 s within the check's context and
asks the one that answered last first; an address or PTR lookup not
answered is Unknown, an answered mismatch beside it still an error;
Checker.Server keeps the last answer's addresses, so SPF does not go Unknown
with it. Notifications report faults only, now tested with SPF unknown
alone and beside a wrong DKIM.

DMARC reports: tests of malformed XML, gzip and zip bombs, a message over
the limit, the service's Resync, hosted suggestion and validation. The
finding: both limits cut silently and an oversized report failed as
damaged; readAtMost refuses it for its size (MaxMessage 10 MiB, moved into
IngestMessage).

healthReadings replaces map[string]any for Health and Overview.

L5: an inbound upstream has one TLS mode - a second domain to the same
[host]:port with another mode is refused, naming the first; the TLS policy
map holds each next hop once.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 631 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:15:58 +03:00

155 lines
5.3 KiB
Go

package dnscheck
import (
"context"
"errors"
"net"
"strings"
"sync"
"time"
)
// DefaultResolvers are the recursive resolvers the checks query when the
// deployment does not name its own (SELFPOST_DNS_RESOLVERS). Three independent
// operators, so one being unreachable from the host does not blind the checks:
// each is given a time of its own to answer (perResolverTimeout), and the one
// that answered last is asked first (audit 2026-10-12, M14 - before, the first
// had the whole check's five seconds, and one that dropped packets left the
// others an expired context).
var DefaultResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
// perResolverTimeout is how long one resolver has to answer one question
// before the next is asked. A resolver that is there answers in tens of
// milliseconds; one that drops packets would otherwise take the check's whole
// lookupTimeout.
const perResolverTimeout = 2 * time.Second
// externalResolver talks to a fixed list of recursive resolvers directly
// instead of going through the system resolver.
//
// That detour is deliberate: these checks answer "what does a receiving mail
// server see about us?", and the machine's own stub resolver is the one place
// where the answer differs. systemd-resolved — which a Docker container reaches
// through the embedded 127.0.0.11 forwarder — synthesises a PTR record for the
// host's own addresses out of the local hostname, never asking public DNS. A
// server whose reverse DNS was published correctly therefore had its
// provider-assigned hostname reported back to the panel, and the FCrDNS check
// failed a record that was in fact right.
//
// This bypasses the resolvers in /etc/resolv.conf, not /etc/hosts: Go still
// consults the hosts file first, as any local program would.
type externalResolver struct {
servers []*net.Resolver
// timeout is perResolverTimeout; tests shorten it.
timeout time.Duration
mu sync.Mutex
// first is the resolver that answered last, asked first next time.
first int
}
// newExternalResolver builds a resolver over addrs ("host" or "host:port"). An
// empty list falls back to DefaultResolvers.
func newExternalResolver(addrs []string) *externalResolver {
if len(addrs) == 0 {
addrs = DefaultResolvers
}
e := &externalResolver{timeout: perResolverTimeout}
for _, a := range addrs {
addr := withDefaultPort(a)
e.servers = append(e.servers, &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
},
})
}
return e
}
func (e *externalResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return queryEach(ctx, e, func(ctx context.Context, i int) ([]string, error) { return e.servers[i].LookupTXT(ctx, name) })
}
func (e *externalResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
return queryEach(ctx, e, func(ctx context.Context, i int) ([]net.IPAddr, error) { return e.servers[i].LookupIPAddr(ctx, host) })
}
func (e *externalResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
return queryEach(ctx, e, func(ctx context.Context, i int) ([]string, error) { return e.servers[i].LookupAddr(ctx, addr) })
}
func (e *externalResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
return queryEach(ctx, e, func(ctx context.Context, i int) ([]*net.MX, error) { return e.servers[i].LookupMX(ctx, name) })
}
// queryEach asks the resolvers in turn, the one that answered last first, and
// stops at the first one that answers. "No such name" is an answer - only a
// resolver that cannot be reached, or that times out, moves the query on to
// the next one. Each is asked under a time of its own within ctx, so a
// resolver that drops packets costs e.timeout, not the check's whole budget.
func queryEach[T any](ctx context.Context, e *externalResolver, ask func(ctx context.Context, i int) (T, error)) (T, error) {
var zero T
n := len(e.servers)
if n == 0 {
return zero, errors.New("no DNS resolver configured")
}
e.mu.Lock()
start := e.first
e.mu.Unlock()
answered := func(i int) {
e.mu.Lock()
e.first = i
e.mu.Unlock()
}
var lastErr error
for k := 0; k < n; k++ {
i := (start + k) % n
rctx, cancel := context.WithTimeout(ctx, e.timeout)
v, err := ask(rctx, i)
cancel()
if err == nil {
answered(i)
return v, nil
}
if isNotFound(err) {
answered(i)
return zero, err
}
lastErr = err
if ctx.Err() != nil {
break // the check's own time is up
}
}
return zero, lastErr
}
// isNotFound is an authoritative "no such name": an answer, not a failure.
func isNotFound(err error) bool {
var dnsErr *net.DNSError
return errors.As(err, &dnsErr) && dnsErr.IsNotFound
}
// withDefaultPort appends the DNS port to a bare address, so the environment
// variable can name a resolver as plainly as "1.1.1.1".
func withDefaultPort(addr string) string {
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
return net.JoinHostPort(addr, "53")
}
// ParseResolvers reads a comma-separated resolver list, as it arrives from the
// environment. Blank entries are skipped; an empty result means "use
// DefaultResolvers".
func ParseResolvers(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}