Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 58 additions & 4 deletions internal/drivers/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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, ""
Expand Down Expand Up @@ -638,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
}
Expand All @@ -647,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 {
Expand Down Expand Up @@ -953,7 +1007,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))
Expand All @@ -972,7 +1026,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))
Expand Down
68 changes: 67 additions & 1 deletion internal/drivers/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -184,6 +184,26 @@ 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)
}

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) {
fake := &blockingListRecordsClient{
fakeCFClient: fakeCFClient{
Expand Down Expand Up @@ -761,6 +781,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)
Expand Down