github.com/ethxdao/go-ethereum@v0.0.0-20221218102228-5ae34a9cc189/cmd/devp2p/dns_route53.go (about) 1 // Copyright 2019 The go-ethereum Authors 2 // This file is part of go-ethereum. 3 // 4 // go-ethereum is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // go-ethereum is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with go-ethereum. If not, see <http://www.gnu.org/licenses/>. 16 17 package main 18 19 import ( 20 "context" 21 "errors" 22 "fmt" 23 "sort" 24 "strconv" 25 "strings" 26 "time" 27 28 "github.com/aws/aws-sdk-go-v2/aws" 29 "github.com/aws/aws-sdk-go-v2/config" 30 "github.com/aws/aws-sdk-go-v2/credentials" 31 "github.com/aws/aws-sdk-go-v2/service/route53" 32 "github.com/aws/aws-sdk-go-v2/service/route53/types" 33 "github.com/ethxdao/go-ethereum/log" 34 "github.com/ethxdao/go-ethereum/p2p/dnsdisc" 35 ) 36 37 const ( 38 // Route53 limits change sets to 32k of 'RDATA size'. Change sets are also limited to 39 // 1000 items. UPSERTs count double. 40 // https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests-changeresourcerecordsets 41 route53ChangeSizeLimit = 32000 42 route53ChangeCountLimit = 1000 43 maxRetryLimit = 60 44 ) 45 46 var ( 47 route53AccessKeyFlag = &cli.StringFlag{ 48 Name: "access-key-id", 49 Usage: "AWS Access Key ID", 50 EnvVars: []string{"AWS_ACCESS_KEY_ID"}, 51 } 52 route53AccessSecretFlag = &cli.StringFlag{ 53 Name: "access-key-secret", 54 Usage: "AWS Access Key Secret", 55 EnvVars: []string{"AWS_SECRET_ACCESS_KEY"}, 56 } 57 route53ZoneIDFlag = &cli.StringFlag{ 58 Name: "zone-id", 59 Usage: "Route53 Zone ID", 60 } 61 route53RegionFlag = &cli.StringFlag{ 62 Name: "aws-region", 63 Usage: "AWS Region", 64 Value: "eu-central-1", 65 } 66 ) 67 68 type route53Client struct { 69 api *route53.Client 70 zoneID string 71 } 72 73 type recordSet struct { 74 values []string 75 ttl int64 76 } 77 78 // newRoute53Client sets up a Route53 API client from command line flags. 79 func newRoute53Client(ctx *cli.Context) *route53Client { 80 akey := ctx.String(route53AccessKeyFlag.Name) 81 asec := ctx.String(route53AccessSecretFlag.Name) 82 if akey == "" || asec == "" { 83 exit(fmt.Errorf("need Route53 Access Key ID and secret to proceed")) 84 } 85 creds := aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(akey, asec, "")) 86 cfg, err := config.LoadDefaultConfig(context.Background(), config.WithCredentialsProvider(creds)) 87 if err != nil { 88 exit(fmt.Errorf("can't initialize AWS configuration: %v", err)) 89 } 90 cfg.Region = ctx.String(route53RegionFlag.Name) 91 return &route53Client{ 92 api: route53.NewFromConfig(cfg), 93 zoneID: ctx.String(route53ZoneIDFlag.Name), 94 } 95 } 96 97 // deploy uploads the given tree to Route53. 98 func (c *route53Client) deploy(name string, t *dnsdisc.Tree) error { 99 if err := c.checkZone(name); err != nil { 100 return err 101 } 102 103 // Compute DNS changes. 104 existing, err := c.collectRecords(name) 105 if err != nil { 106 return err 107 } 108 log.Info(fmt.Sprintf("Found %d TXT records", len(existing))) 109 records := t.ToTXT(name) 110 changes := c.computeChanges(name, records, existing) 111 112 // Submit to API. 113 comment := fmt.Sprintf("enrtree update of %s at seq %d", name, t.Seq()) 114 return c.submitChanges(changes, comment) 115 } 116 117 // deleteDomain removes all TXT records of the given domain. 118 func (c *route53Client) deleteDomain(name string) error { 119 if err := c.checkZone(name); err != nil { 120 return err 121 } 122 123 // Compute DNS changes. 124 existing, err := c.collectRecords(name) 125 if err != nil { 126 return err 127 } 128 log.Info(fmt.Sprintf("Found %d TXT records", len(existing))) 129 changes := makeDeletionChanges(existing, nil) 130 131 // Submit to API. 132 comment := "enrtree delete of " + name 133 return c.submitChanges(changes, comment) 134 } 135 136 // submitChanges submits the given DNS changes to Route53. 137 func (c *route53Client) submitChanges(changes []types.Change, comment string) error { 138 if len(changes) == 0 { 139 log.Info("No DNS changes needed") 140 return nil 141 } 142 143 var err error 144 batches := splitChanges(changes, route53ChangeSizeLimit, route53ChangeCountLimit) 145 changesToCheck := make([]*route53.ChangeResourceRecordSetsOutput, len(batches)) 146 for i, changes := range batches { 147 log.Info(fmt.Sprintf("Submitting %d changes to Route53", len(changes))) 148 batch := &types.ChangeBatch{ 149 Changes: changes, 150 Comment: aws.String(fmt.Sprintf("%s (%d/%d)", comment, i+1, len(batches))), 151 } 152 req := &route53.ChangeResourceRecordSetsInput{HostedZoneId: &c.zoneID, ChangeBatch: batch} 153 changesToCheck[i], err = c.api.ChangeResourceRecordSets(context.TODO(), req) 154 if err != nil { 155 return err 156 } 157 } 158 159 // Wait for all change batches to propagate. 160 for _, change := range changesToCheck { 161 log.Info(fmt.Sprintf("Waiting for change request %s", *change.ChangeInfo.Id)) 162 wreq := &route53.GetChangeInput{Id: change.ChangeInfo.Id} 163 var count int 164 for { 165 wresp, err := c.api.GetChange(context.TODO(), wreq) 166 if err != nil { 167 return err 168 } 169 170 count++ 171 172 if wresp.ChangeInfo.Status == types.ChangeStatusInsync || count >= maxRetryLimit { 173 break 174 } 175 176 time.Sleep(30 * time.Second) 177 } 178 } 179 return nil 180 } 181 182 // checkZone verifies zone information for the given domain. 183 func (c *route53Client) checkZone(name string) (err error) { 184 if c.zoneID == "" { 185 c.zoneID, err = c.findZoneID(name) 186 } 187 return err 188 } 189 190 // findZoneID searches for the Zone ID containing the given domain. 191 func (c *route53Client) findZoneID(name string) (string, error) { 192 log.Info(fmt.Sprintf("Finding Route53 Zone ID for %s", name)) 193 var req route53.ListHostedZonesByNameInput 194 for { 195 resp, err := c.api.ListHostedZonesByName(context.TODO(), &req) 196 if err != nil { 197 return "", err 198 } 199 for _, zone := range resp.HostedZones { 200 if isSubdomain(name, *zone.Name) { 201 return *zone.Id, nil 202 } 203 } 204 if !resp.IsTruncated { 205 break 206 } 207 req.DNSName = resp.NextDNSName 208 req.HostedZoneId = resp.NextHostedZoneId 209 } 210 return "", errors.New("can't find zone ID for " + name) 211 } 212 213 // computeChanges creates DNS changes for the given set of DNS discovery records. 214 // The 'existing' arg is the set of records that already exist on Route53. 215 func (c *route53Client) computeChanges(name string, records map[string]string, existing map[string]recordSet) []types.Change { 216 // Convert all names to lowercase. 217 lrecords := make(map[string]string, len(records)) 218 for name, r := range records { 219 lrecords[strings.ToLower(name)] = r 220 } 221 records = lrecords 222 223 var changes []types.Change 224 for path, newValue := range records { 225 prevRecords, exists := existing[path] 226 prevValue := strings.Join(prevRecords.values, "") 227 228 // prevValue contains quoted strings, encode newValue to compare. 229 newValue = splitTXT(newValue) 230 231 // Assign TTL. 232 ttl := int64(rootTTL) 233 if path != name { 234 ttl = int64(treeNodeTTL) 235 } 236 237 if !exists { 238 // Entry is unknown, push a new one 239 log.Info(fmt.Sprintf("Creating %s = %s", path, newValue)) 240 changes = append(changes, newTXTChange("CREATE", path, ttl, newValue)) 241 } else if prevValue != newValue || prevRecords.ttl != ttl { 242 // Entry already exists, only change its content. 243 log.Info(fmt.Sprintf("Updating %s from %s to %s", path, prevValue, newValue)) 244 changes = append(changes, newTXTChange("UPSERT", path, ttl, newValue)) 245 } else { 246 log.Debug(fmt.Sprintf("Skipping %s = %s", path, newValue)) 247 } 248 } 249 250 // Iterate over the old records and delete anything stale. 251 changes = append(changes, makeDeletionChanges(existing, records)...) 252 253 // Ensure changes are in the correct order. 254 sortChanges(changes) 255 return changes 256 } 257 258 // makeDeletionChanges creates record changes which delete all records not contained in 'keep'. 259 func makeDeletionChanges(records map[string]recordSet, keep map[string]string) []types.Change { 260 var changes []types.Change 261 for path, set := range records { 262 if _, ok := keep[path]; ok { 263 continue 264 } 265 log.Info(fmt.Sprintf("Deleting %s = %s", path, strings.Join(set.values, ""))) 266 changes = append(changes, newTXTChange("DELETE", path, set.ttl, set.values...)) 267 } 268 return changes 269 } 270 271 // sortChanges ensures DNS changes are in leaf-added -> root-changed -> leaf-deleted order. 272 func sortChanges(changes []types.Change) { 273 score := map[string]int{"CREATE": 1, "UPSERT": 2, "DELETE": 3} 274 sort.Slice(changes, func(i, j int) bool { 275 if changes[i].Action == changes[j].Action { 276 return *changes[i].ResourceRecordSet.Name < *changes[j].ResourceRecordSet.Name 277 } 278 return score[string(changes[i].Action)] < score[string(changes[j].Action)] 279 }) 280 } 281 282 // splitChanges splits up DNS changes such that each change batch 283 // is smaller than the given RDATA limit. 284 func splitChanges(changes []types.Change, sizeLimit, countLimit int) [][]types.Change { 285 var ( 286 batches [][]types.Change 287 batchSize int 288 batchCount int 289 ) 290 for _, ch := range changes { 291 // Start new batch if this change pushes the current one over the limit. 292 count := changeCount(ch) 293 size := changeSize(ch) * count 294 overSize := batchSize+size > sizeLimit 295 overCount := batchCount+count > countLimit 296 if len(batches) == 0 || overSize || overCount { 297 batches = append(batches, nil) 298 batchSize = 0 299 batchCount = 0 300 } 301 batches[len(batches)-1] = append(batches[len(batches)-1], ch) 302 batchSize += size 303 batchCount += count 304 } 305 return batches 306 } 307 308 // changeSize returns the RDATA size of a DNS change. 309 func changeSize(ch types.Change) int { 310 size := 0 311 for _, rr := range ch.ResourceRecordSet.ResourceRecords { 312 if rr.Value != nil { 313 size += len(*rr.Value) 314 } 315 } 316 return size 317 } 318 319 func changeCount(ch types.Change) int { 320 if ch.Action == types.ChangeActionUpsert { 321 return 2 322 } 323 return 1 324 } 325 326 // collectRecords collects all TXT records below the given name. 327 func (c *route53Client) collectRecords(name string) (map[string]recordSet, error) { 328 var req route53.ListResourceRecordSetsInput 329 req.HostedZoneId = &c.zoneID 330 existing := make(map[string]recordSet) 331 for page := 0; ; page++ { 332 log.Info("Loading existing TXT records", "name", name, "zone", c.zoneID, "page", page) 333 resp, err := c.api.ListResourceRecordSets(context.TODO(), &req) 334 if err != nil { 335 return existing, err 336 } 337 for _, set := range resp.ResourceRecordSets { 338 if !isSubdomain(*set.Name, name) || set.Type != types.RRTypeTxt { 339 continue 340 } 341 s := recordSet{ttl: *set.TTL} 342 for _, rec := range set.ResourceRecords { 343 s.values = append(s.values, *rec.Value) 344 } 345 name := strings.TrimSuffix(*set.Name, ".") 346 existing[name] = s 347 } 348 349 if !resp.IsTruncated { 350 break 351 } 352 // Set the cursor to the next batch. From the AWS docs: 353 // 354 // To display the next page of results, get the values of NextRecordName, 355 // NextRecordType, and NextRecordIdentifier (if any) from the response. Then submit 356 // another ListResourceRecordSets request, and specify those values for 357 // StartRecordName, StartRecordType, and StartRecordIdentifier. 358 req.StartRecordIdentifier = resp.NextRecordIdentifier 359 req.StartRecordName = resp.NextRecordName 360 req.StartRecordType = resp.NextRecordType 361 } 362 363 return existing, nil 364 } 365 366 // newTXTChange creates a change to a TXT record. 367 func newTXTChange(action, name string, ttl int64, values ...string) types.Change { 368 r := types.ResourceRecordSet{ 369 Type: types.RRTypeTxt, 370 Name: &name, 371 TTL: &ttl, 372 } 373 var rrs []types.ResourceRecord 374 for _, val := range values { 375 var rr types.ResourceRecord 376 rr.Value = aws.String(val) 377 rrs = append(rrs, rr) 378 } 379 380 r.ResourceRecords = rrs 381 382 return types.Change{ 383 Action: types.ChangeAction(action), 384 ResourceRecordSet: &r, 385 } 386 } 387 388 // isSubdomain returns true if name is a subdomain of domain. 389 func isSubdomain(name, domain string) bool { 390 domain = strings.TrimSuffix(domain, ".") 391 name = strings.TrimSuffix(name, ".") 392 return strings.HasSuffix("."+name, "."+domain) 393 } 394 395 // splitTXT splits value into a list of quoted 255-character strings. 396 func splitTXT(value string) string { 397 var result strings.Builder 398 for len(value) > 0 { 399 rlen := len(value) 400 if rlen > 253 { 401 rlen = 253 402 } 403 result.WriteString(strconv.Quote(value[:rlen])) 404 value = value[rlen:] 405 } 406 return result.String() 407 }