From 11cbd8a2f2591cdc92e01a9958a37a9cc0cc68b3 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 30 Aug 2026 23:58:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BB=AD=E6=9C=9F=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E8=BF=90=E8=A1=8C=E6=97=B6=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/app.go | 9 +-- backend/internal/certificate/renewal.go | 64 ++++++++++++++----- backend/internal/httpapi/server.go | 49 +++++++------- backend/internal/httpapi/settings_api.go | 39 +++++++++-- backend/internal/httpapi/settings_api_test.go | 20 +++++- 5 files changed, 131 insertions(+), 50 deletions(-) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 7b4d25c..ff08747 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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, diff --git a/backend/internal/certificate/renewal.go b/backend/internal/certificate/renewal.go index 75671fd..4a7057c 100644 --- a/backend/internal/certificate/renewal.go +++ b/backend/internal/certificate/renewal.go @@ -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) } } diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 6ac187c..25ef6e4 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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, diff --git a/backend/internal/httpapi/settings_api.go b/backend/internal/httpapi/settings_api.go index 678c04d..8b171c0 100644 --- a/backend/internal/httpapi/settings_api.go +++ b/backend/internal/httpapi/settings_api.go @@ -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}, diff --git a/backend/internal/httpapi/settings_api_test.go b/backend/internal/httpapi/settings_api_test.go index 8666ff0..e1d6e97 100644 --- a/backend/internal/httpapi/settings_api_test.go +++ b/backend/internal/httpapi/settings_api_test.go @@ -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)