audit 4: DNS outages unknown, DMARC limits said, typed Health, one TLS mode per upstream
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
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>
This commit is contained in:
@@ -145,6 +145,15 @@ func (c *Checker) Server(hostname string, force bool) Server {
|
||||
srv := c.checkServer(ctx, hostname)
|
||||
|
||||
c.mu.Lock()
|
||||
// A lookup the resolvers did not answer finds no addresses; the server
|
||||
// still has the ones the last answer gave, and the SPF check of a domain
|
||||
// asks for them - without them it went Unknown in the same minute
|
||||
// (audit 2026-10-12, M15).
|
||||
if len(srv.IPs) == 0 && srv.PTR.Status == health.StatusUnknown {
|
||||
if prev, ok := c.servers[hostname]; ok {
|
||||
srv.IPs = prev.value.IPs
|
||||
}
|
||||
}
|
||||
c.servers[hostname] = cached[Server]{value: srv, expires: srv.CheckedAt.Add(c.serverTTL)}
|
||||
c.mu.Unlock()
|
||||
return srv
|
||||
|
||||
@@ -53,6 +53,9 @@ func (f *fakeResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAdd
|
||||
|
||||
func (f *fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) {
|
||||
f.lookups++
|
||||
if f.fail[addr] {
|
||||
return nil, &net.DNSError{Err: "i/o timeout", Name: addr, IsTimeout: true}
|
||||
}
|
||||
if v, ok := f.ptr[addr]; ok {
|
||||
return v, nil
|
||||
}
|
||||
@@ -411,3 +414,50 @@ func TestInboundMXAbsent(t *testing.T) {
|
||||
t.Fatalf("status = %q, want error", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A PTR lookup the resolvers did not answer is not known, not "no PTR record"
|
||||
// (audit 2026-10-12, M15); an answered address that does not match is still
|
||||
// an error.
|
||||
func TestServerPTRTimeoutIsUnknown(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10", "203.0.113.11")},
|
||||
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
|
||||
fail: map[string]bool{"203.0.113.11": true},
|
||||
}
|
||||
srv := newTestChecker(f).Server("mail.example.com", false)
|
||||
if srv.PTR.Status != health.StatusUnknown || !strings.Contains(srv.PTR.Detail, "Could not check the reverse DNS") {
|
||||
t.Fatalf("status = %q (%s), want Unknown", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
if len(srv.IPs) != 2 {
|
||||
t.Errorf("IPs = %v", srv.IPs)
|
||||
}
|
||||
|
||||
f.ptr["203.0.113.10"] = []string{"other.example."}
|
||||
srv = newTestChecker(f).Server("mail.example.com", true)
|
||||
if srv.PTR.Status != health.StatusError {
|
||||
t.Errorf("a mismatch beside a timeout = %q, want the error the mismatch is", srv.PTR.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// The address lookup not answered is not known, not "does not resolve"; the
|
||||
// addresses the last answer gave are kept, so a domain's SPF check that needs
|
||||
// them does not go Unknown with it (audit 2026-10-12, M15).
|
||||
func TestServerAddressTimeoutIsUnknownAndKeepsTheAddresses(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
|
||||
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
|
||||
fail: map[string]bool{},
|
||||
}
|
||||
c := newTestChecker(f)
|
||||
if srv := c.Server("mail.example.com", false); srv.PTR.Status != health.StatusOK {
|
||||
t.Fatalf("first check = %q", srv.PTR.Status)
|
||||
}
|
||||
f.fail["mail.example.com"] = true
|
||||
srv := c.Server("mail.example.com", true)
|
||||
if srv.PTR.Status != health.StatusUnknown || strings.Contains(srv.PTR.Detail, "does not resolve") {
|
||||
t.Fatalf("status = %q (%s), want Unknown", srv.PTR.Status, srv.PTR.Detail)
|
||||
}
|
||||
if len(srv.IPs) != 1 || srv.IPs[0] != "203.0.113.10" {
|
||||
t.Errorf("IPs = %v, want the ones the last answer gave", srv.IPs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,25 @@ import (
|
||||
"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.
|
||||
// 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.
|
||||
//
|
||||
@@ -28,6 +40,12 @@ var DefaultResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
|
||||
// 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
|
||||
@@ -36,7 +54,7 @@ func newExternalResolver(addrs []string) *externalResolver {
|
||||
if len(addrs) == 0 {
|
||||
addrs = DefaultResolvers
|
||||
}
|
||||
e := &externalResolver{}
|
||||
e := &externalResolver{timeout: perResolverTimeout}
|
||||
for _, a := range addrs {
|
||||
addr := withDefaultPort(a)
|
||||
e.servers = append(e.servers, &net.Resolver{
|
||||
@@ -51,44 +69,68 @@ func newExternalResolver(addrs []string) *externalResolver {
|
||||
}
|
||||
|
||||
func (e *externalResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
|
||||
return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupTXT(ctx, name) })
|
||||
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(e.servers, func(r *net.Resolver) ([]net.IPAddr, error) { return r.LookupIPAddr(ctx, host) })
|
||||
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(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupAddr(ctx, addr) })
|
||||
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(e.servers, func(r *net.Resolver) ([]*net.MX, error) { return r.LookupMX(ctx, name) })
|
||||
return queryEach(ctx, e, func(ctx context.Context, i int) ([]*net.MX, error) { return e.servers[i].LookupMX(ctx, name) })
|
||||
}
|
||||
|
||||
// queryEach asks each resolver in turn 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.
|
||||
func queryEach[T any](servers []*net.Resolver, ask func(*net.Resolver) (T, error)) (T, error) {
|
||||
// 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 _, r := range servers {
|
||||
v, err := ask(r)
|
||||
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
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
if isNotFound(err) {
|
||||
answered(i)
|
||||
return zero, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no DNS resolver configured")
|
||||
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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWithDefaultPort(t *testing.T) {
|
||||
@@ -48,10 +49,10 @@ func TestNewExternalResolverFallsBackToDefaults(t *testing.T) {
|
||||
// must not second-guess an authoritative "no such name" — otherwise a domain
|
||||
// that genuinely lacks a record costs one timeout per configured resolver.
|
||||
func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) {
|
||||
servers := []*net.Resolver{{}, {}, {}}
|
||||
e := &externalResolver{servers: []*net.Resolver{{}, {}, {}}, timeout: time.Second}
|
||||
|
||||
asked := 0
|
||||
got, err := queryEach(servers, func(*net.Resolver) (string, error) {
|
||||
got, err := queryEach(context.Background(), e, func(context.Context, int) (string, error) {
|
||||
asked++
|
||||
if asked < 3 {
|
||||
return "", &net.DNSError{Err: "timed out", IsTimeout: true}
|
||||
@@ -66,7 +67,7 @@ func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
asked = 0
|
||||
_, err = queryEach(servers, func(*net.Resolver) (string, error) {
|
||||
_, err = queryEach(context.Background(), e, func(context.Context, int) (string, error) {
|
||||
asked++
|
||||
return "", notFound("absent.example")
|
||||
})
|
||||
@@ -79,6 +80,40 @@ func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A first resolver that drops packets costs its own time, not the check's:
|
||||
// the second is asked with time left, and answers; and the next question goes
|
||||
// to the one that answered (audit 2026-10-12, M14).
|
||||
func TestADroppingResolverLeavesTheNextItsTime(t *testing.T) {
|
||||
e := &externalResolver{servers: []*net.Resolver{{}, {}, {}}, timeout: 50 * time.Millisecond}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
var order []int
|
||||
drops := func(ctx context.Context, i int) (string, error) {
|
||||
order = append(order, i)
|
||||
if i == 0 {
|
||||
<-ctx.Done() // a resolver that never answers
|
||||
return "", &net.DNSError{Err: "i/o timeout", IsTimeout: true}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "answer", nil
|
||||
}
|
||||
start := time.Now()
|
||||
got, err := queryEach(ctx, e, drops)
|
||||
if err != nil || got != "answer" {
|
||||
t.Fatalf("got (%q, %v): the second resolver was not given its time", got, err)
|
||||
}
|
||||
if took := time.Since(start); took > 500*time.Millisecond {
|
||||
t.Errorf("the dropping resolver took %v of the check's second", took)
|
||||
}
|
||||
order = nil
|
||||
if _, err := queryEach(ctx, e, drops); err != nil || len(order) != 1 || order[0] != 1 {
|
||||
t.Errorf("the next question went to %v (%v), want the resolver that answered", order, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason this package does not use net.DefaultResolver: systemd-resolved
|
||||
// answers PTR queries for the machine's own addresses out of the local
|
||||
// hostname, which is not what the rest of the internet sees. The dial hook must
|
||||
|
||||
@@ -24,7 +24,14 @@ func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
|
||||
}
|
||||
|
||||
addrs, err := c.resolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
if err != nil && !isNotFound(err) {
|
||||
// The resolvers did not answer: not known, rather than "does not
|
||||
// resolve" (audit 2026-10-12, M15). Checker.Server keeps the
|
||||
// addresses it had, for the SPF check that needs them.
|
||||
srv.PTR = lookupFailed("the address of "+hostname, err)
|
||||
return srv
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
srv.PTR = Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("%s does not resolve to any address. Publish an A (or AAAA) record for it — receiving servers check the name this server announces in HELO.", hostname),
|
||||
@@ -33,14 +40,21 @@ func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
|
||||
}
|
||||
|
||||
want := normalizeName(hostname)
|
||||
matched, total := 0, len(addrs)
|
||||
matched, total, unanswered := 0, len(addrs), 0
|
||||
var records []string
|
||||
var lastErr error
|
||||
for _, a := range addrs {
|
||||
ip := a.IP.String()
|
||||
srv.IPs = append(srv.IPs, ip)
|
||||
|
||||
names, err := c.resolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(names) == 0 {
|
||||
if err != nil && !isNotFound(err) {
|
||||
unanswered++
|
||||
lastErr = err
|
||||
records = append(records, ip+" → not answered ("+dnsErrorText(err)+")")
|
||||
continue
|
||||
}
|
||||
if len(names) == 0 {
|
||||
records = append(records, ip+" → no PTR record")
|
||||
continue
|
||||
}
|
||||
@@ -60,6 +74,10 @@ func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
|
||||
|
||||
srv.PTR.Records = records
|
||||
switch {
|
||||
case unanswered > 0 && matched+unanswered == total:
|
||||
// Every address that was answered matches; the rest are not known.
|
||||
srv.PTR.Status = health.StatusUnknown
|
||||
srv.PTR.Detail = lookupFailed("the reverse DNS of "+joinIPs(srv.IPs), lastErr).Detail
|
||||
case matched == total:
|
||||
srv.PTR.Status = health.StatusOK
|
||||
srv.PTR.Detail = fmt.Sprintf("%s resolves to %s and the reverse lookup points back at it.", hostname, joinIPs(srv.IPs))
|
||||
|
||||
Reference in New Issue
Block a user