| 1 | package main |
| 2 | |
| 3 | // π€ route53DNS β the ADDITIVE Route53 DNS-01 backend (docs/multi-edge.md). It |
| 4 | // is the off-ramp that lets a box obtain a *.dev.11ch.at (or any Route53-hosted) |
| 5 | // wildcard cert with NO DNS token on disk: auth rides the standard AWS |
| 6 | // credential chain (env vars or, on the dev boxes, the EC2/container IAM role |
| 7 | // scoped to just that hosted zone). DNSimple stays the default, fully-supported |
| 8 | // provider (wildcard.go); this NEVER replaces it β it is chosen only by an |
| 9 | // explicit LCHAT_DNS_PROVIDER=route53 (see dnsProviderFromEnv). |
| 10 | // |
| 11 | // Same seam as DNSimple (the dnsProvider interface): publish a |
| 12 | // _acme-challenge.<zone> TXT, wait for the change to go INSYNC across Route53's |
| 13 | // servers, and hand back a closure that removes it. The AWS SDK setup mirrors |
| 14 | // s3blobstore.go (config.LoadDefaultConfig + the default chain), so credentials |
| 15 | // come from exactly one place fleet-wide. |
| 16 | |
| 17 | import ( |
| 18 | "context" |
| 19 | "fmt" |
| 20 | "os" |
| 21 | "strconv" |
| 22 | "strings" |
| 23 | "sync" |
| 24 | "time" |
| 25 | |
| 26 | "github.com/aws/aws-sdk-go-v2/aws" |
| 27 | awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| 28 | "github.com/aws/aws-sdk-go-v2/service/route53" |
| 29 | r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| 30 | ) |
| 31 | |
| 32 | // route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to reach |
| 33 | // INSYNC. Route53 usually syncs in well under a minute; on timeout we proceed |
| 34 | // anyway β the CA runs its own retries and waitForTXT still gates public |
| 35 | // visibility, exactly as the DNSimple path relies on (best-effort, never fatal). |
| 36 | const route53InSyncTimeout = 2 * time.Minute |
| 37 | |
| 38 | // route53API is the slice of the Route53 client route53DNS calls β an interface |
| 39 | // so tests can 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 | type route53DNS struct { |
| 47 | client route53API |
| 48 | mu sync.Mutex |
| 49 | zoneIDs map[string]string // zone apex (no trailing dot) β hosted-zone id |
| 50 | } |
| 51 | |
| 52 | // newRoute53DNS builds the backend from the default AWS credential chain (env / |
| 53 | // instance role), the same setup style as s3blobstore.go. Route53 is a global |
| 54 | // service but the SDK still needs a signing region, so default to us-east-1 when |
| 55 | // the environment names none β harmless, the endpoint is global either way, and |
| 56 | // an explicit AWS_REGION / profile still wins. |
| 57 | func newRoute53DNS(ctx context.Context) (*route53DNS, error) { |
| 58 | var opts []func(*awsconfig.LoadOptions) error |
| 59 | if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" { |
| 60 | opts = append(opts, awsconfig.WithRegion("us-east-1")) |
| 61 | } |
| 62 | cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| 63 | if err != nil { |
| 64 | return nil, fmt.Errorf("route53: load aws config: %w", err) |
| 65 | } |
| 66 | return &route53DNS{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil |
| 67 | } |
| 68 | |
| 69 | // present satisfies dnsProvider: UPSERT the challenge TXT, wait for INSYNC, and |
| 70 | // return a closure that deletes the exact record set it created. |
| 71 | func (r *route53DNS) present(ctx context.Context, zone, name, value string) (func(context.Context) error, error) { |
| 72 | zoneID, err := r.hostedZoneID(ctx, zone) |
| 73 | if err != nil { |
| 74 | return nil, err |
| 75 | } |
| 76 | // Route53 stores a TXT value as a quoted character-string; the ACME token is |
| 77 | // a bare base64url string, so wrap it. UPSERT (not CREATE) so a TXT stranded |
| 78 | // by a crashed prior attempt is overwritten instead of colliding. |
| 79 | rrset := &r53types.ResourceRecordSet{ |
| 80 | Name: aws.String(dnsName(name, zone)), |
| 81 | Type: r53types.RRTypeTxt, |
| 82 | TTL: aws.Int64(60), |
| 83 | ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(strconv.Quote(value))}}, |
| 84 | } |
| 85 | out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ |
| 86 | HostedZoneId: aws.String(zoneID), |
| 87 | ChangeBatch: &r53types.ChangeBatch{ |
| 88 | Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}}, |
| 89 | }, |
| 90 | }) |
| 91 | if err != nil { |
| 92 | return nil, fmt.Errorf("route53: upsert TXT: %w", err) |
| 93 | } |
| 94 | // Wait (best-effort) for the change to reach all Route53 servers before the |
| 95 | // caller tells the CA to look β the DNSimple path leans on waitForTXT for the |
| 96 | // same reason; here we also have Route53's own INSYNC signal. |
| 97 | if out.ChangeInfo != nil { |
| 98 | r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id)) |
| 99 | } |
| 100 | cleanup := func(cctx context.Context) error { |
| 101 | // DELETE must resubmit the same record set that was created. |
| 102 | _, err := r.client.ChangeResourceRecordSets(cctx, &route53.ChangeResourceRecordSetsInput{ |
| 103 | HostedZoneId: aws.String(zoneID), |
| 104 | ChangeBatch: &r53types.ChangeBatch{ |
| 105 | Changes: []r53types.Change{{Action: r53types.ChangeActionDelete, ResourceRecordSet: rrset}}, |
| 106 | }, |
| 107 | }) |
| 108 | return err |
| 109 | } |
| 110 | return cleanup, nil |
| 111 | } |
| 112 | |
| 113 | // upsertA satisfies aRecordUpserter (relocate.go): point host's A record at |
| 114 | // ipv4. It is the OPTIONAL, off-by-default half of relocation β moving the A |
| 115 | // record to the destination box avoids the mesh's proxy hop, but relocation is |
| 116 | // correct without it (the *.dev wildcard + proxy-to-owner already route any |
| 117 | // request to the new owner; docs/multi-edge.md). UPSERT so a re-run is |
| 118 | // idempotent (overwrites in place, never a duplicate-record error), TTL 60 so |
| 119 | // the propagation window the proxy covers stays short, and we wait for INSYNC |
| 120 | // like present so the caller can trust the record is live before returning. |
| 121 | func (r *route53DNS) upsertA(ctx context.Context, zone, host, ipv4 string) error { |
| 122 | zoneID, err := r.hostedZoneID(ctx, zone) |
| 123 | if err != nil { |
| 124 | return err |
| 125 | } |
| 126 | rrset := &r53types.ResourceRecordSet{ |
| 127 | Name: aws.String(dnsFQDN(host)), |
| 128 | Type: r53types.RRTypeA, |
| 129 | TTL: aws.Int64(60), |
| 130 | ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(ipv4)}}, |
| 131 | } |
| 132 | out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ |
| 133 | HostedZoneId: aws.String(zoneID), |
| 134 | ChangeBatch: &r53types.ChangeBatch{ |
| 135 | Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}}, |
| 136 | }, |
| 137 | }) |
| 138 | if err != nil { |
| 139 | return fmt.Errorf("route53: upsert A %s β %s: %w", host, ipv4, err) |
| 140 | } |
| 141 | if out.ChangeInfo != nil { |
| 142 | r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id)) |
| 143 | } |
| 144 | return nil |
| 145 | } |
| 146 | |
| 147 | // hostedZoneID resolves a zone to its Route53 hosted-zone id, caching the |
| 148 | // lookup. It expects the wildcard domain to have its OWN hosted zone (which is |
| 149 | // how the box's IAM role is scoped β one zone, e.g. dev.11ch.at), so it takes |
| 150 | // the most specific returned zone whose name is the zone itself or a parent of |
| 151 | // it. Ids come back as "/hostedzone/Z123"; the change API accepts either form |
| 152 | // but we store the bare id. |
| 153 | func (r *route53DNS) hostedZoneID(ctx context.Context, zone string) (string, error) { |
| 154 | r.mu.Lock() |
| 155 | if id, ok := r.zoneIDs[zone]; ok { |
| 156 | r.mu.Unlock() |
| 157 | return id, nil |
| 158 | } |
| 159 | r.mu.Unlock() |
| 160 | |
| 161 | want := dnsFQDN(zone) |
| 162 | out, err := r.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(want)}) |
| 163 | if err != nil { |
| 164 | return "", fmt.Errorf("route53: list hosted zones: %w", err) |
| 165 | } |
| 166 | var bestName, bestID string |
| 167 | for _, hz := range out.HostedZones { |
| 168 | zn := strings.ToLower(aws.ToString(hz.Name)) |
| 169 | // The zone must be authoritative for `want`: equal to it or a parent |
| 170 | // suffix (".example.com" covers "sub.example.com"). Keep the longest β |
| 171 | // the most specific β match. |
| 172 | if zn == want || strings.HasSuffix(want, "."+zn) { |
| 173 | if len(zn) > len(bestName) { |
| 174 | bestName, bestID = zn, strings.TrimPrefix(aws.ToString(hz.Id), "/hostedzone/") |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | if bestID == "" { |
| 179 | return "", fmt.Errorf("route53: no hosted zone for %s", zone) |
| 180 | } |
| 181 | r.mu.Lock() |
| 182 | r.zoneIDs[zone] = bestID |
| 183 | r.mu.Unlock() |
| 184 | return bestID, nil |
| 185 | } |
| 186 | |
| 187 | // waitInSync polls GetChange until the change is INSYNC or the timeout passes. |
| 188 | // Best-effort, mirroring waitForTXT: a timeout is not fatal (the CA retries). |
| 189 | func (r *route53DNS) waitInSync(ctx context.Context, changeID string) { |
| 190 | if changeID == "" { |
| 191 | return |
| 192 | } |
| 193 | deadline := time.Now().Add(route53InSyncTimeout) |
| 194 | for time.Now().Before(deadline) && ctx.Err() == nil { |
| 195 | out, err := r.client.GetChange(ctx, &route53.GetChangeInput{Id: aws.String(changeID)}) |
| 196 | if err == nil && out.ChangeInfo != nil && out.ChangeInfo.Status == r53types.ChangeStatusInsync { |
| 197 | return |
| 198 | } |
| 199 | time.Sleep(3 * time.Second) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | // dnsFQDN appends the root dot Route53 stores names with. |
| 204 | func dnsFQDN(zone string) string { |
| 205 | zone = strings.ToLower(zone) |
| 206 | if strings.HasSuffix(zone, ".") { |
| 207 | return zone |
| 208 | } |
| 209 | return zone + "." |
| 210 | } |
| 211 | |
| 212 | // dnsName builds the fully-qualified record name Route53 wants, e.g. |
| 213 | // ("_acme-challenge", "11ch.at") β "_acme-challenge.11ch.at." |
| 214 | func dnsName(name, zone string) string { |
| 215 | return name + "." + dnsFQDN(zone) |
| 216 | } |
| 217 | |