dns_route53.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  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. // Submit to API.
  103. comment := fmt.Sprintf("enrtree update of %s at seq %d", name, t.Seq())
  104. return c.submitChanges(changes, comment)
  105. }
  106. // deleteDomain removes all TXT records of the given domain.
  107. func (c *route53Client) deleteDomain(name string) error {
  108. if err := c.checkZone(name); err != nil {
  109. return err
  110. }
  111. // Compute DNS changes.
  112. existing, err := c.collectRecords(name)
  113. if err != nil {
  114. return err
  115. }
  116. log.Info(fmt.Sprintf("Found %d TXT records", len(existing)))
  117. changes := makeDeletionChanges(existing, nil)
  118. // Submit to API.
  119. comment := "enrtree delete of " + name
  120. return c.submitChanges(changes, comment)
  121. }
  122. // submitChanges submits the given DNS changes to Route53.
  123. func (c *route53Client) submitChanges(changes []types.Change, comment string) error {
  124. if len(changes) == 0 {
  125. log.Info("No DNS changes needed")
  126. return nil
  127. }
  128. var err error
  129. batches := splitChanges(changes, route53ChangeSizeLimit, route53ChangeCountLimit)
  130. changesToCheck := make([]*route53.ChangeResourceRecordSetsOutput, len(batches))
  131. for i, changes := range batches {
  132. log.Info(fmt.Sprintf("Submitting %d changes to Route53", len(changes)))
  133. batch := &types.ChangeBatch{
  134. Changes: changes,
  135. Comment: aws.String(fmt.Sprintf("%s (%d/%d)", comment, i+1, len(batches))),
  136. }
  137. req := &route53.ChangeResourceRecordSetsInput{HostedZoneId: &c.zoneID, ChangeBatch: batch}
  138. changesToCheck[i], err = c.api.ChangeResourceRecordSets(context.TODO(), req)
  139. if err != nil {
  140. return err
  141. }
  142. }
  143. // Wait for all change batches to propagate.
  144. for _, change := range changesToCheck {
  145. log.Info(fmt.Sprintf("Waiting for change request %s", *change.ChangeInfo.Id))
  146. wreq := &route53.GetChangeInput{Id: change.ChangeInfo.Id}
  147. var count int
  148. for {
  149. wresp, err := c.api.GetChange(context.TODO(), wreq)
  150. if err != nil {
  151. return err
  152. }
  153. count++
  154. if wresp.ChangeInfo.Status == types.ChangeStatusInsync || count >= maxRetryLimit {
  155. break
  156. }
  157. time.Sleep(30 * time.Second)
  158. }
  159. }
  160. return nil
  161. }
  162. // checkZone verifies zone information for the given domain.
  163. func (c *route53Client) checkZone(name string) (err error) {
  164. if c.zoneID == "" {
  165. c.zoneID, err = c.findZoneID(name)
  166. }
  167. return err
  168. }
  169. // findZoneID searches for the Zone ID containing the given domain.
  170. func (c *route53Client) findZoneID(name string) (string, error) {
  171. log.Info(fmt.Sprintf("Finding Route53 Zone ID for %s", name))
  172. var req route53.ListHostedZonesByNameInput
  173. for {
  174. resp, err := c.api.ListHostedZonesByName(context.TODO(), &req)
  175. if err != nil {
  176. return "", err
  177. }
  178. for _, zone := range resp.HostedZones {
  179. if isSubdomain(name, *zone.Name) {
  180. return *zone.Id, nil
  181. }
  182. }
  183. if !resp.IsTruncated {
  184. break
  185. }
  186. req.DNSName = resp.NextDNSName
  187. req.HostedZoneId = resp.NextHostedZoneId
  188. }
  189. return "", errors.New("can't find zone ID for " + name)
  190. }
  191. // computeChanges creates DNS changes for the given set of DNS discovery records.
  192. // The 'existing' arg is the set of records that already exist on Route53.
  193. func (c *route53Client) computeChanges(name string, records map[string]string, existing map[string]recordSet) []types.Change {
  194. // Convert all names to lowercase.
  195. lrecords := make(map[string]string, len(records))
  196. for name, r := range records {
  197. lrecords[strings.ToLower(name)] = r
  198. }
  199. records = lrecords
  200. var changes []types.Change
  201. for path, newValue := range records {
  202. prevRecords, exists := existing[path]
  203. prevValue := strings.Join(prevRecords.values, "")
  204. // prevValue contains quoted strings, encode newValue to compare.
  205. newValue = splitTXT(newValue)
  206. // Assign TTL.
  207. ttl := int64(rootTTL)
  208. if path != name {
  209. ttl = int64(treeNodeTTL)
  210. }
  211. if !exists {
  212. // Entry is unknown, push a new one
  213. log.Info(fmt.Sprintf("Creating %s = %s", path, newValue))
  214. changes = append(changes, newTXTChange("CREATE", path, ttl, newValue))
  215. } else if prevValue != newValue || prevRecords.ttl != ttl {
  216. // Entry already exists, only change its content.
  217. log.Info(fmt.Sprintf("Updating %s from %s to %s", path, prevValue, newValue))
  218. changes = append(changes, newTXTChange("UPSERT", path, ttl, newValue))
  219. } else {
  220. log.Debug(fmt.Sprintf("Skipping %s = %s", path, newValue))
  221. }
  222. }
  223. // Iterate over the old records and delete anything stale.
  224. changes = append(changes, makeDeletionChanges(existing, records)...)
  225. // Ensure changes are in the correct order.
  226. sortChanges(changes)
  227. return changes
  228. }
  229. // makeDeletionChanges creates record changes which delete all records not contained in 'keep'.
  230. func makeDeletionChanges(records map[string]recordSet, keep map[string]string) []types.Change {
  231. var changes []types.Change
  232. for path, set := range records {
  233. if _, ok := keep[path]; ok {
  234. continue
  235. }
  236. log.Info(fmt.Sprintf("Deleting %s = %s", path, strings.Join(set.values, "")))
  237. changes = append(changes, newTXTChange("DELETE", path, set.ttl, set.values...))
  238. }
  239. return changes
  240. }
  241. // sortChanges ensures DNS changes are in leaf-added -> root-changed -> leaf-deleted order.
  242. func sortChanges(changes []types.Change) {
  243. score := map[string]int{"CREATE": 1, "UPSERT": 2, "DELETE": 3}
  244. sort.Slice(changes, func(i, j int) bool {
  245. if changes[i].Action == changes[j].Action {
  246. return *changes[i].ResourceRecordSet.Name < *changes[j].ResourceRecordSet.Name
  247. }
  248. return score[string(changes[i].Action)] < score[string(changes[j].Action)]
  249. })
  250. }
  251. // splitChanges splits up DNS changes such that each change batch
  252. // is smaller than the given RDATA limit.
  253. func splitChanges(changes []types.Change, sizeLimit, countLimit int) [][]types.Change {
  254. var (
  255. batches [][]types.Change
  256. batchSize int
  257. batchCount int
  258. )
  259. for _, ch := range changes {
  260. // Start new batch if this change pushes the current one over the limit.
  261. count := changeCount(ch)
  262. size := changeSize(ch) * count
  263. overSize := batchSize+size > sizeLimit
  264. overCount := batchCount+count > countLimit
  265. if len(batches) == 0 || overSize || overCount {
  266. batches = append(batches, nil)
  267. batchSize = 0
  268. batchCount = 0
  269. }
  270. batches[len(batches)-1] = append(batches[len(batches)-1], ch)
  271. batchSize += size
  272. batchCount += count
  273. }
  274. return batches
  275. }
  276. // changeSize returns the RDATA size of a DNS change.
  277. func changeSize(ch types.Change) int {
  278. size := 0
  279. for _, rr := range ch.ResourceRecordSet.ResourceRecords {
  280. if rr.Value != nil {
  281. size += len(*rr.Value)
  282. }
  283. }
  284. return size
  285. }
  286. func changeCount(ch types.Change) int {
  287. if ch.Action == types.ChangeActionUpsert {
  288. return 2
  289. }
  290. return 1
  291. }
  292. // collectRecords collects all TXT records below the given name.
  293. func (c *route53Client) collectRecords(name string) (map[string]recordSet, error) {
  294. var req route53.ListResourceRecordSetsInput
  295. req.HostedZoneId = &c.zoneID
  296. existing := make(map[string]recordSet)
  297. for page := 0; ; page++ {
  298. log.Info("Loading existing TXT records", "name", name, "zone", c.zoneID, "page", page)
  299. resp, err := c.api.ListResourceRecordSets(context.TODO(), &req)
  300. if err != nil {
  301. return existing, err
  302. }
  303. for _, set := range resp.ResourceRecordSets {
  304. if !isSubdomain(*set.Name, name) || set.Type != types.RRTypeTxt {
  305. continue
  306. }
  307. s := recordSet{ttl: *set.TTL}
  308. for _, rec := range set.ResourceRecords {
  309. s.values = append(s.values, *rec.Value)
  310. }
  311. name := strings.TrimSuffix(*set.Name, ".")
  312. existing[name] = s
  313. }
  314. if !resp.IsTruncated {
  315. break
  316. }
  317. // Set the cursor to the next batch. From the AWS docs:
  318. //
  319. // To display the next page of results, get the values of NextRecordName,
  320. // NextRecordType, and NextRecordIdentifier (if any) from the response. Then submit
  321. // another ListResourceRecordSets request, and specify those values for
  322. // StartRecordName, StartRecordType, and StartRecordIdentifier.
  323. req.StartRecordIdentifier = resp.NextRecordIdentifier
  324. req.StartRecordName = resp.NextRecordName
  325. req.StartRecordType = resp.NextRecordType
  326. }
  327. return existing, nil
  328. }
  329. // newTXTChange creates a change to a TXT record.
  330. func newTXTChange(action, name string, ttl int64, values ...string) types.Change {
  331. r := types.ResourceRecordSet{
  332. Type: types.RRTypeTxt,
  333. Name: &name,
  334. TTL: &ttl,
  335. }
  336. var rrs []types.ResourceRecord
  337. for _, val := range values {
  338. var rr types.ResourceRecord
  339. rr.Value = aws.String(val)
  340. rrs = append(rrs, rr)
  341. }
  342. r.ResourceRecords = rrs
  343. return types.Change{
  344. Action: types.ChangeAction(action),
  345. ResourceRecordSet: &r,
  346. }
  347. }
  348. // isSubdomain returns true if name is a subdomain of domain.
  349. func isSubdomain(name, domain string) bool {
  350. domain = strings.TrimSuffix(domain, ".")
  351. name = strings.TrimSuffix(name, ".")
  352. return strings.HasSuffix("."+name, "."+domain)
  353. }
  354. // splitTXT splits value into a list of quoted 255-character strings.
  355. func splitTXT(value string) string {
  356. var result strings.Builder
  357. for len(value) > 0 {
  358. rlen := len(value)
  359. if rlen > 253 {
  360. rlen = 253
  361. }
  362. result.WriteString(strconv.Quote(value[:rlen]))
  363. value = value[rlen:]
  364. }
  365. return result.String()
  366. }