ipn/localapi,client/local: honour Retry-After on cert rate-limit (#20315)
* ipn/localapi,ipnlocal,feature/acme,client/local: honour Retry-After on cert rate-limit serveCert now responds with 429 + Retry-After when the underlying ACME error is a rate limit, instead of a generic 500. client/local surfaces this as a typed RateLimitedError with the parsed hint so callers can back off intelligently. Updates tailscale/corp#42164 Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk> * tsweb,feature/acme,ipn/localapi,ipnlocal: generalise cert error → HTTP mapping via tsweb.HTTPStatuser Introduces a tsweb.HTTPStatuser interface, any error can implement to describe its intended HTTP response (code, message, headers). Moves CertRateLimitedError from ipnlocal to feature/acme where it's constructed, and it now uses HTTPStatuser to return 429 + Retry-After. serveCert now checks for tsweb.HTTPStatuser rather than the specific error type, so it no longer needs to know about the ACME rate-limit type. Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk> --------- Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
This commit is contained in:
@@ -10,13 +10,55 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
)
|
||||
|
||||
// rateLimitedError is returned from cert-fetching methods when the
|
||||
// upstream ACME CA reported a rate limit. Callers should unpack it via
|
||||
// [RateLimitRetryAfter].
|
||||
type rateLimitedError struct {
|
||||
retryAfter time.Duration
|
||||
underlying error
|
||||
}
|
||||
|
||||
func (e rateLimitedError) Error() string { return e.underlying.Error() }
|
||||
func (e rateLimitedError) Unwrap() error { return e.underlying }
|
||||
|
||||
// RateLimitRetryAfter reports whether err was a rate-limit failure from
|
||||
// the upstream ACME CA and, if so, returns the CA's suggested wait
|
||||
// (zero if none was provided).
|
||||
func RateLimitRetryAfter(err error) (retryAfter time.Duration, ok bool) {
|
||||
var rl rateLimitedError
|
||||
if errors.As(err, &rl) {
|
||||
return rl.retryAfter, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// retryAfterFromHeader parses a Retry-After header, matching the
|
||||
// delta-seconds + HTTP-date pattern in tempfork/acme/http.go.
|
||||
func retryAfterFromHeader(h http.Header) time.Duration {
|
||||
v := h.Get("Retry-After")
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return time.Duration(i) * time.Second
|
||||
}
|
||||
t, err := http.ParseTime(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
d := time.Until(t)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// SetDNS adds a DNS TXT record for the given domain name, containing
|
||||
// the provided TXT value. The intended use case is answering
|
||||
// LetsEncrypt/ACME dns-01 challenges.
|
||||
@@ -43,6 +85,8 @@ func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// Rate-limit failures can be identified via [RateLimitRetryAfter].
|
||||
//
|
||||
// Deprecated: use [Client.CertPair].
|
||||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return defaultClient.CertPair(ctx, domain)
|
||||
@@ -52,6 +96,8 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// Rate-limit failures can be identified via [RateLimitRetryAfter].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return lc.CertPairWithValidity(ctx, domain, 0)
|
||||
@@ -65,10 +111,18 @@ func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM
|
||||
// least the given duration, if permitted by the CA. If the certificate is
|
||||
// valid, but for less than minValidity, it will be synchronously renewed.
|
||||
//
|
||||
// Rate-limit failures can be identified via [RateLimitRetryAfter].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
|
||||
if err != nil {
|
||||
if hse, ok := errors.AsType[httpStatusError](err); ok && hse.HTTPStatus == http.StatusTooManyRequests {
|
||||
return nil, nil, rateLimitedError{
|
||||
retryAfter: retryAfterFromHeader(hse.Header),
|
||||
underlying: err,
|
||||
}
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
// with ?type=pair, the response PEM is first the one private
|
||||
|
||||
@@ -280,7 +280,7 @@ func (lc *Client) sendWithHeaders(
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||||
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
|
||||
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode, res.Header}
|
||||
}
|
||||
return slurp, res.Header, nil
|
||||
}
|
||||
@@ -288,6 +288,7 @@ func (lc *Client) sendWithHeaders(
|
||||
type httpStatusError struct {
|
||||
error
|
||||
HTTPStatus int
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {
|
||||
|
||||
Reference in New Issue
Block a user