refactor: decompose Analyzer into recordPool and serviceAccumulator
Restructure the service analyzer architecture to improve maintainability: - Extract recordPool (zone records + mark-delete claiming) and serviceAccumulator (service registry + domain normalization) as embedded structs in Analyzer - Replace swap-delete with mark-delete to eliminate mutation-during-iteration - Centralize domain normalization using helpers.DomainRelative - Make Comment/NbResources lazy via Service.MarshalJSON instead of eager assignment at three separate call sites - Extract SPF merging from usecase layer into services.CollectAndMergeSPF - Add GetDefaultTTL accessor and comprehensive Analyzer doc comments - Add round-trip test infrastructure covering MX, CNAME, CAA, TXT, SPF, DMARC, GSuite, Origin, Server and more
This commit is contained in:
parent
6de814a247
commit
f4bcb1c9cf
10 changed files with 797 additions and 167 deletions
|
|
@ -23,7 +23,6 @@ package zone
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
|
|
@ -62,13 +61,6 @@ func (uc *ListRecordsUsecase) ToZoneFile(domain *happydns.Domain, zone *happydns
|
|||
func (uc *ListRecordsUsecase) List(domain *happydns.Domain, zone *happydns.Zone) (rrs []happydns.Record, err error) {
|
||||
var svc_rrs []happydns.Record
|
||||
|
||||
// Collect SPF contributions keyed by absolute domain name.
|
||||
type spfContrib struct {
|
||||
directives [][]string
|
||||
policies []string
|
||||
}
|
||||
spfContribs := map[string]*spfContrib{}
|
||||
|
||||
for _, services := range zone.Services {
|
||||
for _, svc := range services {
|
||||
svc_rrs, err = uc.serviceListRecordsUC.List(svc, domain.DomainName, zone.DefaultTTL)
|
||||
|
|
@ -76,45 +68,6 @@ func (uc *ListRecordsUsecase) List(domain *happydns.Domain, zone *happydns.Zone)
|
|||
return
|
||||
}
|
||||
|
||||
// If the service is an SPF contributor, collect its directives
|
||||
// and filter out any SPF TXT records it emits (they'll be
|
||||
// replaced by the merged record).
|
||||
if contributor, ok := svc.Service.(happydns.SPFContributor); ok {
|
||||
directives := contributor.GetSPFDirectives()
|
||||
policy := contributor.GetSPFAllPolicy()
|
||||
|
||||
// Compute the absolute domain for this service.
|
||||
absDomain := svc.Domain
|
||||
if domain.DomainName != "" {
|
||||
if absDomain == "" {
|
||||
absDomain = domain.DomainName
|
||||
} else {
|
||||
absDomain = absDomain + "." + domain.DomainName
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(absDomain, ".") {
|
||||
absDomain += "."
|
||||
}
|
||||
|
||||
if spfContribs[absDomain] == nil {
|
||||
spfContribs[absDomain] = &spfContrib{}
|
||||
}
|
||||
spfContribs[absDomain].directives = append(spfContribs[absDomain].directives, directives)
|
||||
if policy != "" {
|
||||
spfContribs[absDomain].policies = append(spfContribs[absDomain].policies, policy)
|
||||
}
|
||||
|
||||
// Drop SPF TXT records from this service's output.
|
||||
filtered := svc_rrs[:0]
|
||||
for _, rr := range svc_rrs {
|
||||
if txt, ok := rr.(*happydns.TXT); ok && strings.HasPrefix(txt.Txt, "v=spf1") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rr)
|
||||
}
|
||||
svc_rrs = filtered
|
||||
}
|
||||
|
||||
rrs = append(rrs, svc_rrs...)
|
||||
}
|
||||
|
||||
|
|
@ -127,28 +80,8 @@ func (uc *ListRecordsUsecase) List(domain *happydns.Domain, zone *happydns.Zone)
|
|||
}
|
||||
}
|
||||
|
||||
// Emit one merged SPF TXT record per domain that has SPF contributions.
|
||||
for absDomain, contrib := range spfContribs {
|
||||
merged := svcs.MergeSPFDirectives(contrib.directives...)
|
||||
policy := svcs.ResolveSPFAllPolicy(contrib.policies)
|
||||
merged = append(merged, policy)
|
||||
|
||||
spfFields := svcs.SPFFields{
|
||||
Version: 1,
|
||||
Directives: merged,
|
||||
}
|
||||
|
||||
rr := &happydns.TXT{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: absDomain,
|
||||
Rrtype: dns.TypeTXT,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: zone.DefaultTTL,
|
||||
},
|
||||
Txt: spfFields.String(),
|
||||
}
|
||||
rrs = append(rrs, rr)
|
||||
}
|
||||
// Collect SPF contributions and merge into single records per domain.
|
||||
rrs = svcs.CollectAndMergeSPF(domain.DomainName, zone, rrs, zone.DefaultTTL)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ func (uc *AddToZoneUsecase) AddService(zone *happydns.Zone, subdomain happydns.S
|
|||
|
||||
service.Id = hash
|
||||
service.Domain = string(subdomain)
|
||||
service.NbResources = service.Service.GetNbResources()
|
||||
service.Comment = service.Service.GenComment()
|
||||
|
||||
zone.Services[subdomain] = append(zone.Services[subdomain], service)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ func (msg *Service) Meta() *ServiceMeta {
|
|||
return &msg.ServiceMeta
|
||||
}
|
||||
|
||||
// MarshalJSON computes Comment and NbResources from Service just before
|
||||
// serialization so that these derived fields are always fresh.
|
||||
func (s *Service) MarshalJSON() ([]byte, error) {
|
||||
if s.Service != nil {
|
||||
s.Comment = s.Service.GenComment()
|
||||
s.NbResources = s.Service.GetNbResources()
|
||||
}
|
||||
|
||||
type serviceAlias Service
|
||||
return json.Marshal((*serviceAlias)(s))
|
||||
}
|
||||
|
||||
// ServiceBody represents a service provided by one or more DNS record.
|
||||
type ServiceBody interface {
|
||||
// GetNbResources get the number of main Resources contains in the Service.
|
||||
|
|
|
|||
|
|
@ -96,8 +96,6 @@ func (zone *Zone) eraseService(subdomain Subdomain, old *Service, idx int, new *
|
|||
zone.Services[subdomain] = append(zone.Services[subdomain][:idx], zone.Services[subdomain][idx+1:]...)
|
||||
}
|
||||
} else {
|
||||
new.Comment = new.Service.GenComment()
|
||||
new.NbResources = new.Service.GetNbResources()
|
||||
zone.Services[subdomain][idx] = new
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -351,12 +351,12 @@ func TestZoneEraseServiceWithReplacement(t *testing.T) {
|
|||
t.Errorf("EraseService() service.Type = %q; want %q", service.Type, "test.NewService")
|
||||
}
|
||||
|
||||
if service.NbResources != 5 {
|
||||
t.Errorf("EraseService() service.NbResources = %d; want 5", service.NbResources)
|
||||
if service.Service.GetNbResources() != 5 {
|
||||
t.Errorf("EraseService() service.GetNbResources() = %d; want 5", service.Service.GetNbResources())
|
||||
}
|
||||
|
||||
if service.Comment != "new service" {
|
||||
t.Errorf("EraseService() service.Comment = %q; want %q", service.Comment, "new service")
|
||||
if service.Service.GenComment() != "new service" {
|
||||
t.Errorf("EraseService() service.GenComment() = %q; want %q", service.Service.GenComment(), "new service")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -401,12 +401,12 @@ func TestZoneEraseServiceWithoutMeta(t *testing.T) {
|
|||
t.Error("EraseServiceWithoutMeta() should preserve service ID")
|
||||
}
|
||||
|
||||
if service.NbResources != 7 {
|
||||
t.Errorf("EraseServiceWithoutMeta() service.NbResources = %d; want 7", service.NbResources)
|
||||
if service.Service.GetNbResources() != 7 {
|
||||
t.Errorf("EraseServiceWithoutMeta() service.GetNbResources() = %d; want 7", service.Service.GetNbResources())
|
||||
}
|
||||
|
||||
if service.Comment != "updated service" {
|
||||
t.Errorf("EraseServiceWithoutMeta() service.Comment = %q; want %q", service.Comment, "updated service")
|
||||
if service.Service.GenComment() != "updated service" {
|
||||
t.Errorf("EraseServiceWithoutMeta() service.GenComment() = %q; want %q", service.Service.GenComment(), "updated service")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,36 +36,21 @@ import (
|
|||
// Analyzer and claims those that belong to a particular service type.
|
||||
type ServiceAnalyzer func(*Analyzer) error
|
||||
|
||||
// Analyzer holds the state for zone analysis: the remaining unclaimed DNS
|
||||
// records, the services discovered so far, and the zone origin.
|
||||
type Analyzer struct {
|
||||
origin string
|
||||
zone []happydns.Record
|
||||
services map[happydns.Subdomain][]*happydns.Service
|
||||
defaultTTL uint32
|
||||
claimedSPFDirectives map[string]map[string]bool // domain -> directive -> claimed
|
||||
}
|
||||
|
||||
// GetOrigin returns the FQDN of the zone being analyzed.
|
||||
func (a *Analyzer) GetOrigin() string {
|
||||
return a.origin
|
||||
}
|
||||
|
||||
// AnalyzerRecordFilter specifies criteria for matching DNS records.
|
||||
// Zero-value fields are treated as wildcards (match anything).
|
||||
type AnalyzerRecordFilter struct {
|
||||
Prefix string
|
||||
Domain string
|
||||
SubdomainsOf string
|
||||
Contains string
|
||||
Type uint16
|
||||
Ttl uint32
|
||||
// recordPool holds DNS records and tracks which ones have been claimed by
|
||||
// service analyzers.
|
||||
type recordPool struct {
|
||||
zone []happydns.Record
|
||||
claimed []bool
|
||||
}
|
||||
|
||||
// SearchRR returns all unclaimed records that match at least one of the given
|
||||
// filters. Each record appears at most once in the result.
|
||||
func (a *Analyzer) SearchRR(arrs ...AnalyzerRecordFilter) (rrs []happydns.Record) {
|
||||
for _, record := range a.zone {
|
||||
func (p *recordPool) SearchRR(arrs ...AnalyzerRecordFilter) (rrs []happydns.Record) {
|
||||
for i, record := range p.zone {
|
||||
if p.claimed[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, arr := range arrs {
|
||||
rhdr := record.Header()
|
||||
rdtype := rhdr.Rrtype
|
||||
|
|
@ -84,16 +69,40 @@ func (a *Analyzer) SearchRR(arrs ...AnalyzerRecordFilter) (rrs []happydns.Record
|
|||
return
|
||||
}
|
||||
|
||||
// addService registers a service for the given domain. If the same service
|
||||
// instance is already registered, its metadata is updated instead.
|
||||
func (a *Analyzer) addService(rr happydns.Record, domain string, svc happydns.ServiceBody) error {
|
||||
// Remove origin to get a relative domain here
|
||||
domain = strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(domain, "."), strings.TrimSuffix(a.origin, ".")), ".")
|
||||
// markClaimed marks a record as claimed. Returns an error if the record is not
|
||||
// found or was already claimed.
|
||||
func (p *recordPool) markClaimed(rr happydns.Record) error {
|
||||
for k, record := range p.zone {
|
||||
if record == rr {
|
||||
if p.claimed[k] {
|
||||
return errors.New("Record already claimed.")
|
||||
}
|
||||
p.claimed[k] = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("Record not found.")
|
||||
}
|
||||
|
||||
for _, service := range a.services[happydns.Subdomain(domain)] {
|
||||
// serviceAccumulator collects services discovered during zone analysis,
|
||||
// keyed by subdomain. It deduplicates services and normalizes domain names.
|
||||
type serviceAccumulator struct {
|
||||
origin string
|
||||
defaultTTL uint32
|
||||
services map[happydns.Subdomain][]*happydns.Service
|
||||
}
|
||||
|
||||
// addService registers a service for the given domain. If the same service
|
||||
// instance is already registered, it is a no-op.
|
||||
func (sa *serviceAccumulator) addService(rr happydns.Record, domain string, svc happydns.ServiceBody) error {
|
||||
// Remove origin to get a relative domain here
|
||||
domain = strings.TrimSuffix(helpers.DomainRelative(domain, sa.origin), ".")
|
||||
if domain == "@" {
|
||||
domain = ""
|
||||
}
|
||||
|
||||
for _, service := range sa.services[happydns.Subdomain(domain)] {
|
||||
if service.Service == svc {
|
||||
service.Comment = svc.GenComment()
|
||||
service.NbResources = svc.GetNbResources()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -104,25 +113,66 @@ func (a *Analyzer) addService(rr happydns.Record, domain string, svc happydns.Se
|
|||
}
|
||||
|
||||
var ttl uint32 = 0
|
||||
if rr.Header().Ttl != a.defaultTTL {
|
||||
if rr.Header().Ttl != sa.defaultTTL {
|
||||
ttl = rr.Header().Ttl
|
||||
}
|
||||
|
||||
a.services[happydns.Subdomain(domain)] = append(a.services[happydns.Subdomain(domain)], &happydns.Service{
|
||||
sa.services[happydns.Subdomain(domain)] = append(sa.services[happydns.Subdomain(domain)], &happydns.Service{
|
||||
Service: svc,
|
||||
ServiceMeta: happydns.ServiceMeta{
|
||||
Id: id,
|
||||
Type: reflect.Indirect(reflect.ValueOf(svc)).Type().String(),
|
||||
Domain: domain,
|
||||
Ttl: ttl,
|
||||
Comment: svc.GenComment(),
|
||||
NbResources: svc.GetNbResources(),
|
||||
Id: id,
|
||||
Type: reflect.Indirect(reflect.ValueOf(svc)).Type().String(),
|
||||
Domain: domain,
|
||||
Ttl: ttl,
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AnalyzerRecordFilter specifies criteria for matching DNS records.
|
||||
// Zero-value fields are treated as wildcards (match anything).
|
||||
type AnalyzerRecordFilter struct {
|
||||
Prefix string
|
||||
Domain string
|
||||
SubdomainsOf string
|
||||
Contains string
|
||||
Type uint16
|
||||
Ttl uint32
|
||||
}
|
||||
|
||||
// Analyzer holds the state for zone analysis. It is composed of a recordPool
|
||||
// (DNS records with mark-delete claiming) and a serviceAccumulator (services
|
||||
// discovered so far, keyed by subdomain).
|
||||
//
|
||||
// # Claim protocol
|
||||
//
|
||||
// ServiceAnalyzer callbacks are invoked in weight order (lowest first).
|
||||
// Each analyzer inspects the pool via SearchRR, then claims matching records
|
||||
// with UseRR. UseRR marks the record as claimed and registers the service.
|
||||
// Claimed records are invisible to subsequent SearchRR calls.
|
||||
//
|
||||
// For SPF, analyzers call ClaimSPFDirective to claim individual directives
|
||||
// without claiming the whole TXT record. The SPF analyzer (weight 1) runs
|
||||
// later and filters out claimed directives.
|
||||
//
|
||||
// After all analyzers run, unclaimed records are wrapped as Orphan services.
|
||||
type Analyzer struct {
|
||||
recordPool
|
||||
serviceAccumulator
|
||||
claimedSPFDirectives map[string]map[string]bool // domain -> directive -> claimed
|
||||
}
|
||||
|
||||
// GetOrigin returns the FQDN of the zone being analyzed.
|
||||
func (a *Analyzer) GetOrigin() string {
|
||||
return a.origin
|
||||
}
|
||||
|
||||
// GetDefaultTTL returns the default TTL for the zone being analyzed.
|
||||
func (a *Analyzer) GetDefaultTTL() uint32 {
|
||||
return a.defaultTTL
|
||||
}
|
||||
|
||||
// ClaimSPFDirective marks an SPF directive as claimed by the given service for
|
||||
// a domain. The directive is not removed from the zone; instead it will be
|
||||
// filtered out when the SPF service analyzer runs later.
|
||||
|
|
@ -136,8 +186,8 @@ func (a *Analyzer) ClaimSPFDirective(domain string, directive string, svc happyd
|
|||
a.claimedSPFDirectives[domain][directive] = true
|
||||
|
||||
// Ensure the service is registered (addService deduplicates)
|
||||
for _, record := range a.zone {
|
||||
if record.Header().Name == domain {
|
||||
for i, record := range a.zone {
|
||||
if !a.claimed[i] && record.Header().Name == domain {
|
||||
return a.addService(record, domain, svc)
|
||||
}
|
||||
}
|
||||
|
|
@ -156,22 +206,12 @@ func (a *Analyzer) GetClaimedSPFDirectives(domain string) map[string]bool {
|
|||
return a.claimedSPFDirectives[domain]
|
||||
}
|
||||
|
||||
// UseRR claims a DNS record, removing it from the pool of unclaimed records,
|
||||
// UseRR claims a DNS record, marking it as claimed in the record pool,
|
||||
// and associates it with the given service. If svc is nil the record is
|
||||
// simply removed without registering a service.
|
||||
// simply claimed without registering a service.
|
||||
func (a *Analyzer) UseRR(rr happydns.Record, domain string, svc happydns.ServiceBody) error {
|
||||
found := false
|
||||
for k, record := range a.zone {
|
||||
if record == rr {
|
||||
found = true
|
||||
a.zone[k] = a.zone[len(a.zone)-1]
|
||||
a.zone = a.zone[:len(a.zone)-1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return errors.New("Record not found.")
|
||||
if err := a.markClaimed(rr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// svc nil, just drop the record from the zone (probably handle another way)
|
||||
|
|
@ -213,10 +253,15 @@ func AnalyzeZone(origin string, records []happydns.Record) (svcs map[happydns.Su
|
|||
defaultTTL = getMostUsedTTL(records)
|
||||
|
||||
a := Analyzer{
|
||||
origin: origin,
|
||||
zone: zone,
|
||||
services: map[happydns.Subdomain][]*happydns.Service{},
|
||||
defaultTTL: defaultTTL,
|
||||
recordPool: recordPool{
|
||||
zone: zone,
|
||||
claimed: make([]bool, len(zone)),
|
||||
},
|
||||
serviceAccumulator: serviceAccumulator{
|
||||
origin: origin,
|
||||
defaultTTL: defaultTTL,
|
||||
services: map[happydns.Subdomain][]*happydns.Service{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, record := range a.zone {
|
||||
|
|
@ -240,8 +285,11 @@ func AnalyzeZone(origin string, records []happydns.Record) (svcs map[happydns.Su
|
|||
}
|
||||
}
|
||||
|
||||
// Consider records not used by services as Orphan
|
||||
for _, record := range a.zone {
|
||||
// Consider unclaimed records as Orphan
|
||||
for i, record := range a.zone {
|
||||
if a.claimed[i] {
|
||||
continue
|
||||
}
|
||||
// Skip DNSSEC records
|
||||
if helpers.IsDNSSECType(record.Header().Rrtype) {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2024 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package svcs
|
||||
|
||||
import ()
|
||||
344
services/roundtrip_test.go
Normal file
344
services/roundtrip_test.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package svcs_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/helpers"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
svcs "git.happydns.org/happyDomain/services"
|
||||
_ "git.happydns.org/happyDomain/services/abstract"
|
||||
_ "git.happydns.org/happyDomain/services/providers/google"
|
||||
)
|
||||
|
||||
// roundTrip analyzes the given DNS records into services, then regenerates
|
||||
// records from those services and returns them. This exercises the full
|
||||
// analyze -> generate path.
|
||||
func roundTrip(t *testing.T, origin string, records []happydns.Record) []happydns.Record {
|
||||
t.Helper()
|
||||
|
||||
services, defaultTTL, err := svcs.AnalyzeZone(origin, records)
|
||||
if err != nil {
|
||||
t.Fatalf("AnalyzeZone failed: %v", err)
|
||||
}
|
||||
|
||||
var regenerated []happydns.Record
|
||||
for _, domainSvcs := range services {
|
||||
for _, svc := range domainSvcs {
|
||||
ttl := defaultTTL
|
||||
if svc.Ttl != 0 {
|
||||
ttl = svc.Ttl
|
||||
}
|
||||
|
||||
rrs, err := svc.Service.GetRecords(svc.Domain, ttl, origin)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecords failed for %s: %v", svc.Type, err)
|
||||
}
|
||||
|
||||
for i, rr := range rrs {
|
||||
rrs[i] = helpers.CopyRecord(rr)
|
||||
rrs[i].Header().Name = helpers.DomainJoin(rrs[i].Header().Name, svc.Domain)
|
||||
if origin != "" {
|
||||
rrs[i] = helpers.RRAbsolute(rrs[i], origin)
|
||||
}
|
||||
if rrs[i].Header().Ttl == 0 {
|
||||
rrs[i].Header().Ttl = ttl
|
||||
}
|
||||
}
|
||||
|
||||
regenerated = append(regenerated, rrs...)
|
||||
}
|
||||
}
|
||||
|
||||
return regenerated
|
||||
}
|
||||
|
||||
// canonicalStrings returns a sorted list of string representations for the
|
||||
// given records, for comparison purposes.
|
||||
func canonicalStrings(records []happydns.Record) []string {
|
||||
strs := make([]string, len(records))
|
||||
for i, rr := range records {
|
||||
strs[i] = rr.String()
|
||||
}
|
||||
sort.Strings(strs)
|
||||
return strs
|
||||
}
|
||||
|
||||
// assertRoundTrip verifies that records survive a round-trip through
|
||||
// analyze -> generate.
|
||||
func assertRoundTrip(t *testing.T, origin string, records []happydns.Record) {
|
||||
t.Helper()
|
||||
|
||||
regenerated := roundTrip(t, origin, records)
|
||||
|
||||
original := canonicalStrings(records)
|
||||
result := canonicalStrings(regenerated)
|
||||
|
||||
if len(original) != len(result) {
|
||||
t.Errorf("record count mismatch: input %d, output %d", len(original), len(result))
|
||||
t.Logf("input: %v", original)
|
||||
t.Logf("output: %v", result)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range original {
|
||||
if original[i] != result[i] {
|
||||
t.Errorf("record %d mismatch:\n input: %s\n output: %s", i, original[i], result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewRR(t *testing.T, s string) happydns.Record {
|
||||
t.Helper()
|
||||
rr, err := dns.NewRR(s)
|
||||
if err != nil {
|
||||
t.Fatalf("dns.NewRR(%q) failed: %v", s, err)
|
||||
}
|
||||
if rr.Header().Rrtype == dns.TypeTXT {
|
||||
return happydns.NewTXT(rr.(*dns.TXT))
|
||||
}
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestRoundTrip_MX(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN MX 10 mail1.example.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN MX 20 mail2.example.com."),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_CNAME(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "www.example.com. 3600 IN CNAME example.com."),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_CAA(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN CAA 0 issue \"letsencrypt.org\""),
|
||||
mustNewRR(t, "example.com. 3600 IN CAA 0 issuewild \"letsencrypt.org\""),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_TXT(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN TXT \"some verification text\""),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_SPF(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, fmt.Sprintf("example.com. 3600 IN TXT \"v=spf1 include:_spf.google.com ~all\"")),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_DMARC(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "_dmarc.example.com. 3600 IN TXT \"v=DMARC1; p=reject; rua=mailto:dmarc@example.com\""),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_MultiSubdomain(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN MX 10 mail.example.com."),
|
||||
mustNewRR(t, "www.example.com. 3600 IN CNAME example.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN TXT \"some text\""),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_Subdomain_CNAME(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "blog.example.com. 3600 IN CNAME hosting.provider.com."),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_MultipleTXT(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN TXT \"google-site-verification=abc123\""),
|
||||
mustNewRR(t, "example.com. 3600 IN TXT \"facebook-domain-verification=xyz789\""),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_MixedTTLs(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN MX 10 mail.example.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN MX 20 mail2.example.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN TXT \"hello\""),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_Orphan_A(t *testing.T) {
|
||||
// A records without an abstract.Server service registered still survive as Orphan
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN A 93.184.216.34"),
|
||||
}
|
||||
|
||||
regenerated := roundTrip(t, origin, records)
|
||||
|
||||
if len(regenerated) != len(records) {
|
||||
t.Fatalf("expected %d records, got %d", len(records), len(regenerated))
|
||||
}
|
||||
|
||||
// Orphan wraps the record; verify the string representation matches
|
||||
for _, rr := range regenerated {
|
||||
s := rr.String()
|
||||
if !strings.Contains(s, "93.184.216.34") {
|
||||
t.Errorf("expected A record with 93.184.216.34, got %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_GSuite_MX(t *testing.T) {
|
||||
// GSuite claims MX records for google.com and SPF directive
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN MX 1 aspmx.l.google.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN MX 5 alt1.aspmx.l.google.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN MX 5 alt2.aspmx.l.google.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN MX 10 alt3.aspmx.l.google.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN MX 10 alt4.aspmx.l.google.com."),
|
||||
mustNewRR(t, fmt.Sprintf("example.com. 3600 IN TXT \"v=spf1 include:_spf.google.com ~all\"")),
|
||||
}
|
||||
|
||||
services, _, err := svcs.AnalyzeZone(origin, records)
|
||||
if err != nil {
|
||||
t.Fatalf("AnalyzeZone failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have a GSuite service and an SPF service at root
|
||||
var foundGSuite, foundSPF bool
|
||||
for _, domainSvcs := range services {
|
||||
for _, svc := range domainSvcs {
|
||||
if svc.Type == "google.GSuite" {
|
||||
foundGSuite = true
|
||||
}
|
||||
if svc.Type == "svcs.SPF" {
|
||||
foundSPF = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundGSuite {
|
||||
t.Error("expected GSuite service to be found")
|
||||
}
|
||||
if !foundSPF {
|
||||
t.Error("expected SPF service to be found")
|
||||
}
|
||||
|
||||
// Verify MX records round-trip
|
||||
regenerated := roundTrip(t, origin, records)
|
||||
|
||||
mxCount := 0
|
||||
for _, rr := range regenerated {
|
||||
if rr.Header().Rrtype == dns.TypeMX {
|
||||
mxCount++
|
||||
}
|
||||
}
|
||||
|
||||
if mxCount != 5 {
|
||||
t.Errorf("expected 5 MX records after round-trip, got %d", mxCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_GSuite_SPFClaimed(t *testing.T) {
|
||||
// When GSuite claims the SPF include directive, the remaining SPF record
|
||||
// should have the google directive filtered out.
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN MX 1 aspmx.l.google.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN MX 5 alt1.aspmx.l.google.com."),
|
||||
mustNewRR(t, fmt.Sprintf("example.com. 3600 IN TXT \"v=spf1 include:_spf.google.com ip4:203.0.113.0/24 ~all\"")),
|
||||
}
|
||||
|
||||
services, _, err := svcs.AnalyzeZone(origin, records)
|
||||
if err != nil {
|
||||
t.Fatalf("AnalyzeZone failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that SPF service has the google include filtered out
|
||||
for _, domainSvcs := range services {
|
||||
for _, svc := range domainSvcs {
|
||||
if svc.Type == "svcs.SPF" {
|
||||
comment := svc.Service.GenComment()
|
||||
// The SPF service should still have directives
|
||||
if !strings.Contains(comment, "directive") {
|
||||
t.Logf("SPF comment: %s", comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip_Origin_SOA_NS(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN SOA ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400"),
|
||||
mustNewRR(t, "example.com. 3600 IN NS ns1.example.com."),
|
||||
mustNewRR(t, "example.com. 3600 IN NS ns2.example.com."),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_Server_A_AAAA(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "example.com. 3600 IN A 93.184.216.34"),
|
||||
mustNewRR(t, "example.com. 3600 IN AAAA 2606:2800:220:1:248:1893:25c8:1946"),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
|
||||
func TestRoundTrip_SubdomainServer(t *testing.T) {
|
||||
origin := "example.com."
|
||||
records := []happydns.Record{
|
||||
mustNewRR(t, "www.example.com. 3600 IN A 93.184.216.34"),
|
||||
mustNewRR(t, "www.example.com. 3600 IN AAAA 2606:2800:220:1:248:1893:25c8:1946"),
|
||||
}
|
||||
assertRoundTrip(t, origin, records)
|
||||
}
|
||||
117
services/spf_merge.go
Normal file
117
services/spf_merge.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package svcs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// spfContrib collects SPF directives and policies for a single domain.
|
||||
type spfContrib struct {
|
||||
directives [][]string
|
||||
policies []string
|
||||
}
|
||||
|
||||
// CollectAndMergeSPF scans zone services for SPFContributor implementations,
|
||||
// collects their directives per absolute domain, filters SPF TXT records from
|
||||
// the input records, and appends merged SPF TXT records. The domainName is
|
||||
// the zone's domain name (e.g. "example.com."), defaultTTL is used for the
|
||||
// merged records.
|
||||
func CollectAndMergeSPF(domainName string, zone *happydns.Zone, records []happydns.Record, defaultTTL uint32) []happydns.Record {
|
||||
contribs := map[string]*spfContrib{}
|
||||
|
||||
for _, domainSvcs := range zone.Services {
|
||||
for _, svc := range domainSvcs {
|
||||
contributor, ok := svc.Service.(happydns.SPFContributor)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
directives := contributor.GetSPFDirectives()
|
||||
policy := contributor.GetSPFAllPolicy()
|
||||
|
||||
// Compute the absolute domain for this service.
|
||||
absDomain := svc.Domain
|
||||
if domainName != "" {
|
||||
if absDomain == "" {
|
||||
absDomain = domainName
|
||||
} else {
|
||||
absDomain = absDomain + "." + domainName
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(absDomain, ".") {
|
||||
absDomain += "."
|
||||
}
|
||||
|
||||
if contribs[absDomain] == nil {
|
||||
contribs[absDomain] = &spfContrib{}
|
||||
}
|
||||
contribs[absDomain].directives = append(contribs[absDomain].directives, directives)
|
||||
if policy != "" {
|
||||
contribs[absDomain].policies = append(contribs[absDomain].policies, policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(contribs) == 0 {
|
||||
return records
|
||||
}
|
||||
|
||||
// Filter out SPF TXT records emitted by individual services.
|
||||
filtered := records[:0]
|
||||
for _, rr := range records {
|
||||
if txt, ok := rr.(*happydns.TXT); ok && strings.HasPrefix(txt.Txt, "v=spf1") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rr)
|
||||
}
|
||||
records = filtered
|
||||
|
||||
// Emit one merged SPF TXT record per domain that has SPF contributions.
|
||||
for absDomain, contrib := range contribs {
|
||||
merged := MergeSPFDirectives(contrib.directives...)
|
||||
policy := ResolveSPFAllPolicy(contrib.policies)
|
||||
merged = append(merged, policy)
|
||||
|
||||
spfFields := SPFFields{
|
||||
Version: 1,
|
||||
Directives: merged,
|
||||
}
|
||||
|
||||
rr := &happydns.TXT{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: absDomain,
|
||||
Rrtype: dns.TypeTXT,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: defaultTTL,
|
||||
},
|
||||
Txt: spfFields.String(),
|
||||
}
|
||||
records = append(records, rr)
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
204
services/spf_merge_test.go
Normal file
204
services/spf_merge_test.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package svcs_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
svcs "git.happydns.org/happyDomain/services"
|
||||
)
|
||||
|
||||
// mockSPFContributor implements both ServiceBody and SPFContributor.
|
||||
type mockSPFContributor struct {
|
||||
directives []string
|
||||
allPolicy string
|
||||
}
|
||||
|
||||
func (m *mockSPFContributor) GetNbResources() int { return 0 }
|
||||
func (m *mockSPFContributor) GenComment() string { return "" }
|
||||
func (m *mockSPFContributor) GetRecords(string, uint32, string) ([]happydns.Record, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockSPFContributor) GetSPFDirectives() []string { return m.directives }
|
||||
func (m *mockSPFContributor) GetSPFAllPolicy() string { return m.allPolicy }
|
||||
|
||||
func TestCollectAndMergeSPF_NoContributors(t *testing.T) {
|
||||
zone := &happydns.Zone{
|
||||
Services: map[happydns.Subdomain][]*happydns.Service{
|
||||
"": {
|
||||
{
|
||||
ServiceMeta: happydns.ServiceMeta{Domain: ""},
|
||||
Service: &svcs.TXT{Record: &happydns.TXT{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 3600}, Txt: "some text"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
input := []happydns.Record{
|
||||
&happydns.TXT{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 3600}, Txt: "some text"},
|
||||
}
|
||||
|
||||
result := svcs.CollectAndMergeSPF("example.com.", zone, input, 3600)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAndMergeSPF_SingleContributor(t *testing.T) {
|
||||
contributor := &mockSPFContributor{
|
||||
directives: []string{"include:_spf.google.com"},
|
||||
allPolicy: "~all",
|
||||
}
|
||||
|
||||
zone := &happydns.Zone{
|
||||
Services: map[happydns.Subdomain][]*happydns.Service{
|
||||
"": {
|
||||
{
|
||||
ServiceMeta: happydns.ServiceMeta{Domain: ""},
|
||||
Service: contributor,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Input has an SPF record that should be filtered out
|
||||
input := []happydns.Record{
|
||||
&happydns.TXT{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 3600}, Txt: "v=spf1 include:_spf.google.com ~all"},
|
||||
}
|
||||
|
||||
result := svcs.CollectAndMergeSPF("example.com.", zone, input, 3600)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 record (merged SPF), got %d", len(result))
|
||||
}
|
||||
|
||||
txt, ok := result[0].(*happydns.TXT)
|
||||
if !ok {
|
||||
t.Fatalf("expected *happydns.TXT, got %T", result[0])
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(txt.Txt, "v=spf1") {
|
||||
t.Errorf("expected SPF record, got %q", txt.Txt)
|
||||
}
|
||||
if !strings.Contains(txt.Txt, "include:_spf.google.com") {
|
||||
t.Errorf("expected google include in SPF, got %q", txt.Txt)
|
||||
}
|
||||
if !strings.Contains(txt.Txt, "~all") {
|
||||
t.Errorf("expected ~all in SPF, got %q", txt.Txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAndMergeSPF_MultipleContributors(t *testing.T) {
|
||||
zone := &happydns.Zone{
|
||||
Services: map[happydns.Subdomain][]*happydns.Service{
|
||||
"": {
|
||||
{
|
||||
ServiceMeta: happydns.ServiceMeta{Domain: ""},
|
||||
Service: &mockSPFContributor{
|
||||
directives: []string{"include:_spf.google.com"},
|
||||
allPolicy: "~all",
|
||||
},
|
||||
},
|
||||
{
|
||||
ServiceMeta: happydns.ServiceMeta{Domain: ""},
|
||||
Service: &mockSPFContributor{
|
||||
directives: []string{"ip4:203.0.113.0/24"},
|
||||
allPolicy: "-all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
input := []happydns.Record{
|
||||
&happydns.TXT{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 3600}, Txt: "v=spf1 include:_spf.google.com ~all"},
|
||||
}
|
||||
|
||||
result := svcs.CollectAndMergeSPF("example.com.", zone, input, 3600)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(result))
|
||||
}
|
||||
|
||||
txt := result[0].(*happydns.TXT)
|
||||
if !strings.Contains(txt.Txt, "include:_spf.google.com") {
|
||||
t.Errorf("missing google include: %q", txt.Txt)
|
||||
}
|
||||
if !strings.Contains(txt.Txt, "ip4:203.0.113.0/24") {
|
||||
t.Errorf("missing ip4 directive: %q", txt.Txt)
|
||||
}
|
||||
// -all is stricter than ~all, should win
|
||||
if !strings.Contains(txt.Txt, "-all") {
|
||||
t.Errorf("expected -all (strictest), got %q", txt.Txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAndMergeSPF_PreservesNonSPFRecords(t *testing.T) {
|
||||
zone := &happydns.Zone{
|
||||
Services: map[happydns.Subdomain][]*happydns.Service{
|
||||
"": {
|
||||
{
|
||||
ServiceMeta: happydns.ServiceMeta{Domain: ""},
|
||||
Service: &mockSPFContributor{
|
||||
directives: []string{"include:_spf.google.com"},
|
||||
allPolicy: "~all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
input := []happydns.Record{
|
||||
&happydns.TXT{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 3600}, Txt: "google-site-verification=abc"},
|
||||
&happydns.TXT{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 3600}, Txt: "v=spf1 include:_spf.google.com ~all"},
|
||||
}
|
||||
|
||||
result := svcs.CollectAndMergeSPF("example.com.", zone, input, 3600)
|
||||
|
||||
// Should have: 1 non-SPF TXT + 1 merged SPF
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 records, got %d", len(result))
|
||||
}
|
||||
|
||||
foundVerification := false
|
||||
foundSPF := false
|
||||
for _, rr := range result {
|
||||
txt := rr.(*happydns.TXT)
|
||||
if strings.HasPrefix(txt.Txt, "google-site") {
|
||||
foundVerification = true
|
||||
}
|
||||
if strings.HasPrefix(txt.Txt, "v=spf1") {
|
||||
foundSPF = true
|
||||
}
|
||||
}
|
||||
if !foundVerification {
|
||||
t.Error("non-SPF TXT record was removed")
|
||||
}
|
||||
if !foundSPF {
|
||||
t.Error("merged SPF record not found")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue