| new file mode 100644 |
| index 000000000..45d8d0776 |
| --- /dev/null |
| +++ b/internal/edge/route53dns.go |
| @@ -0,0 +1,166 @@ |
| +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 via ListHostedZonesByName's |
| +// suffix match, 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" |
| +) |
| + |
| +// 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) |
| +} |
| + |
| +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 := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + if err != nil { |
| + return nil, fmt.Errorf("route53: load aws config: %w", err) |
| + } |
| + return &route53DNS{client: 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: the most specific returned zone whose name equals fqdn or is a |
| +// parent suffix of it. 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() |
| + |
| + want := dnsFQDN(fqdn) |
| + out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(want)}) |
| + if err != nil { |
| + return "", fmt.Errorf("route53: list hosted zones: %w", err) |
| + } |
| + var bestName, bestID string |
| + for _, hz := range out.HostedZones { |
| + zn := strings.ToLower(aws.ToString(hz.Name)) |
| + if zn == want || strings.HasSuffix(want, "."+zn) { |
| + if len(zn) > len(bestName) { |
| + bestName, bestID = zn, strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/") |
| + } |
| + } |
| + } |
| + if bestID == "" { |
| + return "", fmt.Errorf("route53: no hosted zone for %s", fqdn) |
| + } |
| + r.mu.Lock() |
| + r.zoneIDs[fqdn] = bestID |
| + r.mu.Unlock() |
| + return bestID, nil |
| +} |
| + |
| +// 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 + "." |
| +} |