From ed5c0a3869d2128703376b03f5a5276f4b48ab28 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Mon, 31 May 2021 23:42:24 +0200 Subject: [PATCH] hurricane: add API rate limiter. (#1417) --- go.mod | 1 + go.sum | 3 +- providers/dns/hurricane/hurricane.go | 5 +-- providers/dns/hurricane/internal/client.go | 34 +++++++++++++++++-- .../dns/hurricane/internal/client_test.go | 3 +- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 8924313e..24215875 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d + golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba google.golang.org/api v0.20.0 gopkg.in/ns1/ns1-go.v2 v2.4.4 gopkg.in/square/go-jose.v2 v2.5.1 diff --git a/go.sum b/go.sum index 5cb7f527..631fae7c 100644 --- a/go.sum +++ b/go.sum @@ -631,8 +631,9 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/providers/dns/hurricane/hurricane.go b/providers/dns/hurricane/hurricane.go index d769e134..3427e600 100644 --- a/providers/dns/hurricane/hurricane.go +++ b/providers/dns/hurricane/hurricane.go @@ -1,6 +1,7 @@ package hurricane import ( + "context" "errors" "fmt" "net/http" @@ -87,7 +88,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { func (d *DNSProvider) Present(domain, _, keyAuth string) error { _, txtRecord := dns01.GetRecord(domain, keyAuth) - err := d.client.UpdateTxtRecord(domain, txtRecord) + err := d.client.UpdateTxtRecord(context.Background(), domain, txtRecord) if err != nil { return fmt.Errorf("hurricane: %w", err) } @@ -97,7 +98,7 @@ func (d *DNSProvider) Present(domain, _, keyAuth string) error { // CleanUp updates the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, _ string) error { - err := d.client.UpdateTxtRecord(domain, ".") + err := d.client.UpdateTxtRecord(context.Background(), domain, ".") if err != nil { return fmt.Errorf("hurricane: %w", err) } diff --git a/providers/dns/hurricane/internal/client.go b/providers/dns/hurricane/internal/client.go index dd06fbf8..38696f40 100644 --- a/providers/dns/hurricane/internal/client.go +++ b/providers/dns/hurricane/internal/client.go @@ -2,6 +2,7 @@ package internal import ( "bytes" + "context" "fmt" "io/ioutil" "log" @@ -10,6 +11,8 @@ import ( "strings" "sync" "time" + + "golang.org/x/time/rate" ) const defaultBaseURL = "https://dyn.dns.he.net/nic/update" @@ -20,14 +23,19 @@ const ( codeAbuse = "abuse" codeBadAgent = "badagent" codeBadAuth = "badauth" + codeInterval = "interval" codeNoHost = "nohost" codeNotFqdn = "notfqdn" ) +const defaultBurst = 5 + // Client the Hurricane Electric client. type Client struct { - HTTPClient *http.Client - baseURL string + HTTPClient *http.Client + rateLimiters sync.Map + + baseURL string credentials map[string]string credMu sync.Mutex @@ -43,7 +51,7 @@ func NewClient(credentials map[string]string) *Client { } // UpdateTxtRecord updates a TXT record. -func (c *Client) UpdateTxtRecord(domain string, txt string) error { +func (c *Client) UpdateTxtRecord(ctx context.Context, domain string, txt string) error { hostname := fmt.Sprintf("_acme-challenge.%s", domain) c.credMu.Lock() @@ -59,6 +67,13 @@ func (c *Client) UpdateTxtRecord(domain string, txt string) error { data.Set("hostname", hostname) data.Set("txt", txt) + rl, _ := c.rateLimiters.LoadOrStore(hostname, rate.NewLimiter(limit(defaultBurst), defaultBurst)) + + err := rl.(*rate.Limiter).Wait(ctx) + if err != nil { + return err + } + resp, err := c.HTTPClient.PostForm(c.baseURL, data) if err != nil { return err @@ -95,6 +110,8 @@ func evaluateBody(body string, hostname string) error { return fmt.Errorf("%s: user agent not sent or HTTP method not recognized; open an issue on go-acme/lego on Github", body) case codeBadAuth: return fmt.Errorf("%s: wrong authentication token provided for TXT record %s", body, hostname) + case codeInterval: + return fmt.Errorf("%s: TXT records update exceeded API rate limit", body) case codeNoHost: return fmt.Errorf("%s: the record provided does not exist in this account: %s", body, hostname) case codeNotFqdn: @@ -104,3 +121,14 @@ func evaluateBody(body string, hostname string) error { return fmt.Errorf("attempt to change TXT record %s returned %s", hostname, body) } } + +// limit computes the rate based on burst. +// The API rate limit per-record is 10 reqs / 2 minutes. +// +// 10 reqs / 2 minutes = freq 1/12 (burst = 1) +// 6 reqs / 2 minutes = freq 1/20 (burst = 5) +// +// https://github.com/go-acme/lego/issues/1415 +func limit(burst int) rate.Limit { + return 1 / rate.Limit(120/(10-burst+1)) +} diff --git a/providers/dns/hurricane/internal/client_test.go b/providers/dns/hurricane/internal/client_test.go index 286e95ae..b4869d69 100644 --- a/providers/dns/hurricane/internal/client_test.go +++ b/providers/dns/hurricane/internal/client_test.go @@ -1,6 +1,7 @@ package internal import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -74,7 +75,7 @@ func TestClient_UpdateTxtRecord(t *testing.T) { client := NewClient(map[string]string{"example.com": "secret"}) client.baseURL = server.URL - err := client.UpdateTxtRecord("example.com", "foo") + err := client.UpdateTxtRecord(context.Background(), "example.com", "foo") test.expected(t, err) }) }