// 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" "github.com/carlosframework/platform/internal/rootaws" ) 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 := rootaws.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) }