Skip to content

Commit ae9759d

Browse files
authored
CLOUDFLAREAPI: Prevent web UI from displaying warnings about TXT records (#3834)
# Issue The way that DNSControl does its TXT record updates results in the Cloudflare web dashboard telling people that they're bad people and should feel bad. # Resolution Guess Cloudflare's mystery protocol and encode data by what we think is correct. NOTE: Existing TXT records will not be fixed. The updated doc (https://docs.dnscontrol.org/provider/cloudflareapi) explains ways to manually remove the error.
1 parent fd75c58 commit ae9759d

File tree

7 files changed

+158
-9
lines changed

7 files changed

+158
-9
lines changed
39.1 KB
Loading

documentation/provider/cloudflareapi.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,23 @@ were created outside of DNSControl.
390390
Cloudflare has restrictions that may result in DNSControl's attempt to insert
391391
DS records to fail.
392392

393+
## TXT records
394+
395+
Do you see this warning in the Cloudflare dashboard?
396+
397+
> "The content field of TXT records must be in quotation marks. Cloudflare may
398+
> add quotation marks on your behalf, which will not affect how the record
399+
> works."
400+
401+
![Cloudflare dumb TXT warning](../assets/providers/cloudflareapi/invalid-warning.png)
402+
403+
TXT records created/updated by DNSControl v4.31.1 and prior will produce this warning. It is meaningless and should be ignored.
404+
405+
If you are unable to ignore the warning, any of these will remove it:
406+
407+
* In the Cloudflare dashboard, click to edit the record and immediately save it. As of 2026-01-21, Cloudflare's UI can fix the issue, not just complain about it.
408+
* Force DNSControl to update the record. Either change it (make an inconsequential change), or delete the TXT record and allow DNSControl to recreate it.
409+
393410
## Integration testing
394411

395412
The integration tests assume that Cloudflare Workers are enabled and the credentials used

pkg/txtutil/txtcode_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ func TestTxtDecode(t *testing.T) {
6565
{`"q4backs\\\\lash"`, []string{`q4backs\\lash`}},
6666
// HETZNER includes a space after the last quote. Make sure we handle that.
6767
{`"one" "more" `, []string{`one`, `more`}},
68+
// Edge case: unquoted strings are treated as literals to be joined with no space!
69+
{`v=spf1 -all`, []string{`v=spf1-all`}},
6870
}
6971
for i, test := range tests {
7072
got, err := txtDecode(test.data)
@@ -84,6 +86,8 @@ func TestTxtEncode(t *testing.T) {
8486
data []string
8587
expected string
8688
}{
89+
{[]string{"simple"}, `"simple"`},
90+
{[]string{`"quoted"`}, `"\"quoted\""`},
8791
{[]string{}, `""`},
8892
{[]string{``}, `""`},
8993
{[]string{`foo`}, `"foo"`},

providers/cloudflare/auditrecords.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import (
1111
func AuditRecords(records []*models.RecordConfig) []error {
1212
a := rejectif.Auditor{}
1313

14-
a.Add("TXT", rejectif.TxtHasTrailingSpace) // Last verified 2022-06-18
15-
16-
a.Add("TXT", rejectif.TxtIsEmpty) // Last verified 2022-06-18
14+
a.Add("TXT", rejectif.TxtIsEmpty) // Last verified 2026-01-21
1715

1816
return a.Audit(records)
1917
}

providers/cloudflare/cloudflareProvider.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
2020
"github.com/StackExchange/dnscontrol/v4/pkg/providers"
2121
"github.com/StackExchange/dnscontrol/v4/pkg/transform"
22+
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
2223
"github.com/StackExchange/dnscontrol/v4/pkg/zonecache"
2324
"github.com/StackExchange/dnscontrol/v4/providers/cloudflare/rtypes/cfsingleredirect"
2425
)
@@ -800,7 +801,11 @@ func (c *cloudflareProvider) nativeToRecord(domain string, cr cloudflare.DNSReco
800801
return nil, fmt.Errorf("unparsable SRV record received from cloudflare: %w", err)
801802
}
802803
case "TXT":
803-
err := rc.SetTargetTXT(cr.Content)
804+
s, err := parseCfTxtContent(cr.Content)
805+
if err != nil {
806+
return rc, err
807+
}
808+
err = rc.SetTargetTXT(s)
804809
return rc, err
805810
default:
806811
if err := rc.PopulateFromString(rType, cr.Content, domain); err != nil {
@@ -811,6 +816,55 @@ func (c *cloudflareProvider) nativeToRecord(domain string, cr cloudflare.DNSReco
811816
return rc, nil
812817
}
813818

819+
func parseCfTxtContent(s string) (string, error) {
820+
// Cloudflare encodes TXT records in a mystery format. They tell you when
821+
// you've done something wrong, but won't document what they do want.
822+
// If you use their web dashboard and enter the string as any normal human
823+
// would, they display a warning that you're a bad person and should feel
824+
// bad for doing that. However, they accept it just fine, and present it in
825+
// their API as a string like any person on this planet would expect. If
826+
// you enter the string with quotes, they accept that like a BIND zonefile.
827+
828+
// There is a difference between what you enter in their web dashboard, how
829+
// it is rewritten by the UI, and what you get in the JSON. Examples:
830+
831+
// dashboard: i love dns it is great
832+
// rewritten: "i love dns it is great"
833+
// seen json: "i love dns it is great"
834+
835+
// dashboard: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
836+
// rewritten: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
837+
// seen json: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
838+
839+
// dashboard: "i love dns" "it is great"
840+
// rewritten: "i love dns" "it is great"
841+
// seen json: "i love dns" "it is great"
842+
843+
// dashboard: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
844+
// rewritten: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
845+
// seen json: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
846+
847+
// dashboard: "xxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
848+
// rewritten: "xxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
849+
// seen json: "xxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
850+
851+
// From this we conclude:
852+
// If it begins and ends with a quote, use ParseQuoted() to decode it.
853+
// Otherwise, it is a raw string. They could just fucking tell us that in
854+
// the documenation, but where's the fun in that?
855+
856+
if s == "" {
857+
return "", nil
858+
}
859+
if s == `"` {
860+
return "", errors.New("invalid TXT record content: one double quote")
861+
}
862+
if s[0] == '"' && s[len(s)-1] == '"' {
863+
return txtutil.ParseQuoted(s)
864+
}
865+
return s, nil
866+
}
867+
814868
func getProxyMetadata(r *models.RecordConfig) map[string]string {
815869
if r.Type != "A" && r.Type != "AAAA" && r.Type != "CNAME" {
816870
return nil
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package cloudflare
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseCfTxtContent(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
input string
11+
want string
12+
wantErr bool
13+
}{
14+
{
15+
name: "empty string",
16+
input: "",
17+
want: "",
18+
wantErr: false,
19+
},
20+
{
21+
name: "unquoted string",
22+
input: "simple text",
23+
want: "simple text",
24+
wantErr: false,
25+
},
26+
{
27+
name: "quoted string",
28+
input: `"quoted text"`,
29+
want: "quoted text",
30+
wantErr: false,
31+
},
32+
{
33+
name: "quoted string with spaces",
34+
input: `"text with spaces"`,
35+
want: "text with spaces",
36+
wantErr: false,
37+
},
38+
{
39+
name: "only opening quote",
40+
input: `"incomplete`,
41+
want: `"incomplete`,
42+
wantErr: false,
43+
},
44+
{
45+
name: "only closing quote",
46+
input: `incomplete"`,
47+
want: `incomplete"`,
48+
wantErr: false,
49+
},
50+
{
51+
name: "single quote char",
52+
input: `"`,
53+
want: ``,
54+
wantErr: true,
55+
},
56+
{
57+
name: "double quotes only",
58+
input: `""`,
59+
want: "",
60+
wantErr: false,
61+
},
62+
}
63+
64+
for _, tt := range tests {
65+
t.Run(tt.name, func(t *testing.T) {
66+
got, err := parseCfTxtContent(tt.input)
67+
if (err != nil) != tt.wantErr {
68+
t.Errorf("parseCfTxtContent() error = %v, wantErr %v", err, tt.wantErr)
69+
return
70+
}
71+
if got != tt.want {
72+
t.Errorf("parseCfTxtContent() = %q, want %q", got, tt.want)
73+
}
74+
})
75+
}
76+
}

providers/cloudflare/rest.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import (
66
"fmt"
77
"strings"
88

9-
"github.com/cloudflare/cloudflare-go"
10-
"golang.org/x/net/idna"
11-
129
"github.com/StackExchange/dnscontrol/v4/models"
1310
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
1411
"github.com/StackExchange/dnscontrol/v4/pkg/rtypecontrol"
12+
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
1513
"github.com/StackExchange/dnscontrol/v4/providers/cloudflare/rtypes/cfsingleredirect"
14+
"github.com/cloudflare/cloudflare-go"
15+
"golang.org/x/net/idna"
1616
)
1717

1818
func (c *cloudflareProvider) fetchAllZones() (map[string]cloudflare.Zone, error) {
@@ -180,7 +180,7 @@ func (c *cloudflareProvider) createRecDiff2(rec *models.RecordConfig, domainID s
180180
case "MX":
181181
prio = fmt.Sprintf(" %d ", rec.MxPreference)
182182
case "TXT":
183-
content = rec.GetTargetTXTJoined()
183+
content = txtutil.EncodeQuoted(rec.GetTargetTXTJoined())
184184
case "DS":
185185
content = fmt.Sprintf("%d %d %d %s", rec.DsKeyTag, rec.DsAlgorithm, rec.DsDigestType, rec.DsDigest)
186186
}
@@ -258,7 +258,7 @@ func (c *cloudflareProvider) modifyRecord(domainID, recID string, proxied bool,
258258
}
259259
switch rec.Type {
260260
case "TXT":
261-
r.Content = rec.GetTargetTXTJoined()
261+
r.Content = txtutil.EncodeQuoted(rec.GetTargetTXTJoined())
262262
case "SRV":
263263
r.Data = cfSrvData(rec)
264264
r.Name = rec.GetLabelFQDN()

0 commit comments

Comments
 (0)