carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3// route53DNS — the Route53 DNS-01 backend (CARLOS_DNS_PROVIDER=route53), the
4// tito path: obtain a *.go.tito.io wildcard with NO DNS token on disk; auth
5// rides the standard AWS credential chain (env vars or the box IAM role,
6// scoped to the hosted zone — infra/modules/region-host). Ported from
7// Eleven's route53dns.go minus its relocation-only upsertA.
8//
9// Same seam as DNSimple (dnsProvider): UPSERT the challenge TXT, wait for
10// the change to go INSYNC, and hand back a closure that removes it. The
11// zone owning the record fqdn is discovered by walking fqdn's parent
12// suffixes and probing ListHostedZonesByName per candidate (see
13// hostedZoneID for why), so per-account parents (which have no zone of
14// their own) resolve to the registered domain's zone.
15
16import (
17 "context"
18 "fmt"
19 "os"
20 "strconv"
21 "strings"
22 "sync"
23 "time"
24
25 "github.com/aws/aws-sdk-go-v2/aws"
26 awsconfig "github.com/aws/aws-sdk-go-v2/config"
27 "github.com/aws/aws-sdk-go-v2/service/route53"
28 r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
29 "github.com/carlosframework/platform/internal/rootaws"
30)
31
32// route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to
33// reach INSYNC. Route53 usually syncs in well under a minute; on timeout we
34// proceed anyway — the CA runs its own retries and waitForTXT still gates
35// public visibility (best-effort, never fatal).
36const route53InSyncTimeout = 2 * time.Minute
37
38// route53API is the slice of the Route53 client route53DNS calls — an
39// interface so tests substitute a fake and never reach AWS.
40type route53API interface {
41 ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error)
42 ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
43 GetChange(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error)
44}
45
46// route53Methods satisfies route53API from method values of one route53 client.
47// Storing the *route53.Client itself in the interface would make the linker keep every exported Route53 method once html/template is linked in (discussion #275); method values keep only the calls made here.
48type route53Methods struct {
49 listHostedZonesByName func(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error)
50 changeResourceRecordSets func(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
51 getChange func(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error)
52}
53
54func (m route53Methods) ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, opts ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) {
55 return m.listHostedZonesByName(ctx, in, opts...)
56}
57
58func (m route53Methods) ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, opts ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
59 return m.changeResourceRecordSets(ctx, in, opts...)
60}
61
62func (m route53Methods) GetChange(ctx context.Context, in *route53.GetChangeInput, opts ...func(*route53.Options)) (*route53.GetChangeOutput, error) {
63 return m.getChange(ctx, in, opts...)
64}
65
66func newRoute53Methods(c *route53.Client) route53Methods {
67 return route53Methods{
68 listHostedZonesByName: c.ListHostedZonesByName,
69 changeResourceRecordSets: c.ChangeResourceRecordSets,
70 getChange: c.GetChange,
71 }
72}
73
74type route53DNS struct {
75 client route53API
76 mu sync.Mutex
77 zoneIDs map[string]string // record fqdn → hosted-zone id
78}
79
80// newRoute53DNS builds the backend from the default AWS credential chain.
81// Route53 is a global service but the SDK still needs a signing region, so
82// default to us-east-1 when the environment names none — harmless, the
83// endpoint is global either way, and an explicit AWS_REGION still wins.
84func newRoute53DNS(ctx context.Context) (dnsProvider, error) {
85 var opts []func(*awsconfig.LoadOptions) error
86 if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
87 opts = append(opts, awsconfig.WithRegion("us-east-1"))
88 }
89 cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
90 if err != nil {
91 return nil, fmt.Errorf("route53: load aws config: %w", err)
92 }
93 return &route53DNS{client: newRoute53Methods(route53.NewFromConfig(cfg)), zoneIDs: map[string]string{}}, nil
94}
95
96// present satisfies dnsProvider: UPSERT the challenge TXT, wait for INSYNC,
97// and return a closure that deletes the exact record set it created. UPSERT
98// (not CREATE) so a TXT stranded by a crashed prior attempt is overwritten
99// instead of colliding. Route53 stores a TXT value as a quoted
100// character-string; the ACME token is a bare base64url string, so wrap it.
101func (r *route53DNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) {
102 zoneID, err := r.hostedZoneID(ctx, fqdn)
103 if err != nil {
104 return nil, err
105 }
106 rrset := &r53types.ResourceRecordSet{
107 Name: aws.String(dnsFQDN(fqdn)),
108 Type: r53types.RRTypeTxt,
109 TTL: aws.Int64(60),
110 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(strconv.Quote(value))}},
111 }
112 out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
113 HostedZoneId: aws.String(zoneID),
114 ChangeBatch: &r53types.ChangeBatch{
115 Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}},
116 },
117 })
118 if err != nil {
119 return nil, fmt.Errorf("route53: upsert TXT: %w", err)
120 }
121 if out.ChangeInfo != nil {
122 r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id))
123 }
124 cleanup := func(cctx context.Context) error {
125 // DELETE must resubmit the same record set that was created.
126 _, err := r.client.ChangeResourceRecordSets(cctx, &route53.ChangeResourceRecordSetsInput{
127 HostedZoneId: aws.String(zoneID),
128 ChangeBatch: &r53types.ChangeBatch{
129 Changes: []r53types.Change{{Action: r53types.ChangeActionDelete, ResourceRecordSet: rrset}},
130 },
131 })
132 return err
133 }
134 return cleanup, nil
135}
136
137// hostedZoneID resolves the hosted zone authoritative for fqdn, caching the
138// lookup.
139//
140// ListHostedZonesByName's DNSName is NOT a filter — it is the start point of
141// a listing sorted by zone name with labels reversed (so "com.example" sorts
142// near other ".example.com" zones). A parent zone of fqdn, being shorter,
143// sorts BEFORE fqdn's own reversed key and would therefore be entirely
144// absent from a listing started at fqdn — silently, since the call still
145// succeeds with zero relevant results. Suffix-matching over that listing (as
146// an earlier version of this function did) never sees the parent and always
147// reports "no hosted zone".
148//
149// So instead we walk fqdn's parent suffixes longest-first (most specific
150// first) and issue one ListHostedZonesByName probe per candidate, using the
151// candidate itself as DNSName. A zone equal to the candidate has a reversed
152// key identical to the start point, so it sorts at the head of that
153// specific listing — meaning pagination is not load-bearing here, we only
154// ever need to look at the zones present in the first page. We accept only
155// a returned zone whose Name is an EXACT match for the candidate; the first
156// hit wins, which is automatically the most specific zone since candidates
157// are tried longest-first. Ids come back as "/hostedzone/Z123"; we store the
158// bare id.
159func (r *route53DNS) hostedZoneID(ctx context.Context, fqdn string) (string, error) {
160 r.mu.Lock()
161 if id, ok := r.zoneIDs[fqdn]; ok {
162 r.mu.Unlock()
163 return id, nil
164 }
165 r.mu.Unlock()
166
167 labels := strings.Split(strings.ToLower(strings.TrimSuffix(fqdn, ".")), ".")
168 for i := 1; i <= len(labels)-2; i++ { // a zone has at least two labels
169 cand := dnsFQDN(strings.Join(labels[i:], "."))
170 out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(cand)})
171 if err != nil {
172 return "", fmt.Errorf("route53: list hosted zones: %w", err)
173 }
174 for _, hz := range out.HostedZones {
175 if strings.ToLower(aws.ToString(hz.Name)) == cand {
176 id := strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/")
177 r.mu.Lock()
178 r.zoneIDs[fqdn] = id
179 r.mu.Unlock()
180 return id, nil
181 }
182 }
183 }
184 return "", fmt.Errorf("route53: no hosted zone for %s: %w", fqdn, errNoZone)
185}
186
187// checkZone satisfies zoneChecker with the lookup present uses.
188func (r *route53DNS) checkZone(ctx context.Context, fqdn string) error {
189 _, err := r.hostedZoneID(ctx, fqdn)
190 return err
191}
192
193// waitInSync polls GetChange until the change is INSYNC or the timeout
194// passes. Best-effort, mirroring waitForTXT: a timeout is not fatal (the CA
195// retries).
196func (r *route53DNS) waitInSync(ctx context.Context, changeID string) {
197 if changeID == "" {
198 return
199 }
200 deadline := time.Now().Add(route53InSyncTimeout)
201 for time.Now().Before(deadline) && ctx.Err() == nil {
202 out, err := r.client.GetChange(ctx, &route53.GetChangeInput{Id: aws.String(changeID)})
203 if err == nil && out.ChangeInfo != nil && out.ChangeInfo.Status == r53types.ChangeStatusInsync {
204 return
205 }
206 time.Sleep(3 * time.Second)
207 }
208}
209
210// dnsFQDN appends the root dot Route53 stores names with.
211func dnsFQDN(name string) string {
212 name = strings.ToLower(name)
213 if strings.HasSuffix(name, ".") {
214 return name
215 }
216 return name + "."
217}
218