| 1 | // The Route53 backend. Zone discovery is the parent-suffix walk from |
| 2 | // internal/edge/route53dns.go's hostedZoneID — see that function's comment |
| 3 | // for why ListHostedZonesByName cannot be used as a filter. The reasoning is |
| 4 | // load-bearing and is repeated here rather than shared, because edge's copy |
| 5 | // is unexported and internal/edge is a box-side package this one must not |
| 6 | // depend on. |
| 7 | package dnsrec |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "fmt" |
| 12 | "os" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | |
| 17 | "github.com/aws/aws-sdk-go-v2/aws" |
| 18 | awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| 19 | "github.com/aws/aws-sdk-go-v2/service/route53" |
| 20 | r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| 21 | "github.com/carlosframework/platform/internal/rootaws" |
| 22 | ) |
| 23 | |
| 24 | type route53Writer struct { |
| 25 | client *route53.Client |
| 26 | mu sync.Mutex |
| 27 | zoneIDs map[string]string |
| 28 | } |
| 29 | |
| 30 | // NewRoute53Writer builds a Writer against Route53, authed by the standard |
| 31 | // AWS credential chain — no DNS token on disk. |
| 32 | func NewRoute53Writer(ctx context.Context) (Writer, error) { |
| 33 | var opts []func(*awsconfig.LoadOptions) error |
| 34 | if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" { |
| 35 | opts = append(opts, awsconfig.WithRegion("us-east-1")) |
| 36 | } |
| 37 | cfg, err := rootaws.LoadDefaultConfig(ctx, opts...) |
| 38 | if err != nil { |
| 39 | return nil, fmt.Errorf("dnsrec: load aws config: %w", err) |
| 40 | } |
| 41 | return &route53Writer{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil |
| 42 | } |
| 43 | |
| 44 | // quoteTXT renders a TXT value as Route53's quoted character-string. SPF and |
| 45 | // DMARC values carry spaces and semicolons, and an unquoted one is either |
| 46 | // refused or stored as something no resolver matches — which presents as |
| 47 | // "published, still pending" indefinitely. |
| 48 | func quoteTXT(v string) string { return strconv.Quote(v) } |
| 49 | |
| 50 | // renderValue quotes TXT values and leaves every other type alone. Quoting a |
| 51 | // CNAME or MX target produces a record Route53 accepts and nothing can follow. |
| 52 | func renderValue(typ, v string) string { |
| 53 | if typ == TypeTXT { |
| 54 | return quoteTXT(v) |
| 55 | } |
| 56 | return v |
| 57 | } |
| 58 | |
| 59 | func (w *route53Writer) rrset(r Record) *r53types.ResourceRecordSet { |
| 60 | ttl := r.TTL |
| 61 | if ttl == 0 { |
| 62 | ttl = 300 |
| 63 | } |
| 64 | recs := make([]r53types.ResourceRecord, 0, len(r.Values)) |
| 65 | for _, v := range r.Values { |
| 66 | recs = append(recs, r53types.ResourceRecord{Value: aws.String(renderValue(r.Type, v))}) |
| 67 | } |
| 68 | return &r53types.ResourceRecordSet{ |
| 69 | Name: aws.String(fqdn(r.Name)), |
| 70 | Type: r53types.RRType(r.Type), |
| 71 | TTL: aws.Int64(ttl), |
| 72 | ResourceRecords: recs, |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func (w *route53Writer) EnsureRecord(ctx context.Context, r Record) error { |
| 77 | zoneID, err := w.hostedZoneID(ctx, r.Name) |
| 78 | if err != nil { |
| 79 | return err |
| 80 | } |
| 81 | _, err = w.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ |
| 82 | HostedZoneId: aws.String(zoneID), |
| 83 | ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{{ |
| 84 | Action: r53types.ChangeActionUpsert, ResourceRecordSet: w.rrset(r), |
| 85 | }}}, |
| 86 | }) |
| 87 | if err != nil { |
| 88 | return fmt.Errorf("dnsrec: upsert %s %s: %w", r.Type, r.Name, err) |
| 89 | } |
| 90 | return nil |
| 91 | } |
| 92 | |
| 93 | // DeleteRecord resubmits the same set with DELETE. A set that is already |
| 94 | // gone answers InvalidChangeBatch ("not found"), which is a SUCCESS here — |
| 95 | // teardown must be re-runnable. |
| 96 | func (w *route53Writer) DeleteRecord(ctx context.Context, r Record) error { |
| 97 | zoneID, err := w.hostedZoneID(ctx, r.Name) |
| 98 | if err != nil { |
| 99 | return err |
| 100 | } |
| 101 | _, err = w.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ |
| 102 | HostedZoneId: aws.String(zoneID), |
| 103 | ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{{ |
| 104 | Action: r53types.ChangeActionDelete, ResourceRecordSet: w.rrset(r), |
| 105 | }}}, |
| 106 | }) |
| 107 | if err != nil { |
| 108 | if isNotFoundChange(err) { |
| 109 | return nil |
| 110 | } |
| 111 | return fmt.Errorf("dnsrec: delete %s %s: %w", r.Type, r.Name, err) |
| 112 | } |
| 113 | return nil |
| 114 | } |
| 115 | |
| 116 | // isNotFoundChange recognises Route53's answer for deleting a record set |
| 117 | // that is not there. It is reported as InvalidChangeBatch with a message |
| 118 | // naming the absence rather than as a typed "no such record" error, so the |
| 119 | // message is the only thing to match on. |
| 120 | func isNotFoundChange(err error) bool { |
| 121 | s := err.Error() |
| 122 | return strings.Contains(s, "not found") || strings.Contains(s, "but it was not found") |
| 123 | } |
| 124 | |
| 125 | // fqdn appends the root label Route53 expects. |
| 126 | func fqdn(name string) string { |
| 127 | if strings.HasSuffix(name, ".") { |
| 128 | return name |
| 129 | } |
| 130 | return name + "." |
| 131 | } |
| 132 | |
| 133 | // hostedZoneID resolves the zone authoritative for name, caching the lookup. |
| 134 | // Candidates are walked longest-first so the FIRST exact hit is automatically |
| 135 | // the most specific zone. See internal/edge/route53dns.go's hostedZoneID for |
| 136 | // why each candidate needs its own ListHostedZonesByName probe. |
| 137 | func (w *route53Writer) hostedZoneID(ctx context.Context, name string) (string, error) { |
| 138 | w.mu.Lock() |
| 139 | if id, ok := w.zoneIDs[name]; ok { |
| 140 | w.mu.Unlock() |
| 141 | return id, nil |
| 142 | } |
| 143 | w.mu.Unlock() |
| 144 | |
| 145 | labels := strings.Split(strings.TrimSuffix(name, "."), ".") |
| 146 | for i := 0; i < len(labels)-1; i++ { |
| 147 | cand := strings.Join(labels[i:], ".") |
| 148 | out, err := w.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{ |
| 149 | DNSName: aws.String(cand), |
| 150 | }) |
| 151 | if err != nil { |
| 152 | return "", fmt.Errorf("dnsrec: list hosted zones for %q: %w", cand, err) |
| 153 | } |
| 154 | for _, z := range out.HostedZones { |
| 155 | if strings.TrimSuffix(aws.ToString(z.Name), ".") != cand { |
| 156 | continue |
| 157 | } |
| 158 | id := strings.TrimPrefix(aws.ToString(z.Id), "/hostedzone/") |
| 159 | w.mu.Lock() |
| 160 | w.zoneIDs[name] = id |
| 161 | w.mu.Unlock() |
| 162 | return id, nil |
| 163 | } |
| 164 | } |
| 165 | return "", fmt.Errorf("dnsrec: no hosted zone found for %q", name) |
| 166 | } |
| 167 | |