carlos / platform Public

dnsrec: Route53 backend with parent-suffix zone discovery

Zone discovery repeats internal/edge/route53dns.go's walk rather than
sharing it — edge's copy is unexported and is a box-side package this one
must not depend on. TXT values are quoted and CNAME/MX values are not;
getting that backwards yields a record Route53 accepts and no resolver can
use, which presents as verification pending forever.
Paul Campbell 26c9ae84fd4d0b7ff84e661e283c7f2867439eea parent 6734ba8
2 files changed, +200 −0
  • internal/dnsrec/route53.go +165 −0
  • internal/dnsrec/route53_test.go +35 −0
diff --git a/internal/dnsrec/route53.go b/internal/dnsrec/route53.go
new file mode 100644
index 000000000..96ab91d5f
--- /dev/null
+++ b/internal/dnsrec/route53.go
@@ -0,0 +1,165 @@
+// The Route53 backend. Zone discovery is the parent-suffix walk from
+// internal/edge/route53dns.go's hostedZoneID — see that function's comment
+// for why ListHostedZonesByName cannot be used as a filter. The reasoning is
+// load-bearing and is repeated here rather than shared, because edge's copy
+// is unexported and internal/edge is a box-side package this one must not
+// depend on.
+package dnsrec
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+type route53Writer struct {
+ client *route53.Client
+ mu sync.Mutex
+ zoneIDs map[string]string
+}
+
+// NewRoute53Writer builds a Writer against Route53, authed by the standard
+// AWS credential chain — no DNS token on disk.
+func NewRoute53Writer(ctx context.Context) (Writer, error) {
+ var opts []func(*awsconfig.LoadOptions) error
+ if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
+ opts = append(opts, awsconfig.WithRegion("us-east-1"))
+ }
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("dnsrec: load aws config: %w", err)
+ }
+ return &route53Writer{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil
+}
+
+// quoteTXT renders a TXT value as Route53's quoted character-string. SPF and
+// DMARC values carry spaces and semicolons, and an unquoted one is either
+// refused or stored as something no resolver matches — which presents as
+// "published, still pending" indefinitely.
+func quoteTXT(v string) string { return strconv.Quote(v) }
+
+// renderValue quotes TXT values and leaves every other type alone. Quoting a
+// CNAME or MX target produces a record Route53 accepts and nothing can follow.
+func renderValue(typ, v string) string {
+ if typ == TypeTXT {
+ return quoteTXT(v)
+ }
+ return v
+}
+
+func (w *route53Writer) rrset(r Record) *r53types.ResourceRecordSet {
+ ttl := r.TTL
+ if ttl == 0 {
+ ttl = 300
+ }
+ recs := make([]r53types.ResourceRecord, 0, len(r.Values))
+ for _, v := range r.Values {
+ recs = append(recs, r53types.ResourceRecord{Value: aws.String(renderValue(r.Type, v))})
+ }
+ return &r53types.ResourceRecordSet{
+ Name: aws.String(fqdn(r.Name)),
+ Type: r53types.RRType(r.Type),
+ TTL: aws.Int64(ttl),
+ ResourceRecords: recs,
+ }
+}
+
+func (w *route53Writer) EnsureRecord(ctx context.Context, r Record) error {
+ zoneID, err := w.hostedZoneID(ctx, r.Name)
+ if err != nil {
+ return err
+ }
+ _, err = w.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{{
+ Action: r53types.ChangeActionUpsert, ResourceRecordSet: w.rrset(r),
+ }}},
+ })
+ if err != nil {
+ return fmt.Errorf("dnsrec: upsert %s %s: %w", r.Type, r.Name, err)
+ }
+ return nil
+}
+
+// DeleteRecord resubmits the same set with DELETE. A set that is already
+// gone answers InvalidChangeBatch ("not found"), which is a SUCCESS here —
+// teardown must be re-runnable.
+func (w *route53Writer) DeleteRecord(ctx context.Context, r Record) error {
+ zoneID, err := w.hostedZoneID(ctx, r.Name)
+ if err != nil {
+ return err
+ }
+ _, err = w.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{{
+ Action: r53types.ChangeActionDelete, ResourceRecordSet: w.rrset(r),
+ }}},
+ })
+ if err != nil {
+ if isNotFoundChange(err) {
+ return nil
+ }
+ return fmt.Errorf("dnsrec: delete %s %s: %w", r.Type, r.Name, err)
+ }
+ return nil
+}
+
+// isNotFoundChange recognises Route53's answer for deleting a record set
+// that is not there. It is reported as InvalidChangeBatch with a message
+// naming the absence rather than as a typed "no such record" error, so the
+// message is the only thing to match on.
+func isNotFoundChange(err error) bool {
+ s := err.Error()
+ return strings.Contains(s, "not found") || strings.Contains(s, "but it was not found")
+}
+
+// fqdn appends the root label Route53 expects.
+func fqdn(name string) string {
+ if strings.HasSuffix(name, ".") {
+ return name
+ }
+ return name + "."
+}
+
+// hostedZoneID resolves the zone authoritative for name, caching the lookup.
+// Candidates are walked longest-first so the FIRST exact hit is automatically
+// the most specific zone. See internal/edge/route53dns.go's hostedZoneID for
+// why each candidate needs its own ListHostedZonesByName probe.
+func (w *route53Writer) hostedZoneID(ctx context.Context, name string) (string, error) {
+ w.mu.Lock()
+ if id, ok := w.zoneIDs[name]; ok {
+ w.mu.Unlock()
+ return id, nil
+ }
+ w.mu.Unlock()
+
+ labels := strings.Split(strings.TrimSuffix(name, "."), ".")
+ for i := 0; i < len(labels)-1; i++ {
+ cand := strings.Join(labels[i:], ".")
+ out, err := w.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{
+ DNSName: aws.String(cand),
+ })
+ if err != nil {
+ return "", fmt.Errorf("dnsrec: list hosted zones for %q: %w", cand, err)
+ }
+ for _, z := range out.HostedZones {
+ if strings.TrimSuffix(aws.ToString(z.Name), ".") != cand {
+ continue
+ }
+ id := strings.TrimPrefix(aws.ToString(z.Id), "/hostedzone/")
+ w.mu.Lock()
+ w.zoneIDs[name] = id
+ w.mu.Unlock()
+ return id, nil
+ }
+ }
+ return "", fmt.Errorf("dnsrec: no hosted zone found for %q", name)
+}
diff --git a/internal/dnsrec/route53_test.go b/internal/dnsrec/route53_test.go
new file mode 100644
index 000000000..dd51b9df4
--- /dev/null
+++ b/internal/dnsrec/route53_test.go
@@ -0,0 +1,35 @@
+package dnsrec
+
+import "testing"
+
+// Route53 stores a TXT value as a quoted character-string. SPF and DMARC
+// values contain spaces and semicolons, so an unquoted value is either
+// rejected or silently stored as something no resolver will match — which
+// looks like "DNS published, verification still pending" forever.
+func TestTXTValuesAreQuoted(t *testing.T) {
+ got := quoteTXT(`v=DMARC1; p=none; adkim=s; aspf=s`)
+ want := `"v=DMARC1; p=none; adkim=s; aspf=s"`
+ if got != want {
+ t.Fatalf("quoteTXT = %s, want %s", got, want)
+ }
+}
+
+// A TXT value with an embedded quote must be escaped, not truncated.
+func TestTXTQuotingEscapes(t *testing.T) {
+ if got := quoteTXT(`a"b`); got != `"a\"b"` {
+ t.Fatalf("quoteTXT = %s, want %s", got, `"a\"b"`)
+ }
+}
+
+// A CNAME or MX value is NOT quoted — quoting one produces a record Route53
+// accepts and no resolver can follow.
+func TestNonTXTValuesAreNotQuoted(t *testing.T) {
+ for _, typ := range []string{TypeCNAME, TypeMX} {
+ if got := renderValue(typ, "x.dkim.amazonses.com"); got != "x.dkim.amazonses.com" {
+ t.Fatalf("renderValue(%s) = %q, want unquoted", typ, got)
+ }
+ }
+ if got := renderValue(TypeTXT, "hello"); got != `"hello"` {
+ t.Fatalf("renderValue(TXT) = %q, want quoted", got)
+ }
+}