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 @@

{{ lang.setSSL }}

-
+

+ HTTPS 证书的申请和续期由反向代理负责,Smail 不再自动处理。SMTP、IMAP、POP3 仍需本地证书,请导入由外部维护的证书与私钥;外部续期后也需重新导入并应用。 +

- - + - +
@@ -311,7 +314,7 @@
-
+
{{ lang.ssl_auto_storage_title }}
{{ lang.ssl_auto_storage_desc }}
@@ -413,6 +416,7 @@ const { sslSettings, dnsChecking, port, + proxyHTTPS, dnsChallengeNotice, waitDesc, next, diff --git a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue index 78e8cf7..975594f 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue @@ -83,6 +83,10 @@
+

+ 当前由反向代理提供 HTTPS,Smail 不自动申请或续期 SSL 证书。下方配置仅用于本地邮件服务证书;外部续期后请重新导入并应用,不能仅更新反代服务器上的证书。 +

+ config.http?.tlsMode !== 'proxy' }), port('http.httpsPort', 'HTTPS 端口', { show: config => config.http?.tlsMode !== 'proxy', hint: '启用内置 HTTPS 时需填写有效端口;关闭时 0 表示不使用。' }), field('http.trustedProxyCidrs', '可信代理网段', 'list', { placeholder: '127.0.0.1/32', hint: '仅填写实际代理的 CIDR 网段,不要无条件信任所有来源。' }) diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index ace4de8..c2efb35 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -42,6 +42,7 @@ export function usePage(pageKey = 'base') { const auditError = ref('') const status = reactive({ setupState: '', + tlsMode: '', activeGeneration: 0, lastValidAt: '', pendingRestart: [], diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index abd3e54..e135694 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -49,6 +49,9 @@ test('单字段更新完整保留内部数据,锁定字段和污染路径无 }) test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正确联动', () => { + const modeField = getFieldGroups('base', {}).flatMap(group => group.fields).find(field => field.path.join('.') === 'http.tlsMode') + assert.match(modeField.hint, /不自动申请或续期 SSL 证书/) + assert.match(modeField.hint, /邮件协议仍需有效的本地证书/) let text = JSON.stringify({ http: { tlsMode: 'direct', httpsEnabled: true, httpsPort: 443 } }) text = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'proxy') assert.deepEqual(readFormConfig(text).http, { tlsMode: 'proxy', httpsEnabled: false, httpsPort: 0 }) diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index b55d75e..b58c6e2 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -35,6 +35,20 @@ function deferred() { return { promise, resolve, reject } } +test('证书页从状态接口读取生效反代模式,不额外加载基础配置', async () => { + const requests = [] + const { page } = fixture(async path => { + requests.push(path) + return { errorNo: 0, data: path.endsWith('/status') ? { available: true, tlsMode: 'proxy', activeGeneration: 2 } : { tls: { mode: 'managed_upload' } } } + }, 'ssl') + await page.loadAll() + assert.equal(page.status.tlsMode, 'proxy') + assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/ssl']) + const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8') + assert.match(template, /v-if="selectedSection === 'ssl' && status.tlsMode === 'proxy'"/) + assert.match(template, /Smail 不自动申请或续期 SSL 证书/) +}) + for (const failedRequest of ['/status', '/pending']) { test(`候选保存成功后 ${failedRequest} 读取失败不能重新保存,刷新后可恢复或取消`, async () => { let pending = null diff --git a/test/frontend/setup-ssl-proxy.test.mjs b/test/frontend/setup-ssl-proxy.test.mjs new file mode 100644 index 0000000..555df95 --- /dev/null +++ b/test/frontend/setup-ssl-proxy.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' +import { createRequire } from 'node:module' +import { computed, reactive, ref } from './vue.mjs' + +const root = new URL('../../frontend/src/views/Setup/', import.meta.url) +const source = readFileSync(new URL('Setup.js', root), 'utf8') +const createPage = compileFunction( + source.replace(/^import .*$/gm, '').replaceAll('import.meta.env.DEV', 'false') + .replace('export function usePage', 'function usePage') + '\nreturn usePage()', + ['computed', 'onUnmounted', 'reactive', 'ref', 'ElMessage', 'lang', 'axios', 'Plus', 'http', 'buildSetupFinalURL', 'probeService', 'window'] +) + +function fixture(tlsMode, type = '2') { + const requests = [] + const messages = [] + const window = { location: { href: 'https://mail.example.test/setup' } } + const page = createPage(computed, () => {}, reactive, ref, + { error: message => messages.push(message) }, {}, {}, {}, { + post: async (path, payload) => { + requests.push({ path, payload }) + return { errorNo: 0, data: { tlsMode, type, port: 8080 } } + } + }, () => 'https://mail.example.test/', () => assert.fail('外部证书不应探测签发状态'), window) + return { page, requests, messages, window } +} + +test('反代初始化强制导入外部证书,即使旧配置仍是自动 DNS 类型', async () => { + const { page, requests, messages, window } = fixture('proxy') + await page.getSSLConfig() + assert.equal(page.proxyHTTPS.value, true) + assert.equal(page.sslSettings.type, '1') + page.sslSettings.type = '0' + page.handleSSLTypeChange() + assert.equal(page.sslSettings.type, '1') + await page.handleSSLChallengeChange('dns') + assert.equal(requests.length, 1) + assert.equal(page.dnsChecking.value, false) + // 绕过控件强行修改内存也不能让提交回到自动签发分支。 + page.sslSettings.type = '0' + page.sslSettings.challenge = 'dns' + page.sslSettings.key_path = '/external/key.pem' + page.sslSettings.crt_path = '/external/cert.pem' + await page.setSSLConfig() + assert.deepEqual(requests.at(-1), { + path: '/api/v1/setup', payload: { + action: 'set', step: 'ssl', ssl_type: '1', + key_path: '/external/key.pem', crt_path: '/external/cert.pem' + } + }) + assert.equal(requests.length, 2) + assert.equal(window.location.href, 'https://mail.example.test/') + assert.deepEqual(messages, []) +}) + +test('直接 HTTPS 初始化保留自动 HTTP 和 DNS 选项', async () => { + for (const type of ['0', '2']) { + const { page, messages } = fixture('direct', type) + await page.getSSLConfig() + assert.equal(page.proxyHTTPS.value, false) + assert.equal(page.sslSettings.type, '0') + assert.equal(page.sslSettings.challenge, type === '2' ? 'dns' : 'http') + assert.deepEqual(messages, []) + } +}) + +test('初始化模板能编译,实际渲染按反代策略隐藏自动选项并保留导入路径', async () => { + const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) + const { parse, compileScript } = require('@vue/compiler-sfc') + const { createSSRApp, compile, h } = require('vue') + const { renderToString } = require('vue/server-renderer') + const { descriptor, errors } = parse(readFileSync(new URL('Setup.vue', root), 'utf8')) + assert.deepEqual(errors, []) + assert.doesNotThrow(() => compileScript(descriptor, { id: 'setup-proxy', inlineTemplate: true })) + const render = compile(descriptor.template.content) + for (const tlsMode of ['proxy', 'direct']) { + const { page } = fixture(tlsMode, '0') + await page.getSSLConfig() + page.active.value = 5 + page.lang.ssl_auto = '自动申请证书' + page.lang.ssl_manuallyf = '导入外部证书' + const app = createSSRApp({ setup: () => page, render }) + for (const name of new Set(descriptor.template.content.match(/\bel-[a-z-]+/g))) { + app.component(name, { + props: ['label', 'value'], + setup: (props, { slots }) => () => h('div', { 'data-label': props.label, 'data-value': props.value }, slots.default?.()) + }) + } + app.directive('loading', { getSSRProps: () => ({}) }) + const html = await renderToString(app) + assert.match(html, /data-label="导入外部证书"/) + if (tlsMode === 'proxy') { + assert.doesNotMatch(html, /data-label="自动申请证书"|ssl-challenge-select-row|ssl-storage-card|ssl-port-warning/) + assert.match(html, /外部续期后也需重新导入并应用/) + assert.match(html, /modelvalue="\.\/config\/ssl\/private\.key"/) + assert.match(html, /modelvalue="\.\/config\/ssl\/public\.crt"/) + } else { + assert.match(html, /data-label="自动申请证书"/) + assert.match(html, /ssl-challenge-select-row|ssl-storage-card/) + } + } +}) diff --git a/test/review/config_findings_test.go b/test/review/config_findings_test.go index b3426c3..fb5ba3b 100644 --- a/test/review/config_findings_test.go +++ b/test/review/config_findings_test.go @@ -13,6 +13,91 @@ import ( _ "modernc.org/sqlite" ) +func TestAuditAutomaticSSLPolicy(t *testing.T) { + var empty *Config + if empty.UsesProxyHTTPS() || empty.AutomaticSSLEnabled() { + t.Fatal("空配置不能启用自动证书") + } + for _, mode := range []string{"", HTTPSTLSModeDirect, HTTPSTLSModeProxy, " PROXY "} { + for _, sslType := range []string{"", SSLTypeUser, SSLTypeAutoHTTP, SSLTypeAutoDNS} { + cfg := &Config{TLSMode: mode, SSLType: sslType} + want := (mode == "" || mode == HTTPSTLSModeDirect) && (sslType == SSLTypeAutoHTTP || sslType == SSLTypeAutoDNS) + if cfg.AutomaticSSLEnabled() != want { + t.Fatalf("自动证书策略错误: mode=%q type=%q", mode, sslType) + } + } + } +} + +func TestAuditProxySwitchRejectsStaleSSLCommit(t *testing.T) { + previousRoot, previousSnapshot := ROOT_PATH, CurrentConfig() + ROOT_PATH = t.TempDir() + t.Cleanup(func() { + ROOT_PATH = previousRoot + PublishConfigSnapshot(previousSnapshot) + }) + expected := &Config{TLSMode: HTTPSTLSModeDirect, SSLType: SSLTypeAutoDNS, HttpsEnabled: 2} + if err := WriteConfig(expected); err != nil { + t.Fatal(err) + } + live := *expected + // 只修改 TLSMode,确保拒绝不是由 HTTPS 开关或域名变化引起。 + live.TLSMode = HTTPSTLSModeProxy + if err := WriteConfig(&live); err != nil { + t.Fatal(err) + } + if err := WriteSSLConfig(expected, expected); !errors.Is(err, ErrGenerationConflict) { + t.Fatalf("切换反代后未拒绝旧签发结果: %v", err) + } + stored, err := ReadConfig() + if err != nil || !stored.UsesProxyHTTPS() { + t.Fatal("过期签发改变了反代策略") + } +} + +func TestAuditTLSStatusTracksAppliedPolicy(t *testing.T) { + root := t.TempDir() + runtime := validRuntimeConfig() + runtime.Root.HTTP.TLSMode = HTTPSTLSModeDirect + writeRuntimeFixture(t, root, runtime) + manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}) + if err != nil { + t.Fatal(err) + } + actor := authorizedConfigActor() + for _, mode := range []string{HTTPSTLSModeProxy, HTTPSTLSModeDirect} { + current := manager.Snapshot() + next := current.Root + next.HTTP.TLSMode = mode + next.HTTP.HTTPSEnabled = mode == HTTPSTLSModeDirect + next.HTTP.HTTPSPort = 0 + if next.HTTP.HTTPSEnabled { + next.HTTP.HTTPSPort = 443 + } + next.HTTP.TrustedProxyCIDRs = []string{"127.0.0.1/32"} + payload, err := json.Marshal(next) + if err != nil { + t.Fatal(err) + } + updated, err := manager.Update(context.Background(), UpdateRequest{ + Section: SectionRoot, ExpectedGeneration: current.Generation, Payload: payload, Actor: actor, + Confirmation: StepUpConfirmation{Confirmed: true, Method: "recent_auth"}, + }) + if err != nil { + t.Fatal(err) + } + if manager.Status().TLSMode != current.Root.HTTP.TLSMode { + t.Fatal("未应用的候选不能改变生效模式提示") + } + if _, err := manager.ApplyAuthorized(context.Background(), updated.Generation, actor); err != nil { + t.Fatal(err) + } + if manager.Status().TLSMode != mode { + t.Fatal("状态没有跟随已应用的 HTTPS 模式") + } + } +} + func TestAuditPendingIdentityAndRecovery(t *testing.T) { root := t.TempDir() writeRuntimeFixture(t, root, validRuntimeConfig()) diff --git a/test/review/cron_ssl_test.go b/test/review/cron_ssl_test.go new file mode 100644 index 0000000..ff9a5d3 --- /dev/null +++ b/test/review/cron_ssl_test.go @@ -0,0 +1,52 @@ +package cron_server + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/signal" +) + +type auditHTTPTransport struct { + calls int +} + +func (transport *auditHTTPTransport) RoundTrip(request *http.Request) (*http.Response, error) { + transport.calls++ + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok")), Header: make(http.Header), Request: request}, nil +} + +// 代理检查和就绪探测只使用隔离配置,传输层替身阻止访问真实服务。 +func TestAuditProxyMaintenanceSkipsNetwork(t *testing.T) { + previousRoot, previousSnapshot := config.ROOT_PATH, config.CurrentConfig() + previousTransport, previousExpiry, previousRestart := http.DefaultTransport, expiredTime, signal.RestartChan + config.ROOT_PATH = t.TempDir() + transport := &auditHTTPTransport{} + http.DefaultTransport = transport + signal.RestartChan = make(chan bool, 1) + expiredTime = time.Time{} + t.Cleanup(func() { + config.ROOT_PATH = previousRoot + config.PublishConfigSnapshot(previousSnapshot) + http.DefaultTransport, expiredTime, signal.RestartChan = previousTransport, previousExpiry, previousRestart + }) + for _, sslType := range []string{config.SSLTypeAutoHTTP, config.SSLTypeAutoDNS, config.SSLTypeUser} { + config.PublishConfigSnapshot(&config.Config{TLSMode: config.HTTPSTLSModeProxy, SSLType: sslType, SetupState: config.SetupStateReady}) + if waitHTTPReady() { + t.Fatal("反代模式不应探测 HTTP 挑战服务") + } + maintainSSL() + } + if transport.calls != 0 || len(signal.RestartChan) != 0 { + t.Fatal("反代模式触发了网络请求或无证书重启") + } + // 切回直接 HTTPS 后,每轮读取最新策略,而非沿用启动时的分支。 + config.PublishConfigSnapshot(&config.Config{TLSMode: config.HTTPSTLSModeDirect, SSLType: config.SSLTypeAutoHTTP, SetupState: config.SetupStateReady}) + if !waitHTTPReady() || transport.calls != 1 { + t.Fatal("直接 HTTPS 的 HTTP 就绪探测未恢复") + } +} diff --git a/test/review/proxy_controller_test.go b/test/review/proxy_controller_test.go new file mode 100644 index 0000000..d181c35 --- /dev/null +++ b/test/review/proxy_controller_test.go @@ -0,0 +1,58 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/services/access" + appcontext "git.pcux.cn/xuans/Smail/utils/context" +) + +func TestAuditConfigStatusIncludesTLSMode(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "config"), 0700); err != nil { + t.Fatal(err) + } + legacy := []byte(`{"domain":"mail.example.test","webDomain":"mail.example.test","dbType":"sqlite","dbDSN":"./config/test.db","isInit":true,"httpPort":8080,"httpsEnabled":2,"tlsMode":"proxy","trustedProxyCidrs":["127.0.0.1/32"]}`) + if err := os.WriteFile(filepath.Join(root, "config", "config.json"), legacy, 0600); err != nil { + t.Fatal(err) + } + validation := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true} + if _, err := config.MigrateLegacyConfig(root, validation); err != nil { + t.Fatal(err) + } + manager, err := config.NewConfigManager(root, config.ConfigLoaderOptions{Validation: validation}) + if err != nil { + t.Fatal(err) + } + previous := config.CurrentManager() + config.SetRuntimeManager(manager) + t.Cleanup(func() { config.SetRuntimeManager(previous) }) + for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleUser} { + recorder := httptest.NewRecorder() + ConfigStatus(&appcontext.Context{UserID: 1, Role: role}, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/config/status", nil)) + if role == access.RoleUser { + if recorder.Code != http.StatusForbidden { + t.Fatal("增加证书状态字段不能放宽配置读取权限") + } + continue + } + var payload struct { + ErrorNo int `json:"errorNo"` + Data struct { + TLSMode string `json:"tlsMode"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if recorder.Code != http.StatusOK || payload.ErrorNo != 0 || payload.Data.TLSMode != config.HTTPSTLSModeProxy { + t.Fatalf("状态响应缺少生效反代模式: status=%d error=%d mode=%q", recorder.Code, payload.ErrorNo, payload.Data.TLSMode) + } + } +} diff --git a/test/review/run.mjs b/test/review/run.mjs index 78d20dd..f550a28 100644 --- a/test/review/run.mjs +++ b/test/review/run.mjs @@ -15,6 +15,8 @@ const replacements = { 'backend/utils/address/audit_findings_test.go': 'test/review/address_findings_test.go', 'backend/services/totp/audit_findings_test.go': 'test/review/totp_findings_test.go', 'backend/services/setup/ssl/audit_findings_test.go': 'test/review/ssl_findings_test.go', + 'backend/services/setup/ssl/audit_proxy_test.go': 'test/review/ssl_proxy_test.go', + 'backend/listen/cron_server/audit_ssl_test.go': 'test/review/cron_ssl_test.go', 'backend/services/setup/audit_findings_test.go': 'test/review/setup_findings_test.go', 'backend/config/audit_findings_test.go': 'test/review/config_findings_test.go', 'backend/services/dbmigration/audit_findings_test.go': 'test/review/dbmigration_findings_test.go', @@ -27,6 +29,7 @@ const replacements = { 'backend/listen/imap_server/audit_wire_findings_test.go': 'test/review/imap_wire_findings_test.go', 'backend/controllers/audit_findings_test.go': 'test/review/runtime_findings_test.go', 'backend/controllers/audit_controller_findings_test.go': 'test/review/controller_findings_test.go', + 'backend/controllers/audit_proxy_test.go': 'test/review/proxy_controller_test.go', 'backend/internal/gopop/audit_findings_test.go': 'test/review/pop3_parser_findings_test.go', 'backend/listen/pop3_server/audit_findings_test.go': 'test/review/pop3_action_findings_test.go', 'backend/dto/parsemail/audit_findings_test.go': 'test/review/message_findings_test.go', @@ -51,10 +54,11 @@ const totpOnly = process.argv.includes('--totp-only') const frameworkOnly = process.argv.includes('--framework-only') const asyncOnly = process.argv.includes('--async-only') const addressOnly = process.argv.includes('--address-only') -const packages = frameworkOnly ? ['./hooks/framework'] : asyncOnly ? ['./utils/async'] : addressOnly ? ['./utils/address'] : totpOnly ? ['./services/totp'] : sslOnly ? ['./services/setup/ssl'] : setupOnly ? ['./services/setup'] : httpOnly ? ['./listen/http_server'] : controllersOnly ? ['./controllers'] : configOnly ? ['./config'] : migrationOnly ? ['./services/dbmigration'] : pop3Only ? ['./internal/gopop', './listen/pop3_server'] : messageOnly ? [ +const packages = frameworkOnly ? ['./hooks/framework'] : asyncOnly ? ['./utils/async'] : addressOnly ? ['./utils/address'] : totpOnly ? ['./services/totp'] : sslOnly ? ['./services/setup/ssl', './listen/cron_server'] : setupOnly ? ['./services/setup'] : httpOnly ? ['./listen/http_server'] : controllersOnly ? ['./controllers'] : configOnly ? ['./config'] : migrationOnly ? ['./services/dbmigration'] : pop3Only ? ['./internal/gopop', './listen/pop3_server'] : messageOnly ? [ './dto/parsemail', './listen/smtp_server' ] : [ './res_init', + './listen/cron_server', './hooks/wechat_push', './services/dbmigration', './listen/http_server', './services/list', './services/recovery', './listen/imap_server', './controllers', './internal/gopop', './listen/pop3_server', diff --git a/test/review/ssl_proxy_test.go b/test/review/ssl_proxy_test.go new file mode 100644 index 0000000..6447b71 --- /dev/null +++ b/test/review/ssl_proxy_test.go @@ -0,0 +1,163 @@ +package ssl + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/signal" +) + +type proxyBlockedTransport struct { + calls int +} + +func (transport *proxyBlockedTransport) RoundTrip(*http.Request) (*http.Response, error) { + transport.calls++ + return nil, errors.New("测试禁止外部网络请求") +} + +// 旧版及分区配置均只在临时目录中测试,禁止连接真实 ACME 服务。 +func TestAuditProxyDisablesAutomaticSSL(t *testing.T) { + for _, sslType := range []string{config.SSLTypeAutoHTTP, config.SSLTypeAutoDNS} { + t.Run(sslType, func(t *testing.T) { + auditSSLRoot(t) + cfg := &config.Config{ + TLSMode: config.HTTPSTLSModeProxy, HttpsEnabled: 2, + SSLType: sslType, SetupState: config.SetupStateReady, + Domain: "example.test", WebDomain: "mail.example.test", + } + if err := config.WriteConfig(cfg); err != nil { + t.Fatal(err) + } + path := filepath.Join(config.ROOT_PATH, "config", "config.json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + transport := &proxyBlockedTransport{} + previousTransport, previousRestart := http.DefaultTransport, signal.RestartChan + http.DefaultTransport = transport + signal.RestartChan = make(chan bool, 1) + t.Cleanup(func() { + http.DefaultTransport, signal.RestartChan = previousTransport, previousRestart + }) + selected, err := GetSSL() + if err != nil || selected != config.SSLTypeUser { + t.Fatalf("反代初始化必须选择外部证书: type=%s err=%v", selected, err) + } + if err := SetSSL(sslType, "", ""); !strings.Contains(UserCertificateErrorMessage(err), "反代") { + t.Fatalf("反代模式未拒绝自动证书设置: %v", err) + } + for _, update := range []bool{false, true} { + if err := GenSSL(update); !strings.Contains(UserCertificateErrorMessage(err), "反代") { + t.Fatalf("反代模式未拒绝自动签发: update=%t err=%v", update, err) + } + } + Update(true) + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("反代模式改变了已有配置") + } + if transport.calls != 0 || len(signal.RestartChan) != 0 { + t.Fatalf("反代模式触发网络请求或重启: requests=%d", transport.calls) + } + if _, err := os.Stat(filepath.Join(config.ROOT_PATH, "config", "ssl", "account_private.pem")); !os.IsNotExist(err) { + t.Fatalf("反代模式不应生成 ACME 账户密钥: %v", err) + } + }) + } +} + +func TestAuditProxyCertificateImportPreservesInternalHTTPSDisabled(t *testing.T) { + auditSSLRoot(t) + cfg := &config.Config{ + TLSMode: config.HTTPSTLSModeProxy, HttpsEnabled: 2, + Domain: "mail.example.com", WebDomain: "mail.example.com", + } + if err := config.WriteConfig(cfg); err != nil { + t.Fatal(err) + } + key, cert := writeTestCertificate(t, t.TempDir(), 2048) + if err := SetSSL(config.SSLTypeUser, key, cert); err != nil { + t.Fatal(err) + } + stored, err := config.ReadConfig() + if err != nil { + t.Fatal(err) + } + if stored.HttpsEnabled != 2 || !stored.UsesProxyHTTPS() || stored.SSLType != config.SSLTypeUser { + t.Fatal("导入邮件证书改变了反代 HTTPS 策略") + } + if _, err := config.LoadRuntimeTLSCertificate(stored); err != nil { + t.Fatalf("反代模式邮件 TLS 证书必须仍可加载: %v", err) + } +} + +func TestAuditPartitionedProxyBlocksIssuance(t *testing.T) { + expected, _ := auditMigratedSSL(t, config.SSLTypeAutoHTTP) + // 保持证书分区不变,只在测试根文件上模拟管理员切换反代。 + path := filepath.Join(config.ROOT_PATH, "config", "config.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // 使用结构化配置读写,避免依赖序列化格式。 + var root config.RootConfig + if err := json.Unmarshal(data, &root); err != nil { + t.Fatal(err) + } + root.HTTP.TLSMode = config.HTTPSTLSModeProxy + root.HTTP.HTTPSEnabled = false + root.HTTP.HTTPSPort = 0 + root.HTTP.TrustedProxyCIDRs = []string{"127.0.0.1/32"} + data, err = json.Marshal(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + loaded, err := config.ReadConfig() + if err != nil { + t.Fatal(err) + } + if loaded.AutomaticSSLEnabled() { + t.Fatal("分区兼容快照仍允许反代自动续期") + } + if err := GenSSL(true); !strings.Contains(UserCertificateErrorMessage(err), "反代") { + t.Fatalf("分区反代未拒绝自动签发: %v", err) + } + resource := auditCertificateChain(t, "imap.example.test", "smtp.imap.example.test", "pop.imap.example.test", "imap.imap.example.test") + if err := writeCertificateFiles(expected, resource.PrivateKey, resource.Certificate, nil); !errors.Is(err, config.ErrGenerationConflict) { + t.Fatalf("分区反代必须拒绝旧签发结果提交: %v", err) + } + // 使用同一临时分区配置,验证转交外部管理后仍可安全导入邮件证书。 + directory := t.TempDir() + key, cert := filepath.Join(directory, "private.pem"), filepath.Join(directory, "certificate.pem") + if err := os.WriteFile(key, resource.PrivateKey, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cert, resource.Certificate, 0600); err != nil { + t.Fatal(err) + } + if err := SetSSL(config.SSLTypeUser, key, cert); err != nil { + t.Fatal(err) + } + stored, err := config.ReadConfig() + if err != nil { + t.Fatal(err) + } + if !stored.UsesProxyHTTPS() || stored.HttpsEnabled != 2 || stored.AutomaticSSLEnabled() { + t.Fatal("分区证书导入改变了反代策略") + } + if _, err := config.LoadRuntimeTLSCertificate(stored); err != nil { + t.Fatalf("分区配置导入后邮件 TLS 证书无法加载: %v", err) + } +}