Repository: StackExchange/dnscontrol
Pull request #2037 (2023-02-01 01:36:51)

Expect SOA mailbox in hostmaster@example.org format instead of hostmaster.example.org
As discussed in https://github.com/StackExchange/dnscontrol/pull/2023#issuecomment-1410645574

Yannik: @tlimoncelli I have extracted the function and added tests as you suggested.
tlimoncelli: Looks good! 

Patch:
diff --git a/documentation/functions/domain/SOA.md b/documentation/functions/domain/SOA.md
index f4258cf2..1c85dae1 100644
--- a/documentation/functions/domain/SOA.md
+++ b/documentation/functions/domain/SOA.md
@@ -24,12 +24,14 @@ parameter_types:
 
 ```javascript
 D("example.com", REG_THIRDPARTY, DnsProvider("DNS_BIND"),
-  SOA("@", "ns3.example.org.", "hostmaster.example.org.", 3600, 600, 604800, 1440),
+  SOA("@", "ns3.example.org.", "hostmaster@example.org", 3600, 600, 604800, 1440),
 );
 ```
 
-## Notes
+The email address should be specified like a normal RFC822/RFC5322 address (user@hostname.com). It will be converted into the required format (e.g. BIND format: `user.hostname.com`) by the provider as required. This has the benefit of being more human-readable plus DNSControl can properly handle escaping and other issues.
 
+## Notes
+* Previously, the accepted format for the SOA mailbox field was `hostmaster.example.org`. This has been changed to `hostmaster@example.org`
 * The serial number is managed automatically.  It isn't even a field in `SOA()`.
 * Most providers automatically generate SOA records.  They will ignore any `SOA()` statements.
 
diff --git a/pkg/soautil/soautil.go b/pkg/soautil/soautil.go
new file mode 100644
index 00000000..e99a930e
--- /dev/null
+++ b/pkg/soautil/soautil.go
@@ -0,0 +1,11 @@
+package soautil
+
+import "strings"
+
+func RFC5322MailToBind(rfc5322Mail string) string {
+	res := strings.SplitN(rfc5322Mail, "@", 2)
+	user_part, domain_part := res[0], res[1]
+	// RFC-1035 [Section-8]
+	user_part = strings.ReplaceAll(user_part, ".", "\\.")
+	return user_part + "." + domain_part
+}
diff --git a/providers/bind/soa.go b/providers/bind/soa.go
index c057e4ec..3c54be58 100644
--- a/providers/bind/soa.go
+++ b/providers/bind/soa.go
@@ -1,6 +1,11 @@
 package bind
 
