diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 7c0cca5..c3946e6 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -6,7 +6,6 @@ import ( "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/internal/buildinfo" - "git.pcux.cn/xuans/Smail/listen/cron_server" "git.pcux.cn/xuans/Smail/res_init" "git.pcux.cn/xuans/Smail/services/runtimelog" "git.pcux.cn/xuans/Smail/utils/runtimepath" @@ -37,7 +36,6 @@ func Run() (err error) { return fmt.Errorf("启动运行日志失败: %w", err) } defer finishRuntimeLogs(runtimeLogs, &err) - config.MaintainManagedSSLHistory() build := buildinfo.Current() if build.Version == "dev" { @@ -46,9 +44,6 @@ func Run() (err error) { log.WithFields(log.Fields{"action": "启动主服务", "object": "Smail", "result": "started"}).Infof("服务启动,版本:%s", build.Version) - // 启动定时任务。 - go cron_server.Start() - // 启动核心服务。 if err := res_init.Init(build.Version); err != nil { return err diff --git a/backend/listen/cron_server/ssl_update.go b/backend/listen/cron_server/ssl_update.go index 192059f..3b591e3 100644 --- a/backend/listen/cron_server/ssl_update.go +++ b/backend/listen/cron_server/ssl_update.go @@ -13,20 +13,35 @@ import ( "strconv" ) -func Start() { +// Runtime 将证书维护绑定到核心服务,避免恢复配置时并发争用状态锁。 +type Runtime struct { + cancel context.CancelFunc + done chan struct{} +} - // 第一次启动,等待到初始化完成 - if cfg := config.CurrentConfig(); cfg == nil || !cfg.IsSetupReady() { - for { - time.Sleep(1 * time.Minute) - if cfg := config.CurrentConfig(); cfg != nil && cfg.IsSetupReady() { - break - } - } +// Start 只能在配置恢复及核心服务初始化完成后调用。 +func Start() *Runtime { + ctx, cancel := context.WithCancel(context.Background()) + runtime := &Runtime{cancel: cancel, done: make(chan struct{})} + go func() { + defer close(runtime.done) + sslUpdateLoop(ctx) + }() + return runtime +} + +// Stop 取消后续维护并等待当前操作结束;超时不能继续关闭配置及数据库。 +func (runtime *Runtime) Stop(ctx context.Context) error { + if runtime == nil { + return nil + } + runtime.cancel() + select { + case <-runtime.done: + return nil + case <-ctx.Done(): + return ctx.Err() } - - go sslUpdateLoop() - } // 只检查外部维护的本地邮件证书是否更新,不触发 ACME。 @@ -37,6 +52,13 @@ func sslCheck() { // 每轮读取最新策略,切换反代模式后不再等待 HTTP 挑战或执行自动续期。 func maintainSSL() { + maintainSSLContext(context.Background()) +} + +func maintainSSLContext(ctx context.Context) { + if ctx.Err() != nil { + return + } config.MaintainManagedSSLHistory() cfg := config.CurrentCertificateConfig() if cfg == nil || !cfg.IsSetupReady() { @@ -46,30 +68,43 @@ func maintainSSL() { sslCheck() return } - if cfg.EffectiveSSLType() == config.SSLTypeAutoHTTP && !waitHTTPReady() { + if cfg.EffectiveSSLType() == config.SSLTypeAutoHTTP && !waitHTTPReadyContext(ctx) { + return + } + if ctx.Err() != nil { return } ssl.Update(true) } // 每天检查证书,外部管理模式仅保留本地证书变更检查。 -func sslUpdateLoop() { +func sslUpdateLoop(ctx context.Context) { lastRenewal := time.Time{} - for { + for ctx.Err() == nil { if time.Since(lastRenewal) >= 24*time.Hour { - maintainSSL() + maintainSSLContext(ctx) lastRenewal = time.Now() } else { config.MaintainManagedSSLHistory() sslCheck() } // 每分钟清理已到期的上一套证书;自动签发仍最多每天检查一次。 - time.Sleep(time.Minute) + timer := time.NewTimer(time.Minute) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } } } // 新增:HTTP 就绪探测(最多等待 ~90 秒) func waitHTTPReady() bool { + return waitHTTPReadyContext(context.Background()) +} + +func waitHTTPReadyContext(ctx context.Context) bool { port := config.CurrentHTTPConfig().Port if port < 1 || port > 65535 { log.Warnf("invalid HTTP port %d, skipping SSL update this round", port) @@ -84,10 +119,13 @@ func waitHTTPReady() bool { client := &http.Client{Timeout: 2 * time.Second} for i := 0; i < 90; i++ { + if ctx.Err() != nil { + return false + } if !config.CurrentCertificateConfig().AutomaticSSLEnabled() { return false } - requestContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + requestContext, cancel := context.WithTimeout(ctx, 2*time.Second) request := (&http.Request{ Method: http.MethodGet, URL: target, @@ -105,7 +143,13 @@ func waitHTTPReady() bool { log.Infof("HTTP ready: %s", target.String()) return true } - time.Sleep(1 * time.Second) + timer := time.NewTimer(time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return false + case <-timer.C: + } } log.Warnf("HTTP not ready after 90s, skipping SSL update this round") return false diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 2cb42b0..08c2def 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -16,6 +16,7 @@ import ( "git.pcux.cn/xuans/Smail/db/migration" "git.pcux.cn/xuans/Smail/dto/parsemail" "git.pcux.cn/xuans/Smail/hooks" + "git.pcux.cn/xuans/Smail/listen/cron_server" "git.pcux.cn/xuans/Smail/listen/http_server" "git.pcux.cn/xuans/Smail/listen/imap_server" "git.pcux.cn/xuans/Smail/listen/pop3_server" @@ -52,6 +53,7 @@ var credentialService *credentials.Service var webAuthnRuntimeService *webauthnservice.Service var logRuntime *logstore.Runtime var outboxRuntime *outbox.Runtime +var certificateRuntime *cron_server.Runtime func startConfigManager() error { root := filepath.Join(config.ROOT_PATH, "config") @@ -261,6 +263,7 @@ func Init(serverVersion string) error { } log.WithFields(log.Fields{"action": "启动邮箱监听", "object": "SMTP / IMAP / POP3", "result": "started"}).Info("邮箱监听任务已提交,具体监听结果由各服务报告") + certificateRuntime = cron_server.Start() restart.SetCurrent(restart.NewSignalTrigger(signal.RestartChan)) select { @@ -293,6 +296,14 @@ func startService(serviceErrors chan<- error, name string, service func() error) func stopServices() error { restart.SetCurrent(nil) + // 先结束证书维护,再关闭其依赖;超时直接上报,禁止进入下一轮配置恢复。 + certificateCtx, certificateCancel := context.WithTimeout(context.Background(), 25*time.Second) + certificateErr := certificateRuntime.Stop(certificateCtx) + certificateCancel() + if certificateErr != nil { + return fmt.Errorf("certificate maintenance did not stop; dependencies retained: %w", certificateErr) + } + certificateRuntime = nil stopAdminResetControl() smtp_server.Stop() http_server.HttpsStop()