Skip to content

Commit bf85afa

Browse files
authored
Implement libdnstest and fix related issues (CAA, MX, SVCB, trailing dot preservation) (#29)
- Add proper libdnstest implementation with full test coverage - Fix CAA record handling with proper flags pointer for zero values - Fix MX record preference field handling (use root-level Priority) - Fix SVCB/HTTPS record support with structured data fields - Fix trailing dot handling across CNAME, NS, MX, SRV targets - Improve SRV record parsing using libdns built-in parser - Add comprehensive test suite for all record types - Document Cloudflare API quirks and workarounds
1 parent c574dcc commit bf85afa

File tree

9 files changed

+184
-22
lines changed

9 files changed

+184
-22
lines changed

client.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ func (p *Provider) getDNSRecords(ctx context.Context, zoneInfo cfZone, rec libdn
7575
unwrappedContent = unwrapContent(rr.Content)
7676
// Use the contains (wildcard) search with unquoted content to return both quoted and unquoted content
7777
qs.Set("content.contains", unwrappedContent)
78-
} else {
78+
} else if rr.Type != "SRV" && rr.Type != "HTTPS" && rr.Type != "SVCB" {
79+
// SRV, HTTPS, SVCB records don't support content.exact filtering in Cloudflare API
80+
// They will be matched by type and name only
7981
qs.Set("content.exact", rr.Content)
8082
}
8183
}

libdnstest/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Cloudflare credentials for E2E testing
2+
# Single token for everything: set CLOUDFLARE_API_TOKEN
3+
# Dual token method: set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_TOKEN
4+
5+
CLOUDFLARE_API_TOKEN=your-api-token-here
6+
CLOUDFLARE_ZONE_TOKEN=your-zone-token-here
7+
8+
# test zone FQDN (include trailing dot)
9+
CLOUDFLARE_TEST_ZONE=example.com.

libdnstest/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.env

libdnstest/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Provider-Specific Tests for Cloudflare
2+
3+
This directory contains provider-specific tests for the Cloudflare libdns provider using the official [libdnstest package](https://github.com/libdns/libdns/tree/master/libdnstest). These tests verify the provider implementation against the real Cloudflare API, ensuring all libdns interface methods work correctly with actual DNS operations.
4+
5+
## How To Run
6+
7+
1. **Get API Token and setup zone**: See main README for token setup instructions. Test will use single or dual token depending on env variables. Setup some test Cloudflare zone.
8+
9+
2. **Set Environment Variables**:
10+
```bash
11+
export CLOUDFLARE_API_TOKEN="your-token-here"
12+
export CLOUDFLARE_TEST_ZONE="example.org." # Include trailing dot
13+
```
14+
15+
Or copy `.env.example` to `.env` and fill in values.
16+
17+
3. **Run Tests**
18+
19+
```bash
20+
set -a && source .env && set +a && go test -v
21+
```
22+
23+
## What Gets Tested
24+
25+
- ListZones, GetRecords, AppendRecords, SetRecords, DeleteRecords
26+
- Complete record lifecycle (create → update → delete)
27+
- Various DNS record types
28+
29+
**Warning**: Tests create/delete real DNS records prefixed with "test-". Use a dedicated test zone or ensure you have backups.

libdnstest/cloudflare_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"strings"
6+
"testing"
7+
8+
"github.com/libdns/cloudflare"
9+
"github.com/libdns/libdns/libdnstest"
10+
)
11+
12+
func TestCloudflareProvider(t *testing.T) {
13+
apiToken := os.Getenv("CLOUDFLARE_API_TOKEN")
14+
zoneToken := os.Getenv("CLOUDFLARE_ZONE_TOKEN")
15+
testZone := os.Getenv("CLOUDFLARE_TEST_ZONE")
16+
17+
if apiToken == "" || testZone == "" {
18+
t.Skip("Skipping Cloudflare provider tests: CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_TEST_ZONE environment variables must be set")
19+
}
20+
21+
if !strings.HasSuffix(testZone, ".") {
22+
t.Fatal("We expect the test zone to to have trailing dot")
23+
}
24+
25+
provider := &cloudflare.Provider{
26+
APIToken: apiToken,
27+
ZoneToken: zoneToken, // optional
28+
}
29+
30+
suite := libdnstest.NewTestSuite(provider, testZone)
31+
suite.RunTests(t)
32+
}

libdnstest/go.mod

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module github.com/libdns/cloudflare/libdnstest
2+
3+
go 1.18
4+
5+
require (
6+
github.com/libdns/cloudflare v1.1.0
7+
github.com/libdns/libdns v1.1.0
8+
)
9+
10+
replace (
11+
github.com/libdns/cloudflare => ../
12+
github.com/libdns/libdns => github.com/libdns/libdns v1.2.0-alpha.1.0.20250913035451-da352cac42d0
13+
)

