Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dns: add support for importing and exporting DNS records using BIND file configurations #1266

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/1266.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:enhancement
dns: add support for importing and exporting DNS records using BIND file configurations
```
146 changes: 146 additions & 0 deletions dns.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package cloudflare

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"

"golang.org/x/net/idna"
)

// ErrMissingBINDContents is for when the BIND file contents is required but not set.
var ErrMissingBINDContents = errors.New("required BIND config contents missing")

// DNSRecord represents a DNS record in a zone.
type DNSRecord struct {
CreatedOn time.Time `json:"created_on,omitempty"`
Expand Down Expand Up @@ -104,6 +110,73 @@ func toUTS46ASCII(name string) string {
return name
}

// proxiedRecordsRe is the regular expression for determining if a DNS record
// is proxied or not.
var proxiedRecordsRe = regexp.MustCompile(`(?m)^.*\.\s+1\s+IN\s+CNAME.*$`)

// proxiedRecordImportTemplate is the multipart template for importing *only*
// proxied records. See `nonProxiedRecordImportTemplate` for importing records
// that are not proxied.
var proxiedRecordImportTemplate = `--------------------------BOUNDARY
Content-Disposition: form-data; name="file"; filename="bind.txt"

%s
--------------------------BOUNDARY
Content-Disposition: form-data; name="proxied"

true
--------------------------BOUNDARY--`

// nonProxiedRecordImportTemplate is the multipart template for importing DNS
// records that are not proxed. For importing proxied records, use
// `proxiedRecordImportTemplate`.
var nonProxiedRecordImportTemplate = `--------------------------BOUNDARY
Content-Disposition: form-data; name="file"; filename="bind.txt"

%s
--------------------------BOUNDARY--`

// sanitiseBINDFileInput accepts the BIND file as a string and removes parts
// that are not required for importing or would break the import (like SOA
// records).
func sanitiseBINDFileInput(s string) string {
// Remove SOA records.
soaRe := regexp.MustCompile(`(?m)[\r\n]+^.*IN\s+SOA.*$`)
s = soaRe.ReplaceAllString(s, "")

// Remove all comments.
commentRe := regexp.MustCompile(`(?m)[\r\n]+^.*;;.*$`)
s = commentRe.ReplaceAllString(s, "")

// Swap all the tabs to spaces.
r := strings.NewReplacer(
"\t", " ",
"\n\n", "\n",
)
s = r.Replace(s)
s = strings.TrimSpace(s)

return s
}

// extractProxiedRecords accepts a BIND file (as a string) and returns only the
// proxied DNS records.
func extractProxiedRecords(s string) string {
proxiedOnlyRecords := proxiedRecordsRe.FindAllString(s, -1)
return strings.Join(proxiedOnlyRecords, "\n")
}

// removeProxiedRecords accepts a BIND file (as a string) and returns the file
// contents without any proxied records included.
func removeProxiedRecords(s string) string {
return proxiedRecordsRe.ReplaceAllString(s, "")
}

type ExportDNSRecordsParams struct{}
type ImportDNSRecordsParams struct {
BINDContents string
}

type CreateDNSRecordParams struct {
CreatedOn time.Time `json:"created_on,omitempty" url:"created_on,omitempty"`
ModifiedOn time.Time `json:"modified_on,omitempty" url:"modified_on,omitempty"`
Expand Down Expand Up @@ -277,3 +350,76 @@ func (api *API) DeleteDNSRecord(ctx context.Context, rc *ResourceContainer, reco
}
return nil
}

// ExportDNSRecords returns all DNS records for a zone in the BIND format.
//
// API reference: https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-export-dns-records
func (api *API) ExportDNSRecords(ctx context.Context, rc *ResourceContainer, params ExportDNSRecordsParams) (string, error) {
if rc.Level != ZoneRouteLevel {
return "", ErrRequiredZoneLevelResourceContainer
}

if rc.Identifier == "" {
return "", ErrMissingZoneID
}

uri := fmt.Sprintf("/zones/%s/dns_records/export", rc.Identifier)
res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
if err != nil {
return "", err
}

return string(res), nil
}

// ImportDNSRecords takes the contents of a BIND configuration file and imports
// all records at once.
//
// The current state of the API doesn't allow the proxying field to be
// automatically set on records where the TTL is 1. Instead you need to
// explicitly tell the endpoint which records are proxied in the form data. To
// achieve a simpler abstraction, we do the legwork in the method of making the
// two separate API calls (one for proxied and one for non-proxied) instead of
// making the end user know about this detail.
//
// API reference: https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-import-dns-records
func (api *API) ImportDNSRecords(ctx context.Context, rc *ResourceContainer, params ImportDNSRecordsParams) error {
if rc.Level != ZoneRouteLevel {
return ErrRequiredZoneLevelResourceContainer
}

if rc.Identifier == "" {
return ErrMissingZoneID
}

if params.BINDContents == "" {
return ErrMissingBINDContents
}

sanitisedBindData := sanitiseBINDFileInput(params.BINDContents)
nonProxiedRecords := removeProxiedRecords(sanitisedBindData)
proxiedOnlyRecords := extractProxiedRecords(sanitisedBindData)

nonProxiedRecordPayload := []byte(fmt.Sprintf(nonProxiedRecordImportTemplate, nonProxiedRecords))
nonProxiedReqBody := bytes.NewReader(nonProxiedRecordPayload)

uri := fmt.Sprintf("/zones/%s/dns_records/import", rc.Identifier)
multipartUploadHeaders := http.Header{
"Content-Type": {"multipart/form-data; boundary=------------------------BOUNDARY"},
}

_, err := api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, nonProxiedReqBody, multipartUploadHeaders)
if err != nil {
return err
}

proxiedRecordPayload := []byte(fmt.Sprintf(proxiedRecordImportTemplate, proxiedOnlyRecords))
proxiedReqBody := bytes.NewReader(proxiedRecordPayload)

_, err = api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, proxiedReqBody, multipartUploadHeaders)
if err != nil {
return err
}

return nil
}