| 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) |
| +} |