-import "github.com/StackExchange/dnscontrol/v3/models"
+import (
+	"fmt"
+	"github.com/StackExchange/dnscontrol/v3/models"
+	"github.com/StackExchange/dnscontrol/v3/pkg/soautil"
+	"strings"
+)
 
 func makeSoa(origin string, defSoa *SoaDefaults, existing, desired *models.RecordConfig) (*models.RecordConfig, uint32) {
 	// Create a SOA record.  Take data from desired, existing, default,
@@ -19,10 +24,18 @@ func makeSoa(origin string, defSoa *SoaDefaults, existing, desired *models.Recor
 		desired = &models.RecordConfig{}
 	}
 
+	soaMail := firstNonNull(desired.SoaMbox, existing.SoaMbox, defSoa.Mbox, "DEFAULT_NOT_SET.")
+	if strings.Contains(soaMail, "@") {
+		soaMail = soautil.RFC5322MailToBind(soaMail)
+	} else {
+		fmt.Println("WARNING: SOA hostmaster address must be in the format hostmaster@example.com")
+		fmt.Println("WARNING: hostmaster.example.com is deprecated and will be dropped in a future version")
+	}
+
 	soaRec.TTL = firstNonZero(desired.TTL, defSoa.TTL, existing.TTL, models.DefaultTTL)
 	soaRec.SetTargetSOA(
 		firstNonNull(desired.GetTargetField(), existing.GetTargetField(), defSoa.Ns, "DEFAULT_NOT_SET."),
-		firstNonNull(desired.SoaMbox, existing.SoaMbox, defSoa.Mbox, "DEFAULT_NOT_SET."),
+		soaMail,
 		firstNonZero(desired.SoaSerial, existing.SoaSerial, defSoa.Serial, 1),
 		firstNonZero(desired.SoaRefresh, existing.SoaRefresh, defSoa.Refresh, 3600),
 		firstNonZero(desired.SoaRetry, existing.SoaRetry, defSoa.Retry, 600),


Test patch:
diff --git a/pkg/soautil/soautil_test.go b/pkg/soautil/soautil_test.go
new file mode 100644
index 00000000..ca0e76b8
--- /dev/null
+++ b/pkg/soautil/soautil_test.go
@@ -0,0 +1,28 @@
+package soautil
+
+import (
+	"reflect"
+	"testing"
+)
+
+func Test_RFC5322MailToBind(t *testing.T) {
+	type args struct {
+		rfc5322Mail string
+	}
+	tests := []struct {
+		name        string
+		rfc5322Mail string
+		bindMail    string
+	}{
+		{"0", "hostmaster@example.com", "hostmaster.example.com"},
+		{"1", "admin.dns@example.com", "admin\\.dns.example.com"},
+		{"2", "hostmaster@sub.example.com", "hostmaster.sub.example.com"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := RFC5322MailToBind(tt.rfc5322Mail); !reflect.DeepEqual(got, tt.bindMail) {
+				t.Errorf("RFC5322MailToBind(%v) = %v, want %v", tt.rfc5322Mail, got, tt.bindMail)
+			}
+		})
+	}
+}
diff --git a/providers/bind/soa_test.go b/providers/bind/soa_test.go
index d1147a54..40b1fb92 100644
--- a/providers/bind/soa_test.go
+++ b/providers/bind/soa_test.go
@@ -32,7 +32,7 @@ func Test_makeSoa(t *testing.T) {
 		},
 		{
 			// If everything is filled, leave the desired values in place.
-			&SoaDefaults{"ns.example.com", "root.example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
+			&SoaDefaults{"ns.example.com", "root@example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
 			mkRC("a", &models.RecordConfig{SoaMbox: "aa", SoaSerial: 10, SoaRefresh: 11, SoaRetry: 12, SoaExpire: 13, SoaMinttl: 14}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "bb", SoaSerial: 15, SoaRefresh: 16, SoaRetry: 17, SoaExpire: 18, SoaMinttl: 19}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "bb", SoaSerial: 15, SoaRefresh: 16, SoaRetry: 17, SoaExpire: 18, SoaMinttl: 19}),
@@ -40,7 +40,7 @@ func Test_makeSoa(t *testing.T) {
 		},
 		{
 			// Test incrementing serial.
-			&SoaDefaults{"ns.example.com", "root.example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
+			&SoaDefaults{"ns.example.com", "root@example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
 			mkRC("a", &models.RecordConfig{SoaMbox: "aa", SoaSerial: 2019022301, SoaRefresh: 11, SoaRetry: 12, SoaExpire: 13, SoaMinttl: 14}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "bb", SoaSerial: 0, SoaRefresh: 16, SoaRetry: 17, SoaExpire: 18, SoaMinttl: 19}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "bb", SoaSerial: 2019022301, SoaRefresh: 16, SoaRetry: 17, SoaExpire: 18, SoaMinttl: 19}),
@@ -48,7 +48,7 @@ func Test_makeSoa(t *testing.T) {
 		},
 		{
 			// Test incrementing serial_2.
-			&SoaDefaults{"ns.example.com", "root.example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
+			&SoaDefaults{"ns.example.com", "root@example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
 			mkRC("a", &models.RecordConfig{SoaMbox: "aa", SoaSerial: 0, SoaRefresh: 11, SoaRetry: 12, SoaExpire: 13, SoaMinttl: 14}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "bb", SoaSerial: 2019022304, SoaRefresh: 16, SoaRetry: 17, SoaExpire: 18, SoaMinttl: 19}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "bb", SoaSerial: 2019022304, SoaRefresh: 16, SoaRetry: 17, SoaExpire: 18, SoaMinttl: 19}),
@@ -56,7 +56,7 @@ func Test_makeSoa(t *testing.T) {
 		},
 		{
 			// If there are gaps in existing or desired, fill in as appropriate.
-			&SoaDefaults{"ns.example.com", "root.example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
+			&SoaDefaults{"ns.example.com", "root@example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
 			mkRC("", &models.RecordConfig{SoaMbox: "aa", SoaSerial: 0, SoaRefresh: 11, SoaRetry: 0, SoaExpire: 13, SoaMinttl: 0}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "", SoaSerial: 15, SoaRefresh: 0, SoaRetry: 17, SoaExpire: 0, SoaMinttl: 19}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "aa", SoaSerial: 15, SoaRefresh: 11, SoaRetry: 17, SoaExpire: 13, SoaMinttl: 19}),
@@ -64,7 +64,7 @@ func Test_makeSoa(t *testing.T) {
 		},
 		{
 			// Gaps + existing==nil
-			&SoaDefaults{"ns.example.com", "root.example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
+			&SoaDefaults{"ns.example.com", "root@example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
 			nil,
 			mkRC("b", &models.RecordConfig{SoaMbox: "", SoaSerial: 15, SoaRefresh: 0, SoaRetry: 17, SoaExpire: 0, SoaMinttl: 19}),
 			mkRC("b", &models.RecordConfig{SoaMbox: "root.example.com", SoaSerial: 15, SoaRefresh: 2, SoaRetry: 17, SoaExpire: 4, SoaMinttl: 19}),
@@ -73,7 +73,7 @@ func Test_makeSoa(t *testing.T) {
 		{
 			// Gaps + desired==nil
 			// NB(tom): In the code as of 2020-02-23, desired will never be nil.
-			&SoaDefaults{"ns.example.com", "root.example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
+			&SoaDefaults{"ns.example.com", "root@example.com", 1, 2, 3, 4, 5, models.DefaultTTL},
 			mkRC("", &models.RecordConfig{SoaMbox: "aa", SoaSerial: 0, SoaRefresh: 11, SoaRetry: 0, SoaExpire: 13, SoaMinttl: 0}),
 			nil,
 			mkRC("ns.example.com", &models.RecordConfig{SoaMbox: "aa", SoaSerial: 1, SoaRefresh: 11, SoaRetry: 3, SoaExpire: 13, SoaMinttl: 5}),