diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index c6cb7a119..2457c758c 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -348,6 +348,7 @@ func runTests(t *testing.T, prv providers.DNSServiceProvider, domainName string, } func TestDualProviders(t *testing.T) { + t.Skip() p, domain, _, _ := getProvider(t) if p == nil { return @@ -652,6 +653,27 @@ func sshfp(name string, algorithm uint8, fingerprint uint8, target string) *mode return r } +func ovhdkim(name, target string) *models.RecordConfig { + return makeOvhNativeRecord(name, target, "DKIM") +} + +func ovhspf(name, target string) *models.RecordConfig { + return makeOvhNativeRecord(name, target, "SPF") +} + +func ovhdmarc(name, target string) *models.RecordConfig { + return makeOvhNativeRecord(name, target, "DMARC") +} + +func makeOvhNativeRecord(name, target, rType string) *models.RecordConfig { + r := makeRec(name, "", "TXT") + r.Metadata = make(map[string]string) + r.Metadata["create_ovh_native_record"] = rType + r.TxtStrings = []string{target} + r.SetTarget(target) + return r +} + func testgroup(desc string, items ...interface{}) *TestGroup { group := &TestGroup{Desc: desc} for _, item := range items { @@ -2030,6 +2052,30 @@ func makeTests(t *testing.T) []*TestGroup { ).ExpectNoChanges(), ).Diff2Only(), + testgroup("structured TXT", + only("OVH"), + tc("Create TXT", + txt("spf", "v=spf1 ip4:99.99.99.99 -all"), + txt("dkim", "v=DKIM1;t=s;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCzwOUgwGWVIwQG8PBl89O37BdaoqEd/rT6r/Iot4PidtPJkPbVxWRi0mUgduAnsO8zHCz2QKAd5wPe9+l+Stwy6e0h27nAOkI/Edx3qwwWqWSUfwfIBWZG+lrFrhWgSIWCj2/TMkMMzBZJdhVszCzdGQiNPkGvKgjfqW5T0TZt0QIDAQAB"), + txt("_dmarc", "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com")), + tc("Update TXT", + txt("spf", "v=spf1 a mx -all"), + txt("dkim", "v=DKIM1;t=s;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDk72yk6UML8LGIXFobhvx6UDUntqGzmyie2FLMyrOYk1C7CVYR139VMbO9X1rFvZ8TaPnMCkMbuEGWGgWNc27MLYKfI+wP/SYGjRS98TNl9wXxP8tPfr6id5gks95sEMMaYTu8sctnN6sBOvr4hQ2oipVcBn/oxkrfhqvlcat5gQIDAQAB"), + txt("_dmarc", "v=DMARC1; p=none; rua=mailto:dmarc@example.com")), + ), + + testgroup("structured TXT as native records", + only("OVH"), + tc("Create native OVH records", + ovhspf("spf", "v=spf1 ip4:99.99.99.99 -all"), + ovhdkim("dkim", "v=DKIM1;t=s;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCzwOUgwGWVIwQG8PBl89O37BdaoqEd/rT6r/Iot4PidtPJkPbVxWRi0mUgduAnsO8zHCz2QKAd5wPe9+l+Stwy6e0h27nAOkI/Edx3qwwWqWSUfwfIBWZG+lrFrhWgSIWCj2/TMkMMzBZJdhVszCzdGQiNPkGvKgjfqW5T0TZt0QIDAQAB"), + ovhdmarc("_dmarc", "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com")), + tc("Update native OVH records", + ovhspf("spf", "v=spf1 a mx -all"), + ovhdkim("dkim", "v=DKIM1;t=s;p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDk72yk6UML8LGIXFobhvx6UDUntqGzmyie2FLMyrOYk1C7CVYR139VMbO9X1rFvZ8TaPnMCkMbuEGWGgWNc27MLYKfI+wP/SYGjRS98TNl9wXxP8tPfr6id5gks95sEMMaYTu8sctnN6sBOvr4hQ2oipVcBn/oxkrfhqvlcat5gQIDAQAB"), + ovhdmarc("_dmarc", "v=DMARC1; p=none; rua=mailto:dmarc@example.com")), + ), + // Narrative: Congrats! You're done! If you've made it this far // you're very close to being able to submit your PR. Here's // some tips: diff --git a/providers/ovh/protocol.go b/providers/ovh/protocol.go index 4d67c72b6..c86fa4ea6 100644 --- a/providers/ovh/protocol.go +++ b/providers/ovh/protocol.go @@ -3,7 +3,6 @@ package ovh import ( "errors" "fmt" - "strings" "github.com/StackExchange/dnscontrol/v4/models" "github.com/miekg/dns/dnsutil" @@ -109,17 +108,30 @@ func (c *ovhProvider) deleteRecordFunc(id int64, fqdn string) func() error { // Returns a function that can be invoked to create a record in a zone. func (c *ovhProvider) createRecordFunc(rc *models.RecordConfig, fqdn string) func() error { return func() error { + recordType := rc.Type + if nativeType, ok := rc.Metadata["create_ovh_native_record"]; ok { + recordType = nativeType + } record := Record{ SubDomain: dnsutil.TrimDomainName(rc.GetLabelFQDN(), fqdn), - FieldType: rc.Type, + FieldType: recordType, Target: rc.GetTargetCombined(), TTL: rc.TTL, } if record.SubDomain == "@" { record.SubDomain = "" } + + // note that we never create OVH custom TXT records such as DKIM, SPF or DMARC, instead we prefer to + // use regular TXT records, unless the record is anotated with the `create_ovh_native_record` metadata + + err := adaptNativeRecord(&record) + if err != nil { + return err + } + var response Record - err := c.client.CallAPI("POST", fmt.Sprintf("/domain/zone/%s/record", fqdn), &record, &response, true) + err = c.client.CallAPI("POST", fmt.Sprintf("/domain/zone/%s/record", fqdn), &record, &response, true) return err } } @@ -127,9 +139,19 @@ func (c *ovhProvider) createRecordFunc(rc *models.RecordConfig, fqdn string) fun // Returns a function that can be invoked to update a record in a zone. func (c *ovhProvider) updateRecordFunc(old *Record, rc *models.RecordConfig, fqdn string) func() error { return func() error { + recordType := rc.Type + + if rc.Type != "TXT" && (old.FieldType == "DKIM" || old.FieldType == "SPF" || old.FieldType == "DMARC") { + return fmt.Errorf("OVH doesn't allow to change %s native type to a non TXT type - delete the record manually and run dnscontrol again", old.FieldType) + } + + if old.FieldType == "DKIM" || old.FieldType == "SPF" || old.FieldType == "DMARC" { + recordType = old.FieldType + } + record := Record{ SubDomain: rc.GetLabel(), - FieldType: rc.Type, + FieldType: recordType, Target: rc.GetTargetCombined(), TTL: rc.TTL, Zone: fqdn, @@ -139,21 +161,36 @@ func (c *ovhProvider) updateRecordFunc(old *Record, rc *models.RecordConfig, fqd record.SubDomain = "" } - // We do this last just right before the final API call - if c.isDKIMRecord(rc) { - // When DKIM value is longer than 255, the MODIFY fails with "Try to alter read-only properties: fieldType" - // Setting FieldType to empty string results in the property not being altered, hence error does not occur. - record.FieldType = "DKIM" + err := adaptNativeRecord(&record) + if err != nil { + return err } - err := c.client.CallAPI("PUT", fmt.Sprintf("/domain/zone/%s/record/%d", fqdn, old.ID), &record, &Void{}, true) + + // Native DKIM, SPF or DMARC record field type created through the OVH UI shouldn't be updated + // or we get the "Try to alter read-only properties: fieldType" error + switch old.FieldType { + case "DKIM", "SPF", "DMARC": + record.FieldType = "" + } + + err = c.client.CallAPI("PUT", fmt.Sprintf("/domain/zone/%s/record/%d", fqdn, old.ID), &record, &Void{}, true) return err } } -// Check if provided record is DKIM -func (c *ovhProvider) isDKIMRecord(rc *models.RecordConfig) bool { - return (rc != nil && rc.Type == "TXT" && strings.Contains(rc.GetLabel(), "._domainkey")) +// adaptNativeRecord adapts the record for native OVH types such as DMARC or DKIM +func adaptNativeRecord(r *Record) error { + // OVH needs DMARC and DKIM to be "unquoted" + if r.FieldType == "DMARC" || r.FieldType == "DKIM" { + // make sure target is fully unquoted to prevent "Invalid subfield found in DMARC" error + r.Target = models.StripQuotes(r.Target) + } + // DMARC record can be created only for `_dmarc` subdomain + if r.FieldType == "DMARC" && r.SubDomain != "_dmarc" { + return fmt.Errorf("native OVH DMARC record requires subdomain to always be _dmarc, %s given", r.SubDomain) + } + return nil } // refreshZone initiates a refresh task on OVHs backend