carlos / platform Public

edge: Route53 DNS-01 backend (CARLOS_DNS_PROVIDER=route53)

Port of Eleven's route53dns.go minus relocation-only upsertA; zone for a
record fqdn is discovered by suffix match, so per-account parents with no
zone of their own land in the registered domain's zone.
Paul Campbell 0f0c24dd79ff1a1f6ba1b26313d1909e9e7317a9 parent 7e8ba91
4 files changed, +237 −7
  • go.mod +1 −1
  • internal/edge/dns.go +0 −6
  • internal/edge/route53dns.go +166 −0
  • internal/edge/route53dns_test.go +70 −0
diff --git a/go.mod b/go.mod
index 1997f618d..6d1969096 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.26
require (
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.6
+ github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/aws/smithy-go v1.27.3
github.com/benbjohnson/litestream v0.5.14
@@ -26,7 +27,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
- github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
diff --git a/internal/edge/dns.go b/internal/edge/dns.go
index 88f6d8033..89c152ea6 100644
--- a/internal/edge/dns.go
+++ b/internal/edge/dns.go
@@ -266,9 +266,3 @@ func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) e
}
return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil)
}
-
-// newRoute53DNS is implemented in route53dns.go (Task 2). Stub so the
-// dnsimple-first commit compiles; selection tests skip route53 construction.
-func newRoute53DNS(ctx context.Context) (dnsProvider, error) {
- return nil, errors.New("route53 backend not built yet")
-}
diff --git a/internal/edge/route53dns.go b/internal/edge/route53dns.go
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 + "."
+}
diff --git a/internal/edge/route53dns_test.go b/internal/edge/route53dns_test.go
new file mode 100644
index 000000000..2d20d5763
--- /dev/null
+++ b/internal/edge/route53dns_test.go
@@ -0,0 +1,70 @@
+package edge
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+)
+
+// fakeRoute53 satisfies route53API and records the record-set changes.
+type fakeRoute53 struct {
+ zones map[string]string // zone name (with trailing dot) → id
+ changes []string // "ACTION|name|value"
+}
+
+func (f *fakeRoute53) ListHostedZonesByName(_ context.Context, in *route53.ListHostedZonesByNameInput, _ ...func(*route53.Options)) (*route53.ListHostedZonesByNameOutput, error) {
+ out := &route53.ListHostedZonesByNameOutput{}
+ for name, id := range f.zones {
+ out.HostedZones = append(out.HostedZones, r53types.HostedZone{Name: aws.String(name), Id: aws.String("/hostedzone/" + id)})
+ }
+ return out, nil
+}
+
+func (f *fakeRoute53) ChangeResourceRecordSets(_ context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
+ for _, c := range in.ChangeBatch.Changes {
+ f.changes = append(f.changes, string(c.Action)+"|"+aws.ToString(c.ResourceRecordSet.Name)+"|"+aws.ToString(c.ResourceRecordSet.ResourceRecords[0].Value))
+ }
+ return &route53.ChangeResourceRecordSetsOutput{ChangeInfo: &r53types.ChangeInfo{Id: aws.String("c1"), Status: r53types.ChangeStatusInsync}}, nil
+}
+
+func (f *fakeRoute53) GetChange(_ context.Context, _ *route53.GetChangeInput, _ ...func(*route53.Options)) (*route53.GetChangeOutput, error) {
+ return &route53.GetChangeOutput{ChangeInfo: &r53types.ChangeInfo{Status: r53types.ChangeStatusInsync}}, nil
+}
+
+// The fqdn seam: the backend must find the most specific zone that is a
+// parent of the record fqdn — tito.io owns _acme-challenge.go.tito.io when
+// go.tito.io has no zone of its own, and the value must be quoted (Route53
+// stores TXT values as quoted character-strings).
+func TestRoute53PresentUpsertsAndCleansUp(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"tito.io.": "Z1", "io.": "Z0"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+
+ cleanup, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "txtvalue")
+ if err != nil {
+ t.Fatalf("present: %v", err)
+ }
+ if len(fake.changes) != 1 || fake.changes[0] != `UPSERT|_acme-challenge.go.tito.io.|"txtvalue"` {
+ t.Fatalf("changes after present = %v", fake.changes)
+ }
+ if err := cleanup(context.Background()); err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if len(fake.changes) != 2 || !strings.HasPrefix(fake.changes[1], "DELETE|_acme-challenge.go.tito.io.|") {
+ t.Fatalf("changes after cleanup = %v", fake.changes)
+ }
+ if got := r.zoneIDs["_acme-challenge.go.tito.io"]; got != "Z1" {
+ t.Fatalf("cached zone id = %q, want Z1 (most specific parent zone)", got)
+ }
+}
+
+func TestRoute53NoZone(t *testing.T) {
+ fake := &fakeRoute53{zones: map[string]string{"example.org.": "Z9"}}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+ if _, err := r.present(context.Background(), "_acme-challenge.go.tito.io", "v"); err == nil {
+ t.Fatal("present with no owning zone: want error, got nil")
+ }
+}