From 86064c8fcc12bf4044d88dd700b11d71953b747f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 16 Jun 2026 18:53:11 -0400 Subject: [PATCH 1/2] fix: quote TXT writes and clean stale markers --- README.md | 7 +++-- internal/drivers/dns.go | 33 ++++++++++++++++++-- internal/drivers/dns_test.go | 60 +++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7ce58a8..d5d81b5 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ resources: ``` TXT `data` values may be written with or without one outer pair of double -quotes. The driver sends raw TXT content to the Cloudflare API, compares quoted -and unquoted forms equivalently, and emits quoted TXT values in read/import -outputs for Cloudflare-compatible presentation. +quotes. The driver sends quoted TXT content to the Cloudflare API to match +Cloudflare's dashboard presentation, compares quoted and unquoted forms +equivalently, and emits quoted TXT values in read/import outputs for +Cloudflare-compatible presentation. ## HTTP Redirects diff --git a/internal/drivers/dns.go b/internal/drivers/dns.go index 148c6f5..0521e92 100644 --- a/internal/drivers/dns.go +++ b/internal/drivers/dns.go @@ -304,9 +304,13 @@ func (d *DNSDriver) applyRecords(ctx context.Context, zoneID, zoneName string, d } currentByKey := recordsByKey(current, zoneName) desiredKeys := map[string]struct{}{} + cleanupManagedMarkers := false for _, record := range desired { key := recordKey(record, zoneName) desiredKeys[key] = struct{}{} + if isWorkflowManagedMarker(record, zoneName) { + cleanupManagedMarkers = true + } existing := currentByKey[key] if len(existing) == 0 { if _, err := d.client.CreateRecord(ctx, zoneID, record); err != nil { @@ -321,6 +325,19 @@ func (d *DNSDriver) applyRecords(ctx context.Context, zoneID, zoneName string, d } } } + if cleanupManagedMarkers { + for _, record := range current { + if !isWorkflowManagedMarker(record, zoneName) { + continue + } + if _, ok := desiredKeys[recordKey(record, zoneName)]; ok { + continue + } + if err := d.client.DeleteRecord(ctx, zoneID, record.ID); err != nil { + return fmt.Errorf("delete stale workflow managed marker %q in zone %q: %w", record.Name, zoneID, err) + } + } + } if !manageUnlisted { return nil } @@ -603,6 +620,18 @@ func recordMatches(current, desired Record, domain string) bool { return true } +func isWorkflowManagedMarker(record Record, domain string) bool { + if !strings.EqualFold(record.Type, "TXT") { + return false + } + name := "_workflow-dns-managed." + strings.ToLower(strings.TrimSuffix(domain, ".")) + if canonicalName(record.Name, domain) != name { + return false + } + data := rawTXTData(record.Type, record.Data) + return strings.Contains(data, "heritage=wfinfra-v1") && strings.Contains(data, "managed_by=wfctl") +} + func domainAndZoneIDFromRef(ref interfaces.ResourceRef) (string, string) { if ref.ProviderID != "" && interfaces.ValidateProviderID(ref.ProviderID, interfaces.IDFormatDomainName) { return ref.ProviderID, "" @@ -953,7 +982,7 @@ func newRecordBody(record Record) cfdns.RecordNewParamsBody { Name: cloudflare.String(record.Name), TTL: cloudflare.F(cfdns.TTL(record.TTL)), Type: cloudflare.F(cfdns.RecordNewParamsBodyType(record.Type)), - Content: cloudflare.String(rawTXTData(record.Type, record.Data)), + Content: cloudflare.String(presentationTXTData(record.Type, record.Data)), } if record.Priority > 0 || record.Type == "MX" { body.Priority = cloudflare.Float(float64(record.Priority)) @@ -972,7 +1001,7 @@ func editRecordBody(record Record) cfdns.RecordEditParamsBody { Name: cloudflare.String(record.Name), TTL: cloudflare.F(cfdns.TTL(record.TTL)), Type: cloudflare.F(cfdns.RecordEditParamsBodyType(record.Type)), - Content: cloudflare.String(rawTXTData(record.Type, record.Data)), + Content: cloudflare.String(presentationTXTData(record.Type, record.Data)), } if record.Priority > 0 || record.Type == "MX" { body.Priority = cloudflare.Float(float64(record.Priority)) diff --git a/internal/drivers/dns_test.go b/internal/drivers/dns_test.go index 7df781c..d4af12c 100644 --- a/internal/drivers/dns_test.go +++ b/internal/drivers/dns_test.go @@ -155,7 +155,7 @@ func TestDNSDriver_CreateCreatesMissingZoneAndRecord(t *testing.T) { } } -func TestDNSDriver_CreateSendsRawTXTContentToCloudflare(t *testing.T) { +func TestDNSDriver_CreateCanonicalizesTXTContentInState(t *testing.T) { fake := &fakeCFClient{} driver := NewDNSDriverWithClient(fake) _, err := driver.Create(context.Background(), interfaces.ResourceSpec{ @@ -184,6 +184,18 @@ func TestDNSDriver_CreateSendsRawTXTContentToCloudflare(t *testing.T) { } } +func TestDNSDriver_RecordBodiesSendQuotedTXTContentToCloudflare(t *testing.T) { + newBody := newRecordBody(Record{Type: "TXT", Name: "example.com", Data: "google-site-verification=abc123", TTL: 300}) + if got, want := newBody.Content.Value, `"google-site-verification=abc123"`; got != want { + t.Fatalf("new TXT content = %q, want %q", got, want) + } + + editBody := editRecordBody(Record{Type: "TXT", Name: "_dmarc.example.com", Data: `"v=DMARC1; p=none"`, TTL: 300}) + if got, want := editBody.Content.Value, `"v=DMARC1; p=none"`; got != want { + t.Fatalf("edit TXT content = %q, want %q", got, want) + } +} + func TestDNSDriver_CreateTimesOutBlockedClientOperation(t *testing.T) { fake := &blockingListRecordsClient{ fakeCFClient: fakeCFClient{ @@ -761,6 +773,52 @@ func TestDNSDriver_UpdateDeletesUnlistedRecordsWhenManaged(t *testing.T) { } } +func TestDNSDriver_UpdateDeletesStaleWorkflowManagedMarkersWhenUnlistedRecordsArePreserved(t *testing.T) { + fake := &fakeCFClient{ + zone: &Zone{ID: "zone", Name: "gigbagg.rocks"}, + records: []Record{ + { + ID: "current-marker", + Type: "TXT", + Name: "_workflow-dns-managed.gigbagg.rocks", + Data: "heritage=wfinfra-v1 managed_by=wfctl state_dir=.state/cloudflare-staging/ resource=cf-gigbagg-rocks", + TTL: 300, + }, + { + ID: "stale-marker", + Type: "TXT", + Name: "_workflow-dns-managed.gigbagg.rocks", + Data: "heritage=wfinfra-v1 managed_by=wfctl state_dir=.state/domain-reconcile/ resource=cf-gigbagg-rocks", + TTL: 300, + }, + {ID: "unmanaged", Type: "TXT", Name: "gigbagg.rocks", Data: "external", TTL: 300}, + }, + } + driver := NewDNSDriverWithClient(fake) + _, err := driver.Update(context.Background(), interfaces.ResourceRef{Name: "gigbagg.rocks", Type: "infra.dns", ProviderID: "zone"}, interfaces.ResourceSpec{ + Name: "gigbagg.rocks", + Type: "infra.dns", + Config: map[string]any{ + "domain": "gigbagg.rocks", + "manage_unlisted": false, + "records": []any{ + map[string]any{ + "type": "TXT", + "name": "_workflow-dns-managed", + "data": `"heritage=wfinfra-v1 managed_by=wfctl state_dir=.state/cloudflare-staging/ resource=cf-gigbagg-rocks"`, + "ttl": 300, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if len(fake.deletedRecords) != 1 || fake.deletedRecords[0] != "stale-marker" { + t.Fatalf("deletedRecords = %#v, want stale-marker only", fake.deletedRecords) + } +} + func TestDNSDriver_MissingRecordsErrorsBeforeMutation(t *testing.T) { fake := &fakeCFClient{zone: &Zone{ID: "zone", Name: "example.com"}} driver := NewDNSDriverWithClient(fake) From 0abb58fd9627e57d9bd0b8edc78527076a9d544f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 16 Jun 2026 19:00:30 -0400 Subject: [PATCH 2/2] fix: escape TXT presentation values --- internal/drivers/dns.go | 29 +++++++++++++++++++++++++++-- internal/drivers/dns_test.go | 8 ++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/drivers/dns.go b/internal/drivers/dns.go index 0521e92..c03902c 100644 --- a/internal/drivers/dns.go +++ b/internal/drivers/dns.go @@ -667,7 +667,7 @@ func rawTXTData(recordType, data string) string { return data } if len(data) >= 2 && data[0] == '"' && data[len(data)-1] == '"' { - return data[1 : len(data)-1] + return unescapeTXTData(data[1 : len(data)-1]) } return data } @@ -676,7 +676,32 @@ func presentationTXTData(recordType, data string) string { if !strings.EqualFold(recordType, "TXT") { return data } - return `"` + rawTXTData(recordType, data) + `"` + return `"` + escapeTXTData(rawTXTData(recordType, data)) + `"` +} + +func escapeTXTData(data string) string { + replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`) + return replacer.Replace(data) +} + +func unescapeTXTData(data string) string { + if !strings.Contains(data, `\`) { + return data + } + var builder strings.Builder + builder.Grow(len(data)) + for i := 0; i < len(data); i++ { + if data[i] == '\\' && i+1 < len(data) { + next := data[i+1] + if next == '\\' || next == '"' { + builder.WriteByte(next) + i++ + continue + } + } + builder.WriteByte(data[i]) + } + return builder.String() } func boolPtrValue(v *bool) bool { diff --git a/internal/drivers/dns_test.go b/internal/drivers/dns_test.go index d4af12c..bff7bdd 100644 --- a/internal/drivers/dns_test.go +++ b/internal/drivers/dns_test.go @@ -194,6 +194,14 @@ func TestDNSDriver_RecordBodiesSendQuotedTXTContentToCloudflare(t *testing.T) { if got, want := editBody.Content.Value, `"v=DMARC1; p=none"`; got != want { t.Fatalf("edit TXT content = %q, want %q", got, want) } + + escapedBody := newRecordBody(Record{Type: "TXT", Name: "escaped.example.com", Data: `owner="wfctl"\state`, TTL: 300}) + if got, want := escapedBody.Content.Value, `"owner=\"wfctl\"\\state"`; got != want { + t.Fatalf("escaped TXT content = %q, want %q", got, want) + } + if got, want := rawTXTData("TXT", escapedBody.Content.Value), `owner="wfctl"\state`; got != want { + t.Fatalf("escaped TXT canonical data = %q, want %q", got, want) + } } func TestDNSDriver_CreateTimesOutBlockedClientOperation(t *testing.T) {