Skip to content

Commit d4d9ae2

Browse files
committed
fix: format TXT records based on specs
- Chunk strings longer than 255 - Quote TXT/SPF strings and escape special characters Fixes: #21 Fixes: #20 Fixes: caddy-dns/route53#29 Reference: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html#TXTFormat
1 parent b7898f7 commit d4d9ae2

3 files changed

Lines changed: 519 additions & 60 deletions

File tree

client.go

Lines changed: 116 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"log"
8-
"strconv"
8+
"strings"
99
"time"
1010

1111
"github.com/aws/aws-sdk-go-v2/aws"
@@ -49,12 +49,105 @@ func (p *Provider) init(ctx context.Context) {
4949
cfg, err := config.LoadDefaultConfig(ctx, opts...)
5050

5151
if err != nil {
52-
log.Fatal(err)
52+
log.Fatalf("route53: unable to load AWS SDK config, %v", err)
5353
}
5454

5555
p.client = r53.NewFromConfig(cfg)
5656
}
5757

58+
func chunkString(s string, chunkSize int) []string {
59+
var chunks []string
60+
for i := 0; i < len(s); i += chunkSize {
61+
end := i + chunkSize
62+
if end > len(s) {
63+
end = len(s)
64+
}
65+
chunks = append(chunks, s[i:end])
66+
}
67+
return chunks
68+
}
69+
70+
func parseRecordSet(set types.ResourceRecordSet) []libdns.Record {
71+
records := make([]libdns.Record, 0)
72+
73+
// Route53 returns TXT & SPF records with quotes around them.
74+
// https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html#TXTFormat
75+
var ttl int64
76+
if set.TTL != nil {
77+
ttl = *set.TTL
78+
}
79+
80+
rtype := string(set.Type)
81+
for _, record := range set.ResourceRecords {
82+
value := *record.Value
83+
switch rtype {
84+
case "TXT", "SPF":
85+
rows := strings.Split(value, "\n")
86+
for i, row := range rows {
87+
parts := strings.Split(row, `" "`)
88+
if len(parts) > 0 {
89+
parts[0] = strings.TrimPrefix(parts[0], `"`)
90+
parts[len(parts)-1] = strings.TrimSuffix(parts[len(parts)-1], `"`)
91+
}
92+
93+
// Join parts
94+
row = strings.Join(parts, "")
95+
row = unquote(row)
96+
rows[i] = row
97+
98+
records = append(records, libdns.Record{
99+
Name: *set.Name,
100+
Value: row,
101+
Type: rtype,
102+
TTL: time.Duration(ttl) * time.Second,
103+
})
104+
}
105+
default:
106+
records = append(records, libdns.Record{
107+
Name: *set.Name,
108+
Value: value,
109+
Type: rtype,
110+
TTL: time.Duration(ttl) * time.Second,
111+
})
112+
}
113+
114+
}
115+
116+
return records
117+
}
118+
119+
func marshalRecord(record libdns.Record) []types.ResourceRecord {
120+
resourceRecords := make([]types.ResourceRecord, 0)
121+
122+
// Route53 requires TXT & SPF records to be quoted.
123+
// https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html#TXTFormat
124+
switch record.Type {
125+
case "TXT", "SPF":
126+
strs := make([]string, 0)
127+
if len(record.Value) > 255 {
128+
strs = append(strs, chunkString(record.Value, 255)...)
129+
} else {
130+
strs = append(strs, record.Value)
131+
}
132+
133+
// Quote strings
134+
for i, str := range strs {
135+
strs[i] = quote(str)
136+
}
137+
138+
// Finally join chunks with spaces
139+
resourceRecords = append(resourceRecords, types.ResourceRecord{
140+
Value: aws.String(strings.Join(strs, " ")),
141+
})
142+
default:
143+
resourceRecords = append(resourceRecords, types.ResourceRecord{
144+
Value: aws.String(record.Value),
145+
})
146+
}
147+
148+
return resourceRecords
149+
}
150+
58151
func (p *Provider) getRecords(ctx context.Context, zoneID string, zone string) ([]libdns.Record, error) {
59152
getRecordsInput := &r53.ListResourceRecordSetsInput{
60153
HostedZoneId: aws.String(zoneID),
@@ -79,6 +172,10 @@ func (p *Provider) getRecords(ctx context.Context, zoneID string, zone string) (
79172
}
80173

81174
recordSets = append(recordSets, getRecordResult.ResourceRecordSets...)
175+
for _, s := range recordSets {
176+
records = append(records, parseRecordSet(s)...)
177+
}
178+
82179
if getRecordResult.IsTruncated {
83180
getRecordsInput.StartRecordName = getRecordResult.NextRecordName
84181
getRecordsInput.StartRecordType = getRecordResult.NextRecordType
@@ -88,31 +185,6 @@ func (p *Provider) getRecords(ctx context.Context, zoneID string, zone string) (
88185
}
89186
}
90187

91-
for _, rrset := range recordSets {
92-
for _, rrsetRecord := range rrset.ResourceRecords {
93-
rtype := rrset.Type
94-
value := *rrsetRecord.Value
95-
// Route53 returns TXT & SPF records with quotes around them.
96-
// https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html#TXTFormat
97-
switch rtype {
98-
case types.RRTypeTxt, types.RRTypeSpf:
99-
var err error
100-
value, err = strconv.Unquote(value)
101-
if err != nil {
102-
return records, fmt.Errorf("Error unquoting TXT/SPF record: %s", err)
103-
}
104-
}
105-
record := libdns.Record{
106-
Name: *rrset.Name,
107-
Value: value,
108-
Type: string(rtype),
109-
TTL: time.Duration(*rrset.TTL) * time.Second,
110-
}
111-
112-
records = append(records, record)
113-
}
114-
}
115-
116188
return records, nil
117189
}
118190

@@ -170,24 +242,19 @@ func (p *Provider) createRecord(ctx context.Context, zoneID string, record libdn
170242
switch record.Type {
171243
case "TXT":
172244
return p.updateRecord(ctx, zoneID, record, zone)
173-
case "SPF":
174-
record.Value = strconv.Quote(record.Value)
175245
}
176246

247+
resourceRecords := marshalRecord(record)
177248
createInput := &r53.ChangeResourceRecordSetsInput{
178249
ChangeBatch: &types.ChangeBatch{
179250
Changes: []types.Change{
180251
{
181252
Action: types.ChangeActionCreate,
182253
ResourceRecordSet: &types.ResourceRecordSet{
183-
Name: aws.String(libdns.AbsoluteName(record.Name, zone)),
184-
ResourceRecords: []types.ResourceRecord{
185-
{
186-
Value: aws.String(record.Value),
187-
},
188-
},
189-
TTL: aws.Int64(int64(record.TTL.Seconds())),
190-
Type: types.RRType(record.Type),
254+
Name: aws.String(libdns.AbsoluteName(record.Name, zone)),
255+
ResourceRecords: resourceRecords,
256+
TTL: aws.Int64(int64(record.TTL.Seconds())),
257+
Type: types.RRType(record.Type),
191258
},
192259
},
193260
},
@@ -206,26 +273,19 @@ func (p *Provider) createRecord(ctx context.Context, zoneID string, record libdn
206273
func (p *Provider) updateRecord(ctx context.Context, zoneID string, record libdns.Record, zone string) (libdns.Record, error) {
207274
resourceRecords := make([]types.ResourceRecord, 0)
208275
// AWS Route53 TXT record value must be enclosed in quotation marks on update
209-
switch record.Type {
210-
case "SPF", "TXT":
211-
resourceRecords = append(resourceRecords, types.ResourceRecord{
212-
Value: aws.String(strconv.Quote(record.Value)),
213-
})
214-
}
215276
if record.Type == "TXT" {
216277
txtRecords, err := p.getTxtRecordsFor(ctx, zoneID, zone, record.Name)
217278
if err != nil {
218279
return record, err
219280
}
220281
for _, r := range txtRecords {
221282
if record.Value != r.Value {
222-
resourceRecords = append(resourceRecords, types.ResourceRecord{
223-
Value: aws.String(strconv.Quote(r.Value)),
224-
})
283+
resourceRecords = append(resourceRecords, marshalRecord(r)...)
225284
}
226285
}
227286
}
228287

288+
resourceRecords = append(resourceRecords, marshalRecord(record)...)
229289
updateInput := &r53.ChangeResourceRecordSetsInput{
230290
ChangeBatch: &types.ChangeBatch{
231291
Changes: []types.Change{
@@ -255,28 +315,24 @@ func (p *Provider) deleteRecord(ctx context.Context, zoneID string, record libdn
255315
action := types.ChangeActionDelete
256316
resourceRecords := make([]types.ResourceRecord, 0)
257317
// AWS Route53 TXT record value must be enclosed in quotation marks on update
258-
switch record.Type {
259-
case "SPF", "TXT":
260-
resourceRecords = append(resourceRecords, types.ResourceRecord{
261-
Value: aws.String(strconv.Quote(record.Value)),
262-
})
263-
}
264318
if record.Type == "TXT" {
265319
txtRecords, err := p.getTxtRecordsFor(ctx, zoneID, zone, record.Name)
266320
if err != nil {
267321
return record, err
268322
}
323+
269324
switch {
270-
case len(txtRecords) > 0 && txtRecords[0].Value != record.Value,
271-
len(txtRecords) > 1:
325+
// If there is only one record, we can delete the entire record set.
326+
case len(txtRecords) == 1:
327+
resourceRecords = append(resourceRecords, marshalRecord(record)...)
328+
// If there are multiple records, we need to upsert the remaining records.
329+
case len(txtRecords) > 1:
272330
action = types.ChangeActionUpsert
273331
resourceRecords = make([]types.ResourceRecord, 0)
274-
}
275-
for _, r := range txtRecords {
276-
if record.Value != r.Value {
277-
resourceRecords = append(resourceRecords, types.ResourceRecord{
278-
Value: aws.String(strconv.Quote(r.Value)),
279-
})
332+
for _, r := range txtRecords {
333+
if record.Value != r.Value {
334+
resourceRecords = append(resourceRecords, marshalRecord(r)...)
335+
}
280336
}
281337
}
282338
}

0 commit comments

Comments
 (0)