dns_route53.go 11 KB

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