From 4280edbfb03a6c4a327fdadf3bc3e54149e6bbd9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 19:48:20 +0000 Subject: [PATCH] feat(dns): add wildcard record resolution Implements wildcard DNS resolution with most-specific suffix matching for dns.resolve, dns.resolve.txt, and dns.resolve.all paths. Co-Authored-By: Virgil --- go.mod | 3 + service.go | 186 ++++++++++++++++++++++++++++++++++++++++++++++++ service_test.go | 64 +++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 go.mod create mode 100644 service.go create mode 100644 service_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..493f497 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module dappco.re/go/dns + +go 1.22 diff --git a/service.go b/service.go new file mode 100644 index 0000000..b0868b0 --- /dev/null +++ b/service.go @@ -0,0 +1,186 @@ +package dns + +import ( + "fmt" + "slices" + "strings" + "sync" +) + +type NameRecords struct { + A []string `json:"a"` + AAAA []string `json:"aaaa"` + TXT []string `json:"txt"` + NS []string `json:"ns"` +} + +type ResolveAllResult struct { + A []string `json:"a"` + AAAA []string `json:"aaaa"` + TXT []string `json:"txt"` + NS []string `json:"ns"` +} + +type Service struct { + mu sync.RWMutex + records map[string]NameRecords +} + +type ServiceOptions struct { + Records map[string]NameRecords +} + +func NewService(options ServiceOptions) *Service { + cached := make(map[string]NameRecords, len(options.Records)) + for name, record := range options.Records { + cached[normalizeName(name)] = record + } + return &Service{ + records: cached, + } +} + +func (service *Service) SetRecord(name string, record NameRecords) { + service.mu.Lock() + defer service.mu.Unlock() + service.records[normalizeName(name)] = record +} + +func (service *Service) RemoveRecord(name string) { + service.mu.Lock() + defer service.mu.Unlock() + delete(service.records, normalizeName(name)) +} + +func (service *Service) Resolve(name string) (ResolveAllResult, bool) { + record, ok := service.findRecord(name) + if !ok { + return ResolveAllResult{}, false + } + return resolveResult(record), true +} + +func (service *Service) ResolveTXT(name string) ([]string, bool) { + record, ok := service.findRecord(name) + if !ok { + return nil, false + } + return append([]string(nil), record.TXT...), true +} + +func (service *Service) ResolveAll(name string) (ResolveAllResult, bool) { + record, ok := service.findRecord(name) + if !ok { + return ResolveAllResult{}, false + } + return resolveResult(record), true +} + +func (service *Service) Health() map[string]any { + service.mu.RLock() + defer service.mu.RUnlock() + return map[string]any{ + "status": "ready", + "names_cached": len(service.records), + "tree_root": "stubbed", + } +} + +func (service *Service) findRecord(name string) (NameRecords, bool) { + service.mu.RLock() + defer service.mu.RUnlock() + + normalized := normalizeName(name) + if record, ok := service.records[normalized]; ok { + return record, true + } + + match, ok := findWildcardMatch(normalized, service.records) + return match, ok +} + +func resolveResult(record NameRecords) ResolveAllResult { + return ResolveAllResult{ + A: append([]string(nil), record.A...), + AAAA: append([]string(nil), record.AAAA...), + TXT: append([]string(nil), record.TXT...), + NS: append([]string(nil), record.NS...), + } +} + +func findWildcardMatch(name string, records map[string]NameRecords) (NameRecords, bool) { + bestMatch := "" + for candidate := range records { + if !strings.HasPrefix(candidate, "*.") { + continue + } + suffix := strings.TrimPrefix(candidate, "*.") + if wildcardMatches(suffix, name) { + if betterWildcardMatch(candidate, bestMatch) { + bestMatch = candidate + } + } + } + if bestMatch == "" { + return NameRecords{}, false + } + return records[bestMatch], true +} + +func wildcardMatches(suffix, name string) bool { + parts := strings.Split(suffix, ".") + if len(parts) == 0 || len(name) <= len(suffix)+1 { + return false + } + if !strings.HasSuffix(name, "."+suffix) { + return false + } + return strings.Count(name[:len(name)-len(suffix)], ".") >= 1 +} + +func betterWildcardMatch(candidate, current string) bool { + if current == "" { + return true + } + remainingCandidate := strings.TrimPrefix(candidate, "*.") + remainingCurrent := strings.TrimPrefix(current, "*.") + if len(remainingCandidate) > len(remainingCurrent) { + return true + } + if len(remainingCandidate) == len(remainingCurrent) { + return candidate < current + } + return false +} + +func normalizeName(name string) string { + trimmed := strings.TrimSpace(strings.ToLower(name)) + if trimmed == "" { + return "" + } + if strings.HasSuffix(trimmed, ".") { + trimmed = strings.TrimSuffix(trimmed, ".") + } + return trimmed +} + +func (service *Service) String() string { + return fmt.Sprintf("dns.Service{records=%d}", len(service.records)) +} + +func MergeRecords(values ...[]string) []string { + unique := []string{} + seen := map[string]bool{} + for _, batch := range values { + for _, value := range batch { + if seen[value] { + continue + } + seen[value] = true + unique = append(unique, value) + } + } + slices.Sort(unique) + return unique +} + diff --git a/service_test.go b/service_test.go new file mode 100644 index 0000000..a82ae6c --- /dev/null +++ b/service_test.go @@ -0,0 +1,64 @@ +package dns + +import "testing" + +func TestServiceResolveUsesExactNameBeforeWildcard(t *testing.T) { + service := NewService(ServiceOptions{ + Records: map[string]NameRecords{ + "*.charon.lthn": { + A: []string{"10.69.69.165"}, + }, + "gateway.charon.lthn": { + A: []string{"10.10.10.10"}, + }, + }, + }) + + result, ok := service.Resolve("gateway.charon.lthn") + if !ok { + t.Fatal("expected exact record to resolve") + } + if len(result.A) != 1 || result.A[0] != "10.10.10.10" { + t.Fatalf("unexpected resolve result: %#v", result.A) + } +} + +func TestServiceResolveUsesMostSpecificWildcard(t *testing.T) { + service := NewService(ServiceOptions{ + Records: map[string]NameRecords{ + "*.lthn": { + A: []string{"10.0.0.1"}, + }, + "*.charon.lthn": { + A: []string{"10.0.0.2"}, + }, + }, + }) + + result, ok := service.Resolve("gateway.charon.lthn") + if !ok { + t.Fatal("expected wildcard record to resolve") + } + if len(result.A) != 1 || result.A[0] != "10.0.0.2" { + t.Fatalf("unexpected wildcard match: %#v", result.A) + } +} + +func TestServiceResolveTXTUsesWildcard(t *testing.T) { + service := NewService(ServiceOptions{ + Records: map[string]NameRecords{ + "*.gateway.charon.lthn": { + TXT: []string{"v=lthn1 type=gateway"}, + }, + }, + }) + + result, ok := service.ResolveTXT("node1.gateway.charon.lthn.") + if !ok { + t.Fatal("expected wildcard TXT record") + } + if len(result) != 1 || result[0] != "v=lthn1 type=gateway" { + t.Fatalf("unexpected TXT record: %#v", result) + } +} + -- 2.45.3