[client] Add support to wildcard custom records (#5125)

* **New Features**
  * Wildcard DNS fallback for eligible query types (excluding NS/SOA): attempts wildcard records when no exact match, rewrites wildcard names back to the original query, and rotates responses; preserves CNAME resolution.

* **Tests**
  * Vastly expanded coverage for wildcard behaviors, precedence, multi-record round‑robin, multi-type chains, multi-hop and cross-zone scenarios, and edge cases (NXDOMAIN/NODATA, fallthrough).

* **Chores**
  * CI lint config updated to ignore an additional codespell entry.
This commit is contained in:
Maycon Santos
2026-01-20 17:21:25 +01:00
committed by GitHub
parent 4888021ba6
commit 202fa47f2b
3 changed files with 1290 additions and 7 deletions

View File

@@ -19,7 +19,7 @@ jobs:
- name: codespell - name: codespell
uses: codespell-project/actions-codespell@v2 uses: codespell-project/actions-codespell@v2
with: with:
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans
skip: go.mod,go.sum skip: go.mod,go.sum
golangci: golangci:
strategy: strategy:

View File

@@ -120,7 +120,7 @@ func (d *Resolver) determineRcode(question dns.Question, result lookupResult) in
} }
// No records found, but domain exists with different record types (NODATA) // No records found, but domain exists with different record types (NODATA)
if d.hasRecordsForDomain(domain.Domain(question.Name)) { if d.hasRecordsForDomain(domain.Domain(question.Name), question.Qtype) {
return dns.RcodeSuccess return dns.RcodeSuccess
} }
@@ -164,11 +164,15 @@ func (d *Resolver) continueToNext(logger *log.Entry, w dns.ResponseWriter, r *dn
} }
// hasRecordsForDomain checks if any records exist for the given domain name regardless of type // hasRecordsForDomain checks if any records exist for the given domain name regardless of type
func (d *Resolver) hasRecordsForDomain(domainName domain.Domain) bool { func (d *Resolver) hasRecordsForDomain(domainName domain.Domain, qType uint16) bool {
d.mu.RLock() d.mu.RLock()
defer d.mu.RUnlock() defer d.mu.RUnlock()
_, exists := d.domains[domainName] _, exists := d.domains[domainName]
if !exists && supportsWildcard(qType) {
testWild := transformDomainToWildcard(string(domainName))
_, exists = d.domains[domain.Domain(testWild)]
}
return exists return exists
} }
@@ -195,6 +199,12 @@ type lookupResult struct {
func (d *Resolver) lookupRecords(logger *log.Entry, question dns.Question) lookupResult { func (d *Resolver) lookupRecords(logger *log.Entry, question dns.Question) lookupResult {
d.mu.RLock() d.mu.RLock()
records, found := d.records[question] records, found := d.records[question]
usingWildcard := false
wildQuestion := transformToWildcard(question)
if !found && supportsWildcard(question.Qtype) {
records, found = d.records[wildQuestion]
usingWildcard = found
}
if !found { if !found {
d.mu.RUnlock() d.mu.RUnlock()
@@ -216,18 +226,53 @@ func (d *Resolver) lookupRecords(logger *log.Entry, question dns.Question) looku
// if there's more than one record, rotate them (round-robin) // if there's more than one record, rotate them (round-robin)
if len(recordsCopy) > 1 { if len(recordsCopy) > 1 {
d.mu.Lock() d.mu.Lock()
records = d.records[question] q := question
if usingWildcard {
q = wildQuestion
}
records = d.records[q]
if len(records) > 1 { if len(records) > 1 {
first := records[0] first := records[0]
records = append(records[1:], first) records = append(records[1:], first)
d.records[question] = records d.records[q] = records
} }
d.mu.Unlock() d.mu.Unlock()
} }
if usingWildcard {
return responseFromWildRecords(question.Name, wildQuestion.Name, recordsCopy)
}
return lookupResult{records: recordsCopy, rcode: dns.RcodeSuccess} return lookupResult{records: recordsCopy, rcode: dns.RcodeSuccess}
} }
func transformToWildcard(question dns.Question) dns.Question {
wildQuestion := question
wildQuestion.Name = transformDomainToWildcard(wildQuestion.Name)
return wildQuestion
}
func transformDomainToWildcard(domain string) string {
s := strings.Split(domain, ".")
s[0] = "*"
return strings.Join(s, ".")
}
func supportsWildcard(queryType uint16) bool {
return queryType != dns.TypeNS && queryType != dns.TypeSOA
}
func responseFromWildRecords(originalName, wildName string, wildRecords []dns.RR) lookupResult {
records := make([]dns.RR, len(wildRecords))
for i, record := range wildRecords {
copiedRecord := dns.Copy(record)
copiedRecord.Header().Name = originalName
records[i] = copiedRecord
}
return lookupResult{records: records, rcode: dns.RcodeSuccess}
}
// lookupCNAMEChain follows a CNAME chain and returns the CNAME records along with // lookupCNAMEChain follows a CNAME chain and returns the CNAME records along with
// the final resolved record of the requested type. This is required for musl libc // the final resolved record of the requested type. This is required for musl libc
// compatibility, which expects the full answer chain rather than just the CNAME. // compatibility, which expects the full answer chain rather than just the CNAME.
@@ -237,6 +282,13 @@ func (d *Resolver) lookupCNAMEChain(logger *log.Entry, cnameQuestion dns.Questio
for range maxDepth { for range maxDepth {
cnameRecords := d.getRecords(cnameQuestion) cnameRecords := d.getRecords(cnameQuestion)
if len(cnameRecords) == 0 && supportsWildcard(targetType) {
wildQuestion := transformToWildcard(cnameQuestion)
if wildRecords := d.getRecords(wildQuestion); len(wildRecords) > 0 {
cnameRecords = responseFromWildRecords(cnameQuestion.Name, wildQuestion.Name, wildRecords).records
}
}
if len(cnameRecords) == 0 { if len(cnameRecords) == 0 {
break break
} }
@@ -303,7 +355,7 @@ func (d *Resolver) resolveCNAMETarget(logger *log.Entry, targetName string, targ
} }
// domain exists locally but not this record type (NODATA) // domain exists locally but not this record type (NODATA)
if d.hasRecordsForDomain(domain.Domain(targetName)) { if d.hasRecordsForDomain(domain.Domain(targetName), targetType) {
return lookupResult{rcode: dns.RcodeSuccess} return lookupResult{rcode: dns.RcodeSuccess}
} }

File diff suppressed because it is too large Load Diff