From 40c221a5a94ebf207502bf48d9021ea390651000 Mon Sep 17 00:00:00 2001 From: Jacob Bednarz Date: Fri, 14 Apr 2023 14:41:07 +1000 Subject: [PATCH] dns: add support for importing and exporting DNS records using BIND file configurations Updates our DNS API coverage for importing and exporting via BIND file formats. --- .changelog/1266.txt | 3 + dns.go | 146 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 .changelog/1266.txt diff --git a/.changelog/1266.txt b/.changelog/1266.txt new file mode 100644 index 00000000000..749e3b8c8aa --- /dev/null +++ b/.changelog/1266.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dns: add support for importing and exporting DNS records using BIND file configurations +``` diff --git a/dns.go b/dns.go index afc47b66bda..7e2ddb7c55f 100644 --- a/dns.go +++ b/dns.go @@ -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"` @@ -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"` @@ -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 +}