diff --git a/README.md b/README.md index ab3c03d..c78d91d 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,8 @@ To get certificates signed from LetsEncrypt use the following config options | `dns01_txt.provider` | [Lego Provider](https://go-acme.github.io/lego/dns/index.html) CLI Flag name | | `dns01_txt.dns_servers` | Use your authoritative DNS server's addresses to avoid caching/TTL problems | | `dns01_txt.env_vars` | Environment variables specific to your Lego DNS Provider for authentication | +| `cert_obtain_timeout` | Seconds to wait for the CA to issue the certificate after finalization (default `30`). Raise it if your CA's post-finalization checks take longer — otherwise the order fails with `certificate: time limit exceeded` while the CA still issues the cert. CertiNext currently recommends `300` while a CT log operator delays their 30-day product. Must be below `request_timeout` | +| `request_timeout` | Seconds the whole certificate request may take end to end — registration, authorizations, finalization and the wait above (default `120`). Raise it together with `cert_obtain_timeout`, e.g. `330` for a `300` wait | ### Starting acme-proxy diff --git a/externalcas/config.go b/externalcas/config.go index 0b44abc..b04eb34 100644 --- a/externalcas/config.go +++ b/externalcas/config.go @@ -34,6 +34,17 @@ type acmeProxyConfig struct { // Certificate lifetime in days (optional) CertLifetime int `json:"certlifetime,omitempty"` + // Seconds to wait for the external CA to issue the certificate after the order + // is finalized (optional, default 30 — lego's default). Some CAs run + // post-finalization checks that take longer than that; must stay below + // request_timeout so the outer context still bounds the whole request. + CertObtainTimeout int `json:"cert_obtain_timeout,omitempty"` + + // Seconds the whole certificate request may take end to end — account + // registration, authorizations, finalization and the wait above (optional, + // default 120). Raise it together with cert_obtain_timeout. + RequestTimeoutSec int `json:"request_timeout,omitempty"` + // Lego provider connection variables for dns01 TXT challenge Lego legoConfig `json:"dns01_txt"` @@ -64,6 +75,15 @@ func (c *acmeProxyConfig) Validate() error { if c.CertLifetime < 0 { return errors.New("certlifetime cannot be negative") } + if c.CertObtainTimeout < 0 { + return errors.New("cert_obtain_timeout cannot be negative") + } + if c.RequestTimeoutSec < 0 { + return errors.New("request_timeout cannot be negative") + } + if c.ObtainTimeout() >= c.RequestTimeout() { + return fmt.Errorf("cert_obtain_timeout (%s) must be less than request_timeout (%s)", c.ObtainTimeout(), c.RequestTimeout()) + } // Consider Metrics enabled only when port & datasource both are defined if c.Metrics.Port > 0 && c.Metrics.DataSource != "" { @@ -83,7 +103,19 @@ func (c *acmeProxyConfig) HTTPTimeout() time.Duration { // RequestTimeout returns the timeout for certificate request operations func (c *acmeProxyConfig) RequestTimeout() time.Duration { - return 2 * time.Minute + if c.RequestTimeoutSec <= 0 { + return 2 * time.Minute + } + return time.Duration(c.RequestTimeoutSec) * time.Second +} + +// ObtainTimeout returns how long lego waits for the external CA to issue the +// certificate after finalization +func (c *acmeProxyConfig) ObtainTimeout() time.Duration { + if c.CertObtainTimeout <= 0 { + return 30 * time.Second + } + return time.Duration(c.CertObtainTimeout) * time.Second } // parseConfig is a helper function which reads ca.json file as rawjson and validates diff --git a/externalcas/config_test.go b/externalcas/config_test.go index b7b16d6..6578939 100644 --- a/externalcas/config_test.go +++ b/externalcas/config_test.go @@ -57,6 +57,72 @@ func TestAcmeProxyConfig_Validate(t *testing.T) { }, wantErr: false, }, + { + name: "negative cert_obtain_timeout", + config: acmeProxyConfig{ + CaURL: "https://acme.example.com", + Kid: "test-kid", + HmacKey: "test-hmac", + CertObtainTimeout: -1, + }, + wantErr: true, + errMsg: "cert_obtain_timeout cannot be negative", + }, + { + name: "cert_obtain_timeout at or above request timeout", + config: acmeProxyConfig{ + CaURL: "https://acme.example.com", + Kid: "test-kid", + HmacKey: "test-hmac", + CertObtainTimeout: 120, + }, + wantErr: true, + errMsg: "cert_obtain_timeout (2m0s) must be less than request_timeout (2m0s)", + }, + { + name: "cert_obtain_timeout below request timeout is valid", + config: acmeProxyConfig{ + CaURL: "https://acme.example.com", + Kid: "test-kid", + HmacKey: "test-hmac", + CertObtainTimeout: 90, + }, + wantErr: false, + }, + { + name: "negative request_timeout", + config: acmeProxyConfig{ + CaURL: "https://acme.example.com", + Kid: "test-kid", + HmacKey: "test-hmac", + RequestTimeoutSec: -1, + }, + wantErr: true, + errMsg: "request_timeout cannot be negative", + }, + { + name: "cert_obtain_timeout above default request timeout is valid when request_timeout is raised", + config: acmeProxyConfig{ + CaURL: "https://acme.example.com", + Kid: "test-kid", + HmacKey: "test-hmac", + CertObtainTimeout: 300, + RequestTimeoutSec: 330, + }, + wantErr: false, + }, + { + name: "cert_obtain_timeout at a raised request_timeout is rejected", + config: acmeProxyConfig{ + CaURL: "https://acme.example.com", + Kid: "test-kid", + HmacKey: "test-hmac", + CertObtainTimeout: 330, + RequestTimeoutSec: 330, + }, + wantErr: true, + errMsg: "cert_obtain_timeout (5m30s) must be less than request_timeout (5m30s)", + }, { name: "metrics enabled without valid datasource", config: acmeProxyConfig{ @@ -173,6 +239,23 @@ func TestAcmeProxyConfig_Timeouts(t *testing.T) { if requestTimeout != 2*time.Minute { t.Errorf("RequestTimeout() = %v, want %v", requestTimeout, 2*time.Minute) } + + obtainTimeout := config.ObtainTimeout() + if obtainTimeout != 30*time.Second { + t.Errorf("ObtainTimeout() default = %v, want %v", obtainTimeout, 30*time.Second) + } + + config.CertObtainTimeout = 90 + obtainTimeout = config.ObtainTimeout() + if obtainTimeout != 90*time.Second { + t.Errorf("ObtainTimeout() with cert_obtain_timeout=90 = %v, want %v", obtainTimeout, 90*time.Second) + } + + config.RequestTimeoutSec = 330 + requestTimeout = config.RequestTimeout() + if requestTimeout != 330*time.Second { + t.Errorf("RequestTimeout() with request_timeout=330 = %v, want %v", requestTimeout, 330*time.Second) + } } func TestParseConfig(t *testing.T) { diff --git a/externalcas/external.go b/externalcas/external.go index a48ffaa..791ff2a 100644 --- a/externalcas/external.go +++ b/externalcas/external.go @@ -107,6 +107,7 @@ func (c *ExternalCAS) createLegoClient(cfg *acmeProxyConfig) (ACMEClient, error) clientConfig := lego.NewConfig(user) clientConfig.CADirURL = cfg.CaURL clientConfig.Certificate.KeyType = certcrypto.EC256 // gitleaks:allow + clientConfig.Certificate.Timeout = cfg.ObtainTimeout() clientConfig.HTTPClient = &http.Client{ Timeout: cfg.HTTPTimeout(), }