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