package edge // route53DNS — the Route53 DNS-01 backend (CARLOS_DNS_PROVIDER=route53), the // tito path: obtain a *.go.tito.io wildcard with NO DNS token on disk; auth // rides the standard AWS credential chain (env vars or the box IAM role, // scoped to the hosted zone — infra/modules/region-host). Ported from // Eleven's route53dns.go minus its relocation-only upsertA. // // Same seam as DNSimple (dnsProvider): UPSERT the challenge TXT, wait for // the change to go INSYNC, and hand back a closure that removes it. The // zone owning the record fqdn is discovered by walking fqdn's parent // suffixes and probing ListHostedZonesByName per candidate (see // hostedZoneID for why), so per-account parents (which have no zone of // their own) resolve to the registered domain's zone. import ( "context" "fmt" "os" "strconv" "strings" "sync" "time" "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" ) // route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to // reach INSYNC. Route53 usually syncs in well under a minute; on timeout we // proceed anyway — the CA runs its own retries and waitForTXT still gates // public visibility (best-effort, never fatal). const route53InSyncTimeout = 2 * time.Minute // route53API is the slice of the Route53 client route53DNS calls — an // interface so tests substitute a fake and never reach AWS. type route53API interface { ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) GetChange(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error) } // route53Methods satisfies route53API from method values of one route53 client. // 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. type route53Methods struct { listHostedZonesByName func(ctx context.Context, in *route53.ListHostedZonesByNameInput, optFns ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) changeResourceRecordSets func(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) getChange func(ctx context.Context, in *route53.GetChangeInput, optFns ...func(*route53.Options)) (*route53.GetChangeOutput, error) } func (m route53Methods) ListHostedZonesByName(ctx context.Context, in *route53.ListHostedZonesByNameInput, opts ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) { return m.listHostedZonesByName(ctx, in, opts...) } func (m route53Methods) ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, opts ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { return m.changeResourceRecordSets(ctx, in, opts...) } func (m route53Methods) GetChange(ctx context.Context, in *route53.GetChangeInput, opts ...func(*route53.Options)) (*route53.GetChangeOutput, error) { return m.getChange(ctx, in, opts...) } func newRoute53Methods(c *route53.Client) route53Methods { return route53Methods{ listHostedZonesByName: c.ListHostedZonesByName, changeResourceRecordSets: c.ChangeResourceRecordSets, getChange: c.GetChange, } } type route53DNS struct { client route53API mu sync.Mutex zoneIDs map[string]string // record fqdn → hosted-zone id } // newRoute53DNS builds the backend from the default AWS credential chain. // Route53 is a global service but the SDK still needs a signing region, so // default to us-east-1 when the environment names none — harmless, the // endpoint is global either way, and an explicit AWS_REGION still wins. func newRoute53DNS(ctx context.Context) (dnsProvider, 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("route53: load aws config: %w", err) } return &route53DNS{client: newRoute53Methods(route53.NewFromConfig(cfg)), zoneIDs: map[string]string{}}, nil } // present satisfies dnsProvider: UPSERT the challenge TXT, wait for INSYNC, // and return a closure that deletes the exact record set it created. UPSERT // (not CREATE) so a TXT stranded by a crashed prior attempt is overwritten // instead of colliding. Route53 stores a TXT value as a quoted // character-string; the ACME token is a bare base64url string, so wrap it. func (r *route53DNS) present(ctx context.Context, fqdn, value string) (func(context.Context) error, error) { zoneID, err := r.hostedZoneID(ctx, fqdn) if err != nil { return nil, err } rrset := &r53types.ResourceRecordSet{ Name: aws.String(dnsFQDN(fqdn)), Type: r53types.RRTypeTxt, TTL: aws.Int64(60), ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(strconv.Quote(value))}}, } out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ HostedZoneId: aws.String(zoneID), ChangeBatch: &r53types.ChangeBatch{ Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}}, }, }) if err != nil { return nil, fmt.Errorf("route53: upsert TXT: %w", err) } if out.ChangeInfo != nil { r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id)) } cleanup := func(cctx context.Context) error { // DELETE must resubmit the same record set that was created. _, err := r.client.ChangeResourceRecordSets(cctx, &route53.ChangeResourceRecordSetsInput{ HostedZoneId: aws.String(zoneID), ChangeBatch: &r53types.ChangeBatch{ Changes: []r53types.Change{{Action: r53types.ChangeActionDelete, ResourceRecordSet: rrset}}, }, }) return err } return cleanup, nil } // hostedZoneID resolves the hosted zone authoritative for fqdn, caching the // lookup. // // ListHostedZonesByName's DNSName is NOT a filter — it is the start point of // a listing sorted by zone name with labels reversed (so "com.example" sorts // near other ".example.com" zones). A parent zone of fqdn, being shorter, // sorts BEFORE fqdn's own reversed key and would therefore be entirely // absent from a listing started at fqdn — silently, since the call still // succeeds with zero relevant results. Suffix-matching over that listing (as // an earlier version of this function did) never sees the parent and always // reports "no hosted zone". // // So instead we walk fqdn's parent suffixes longest-first (most specific // first) and issue one ListHostedZonesByName probe per candidate, using the // candidate itself as DNSName. A zone equal to the candidate has a reversed // key identical to the start point, so it sorts at the head of that // specific listing — meaning pagination is not load-bearing here, we only // ever need to look at the zones present in the first page. We accept only // a returned zone whose Name is an EXACT match for the candidate; the first // hit wins, which is automatically the most specific zone since candidates // are tried longest-first. Ids come back as "/hostedzone/Z123"; we store the // bare id. func (r *route53DNS) hostedZoneID(ctx context.Context, fqdn string) (string, error) { r.mu.Lock() if id, ok := r.zoneIDs[fqdn]; ok { r.mu.Unlock() return id, nil } r.mu.Unlock() labels := strings.Split(strings.ToLower(strings.TrimSuffix(fqdn, ".")), ".") for i := 1; i <= len(labels)-2; i++ { // a zone has at least two labels cand := dnsFQDN(strings.Join(labels[i:], ".")) out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(cand)}) if err != nil { return "", fmt.Errorf("route53: list hosted zones: %w", err) } for _, hz := range out.HostedZones { if strings.ToLower(aws.ToString(hz.Name)) == cand { id := strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/") r.mu.Lock() r.zoneIDs[fqdn] = id r.mu.Unlock() return id, nil } } } return "", fmt.Errorf("route53: no hosted zone for %s: %w", fqdn, errNoZone) } // checkZone satisfies zoneChecker with the lookup present uses. func (r *route53DNS) checkZone(ctx context.Context, fqdn string) error { _, err := r.hostedZoneID(ctx, fqdn) return err } // waitInSync polls GetChange until the change is INSYNC or the timeout // passes. Best-effort, mirroring waitForTXT: a timeout is not fatal (the CA // retries). func (r *route53DNS) waitInSync(ctx context.Context, changeID string) { if changeID == "" { return } deadline := time.Now().Add(route53InSyncTimeout) for time.Now().Before(deadline) && ctx.Err() == nil { out, err := r.client.GetChange(ctx, &route53.GetChangeInput{Id: aws.String(changeID)}) if err == nil && out.ChangeInfo != nil && out.ChangeInfo.Status == r53types.ChangeStatusInsync { return } time.Sleep(3 * time.Second) } } // dnsFQDN appends the root dot Route53 stores names with. func dnsFQDN(name string) string { name = strings.ToLower(name) if strings.HasSuffix(name, ".") { return name } return name + "." }