All checks were successful
continuous-integration/drone/push Build is passing
Analogous to internal/provider, extract the service registry (Svc, RegisterService, FindService, ListServices, OrderedServices, FindSubService, RegisterSubServices) and the zone analyzer (ServiceAnalyzer, Analyzer, AnalyzeZone) from services/ into a new internal/service package.
345 lines
10 KiB
Go
345 lines
10 KiB
Go
// 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"
|
|
intsvc "git.happydns.org/happyDomain/internal/service"
|
|
"git.happydns.org/happyDomain/model"
|
|
_ "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 := intsvc.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 := intsvc.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 := intsvc.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)
|
|
}
|