feat: change CertificateService and GetRenewalInfo signatures (#2870)

This commit is contained in:
Ludovic Fernandez 2026-02-23 20:04:01 +01:00 committed by GitHub
commit 4a6e757b24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 338 additions and 324 deletions

View file

@ -18,13 +18,13 @@ type CertificateService service
// Get Returns the certificate and the issuer certificate.
// 'bundle' is only applied if the issuer is provided by the 'up' link.
func (c *CertificateService) Get(ctx context.Context, certURL string, bundle bool) ([]byte, []byte, error) {
func (c *CertificateService) Get(ctx context.Context, certURL string, bundle bool) (*acme.RawCertificate, error) {
cert, _, err := c.get(ctx, certURL, bundle)
if err != nil {
return nil, nil, err
return nil, err
}
return cert.Cert, cert.Issuer, nil
return cert, nil
}
// GetAll the certificates and the alternate certificates.

View file

@ -0,0 +1,92 @@
package api
import (
"context"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/go-acme/lego/v5/acme"
)
// ErrNoARI is returned when the server does not advertise a renewal info endpoint.
var ErrNoARI = errors.New("renewalInfo[get/post]: server does not advertise a renewal info endpoint")
// GetRenewalInfo GETs renewal information for a certificate from the renewalInfo endpoint.
// This is used to determine if a certificate needs to be renewed.
//
// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
//
// https://www.rfc-editor.org/rfc/rfc9773.html
func (c *CertificateService) GetRenewalInfo(ctx context.Context, certID string) (*acme.ExtendedRenewalInfo, error) {
if c.core.GetDirectory().RenewalInfo == "" {
return nil, ErrNoARI
}
if certID == "" {
return nil, errors.New("renewalInfo[get]: 'certID' cannot be empty")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.core.GetDirectory().RenewalInfo+"/"+certID, nil)
if err != nil {
return nil, err
}
resp, err := c.core.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
info := new(acme.ExtendedRenewalInfo)
err = json.NewDecoder(resp.Body).Decode(info)
if err != nil {
return nil, err
}
if retry := resp.Header.Get("Retry-After"); retry != "" {
info.RetryAfter, err = ParseRetryAfter(retry)
if err != nil {
return nil, fmt.Errorf("failed to parse Retry-After header: %w", err)
}
}
return info, nil
}
// MakeARICertID constructs a certificate identifier as described in RFC 9773, section 4.1.
func MakeARICertID(leaf *x509.Certificate) (string, error) {
if leaf == nil {
return "", errors.New("leaf certificate is nil")
}
// Marshal the Serial Number into DER.
der, err := asn1.Marshal(leaf.SerialNumber)
if err != nil {
return "", err
}
// Check if the DER encoded bytes are sufficient (at least 3 bytes: tag,
// length, and value).
if len(der) < 3 {
return "", errors.New("invalid DER encoding of serial number")
}
// Extract only the integer bytes from the DER encoded Serial Number
// Skipping the first 2 bytes (tag and length).
serial := base64.RawURLEncoding.EncodeToString(der[2:])
// Convert the Authority Key Identifier to base64url encoding without
// padding.
aki := base64.RawURLEncoding.EncodeToString(leaf.AuthorityKeyId)
// Construct the final identifier by concatenating AKI and Serial Number.
return fmt.Sprintf("%s.%s", aki, serial), nil
}

View file

@ -0,0 +1,171 @@
package api
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"net/http"
"testing"
"time"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/platform/tester"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
ariLeafPEM = `-----BEGIN CERTIFICATE-----
MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt
cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS
BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu
7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf
qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B
yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb
+FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK
-----END CERTIFICATE-----`
ariLeafCertID = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"
)
func TestMakeCertID(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
actual, err := MakeARICertID(leaf)
require.NoError(t, err)
assert.Equal(t, ariLeafCertID, actual)
}
func TestCertificateService_GetRenewalInfo(t *testing.T) {
// small value keeps test fast
privateKey, errK := rsa.GenerateKey(rand.Reader, 1024)
require.NoError(t, errK, "Could not generate test key")
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID,
servermock.RawStringResponse(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
"explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
}
}`).
WithHeader("Content-Type", "application/json").
WithHeader("Retry-After", "21600")).
BuildHTTPS(t)
core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", privateKey)
require.NoError(t, err)
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
ri, err := core.Certificates.GetRenewalInfo(t.Context(), mustMakeARICertID(t, leaf))
require.NoError(t, err)
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter)
}
func TestCertificateService_GetRenewalInfo_retryAfter(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID,
servermock.RawStringResponse(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
"explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
}
}`).
WithHeader("Content-Type", "application/json").
WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
ri, err := core.Certificates.GetRenewalInfo(t.Context(), mustMakeARICertID(t, leaf))
require.NoError(t, err)
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001)
}
func TestCertificateService_GetRenewalInfo_errors(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
testCases := []struct {
desc string
timeout time.Duration
request string
handler http.HandlerFunc
}{
{
desc: "API timeout",
timeout: 500 * time.Millisecond, // HTTP client that times out after 500ms.
request: mustMakeARICertID(t, leaf),
handler: func(w http.ResponseWriter, r *http.Request) {
// API that takes 2ms to respond.
time.Sleep(2 * time.Millisecond)
},
},
{
desc: "API error",
request: mustMakeARICertID(t, leaf),
handler: func(w http.ResponseWriter, r *http.Request) {
// API that responds with error instead of renewal info.
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID, test.handler).
BuildHTTPS(t)
client := server.Client()
if test.timeout != 0 {
client.Timeout = test.timeout
}
core, err := New(client, "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
response, err := core.Certificates.GetRenewalInfo(t.Context(), test.request)
require.Error(t, err)
assert.Nil(t, response)
})
}
}
func mustMakeARICertID(t *testing.T, leaf *x509.Certificate) string {
t.Helper()
certID, err := MakeARICertID(leaf)
require.NoError(t, err)
return certID
}

View file

@ -83,10 +83,10 @@ func TestCertificateService_Get_issuerRelUp(t *testing.T) {
core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
cert, issuer, err := core.Certificates.Get(t.Context(), server.URL+"/certificate", true)
rawCert, err := core.Certificates.Get(t.Context(), server.URL+"/certificate", true)
require.NoError(t, err)
assert.Equal(t, certResponseMock, string(cert), "Certificate")
assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate")
assert.Equal(t, certResponseMock, string(rawCert.Cert), "Certificate")
assert.Equal(t, issuerMock, string(rawCert.Issuer), "IssuerCertificate")
}
func TestCertificateService_Get_embeddedIssuer(t *testing.T) {
@ -100,8 +100,8 @@ func TestCertificateService_Get_embeddedIssuer(t *testing.T) {
core, err := New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
cert, issuer, err := core.Certificates.Get(t.Context(), server.URL+"/certificate", true)
rawCert, err := core.Certificates.Get(t.Context(), server.URL+"/certificate", true)
require.NoError(t, err)
assert.Equal(t, certResponseMock, string(cert), "Certificate")
assert.Equal(t, issuerMock, string(issuer), "IssuerCertificate")
assert.Equal(t, certResponseMock, string(rawCert.Cert), "Certificate")
assert.Equal(t, issuerMock, string(rawCert.Issuer), "IssuerCertificate")
}

View file

@ -1,34 +0,0 @@
package api
import (
"context"
"errors"
"net/http"
)
// ErrNoARI is returned when the server does not advertise a renewal info endpoint.
var ErrNoARI = errors.New("renewalInfo[get/post]: server does not advertise a renewal info endpoint")
// GetRenewalInfo GETs renewal information for a certificate from the renewalInfo endpoint.
// This is used to determine if a certificate needs to be renewed.
//
// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
//
// https://www.rfc-editor.org/rfc/rfc9773.html
func (c *CertificateService) GetRenewalInfo(ctx context.Context, certID string) (*http.Response, error) {
if c.core.GetDirectory().RenewalInfo == "" {
return nil, ErrNoARI
}
if certID == "" {
return nil, errors.New("renewalInfo[get]: 'certID' cannot be empty")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.core.GetDirectory().RenewalInfo+"/"+certID, nil)
if err != nil {
return nil, err
}
return c.core.HTTPClient.Do(req)
}

View file

@ -350,9 +350,19 @@ type Window struct {
End time.Time `json:"end"`
}
// RenewalInfoResponse is the response to GET requests made the renewalInfo endpoint.
// - (4.1. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html
type RenewalInfoResponse struct {
type ExtendedRenewalInfo struct {
RenewalInfo
// RetryAfter header indicating the polling interval that the ACME server recommends.
// Conforming clients SHOULD query the renewalInfo URL again after the RetryAfter period has passed,
// as the server may provide a different suggestedWindow.
// https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2
RetryAfter time.Duration
}
// RenewalInfo is the response to GET requests made the renewalInfo endpoint.
// - (4.2. RenewalInfo Objects) https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2
type RenewalInfo struct {
// SuggestedWindow contains two fields, start and end,
// whose values are timestamps which bound the window of time in which the CA recommends renewing the certificate.
SuggestedWindow Window `json:"suggestedWindow"`
@ -362,17 +372,3 @@ type RenewalInfoResponse struct {
// Callers SHOULD provide this URL to their operator, if present.
ExplanationURL string `json:"explanationURL"`
}
// RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint.
// - (4.2. RenewalInfo Objects) https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2
type RenewalInfoUpdateRequest struct {
// CertID is a composite string in the format: base64url(AKI) || '.' || base64url(Serial), where AKI is the
// certificate's authority key identifier and Serial is the certificate's serial number. For details, see:
// https://www.rfc-editor.org/rfc/rfc9773.html#section-4.1
CertID string `json:"certID"`
// Replaced is required and indicates whether or not the client considers the certificate to have been replaced.
// A certificate is considered replaced when its revocation would not disrupt any ongoing services,
// for instance because it has been renewed and the new certificate is in use, or because it is no longer in use.
// Clients SHOULD NOT send a request where this value is false.
Replaced bool `json:"replaced"`
}

View file

@ -674,13 +674,13 @@ func (c *Certifier) GetOCSP(ctx context.Context, bundle []byte) ([]byte, *ocsp.R
//
// If bundle is true, the Certificate field in the returned Resource includes the issuer certificate.
func (c *Certifier) Get(ctx context.Context, url string, bundle bool) (*Resource, error) {
cert, issuer, err := c.core.Certificates.Get(ctx, url, bundle)
rawCert, err := c.core.Certificates.Get(ctx, url, bundle)
if err != nil {
return nil, err
}
// Parse the returned cert bundle so that we can grab the domain from the common name.
x509Certs, err := certcrypto.ParsePEMBundle(cert)
x509Certs, err := certcrypto.ParsePEMBundle(rawCert.Cert)
if err != nil {
return nil, err
}
@ -693,8 +693,8 @@ func (c *Certifier) Get(ctx context.Context, url string, bundle bool) (*Resource
return &Resource{
ID: domain,
Domains: certcrypto.ExtractDomains(x509Certs[0]),
Certificate: cert,
IssuerCertificate: issuer,
Certificate: rawCert.Cert,
IssuerCertificate: rawCert.Issuer,
CertURL: url,
CertStableURL: url,
}, nil

View file

@ -3,10 +3,6 @@ package certificate
import (
"context"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/rand"
"time"
@ -15,28 +11,18 @@ import (
"github.com/go-acme/lego/v5/acme/api"
)
// RenewalInfoRequest contains the necessary renewal information.
type RenewalInfoRequest struct {
Cert *x509.Certificate
// RenewalInfo is a wrapper around acme.ExtendedRenewalInfo that provides a method for determining when to renew a certificate.
type RenewalInfo struct {
*acme.ExtendedRenewalInfo
}
// RenewalInfoResponse is a wrapper around acme.RenewalInfoResponse that provides a method for determining when to renew a certificate.
type RenewalInfoResponse struct {
acme.RenewalInfoResponse
// RetryAfter header indicating the polling interval that the ACME server recommends.
// Conforming clients SHOULD query the renewalInfo URL again after the RetryAfter period has passed,
// as the server may provide a different suggestedWindow.
// https://www.rfc-editor.org/rfc/rfc9773.html#section-4.2
RetryAfter time.Duration
}
// ShouldRenewAt determines the optimal renewal time based on the current time (UTC),renewal window suggest by ARI, and the client's willingness to sleep.
// ShouldRenewAt determines the optimal renewal time based on the current time (UTC),
// renewal window suggest by ARI, and the client's willingness to sleep.
// It returns a pointer to a time.Time value indicating when the renewal should be attempted or nil if deferred until the next normal wake time.
// This method implements the RECOMMENDED algorithm described in RFC 9773.
//
// - (4.1-11. Getting Renewal Information) https://www.rfc-editor.org/rfc/rfc9773.html
func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time {
func (r *RenewalInfo) ShouldRenewAt(now time.Time, willingToSleep time.Duration) *time.Time {
// Explicitly convert all times to UTC.
now = now.UTC()
start := r.SuggestedWindow.Start.UTC()
@ -68,67 +54,22 @@ func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.D
// GetRenewalInfo sends a request to the ACME server's renewalInfo endpoint to obtain a suggested renewal window.
// The caller MUST provide the certificate and issuer certificate for the certificate they wish to renew.
// The caller should attempt to renew the certificate at the time indicated by the ShouldRenewAt method of the returned RenewalInfoResponse object.
// The caller should attempt to renew the certificate at the time indicated by the RenewalInfo.ShouldRenewAt method.
//
// Note: this endpoint is part of a draft specification, not all ACME servers will implement it.
// This method will return api.ErrNoARI if the server does not advertise a renewal info endpoint.
//
// https://www.rfc-editor.org/rfc/rfc9773.html
func (c *Certifier) GetRenewalInfo(ctx context.Context, req RenewalInfoRequest) (*RenewalInfoResponse, error) {
certID, err := MakeARICertID(req.Cert)
func (c *Certifier) GetRenewalInfo(ctx context.Context, cert *x509.Certificate) (*RenewalInfo, error) {
certID, err := api.MakeARICertID(cert)
if err != nil {
return nil, fmt.Errorf("error making certID: %w", err)
}
resp, err := c.core.Certificates.GetRenewalInfo(ctx, certID)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var info RenewalInfoResponse
err = json.NewDecoder(resp.Body).Decode(&info)
info, err := c.core.Certificates.GetRenewalInfo(ctx, certID)
if err != nil {
return nil, err
}
if retry := resp.Header.Get("Retry-After"); retry != "" {
info.RetryAfter, err = api.ParseRetryAfter(retry)
if err != nil {
return nil, fmt.Errorf("failed to parse Retry-After header: %w", err)
}
}
return &info, nil
}
// MakeARICertID constructs a certificate identifier as described in RFC 9773, section 4.1.
func MakeARICertID(leaf *x509.Certificate) (string, error) {
if leaf == nil {
return "", errors.New("leaf certificate is nil")
}
// Marshal the Serial Number into DER.
der, err := asn1.Marshal(leaf.SerialNumber)
if err != nil {
return "", err
}
// Check if the DER encoded bytes are sufficient (at least 3 bytes: tag,
// length, and value).
if len(der) < 3 {
return "", errors.New("invalid DER encoding of serial number")
}
// Extract only the integer bytes from the DER encoded Serial Number
// Skipping the first 2 bytes (tag and length).
serial := base64.RawURLEncoding.EncodeToString(der[2:])
// Convert the Authority Key Identifier to base64url encoding without
// padding.
aki := base64.RawURLEncoding.EncodeToString(leaf.AuthorityKeyId)
// Construct the final identifier by concatenating AKI and Serial Number.
return fmt.Sprintf("%s.%s", aki, serial), nil
return &RenewalInfo{ExtendedRenewalInfo: info}, err
}

View file

@ -1,186 +1,29 @@
package certificate
import (
"crypto/rand"
"crypto/rsa"
"net/http"
"testing"
"time"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/acme/api"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/platform/tester"
"github.com/go-acme/lego/v5/platform/tester/servermock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
ariLeafPEM = `-----BEGIN CERTIFICATE-----
MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt
cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS
BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu
7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf
qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B
yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb
+FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK
-----END CERTIFICATE-----`
ariLeafCertID = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"
)
func Test_makeCertID(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
actual, err := MakeARICertID(leaf)
require.NoError(t, err)
assert.Equal(t, ariLeafCertID, actual)
}
func TestCertifier_GetRenewalInfo(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
// Test with a fake API.
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID,
servermock.RawStringResponse(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
"explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
}
}`).
WithHeader("Content-Type", "application/json").
WithHeader("Retry-After", "21600")).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
ri, err := certifier.GetRenewalInfo(t.Context(), RenewalInfoRequest{leaf})
require.NoError(t, err)
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter)
}
func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID,
servermock.RawStringResponse(`{
"suggestedWindow": {
"start": "2020-03-17T17:51:09Z",
"end": "2020-03-17T18:21:09Z"
},
"explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/"
}
}`).
WithHeader("Content-Type", "application/json").
WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))).
BuildHTTPS(t)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
ri, err := certifier.GetRenewalInfo(t.Context(), RenewalInfoRequest{leaf})
require.NoError(t, err)
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL)
assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001)
}
func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
require.NoError(t, err)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "Could not generate test key")
testCases := []struct {
desc string
timeout time.Duration
request RenewalInfoRequest
handler http.HandlerFunc
}{
{
desc: "API timeout",
timeout: 500 * time.Millisecond, // HTTP client that times out after 500ms.
request: RenewalInfoRequest{leaf},
handler: func(w http.ResponseWriter, r *http.Request) {
// API that takes 2ms to respond.
time.Sleep(2 * time.Millisecond)
},
},
{
desc: "API error",
request: RenewalInfoRequest{leaf},
handler: func(w http.ResponseWriter, r *http.Request) {
// API that responds with error instead of renewal info.
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
server := tester.MockACMEServer().
Route("GET /renewalInfo/"+ariLeafCertID, test.handler).
BuildHTTPS(t)
client := server.Client()
if test.timeout != 0 {
client.Timeout = test.timeout
}
core, err := api.New(client, "lego-test", server.URL+"/dir", "", key)
require.NoError(t, err)
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
response, err := certifier.GetRenewalInfo(t.Context(), test.request)
require.Error(t, err)
assert.Nil(t, response)
})
}
}
func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
now := time.Now().UTC()
t.Run("Window is in the past", func(t *testing.T) {
ri := RenewalInfoResponse{
RenewalInfoResponse: acme.RenewalInfoResponse{
SuggestedWindow: acme.Window{
Start: now.Add(-2 * time.Hour),
End: now.Add(-1 * time.Hour),
ri := RenewalInfo{
ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{
RenewalInfo: acme.RenewalInfo{
SuggestedWindow: acme.Window{
Start: now.Add(-2 * time.Hour),
End: now.Add(-1 * time.Hour),
},
ExplanationURL: "",
},
ExplanationURL: "",
RetryAfter: 0,
},
RetryAfter: 0,
}
rt := ri.ShouldRenewAt(now, 0)
@ -189,15 +32,17 @@ func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
})
t.Run("Window is in the future", func(t *testing.T) {
ri := RenewalInfoResponse{
RenewalInfoResponse: acme.RenewalInfoResponse{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
ri := RenewalInfo{
ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{
RenewalInfo: acme.RenewalInfo{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
},
ExplanationURL: "",
},
ExplanationURL: "",
RetryAfter: 0,
},
RetryAfter: 0,
}
rt := ri.ShouldRenewAt(now, 0)
@ -205,15 +50,17 @@ func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
})
t.Run("Window is in the future, but caller is willing to sleep", func(t *testing.T) {
ri := RenewalInfoResponse{
RenewalInfoResponse: acme.RenewalInfoResponse{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
ri := RenewalInfo{
ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{
RenewalInfo: acme.RenewalInfo{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
},
ExplanationURL: "",
},
ExplanationURL: "",
RetryAfter: 0,
},
RetryAfter: 0,
}
rt := ri.ShouldRenewAt(now, 2*time.Hour)
@ -222,15 +69,17 @@ func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
})
t.Run("Window is in the future, but caller isn't willing to sleep long enough", func(t *testing.T) {
ri := RenewalInfoResponse{
RenewalInfoResponse: acme.RenewalInfoResponse{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
ri := RenewalInfo{
ExtendedRenewalInfo: &acme.ExtendedRenewalInfo{
RenewalInfo: acme.RenewalInfo{
SuggestedWindow: acme.Window{
Start: now.Add(1 * time.Hour),
End: now.Add(2 * time.Hour),
},
ExplanationURL: "",
},
ExplanationURL: "",
RetryAfter: 0,
},
RetryAfter: 0,
}
rt := ri.ShouldRenewAt(now, 59*time.Minute)

View file

@ -17,7 +17,6 @@ import (
"github.com/go-acme/lego/v5/acme/api"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/hook"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/go-acme/lego/v5/lego"
@ -354,7 +353,7 @@ func getARIInfo(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certI
}
}
replacesCertID, err := certificate.MakeARICertID(cert)
replacesCertID, err := api.MakeARICertID(cert)
if err != nil {
return nil, "", fmt.Errorf("error while constructing the ARI CertID for domain %q: %w", certID, err)
}
@ -364,7 +363,7 @@ func getARIInfo(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certI
// getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint.
func getARIRenewalTime(ctx context.Context, willingToSleep time.Duration, cert *x509.Certificate, certID string, client *lego.Client) *time.Time {
renewalInfo, err := client.Certificate.GetRenewalInfo(ctx, certificate.RenewalInfoRequest{Cert: cert})
renewalInfo, err := client.Certificate.GetRenewalInfo(ctx, cert)
if err != nil {
if errors.Is(err, api.ErrNoARI) {
log.Warn("acme: the server does not advertise a renewal info endpoint.",