Summary
For TXT records longer than 255 bytes (DKIM keys, SPF with many includes, long ACME challenges, etc.), the v0.2.2 Provider mishandles the round-trip between AppendRecords and the Cloudflare REST API's stored representation. Two related symptoms:
GetRecords returns libdns.TXT.Text with embedded zone-file segment separators. Cloudflare stores TXT >255 bytes as multiple "..." segments per RFC 1035; the API returns the literal stored representation "<chunk1>" "<chunk2>". unwrapContent only strips the outer quotes, so Text ends up as <chunk1>" "<chunk2> (with a literal " " 3-byte separator between segments). Callers that compare against the original value get false negatives.
DeleteRecords becomes a silent no-op. DeleteRecords calls getDNSRecords(rec, true), which in getDNSRecords builds the search content via cloudflareRecord(rec) → wrapContent. For TXT, wrapContent only adds outer quotes (uses fmt.Sprintf("%q", content)), it does not chunk the content. So rr.Content becomes "<410-byte string>" while the API returns the stored chunked form "<chunk1>" "<chunk2>". Neither results[i].Content == rr.Content nor results[i].Content == unwrappedContent ever matches; the function returns []cfDNSRecord{} and DeleteRecords reports zero deletions with no error.
Reproduction
Tested with libdns/libdns@v1.1.1 and libdns/cloudflare@v0.2.2.
ctx := context.Background()
p := &cloudflare.Provider{APIToken: os.Getenv("CLOUDFLARE_API_TOKEN")}
// 410-byte DKIM-shaped TXT
value := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA", 9) + "IDAQAB"
rec := libdns.TXT{Name: "postern-test._domainkey", Text: value, TTL: 5 * time.Minute}
zone := "example.com."
if _, err := p.AppendRecords(ctx, zone, []libdns.Record{rec}); err != nil {
log.Fatal(err)
}
// AppendRecords succeeds. Record is created and DNS-resolvable.
got, _ := p.GetRecords(ctx, zone)
for _, r := range got {
rr := r.RR()
if rr.Type == "TXT" && rr.Name == rec.Name {
fmt.Printf("rr.Data.len=%d (input was %d)\n", len(rr.Data), len(value))
// rr.Data.len=413 (input was 410)
// rr.Data still contains literal `" "` between segments
}
}
deleted, err := p.DeleteRecords(ctx, zone, []libdns.Record{rec})
fmt.Printf("delete err=%v deleted=%d\n", err, len(deleted))
// err=<nil> deleted=0 -- record stays in CF, AND keeps resolving
Diagnostic from the wild
Discovered while migrating a downstream consumer (postern-dns) from libdns 0.x → 1.x. The instrumented diagnostic reads:
postern-dns(debug): txt-delete provider=cloudflare zone="binarydreams.me." name="postern-e2e-test._domainkey.test-postern" value.len=410 value.prefix="v=DKIM1; k=rsa; p=MIIBIj" value.suffix="P6ZM79X0LiNriDWLOwIDAQAB"
postern-dns(debug): GetRecords returned 14 records in zone "binarydreams.me."
postern-dns(debug): TXT@"postern-e2e-test._domainkey.test-postern" gotype=libdns.TXT rr.Data.len=413 rr.Data.prefix="v=DKIM1; k=rsa; p=MIIBIj" rr.Data.suffix="P6ZM79X0LiNriDWLOwIDAQAB" eq=false diff_at=255
diff_at=255 is the smoking gun: every byte through index 254 matches, then rr.Data has " " while value has the next base64 char. The 3-byte length delta (413 vs 410) is the single embedded " " separator between two segments.
Affected
Anything that publishes a TXT record >255 bytes through this driver and later wants to retire it via DeleteRecords -- DKIM rotation is the obvious one, but also long SPF entries, certain ACME providers, and large site-verification tokens. The bug is silent: callers see nil error and a count of 0 deleted records (which most callers don't check), and the record keeps resolving forever. Combined with 81058 An identical record already exists on the next attempt to publish the same content, this can corrupt rotation state machines.
Suggested fix shape
I see at least three workable shapes; I'm happy to send a PR for whichever you'd prefer:
- Fix on read.
unwrapContent removes embedded zone-file separators in addition to outer quotes (e.g. strings.ReplaceAll(s, + "\" \"" + , "") after the existing prefix/suffix trim, or a small parser that reconstructs the joined string properly).
- Fix on write.
wrapContent produces zone-file chunked form for content >255 bytes ("chunk1" "chunk2" ...) so the round-trip is idempotent.
- Fix the comparator. In
getDNSRecords's match loop, compare normalized forms (strip " " separators on both sides) before declaring no match.
Fix-on-read alone resolves the GetRecords surface; fix-on-write or the comparator change is needed to make DeleteRecords work without consumer-side workarounds. Doing both is probably cleanest.
I have a downstream workaround in postern-dns that normalizes on read AND repackages into libdns.RR{Data: + "\"<text>\"" + } on delete to make wrapContent a no-op. Happy to mirror that into a PR here if you'd like.
Environment
- libdns/cloudflare: v0.2.2 (also master HEAD
57f633ac)
- libdns/libdns: v1.1.1
- go: 1.25
Summary
For TXT records longer than 255 bytes (DKIM keys, SPF with many includes, long ACME challenges, etc.), the v0.2.2
Providermishandles the round-trip betweenAppendRecordsand the Cloudflare REST API's stored representation. Two related symptoms:GetRecordsreturnslibdns.TXT.Textwith embedded zone-file segment separators. Cloudflare stores TXT >255 bytes as multiple"..."segments per RFC 1035; the API returns the literal stored representation"<chunk1>" "<chunk2>".unwrapContentonly strips the outer quotes, soTextends up as<chunk1>" "<chunk2>(with a literal" "3-byte separator between segments). Callers that compare against the original value get false negatives.DeleteRecordsbecomes a silent no-op.DeleteRecordscallsgetDNSRecords(rec, true), which ingetDNSRecordsbuilds the search content viacloudflareRecord(rec)→wrapContent. For TXT,wrapContentonly adds outer quotes (usesfmt.Sprintf("%q", content)), it does not chunk the content. Sorr.Contentbecomes"<410-byte string>"while the API returns the stored chunked form"<chunk1>" "<chunk2>". Neitherresults[i].Content == rr.Contentnorresults[i].Content == unwrappedContentever matches; the function returns[]cfDNSRecord{}andDeleteRecordsreports zero deletions with no error.Reproduction
Tested with
libdns/libdns@v1.1.1andlibdns/cloudflare@v0.2.2.Diagnostic from the wild
Discovered while migrating a downstream consumer (postern-dns) from libdns 0.x → 1.x. The instrumented diagnostic reads:
diff_at=255is the smoking gun: every byte through index 254 matches, thenrr.Datahas" "whilevaluehas the next base64 char. The 3-byte length delta (413 vs 410) is the single embedded" "separator between two segments.Affected
Anything that publishes a TXT record >255 bytes through this driver and later wants to retire it via
DeleteRecords-- DKIM rotation is the obvious one, but also long SPF entries, certain ACME providers, and large site-verification tokens. The bug is silent: callers seenilerror and a count of 0 deleted records (which most callers don't check), and the record keeps resolving forever. Combined with81058 An identical record already existson the next attempt to publish the same content, this can corrupt rotation state machines.Suggested fix shape
I see at least three workable shapes; I'm happy to send a PR for whichever you'd prefer:
unwrapContentremoves embedded zone-file separators in addition to outer quotes (e.g.strings.ReplaceAll(s,+ "\" \"" +, "")after the existing prefix/suffix trim, or a small parser that reconstructs the joined string properly).wrapContentproduces zone-file chunked form for content >255 bytes ("chunk1" "chunk2" ...) so the round-trip is idempotent.getDNSRecords's match loop, compare normalized forms (strip" "separators on both sides) before declaring no match.Fix-on-read alone resolves the GetRecords surface; fix-on-write or the comparator change is needed to make
DeleteRecordswork without consumer-side workarounds. Doing both is probably cleanest.I have a downstream workaround in postern-dns that normalizes on read AND repackages into
libdns.RR{Data:+ "\"<text>\"" +}on delete to makewrapContenta no-op. Happy to mirror that into a PR here if you'd like.Environment
57f633ac)