Repository: StackExchange/dnscontrol
Pull request #1165 (2021-05-30 18:35:37)

Improve MSDNS naptr support


Patch:
diff --git a/commands/getZones.go b/commands/getZones.go
index b37dfff8..c95a8222 100644
--- a/commands/getZones.go
+++ b/commands/getZones.go
@@ -1,6 +1,7 @@
 package commands
 
 import (
+	"encoding/json"
 	"fmt"
 	"os"
 	"strings"
@@ -249,6 +250,16 @@ func GetZone(args GetZoneArgs) error {
 	return nil
 }
 
+// jsonQuoted returns a properly escaped JSON string (without quotes).
+func jsonQuoted(i string) string {
+	// https://stackoverflow.com/questions/51691901
+	b, err := json.Marshal(i)
+	if err != nil {
+		panic(err)
+	}
+	return string(b)
+}
+
 func formatDsl(zonename string, rec *models.RecordConfig, defaultTTL uint32) string {
 
 	target := rec.GetTargetCombined()
@@ -272,6 +283,15 @@ func formatDsl(zonename string, rec *models.RecordConfig, defaultTTL uint32) str
 		return makeCaa(rec, ttlop)
 	case "MX":
 		target = fmt.Sprintf("%d, '%s'", rec.MxPreference, rec.GetTargetField())
+	case "NAPTR":
+		target = fmt.Sprintf(`%d, %d, %s, %s, %s, %s`,
+			rec.NaptrOrder,                   // 1
+			rec.NaptrPreference,              // 10
+			jsonQuoted(rec.NaptrFlags),       // U
+			jsonQuoted(rec.NaptrService),     // E2U+sip
+			jsonQuoted(rec.NaptrRegexp),      // regex
+			jsonQuoted(rec.GetTargetField()), // .
+		)
 	case "SSHFP":
 		target = fmt.Sprintf("%d, %d, '%s'", rec.SshfpAlgorithm, rec.SshfpFingerprint, rec.GetTargetField())
 	case "SOA":
diff --git a/models/t_naptr.go b/models/t_naptr.go
index 9aae5d36..24517dc6 100644
--- a/models/t_naptr.go
+++ b/models/t_naptr.go
@@ -8,6 +8,9 @@ import (
 
 // SetTargetNAPTR sets the NAPTR fields.
 func (rc *RecordConfig) SetTargetNAPTR(order uint16, preference uint16, flags string, service string, regexp string, target string) error {
+	if target == "" {
+		target = "."
+	}
 	rc.NaptrOrder = order
 	rc.NaptrPreference = preference
 	rc.NaptrFlags = flags
diff --git a/models/target.go b/models/target.go
index ffe8401b..faa32b29 100644
--- a/models/target.go
+++ b/models/target.go
@@ -55,14 +55,16 @@ func (rc *RecordConfig) GetTargetCombined() string {
 		case "AZURE_ALIAS":
 			// Differentiate between multiple AZURE_ALIASs on the same label.
 			return fmt.Sprintf("%s atype=%s", rc.target, rc.AzureAlias["type"])
-		case "SOA":
-			return fmt.Sprintf("%s %v %d %d %d %d %d", rc.target, rc.SoaMbox, rc.SoaSerial, rc.SoaRefresh, rc.SoaRetry, rc.SoaExpire, rc.SoaMinttl)
 		default:
 			// Just return the target.
 			return rc.target
 		}
 	}
 
+	if rc.Type == "SOA" {
+		return fmt.Sprintf("%s %v %d %d %d %d %d", rc.target, rc.SoaMbox, rc.SoaSerial, rc.SoaRefresh, rc.SoaRetry, rc.SoaExpire, rc.SoaMinttl)
+	}
+
 	return rc.zoneFileQuoted()
 }
 
@@ -73,6 +75,9 @@ func (rc *RecordConfig) zoneFileQuoted() string {
 	// Sadly String() always includes a header, which we must strip out.
 	// TODO(tlim): Request the dns project add a function that returns
 	// the string without the header.
+	if rc.Type == "NAPTR" && rc.GetTargetField() == "" {
+		rc.SetTarget(".")
+	}
 	rr := rc.ToRR()
 	header := rr.Header().String()
 	full := rr.String()
diff --git a/pkg/normalize/validate.go b/pkg/normalize/validate.go
index 3852188b..be782291 100644
--- a/pkg/normalize/validate.go
+++ b/pkg/normalize/validate.go
@@ -180,7 +180,9 @@ func checkTargets(rec *models.RecordConfig, domain string) (errs []error) {
 	case "PTR":
 		check(checkTarget(target))
 	case "NAPTR":
-		check(checkTarget(target))
+		if target != "" {
+			check(checkTarget(target))
+		}
 	case "ALIAS":
 		check(checkTarget(target))
 	case "SOA":
@@ -345,7 +347,7 @@ func ValidateAndNormalizeConfig(config *models.DNSConfig) (errs []error) {
 			}
 
 			// Canonicalize Targets.
-			if rec.Type == "CNAME" || rec.Type == "MX" || rec.Type == "NAPTR" || rec.Type == "NS" || rec.Type == "SRV" {
+			if rec.Type == "CNAME" || rec.Type == "MX" || rec.Type == "NS" || rec.Type == "SRV" {
 				// #rtype_variations
 				// These record types have a target that is a hostname.
 				// We normalize them to a FQDN so there is less variation to handle.  If a
diff --git a/providers/msdns/convert.go b/providers/msdns/convert.go
index 0cd3ab6e..1f89eb35 100644
--- a/providers/msdns/convert.go
+++ b/providers/msdns/convert.go
@@ -90,6 +90,9 @@ func nativeToRecords(nr nativeRecord, origin string) (*models.RecordConfig, erro
 		rc.SetTargetMX(uint16(uprops["Preference"]), sprops["MailExchange"])
 	case "NS":
 		rc.SetTarget(sprops["NameServer"])
+	case "NAPTR":
+		n := decodeRecordDataNaptr(sprops["Data"])
+		rc.SetTargetNAPTR(n.NaptrOrder, n.NaptrPreference, n.NaptrFlags, n.NaptrService, n.NaptrRegexp, n.GetTargetField())
 	case "PTR":
 		rc.SetTarget(sprops["PtrDomainName"])
 	case "SRV":
diff --git a/providers/msdns/escape.go b/providers/msdns/escape.go
new file mode 100644
index 00000000..b3bdcbe4
--- /dev/null
+++ b/providers/msdns/escape.go
@@ -0,0 +1,7 @@
+package msdns
+
+import "strings"
+
+func escapePS(s string) string {
+	return `'` + strings.Replace(s, `'`, `"`, -1) + `'`
+}
diff --git a/providers/msdns/msdnsProvider.go b/providers/msdns/msdnsProvider.go
index 99d6096f..2a0808d3 100644
--- a/providers/msdns/msdnsProvider.go
+++ b/providers/msdns/msdnsProvider.go
@@ -24,6 +24,7 @@ var features = providers.DocumentationNotes{
 	providers.CanUseAlias:            providers.Cannot(),
 	providers.CanUseCAA:              providers.Cannot(),
 	providers.CanUseDS:               providers.Unimplemented(),
+	providers.CanUseNAPTR:            providers.Can(),
 	providers.CanUsePTR:              providers.Can(),
 	providers.CanUseSRV:              providers.Can(),
 	providers.CanUseTLSA:             providers.Unimplemented(),
diff --git a/providers/msdns/naptr.go b/providers/msdns/naptr.go
new file mode 100644
index 00000000..169df756
--- /dev/null
+++ b/providers/msdns/naptr.go
@@ -0,0 +1,110 @@
+package msdns
+
+// NAPTR records are not supported by the PowerShell module.
+// Until this bug is fixed we use old-school commands instead.
+
+import (
+	"bytes"
+	"encoding/hex"
+	"fmt"
+	"log"
+	"strconv"
+
+	"github.com/StackExchange/dnscontrol/v3/models"
+)
+
+func generatePSCreateNaptr(dnsServerName, domain string, rec *models.RecordConfig) string {
+
+	var computername string
+	if dnsServerName != "" {
+		computername = escapePS(dnsServerName) + " "
+	}
+
+	var b bytes.Buffer
+	fmt.Fprintf(&b, `$zoneName    = %s ; `, escapePS(domain))
+	fmt.Fprintf(&b, `$rrName      = %s ; `, escapePS(rec.Name))
+	fmt.Fprintf(&b, `$Order       = %d ; `, rec.NaptrOrder)
+	fmt.Fprintf(&b, `$Preference  = %d ; `, rec.NaptrPreference)
+	fmt.Fprintf(&b, `$Flags       = %s ; `, escapePS(rec.NaptrFlags))
+	fmt.Fprintf(&b, `$Service     = %s ; `, escapePS(rec.NaptrService))
+	fmt.Fprintf(&b, `$Regex       = %s ; `, escapePS(rec.NaptrRegexp))
+	fmt.Fprintf(&b, `$Replacement = %s ; `, escapePS(rec.GetTargetField()))
+	fmt.Fprintf(&b, `dnscmd %s/recordadd $zoneName $rrName %d naptr $Order $Preference $Flags $Service $Regex $Replacement ; `, computername, rec.TTL)
+	return b.String()
+}
+
+func generatePSDeleteNaptr(dnsServerName, domain string, rec *models.RecordConfig) string {
+	target := rec.GetTargetField()
+	if target == "" {
+		target = "."
+	}
+
+	var computername string
+	if dnsServerName != "" {
+		computername = escapePS(dnsServerName) + " "
+	}
+
+	var b bytes.Buffer
+	fmt.Fprintf(&b, `$zoneName    = %s ; `, escapePS(domain))
+	fmt.Fprintf(&b, `$rrName      = %s ; `, escapePS(rec.Name))
+	fmt.Fprintf(&b, `$Order       = %d ; `, rec.NaptrOrder)
+	fmt.Fprintf(&b, `$Preference  = %d ; `, rec.NaptrPreference)
+	fmt.Fprintf(&b, `$Flags       = %s ; `, escapePS(rec.NaptrFlags))
+	fmt.Fprintf(&b, `$Service     = %s ; `, escapePS(rec.NaptrService))
+	fmt.Fprintf(&b, `$Regex       = %s ; `, escapePS(rec.NaptrRegexp))
+	fmt.Fprintf(&b, `$Replacement = %s ; `, escapePS(target))
+	fmt.Fprintf(&b, `dnscmd %s/recorddelete $zoneName $rrName naptr $Order $Preference $Flags $Service $Regex $Replacement /f ; `, computername)
+	return b.String()
+}
+
+// decoding
+
+func decodeRecordDataNaptr(s string) models.RecordConfig {
+	// These strings look like this:
+	// C8AFB0B30153075349502B4432540474657374165F7369702E5F7463702E6578616D706C652E6F72672E
+	// The first 2 groups of 16 bits (4 hex digits) are uinet16.
+	// The rest are 4 length-prefixed strings.
+	//	The string should be entirely consumed.
+	rc := models.RecordConfig{}
+
+	s, rc.NaptrOrder = eatUint16(s)
+	s, rc.NaptrPreference = eatUint16(s)
+	s, rc.NaptrFlags = eatString(s)
+	s, rc.NaptrService = eatString(s)
+	s, rc.NaptrRegexp = eatString(s)
+	s, targ := eatString(s)
+	rc.SetTarget(targ)
+
+	// At this point we should have consumed the entire string.
+	if s != "" {
+		fmt.Printf("WARNING: REMAINDER:=%q\n", s)
+	}
+
+	return rc
+}
+
+// eatUint16 consumes the first 16 bits of the string, returns it as a
+// uint16, and returns the remaining bytes of the string.
+func eatUint16(s string) (string, uint16) {
+	value, err := strconv.ParseUint(s[2:4]+s[0:2], 16, 64)
+	if err != nil {
+		log.Fatal(err)
+	}
+	return s[4:], uint16(value)
+}
+
+// eatString consumes an encoded string (8-bit length byte, then the string).
+func eatString(s string) (string, string) {
+	sl, err := strconv.ParseUint(s[:2], 16, 64)
+	if err != nil {
+		log.Fatal(err)
+	}
+	last := 2 + sl*2
+	hexcoded := s[2:last]
+	ret, err := hex.DecodeString(hexcoded)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	return s[last:], string(ret)
+}
diff --git a/providers/msdns/powershell.go b/providers/msdns/powershell.go
index 93add2cf..582b5a1f 100644
--- a/providers/msdns/powershell.go
+++ b/providers/msdns/powershell.go
@@ -73,7 +73,10 @@ func (psh *psHandle) GetDNSServerZoneAll(dnsserver string) ([]string, error) {
 	}
 
 	var zones []dnsZone
-	json.Unmarshal([]byte(stdout), &zones)
+	err = json.Unmarshal([]byte(stdout), &zones)
+	if err != nil {
+		return nil, err
+	}
 
 	var result []string
 	for _, z := range zones {
@@ -110,11 +113,11 @@ func (psh *psHandle) GetDNSZoneRecords(dnsserver, domain string) ([]nativeRecord
 		return nil, err
 	}
 	if stderr != "" {
-		fmt.Printf("STDERROR = %q\n", stderr)
+		fmt.Printf("STDERROR GetDNSZR = %q\n", stderr)
 		return nil, fmt.Errorf("unexpected stderr from PSZoneDump: %q", stderr)
 	}
 	if stdout != "" {
-		fmt.Printf("STDOUT = %q\n", stdout)
+		fmt.Printf("STDOUT GetDNSZR = %q\n", stdout)
 	}
 
 	contents, err := utfutil.ReadFile(filename, utfutil.UTF8)
@@ -123,10 +126,21 @@ func (psh *psHandle) GetDNSZoneRecords(dnsserver, domain string) ([]nativeRecord
 	}
 	os.Remove(filename) // TODO(tlim): There should be a debug flag that leaves the tmp file around.
 
+	//fmt.Printf("CONTENTS = %s\n", contents)
+	//fmt.Printf("CONTENTS STR = %q\n", contents[:10])
+	//fmt.Printf("CONTENTS HEX = %v\n", []byte(contents)[:10])
+	//ioutil.WriteFile("/temp/list.json", contents, 0777)
 	var records []nativeRecord
 	err = json.Unmarshal(contents, &records)
 	if err != nil {
-		return nil, fmt.Errorf("PSZoneDump json error: %w", err)
+		// PowerShell generates bad JSON if there is only one record.  Therefore, if there
+		// is an error we try decoding the bad format before completing erroring out.
+		// The "bad JSON" is that they generate a single record instead of a list of length 1.
+		records = append(records, nativeRecord{})
+		err2 := json.Unmarshal(contents, &(records[0]))
+		if err2 != nil {
+			return nil, fmt.Errorf("PSZoneDump json error: %w", err)
+		}
 	}
 
 	return records, nil
@@ -141,17 +155,35 @@ func generatePSZoneDump(dnsserver, domainname, filename string) string {
 	// a BOM.  A UTF-8 file shouldn't have a BOM, but Microsoft messed up.
 	// When we switch to PowerShell Core, the BOM will disappear.
 	var b bytes.Buffer
+
+	// Set the output to be UTF8.  Previously we didn't do that and the
+	// output was twice as large, plus it required an extra conversion
+	// step.  Windows PowerShell is native UTF16 but PowerShell Core is
+	// native UTF8, thus this may not be needed if we move to Core.
 	fmt.Fprintf(&b, `$OutputEncoding = [Text.UTF8Encoding]::UTF8 ; `)
+
+	// Output everything we know about the zone.
 	fmt.Fprintf(&b, `Get-DnsServerResourceRecord`)
 	if dnsserver != "" {
 		fmt.Fprintf(&b, ` -ComputerName "%v"`, dnsserver)
 	}
 	fmt.Fprintf(&b, ` -ZoneName "%v"`, domainname)
+
+	// Strip out the `Cim*` properties at the root. This shrinks one
+	// zone from 99M to 11M.  We don't need the Cim* properties (at
+	// least the ones at the root) and decocding 99M of JSON was slow
+	// (30+ minutes).
+	// NB(tlim): Windows PowerShell requires the `-Property *` but
+	// Windows PowerShell Core makes that optional.
 	fmt.Fprintf(&b, ` | `)
 	fmt.Fprintf(&b, `Select-Object -Property * -ExcludeProperty Cim*`)
 	fmt.Fprintf(&b, ` | `)
 	fmt.Fprintf(&b, `ConvertTo-Json -depth 4`) // Tested with 3 (causes errors).  4 and larger work.
 	fmt.Fprintf(&b, ` | `)
+
+	// Prevously we captured stdout. Now we write it to a file. This is
+	// safer since there is no chance of junk accidentally being mixed
+	// into stdout.
 	fmt.Fprintf(&b, `Out-File "%s" -Encoding utf8`, filename)
 	return b.String()
 }
@@ -159,7 +191,16 @@ func generatePSZoneDump(dnsserver, domainname, filename string) string {
 // Functions for record manipulation
 
 func (psh *psHandle) RecordDelete(dnsserver, domain string, rec *models.RecordConfig) error {
-	_, stderr, err := psh.shell.Execute(generatePSDelete(dnsserver, domain, rec))
+
+	var c string
+	if rec.Type == "NAPTR" {
+		c = generatePSDeleteNaptr(dnsserver, domain, rec)
+		//fmt.Printf("DEBUG: deleteNAPTR: %s\n", c)
+	} else {
+		c = generatePSDelete(dnsserver, domain, rec)
+	}
+
+	_, stderr, err := psh.shell.Execute(c)
 	if err != nil {
 		return err
 	}
@@ -171,9 +212,17 @@ func (psh *psHandle) RecordDelete(dnsserver, domain string, rec *models.RecordCo
 }
 
 func generatePSDelete(dnsserver, domain string, rec *models.RecordConfig) string {
+
 	var b bytes.Buffer
-	fmt.Fprintf(&b, `echo DELETE "%s" "%s" "%s"`, rec.Type, rec.Name, rec.GetTargetCombined())
+	fmt.Fprintf(&b, `echo DELETE "%s" "%s" "..."`, rec.Type, rec.Name)
 	fmt.Fprintf(&b, " ; ")
+
+	if rec.Type == "NAPTR" {
+		x := b.String() + generatePSDeleteNaptr(dnsserver, domain, rec)
+		//fmt.Printf("NAPTR DELETE: %s\n", x)
+		return x
+	}
+
 	fmt.Fprintf(&b, `Remove-DnsServerResourceRecord`)
 	if dnsserver != "" {
 		fmt.Fprintf(&b, ` -ComputerName "%s"`, dnsserver)
@@ -197,12 +246,23 @@ func generatePSDelete(dnsserver, domain string, rec *models.RecordConfig) string
 }
 
 func (psh *psHandle) RecordCreate(dnsserver, domain string, rec *models.RecordConfig) error {
-	_, stderr, err := psh.shell.Execute(generatePSCreate(dnsserver, domain, rec))
+
+	var c string
+	if rec.Type == "NAPTR" {
+		c = generatePSCreateNaptr(dnsserver, domain, rec)
+		//fmt.Printf("DEBUG: createNAPTR: %s\n", c)
+	} else {
+		c = generatePSCreate(dnsserver, domain, rec)
+		//fmt.Printf("DEBUG: PScreate\n")
+	}
+
+	stdout, stderr, err := psh.shell.Execute(c)
 	if err != nil {
 		return err
 	}
 	if stderr != "" {
-		fmt.Printf("STDERROR = %q\n", stderr)
+		fmt.Printf("STDOUT RecordCreate = %s\n", stdout)
+		fmt.Printf("STDERROR RecordCreate = %q\n", stderr)
 		return fmt.Errorf("unexpected stderr from PSCreate: %q", stderr)
 	}
 	return nil
@@ -210,9 +270,13 @@ func (psh *psHandle) RecordCreate(dnsserver, domain string, rec *models.RecordCo
 
 func generatePSCreate(dnsserver, domain string, rec *models.RecordConfig) string {
 	var b bytes.Buffer
-	fmt.Fprintf(&b, `echo CREATE "%s" "%s" "%s"`, rec.Type, rec.Name, rec.GetTargetCombined())
+	fmt.Fprintf(&b, `echo CREATE "%s" "%s" "..."`, rec.Type, rec.Name)
 	fmt.Fprintf(&b, " ; ")
 
+	if rec.Type == "NAPTR" {
+		return b.String() + generatePSCreateNaptr(dnsserver, domain, rec)
+	}
+
 	fmt.Fprint(&b, `Add-DnsServerResourceRecord`)
 	if dnsserver != "" {
 		fmt.Fprintf(&b, ` -ComputerName "%s"`, dnsserver)
@@ -238,8 +302,8 @@ func generatePSCreate(dnsserver, domain string, rec *models.RecordConfig) string
 	//case "WKS":
 	//	fmt.Fprintf(&b, ` -Wks -InternetAddress <IPAddress> -InternetProtocol {UDP | TCP} -Service <String[]>`, rec.GetTargetField())
 	case "TXT":
-		fmt.Printf("DEBUG TXT len = %v\n", rec.TxtStrings)
-		fmt.Printf("DEBUG TXT target = %q\n", rec.GetTargetField())
+		//fmt.Printf("DEBUG TXT len = %v\n", rec.TxtStrings)
+		//fmt.Printf("DEBUG TXT target = %q\n", rec.GetTargetField())
 		fmt.Fprintf(&b, ` -Txt -DescriptiveText %s`, rec.GetTargetField())
 	//case "RT":
 	//	fmt.Fprintf(&b, ` -RT -IntermediateHost <String> -Preference <UInt16>`, rec.GetTargetField())


Test patch:
diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go
index 5c4ccfb6..d7a45e0f 100644
--- a/integrationTest/integration_test.go
+++ b/integrationTest/integration_test.go
@@ -218,7 +218,7 @@ func makeChanges(t *testing.T, prv providers.DNSServiceProvider, dc *models.Doma
 		if len(corrections) != 0 {
 			t.Logf("Expected 0 corrections on second run, but found %d.", len(corrections))
 			for i, c := range corrections {
-				t.Logf("#%d: %s", i, c.Msg)
+				t.Logf("UNEXPECTED #%d: %s", i, c.Msg)
 			}
 			t.FailNow()
 		}
diff --git a/pkg/js/parse_tests/035-naptr.js b/pkg/js/parse_tests/035-naptr.js
index b8cdc952..a44181f5 100644
--- a/pkg/js/parse_tests/035-naptr.js
+++ b/pkg/js/parse_tests/035-naptr.js
@@ -1,4 +1,6 @@
 D("foo.com","none",
     NAPTR("@",100,10,"U","E2U+sip","!^.*$!sip:customer-service@example.com!","example"),
-    NAPTR("@",102,10,"U","E2U+email","!^.*$!mailto:information@example.com!","example")
+    NAPTR("@",102,10,"U","E2U+email","!^.*$!mailto:information@example.com!","example"),
+    NAPTR("@",103,10,"U","E2U+email","!^.*$!mailto:information@example.com!",""),
+    NAPTR("@",104,10,"U","E2U+email","!^.*$!mailto:information@example.com!",".")
 );
diff --git a/pkg/js/parse_tests/035-naptr.json b/pkg/js/parse_tests/035-naptr.json
index 87736f35..dc84f0d5 100644
--- a/pkg/js/parse_tests/035-naptr.json
+++ b/pkg/js/parse_tests/035-naptr.json
@@ -1,33 +1,53 @@
 {
-  "registrars": [],
   "dns_providers": [],
   "domains": [
     {
-      "name": "foo.com",
-      "registrar": "none",
       "dnsProviders": {},
+      "name": "foo.com",
       "records": [
         {
-          "type": "NAPTR",
           "name": "@",
-          "target": "example",
+          "naptrflags": "U",
           "naptrorder": 100,
           "naptrpreference": 10,
-          "naptrflags": "U",
+          "naptrregexp": "!^.*$!sip:customer-service@example.com!",
           "naptrservice": "E2U+sip",
-          "naptrregexp": "!^.*$!sip:customer-service@example.com!"
+          "target": "example",
+          "type": "NAPTR"
         },
         {
-          "type": "NAPTR",
           "name": "@",
-          "target": "example",
+          "naptrflags": "U",
           "naptrorder": 102,
           "naptrpreference": 10,
+          "naptrregexp": "!^.*$!mailto:information@example.com!",
+          "naptrservice": "E2U+email",
+          "target": "example",
+          "type": "NAPTR"
+        },
+        {
+          "name": "@",
+          "naptrflags": "U",
+          "naptrorder": 103,
+          "naptrpreference": 10,
+          "naptrregexp": "!^.*$!mailto:information@example.com!",
+          "naptrservice": "E2U+email",
+          "target": "",
+          "type": "NAPTR"
+        },
+        {
+          "name": "@",
           "naptrflags": "U",
+          "naptrorder": 104,
+          "naptrpreference": 10,
+          "naptrregexp": "!^.*$!mailto:information@example.com!",
           "naptrservice": "E2U+email",
-          "naptrregexp": "!^.*$!mailto:information@example.com!"
+          "target": ".",
+          "type": "NAPTR"
         }
-      ]
+      ],
+      "registrar": "none"
     }
-  ]
+  ],
+  "registrars": []
 }
diff --git a/pkg/js/parse_tests/035-naptr/foo.com.zone b/pkg/js/parse_tests/035-naptr/foo.com.zone
index 4b3db8ef..ab6ac114 100644
--- a/pkg/js/parse_tests/035-naptr/foo.com.zone
+++ b/pkg/js/parse_tests/035-naptr/foo.com.zone
@@ -1,3 +1,5 @@
 $TTL 300
-@                IN NAPTR 100 10 "U" "E2U+sip" "!^.*$!sip:customer-service@example.com!" example.foo.com.
-                 IN NAPTR 102 10 "U" "E2U+email" "!^.*$!mailto:information@example.com!" example.foo.com.
+@                IN NAPTR 100 10 "U" "E2U+sip" "!^.*$!sip:customer-service@example.com!" example
+                 IN NAPTR 102 10 "U" "E2U+email" "!^.*$!mailto:information@example.com!" example
+                 IN NAPTR 103 10 "U" "E2U+email" "!^.*$!mailto:information@example.com!" .
+                 IN NAPTR 104 10 "U" "E2U+email" "!^.*$!mailto:information@example.com!" .
diff --git a/providers/msdns/naptr_test.go b/providers/msdns/naptr_test.go
new file mode 100644
index 00000000..66dfea86
--- /dev/null
+++ b/providers/msdns/naptr_test.go
@@ -0,0 +1,30 @@
+package msdns
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/StackExchange/dnscontrol/v3/models"
+)
+
+func Test_decodeRecordDataNaptr(t *testing.T) {
+	type args struct {
+		s string
+	}
+	tests := []struct {
+		name string
+		args args
+		want models.RecordConfig
+	}{
+		{"01", args{"C8AFB0B30153075349502B4432540474657374165F7369702E5F7463702E6578616D706C652E6F72672E"}, models.RecordConfig{NaptrOrder: 45000, NaptrPreference: 46000, NaptrFlags: "S", NaptrService: "SIP+D2T", NaptrRegexp: "test", Name: "_sip._tcp.example.org."}},
+	}
+	for _, tt := range tests {
+		tt.want.SetTarget(tt.want.Name)
+		tt.want.Name = ""
+		t.Run(tt.name, func(t *testing.T) {
+			if got := decodeRecordDataNaptr(tt.args.s); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("decodeRecordDataNaptr() = %+v, want %+v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/providers/msdns/powershell_test.go b/providers/msdns/powershell_test.go
index 9e89aed8..06680bbc 100644
--- a/providers/msdns/powershell_test.go
+++ b/providers/msdns/powershell_test.go
@@ -146,16 +146,16 @@ func Test_generatePSModify(t *testing.T) {
 		want string
 	}{
 		{name: "A", args: args{domain: "example.com", dnsserver: "", old: recA1, rec: recA2},
-			want: `echo DELETE "A" "@" "1.2.3.4" ; Remove-DnsServerResourceRecord -Force -ZoneName "example.com" -Name "@" -RRType "A" -RecordData "1.2.3.4" ; echo CREATE "A" "@" "10.20.30.40" ; Add-DnsServerResourceRecord -ZoneName "example.com" -Name "@" -TimeToLive $(New-TimeSpan -Seconds 0) -A -IPv4Address "10.20.30.40"`,
+			want: `echo DELETE "A" "@" "..." ; Remove-DnsServerResourceRecord -Force -ZoneName "example.com" -Name "@" -RRType "A" -RecordData "1.2.3.4" ; echo CREATE "A" "@" "..." ; Add-DnsServerResourceRecord -ZoneName "example.com" -Name "@" -TimeToLive $(New-TimeSpan -Seconds 0) -A -IPv4Address "10.20.30.40"`,
 		},
 		{name: "MX1", args: args{domain: "example.com", dnsserver: "", old: recMX1, rec: recMX2},
-			want: `echo DELETE "MX" "@" "5 foo.com." ; Remove-DnsServerResourceRecord -Force -ZoneName "example.com" -Name "@" -RRType "MX" -RecordData 5,"foo.com." ; echo CREATE "MX" "@" "50 foo2.com." ; Add-DnsServerResourceRecord -ZoneName "example.com" -Name "@" -TimeToLive $(New-TimeSpan -Seconds 0) -MX -MailExchange "foo2.com." -Preference 50`,
+			want: `echo DELETE "MX" "@" "..." ; Remove-DnsServerResourceRecord -Force -ZoneName "example.com" -Name "@" -RRType "MX" -RecordData 5,"foo.com." ; echo CREATE "MX" "@" "..." ; Add-DnsServerResourceRecord -ZoneName "example.com" -Name "@" -TimeToLive $(New-TimeSpan -Seconds 0) -MX -MailExchange "foo2.com." -Preference 50`,
 		},
 		{name: "A-remote", args: args{domain: "example.com", dnsserver: "myremote", old: recA1, rec: recA2},
-			want: `echo DELETE "A" "@" "1.2.3.4" ; Remove-DnsServerResourceRecord -ComputerName "myremote" -Force -ZoneName "example.com" -Name "@" -RRType "A" -RecordData "1.2.3.4" ; echo CREATE "A" "@" "10.20.30.40" ; Add-DnsServerResourceRecord -ComputerName "myremote" -ZoneName "example.com" -Name "@" -TimeToLive $(New-TimeSpan -Seconds 0) -A -IPv4Address "10.20.30.40"`,
+			want: `echo DELETE "A" "@" "..." ; Remove-DnsServerResourceRecord -ComputerName "myremote" -Force -ZoneName "example.com" -Name "@" -RRType "A" -RecordData "1.2.3.4" ; echo CREATE "A" "@" "..." ; Add-DnsServerResourceRecord -ComputerName "myremote" -ZoneName "example.com" -Name "@" -TimeToLive $(New-TimeSpan -Seconds 0) -A -IPv4Address "10.20.30.40"`,
 		},
 		{name: "MX1-remote", args: args{domain: "example.com", dnsserver: "yourremote", old: recMX1, rec: recMX2},
-			want: `echo DELETE "MX" "@" "5 foo.com." ; Remove-DnsServerResourceRecord -ComputerName "yourremote" -Force -ZoneName "example.com" -Name "@" -RRType "MX" -RecordData 5,"foo.com." ; echo CREATE "MX" "@" "50 foo2.com." ; Add-DnsServerResourceRecord -ComputerName "yourremote" -ZoneName "example.com" -Name "@" -TimeToLive $(New-TimeSpan -Seconds 0) -MX -MailExchange "foo2.com." -Preference 50`,
+			want: `echo DELETE "MX" "@" "..." ; Remove-DnsServerResourceRecord -ComputerName "yourremote" -Force -ZoneName "example.com" -Name "@" -RRType "MX" -RecordData 5,"foo.com." ; echo CREATE "MX" "@" "..." ; Add-DnsServerResourceRecord -ComputerName "yourremote" -ZoneName "example.com" -Name "@" -TimeToLive $(New-TimeSpan -Seconds 0) -MX -MailExchange "foo2.com." -Preference 50`,
 		},
 	}
 	for _, tt := range tests {