Repository: StackExchange/dnscontrol
Pull request #1115 (2021-03-31 10:14:24)

Direct SOA record management
This adds the SOA record macro to JS configs.
It introduces a feature lock (CanUseSOA) plus validation checks I thought reasonable.

tlimoncelli: Looks good so far!

Please add this to the "matrix" variable in `dnscontrol/build/generate/featureMatrix.go` (see https://stackexchange.github.io/dnscontrol/adding-new-rtypes)

patschi: Just a random thought: IIRC in case of INWX the serial number will be generated/managed on the provider-side and will be overwritten there. So I guess in dnscontrol it would be sufficient to simply specify any value then. Not sure if there should be any specific handling when serial number is managed server-side, or when only target and soambox are managed?
tlimoncelli: I propose we don't let the user set the serial number and just manage it ourselves automatically.

My first thought was that if the user sets the serial number to 0 that indicates they want dnscontrol to magically take care of it.  BIND "takes care of it" by setting it to `max(YYYYMMMDD00, current+1)`.  If "current" is "unknown", assume it is zero.  This works surprisingly well.

What if the user **does** specify a (non-zero) serial number?  Now the user has to remember to increment the serial number every time they update dnsconfig.js.   Personally I'd hate to do that.  More likely I would simply forget to update it.  In fact, I used to forget all the time when I was hand-editing BIND zone files.  I would spend an hour trying to figure out why no updates happened, then realize my mistake, get frustrated, and eventually just give up and write a `sed` script to do it for me... then [HostDB](https://www.usenix.org/legacy/event/lisa05/tech/limoncelli.html), then [DNSControl](https://www.usenix.org/conference/srecon17americas/program/presentation/peterson).  See the pattern? ;-)

I can't imagine a scenario where we would want to make the user edit a serial number every time they modify dnsconfig.js.

So why not just always automatically manage the serial number?  (i.e. don't even ask the user for one)
fuero: > I propose we don't let the user set the serial number and just manage it ourselves automatically.

Sounds like a good idea - I'll change the PR accordingly.
fuero: I'd just like a heads up, is the code ok so far, or should I make changes?
tlimoncelli: The code is ok so far!  

You should do a `go generate` to remake `pkg/js/static.go` (you'll hit that when you rebase).

You should also reformat the using gofmt:

```
gofmt -s -w $(find * -name \*.go -print)
```

fuero: ok, have another look.
I used `gofmt -s -w $(git diff master --name-only | grep -Ee '\.go$')` to reformat the code.
tlimoncelli: Is that 'Ok, merge into master" or "Ok, leave it as a separate branch until it's 100% done"?
fuero: It's finished in my opinion.
If you are satisfied with it, merge it, otherwise advise changes please :-)

Patch:
diff --git a/build/generate/featureMatrix.go b/build/generate/featureMatrix.go
index aa4af3f9..1bfd4580 100644
--- a/build/generate/featureMatrix.go
+++ b/build/generate/featureMatrix.go
@@ -34,6 +34,7 @@ func generateFeatureMatrix() error {
 			{"CAA", "Provider can manage CAA records"},
 			{"PTR", "Provider supports adding PTR records for reverse lookup zones"},
 			{"NAPTR", "Provider can manage NAPTR records"},
+			{"SOA", "Provider can manage SOA records"},
 			{"SRV", "Driver has explicitly implemented SRV record management"},
 			{"SSHFP", "Provider can manage SSHFP records"},
 			{"TLSA", "Provider can manage TLSA records"},
@@ -83,6 +84,7 @@ func generateFeatureMatrix() error {
 		setCap("PTR", providers.CanUsePTR)
 		setCap("R53_ALIAS", providers.CanUseRoute53Alias)
 		setCap("AZURE_ALIAS", providers.CanUseAzureAlias)
+		setCap("SOA", providers.CanUseSOA)
 		setCap("SRV", providers.CanUseSRV)
 		setCap("SSHFP", providers.CanUseSSHFP)
 		setCap("TLSA", providers.CanUseTLSA)
diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html
index 684057fc..73daf775 100644
--- a/docs/_includes/matrix.html
+++ b/docs/_includes/matrix.html
@@ -746,6 +746,44 @@
 		<td><i class="fa fa-minus dim"></i></td>
 		<td><i class="fa fa-minus dim"></i></td>
 		</tr>
+	<tr>
+		<th class="row-header" style="text-decoration: underline;" data-toggle="tooltip" data-container="body" data-placement="top" title="Provider can manage SOA records">SOA</th>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		<td><i class="fa fa-minus dim"></i></td>
+		</tr>
 	<tr>
 		<th class="row-header" style="text-decoration: underline;" data-toggle="tooltip" data-container="body" data-placement="top" title="Driver has explicitly implemented SRV record management">SRV</th>
 		<td class="danger">
diff --git a/models/record.go b/models/record.go
index 0ed0a43b..1137afd1 100644
--- a/models/record.go
+++ b/models/record.go
@@ -26,6 +26,7 @@ import (
 //     NS
 //     PTR
 //     SRV
+//     SOA
 //     SSHFP
 //     TLSA
 //     TXT
diff --git a/pkg/js/helpers.js b/pkg/js/helpers.js
index 789e0cca..2d90e159 100644
--- a/pkg/js/helpers.js
+++ b/pkg/js/helpers.js
@@ -357,6 +357,28 @@ var NAPTR = recordBuilder('NAPTR', {
     },
 });
 
+// SOA(name,ns,mbox,serial,refresh,retry,expire,minimum, recordModifiers...)
+var SOA = recordBuilder('SOA', {
+  args: [
+    ['name',    _.isString],
+    ['target',  _.isString],
+    ['mbox',    _.isString],
+    ['refresh', _.isNumber],
+    ['retry',   _.isNumber],
+    ['expire',  _.isNumber],
+    ['minttl',  _.isNumber],
+  ],
+  transform: function(record, args, modifiers) {
+    record.name = args.name;
+    record.target = args.target;
+    record.soambox = args.mbox;
+    record.soarefresh = args.refresh;
+    record.soaretry   = args.retry;
+    record.soaexpire  = args.expire;
+    record.soaminttl = args.minttl;
+  },
+});
+
 // SRV(name,priority,weight,port,target, recordModifiers...)
 var SRV = recordBuilder('SRV', {
     args: [
diff --git a/pkg/js/static.go b/pkg/js/static.go
index 6fa59154..88f74059 100644
--- a/pkg/js/static.go
+++ b/pkg/js/static.go
@@ -212,24 +212,24 @@ var _escData = map[string]*_escFile{
 	"/helpers.js": {
 		name:    "helpers.js",
 		local:   "pkg/js/helpers.js",
-		size:    32454,
+		size:    33082,
 		modtime: 0,
 		compressed: `
 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
-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
+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
-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
+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
@@ -262,98 +262,101 @@ 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
-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
+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
+BAAA//+k69kyOoEAAA==
 `,
 	},
 }
diff --git a/pkg/normalize/validate.go b/pkg/normalize/validate.go
index e8650faf..201ca204 100644
--- a/pkg/normalize/validate.go
+++ b/pkg/normalize/validate.go
@@ -57,6 +57,7 @@ func validateRecordTypes(rec *models.RecordConfig, domain string, pTypes []strin
 		"TLSA":             true,
 		"IMPORT_TRANSFORM": false,
 		"MX":               true,
+		"SOA":              true,
 		"SRV":              true,
 		"SSHFP":            true,
 		"TXT":              true,
@@ -124,6 +125,28 @@ func checkLabel(label string, rType string, target, domain string, meta map[stri
 	return nil
 }
 
+func checkSoa(expire uint32, minttl uint32, refresh uint32, retry uint32, serial uint32, mbox string) error {
+	if expire <= 0 {
+		return fmt.Errorf("SOA Expire must be > 0")
+	}
+	if minttl <= 0 {
+		return fmt.Errorf("SOA Minimum TTL must be > 0")
+	}
+	if refresh <= 0 {
+		return fmt.Errorf("SOA Refresh must be > 0")
+	}
+	if retry <= 0 {
+		return fmt.Errorf("SOA Retry must be > 0")
+	}
+	if mbox == "" {
+		return fmt.Errorf("SOA MBox must be specified")
+	}
+	if strings.ContainsRune(mbox, '@') {
+		return fmt.Errorf("SOA MBox must have '.' instead of '@'")
+	}
+	return nil
+}
+
 // checkTargets returns true if rec.Target is valid for the rec.Type.
 func checkTargets(rec *models.RecordConfig, domain string) (errs []error) {
 	label := rec.GetLabel()
@@ -161,7 +184,11 @@ func checkTargets(rec *models.RecordConfig, domain string) (errs []error) {
 	case "ALIAS":
 		check(checkTarget(target))
 	case "SOA":
+		check(checkSoa(rec.SoaExpire, rec.SoaMinttl, rec.SoaRefresh, rec.SoaRetry, rec.SoaSerial, rec.SoaMbox))
 		check(checkTarget(target))
+		if label != "@" {
+			check(fmt.Errorf("SOA record is only valid for bare domain."))
+		}
 	case "SRV":
 		check(checkTarget(target))
 	case "TXT", "IMPORT_TRANSFORM", "CAA", "SSHFP", "TLSA", "DS":
@@ -524,6 +551,7 @@ var providerCapabilityChecks = []pairTypeCapability{
 	capabilityCheck("PTR", providers.CanUsePTR),
 	capabilityCheck("R53_ALIAS", providers.CanUseRoute53Alias),
 	capabilityCheck("SSHFP", providers.CanUseSSHFP),
+	capabilityCheck("SOA", providers.CanUseSOA),
 	capabilityCheck("SRV", providers.CanUseSRV),
 	capabilityCheck("TLSA", providers.CanUseTLSA),
 	capabilityCheck("AZURE_ALIAS", providers.CanUseAzureAlias),
diff --git a/providers/capabilities.go b/providers/capabilities.go
index 9b5c9161..58a239fe 100644
--- a/providers/capabilities.go
+++ b/providers/capabilities.go
@@ -66,6 +66,9 @@ const (
 
 	// CanUseAzureAlias indicates the provider support the specific Azure_ALIAS records that only the Azure provider supports
 	CanUseAzureAlias
+
+	// CanUseSOA indicates the provider supports full management of a zone's SOA record
+	CanUseSOA
 )
 
 var providerCapabilities = map[string]map[Capability]bool{}
diff --git a/providers/capability_string.go b/providers/capability_string.go
index de38aa4c..dfb759eb 100644
--- a/providers/capability_string.go
+++ b/providers/capability_string.go
@@ -25,11 +25,12 @@ func _() {
 	_ = x[CanUseRoute53Alias-14]
 	_ = x[CanGetZones-15]
 	_ = x[CanUseAzureAlias-16]
+	_ = x[CanUseSOA-17]
 }
 
-const _Capability_name = "CanUseAliasCanUseCAACanUseDSCanUseDSForChildrenCanUsePTRCanUseNAPTRCanUseSRVCanUseSSHFPCanUseTLSACanAutoDNSSECCantUseNOPURGEDocOfficiallySupportedDocDualHostDocCreateDomainsCanUseRoute53AliasCanGetZonesCanUseAzureAlias"
+const _Capability_name = "CanUseAliasCanUseCAACanUseDSCanUseDSForChildrenCanUsePTRCanUseNAPTRCanUseSRVCanUseSSHFPCanUseTLSACanAutoDNSSECCantUseNOPURGEDocOfficiallySupportedDocDualHostDocCreateDomainsCanUseRoute53AliasCanGetZonesCanUseAzureAliasCanUseSOA"
 
-var _Capability_index = [...]uint8{0, 11, 20, 28, 47, 56, 67, 76, 87, 97, 110, 124, 146, 157, 173, 191, 202, 218}
+var _Capability_index = [...]uint8{0, 11, 20, 28, 47, 56, 67, 76, 87, 97, 110, 124, 146, 157, 173, 191, 202, 218, 227}
 
 func (i Capability) String() string {
 	if i >= Capability(len(_Capability_index)-1) {


Test patch:
diff --git a/pkg/js/parse_tests/038-soa.js b/pkg/js/parse_tests/038-soa.js
new file mode 100644
index 00000000..5f943750
--- /dev/null
+++ b/pkg/js/parse_tests/038-soa.js
@@ -0,0 +1,3 @@
+D("foo.com","none",
+    SOA('@', 'ns1.foo.com.', 'admin.foo.com', 3600, 900, 604800, 86400)
+);
diff --git a/pkg/js/parse_tests/038-soa.json b/pkg/js/parse_tests/038-soa.json
new file mode 100644
index 00000000..325d56e5
--- /dev/null
+++ b/pkg/js/parse_tests/038-soa.json
@@ -0,0 +1,23 @@
+{
+  "registrars": [],
+  "dns_providers": [],
+  "domains": [
+    {
+      "name": "foo.com",
+      "registrar": "none",
+      "dnsProviders": {},
+      "records": [
+        {
+          "type": "SOA",
+          "name": "@",
+          "target": "ns1.foo.com.",
+          "soambox": "admin.foo.com",
+          "soarefresh": 3600,
+          "soaexpire": 604800,
+          "soaretry": 900,
+          "soaminttl": 86400
+        }
+      ]
+    }
+  ]
+}
diff --git a/pkg/js/parse_tests/038-soa/foo.com.zone b/pkg/js/parse_tests/038-soa/foo.com.zone
new file mode 100644
index 00000000..8c29f287
--- /dev/null
+++ b/pkg/js/parse_tests/038-soa/foo.com.zone
@@ -0,0 +1,2 @@
+$TTL 300
+@                IN SOA   ns1.foo.com. admin.foo.com 0 3600 900 604800 86400
diff --git a/pkg/normalize/validate_test.go b/pkg/normalize/validate_test.go
index 39f0e97b..ff2de7af 100644
--- a/pkg/normalize/validate_test.go
+++ b/pkg/normalize/validate_test.go
@@ -9,6 +9,73 @@ import (
 	"github.com/StackExchange/dnscontrol/v3/providers"
 )
 
+func TestSoaLabelAndTarget(t *testing.T) {
+	var tests = []struct {
+		isError bool
+		label   string
+		target  string
+	}{
+		{false, "@", "ns1.foo.com."},
+		// Invalid target
+		{true, "@", "ns1.foo.com"},
+		// Invalid label, only '@' is allowed for SOA records
+		{true, "foo.com", "ns1.foo.com."},
+	}
+	for _, test := range tests {
+		experiment := fmt.Sprintf("%s %s", test.label, test.target)
+		rc := makeRC(test.label, "foo.com", test.target, models.RecordConfig{Type: "SOA",
+			SoaExpire: 1, SoaMinttl: 1, SoaRefresh: 1, SoaRetry: 1, SoaSerial: 1, SoaMbox: "bar.foo.com"})
+		err := checkTargets(rc, "foo.com")
+		if err != nil && !test.isError {
+			t.Errorf("%v: Error (%v)\n", experiment, err)
+		}
+		if err == nil && test.isError {
+			t.Errorf("%v: Expected error but got none \n", experiment)
+		}
+	}
+}
+
+func TestCheckSoa(t *testing.T) {
+	var tests = []struct {
+		isError bool
+		expire  uint32
+		minttl  uint32
+		refresh uint32
+		retry   uint32
+		serial  uint32
+		mbox    string
+	}{
+		// Expire
+		{false, 123, 123, 123, 123, 123, "foo.bar.com."},
+		{true, 0, 123, 123, 123, 123, "foo.bar.com."},
+		// MinTTL
+		{false, 123, 123, 123, 123, 123, "foo.bar.com."},
+		{true, 123, 0, 123, 123, 123, "foo.bar.com."},
+		// Refresh
+		{false, 123, 123, 123, 123, 123, "foo.bar.com."},
+		{true, 123, 123, 0, 123, 123, "foo.bar.com."},
+		// Retry
+		{false, 123, 123, 123, 123, 123, "foo.bar.com."},
+		{true, 123, 123, 123, 0, 123, "foo.bar.com."},
+		// Serial
+		{false, 123, 123, 123, 123, 123, "foo.bar.com."},
+		{false, 123, 123, 123, 123, 0, "foo.bar.com."},
+		// MBox
+		{true, 123, 123, 123, 123, 123, ""},
+		{true, 123, 123, 123, 123, 123, "foo@bar.com."},
+		{false, 123, 123, 123, 123, 123, "foo.bar.com."},
+	}
+
+	for _, test := range tests {
+		experiment := fmt.Sprintf("%d %d %d %d %d %s", test.expire, test.minttl, test.refresh,
+			test.retry, test.serial, test.mbox)
+		t.Run(experiment, func(t *testing.T) {
+			err := checkSoa(test.expire, test.minttl, test.refresh, test.retry, test.serial, test.mbox)
+			checkError(t, err, test.isError, experiment)
+		})
+	}
+}
+
 func TestCheckLabel(t *testing.T) {
 	var tests = []struct {
 		label       string