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