mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 22:30:47 +01:00
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
275 lines
7.1 KiB
Go
275 lines
7.1 KiB
Go
package autocert
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
|
|
"github.com/go-acme/lego/v4/certcrypto"
|
|
"github.com/go-acme/lego/v4/challenge"
|
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
|
"github.com/go-acme/lego/v4/lego"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/yusing/godoxy/internal/common"
|
|
gperr "github.com/yusing/goutils/errs"
|
|
strutils "github.com/yusing/goutils/strings"
|
|
)
|
|
|
|
type ConfigExtra Config
|
|
type Config struct {
|
|
Email string `json:"email,omitempty"`
|
|
Domains []string `json:"domains,omitempty"`
|
|
CertPath string `json:"cert_path,omitempty"`
|
|
KeyPath string `json:"key_path,omitempty"`
|
|
Extra []ConfigExtra `json:"extra,omitempty"`
|
|
ACMEKeyPath string `json:"acme_key_path,omitempty"` // shared by all extra providers
|
|
Provider string `json:"provider,omitempty"`
|
|
Options map[string]strutils.Redacted `json:"options,omitempty"`
|
|
|
|
Resolvers []string `json:"resolvers,omitempty"`
|
|
|
|
// Custom ACME CA
|
|
CADirURL string `json:"ca_dir_url,omitempty"`
|
|
CACerts []string `json:"ca_certs,omitempty"`
|
|
|
|
// EAB
|
|
EABKid string `json:"eab_kid,omitempty" validate:"required_with=EABHmac"`
|
|
EABHmac string `json:"eab_hmac,omitempty" validate:"required_with=EABKid"` // base64 encoded
|
|
|
|
HTTPClient *http.Client `json:"-"` // for tests only
|
|
|
|
challengeProvider challenge.Provider
|
|
|
|
idx int // 0: main, 1+: extra[i]
|
|
}
|
|
|
|
var (
|
|
ErrMissingField = gperr.New("missing field")
|
|
ErrDuplicatedPath = gperr.New("duplicated path")
|
|
ErrInvalidDomain = gperr.New("invalid domain")
|
|
ErrUnknownProvider = gperr.New("unknown provider")
|
|
)
|
|
|
|
const (
|
|
ProviderLocal = "local"
|
|
ProviderPseudo = "pseudo"
|
|
ProviderCustom = "custom"
|
|
)
|
|
|
|
var domainOrWildcardRE = regexp.MustCompile(`^\*?([^.]+\.)+[^.]+$`)
|
|
|
|
// Validate implements the utils.CustomValidator interface.
|
|
func (cfg *Config) Validate() gperr.Error {
|
|
seenPaths := make(map[string]int) // path -> provider idx (0 for main, 1+ for extras)
|
|
return cfg.validate(seenPaths)
|
|
}
|
|
|
|
func (cfg *ConfigExtra) Validate() gperr.Error {
|
|
return nil // done by main config's validate
|
|
}
|
|
|
|
func (cfg *ConfigExtra) AsConfig() *Config {
|
|
return (*Config)(cfg)
|
|
}
|
|
|
|
func (cfg *Config) validate(seenPaths map[string]int) gperr.Error {
|
|
if cfg.Provider == "" {
|
|
cfg.Provider = ProviderLocal
|
|
}
|
|
if cfg.CertPath == "" {
|
|
cfg.CertPath = CertFileDefault
|
|
}
|
|
if cfg.KeyPath == "" {
|
|
cfg.KeyPath = KeyFileDefault
|
|
}
|
|
if cfg.ACMEKeyPath == "" {
|
|
cfg.ACMEKeyPath = ACMEKeyFileDefault
|
|
}
|
|
|
|
b := gperr.NewBuilder("certificate error")
|
|
|
|
// check if cert_path is unique
|
|
if first, ok := seenPaths[cfg.CertPath]; ok {
|
|
b.Add(ErrDuplicatedPath.Subjectf("cert_path %s", cfg.CertPath).Withf("first seen in %s", fmt.Sprintf("extra[%d]", first)))
|
|
} else {
|
|
seenPaths[cfg.CertPath] = cfg.idx
|
|
}
|
|
|
|
// check if key_path is unique
|
|
if first, ok := seenPaths[cfg.KeyPath]; ok {
|
|
b.Add(ErrDuplicatedPath.Subjectf("key_path %s", cfg.KeyPath).Withf("first seen in %s", fmt.Sprintf("extra[%d]", first)))
|
|
} else {
|
|
seenPaths[cfg.KeyPath] = cfg.idx
|
|
}
|
|
|
|
if cfg.Provider == ProviderCustom && cfg.CADirURL == "" {
|
|
b.Add(ErrMissingField.Subject("ca_dir_url"))
|
|
}
|
|
|
|
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
|
|
if len(cfg.Domains) == 0 {
|
|
b.Add(ErrMissingField.Subject("domains"))
|
|
}
|
|
if cfg.Email == "" {
|
|
b.Add(ErrMissingField.Subject("email"))
|
|
}
|
|
if cfg.Provider != ProviderCustom {
|
|
for i, d := range cfg.Domains {
|
|
if !domainOrWildcardRE.MatchString(d) {
|
|
b.Add(ErrInvalidDomain.Subjectf("domains[%d]", i))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// check if provider is implemented
|
|
providerConstructor, ok := Providers[cfg.Provider]
|
|
if !ok {
|
|
if cfg.Provider != ProviderCustom {
|
|
b.Add(ErrUnknownProvider.
|
|
Subject(cfg.Provider).
|
|
With(gperr.DoYouMeanField(cfg.Provider, Providers)))
|
|
}
|
|
} else {
|
|
provider, err := providerConstructor(cfg.Options)
|
|
if err != nil {
|
|
b.Add(err)
|
|
} else {
|
|
cfg.challengeProvider = provider
|
|
}
|
|
}
|
|
|
|
if cfg.challengeProvider == nil {
|
|
cfg.challengeProvider, _ = Providers[ProviderLocal](nil)
|
|
}
|
|
|
|
if len(cfg.Extra) > 0 {
|
|
for i := range cfg.Extra {
|
|
cfg.Extra[i] = MergeExtraConfig(cfg, &cfg.Extra[i])
|
|
cfg.Extra[i].AsConfig().idx = i + 1
|
|
err := cfg.Extra[i].AsConfig().validate(seenPaths)
|
|
if err != nil {
|
|
b.Add(err.Subjectf("extra[%d]", i))
|
|
}
|
|
}
|
|
}
|
|
return b.Error()
|
|
}
|
|
|
|
func (cfg *Config) dns01Options() []dns01.ChallengeOption {
|
|
return []dns01.ChallengeOption{
|
|
dns01.CondOption(len(cfg.Resolvers) > 0, dns01.AddRecursiveNameservers(cfg.Resolvers)),
|
|
}
|
|
}
|
|
|
|
func (cfg *Config) GetLegoConfig() (*User, *lego.Config, error) {
|
|
var privKey *ecdsa.PrivateKey
|
|
var err error
|
|
|
|
if cfg.Provider != ProviderLocal && cfg.Provider != ProviderPseudo {
|
|
if privKey, err = cfg.LoadACMEKey(); err != nil {
|
|
log.Info().Err(err).Msg("failed to load ACME private key, generating a now one")
|
|
privKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, nil, gperr.New("generate ACME private key").With(err)
|
|
}
|
|
if err = cfg.SaveACMEKey(privKey); err != nil {
|
|
return nil, nil, gperr.New("save ACME private key").With(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
user := &User{
|
|
Email: cfg.Email,
|
|
Key: privKey,
|
|
}
|
|
|
|
legoCfg := lego.NewConfig(user)
|
|
legoCfg.Certificate.KeyType = certcrypto.EC256
|
|
|
|
if cfg.HTTPClient != nil {
|
|
legoCfg.HTTPClient = cfg.HTTPClient
|
|
}
|
|
|
|
if cfg.CADirURL != "" {
|
|
legoCfg.CADirURL = cfg.CADirURL
|
|
}
|
|
|
|
if len(cfg.CACerts) > 0 {
|
|
certPool, err := lego.CreateCertPool(cfg.CACerts, true)
|
|
if err != nil {
|
|
return nil, nil, gperr.New("failed to create cert pool").With(err)
|
|
}
|
|
legoCfg.HTTPClient.Transport.(*http.Transport).TLSClientConfig.RootCAs = certPool
|
|
}
|
|
|
|
return user, legoCfg, nil
|
|
}
|
|
|
|
func MergeExtraConfig(mainCfg *Config, extraCfg *ConfigExtra) ConfigExtra {
|
|
merged := ConfigExtra(*mainCfg)
|
|
merged.Extra = nil
|
|
merged.CertPath = extraCfg.CertPath
|
|
merged.KeyPath = extraCfg.KeyPath
|
|
// NOTE: Using same ACME key as main provider
|
|
|
|
if extraCfg.Provider != "" {
|
|
merged.Provider = extraCfg.Provider
|
|
}
|
|
if extraCfg.Email != "" {
|
|
merged.Email = extraCfg.Email
|
|
}
|
|
if len(extraCfg.Domains) > 0 {
|
|
merged.Domains = extraCfg.Domains
|
|
}
|
|
if len(extraCfg.Options) > 0 {
|
|
merged.Options = extraCfg.Options
|
|
}
|
|
if len(extraCfg.Resolvers) > 0 {
|
|
merged.Resolvers = extraCfg.Resolvers
|
|
}
|
|
if extraCfg.CADirURL != "" {
|
|
merged.CADirURL = extraCfg.CADirURL
|
|
}
|
|
if len(extraCfg.CACerts) > 0 {
|
|
merged.CACerts = extraCfg.CACerts
|
|
}
|
|
if extraCfg.EABKid != "" {
|
|
merged.EABKid = extraCfg.EABKid
|
|
}
|
|
if extraCfg.EABHmac != "" {
|
|
merged.EABHmac = extraCfg.EABHmac
|
|
}
|
|
if extraCfg.HTTPClient != nil {
|
|
merged.HTTPClient = extraCfg.HTTPClient
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func (cfg *Config) LoadACMEKey() (*ecdsa.PrivateKey, error) {
|
|
if common.IsTest {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
data, err := os.ReadFile(cfg.ACMEKeyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return x509.ParseECPrivateKey(data)
|
|
}
|
|
|
|
func (cfg *Config) SaveACMEKey(key *ecdsa.PrivateKey) error {
|
|
if common.IsTest {
|
|
return nil
|
|
}
|
|
data, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(cfg.ACMEKeyPath, data, 0o600)
|
|
}
|