mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-09-20 14:56:20 +08:00
7fd6a74e0c
Co-authored-by: Josh Zhang <jzhang1@stackoverflow.com>
424 lines
14 KiB
Go
424 lines
14 KiB
Go
package cloudflare
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/goccy/go-json"
|
|
"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"`
|
|
ModifiedOn time.Time `json:"modified_on,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Meta interface{} `json:"meta,omitempty"`
|
|
Data interface{} `json:"data,omitempty"` // data returned by: SRV, LOC
|
|
ID string `json:"id,omitempty"`
|
|
ZoneID string `json:"zone_id,omitempty"`
|
|
ZoneName string `json:"zone_name,omitempty"`
|
|
Priority *uint16 `json:"priority,omitempty"`
|
|
TTL int `json:"ttl,omitempty"`
|
|
Proxied *bool `json:"proxied,omitempty"`
|
|
Proxiable bool `json:"proxiable,omitempty"`
|
|
Comment string `json:"comment,omitempty"` // the server will omit the comment field when the comment is empty
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
// DNSRecordResponse represents the response from the DNS endpoint.
|
|
type DNSRecordResponse struct {
|
|
Result DNSRecord `json:"result"`
|
|
Response
|
|
ResultInfo `json:"result_info"`
|
|
}
|
|
|
|
type ListDirection string
|
|
|
|
const (
|
|
ListDirectionAsc ListDirection = "asc"
|
|
ListDirectionDesc ListDirection = "desc"
|
|
)
|
|
|
|
type ListDNSRecordsParams struct {
|
|
Type string `url:"type,omitempty"`
|
|
Name string `url:"name,omitempty"`
|
|
Content string `url:"content,omitempty"`
|
|
Proxied *bool `url:"proxied,omitempty"`
|
|
Comment string `url:"comment,omitempty"` // currently, the server does not support searching for records with an empty comment
|
|
Tags []string `url:"tag,omitempty"` // potentially multiple `tag=`
|
|
TagMatch string `url:"tag-match,omitempty"`
|
|
Order string `url:"order,omitempty"`
|
|
Direction ListDirection `url:"direction,omitempty"`
|
|
Match string `url:"match,omitempty"`
|
|
Priority *uint16 `url:"-"`
|
|
|
|
ResultInfo
|
|
}
|
|
|
|
type UpdateDNSRecordParams struct {
|
|
Type string `json:"type,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Data interface{} `json:"data,omitempty"` // data for: SRV, LOC
|
|
ID string `json:"-"`
|
|
Priority *uint16 `json:"priority,omitempty"`
|
|
TTL int `json:"ttl,omitempty"`
|
|
Proxied *bool `json:"proxied,omitempty"`
|
|
Comment *string `json:"comment,omitempty"` // nil will keep the current comment, while StringPtr("") will empty it
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
// DNSListResponse represents the response from the list DNS records endpoint.
|
|
type DNSListResponse struct {
|
|
Result []DNSRecord `json:"result"`
|
|
Response
|
|
ResultInfo `json:"result_info"`
|
|
}
|
|
|
|
// listDNSRecordsDefaultPageSize represents the default per_page size of the API.
|
|
var listDNSRecordsDefaultPageSize int = 100
|
|
|
|
// nontransitionalLookup implements the nontransitional processing as specified in
|
|
// Unicode Technical Standard 46 with almost all checkings off to maximize user freedom.
|
|
var nontransitionalLookup = idna.New(
|
|
idna.MapForLookup(),
|
|
idna.StrictDomainName(false),
|
|
idna.ValidateLabels(false),
|
|
)
|
|
|
|
// toUTS46ASCII tries to convert IDNs (international domain names)
|
|
// from Unicode form to Punycode, using non-transitional process specified
|
|
// in UTS 46.
|
|
//
|
|
// Note: conversion errors are silently discarded and partial conversion
|
|
// results are used.
|
|
func toUTS46ASCII(name string) string {
|
|
name, _ = nontransitionalLookup.ToASCII(name)
|
|
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"`
|
|
Type string `json:"type,omitempty" url:"type,omitempty"`
|
|
Name string `json:"name,omitempty" url:"name,omitempty"`
|
|
Content string `json:"content,omitempty" url:"content,omitempty"`
|
|
Meta interface{} `json:"meta,omitempty"`
|
|
Data interface{} `json:"data,omitempty"` // data returned by: SRV, LOC
|
|
ID string `json:"id,omitempty"`
|
|
ZoneID string `json:"zone_id,omitempty"`
|
|
ZoneName string `json:"zone_name,omitempty"`
|
|
Priority *uint16 `json:"priority,omitempty"`
|
|
TTL int `json:"ttl,omitempty"`
|
|
Proxied *bool `json:"proxied,omitempty" url:"proxied,omitempty"`
|
|
Proxiable bool `json:"proxiable,omitempty"`
|
|
Comment string `json:"comment,omitempty" url:"comment,omitempty"` // to the server, there's no difference between "no comment" and "empty comment"
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
// CreateDNSRecord creates a DNS record for the zone identifier.
|
|
//
|
|
// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record
|
|
func (api *API) CreateDNSRecord(ctx context.Context, rc *ResourceContainer, params CreateDNSRecordParams) (DNSRecord, error) {
|
|
if rc.Identifier == "" {
|
|
return DNSRecord{}, ErrMissingZoneID
|
|
}
|
|
params.Name = toUTS46ASCII(params.Name)
|
|
|
|
uri := fmt.Sprintf("/zones/%s/dns_records", rc.Identifier)
|
|
res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
|
|
if err != nil {
|
|
return DNSRecord{}, err
|
|
}
|
|
|
|
var recordResp *DNSRecordResponse
|
|
err = json.Unmarshal(res, &recordResp)
|
|
if err != nil {
|
|
return DNSRecord{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
|
|
}
|
|
|
|
return recordResp.Result, nil
|
|
}
|
|
|
|
// ListDNSRecords returns a slice of DNS records for the given zone identifier.
|
|
//
|
|
// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-list-dns-records
|
|
func (api *API) ListDNSRecords(ctx context.Context, rc *ResourceContainer, params ListDNSRecordsParams) ([]DNSRecord, *ResultInfo, error) {
|
|
if rc.Identifier == "" {
|
|
return nil, nil, ErrMissingZoneID
|
|
}
|
|
|
|
params.Name = toUTS46ASCII(params.Name)
|
|
|
|
autoPaginate := true
|
|
if params.PerPage >= 1 || params.Page >= 1 {
|
|
autoPaginate = false
|
|
}
|
|
|
|
if params.PerPage < 1 {
|
|
params.PerPage = listDNSRecordsDefaultPageSize
|
|
}
|
|
|
|
if params.Page < 1 {
|
|
params.Page = 1
|
|
}
|
|
|
|
var records []DNSRecord
|
|
var lastResultInfo ResultInfo
|
|
|
|
for {
|
|
uri := buildURI(fmt.Sprintf("/zones/%s/dns_records", rc.Identifier), params)
|
|
res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
|
|
if err != nil {
|
|
return []DNSRecord{}, &ResultInfo{}, err
|
|
}
|
|
var listResponse DNSListResponse
|
|
err = json.Unmarshal(res, &listResponse)
|
|
if err != nil {
|
|
return []DNSRecord{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
|
|
}
|
|
records = append(records, listResponse.Result...)
|
|
lastResultInfo = listResponse.ResultInfo
|
|
params.ResultInfo = listResponse.ResultInfo.Next()
|
|
if params.ResultInfo.Done() || !autoPaginate {
|
|
break
|
|
}
|
|
}
|
|
return records, &lastResultInfo, nil
|
|
}
|
|
|
|
// ErrMissingDNSRecordID is for when DNS record ID is needed but not given.
|
|
var ErrMissingDNSRecordID = errors.New("required DNS record ID missing")
|
|
|
|
// GetDNSRecord returns a single DNS record for the given zone & record
|
|
// identifiers.
|
|
//
|
|
// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-dns-record-details
|
|
func (api *API) GetDNSRecord(ctx context.Context, rc *ResourceContainer, recordID string) (DNSRecord, error) {
|
|
if rc.Identifier == "" {
|
|
return DNSRecord{}, ErrMissingZoneID
|
|
}
|
|
if recordID == "" {
|
|
return DNSRecord{}, ErrMissingDNSRecordID
|
|
}
|
|
|
|
uri := fmt.Sprintf("/zones/%s/dns_records/%s", rc.Identifier, recordID)
|
|
res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
|
|
if err != nil {
|
|
return DNSRecord{}, err
|
|
}
|
|
var r DNSRecordResponse
|
|
err = json.Unmarshal(res, &r)
|
|
if err != nil {
|
|
return DNSRecord{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
|
|
}
|
|
return r.Result, nil
|
|
}
|
|
|
|
// UpdateDNSRecord updates a single DNS record for the given zone & record
|
|
// identifiers.
|
|
//
|
|
// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record
|
|
func (api *API) UpdateDNSRecord(ctx context.Context, rc *ResourceContainer, params UpdateDNSRecordParams) (DNSRecord, error) {
|
|
if rc.Identifier == "" {
|
|
return DNSRecord{}, ErrMissingZoneID
|
|
}
|
|
|
|
if params.ID == "" {
|
|
return DNSRecord{}, ErrMissingDNSRecordID
|
|
}
|
|
|
|
params.Name = toUTS46ASCII(params.Name)
|
|
|
|
uri := fmt.Sprintf("/zones/%s/dns_records/%s", rc.Identifier, params.ID)
|
|
res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params)
|
|
if err != nil {
|
|
return DNSRecord{}, err
|
|
}
|
|
|
|
var recordResp *DNSRecordResponse
|
|
err = json.Unmarshal(res, &recordResp)
|
|
if err != nil {
|
|
return DNSRecord{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
|
|
}
|
|
|
|
return recordResp.Result, nil
|
|
}
|
|
|
|
// DeleteDNSRecord deletes a single DNS record for the given zone & record
|
|
// identifiers.
|
|
//
|
|
// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-delete-dns-record
|
|
func (api *API) DeleteDNSRecord(ctx context.Context, rc *ResourceContainer, recordID string) error {
|
|
if rc.Identifier == "" {
|
|
return ErrMissingZoneID
|
|
}
|
|
if recordID == "" {
|
|
return ErrMissingDNSRecordID
|
|
}
|
|
|
|
uri := fmt.Sprintf("/zones/%s/dns_records/%s", rc.Identifier, recordID)
|
|
res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var r DNSRecordResponse
|
|
err = json.Unmarshal(res, &r)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", errUnmarshalError, err)
|
|
}
|
|
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
|
|
}
|