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:
Tom Meadows
2026-07-08 13:34:40 +01:00
committed by GitHub
parent 9106b237eb
commit 87b3d7b7e5
11 changed files with 163 additions and 36 deletions
+11 -1
View File
@@ -28,6 +28,7 @@ import (
"tailscale.com/ipn/ipnext"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/syncs"
xacme "tailscale.com/tempfork/acme"
"tailscale.com/tsconst"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
@@ -171,7 +172,16 @@ func getCertPEMHook(ctx context.Context, b *ipnlocal.LocalBackend, domain string
if err != nil {
return nil, err
}
return e.getCertPEMWithValidity(ctx, b, domain, minValidity)
pair, err := e.getCertPEMWithValidity(ctx, b, domain, minValidity)
if err != nil {
if ae, ok := errors.AsType[*xacme.Error](err); ok {
if d, ok := xacme.RateLimit(ae); ok {
return nil, certRateLimitedError{retryAfter: d, underlying: err}
}
}
return nil, err
}
return pair, nil
}
func getACMETLSALPNCertHook(b *ipnlocal.LocalBackend, hi *tls.ClientHelloInfo) (*tls.Certificate, bool) {
+17 -17
View File
@@ -27,7 +27,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/acme"
xacme "tailscale.com/tempfork/acme"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
"tailscale.com/util/set"
@@ -49,7 +49,7 @@ var acmeDebug = envknob.RegisterBool("TS_DEBUG_ACME")
// and an ACME order is actively waiting on that challenge for
// hi.ServerName.
func (e *extension) getACMETLSALPNCert(hi *tls.ClientHelloInfo) (cert *tls.Certificate, ok bool) {
if hi == nil || hi.ServerName == "" || !slices.Contains(hi.SupportedProtos, acme.ALPNProto) {
if hi == nil || hi.ServerName == "" || !slices.Contains(hi.SupportedProtos, xacme.ALPNProto) {
return nil, false
}
cert, ok = e.pendingACMETLSALPNCerts.Load(hi.ServerName)
@@ -62,7 +62,7 @@ func (e *extension) getACMETLSALPNProto(hi *tls.ClientHelloInfo) (proto string,
if _, ok := e.getACMETLSALPNCert(hi); !ok {
return "", false
}
return acme.ALPNProto, true
return xacme.ALPNProto, true
}
// storeACMETLSALPNCert publishes cert to Serve TLS handshakes for domain
@@ -252,7 +252,7 @@ func (e *extension) isBYOFunnelDomain(b *ipnlocal.LocalBackend, domain string) b
return b.HasFunnelForHostPort(domain, 443)
}
func challengeByType(challenges []*acme.Challenge, typ string) *acme.Challenge {
func challengeByType(challenges []*xacme.Challenge, typ string) *xacme.Challenge {
for _, ch := range challenges {
if ch.Type == typ {
return ch
@@ -349,9 +349,9 @@ var getCertPEM = func(ctx context.Context, e *extension, b *ipnlocal.LocalBacken
case err == nil:
// Great, already registered.
logf("already had ACME account.")
case err == acme.ErrNoAccount:
a, err = ac.Register(ctx, new(acme.Account), acme.AcceptTOS)
if err == acme.ErrAccountAlreadyExists {
case err == xacme.ErrNoAccount:
a, err = ac.Register(ctx, new(xacme.Account), xacme.AcceptTOS)
if err == xacme.ErrAccountAlreadyExists {
// Potential race. Double check.
a, err = ac.GetReg(ctx, "" /* pre-RFC param */)
}
@@ -364,7 +364,7 @@ var getCertPEM = func(ctx context.Context, e *extension, b *ipnlocal.LocalBacken
return nil, fmt.Errorf("acme.GetReg: %w", err)
}
if a.Status != acme.StatusValid {
if a.Status != xacme.StatusValid {
return nil, fmt.Errorf("unexpected ACME account status %q", a.Status)
}
@@ -374,11 +374,11 @@ var getCertPEM = func(ctx context.Context, e *extension, b *ipnlocal.LocalBacken
// Note that this order extension will fail renewals if the ACME account key has changed
// since the last issuance, see
// https://github.com/tailscale/tailscale/issues/18251
var opts []acme.OrderOption
var opts []xacme.OrderOption
if previous != nil && !envknob.Bool("TS_DEBUG_ACME_FORCE_RENEWAL") {
prevCrt, err := parseCertificate(previous)
if err == nil {
opts = append(opts, acme.WithOrderReplacesCert(prevCrt))
opts = append(opts, xacme.WithOrderReplacesCert(prevCrt))
}
}
@@ -415,14 +415,14 @@ type acmeCertIssueArgs struct {
logf logger.Logf // logs ACME progress and failures
traceACME func(any) // optional hook for logging ACME messages
domain string // certificate domain being issued
opts []acme.OrderOption // ACME order options
opts []xacme.OrderOption // ACME order options
challengeType acmeChallengeType // challenge type to fulfill
}
func (args acmeCertIssueArgs) baseDomain() string { return strings.TrimPrefix(args.domain, "*.") }
func (args acmeCertIssueArgs) isWildcard() bool { return isWildcardDomain(args.domain) }
func (e *extension) issueACMECert(ctx context.Context, b *ipnlocal.LocalBackend, ac *acme.Client, args acmeCertIssueArgs) (ret *ipnlocal.TLSCertKeyPair, err error) {
func (e *extension) issueACMECert(ctx context.Context, b *ipnlocal.LocalBackend, ac *xacme.Client, args acmeCertIssueArgs) (ret *ipnlocal.TLSCertKeyPair, err error) {
if args.traceACME == nil {
args.traceACME = func(any) {}
}
@@ -451,14 +451,14 @@ func (e *extension) issueACMECert(ctx context.Context, b *ipnlocal.LocalBackend,
}
// For wildcards, we need to authorize both the wildcard and base domain.
var authzIDs []acme.AuthzID
var authzIDs []xacme.AuthzID
if args.isWildcard() {
authzIDs = []acme.AuthzID{
authzIDs = []xacme.AuthzID{
{Type: "dns", Value: args.domain},
{Type: "dns", Value: args.baseDomain()},
}
} else {
authzIDs = []acme.AuthzID{{Type: "dns", Value: args.domain}}
authzIDs = []xacme.AuthzID{{Type: "dns", Value: args.domain}}
}
order, err := ac.AuthorizeOrder(ctx, authzIDs, args.opts...)
if err != nil {
@@ -504,7 +504,7 @@ func (e *extension) issueACMECert(ctx context.Context, b *ipnlocal.LocalBackend,
if ctx.Err() != nil {
return nil, ctx.Err()
}
if oe, ok := err.(*acme.OrderError); ok {
if oe, ok := err.(*xacme.OrderError); ok {
args.logf("acme: WaitOrder: OrderError status %q", oe.Status)
} else {
args.logf("acme: WaitOrder error: %v", err)
@@ -550,7 +550,7 @@ func (e *extension) issueACMECert(ctx context.Context, b *ipnlocal.LocalBackend,
return &ipnlocal.TLSCertKeyPair{CertPEM: certPEM.Bytes(), KeyPEM: privPEM.Bytes()}, nil
}
func fulfillACMEDNS01Challenge(ctx context.Context, b *ipnlocal.LocalBackend, ac *acme.Client, az *acme.Authorization, logf logger.Logf, traceACME func(any)) error {
func fulfillACMEDNS01Challenge(ctx context.Context, b *ipnlocal.LocalBackend, ac *xacme.Client, az *xacme.Authorization, logf logger.Logf, traceACME func(any)) error {
for _, ch := range az.Challenges {
if ch.Type != string(acmeChallengeDNS01) {
continue
+4 -4
View File
@@ -31,7 +31,7 @@ import (
"tailscale.com/ipn/ipnlocal/ipnlocaltest"
"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/acme"
xacme "tailscale.com/tempfork/acme"
"tailscale.com/tsconst"
"tailscale.com/tstest"
"tailscale.com/types/logger"
@@ -263,7 +263,7 @@ func TestACMETLSALPNCertHook(t *testing.T) {
if got, ok := b.ForTest().GetACMETLSALPNCert(&tls.ClientHelloInfo{
ServerName: "example.com",
SupportedProtos: []string{acme.ALPNProto},
SupportedProtos: []string{xacme.ALPNProto},
}); !ok || got != cert {
t.Fatalf("getACMETLSALPNCert = %v, %v; want stored cert, true", got, ok)
}
@@ -275,7 +275,7 @@ func TestACMETLSALPNCertHook(t *testing.T) {
}
if _, ok := b.ForTest().GetACMETLSALPNCert(&tls.ClientHelloInfo{
ServerName: "other.example.com",
SupportedProtos: []string{acme.ALPNProto},
SupportedProtos: []string{xacme.ALPNProto},
}); ok {
t.Fatal("getACMETLSALPNCert for other name = ok, want false")
}
@@ -283,7 +283,7 @@ func TestACMETLSALPNCertHook(t *testing.T) {
otherBackend := ipnlocaltest.NewBackend(t)
if _, ok := otherBackend.ForTest().GetACMETLSALPNCert(&tls.ClientHelloInfo{
ServerName: "example.com",
SupportedProtos: []string{acme.ALPNProto},
SupportedProtos: []string{xacme.ALPNProto},
}); ok {
t.Fatal("getACMETLSALPNCert on different LocalBackend = ok, want false")
}
+4 -4
View File
@@ -32,7 +32,7 @@ import (
"tailscale.com/ipn/store"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/bakedroots"
"tailscale.com/tempfork/acme"
xacme "tailscale.com/tempfork/acme"
"tailscale.com/util/testenv"
"tailscale.com/version"
"tailscale.com/version/distro"
@@ -354,7 +354,7 @@ func acmeKey(cs certStore) (crypto.Signer, error) {
return privKey, nil
}
func acmeClient(cs certStore) (*acme.Client, error) {
func acmeClient(cs certStore) (*xacme.Client, error) {
key, err := acmeKey(cs)
if err != nil {
return nil, fmt.Errorf("acmeKey: %w", err)
@@ -362,7 +362,7 @@ func acmeClient(cs certStore) (*acme.Client, error) {
// Note: if we add support for additional ACME providers (other than
// LetsEncrypt), we should make sure that they support ARI extension (see
// shouldStartDomainRenewalARI).
return &acme.Client{
return &xacme.Client{
Key: key,
UserAgent: "tailscaled/" + version.Long(),
DirectoryURL: envknob.String("TS_DEBUG_ACME_DIRECTORY_URL"),
@@ -440,5 +440,5 @@ func validateLeaf(leaf *x509.Certificate, intermediates *x509.CertPool, domain s
}
func isDefaultDirectoryURL(u string) bool {
return u == "" || u == acme.LetsEncryptURL
return u == "" || u == xacme.LetsEncryptURL
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package acme
import (
"net/http"
"strconv"
"time"
"tailscale.com/tsweb"
)
// certRateLimitedError is returned when the upstream ACME CA rate-limited
// the issuance. It exists so cert-fetching failures can surface as HTTP
// 429 responses via [tsweb.HTTPStatuser].
type certRateLimitedError struct {
retryAfter time.Duration
underlying error
}
func (e certRateLimitedError) Error() string { return e.underlying.Error() }
func (e certRateLimitedError) Unwrap() error { return e.underlying }
// HTTPStatus implements [tsweb.HTTPStatuser].
func (e certRateLimitedError) HTTPStatus() tsweb.HTTPError {
h := http.Header{}
if e.retryAfter > 0 {
h.Set("Retry-After", strconv.Itoa(int(e.retryAfter.Seconds())))
}
return tsweb.HTTPError{
Code: http.StatusTooManyRequests,
Msg: e.Error(),
Header: h,
}
}