diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index f0a9b55..f50d7ba 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -31,6 +31,7 @@ type PublicIssue struct { type ConfigStatus struct { SetupState SetupState `json:"setupState"` + TLSMode string `json:"tlsMode"` ActiveGeneration uint64 `json:"activeGeneration"` LastValidAt *time.Time `json:"lastValidAt,omitempty"` PendingRestart []ServiceName `json:"pendingRestart,omitempty"` @@ -248,6 +249,7 @@ func (m *ConfigManager) Status() ConfigStatus { m.mu.RLock() defer m.mu.RUnlock() status := m.status + status.TLSMode = m.current.Root.HTTP.TLSMode status.PendingRestart = append([]ServiceName(nil), status.PendingRestart...) status.DegradedReasons = append([]PublicIssue(nil), status.DegradedReasons...) if status.LastValidAt != nil { diff --git a/backend/config/ssl_commit.go b/backend/config/ssl_commit.go index 5bac11f..3ce387d 100644 --- a/backend/config/ssl_commit.go +++ b/backend/config/ssl_commit.go @@ -145,7 +145,8 @@ func sameSSLPolicy(left, right *Config) bool { return reflect.DeepEqual(left.SSL, right.SSL) && left.SSLType == right.SSLType && left.SSLPrivateKeyPath == right.SSLPrivateKeyPath && left.SSLPublicKeyPath == right.SSLPublicKeyPath && left.Domain == right.Domain && left.WebDomain == right.WebDomain && - reflect.DeepEqual(left.Domains, right.Domains) && left.HttpsEnabled == right.HttpsEnabled + reflect.DeepEqual(left.Domains, right.Domains) && left.HttpsEnabled == right.HttpsEnabled && + left.TLSMode == right.TLSMode } func mergeServices(previous, next []ServiceName) []ServiceName { diff --git a/backend/config/ssl_policy.go b/backend/config/ssl_policy.go new file mode 100644 index 0000000..e4b57b2 --- /dev/null +++ b/backend/config/ssl_policy.go @@ -0,0 +1,13 @@ +package config + +import "strings" + +// UsesProxyHTTPS 表示网页 HTTPS 由反向代理终止,不代表邮件协议可以关闭 TLS。 +func (c *Config) UsesProxyHTTPS() bool { + return c != nil && strings.EqualFold(strings.TrimSpace(c.TLSMode), HTTPSTLSModeProxy) +} + +// AutomaticSSLEnabled 保留原证书策略,但反代模式下禁止 Smail 自动签发和续期。 +func (c *Config) AutomaticSSLEnabled() bool { + return c != nil && !c.UsesProxyHTTPS() && (c.SSLType == SSLTypeAutoHTTP || c.SSLType == SSLTypeAutoDNS) +} diff --git a/backend/controllers/config.go b/backend/controllers/config.go index 100500d..db910c8 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -33,6 +33,7 @@ func ConfigStatus(ctx *appcontext.Context, w http.ResponseWriter, req *http.Requ response.NewSuccessResponse(map[string]any{ "available": true, "setupState": status.SetupState, + "tlsMode": status.TLSMode, "activeGeneration": status.ActiveGeneration, "lastValidAt": status.LastValidAt, "pendingRestart": status.PendingRestart, diff --git a/backend/listen/cron_server/ssl_update.go b/backend/listen/cron_server/ssl_update.go index ecc431b..72d6800 100644 --- a/backend/listen/cron_server/ssl_update.go +++ b/backend/listen/cron_server/ssl_update.go @@ -28,64 +28,63 @@ func Start() { } } - cfg := config.CurrentConfig() - if cfg != nil && (cfg.SSLType == config.SSLTypeAutoHTTP || cfg.SSLType == config.SSLTypeAutoDNS) { - go sslUpdateLoop() - } else { - go sslCheck() - } + go sslUpdateLoop() } -// 每天检查一遍SSL证书是否更新,更新就重启 +// 只检查外部维护的本地邮件证书是否更新,不触发 ACME。 func sslCheck() { - var err error - _, expiredTime, _, err = ssl.CheckSSLCrtInfo() + _, newExpTime, _, err := ssl.CheckSSLCrtInfo() if err != nil { log.Errorf("SSL Check Error! %+v", err) - expiredTime = time.Time{} + return } - - for { - time.Sleep(24 * time.Hour) - _, newExpTime, _, err := ssl.CheckSSLCrtInfo() - if err != nil { - log.Errorf("SSL Check Error! %+v", err) - continue - } - if expiredTime.IsZero() { - expiredTime = newExpTime - continue - } - if newExpTime != expiredTime { - expiredTime = newExpTime - log.Infoln("SSL certificate had update! restarting") - signal.RestartChan <- true - } - + if expiredTime.IsZero() { + expiredTime = newExpTime + return + } + if newExpTime != expiredTime { + expiredTime = newExpTime + log.Infoln("SSL certificate had update! restarting") + signal.RestartChan <- true } } -// 每天检查一遍SSL证书是否即将过期,即将过期就重新生成 +// 每轮读取最新策略,切换反代模式后不再等待 HTTP 挑战或执行自动续期。 +func maintainSSL() { + cfg := config.CurrentConfig() + if cfg == nil || !cfg.IsSetupReady() { + return + } + if !cfg.AutomaticSSLEnabled() { + sslCheck() + return + } + expiredTime = time.Time{} + if cfg.SSLType == config.SSLTypeAutoHTTP && !waitHTTPReady() { + return + } + ssl.Update(true) +} + +// 每天检查证书,外部管理模式仅保留本地证书变更检查。 func sslUpdateLoop() { for { - // 等待 HTTP 就绪后再执行 ACME 续期,避免挑战失败 - waitHTTPReady() - ssl.Update(true) + maintainSSL() // 每24小时检测一次证书有效期 time.Sleep(24 * time.Hour) } } // 新增:HTTP 就绪探测(最多等待 ~90 秒) -func waitHTTPReady() { +func waitHTTPReady() bool { port := 80 if cfg := config.CurrentConfig(); cfg != nil && cfg.HttpPort > 0 { port = cfg.HttpPort } if port < 1 || port > 65535 { log.Warnf("invalid HTTP port %d, skipping SSL update this round", port) - return + return false } target := &url.URL{ @@ -96,6 +95,9 @@ func waitHTTPReady() { client := &http.Client{Timeout: 2 * time.Second} for i := 0; i < 90; i++ { + if !config.CurrentConfig().AutomaticSSLEnabled() { + return false + } requestContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) request := (&http.Request{ Method: http.MethodGet, @@ -112,9 +114,10 @@ func waitHTTPReady() { cancel() if statusOK { log.Infof("HTTP ready: %s", target.String()) - return + return true } time.Sleep(1 * time.Second) } log.Warnf("HTTP not ready after 90s, skipping SSL update this round") + return false } diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index 75dd551..2261ce7 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -86,6 +86,9 @@ func GetSSL() (string, error) { if err != nil { return "", errors.Wrap(err) } + if cfg.UsesProxyHTTPS() { + return config.SSLTypeUser, nil + } if cfg.SSLType == "" { return config.SSLTypeAutoHTTP, nil } @@ -104,6 +107,9 @@ func SetSSL(sslType, priKey, crtKey string) error { } else { return errors.New("SSL 类型错误") } + if cfg.UsesProxyHTTPS() && cfg.SSLType != config.SSLTypeUser { + return newUserCertificateError("HTTPS 反代模式由反向代理管理证书,Smail 不自动申请或续期;邮件服务请导入外部维护的证书") + } if cfg.SSLType != config.SSLTypeUser { clearLegacySSLPaths(cfg) @@ -138,8 +144,12 @@ func SetSSL(sslType, priKey, crtKey string) error { return normalizeUserCertificateImportError(err) } applyManagedSSLReference(cfg, reference) - // 手动设置证书的情况下后台地址默认不启用https - cfg.HttpsEnabled = 1 + // 导入邮件服务证书不能重新启用反代模式下的内置 HTTPS。 + if cfg.UsesProxyHTTPS() { + cfg.HttpsEnabled = 2 + } else { + cfg.HttpsEnabled = 1 + } } err = config.WriteSSLConfig(&expected, cfg) @@ -523,6 +533,12 @@ func GenSSL(update bool) error { if err != nil { return errors.Wrap(err) } + if cfg.UsesProxyHTTPS() { + return newUserCertificateError("HTTPS 反代模式由反向代理管理证书,Smail 不自动申请或续期;邮件服务请导入外部维护的证书") + } + if !cfg.AutomaticSSLEnabled() { + return newUserCertificateError("当前证书由外部维护,不能自动申请或续期") + } if err := validateWildcardConfiguration(cfg); err != nil { return err } @@ -586,7 +602,7 @@ func CheckSSLCrtInfo() (int, time.Time, bool, error) { func Update(needRestart bool) { cfg := config.CurrentConfig() - if cfg != nil && cfg.IsSetupReady() && (cfg.SSLType == config.SSLTypeAutoHTTP || cfg.SSLType == config.SSLTypeAutoDNS) { + if cfg.AutomaticSSLEnabled() && cfg.IsSetupReady() { days, _, nameMatchFail, err := CheckSSLCrtInfo() if days < 30 || err != nil || nameMatchFail { diff --git a/docs/HTTPS反向代理部署说明.md b/docs/HTTPS反向代理部署说明.md index eeeb602..dc3cc92 100644 --- a/docs/HTTPS反向代理部署说明.md +++ b/docs/HTTPS反向代理部署说明.md @@ -30,7 +30,7 @@ 配置含义: -- `tlsMode: proxy`:启用反向代理 HTTPS 终止模式。 +- `tlsMode: proxy`:启用反向代理 HTTPS 终止模式,同时停止 Smail 的自动证书申请和续期。 - `httpsEnabled: false`:Smail 不启动内部 HTTPS。 - `httpsPort: 0`:避免遗留 HTTPS 端口配置造成误解或端口冲突。 - `trustedProxyCidrs`:只允许这些来源提交 `X-Forwarded-Proto`。Nginx 与 Smail 同机时,默认使用回环地址即可。 @@ -38,6 +38,14 @@ 修改配置后需要重启 Smail,使监听模式生效。 +## 证书申请与续期职责 + +- Web HTTPS 的证书申请、续期和加载由反代服务器上的证书管理工具负责,Smail 不再发起 ACME 请求,也不需要把 ACME 验证路径转发给 Smail。 +- 反代模式下,初始化 SSL 步骤只提供外部证书导入,不再提供自动 HTTP 或 DNS 验证。仅切换反代模式不会删除已有证书或自动模式配置;切回直接 HTTPS 时,仍保留自动类型的证书可继续由 Smail 维护。若已主动导入外部证书,则仍由外部维护,不会自动恢复签发。 +- HTTP 反代不等于邮件协议代理。SMTP、IMAP、POP3 仍使用 Smail 本地受管证书,必须保留有效证书和匹配私钥,不能改成明文认证。 +- 外部工具续期后,需要将邮件服务使用的新证书和私钥重新导入 Smail 受管存储、应用引用并重启相关服务。导入是受验证的副本,不会自动跟随反代服务器上的源文件变化。 +- 仅仅在 Nginx 上更换证书,不会更新 Smail 的邮件服务证书。本次调整没有增加跨服务器证书同步或自动重新导入功能。 + ## Nginx 配置示例 下面示例将 Nginx 的 15184 本机代理端口替换为实际 Smail HTTP 端口。 @@ -49,11 +57,9 @@ server { server_name xuans.com; location /.well-known/acme-challenge/ { - proxy_pass http://127.0.0.1:15184; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto http; + # 由反代服务器上的 ACME 客户端写入此目录,不转发到 Smail。 + root /var/www/acme; + try_files $uri =404; } location / { @@ -82,6 +88,8 @@ server { } ``` +上面的 ACME 目录仅适用于反代服务器使用 HTTP 验证的情况,需与外部 ACME 客户端的网页根目录一致;使用 DNS 验证时无需该路径。 + 如果 Nginx 配置使用了 `$connection_upgrade`,请在 `http` 配置块中增加: ```nginx @@ -122,4 +130,6 @@ Nginx 对公网提供 Web 服务时通常开放 `80/tcp` 和 `443/tcp`。Smail - [ ] 登录请求的 `Origin` 为 `https://xuans.com` 时可以通过校验。 - [ ] 非可信来源伪造 `X-Forwarded-Proto` 不会被信任。 - [ ] 浏览器收到的会话 Cookie 仍带有 `Secure` 和 `HttpOnly` 属性。 -- [ ] 80 端口的 ACME challenge 可以正常转发,其他 HTTP 路径跳转到 HTTPS。 +- [ ] 证书续期由反代服务器完成,Smail 不再发出 ACME 申请或续期请求。 +- [ ] 使用 HTTP 验证时,ACME 路径由反代服务器提供,其他 HTTP 路径跳转到 HTTPS。 +- [ ] 本地邮件服务证书保持有效;外部续期后已重新导入、应用并重启相关服务。 diff --git a/frontend/src/views/Setup/Setup.js b/frontend/src/views/Setup/Setup.js index 798880d..9b70f78 100644 --- a/frontend/src/views/Setup/Setup.js +++ b/frontend/src/views/Setup/Setup.js @@ -125,6 +125,7 @@ export function usePage() { const port = ref(80) const sslRuntime = ref({}) + const proxyHTTPS = computed(() => sslRuntime.value.tlsMode === 'proxy') const addDomain = () => { domainSettings.multi_domain.push([]) @@ -350,7 +351,7 @@ export function usePage() { const getSSLConfig = () => { return setupRequest({"action": "get", "step": "ssl"}).then((res) => { assertSetupSuccess(res) - sslSettings.type = res.data.type + sslSettings.type = res.data.tlsMode === 'proxy' ? '1' : res.data.type if (sslSettings.type === "2") { sslSettings.type = "0" sslSettings.challenge = "dns" @@ -370,7 +371,7 @@ export function usePage() { const setSSLConfig = () => { fullscreenLoading.value = true; - let sslType = sslSettings.type; + let sslType = proxyHTTPS.value ? '1' : sslSettings.type; if (sslType === "0" && sslSettings.challenge === "dns") { sslType = "2" } @@ -610,6 +611,9 @@ export function usePage() { } const handleSSLTypeChange = () => { + if (proxyHTTPS.value) { + sslSettings.type = '1' + } if (dnsChecking.value) { resetDNSChallenge() } @@ -619,7 +623,7 @@ export function usePage() { } const handleSSLChallengeChange = (challenge) => { - if (fullscreenLoading.value) { + if (fullscreenLoading.value || proxyHTTPS.value) { return } @@ -722,6 +726,7 @@ export function usePage() { dnsChecking, dnsChallengeNotice, port, + proxyHTTPS, dnsInfos, hasDNSIPv6, addDomain, diff --git a/frontend/src/views/Setup/Setup.vue b/frontend/src/views/Setup/Setup.vue index 8ba49b3..2d3e2e4 100644 --- a/frontend/src/views/Setup/Setup.vue +++ b/frontend/src/views/Setup/Setup.vue @@ -278,7 +278,7 @@
+ HTTPS 证书的申请和续期由反向代理负责,Smail 不再自动处理。SMTP、IMAP、POP3 仍需本地证书,请导入由外部维护的证书与私钥;外部续期后也需重新导入并应用。 +
+ 当前由反向代理提供 HTTPS,Smail 不自动申请或续期 SSL 证书。下方配置仅用于本地邮件服务证书;外部续期后请重新导入并应用,不能仅更新反代服务器上的证书。 +
+