Skip to content

Commit c8d86a1

Browse files
edwardrfedw-defangclaude
authored
dns: add LookupTXT to Resolver interface (#2101)
* dns: add LookupTXT to Resolver interface Mirrors LookupIPAddr / LookupCNAME / LookupNS: - Resolver interface gains LookupTXT(ctx, domain) ([]string, error) - DirectResolver.LookupTXT issues a TypeTXT query and joins each record's wire-split strings into one entry per RR - RootResolver.LookupTXT delegates to its per-domain DirectResolver - MockResolver.LookupTXT reads from Records[{Type:"TXT"}] - Test mocks (cli.MockResolver, aws.CallbackMockResolver) add stubs to keep satisfying the interface Prereq for the defang-mvp ResolveTXT server handler (which calls LookupTXT on the resolver) and for the in-flight FabricResolver work in #2069 (which adds a fabric-backed Resolver impl that will need a LookupTXT method too). Standalone from the cert-validation flow on edw/azure-cert-gen — that branch's cert.go integration uses these primitives but isn't part of this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * dns: follow CNAME chains in LookupTXT A TXT query against a hostname that's actually a CNAME returns only the CNAME RR — no TXT records — so RootResolver.LookupTXT must follow the alias, matching how LookupIPAddr handles A/AAAA over CNAME. DirectResolver.LookupTXT now captures any CNAME from the answer section and returns ErrCNAMEFound when the result has no TXT records; RootResolver.LookupTXT loops up to 10 times following each CNAME. Without this, _dnsauth.<host> -> CNAME -> Azure-managed validation record would silently 404, since the upstream wouldn't see the TXT sitting at the CNAME target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Edward J <edw@defang.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 861b50c commit c8d86a1

4 files changed

Lines changed: 60 additions & 0 deletions

File tree

src/pkg/cli/cert_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ func (mr *MockResolver) LookupNS(ctx context.Context, domain string) ([]*net.NS,
191191
mr.calls++
192192
return []*net.NS{{Host: ""}}, nil
193193
}
194+
func (mr *MockResolver) LookupTXT(ctx context.Context, domain string) ([]string, error) {
195+
mr.calls++
196+
return nil, nil
197+
}
194198

195199
func TestHttpClient(t *testing.T) {
196200
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

src/pkg/cli/client/byoc/aws/domain_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ func (r *CallbackMockResolver) LookupCNAME(ctx context.Context, domain string) (
248248
}
249249
return r.MockResolver.LookupCNAME(ctx, domain)
250250
}
251+
func (r *CallbackMockResolver) LookupTXT(ctx context.Context, domain string) ([]string, error) {
252+
if r.Callback != nil {
253+
r.Callback(domain)
254+
}
255+
return r.MockResolver.LookupTXT(ctx, domain)
256+
}
251257

252258
func TestPrepareDomainDelegation(t *testing.T) {
253259
ctx := t.Context()

src/pkg/dns/mock.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,6 @@ func (r MockResolver) LookupNS(ctx context.Context, domain string) ([]*net.NS, e
5656
ns, err := r.records(DNSRequest{Type: "NS", Domain: domain})
5757
return convert(ns, func(n string) *net.NS { return &net.NS{Host: n} }), err
5858
}
59+
func (r MockResolver) LookupTXT(ctx context.Context, domain string) ([]string, error) {
60+
return r.records(DNSRequest{Type: "TXT", Domain: domain})
61+
}

src/pkg/dns/resolver.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net"
88
"slices"
99
"sort"
10+
"strings"
1011

1112
"github.com/DefangLabs/defang/src/pkg"
1213
"github.com/miekg/dns"
@@ -16,6 +17,7 @@ type Resolver interface {
1617
LookupIPAddr(ctx context.Context, domain string) ([]net.IPAddr, error)
1718
LookupCNAME(ctx context.Context, domain string) (string, error)
1819
LookupNS(ctx context.Context, domain string) ([]*net.NS, error)
20+
LookupTXT(ctx context.Context, domain string) ([]string, error)
1921
}
2022

2123
type RootResolver struct{}
@@ -61,6 +63,21 @@ func (r RootResolver) LookupNS(ctx context.Context, domain string) ([]*net.NS, e
6163
return r.getResolver(ctx, domain).LookupNS(ctx, domain)
6264
}
6365

66+
func (r RootResolver) LookupTXT(ctx context.Context, domain string) ([]string, error) {
67+
for range 10 {
68+
txts, err := r.getResolver(ctx, domain).LookupTXT(ctx, domain)
69+
if err != nil {
70+
if cnameErr, ok := err.(ErrCNAMEFound); ok {
71+
domain = cnameErr.CNAME()
72+
continue
73+
}
74+
return nil, err
75+
}
76+
return txts, nil
77+
}
78+
return nil, errors.New("too many CNAME lookups")
79+
}
80+
6481
func (r RootResolver) getResolver(ctx context.Context, domain string) Resolver {
6582
ns, err := FindNSServers(ctx, domain)
6683
if err != nil {
@@ -176,6 +193,36 @@ func (r DirectResolver) LookupCNAME(ctx context.Context, domain string) (string,
176193
return "", ErrNoSuchHost
177194
}
178195

196+
func (r DirectResolver) LookupTXT(ctx context.Context, domain string) ([]string, error) {
197+
res, err := r.query(ctx, domain, dns.TypeTXT)
198+
if err != nil {
199+
return nil, err
200+
}
201+
202+
var result []string
203+
var cname string
204+
// A single TXT response can be split into multiple "strings" on the wire
205+
// (each ≤255 bytes); the convention is to concatenate them. miekg/dns
206+
// already returns each record's strings as a []string in TXT.Txt, so we
207+
// just join those and emit one entry per RR.
208+
for _, rr := range res.Answer {
209+
if t, ok := rr.(*dns.TXT); ok {
210+
result = append(result, strings.Join(t.Txt, ""))
211+
} else if cn, ok := rr.(*dns.CNAME); ok {
212+
cname = cn.Target
213+
}
214+
}
215+
if len(result) == 0 {
216+
// No TXT record but a CNAME — surface it so RootResolver can
217+
// follow the alias, matching LookupIPAddr's behavior.
218+
if cname != "" {
219+
return nil, ErrCNAMEFound(cname)
220+
}
221+
return nil, ErrNoSuchHost
222+
}
223+
return result, nil
224+
}
225+
179226
func (r DirectResolver) LookupNS(ctx context.Context, domain string) ([]*net.NS, error) {
180227
res, err := r.query(ctx, domain, dns.TypeNS)
181228
if err != nil {

0 commit comments

Comments
 (0)