package main // 🤖 route53DNS — the ADDITIVE Route53 DNS-01 backend (docs/multi-edge.md). It // is the off-ramp that lets a box obtain a *.dev.11ch.at (or any Route53-hosted) // wildcard cert with NO DNS token on disk: auth rides the standard AWS // credential chain (env vars or, on the dev boxes, the EC2/container IAM role // scoped to just that hosted zone). DNSimple stays the default, fully-supported // provider (wildcard.go); this NEVER replaces it — it is chosen only by an // explicit LCHAT_DNS_PROVIDER=route53 (see dnsProviderFromEnv). // // Same seam as DNSimple (the dnsProvider interface): publish a // _acme-challenge. TXT, wait for the change to go INSYNC across Route53's // servers, and hand back a closure that removes it. The AWS SDK setup mirrors // s3blobstore.go (config.LoadDefaultConfig + the default chain), so credentials // come from exactly one place fleet-wide. 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, exactly as the DNSimple path relies on (best-effort, never fatal). const route53InSyncTimeout = 2 * time.Minute // route53API is the slice of the Route53 client route53DNS calls — an interface // so tests can 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 // zone apex (no trailing dot) → hosted-zone id } // newRoute53DNS builds the backend from the default AWS credential chain (env / // instance role), the same setup style as s3blobstore.go. 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 / profile still wins. func newRoute53DNS(ctx context.Context) (*route53DNS, 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. func (r *route53DNS) present(ctx context.Context, zone, name, value string) (func(context.Context) error, error) { zoneID, err := r.hostedZoneID(ctx, zone) if err != nil { return nil, err } // Route53 stores a TXT value as a quoted character-string; the ACME token is // a bare base64url string, so wrap it. UPSERT (not CREATE) so a TXT stranded // by a crashed prior attempt is overwritten instead of colliding. rrset := &r53types.ResourceRecordSet{ Name: aws.String(dnsName(name, zone)), 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) } // Wait (best-effort) for the change to reach all Route53 servers before the // caller tells the CA to look — the DNSimple path leans on waitForTXT for the // same reason; here we also have Route53's own INSYNC signal. 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 } // upsertA satisfies aRecordUpserter (relocate.go): point host's A record at // ipv4. It is the OPTIONAL, off-by-default half of relocation — moving the A // record to the destination box avoids the mesh's proxy hop, but relocation is // correct without it (the *.dev wildcard + proxy-to-owner already route any // request to the new owner; docs/multi-edge.md). UPSERT so a re-run is // idempotent (overwrites in place, never a duplicate-record error), TTL 60 so // the propagation window the proxy covers stays short, and we wait for INSYNC // like present so the caller can trust the record is live before returning. func (r *route53DNS) upsertA(ctx context.Context, zone, host, ipv4 string) error { zoneID, err := r.hostedZoneID(ctx, zone) if err != nil { return err } rrset := &r53types.ResourceRecordSet{ Name: aws.String(dnsFQDN(host)), Type: r53types.RRTypeA, TTL: aws.Int64(60), ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(ipv4)}}, } 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 fmt.Errorf("route53: upsert A %s → %s: %w", host, ipv4, err) } if out.ChangeInfo != nil { r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id)) } return nil } // hostedZoneID resolves a zone to its Route53 hosted-zone id, caching the // lookup. It expects the wildcard domain to have its OWN hosted zone (which is // how the box's IAM role is scoped — one zone, e.g. dev.11ch.at), so it takes // the most specific returned zone whose name is the zone itself or a parent of // it. Ids come back as "/hostedzone/Z123"; the change API accepts either form // but we store the bare id. func (r *route53DNS) hostedZoneID(ctx context.Context, zone string) (string, error) { r.mu.Lock() if id, ok := r.zoneIDs[zone]; ok { r.mu.Unlock() return id, nil } r.mu.Unlock() want := dnsFQDN(zone) 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)) // The zone must be authoritative for `want`: equal to it or a parent // suffix (".example.com" covers "sub.example.com"). Keep the longest — // the most specific — match. 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", zone) } r.mu.Lock() r.zoneIDs[zone] = 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(zone string) string { zone = strings.ToLower(zone) if strings.HasSuffix(zone, ".") { return zone } return zone + "." } // dnsName builds the fully-qualified record name Route53 wants, e.g. // ("_acme-challenge", "11ch.at") → "_acme-challenge.11ch.at." func dnsName(name, zone string) string { return name + "." + dnsFQDN(zone) }