| 1 | package 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 | |
| 16 | import ( |
| 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). |
| 36 | const 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. |
| 40 | type 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. |
| 48 | type 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 | |
| 54 | func (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 | |
| 58 | func (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 | |
| 62 | func (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 | |
| 66 | func newRoute53Methods(c *route53.Client) route53Methods { |
| 67 | return route53Methods{ |
| 68 | listHostedZonesByName: c.ListHostedZonesByName, |
| 69 | changeResourceRecordSets: c.ChangeResourceRecordSets, |
| 70 | getChange: c.GetChange, |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | type 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. |
| 84 | func 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. |
| 101 | func (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. |
| 159 | func (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. |
| 188 | func (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). |
| 196 | func (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. |
| 211 | func dnsFQDN(name string) string { |
| 212 | name = strings.ToLower(name) |
| 213 | if strings.HasSuffix(name, ".") { |
| 214 | return name |
| 215 | } |
| 216 | return name + "." |
| 217 | } |
| 218 | |