Skip to content

Long TXT (>255 bytes, e.g. DKIM) round-trip fails: GetRecords surfaces zone-file segment separators in Text; DeleteRecords silent no-op #32

@bindreams

Description

@bindreams

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:

  1. 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.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions