Files
godoxy-yusing/internal/autocert/provider_test/custom_test.go
yusing 2835fd5fb0 fix(autocert): ensure extra certificate registration and renewal scheduling
Extra providers were not being properly initialized during NewProvider(),
causing certificate registration and renewal scheduling to be skipped.

- Add ConfigExtra type with idx field for provider indexing
- Add MergeExtraConfig() for inheriting main provider settings
- Add setupExtraProviders() for recursive extra provider initialization
- Refactor NewProvider to return error and call setupExtraProviders()
- Add provider-scoped logger with "main" or "extra[N]" name
- Add batch operations: ObtainCertIfNotExistsAll(), ObtainCertAll()
- Add ForceExpiryAll() with completion tracking via WaitRenewalDone()
- Add RenewMode (force/ifNeeded) for controlling renewal behavior
- Add PrintCertExpiriesAll() for logging all provider certificate expiries

Summary of staged changes:
- config.go: Added ConfigExtra type, MergeExtraConfig(), recursive validation with path uniqueness checking
- provider.go: Added provider indexing, scoped logger, batch cert operations, force renewal with completion tracking, RenewMode control
- setup.go: New file with setupExtraProviders() for proper extra provider initialization
- setup_test.go: New tests for extra provider setup
- multi_cert_test.go: New tests for multi-certificate functionality
- renew.go: Updated to use new provider API with error handling
- state.go: Updated to handle NewProvider error return
2026-01-04 20:30:58 +08:00

550 lines
16 KiB
Go

