carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
7package dnsrec
8
9import (
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
24type 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.
32func 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.
48func 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.
52func renderValue(typ, v string) string {
53 if typ == TypeTXT {
54 return quoteTXT(v)
55 }
56 return v
57}
58
59func (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
76func (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.
96func (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.
120func 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.
126func 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.
137func (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