From b4ba2b9deb649eca7d483e52debb20bf83ca2c46 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Mon, 26 Oct 2026 20:51:37 +0100 Subject: [PATCH] checker: fix dns only txt (#2982) * checker: fix dns only txt * checker: fix dns only txt --- apps/checker/checker/dns.go | 21 ++++- apps/checker/checker/dns_test.go | 133 +++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/apps/checker/checker/dns.go b/apps/checker/checker/dns.go index a3f2f2311..f25967335 100644 --- a/apps/checker/checker/dns.go +++ b/apps/checker/checker/dns.go @@ -2,6 +2,7 @@ package checker import ( "context" + "errors" "fmt" "net" "strings" @@ -26,8 +27,10 @@ var resolver = net.DefaultResolver func Dns(ctx context.Context, host string) (*DnsResponse, error) { logger := log.Ctx(ctx).With().Str("monitor", host).Logger() + // A host can legitimately carry no address or CNAME (e.g. a TXT-only + // record), so a not-found answer is an empty set, not a failed check. ips, err := resolver.LookupIP(ctx, "ip", host) - if err != nil { + if err != nil && !isNotFound(err) { logger.Warn().Err(err).Msg("DNS IP lookup failed") return nil, fmt.Errorf("failed to lookup IPs: %w", err) } @@ -43,14 +46,14 @@ func Dns(ctx context.Context, host string) (*DnsResponse, error) { } } CNAME, err := lookupCNAME(ctx, host) - if err != nil { + if err != nil && !isNotFound(err) { logger.Warn().Err(err).Msg("DNS CNAME record lookup failed") return nil, fmt.Errorf("failed to lookup CNAME record: %w", err) } MXRecords := lookupMX(ctx, host) NS, err := lookupNS(ctx, host) - if err != nil { + if err != nil && !isNotFound(err) { logger.Warn().Err(err).Msg("DNS NS record lookup failed") return nil, fmt.Errorf("failed to lookup NS record: %w", err) } @@ -63,6 +66,11 @@ func Dns(ctx context.Context, host string) (*DnsResponse, error) { return nil, fmt.Errorf("DNS lookup for %s did not complete: %w", host, err) } + if len(A) == 0 && len(AAAA) == 0 && CNAME == "" && len(MXRecords) == 0 && len(NS) == 0 && len(TXT) == 0 { + logger.Warn().Msg("DNS lookup returned no records") + return nil, fmt.Errorf("no DNS records found for %s", host) + } + response := &DnsResponse{ A: A, AAAA: AAAA, @@ -138,6 +146,13 @@ func lookupTXT(ctx context.Context, domain string) []string { return records } +// isNotFound reports whether a lookup failed because the name has no records of +// the requested type, as opposed to a resolver or network failure. +func isNotFound(err error) bool { + var dnsErr *net.DNSError + return errors.As(err, &dnsErr) && dnsErr.IsNotFound +} + func isSubdomain(domain string) bool { parent := strings.Split(domain, ".") if len(parent) < 3 { diff --git a/apps/checker/checker/dns_test.go b/apps/checker/checker/dns_test.go index d8d34fca2..3400cfe2b 100644 --- a/apps/checker/checker/dns_test.go +++ b/apps/checker/checker/dns_test.go @@ -4,10 +4,12 @@ import ( "context" "errors" "net" + "strings" "testing" "time" "github.com/openstatushq/openstatus/apps/checker/checker" + "golang.org/x/net/dns/dnsmessage" ) func TestPingDNS(t *testing.T) { @@ -74,3 +76,134 @@ func useBlackholeResolver(t *testing.T) { }, }) } + +// A TXT-only host has no address or CNAME; the check must still read its TXT +// record rather than fail on the empty IP lookup. +func TestDns_TXTOnlyHost(t *testing.T) { + useStubResolver(t, map[string]stubRecords{ + "health.example.test": {txt: []string{"ok"}}, + }) + + data, err := checker.Dns(t.Context(), "health.example.test") + if err != nil { + t.Fatalf("Dns() error = %v", err) + } + if len(data.A) != 0 || len(data.AAAA) != 0 { + t.Errorf("Dns() expected no address records, got A=%v AAAA=%v", data.A, data.AAAA) + } + if len(data.TXT) != 1 || data.TXT[0] != "ok" { + t.Errorf("Dns() TXT records = %v, want [ok]", data.TXT) + } +} + +// An apex domain is the one case that queries NS, so an empty NS answer must be +// tolerated like the address and CNAME lookups. +func TestDns_TXTOnlyApexDomain(t *testing.T) { + useStubResolver(t, map[string]stubRecords{ + "example.test": {txt: []string{"ok"}}, + }) + + data, err := checker.Dns(t.Context(), "example.test") + if err != nil { + t.Fatalf("Dns() error = %v", err) + } + if len(data.NS) != 0 { + t.Errorf("Dns() expected no NS records, got %v", data.NS) + } + if len(data.TXT) != 1 || data.TXT[0] != "ok" { + t.Errorf("Dns() TXT records = %v, want [ok]", data.TXT) + } +} + +func TestDns_ReturnsErrorWhenNoRecords(t *testing.T) { + useStubResolver(t, map[string]stubRecords{}) + + _, err := checker.Dns(t.Context(), "missing.example.test") + if err == nil || !strings.Contains(err.Error(), "no DNS records found") { + t.Errorf("expected a no DNS records error, got %v", err) + } +} + +type stubRecords struct { + txt []string +} + +// Points the package resolver at a local UDP server answering from records: +// a known name gets its records (or an empty answer), anything else NXDOMAIN. +func useStubResolver(t *testing.T, records map[string]stubRecords) { + t.Helper() + + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("failed to open stub resolver: %v", err) + } + t.Cleanup(func() { conn.Close() }) + + go func() { + buf := make([]byte, 512) + for { + n, addr, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + if reply, err := stubAnswer(buf[:n], records); err == nil { + conn.WriteToUDP(reply, addr) + } + } + }() + + serverAddr := conn.LocalAddr().String() + checker.SetResolverForTest(t, &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "udp", serverAddr) + }, + }) +} + +func stubAnswer(query []byte, records map[string]stubRecords) ([]byte, error) { + var p dnsmessage.Parser + header, err := p.Start(query) + if err != nil { + return nil, err + } + question, err := p.Question() + if err != nil { + return nil, err + } + + name := strings.TrimSuffix(strings.ToLower(question.Name.String()), ".") + record, known := records[name] + + rcode := dnsmessage.RCodeSuccess + if !known { + rcode = dnsmessage.RCodeNameError + } + + b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ + ID: header.ID, + Response: true, + Authoritative: true, + RecursionAvailable: true, + RCode: rcode, + }) + if err := b.StartQuestions(); err != nil { + return nil, err + } + if err := b.Question(question); err != nil { + return nil, err + } + if err := b.StartAnswers(); err != nil { + return nil, err + } + if question.Type == dnsmessage.TypeTXT { + for _, txt := range record.txt { + hdr := dnsmessage.ResourceHeader{Name: question.Name, Class: dnsmessage.ClassINET, TTL: 60} + if err := b.TXTResource(hdr, dnsmessage.TXTResource{TXT: []string{txt}}); err != nil { + return nil, err + } + } + } + return b.Finish() +} -- 2.54.0