Scert/backend/internal/certificate/renewal.go

300 lines
7.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)
}
// ManagementRenewalCheck 独立检查管理入口,不将其伪装为业务域名。
type ManagementRenewalCheck interface {
RunRenewal(context.Context, RenewalPolicy, time.Time) (RenewalReport, 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
management ManagementRenewalCheck
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 NewRenewalSchedulerWithManagement(lister DomainLister, issuer CertificateIssuer, policy RenewalPolicy, management ManagementRenewalCheck) *RenewalScheduler {
s := NewRenewalSchedulerWithPolicy(lister, issuer, policy)
s.management = management
return s
}
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()
policy := s.Policy()
now := s.now().UTC()
var report RenewalReport
var managementErr error
if s.management != nil {
report, managementErr = s.management.RunRenewal(ctx, policy, now)
if managementErr != nil {
report.Errors = append(report.Errors, "管理证书续期检查失败")
}
}
if err := ctx.Err(); err != nil {
return report, errors.Join(managementErr, err)
}
items, err := s.lister.List(ctx)
if err != nil {
return report, errors.Join(managementErr, fmt.Errorf("查询续期域名失败:%w", err))
}
report.Checked += len(items)
for _, item := range items {
if err := ctx.Err(); err != nil {
return report, errors.Join(managementErr, err)
}
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, managementErr
}
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 (report.NearExpiry > 0 || err != nil) && interval > 24*time.Hour {
interval = 24 * time.Hour
}
timer := time.NewTimer(interval)
select {
case <-runCtx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
case <-s.wake:
}
timer.Stop()
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
}