支持续期策略运行时生效

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) { RunRenewal: func(ctx context.Context) (certificate.RenewalReport, error) {
return renewal.RunOnce(ctx) return renewal.RunOnce(ctx)
}, },
CurrentCertificate: certificates, UpdateRenewalPolicy: renewal.UpdatePolicy,
ResetStatePath: admin.StatePath(cfg.Paths.Data), CurrentCertificate: certificates,
Audit: auditRepo, ResetStatePath: admin.StatePath(cfg.Paths.Data),
Tasks: taskRepo, Audit: auditRepo,
Tasks: taskRepo,
}) })
return &App{ return &App{
Config: cfg, Logger: logger, Database: db, Repository: repo, Config: cfg, Logger: logger, Database: db, Repository: repo,

View File

@ -28,13 +28,15 @@ type RenewalPolicy struct {
} }
type RenewalScheduler struct { type RenewalScheduler struct {
lister DomainLister lister DomainLister
issuer CertificateIssuer issuer CertificateIssuer
policy RenewalPolicy policy RenewalPolicy
now func() time.Time now func() time.Time
mu sync.Mutex mu sync.Mutex
runMu sync.Mutex policyMu sync.RWMutex
running bool runMu sync.Mutex
running bool
wake chan struct{}
} }
type RenewalReport struct { type RenewalReport struct {
@ -63,7 +65,32 @@ func NewRenewalScheduler(lister DomainLister, issuer CertificateIssuer, threshol
func NewRenewalSchedulerWithPolicy(lister DomainLister, issuer CertificateIssuer, policy RenewalPolicy) *RenewalScheduler { func NewRenewalSchedulerWithPolicy(lister DomainLister, issuer CertificateIssuer, policy RenewalPolicy) *RenewalScheduler {
policy = normalizeRenewalPolicy(policy) 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) { 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) return RenewalReport{}, fmt.Errorf("查询续期域名失败:%w", err)
} }
report := RenewalReport{Checked: len(items)} report := RenewalReport{Checked: len(items)}
policy := s.Policy()
now := s.now().UTC() now := s.now().UTC()
for _, item := range items { for _, item := range items {
if !item.AutoRenew || item.Status == "issuing" { if !item.AutoRenew || item.Status == "issuing" {
continue continue
} }
remaining := renewalRemaining(item.ExpiresAt, now) remaining := renewalRemaining(item.ExpiresAt, now)
if item.ExpiresAt != nil && remaining <= s.policy.NearExpiryThreshold { if item.ExpiresAt != nil && remaining <= policy.NearExpiryThreshold {
report.NearExpiry++ report.NearExpiry++
} }
if item.ExpiresAt != nil && remaining > s.policy.RenewalThreshold { if item.ExpiresAt != nil && remaining > policy.RenewalThreshold {
continue continue
} }
report.Selected++ report.Selected++
urgent := item.ExpiresAt != nil && remaining <= s.policy.UrgentThreshold urgent := item.ExpiresAt != nil && remaining <= policy.UrgentThreshold
if urgent { if urgent {
report.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.Failed++
report.Errors = append(report.Errors, fmt.Sprintf("%s:%v", item.Name, err)) report.Errors = append(report.Errors, fmt.Sprintf("%s:%v", item.Name, err))
continue continue
@ -125,7 +153,7 @@ func (s *RenewalScheduler) Start(ctx context.Context) {
}() }()
report, err := s.RunOnce(ctx) report, err := s.RunOnce(ctx)
for { for {
interval := s.policy.CheckInterval interval := s.Policy().CheckInterval
if err == nil && report.NearExpiry > 0 { if err == nil && report.NearExpiry > 0 {
interval = 24 * time.Hour interval = 24 * time.Hour
} }
@ -137,16 +165,20 @@ func (s *RenewalScheduler) Start(ctx context.Context) {
} }
return return
case <-timer.C: case <-timer.C:
case <-s.wake:
}
if ctx.Err() != nil {
return
} }
report, err = s.RunOnce(ctx) 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 attempts := 1
if urgent { if urgent {
attempts = s.policy.MaxRetries attempts = policy.MaxRetries
} }
var lastErr error var lastErr error
for attempt := 1; attempt <= attempts; attempt++ { for attempt := 1; attempt <= attempts; attempt++ {
@ -158,7 +190,7 @@ func (s *RenewalScheduler) issueWithRetry(ctx context.Context, domainID string,
if attempt == attempts { if attempt == attempts {
break 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) 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) DeployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error) RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
RunRenewal func(context.Context) (certificate.RenewalReport, error) RunRenewal func(context.Context) (certificate.RenewalReport, error)
CurrentCertificate certificateReader UpdateRenewalPolicy func(certificate.RenewalPolicy)
ResetStatePath string CurrentCertificate certificateReader
Audit repository.AuditRepository ResetStatePath string
Tasks repository.TaskRepository Audit repository.AuditRepository
Tasks repository.TaskRepository
} }
type Server struct { type Server struct {
@ -78,24 +79,25 @@ type Server struct {
deployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error) deployCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
restoreVersion func(context.Context, string, string) (certificate.IssueResult, error) restoreVersion func(context.Context, string, string) (certificate.IssueResult, error)
runRenewal func(context.Context) (certificate.RenewalReport, error) runRenewal func(context.Context) (certificate.RenewalReport, error)
currentCertificate certificateReader updateRenewalPolicy func(certificate.RenewalPolicy)
resetStatePath string currentCertificate certificateReader
audit repository.AuditRepository resetStatePath string
tasks repository.TaskRepository audit repository.AuditRepository
taskMu sync.Mutex tasks repository.TaskRepository
taskCancels map[string]context.CancelFunc taskMu sync.Mutex
issueTaskMu sync.Mutex taskCancels map[string]context.CancelFunc
resetMu sync.Mutex issueTaskMu sync.Mutex
resetClosedHash string resetMu sync.Mutex
resetLimiter *resetRateLimiter resetClosedHash string
authSessions map[string]authSession resetLimiter *resetRateLimiter
authMu sync.RWMutex authSessions map[string]authSession
loginAttempts map[string]loginAttempt authMu sync.RWMutex
loginMu sync.Mutex loginAttempts map[string]loginAttempt
now func() time.Time loginMu sync.Mutex
httpServer *http.Server now func() time.Time
router *gin.Engine httpServer *http.Server
router *gin.Engine
} }
func New(deps Dependencies) *Server { func New(deps Dependencies) *Server {
@ -119,6 +121,7 @@ func New(deps Dependencies) *Server {
deployCertificate: deps.DeployCertificate, deployCertificate: deps.DeployCertificate,
restoreVersion: deps.RestoreCertificateVersion, restoreVersion: deps.RestoreCertificateVersion,
runRenewal: deps.RunRenewal, runRenewal: deps.RunRenewal,
updateRenewalPolicy: deps.UpdateRenewalPolicy,
currentCertificate: deps.CurrentCertificate, currentCertificate: deps.CurrentCertificate,
resetStatePath: deps.ResetStatePath, resetStatePath: deps.ResetStatePath,
audit: deps.Audit, audit: deps.Audit,

View File

@ -6,9 +6,11 @@ import (
"net/http" "net/http"
"reflect" "reflect"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config" "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) s.writeError(c, http.StatusInternalServerError, "保存设置失败", err)
return return
} }
restartRequired := previous.Server.Host != updated.Server.Host || listenerRestartRequired := previous.Server.Host != updated.Server.Host ||
previous.Server.Port != updated.Server.Port || 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 || 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.DNS, updated.DNS) ||
!reflect.DeepEqual(previous.Notification, updated.Notification) !reflect.DeepEqual(previous.Notification, updated.Notification)
if s.updateRenewalPolicy != nil {
s.updateRenewalPolicy(renewalPolicyFromConfig(updated))
}
message := "设置已保存" message := "设置已保存"
if restartRequired { if listenerRestartRequired {
message = "设置已保存;服务监听地址、端口或 HTTPS 模式已变更,请重启 Scert 服务" message = "设置已保存;服务监听地址、端口或 HTTPS 模式已变更,请重启 Scert 服务"
} else if configRestartRequired {
message = "设置已保存;部分证书、DNS 或通知运行配置已变更,请重启 Scert 服务"
} }
s.config = updated s.config = updated
s.configMu.Unlock() s.configMu.Unlock()
@ -258,7 +266,7 @@ func (s *Server) updateSettings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"settings": settingsViewFromConfig(updated), "settings": settingsViewFromConfig(updated),
"restart_required": restartRequired, "restart_required": configRestartRequired,
"message": message, "message": message,
}) })
} }
@ -296,6 +304,27 @@ func (s *Server) applySettingsDNS(ctx context.Context, cfg *config.Config, previ
return credentialRef, verificationToken, testDomain, nil 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 { func settingsViewFromConfig(cfg config.Config) settingsView {
return settingsView{ return settingsView{
Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode}, Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode},

View File

@ -9,6 +9,7 @@ import (
"os" "os"
"strings" "strings"
"testing" "testing"
"time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -140,7 +141,16 @@ func TestSettingsAPIReturnsAndUpdatesRenewalPolicy(t *testing.T) {
if err := cfg.Save(configPath); err != nil { if err := cfg.Save(configPath); err != nil {
t.Fatalf("保存测试配置失败:%v", err) 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) cookie := loginForSettingsTest(t, server)
getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil) 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 { if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析续期策略响应失败:%v", err) 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) 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) saved, err := config.Load(configPath)
if err != nil { if err != nil {
t.Fatalf("读取续期策略配置失败:%v", err) t.Fatalf("读取续期策略配置失败:%v", err)