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:
nemunaire 2026-03-14 00:05:45 +07:00
commit f4bcb1c9cf
10 changed files with 797 additions and 167 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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.

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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

View file

@ -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
View 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
View 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
View 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")
}
}