libdnstest/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/libdns/libdns v1.2.0-alpha.1.0.20250913035451-da352cac42d0 h1:wHCkMJv6YOAZVv7WHvrgkwoHlju6gKlE75KDA19tzMI=
2+
github.com/libdns/libdns v1.2.0-alpha.1.0.20250913035451-da352cac42d0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=

models.go

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ type cfDNSRecord struct {
9696
Tag string `json:"tag"`
9797

9898
// CAA, DNSKEY
99-
Flags int `json:"flags,omitempty"`
99+
Flags *int `json:"flags,omitempty"`
100100
// DNSKEY
101101
Protocol int `json:"protocol,omitempty"`
102102
Algorithm int `json:"algorithm,omitempty"`
@@ -121,14 +121,22 @@ type cfDNSRecord struct {
121121
} `json:"meta,omitempty"`
122122
}
123123

124+
// ensureTrailingDot adds a trailing dot if not present
125+
func ensureTrailingDot(s string) string {
126+
if s != "" && !strings.HasSuffix(s, ".") {
127+
return s + "."
128+
}
129+
return s
130+
}
131+
124132
func (r cfDNSRecord) libdnsRecord(zone string) (libdns.Record, error) {
125133
name := libdns.RelativeName(r.Name, zone)
126134
ttl := time.Duration(r.TTL) * time.Second
127135
switch r.Type {
128136
case "A", "AAAA":
129137
addr, err := netip.ParseAddr(r.Content)
130138
if err != nil {
131-
return libdns.Address{}, fmt.Errorf("invalid IP address %q: %v", r.Data, err)
139+
return libdns.Address{}, fmt.Errorf("invalid IP address %q: %v", r.Content, err)
132140
}
133141
return libdns.Address{
134142
Name: name,
@@ -139,47 +147,56 @@ func (r cfDNSRecord) libdnsRecord(zone string) (libdns.Record, error) {
139147
// NOTE: CAA records from Cloudflare have a `r.Content` that can be
140148
// parsed by [libdns.RR.Parse], but all the data we need is already sent
141149
// to us in a structured format by Cloudflare, so we use that instead.
150+
flags := uint8(0)
151+
if r.Data.Flags != nil {
152+
flags = uint8(*r.Data.Flags)
153+
}
142154
return libdns.CAA{
143155
Name: name,
144156
TTL: ttl,
145-
Flags: uint8(r.Data.Flags),
157+
Flags: flags,
146158
Tag: r.Data.Tag,
147159
Value: r.Data.Value,
148160
}, nil
149161
case "CNAME":
162+
// Cloudflare treats all CNAME targets as FQDNs and adds trailing dots during DNS resolution.
163+
// We need to add the trailing dot here to match what actually gets resolved in DNS.
164+
target := ensureTrailingDot(r.Content)
150165
return libdns.CNAME{
151166
Name: name,
152167
TTL: ttl,
153-
Target: r.Content,
168+
Target: target,
154169
}, nil
155170
case "MX":
171+
target := ensureTrailingDot(r.Content)
156172
return libdns.MX{
157173
Name: name,
158174
TTL: ttl,
159175
Preference: r.Priority,
160-
Target: r.Content,
176+
Target: target,
161177
}, nil
162178
case "NS":
179+
target := ensureTrailingDot(r.Content)
163180
return libdns.NS{
164181
Name: name,
165182
TTL: ttl,
166-
Target: r.Content,
183+
Target: target,
167184
}, nil
168185
case "SRV":
169-
parts := strings.SplitN(r.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'", r.Name)
186+
// NOTE: Cloudflare's Content field for SRV records is incomplete - it only contains
187+
// "weight port target" and omits the priority field. We construct the complete
188+
// data string from structured fields and use libdns's built-in parsing.
189+
target := ensureTrailingDot(r.Data.Target)
190+
data := fmt.Sprintf("%d %d %d %s", r.Data.Priority, r.Data.Weight, r.Data.Port, target)
191+
192+
// Use libdns's built-in parsing
193+
rr := libdns.RR{
194+
Name: name,
195+
TTL: ttl,
196+
Type: "SRV",
197+
Data: data,
172198
}
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
199+
return rr.Parse()
183200
case "TXT":
184201
// unwrap the quotes from the content
185202
unwrappedContent := unwrapContent(r.Content)
@@ -219,14 +236,35 @@ func cloudflareRecord(r libdns.Record) (cfDNSRecord, error) {
219236
// And of course there's no real good venue to file a bug report:
220237
// https://community.cloudflare.com/t/creating-srv-record-with-content-string-instead-of-individual-component-fields/781178?u=mholt
221238
rr := r.RR()
239+
content := rr.Data
240+
// Cloudflare API is inconsistent with trailing dots:
241+
// - It ACCEPTS targets with trailing dots when creating
242+
// - It RETURNS targets with trailing dots when fetching
243+
// - But it DOESN'T MATCH them for deletion if we send trailing dots
244+
// So we must strip them when sending to the API
245+
if rr.Type == "CNAME" || rr.Type == "NS" || rr.Type == "MX" {
246+
content = strings.TrimSuffix(content, ".")
247+
}
222248
cfRec := cfDNSRecord{
223249
// ID: r.ID,
224250
Name: rr.Name,
225251
Type: rr.Type,
226252
TTL: int(rr.TTL.Seconds()),
227-
Content: rr.Data,
253+
Content: content,
228254
}
229255
switch rec := r.(type) {
256+
case libdns.CAA:
257+
flags := int(rec.Flags)
258+
cfRec.Data.Flags = &flags
259+
cfRec.Data.Tag = rec.Tag
260+
cfRec.Data.Value = rec.Value
261+
// Use RR().Data which properly formats the content field
262+
cfRec.Content = rec.RR().Data
263+
case libdns.MX:
264+
cfRec.Priority = rec.Preference
265+
// Content should be just the target, not include priority
266+
// Must strip trailing dot for Cloudflare API
267+
cfRec.Content = strings.TrimSuffix(rec.Target, ".")
230268
case libdns.SRV:
231269
cfRec.Data.Service = "_" + rec.Service
232270
cfRec.Data.Priority = rec.Priority
@@ -235,11 +273,22 @@ func cloudflareRecord(r libdns.Record) (cfDNSRecord, error) {
235273
cfRec.Data.Name = rec.Name
236274
cfRec.Data.Port = rec.Port
237275
cfRec.Data.Target = rec.Target
276+
// Use the RR.Name() which already constructs the proper format
277+
cfRec.Name = rec.RR().Name
278+
// Note: We don't set Content field for SRV as Cloudflare uses structured data fields
279+
// and doesn't seem to support content.exact filtering for SRV records anyway
280+
// for the same reason we can avoid dealing with dots in Target
238281
case libdns.ServiceBinding:
239-
cfRec.Name = rec.Name
282+
// Get the RR representation which handles name construction
283+
rr := rec.RR()
284+
cfRec.Name = rr.Name
285+
cfRec.Type = rr.Type // This will be either "HTTPS" or "SVCB"
240286
cfRec.Data.Priority = rec.Priority
241287
cfRec.Data.Target = rec.Target
242288
cfRec.Data.Value = rec.Params.String()
289+
// Note: We don't set Content field for HTTPS/SVCB as Cloudflare uses structured data fields
290+
// and doesn't seem to support content.exact filtering for these record types anyway
291+
// for the same reason we can avoid dealing with dots in Target
243292
}
244293
if rr.Type == "CNAME" && strings.HasSuffix(cfRec.Content, ".cfargotunnel.com") {
245294
cfRec.Proxied = true

provider.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,35 @@ func (p *Provider) SetRecords(ctx context.Context, zone string, records []libdns
203203
return results, nil
204204
}
205205

206+
// ListZones lists all the zones in the account.
207+
func (p *Provider) ListZones(ctx context.Context) ([]libdns.Zone, error) {
208+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/zones", nil)
209+
if err != nil {
210+
return nil, err
211+
}
212+
213+
var cfZones []cfZone
214+
_, err = p.doAPIRequest(req, &cfZones)
215+
if err != nil {
216+
return nil, err
217+
}
218+
219+
zones := make([]libdns.Zone, len(cfZones))
220+
for i, cfZone := range cfZones {
221+
zones[i] = libdns.Zone{
222+
// Add trailing dot to make it a FQDN
223+
Name: cfZone.Name + ".",
224+
}
225+
}
226+
227+
return zones, nil
228+
}
229+
206230
// Interface guards
207231
var (
208232
_ libdns.RecordGetter = (*Provider)(nil)
209233
_ libdns.RecordAppender = (*Provider)(nil)
210234
_ libdns.RecordSetter = (*Provider)(nil)
211235
_ libdns.RecordDeleter = (*Provider)(nil)
236+
_ libdns.ZoneLister = (*Provider)(nil)
212237
)

0 commit comments

Comments
 (0)