//nolint:errchkjson,errcheck
package provider_test
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/autocert"
"github.com/yusing/godoxy/internal/dnsproviders"
)
// TestACMEServer implements a minimal ACME server for testing with request tracking.
type TestACMEServer struct {
server *httptest.Server
caCert *x509.Certificate
caKey *rsa.PrivateKey
clientCSRs map[string]*x509.CertificateRequest
orderDomains map[string][]string
authzDomains map[string]string
orderSeq int
certRequestCount map[string]int
renewalRequestCount map[string]int
mu sync.Mutex
}
func newTestACMEServer(t *testing.T) *TestACMEServer {
t.Helper()
// Generate CA certificate and key
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Test CA"},
Country: []string{"US"},
Province: []string{""},
Locality: []string{"Test"},
StreetAddress: []string{""},
PostalCode: []string{""},
},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
require.NoError(t, err)
caCert, err := x509.ParseCertificate(caCertDER)
require.NoError(t, err)
acme := &TestACMEServer{
caCert: caCert,
caKey: caKey,
clientCSRs: make(map[string]*x509.CertificateRequest),
orderDomains: make(map[string][]string),
authzDomains: make(map[string]string),
orderSeq: 0,
certRequestCount: make(map[string]int),
renewalRequestCount: make(map[string]int),
}
mux := http.NewServeMux()
acme.setupRoutes(mux)
acme.server = httptest.NewUnstartedServer(mux)
acme.server.TLS = &tls.Config{
Certificates: []tls.Certificate{
{
Certificate: [][]byte{caCert.Raw},
PrivateKey: caKey,
},
},
MinVersion: tls.VersionTLS12,
}
acme.server.StartTLS()
return acme
}
func (s *TestACMEServer) Close() {
s.server.Close()
}
func (s *TestACMEServer) URL() string {
return s.server.URL
}
func (s *TestACMEServer) httpClient() *http.Client {
certPool := x509.NewCertPool()
certPool.AddCert(s.caCert)
return &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
},
},
}
}
func (s *TestACMEServer) setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/acme/acme/directory", s.handleDirectory)
mux.HandleFunc("/acme/new-nonce", s.handleNewNonce)
mux.HandleFunc("/acme/new-account", s.handleNewAccount)
mux.HandleFunc("/acme/new-order", s.handleNewOrder)
mux.HandleFunc("/acme/authz/", s.handleAuthorization)
mux.HandleFunc("/acme/chall/", s.handleChallenge)
mux.HandleFunc("/acme/order/", s.handleOrder)
mux.HandleFunc("/acme/cert/", s.handleCertificate)
}
func (s *TestACMEServer) handleDirectory(w http.ResponseWriter, r *http.Request) {
directory := map[string]any{
"newNonce": s.server.URL + "/acme/new-nonce",
"newAccount": s.server.URL + "/acme/new-account",
"newOrder": s.server.URL + "/acme/new-order",
"keyChange": s.server.URL + "/acme/key-change",
"meta": map[string]any{
"termsOfService": s.server.URL + "/terms",
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(directory)
}
func (s *TestACMEServer) handleNewNonce(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Replay-Nonce", "test-nonce-12345")
w.WriteHeader(http.StatusOK)
}
func (s *TestACMEServer) handleNewAccount(w http.ResponseWriter, r *http.Request) {
account := map[string]any{
"status": "valid",
"contact": []string{"mailto:test@example.com"},
"orders": s.server.URL + "/acme/orders",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", s.server.URL+"/acme/account/1")
w.Header().Set("Replay-Nonce", "test-nonce-67890")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(account)
}
func (s *TestACMEServer) handleNewOrder(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var jws struct {
Payload string `json:"payload"`
}
json.Unmarshal(body, &jws)
payloadBytes, _ := base64.RawURLEncoding.DecodeString(jws.Payload)
var orderReq struct {
Identifiers []map[string]string `json:"identifiers"`
}
json.Unmarshal(payloadBytes, &orderReq)
domains := []string{}
for _, id := range orderReq.Identifiers {
domains = append(domains, id["value"])
}
sort.Strings(domains)
domainKey := strings.Join(domains, ",")
s.mu.Lock()
s.orderSeq++
orderID := fmt.Sprintf("test-order-%d", s.orderSeq)
authzID := fmt.Sprintf("test-authz-%d", s.orderSeq)
s.orderDomains[orderID] = domains
if len(domains) > 0 {
s.authzDomains[authzID] = domains[0]
}
s.certRequestCount[domainKey]++
s.mu.Unlock()
order := map[string]any{
"status": "ready",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": orderReq.Identifiers,
"authorizations": []string{s.server.URL + "/acme/authz/" + authzID},
"finalize": s.server.URL + "/acme/order/" + orderID + "/finalize",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", s.server.URL+"/acme/order/"+orderID)
w.Header().Set("Replay-Nonce", "test-nonce-order")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
func (s *TestACMEServer) handleAuthorization(w http.ResponseWriter, r *http.Request) {
authzID := strings.TrimPrefix(r.URL.Path, "/acme/authz/")
domain := s.authzDomains[authzID]
if domain == "" {
domain = "test.example.com"
}
authz := map[string]any{
"status": "valid",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifier": map[string]string{"type": "dns", "value": domain},
"challenges": []map[string]any{
{
"type": "dns-01",
"status": "valid",
"url": s.server.URL + "/acme/chall/test-chall-789",
"token": "test-token-abc123",
},
},
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-authz")
json.NewEncoder(w).Encode(authz)
}
func (s *TestACMEServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
challenge := map[string]any{
"type": "dns-01",
"status": "valid",
"url": r.URL.String(),
"token": "test-token-abc123",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-chall")
json.NewEncoder(w).Encode(challenge)
}
func (s *TestACMEServer) handleOrder(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/finalize") {
s.handleFinalize(w, r)
return
}
orderID := strings.TrimPrefix(r.URL.Path, "/acme/order/")
domains := s.orderDomains[orderID]
if len(domains) == 0 {
domains = []string{"test.example.com"}
}
certURL := s.server.URL + "/acme/cert/" + orderID
order := map[string]any{
"status": "valid",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": func() []map[string]string {
out := make([]map[string]string, 0, len(domains))
for _, d := range domains {
out = append(out, map[string]string{"type": "dns", "value": d})
}
return out
}(),
"certificate": certURL,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Replay-Nonce", "test-nonce-order-get")
json.NewEncoder(w).Encode(order)
}
func (s *TestACMEServer) handleFinalize(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
csr, err := s.extractCSRFromJWS(body)
if err != nil {
http.Error(w, "Invalid CSR: "+err.Error(), http.StatusBadRequest)
return
}
orderID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/acme/order/"), "/finalize")
s.mu.Lock()
s.clientCSRs[orderID] = csr
// Detect renewal: if we already have a certificate for these domains, it's a renewal
domains := csr.DNSNames
sort.Strings(domains)
domainKey := strings.Join(domains, ",")
if s.certRequestCount[domainKey] > 1 {
s.renewalRequestCount[domainKey]++
}
s.mu.Unlock()
certURL := s.server.URL + "/acme/cert/" + orderID
order := map[string]any{
"status": "valid",
"expires": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
"identifiers": func() []map[string]string {
out := make([]map[string]string, 0, len(domains))
for _, d := range domains {
out = append(out, map[string]string{"type": "dns", "value": d})
}
return out
}(),
"certificate": certURL,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", strings.TrimSuffix(r.URL.String(), "/finalize"))
w.Header().Set("Replay-Nonce", "test-nonce-finalize")
json.NewEncoder(w).Encode(order)
}
func (s *TestACMEServer) extractCSRFromJWS(jwsData []byte) (*x509.CertificateRequest, error) {
var jws struct {
Payload string `json:"payload"`
}
if err := json.Unmarshal(jwsData, &jws); err != nil {
return nil, err
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(jws.Payload)
if err != nil {
return nil, err
}
var finalizeReq struct {
CSR string `json:"csr"`
}
if err := json.Unmarshal(payloadBytes, &finalizeReq); err != nil {
return nil, err
}
csrBytes, err := base64.RawURLEncoding.DecodeString(finalizeReq.CSR)
if err != nil {
return nil, err
}
return x509.ParseCertificateRequest(csrBytes)
}
func (s *TestACMEServer) handleCertificate(w http.ResponseWriter, r *http.Request) {
orderID := strings.TrimPrefix(r.URL.Path, "/acme/cert/")
csr, exists := s.clientCSRs[orderID]
if !exists {
http.Error(w, "No CSR found for order", http.StatusBadRequest)
return
}
template := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{
Organization: []string{"Test Cert"},
Country: []string{"US"},
},
DNSNames: csr.DNSNames,
NotBefore: time.Now(),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, s.caCert, csr.PublicKey, s.caKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.caCert.Raw})
w.Header().Set("Content-Type", "application/pem-certificate-chain")
w.Header().Set("Replay-Nonce", "test-nonce-cert")
w.Write(append(certPEM, caPEM...))
}
func TestMain(m *testing.M) {
dnsproviders.InitProviders()
m.Run()
}
func TestCustomProvider(t *testing.T) {
t.Run("valid custom provider with step-ca", func(t *testing.T) {
cfg := &autocert.Config{
Email: "test@example.com",
Domains: []string{"example.com", "*.example.com"},
Provider: autocert.ProviderCustom,
CADirURL: "https://ca.example.com:9000/acme/acme/directory",
CertPath: "certs/custom.crt",
KeyPath: "certs/custom.key",
ACMEKeyPath: "certs/custom-acme.key",
}
err := error(cfg.Validate())
require.NoError(t, err)
user, legoCfg, err := cfg.GetLegoConfig()
require.NoError(t, err)
require.NotNil(t, user)
require.NotNil(t, legoCfg)
require.Equal(t, "https://ca.example.com:9000/acme/acme/directory", legoCfg.CADirURL)
require.Equal(t, "test@example.com", user.Email)
})
t.Run("custom provider missing CADirURL", func(t *testing.T) {
cfg := &autocert.Config{
Email: "test@example.com",
Domains: []string{"example.com"},
Provider: autocert.ProviderCustom,
// CADirURL is missing
}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "missing field")
require.Contains(t, err.Error(), "ca_dir_url")
})
t.Run("custom provider with step-ca internal CA", func(t *testing.T) {
cfg := &autocert.Config{
Email: "admin@internal.com",
Domains: []string{"internal.example.com", "api.internal.example.com"},
Provider: autocert.ProviderCustom,
CADirURL: "https://step-ca.internal:443/acme/acme/directory",
CertPath: "certs/internal.crt",
KeyPath: "certs/internal.key",
ACMEKeyPath: "certs/internal-acme.key",
}
err := error(cfg.Validate())
require.NoError(t, err)
user, legoCfg, err := cfg.GetLegoConfig()
require.NoError(t, err)
require.NotNil(t, user)
require.NotNil(t, legoCfg)
require.Equal(t, "https://step-ca.internal:443/acme/acme/directory", legoCfg.CADirURL)
require.Equal(t, "admin@internal.com", user.Email)
provider, err := autocert.NewProvider(cfg, user, legoCfg)
require.NoError(t, err)
require.NotNil(t, provider)
require.Equal(t, "main", provider.GetName())
require.Equal(t, "certs/internal.crt", provider.GetCertPath())
require.Equal(t, "certs/internal.key", provider.GetKeyPath())
})
}
func TestObtainCertFromCustomProvider(t *testing.T) {
// Create a test ACME server
acmeServer := newTestACMEServer(t)
defer acmeServer.Close()
t.Run("obtain cert from custom step-ca server", func(t *testing.T) {
cfg := &autocert.Config{
Email: "test@example.com",
Domains: []string{"test.example.com"},
Provider: autocert.ProviderCustom,
CADirURL: acmeServer.URL() + "/acme/acme/directory",
CertPath: "certs/stepca-test.crt",
KeyPath: "certs/stepca-test.key",
ACMEKeyPath: "certs/stepca-test-acme.key",
HTTPClient: acmeServer.httpClient(),
}
err := error(cfg.Validate())
require.NoError(t, err)
user, legoCfg, err := cfg.GetLegoConfig()
require.NoError(t, err)
require.NotNil(t, user)
require.NotNil(t, legoCfg)
provider, err := autocert.NewProvider(cfg, user, legoCfg)
require.NoError(t, err)
require.NotNil(t, provider)
// Test obtaining certificate
err = provider.ObtainCert()
require.NoError(t, err)
// Verify certificate was obtained
cert, err := provider.GetCert(nil)
require.NoError(t, err)
require.NotNil(t, cert)
// Verify certificate properties
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, x509Cert.DNSNames, "test.example.com")
require.True(t, time.Now().Before(x509Cert.NotAfter))
require.True(t, time.Now().After(x509Cert.NotBefore))
})
t.Run("obtain cert with EAB from custom step-ca server", func(t *testing.T) {
cfg := &autocert.Config{
Email: "test@example.com",
Domains: []string{"test.example.com"},
Provider: autocert.ProviderCustom,
CADirURL: acmeServer.URL() + "/acme/acme/directory",
CertPath: "certs/stepca-eab-test.crt",
KeyPath: "certs/stepca-eab-test.key",
ACMEKeyPath: "certs/stepca-eab-test-acme.key",
HTTPClient: acmeServer.httpClient(),
EABKid: "kid-123",
EABHmac: base64.RawURLEncoding.EncodeToString([]byte("secret")),
}
err := error(cfg.Validate())
require.NoError(t, err)
user, legoCfg, err := cfg.GetLegoConfig()
require.NoError(t, err)
require.NotNil(t, user)
require.NotNil(t, legoCfg)
provider, err := autocert.NewProvider(cfg, user, legoCfg)
require.NoError(t, err)
require.NotNil(t, provider)
err = provider.ObtainCert()
require.NoError(t, err)
cert, err := provider.GetCert(nil)
require.NoError(t, err)
require.NotNil(t, cert)
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
require.Contains(t, x509Cert.DNSNames, "test.example.com")
require.True(t, time.Now().Before(x509Cert.NotAfter))
require.True(t, time.Now().After(x509Cert.NotBefore))
})
}