├── LICENSE ├── README.md ├── client.go ├── go.mod ├── go.sum ├── models.go └── provider.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matthew Holt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Cloudflare for `libdns` 2 | ======================= 3 | 4 | [![godoc reference](https://img.shields.io/badge/godoc-reference-blue.svg)](https://pkg.go.dev/github.com/libdns/cloudflare) 5 | 6 | This package implements the [libdns interfaces](https://github.com/libdns/libdns) for [Cloudflare](https://www.cloudflare.com). 7 | 8 | ## Authenticating 9 | 10 | > [!IMPORTANT] 11 | > This package supports API **token** authentication (as opposed to legacy API **keys**). 12 | 13 | There are two approaches for token permissions supported by this package: 14 | 1. Single token for everything 15 | - `APIToken` permissions required: Zone:Read, Zone.DNS:Write - All zones 16 | 2. Dual token method 17 | - `ZoneToken` permissions required: Zone:Read - All zones 18 | - `APIToken` permissions required: Zone.DNS:Write - for the zone(s) you wish to manage 19 | 20 | The dual token method allows users who have multiple DNS zones in their Cloudflare account to restrict which zones the token can access, whereas the first method will allow access to all DNS Zones. 21 | If you only have one domain/zone then this approach does not provide any benefit, and you might as well just have the single API token 22 | 23 | To use the dual token approach simply ensure that the `ZoneToken` property is provided - otherwise the package will use `APIToken` for all API requests. 24 | 25 | To clarify, do NOT use API keys, which are globally-scoped: 26 | 27 | ![Don't use API keys](https://user-images.githubusercontent.com/1128849/81196485-556aca00-8f7c-11ea-9e13-c6a8a966f689.png) 28 | 29 | DO use scoped API tokens: 30 | 31 | ![Don't use API keys](https://user-images.githubusercontent.com/1128849/81196503-5c91d800-8f7c-11ea-93cc-ad7d73420fab.png) 32 | 33 | ## Example Configuration 34 | 35 | ```golang 36 | // With Auth 37 | p := cloudflare.Provider{ 38 | APIToken: "apitoken", 39 | ZoneToken: "zonetoken", // optional 40 | } 41 | 42 | // With Custom HTTP Client 43 | p := cloudflare.Provider{ 44 | APIToken: "apitoken", 45 | HTTPClient: http.Client{ 46 | Timeout: 10 * time.Second, 47 | }, 48 | } 49 | ``` 50 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | "github.com/libdns/libdns" 13 | ) 14 | 15 | func (p *Provider) createRecord(ctx context.Context, zoneInfo cfZone, record libdns.Record) (cfDNSRecord, error) { 16 | cfRec, err := cloudflareRecord(record) 17 | if err != nil { 18 | return cfDNSRecord{}, err 19 | } 20 | jsonBytes, err := json.Marshal(cfRec) 21 | if err != nil { 22 | return cfDNSRecord{}, err 23 | } 24 | 25 | reqURL := fmt.Sprintf("%s/zones/%s/dns_records", baseURL, zoneInfo.ID) 26 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(jsonBytes)) 27 | if err != nil { 28 | return cfDNSRecord{}, err 29 | } 30 | req.Header.Set("Content-Type", "application/json") 31 | 32 | var result cfDNSRecord 33 | _, err = p.doAPIRequest(req, &result) 34 | if err != nil { 35 | return cfDNSRecord{}, err 36 | } 37 | 38 | return result, nil 39 | } 40 | 41 | // updateRecord updates a DNS record. oldRec must have both an ID and zone ID. 42 | // Only the non-empty fields in newRec will be changed. 43 | func (p *Provider) updateRecord(ctx context.Context, oldRec, newRec cfDNSRecord) (cfDNSRecord, error) { 44 | reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", baseURL, oldRec.ZoneID, oldRec.ID) 45 | jsonBytes, err := json.Marshal(newRec) 46 | if err != nil { 47 | return cfDNSRecord{}, err 48 | } 49 | 50 | // PATCH changes only the populated fields; PUT resets Type, Name, Content, and TTL even if empty 51 | req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(jsonBytes)) 52 | if err != nil { 53 | return cfDNSRecord{}, err 54 | } 55 | req.Header.Set("Content-Type", "application/json") 56 | 57 | var result cfDNSRecord 58 | _, err = p.doAPIRequest(req, &result) 59 | return result, err 60 | } 61 | 62 | func (p *Provider) getDNSRecords(ctx context.Context, zoneInfo cfZone, rec libdns.Record, matchContent bool) ([]cfDNSRecord, error) { 63 | rr, err := cloudflareRecord(rec) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | qs := make(url.Values) 69 | qs.Set("type", rr.Type) 70 | qs.Set("name", libdns.AbsoluteName(rr.Name, zoneInfo.Name)) 71 | 72 | var unwrappedContent string 73 | if matchContent { 74 | if rr.Type == "TXT" { 75 | unwrappedContent = unwrapContent(rr.Content) 76 | // Use the contains (wildcard) search with unquoted content to return both quoted and unquoted content 77 | qs.Set("content.contains", unwrappedContent) 78 | } else { 79 | qs.Set("content.exact", rr.Content) 80 | } 81 | } 82 | 83 | reqURL := fmt.Sprintf("%s/zones/%s/dns_records?%s", baseURL, zoneInfo.ID, qs.Encode()) 84 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | var results []cfDNSRecord 90 | _, err = p.doAPIRequest(req, &results) 91 | 92 | // Since the TXT search used contains (wildcard), check for exact matches 93 | if matchContent && rr.Type == "TXT" { 94 | for i := 0; i < len(results); i++ { 95 | // Prefer exact quoted content 96 | if results[i].Content == rr.Content { 97 | return []cfDNSRecord{results[i]}, nil 98 | } 99 | } 100 | 101 | for i := 0; i < len(results); i++ { 102 | // Using exact unquoted content is acceptable 103 | if results[i].Content == unwrappedContent { 104 | return []cfDNSRecord{results[i]}, nil 105 | } 106 | } 107 | 108 | return []cfDNSRecord{}, nil 109 | } 110 | 111 | return results, err 112 | } 113 | 114 | func (p *Provider) getZoneInfo(ctx context.Context, zoneName string) (cfZone, error) { 115 | p.zonesMu.Lock() 116 | defer p.zonesMu.Unlock() 117 | 118 | // if we already got the zone info, reuse it 119 | if p.zones == nil { 120 | p.zones = make(map[string]cfZone) 121 | } 122 | if zone, ok := p.zones[zoneName]; ok { 123 | return zone, nil 124 | } 125 | 126 | qs := make(url.Values) 127 | qs.Set("name", zoneName) 128 | reqURL := fmt.Sprintf("%s/zones?%s", baseURL, qs.Encode()) 129 | 130 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) 131 | if err != nil { 132 | return cfZone{}, err 133 | } 134 | 135 | if p.ZoneToken != "" { 136 | req.Header.Set("Authorization", "Bearer "+p.ZoneToken) 137 | } 138 | var zones []cfZone 139 | _, err = p.doAPIRequest(req, &zones) 140 | if err != nil { 141 | return cfZone{}, err 142 | } 143 | if len(zones) != 1 { 144 | return cfZone{}, fmt.Errorf("expected 1 zone, got %d for %s", len(zones), zoneName) 145 | } 146 | 147 | // cache this zone for possible reuse 148 | p.zones[zoneName] = zones[0] 149 | 150 | return zones[0], nil 151 | } 152 | 153 | // getClient returns http client to use 154 | func (p *Provider) getClient() HTTPClient { 155 | if p.HTTPClient == nil { 156 | return http.DefaultClient 157 | } 158 | return p.HTTPClient 159 | } 160 | 161 | // doAPIRequest does the round trip, adding Authorization header if not already supplied. 162 | // It returns the decoded response from Cloudflare if successful; otherwise it returns an 163 | // error including error information from the API if applicable. If result is a 164 | // non-nil pointer, the result field from the API response will be decoded into 165 | // it for convenience. 166 | func (p *Provider) doAPIRequest(req *http.Request, result any) (cfResponse, error) { 167 | if req.Header.Get("Authorization") == "" { 168 | req.Header.Set("Authorization", "Bearer "+p.APIToken) 169 | } 170 | 171 | resp, err := p.getClient().Do(req) 172 | if err != nil { 173 | return cfResponse{}, err 174 | } 175 | defer resp.Body.Close() 176 | 177 | var respData cfResponse 178 | err = json.NewDecoder(resp.Body).Decode(&respData) 179 | if err != nil { 180 | return cfResponse{}, err 181 | } 182 | 183 | if resp.StatusCode >= 400 { 184 | return cfResponse{}, fmt.Errorf("got error status: HTTP %d: %+v", resp.StatusCode, respData.Errors) 185 | } 186 | if len(respData.Errors) > 0 { 187 | return cfResponse{}, fmt.Errorf("got errors: HTTP %d: %+v", resp.StatusCode, respData.Errors) 188 | } 189 | 190 | if len(respData.Result) > 0 && result != nil { 191 | err = json.Unmarshal(respData.Result, result) 192 | if err != nil { 193 | return cfResponse{}, err 194 | } 195 | respData.Result = nil 196 | } 197 | 198 | return respData, err 199 | } 200 | 201 | const baseURL = "https://api.cloudflare.com/client/v4" 202 | 203 | func unwrapContent(content string) string { 204 | if strings.HasPrefix(content, `"`) && strings.HasSuffix(content, `"`) { 205 | content = strings.TrimPrefix(strings.TrimSuffix(content, `"`), `"`) 206 | } 207 | return content 208 | } 209 | 210 | func wrapContent(content string) string { 211 | if !strings.HasPrefix(content, `"`) && !strings.HasSuffix(content, `"`) { 212 | content = fmt.Sprintf("%q", content) 213 | } 214 | return content 215 | } 216 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/libdns/cloudflare 2 | 3 | go 1.18 4 | 5 | require github.com/libdns/libdns v1.0.0-beta.1 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/libdns/libdns v1.0.0-beta.1 h1:KIf4wLfsrEpXpZ3vmc/poM8zCATXT2klbdPe6hyOBjQ= 2 | github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= 3 | -------------------------------------------------------------------------------- /models.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/netip" 7 | "strings" 8 | "time" 9 | 10 | "github.com/libdns/libdns" 11 | ) 12 | 13 | type cfZone struct { 14 | ID string `json:"id"` 15 | Name string `json:"name"` 16 | DevelopmentMode int `json:"development_mode"` 17 | OriginalNameServers []string `json:"original_name_servers"` 18 | OriginalRegistrar string `json:"original_registrar"` 19 | OriginalDnshost string `json:"original_dnshost"` 20 | CreatedOn time.Time `json:"created_on"` 21 | ModifiedOn time.Time `json:"modified_on"` 22 | ActivatedOn time.Time `json:"activated_on"` 23 | Account struct { 24 | ID string `json:"id"` 25 | Name string `json:"name"` 26 | } `json:"account"` 27 | Permissions []string `json:"permissions"` 28 | Plan struct { 29 | ID string `json:"id"` 30 | Name string `json:"name"` 31 | Price int `json:"price"` 32 | Currency string `json:"currency"` 33 | Frequency string `json:"frequency"` 34 | LegacyID string `json:"legacy_id"` 35 | IsSubscribed bool `json:"is_subscribed"` 36 | CanSubscribe bool `json:"can_subscribe"` 37 | } `json:"plan"` 38 | PlanPending struct { 39 | ID string `json:"id"` 40 | Name string `json:"name"` 41 | Price int `json:"price"` 42 | Currency string `json:"currency"` 43 | Frequency string `json:"frequency"` 44 | LegacyID string `json:"legacy_id"` 45 | IsSubscribed bool `json:"is_subscribed"` 46 | CanSubscribe bool `json:"can_subscribe"` 47 | } `json:"plan_pending"` 48 | Status string `json:"status"` 49 | Paused bool `json:"paused"` 50 | Type string `json:"type"` 51 | NameServers []string `json:"name_servers"` 52 | } 53 | 54 | type cfDNSRecord struct { 55 | ID string `json:"id,omitempty"` 56 | Type string `json:"type,omitempty"` 57 | Name string `json:"name,omitempty"` 58 | Content string `json:"content,omitempty"` 59 | Priority uint16 `json:"priority,omitempty"` 60 | Proxiable bool `json:"proxiable,omitempty"` 61 | Proxied bool `json:"proxied,omitempty"` 62 | TTL int `json:"ttl,omitempty"` // seconds 63 | Locked bool `json:"locked,omitempty"` 64 | ZoneID string `json:"zone_id,omitempty"` 65 | ZoneName string `json:"zone_name,omitempty"` 66 | CreatedOn time.Time `json:"created_on,omitempty"` 67 | ModifiedOn time.Time `json:"modified_on,omitempty"` 68 | Data struct { 69 | // LOC 70 | LatDegrees int `json:"lat_degrees,omitempty"` 71 | LatMinutes int `json:"lat_minutes,omitempty"` 72 | LatSeconds int `json:"lat_seconds,omitempty"` 73 | LatDirection string `json:"lat_direction,omitempty"` 74 | LongDegrees int `json:"long_degrees,omitempty"` 75 | LongMinutes int `json:"long_minutes,omitempty"` 76 | LongSeconds int `json:"long_seconds,omitempty"` 77 | LongDirection string `json:"long_direction,omitempty"` 78 | Altitude int `json:"altitude,omitempty"` 79 | Size int `json:"size,omitempty"` 80 | PrecisionHorz int `json:"precision_horz,omitempty"` 81 | PrecisionVert int `json:"precision_vert,omitempty"` 82 | 83 | // SRV, HTTPS 84 | Service string `json:"service,omitempty"` 85 | Proto string `json:"proto,omitempty"` 86 | Name string `json:"name,omitempty"` 87 | Priority uint16 `json:"priority,omitempty"` 88 | Weight uint16 `json:"weight,omitempty"` 89 | Port uint16 `json:"port,omitempty"` 90 | Target string `json:"target,omitempty"` 91 | 92 | // CAA, SRV, HTTPS 93 | Value string `json:"value,omitempty"` 94 | 95 | // CAA 96 | Tag string `json:"tag"` 97 | 98 | // CAA, DNSKEY 99 | Flags int `json:"flags,omitempty"` 100 | // DNSKEY 101 | Protocol int `json:"protocol,omitempty"` 102 | Algorithm int `json:"algorithm,omitempty"` 103 | 104 | // DS 105 | KeyTag int `json:"key_tag,omitempty"` 106 | DigestType int `json:"digest_type,omitempty"` 107 | 108 | // TLSA 109 | Usage int `json:"usage,omitempty"` 110 | Selector int `json:"selector,omitempty"` 111 | MatchingType int `json:"matching_type,omitempty"` 112 | 113 | // URI 114 | Content string `json:"content,omitempty"` 115 | } `json:"data,omitempty"` 116 | Meta *struct { 117 | AutoAdded bool `json:"auto_added,omitempty"` 118 | Source string `json:"source,omitempty"` 119 | EmailRouting bool `json:"email_routing,omitempty"` 120 | ReadOnly bool `json:"read_only,omitempty"` 121 | } `json:"meta,omitempty"` 122 | } 123 | 124 | func (r cfDNSRecord) libdnsRecord(zone string) (libdns.Record, error) { 125 | name := libdns.RelativeName(r.Name, zone) 126 | ttl := time.Duration(r.TTL) * time.Second 127 | switch r.Type { 128 | case "A", "AAAA": 129 | addr, err := netip.ParseAddr(r.Content) 130 | if err != nil { 131 | return libdns.Address{}, fmt.Errorf("invalid IP address %q: %v", r.Data, err) 132 | } 133 | return libdns.Address{ 134 | Name: name, 135 | TTL: ttl, 136 | IP: addr, 137 | }, nil 138 | case "CAA": 139 | // NOTE: CAA records from Cloudflare have a `r.Content` that can be 140 | // parsed by [libdns.RR.Parse], but all the data we need is already sent 141 | // to us in a structured format by Cloudflare, so we use that instead. 142 | return libdns.CAA{ 143 | Name: name, 144 | TTL: ttl, 145 | Flags: uint8(r.Data.Flags), 146 | Tag: r.Data.Tag, 147 | Value: r.Data.Value, 148 | }, nil 149 | case "CNAME": 150 | return libdns.CNAME{ 151 | Name: name, 152 | TTL: ttl, 153 | Target: r.Content, 154 | }, nil 155 | case "MX": 156 | return libdns.MX{ 157 | Name: name, 158 | TTL: ttl, 159 | Preference: r.Priority, 160 | Target: r.Content, 161 | }, nil 162 | case "NS": 163 | return libdns.NS{ 164 | Name: name, 165 | TTL: ttl, 166 | Target: r.Content, 167 | }, nil 168 | case "SRV": 169 | parts := strings.SplitN(name, ".", 3) 170 | if len(parts) < 3 { 171 | return libdns.SRV{}, fmt.Errorf("name %v does not contain enough fields; expected format: '_service._proto.name'", name) 172 | } 173 | return libdns.SRV{ 174 | Service: strings.TrimPrefix(parts[0], "_"), 175 | Transport: strings.TrimPrefix(parts[1], "_"), 176 | Name: parts[2], 177 | TTL: ttl, 178 | Priority: r.Data.Priority, 179 | Weight: r.Data.Weight, 180 | Port: r.Data.Port, 181 | Target: r.Data.Target, 182 | }, nil 183 | case "TXT": 184 | // unwrap the quotes from the content 185 | unwrappedContent := unwrapContent(r.Content) 186 | return libdns.TXT{ 187 | Name: name, 188 | TTL: ttl, 189 | Text: unwrappedContent, 190 | }, nil 191 | // NOTE: HTTPS records from Cloudflare have a `r.Content` that can be 192 | // parsed by [libdns.RR.Parse] so that is what we do here. While we are 193 | // provided with structured data, it still requires a bit of parsing 194 | // that would end up duplicating the code from libdns anyways. 195 | // case "HTTPS", "SVCB": 196 | // fallthrough 197 | default: 198 | return libdns.RR{ 199 | Name: name, 200 | TTL: ttl, 201 | Type: r.Type, 202 | Data: r.Content, 203 | }.Parse() 204 | } 205 | } 206 | 207 | func cloudflareRecord(r libdns.Record) (cfDNSRecord, error) { 208 | // Super annoyingly, the Cloudflare API says that a "Content" 209 | // field can contain the record data as a string, and that the 210 | // individual component fields are optional (this would be 211 | // ideal so we don't have to parse every single record type 212 | // into a separate struct, we can just submit the Content 213 | // string like what the RR struct has for us); yet when I try 214 | // to submit records using the Content field, I get errors 215 | // saying that the individual data components are required, 216 | // despite the docs saying they're optional. 217 | // So, instead of a 5-line function, we have a much bigger 218 | // more complicated and error prone function here. 219 | // And of course there's no real good venue to file a bug report: 220 | // https://community.cloudflare.com/t/creating-srv-record-with-content-string-instead-of-individual-component-fields/781178?u=mholt 221 | rr := r.RR() 222 | cfRec := cfDNSRecord{ 223 | // ID: r.ID, 224 | Name: rr.Name, 225 | Type: rr.Type, 226 | TTL: int(rr.TTL.Seconds()), 227 | Content: rr.Data, 228 | } 229 | switch rec := r.(type) { 230 | case libdns.SRV: 231 | cfRec.Data.Service = "_" + rec.Service 232 | cfRec.Data.Priority = rec.Priority 233 | cfRec.Data.Weight = rec.Weight 234 | cfRec.Data.Proto = "_" + rec.Transport 235 | cfRec.Data.Name = rec.Name 236 | cfRec.Data.Port = rec.Port 237 | cfRec.Data.Target = rec.Target 238 | case libdns.ServiceBinding: 239 | cfRec.Name = rec.Name 240 | cfRec.Data.Priority = rec.Priority 241 | cfRec.Data.Target = rec.Target 242 | cfRec.Data.Value = rec.Params.String() 243 | } 244 | if rr.Type == "CNAME" && strings.HasSuffix(cfRec.Content, ".cfargotunnel.com") { 245 | cfRec.Proxied = true 246 | } 247 | if rr.Type == "TXT" { 248 | // wrap the content in quotes 249 | cfRec.Content = wrapContent(cfRec.Content) 250 | } 251 | return cfRec, nil 252 | } 253 | 254 | // All API responses have this structure. 255 | type cfResponse struct { 256 | Result json.RawMessage `json:"result,omitempty"` 257 | Success bool `json:"success"` 258 | Errors []struct { 259 | Code int `json:"code"` 260 | Message string `json:"message"` 261 | ErrorChain []struct { 262 | Code int `json:"code"` 263 | Message string `json:"message"` 264 | } `json:"error_chain,omitempty"` 265 | } `json:"errors,omitempty"` 266 | Messages []any `json:"messages,omitempty"` 267 | ResultInfo *cfResultInfo `json:"result_info,omitempty"` 268 | } 269 | 270 | type cfResultInfo struct { 271 | Page int `json:"page"` 272 | PerPage int `json:"per_page"` 273 | Count int `json:"count"` 274 | TotalCount int `json:"total_count"` 275 | } 276 | -------------------------------------------------------------------------------- /provider.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "sync" 9 | 10 | "github.com/libdns/libdns" 11 | ) 12 | 13 | type HTTPClient interface { 14 | Do(req *http.Request) (*http.Response, error) 15 | } 16 | 17 | // Provider implements the libdns interfaces for Cloudflare. 18 | // TODO: Support retries and handle rate limits. 19 | type Provider struct { 20 | // API tokens are used for authentication. Make sure to use 21 | // scoped API **tokens**, NOT a global API **key**. 22 | APIToken string `json:"api_token,omitempty"` // API token with Zone.DNS:Write (can be scoped to single Zone if ZoneToken is also provided) 23 | ZoneToken string `json:"zone_token,omitempty"` // Optional Zone:Read token (global scope) 24 | 25 | // HTTPClient is the client used to communicate with Cloudflare. 26 | // If nil, a default client will be used. 27 | HTTPClient HTTPClient `json:"-"` 28 | 29 | zones map[string]cfZone 30 | zonesMu sync.Mutex 31 | } 32 | 33 | // GetRecords lists all the records in the zone. 34 | func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { 35 | zoneInfo, err := p.getZoneInfo(ctx, zone) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | page := 1 41 | const maxPageSize = 100 42 | 43 | var allRecords []cfDNSRecord 44 | for { 45 | qs := make(url.Values) 46 | qs.Set("page", fmt.Sprintf("%d", page)) 47 | qs.Set("per_page", fmt.Sprintf("%d", maxPageSize)) 48 | reqURL := fmt.Sprintf("%s/zones/%s/dns_records?%s", baseURL, zoneInfo.ID, qs.Encode()) 49 | 50 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | var pageRecords []cfDNSRecord 56 | response, err := p.doAPIRequest(req, &pageRecords) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | allRecords = append(allRecords, pageRecords...) 62 | 63 | lastPage := (response.ResultInfo.TotalCount + response.ResultInfo.PerPage - 1) / response.ResultInfo.PerPage 64 | if response.ResultInfo == nil || page >= lastPage || len(pageRecords) == 0 { 65 | break 66 | } 67 | 68 | page++ 69 | } 70 | 71 | recs := make([]libdns.Record, 0, len(allRecords)) 72 | for _, rec := range allRecords { 73 | libdnsRec, err := rec.libdnsRecord(zone) 74 | if err != nil { 75 | return nil, fmt.Errorf("parsing Cloudflare DNS record %+v: %v", rec, err) 76 | } 77 | recs = append(recs, libdnsRec) 78 | } 79 | 80 | return recs, nil 81 | } 82 | 83 | // AppendRecords adds records to the zone. It returns the records that were added. 84 | func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { 85 | zoneInfo, err := p.getZoneInfo(ctx, zone) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | var created []libdns.Record 91 | for _, rec := range records { 92 | result, err := p.createRecord(ctx, zoneInfo, rec) 93 | if err != nil { 94 | return nil, err 95 | } 96 | libdnsRec, err := result.libdnsRecord(zone) 97 | if err != nil { 98 | return nil, fmt.Errorf("parsing Cloudflare DNS record %+v: %v", rec, err) 99 | } 100 | created = append(created, libdnsRec) 101 | } 102 | 103 | return created, nil 104 | } 105 | 106 | // DeleteRecords deletes the records from the zone. If a record does not have an ID, 107 | // it will be looked up. It returns the records that were deleted. 108 | func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { 109 | zoneInfo, err := p.getZoneInfo(ctx, zone) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | var recs []libdns.Record 115 | for _, rec := range records { 116 | // record ID is required; try to find it with what was provided 117 | exactMatches, err := p.getDNSRecords(ctx, zoneInfo, rec, true) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | for _, cfRec := range exactMatches { 123 | reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", baseURL, zoneInfo.ID, cfRec.ID) 124 | req, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | var result cfDNSRecord 130 | _, err = p.doAPIRequest(req, &result) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | libdnsRec, err := result.libdnsRecord(zone) 136 | if err != nil { 137 | return nil, fmt.Errorf("parsing Cloudflare DNS record %+v: %v", rec, err) 138 | } 139 | recs = append(recs, libdnsRec) 140 | } 141 | 142 | } 143 | 144 | return recs, nil 145 | } 146 | 147 | // SetRecords sets the records in the zone, either by updating existing records 148 | // or creating new ones. It returns the updated records. 149 | func (p *Provider) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { 150 | zoneInfo, err := p.getZoneInfo(ctx, zone) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | var results []libdns.Record 156 | for _, rec := range records { 157 | oldRec, err := cloudflareRecord(rec) 158 | if err != nil { 159 | return nil, err 160 | } 161 | oldRec.ZoneID = zoneInfo.ID 162 | 163 | // the record might already exist, even if we don't know the ID yet 164 | matches, err := p.getDNSRecords(ctx, zoneInfo, rec, false) 165 | if err != nil { 166 | return nil, err 167 | } 168 | if len(matches) == 0 { 169 | // record doesn't exist; create it 170 | result, err := p.createRecord(ctx, zoneInfo, rec) 171 | if err != nil { 172 | return nil, err 173 | } 174 | libdnsRec, err := result.libdnsRecord(zone) 175 | if err != nil { 176 | return nil, fmt.Errorf("parsing Cloudflare DNS record %+v: %v", rec, err) 177 | } 178 | results = append(results, libdnsRec) 179 | continue 180 | } 181 | if len(matches) > 1 { 182 | return nil, fmt.Errorf("unexpectedly found more than 1 record for %v", rec) 183 | } 184 | // record does exist, fill in the ID so that we can update it 185 | oldRec.ID = matches[0].ID 186 | 187 | // record exists; update it 188 | cfRec, err := cloudflareRecord(rec) 189 | if err != nil { 190 | return nil, err 191 | } 192 | result, err := p.updateRecord(ctx, oldRec, cfRec) 193 | if err != nil { 194 | return nil, err 195 | } 196 | libdnsRec, err := result.libdnsRecord(zone) 197 | if err != nil { 198 | return nil, fmt.Errorf("parsing Cloudflare DNS record %+v: %v", rec, err) 199 | } 200 | results = append(results, libdnsRec) 201 | } 202 | 203 | return results, nil 204 | } 205 | 206 | // Interface guards 207 | var ( 208 | _ libdns.RecordGetter = (*Provider)(nil) 209 | _ libdns.RecordAppender = (*Provider)(nil) 210 | _ libdns.RecordSetter = (*Provider)(nil) 211 | _ libdns.RecordDeleter = (*Provider)(nil) 212 | ) 213 | --------------------------------------------------------------------------------