支持续期策略运行时生效

This commit is contained in:
xuans 2026-08-30 23:58:08 +08:00
parent 8ab3459a03
commit 11cbd8a2f2
5 changed files with 131 additions and 50 deletions

View File

@ -138,10 +138,11 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
RunRenewal: func(ctx context.Context) (certificate.RenewalReport, error) {
return renewal.RunOnce(ctx)
},
CurrentCertificate: certificates,
ResetStatePath: admin.StatePath(cfg.Paths.Data),
Audit: auditRepo,
Tasks: taskRepo,
UpdateRenewalPolicy: renewal.UpdatePolicy,
CurrentCertificate: certificates,
ResetStatePath: admin.StatePath(cfg.Paths.Data),
Audit: auditRepo,
Tasks: taskRepo,
})
return &App{
Config: cfg, Logger: logger, Database: db, Repository: repo,

View File

@ -28,13 +28,15 @@ type RenewalPolicy struct {
}
type RenewalScheduler struct {
lister DomainLister
issuer CertificateIssuer
policy RenewalPolicy
now func() time.Time
mu sync.Mutex
runMu sync.Mutex
running bool
lister DomainLister
issuer CertificateIssuer
policy RenewalPolicy
now func() time.Time
mu sync.Mutex
policyMu sync.RWMutex
runMu sync.Mutex
running bool
wake chan struct{}
}
type RenewalReport struct {
@ -63,7 +65,32 @@ func NewRenewalScheduler(lister DomainLister, issuer CertificateIssuer, threshol
func NewRenewalSchedulerWithPolicy(lister DomainLister, issuer CertificateIssuer, policy RenewalPolicy) *RenewalScheduler {
policy = normalizeRenewalPolicy(policy)
return &RenewalScheduler{lister: lister, issuer: issuer, policy: policy, now: time.Now}
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) {
@ -79,24 +106,25 @@ func (s *RenewalScheduler) RunOnce(ctx context.Context) (RenewalReport, error) {
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 <= s.policy.NearExpiryThreshold {
if item.ExpiresAt != nil && remaining <= policy.NearExpiryThreshold {
report.NearExpiry++
}
if item.ExpiresAt != nil && remaining > s.policy.RenewalThreshold {
if item.ExpiresAt != nil && remaining > policy.RenewalThreshold {
continue
}
report.Selected++
urgent := item.ExpiresAt != nil && remaining <= s.policy.UrgentThreshold
urgent := item.ExpiresAt != nil && remaining <= policy.UrgentThreshold
if urgent {
report.Urgent++
}
if err := s.issueWithRetry(ctx, item.ID, urgent); err != nil {
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
@ -125,7 +153,7 @@ func (s *RenewalScheduler) Start(ctx context.Context) {
}()
report, err := s.RunOnce(ctx)
for {
interval := s.policy.CheckInterval
interval := s.Policy().CheckInterval
if err == nil && report.NearExpiry > 0 {
interval = 24 * time.Hour
}
@ -137,16 +165,20 @@ func (s *RenewalScheduler) Start(ctx context.Context) {
}
return
case <-timer.C:
case <-s.wake:
}
if ctx.Err() != nil {
return
}
report, err = s.RunOnce(ctx)
}
}()
}
func (s *RenewalScheduler) issueWithRetry(ctx context.Context, domainID string, urgent bool) error {
func (s *RenewalScheduler) issueWithRetry(ctx context.Context, domainID string, urgent bool, policy RenewalPolicy) error {
attempts := 1
if urgent {
attempts = s.policy.MaxRetries
attempts = policy.MaxRetries
}
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
@ -158,7 +190,7 @@ func (s *RenewalScheduler) issueWithRetry(ctx context.Context, domainID string,
if attempt == attempts {
break
}
if err := waitForRenewalRetry(ctx, s.policy.RetryInterval); err != nil {
if err := waitForRenewalRetry(ctx, policy.RetryInterval); err != nil {
return fmt.Errorf("第 %d 次续期失败,等待重试被取消:%w", attempt, err)
}
}

View File

@ -51,11 +51,12 @@ type Dependencies struct {
DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
RunRenewal func(context.Context) (certificate.RenewalReport, error)
CurrentCertificate certificateReader
ResetStatePath string
Audit repository.AuditRepository
Tasks repository.TaskRepository
RunRenewal func(context.Context) (certificate.RenewalReport, error)
UpdateRenewalPolicy func(certificate.RenewalPolicy)
CurrentCertificate certificateReader
ResetStatePath string
Audit repository.AuditRepository
Tasks repository.TaskRepository
}
type Server struct {
@ -78,24 +79,25 @@ type Server struct {
deployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
restoreVersion func(context.Context, string, string) (certificate.IssueResult, error)
runRenewal func(context.Context) (certificate.RenewalReport, error)
currentCertificate certificateReader
resetStatePath string
audit repository.AuditRepository
tasks repository.TaskRepository
taskMu sync.Mutex
taskCancels map[string]context.CancelFunc
issueTaskMu sync.Mutex
resetMu sync.Mutex
resetClosedHash string
resetLimiter *resetRateLimiter
authSessions map[string]authSession
authMu sync.RWMutex
loginAttempts map[string]loginAttempt
loginMu sync.Mutex
now func() time.Time
httpServer *http.Server
router *gin.Engine
runRenewal func(context.Context) (certificate.RenewalReport, error)
updateRenewalPolicy func(certificate.RenewalPolicy)
currentCertificate certificateReader
resetStatePath string
audit repository.AuditRepository
tasks repository.TaskRepository
taskMu sync.Mutex
taskCancels map[string]context.CancelFunc
issueTaskMu sync.Mutex
resetMu sync.Mutex
resetClosedHash string
resetLimiter *resetRateLimiter
authSessions map[string]authSession
authMu sync.RWMutex
loginAttempts map[string]loginAttempt
loginMu sync.Mutex
now func() time.Time
httpServer *http.Server
router *gin.Engine
}
func New(deps Dependencies) *Server {
@ -119,6 +121,7 @@ func New(deps Dependencies) *Server {
deployCertificate: deps.DeployCertificate,
restoreVersion: deps.RestoreCertificateVersion,
runRenewal: deps.RunRenewal,
updateRenewalPolicy: deps.UpdateRenewalPolicy,
currentCertificate: deps.CurrentCertificate,
resetStatePath: deps.ResetStatePath,
audit: deps.Audit,

View File

@ -6,9 +6,11 @@ import (
"net/http"
"reflect"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
@ -212,16 +214,22 @@ func (s *Server) updateSettings(c *gin.Context) {
s.writeError(c, http.StatusInternalServerError, "保存设置失败", err)
return
}
restartRequired := previous.Server.Host != updated.Server.Host ||
listenerRestartRequired := previous.Server.Host != updated.Server.Host ||
previous.Server.Port != updated.Server.Port ||
previous.Server.HTTPSMode != updated.Server.HTTPSMode ||
previous.Server.HTTPSMode != updated.Server.HTTPSMode
configRestartRequired := listenerRestartRequired ||
previous.Paths.Certificate != updated.Paths.Certificate ||
!reflect.DeepEqual(previous.ACME, updated.ACME) ||
!reflect.DeepEqual(nonRenewalACMEConfig(previous.ACME), nonRenewalACMEConfig(updated.ACME)) ||
!reflect.DeepEqual(previous.DNS, updated.DNS) ||
!reflect.DeepEqual(previous.Notification, updated.Notification)
if s.updateRenewalPolicy != nil {
s.updateRenewalPolicy(renewalPolicyFromConfig(updated))
}
message := "设置已保存"
if restartRequired {
if listenerRestartRequired {
message = "设置已保存;服务监听地址、端口或 HTTPS 模式已变更,请重启 Scert 服务"
} else if configRestartRequired {
message = "设置已保存;部分证书、DNS 或通知运行配置已变更,请重启 Scert 服务"
}
s.config = updated
s.configMu.Unlock()
@ -258,7 +266,7 @@ func (s *Server) updateSettings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"settings": settingsViewFromConfig(updated),
"restart_required": restartRequired,
"restart_required": configRestartRequired,
"message": message,
})
}
@ -296,6 +304,27 @@ func (s *Server) applySettingsDNS(ctx context.Context, cfg *config.Config, previ
return credentialRef, verificationToken, testDomain, nil
}
func renewalPolicyFromConfig(cfg config.Config) certificate.RenewalPolicy {
return certificate.RenewalPolicy{
CheckInterval: time.Duration(cfg.ACME.RenewalCheckIntervalDays) * 24 * time.Hour,
NearExpiryThreshold: time.Duration(cfg.ACME.RenewalNearExpiryDays) * 24 * time.Hour,
RenewalThreshold: time.Duration(cfg.ACME.RenewalThresholdDays) * 24 * time.Hour,
UrgentThreshold: time.Duration(cfg.ACME.RenewalUrgentThresholdDays) * 24 * time.Hour,
MaxRetries: cfg.ACME.RenewalMaxRetries,
RetryInterval: time.Duration(cfg.ACME.RenewalRetryIntervalMins) * time.Minute,
}
}
func nonRenewalACMEConfig(cfg config.ACMEConfig) config.ACMEConfig {
cfg.RenewalCheckIntervalDays = 0
cfg.RenewalNearExpiryDays = 0
cfg.RenewalThresholdDays = 0
cfg.RenewalUrgentThresholdDays = 0
cfg.RenewalMaxRetries = 0
cfg.RenewalRetryIntervalMins = 0
return cfg
}
func settingsViewFromConfig(cfg config.Config) settingsView {
return settingsView{
Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode},

View File

@ -9,6 +9,7 @@ import (
"os"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
@ -140,7 +141,16 @@ func TestSettingsAPIReturnsAndUpdatesRenewalPolicy(t *testing.T) {
if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err)
}
server := New(Dependencies{Config: cfg, ConfigPath: configPath})
var applied certificate.RenewalPolicy
appliedCalled := false
server := New(Dependencies{
Config: cfg,
ConfigPath: configPath,
UpdateRenewalPolicy: func(policy certificate.RenewalPolicy) {
applied = policy
appliedCalled = true
},
})
cookie := loginForSettingsTest(t, server)
getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
@ -184,9 +194,15 @@ func TestSettingsAPIReturnsAndUpdatesRenewalPolicy(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析续期策略响应失败:%v", err)
}
if !payload.Restart || payload.Settings.ACME.RenewalCheckIntervalDays != 2 || payload.Settings.ACME.RenewalNearExpiryDays != 14 || payload.Settings.ACME.RenewalThresholdDays != 7 || payload.Settings.ACME.RenewalUrgentThresholdDays != 2 || payload.Settings.ACME.RenewalMaxRetries != 5 || payload.Settings.ACME.RenewalRetryIntervalMins != 45 {
if payload.Restart || payload.Settings.ACME.RenewalCheckIntervalDays != 2 || payload.Settings.ACME.RenewalNearExpiryDays != 14 || payload.Settings.ACME.RenewalThresholdDays != 7 || payload.Settings.ACME.RenewalUrgentThresholdDays != 2 || payload.Settings.ACME.RenewalMaxRetries != 5 || payload.Settings.ACME.RenewalRetryIntervalMins != 45 {
t.Fatalf("更新后的续期策略不正确:restart=%v acme=%+v", payload.Restart, payload.Settings.ACME)
}
if !appliedCalled {
t.Fatal("\u4fdd\u5b58\u7eed\u671f\u7b56\u7565\u540e\u5e94\u7acb\u5373\u8c03\u7528\u8fd0\u884c\u65f6\u66f4\u65b0\u56de\u8c03")
}
if applied.CheckInterval != 2*24*time.Hour || applied.NearExpiryThreshold != 14*24*time.Hour || applied.RenewalThreshold != 7*24*time.Hour || applied.UrgentThreshold != 2*24*time.Hour || applied.MaxRetries != 5 || applied.RetryInterval != 45*time.Minute {
t.Fatalf("\u8fd0\u884c\u65f6\u7eed\u671f\u7b56\u7565\u672a\u6b63\u786e\u8f6c\u6362\uff1a%+v", applied)
}
saved, err := config.Load(configPath)
if err != nil {
t.Fatalf("读取续期策略配置失败:%v", err)