Scert/backend/internal/certificate/renewal.go

273 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package certificate
import (
"context"
"errors"
"fmt"
"sync"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
type DomainLister interface {
List(ctx context.Context) ([]domain.Domain, error)
}
type CertificateIssuer interface {
Issue(ctx context.Context, domainID string) (IssueResult, error)
}
type RenewalPolicy struct {
CheckInterval time.Duration
NearExpiryThreshold time.Duration
RenewalThreshold time.Duration
UrgentThreshold time.Duration
MaxRetries int
RetryInterval time.Duration
}
type RenewalScheduler struct {
lister DomainLister
issuer CertificateIssuer
policy RenewalPolicy
now func() time.Time
mu sync.Mutex
policyMu sync.RWMutex
runMu sync.Mutex
running bool
cancel context.CancelFunc
done chan struct{}
wake chan struct{}
}
type RenewalReport struct {
Checked int `json:"checked"`
NearExpiry int `json:"near_expiry"`
Selected int `json:"selected"`
Urgent int `json:"urgent"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Errors []string `json:"errors,omitempty"`
}
func NewRenewalScheduler(lister DomainLister, issuer CertificateIssuer, thresholdDays int) *RenewalScheduler {
if thresholdDays < 1 {
thresholdDays = 6
}
return NewRenewalSchedulerWithPolicy(lister, issuer, RenewalPolicy{
CheckInterval: 3 * 24 * time.Hour,
NearExpiryThreshold: 10 * 24 * time.Hour,
RenewalThreshold: time.Duration(thresholdDays) * 24 * time.Hour,
UrgentThreshold: 3 * 24 * time.Hour,
MaxRetries: 3,
RetryInterval: 30 * time.Minute,
})
}
func NewRenewalSchedulerWithPolicy(lister DomainLister, issuer CertificateIssuer, policy RenewalPolicy) *RenewalScheduler {
policy = normalizeRenewalPolicy(policy)
return &RenewalScheduler{lister: lister, issuer: issuer, policy: policy, now: time.Now, wake: make(chan struct{}, 1)}
}
func (s *RenewalScheduler) UpdatePolicy(policy RenewalPolicy) {
if s == nil {
return
}
policy = normalizeRenewalPolicy(policy)
s.policyMu.Lock()
s.policy = policy
s.policyMu.Unlock()
if s.wake != nil {
select {
case s.wake <- struct{}{}:
default:
}
}
}
func (s *RenewalScheduler) Policy() RenewalPolicy {
if s == nil {
return normalizeRenewalPolicy(RenewalPolicy{})
}
s.policyMu.RLock()
defer s.policyMu.RUnlock()
return s.policy
}
func (s *RenewalScheduler) RunOnce(ctx context.Context) (RenewalReport, error) {
if s == nil || s.lister == nil || s.issuer == nil {
return RenewalReport{}, errors.New("自动续期服务未初始化")
}
if !s.runMu.TryLock() {
return RenewalReport{}, errors.New("自动续期任务正在执行")
}
defer s.runMu.Unlock()
items, err := s.lister.List(ctx)
if err != nil {
return RenewalReport{}, fmt.Errorf("查询续期域名失败:%w", err)
}
report := RenewalReport{Checked: len(items)}
policy := s.Policy()
now := s.now().UTC()
for _, item := range items {
if !item.AutoRenew || item.Status == "issuing" {
continue
}
remaining := renewalRemaining(item.ExpiresAt, now)
if item.ExpiresAt != nil && remaining <= policy.NearExpiryThreshold {
report.NearExpiry++
}
if item.ExpiresAt != nil && remaining > policy.RenewalThreshold {
continue
}
report.Selected++
urgent := item.ExpiresAt != nil && remaining <= policy.UrgentThreshold
if urgent {
report.Urgent++
}
if err := s.issueWithRetry(ctx, item.ID, urgent, policy); err != nil {
report.Failed++
report.Errors = append(report.Errors, fmt.Sprintf("%s:%v", item.Name, err))
continue
}
report.Succeeded++
}
return report, nil
}
func (s *RenewalScheduler) Start(ctx context.Context) {
if s == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
runCtx, cancel := context.WithCancel(ctx)
s.mu.Lock()
if s.running {
s.mu.Unlock()
cancel()
return
}
s.running = true
s.cancel = cancel
s.done = make(chan struct{})
done := s.done
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
s.running = false
s.cancel = nil
s.done = nil
s.mu.Unlock()
close(done)
}()
report, err := s.RunOnce(runCtx)
for {
interval := s.Policy().CheckInterval
if err == nil && report.NearExpiry > 0 {
interval = 24 * time.Hour
}
timer := time.NewTimer(interval)
select {
case <-runCtx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
case <-s.wake:
}
if runCtx.Err() != nil {
return
}
report, err = s.RunOnce(runCtx)
}
}()
}
// Stop 停止后台续期调度并等待当前检查安全退出。
func (s *RenewalScheduler) Stop() {
if s == nil {
return
}
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
}
cancel := s.cancel
done := s.done
s.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
}
func (s *RenewalScheduler) issueWithRetry(ctx context.Context, domainID string, urgent bool, policy RenewalPolicy) error {
attempts := 1
if urgent {
attempts = policy.MaxRetries
}
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if _, err := s.issuer.Issue(ctx, domainID); err == nil {
return nil
} else {
lastErr = err
}
if attempt == attempts {
break
}
if err := waitForRenewalRetry(ctx, policy.RetryInterval); err != nil {
return fmt.Errorf("第 %d 次续期失败,等待重试被取消:%w", attempt, err)
}
}
return fmt.Errorf("续期失败(已尝试 %d 次):%w", attempts, lastErr)
}
func waitForRenewalRetry(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func renewalRemaining(expiresAt *time.Time, now time.Time) time.Duration {
if expiresAt == nil {
return 0
}
return expiresAt.Sub(now)
}
func normalizeRenewalPolicy(policy RenewalPolicy) RenewalPolicy {
if policy.CheckInterval <= 0 {
policy.CheckInterval = 3 * 24 * time.Hour
}
if policy.NearExpiryThreshold <= 0 {
policy.NearExpiryThreshold = 10 * 24 * time.Hour
}
if policy.RenewalThreshold <= 0 {
policy.RenewalThreshold = 6 * 24 * time.Hour
}
if policy.UrgentThreshold <= 0 {
policy.UrgentThreshold = 3 * 24 * time.Hour
}
if policy.MaxRetries <= 0 {
policy.MaxRetries = 3
}
if policy.RetryInterval <= 0 {
policy.RetryInterval = 30 * time.Minute
}
return policy
}