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
+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
}