反代模式停用自动证书签发续期并完善初始化提示与测试
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-06 13:27:06 +08:00
parent 7ffb1e751d
commit 369f502c10
20 changed files with 599 additions and 56 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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。
- [ ] 本地邮件服务证书保持有效;外部续期后已重新导入、应用并重启相关服务。

View File

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

View File

@ -278,7 +278,7 @@
<div class="desc">
<div class="ssl-title-row">
<h2>{{ lang.setSSL }}</h2>
<div v-if="sslSettings.type === '0' && sslSettings.challenge === 'http' && port !== 80"
<div v-if="!proxyHTTPS && sslSettings.type === '0' && sslSettings.challenge === 'http' && port !== 80"
class="ssl-port-warning" role="alert">
<strong>{{ lang.warning }}</strong>
<span>{{ lang.autoSSLWarn }}</span>
@ -287,16 +287,19 @@
<div class="ssl-title-description">{{ lang.setSSL }}</div>
</div>
<div class="form ssl-form">
<p v-if="proxyHTTPS" class="ssl-manual-desc" role="note">
HTTPS 证书的申请和续期由反向代理负责,Smail 不再自动处理。SMTP、IMAP、POP3 仍需本地证书,请导入由外部维护的证书与私钥;外部续期后也需重新导入并应用。
</p>
<el-form label-position="top">
<el-form-item :label="lang.type">
<el-select class="ssl-type-select" :placeholder="lang.ssl_auto" v-model="sslSettings.type"
<el-select class="ssl-type-select" :placeholder="proxyHTTPS ? lang.ssl_manuallyf : lang.ssl_auto" v-model="sslSettings.type"
:disabled="fullscreenLoading" @change="handleSSLTypeChange">
<el-option :label="lang.ssl_auto" value="0"/>
<el-option v-if="!proxyHTTPS" :label="lang.ssl_auto" value="0"/>
<el-option :label="lang.ssl_manuallyf" value="1"/>
</el-select>
</el-form-item>
<el-form-item :label="lang.ssl_challenge_type" v-if="sslSettings.type === '0'">
<el-form-item :label="lang.ssl_challenge_type" v-if="!proxyHTTPS && sslSettings.type === '0'">
<div class="ssl-challenge-select-row">
<el-select class="ssl-type-select" :placeholder="lang.ssl_challenge_type" v-model="sslSettings.challenge"
:disabled="fullscreenLoading" @change="handleSSLChallengeChange">
@ -311,7 +314,7 @@
</div>
</el-form-item>
<div v-if="sslSettings.type === '0'" class="ssl-storage-card">
<div v-if="!proxyHTTPS && sslSettings.type === '0'" class="ssl-storage-card">
<div class="ssl-storage-title">{{ lang.ssl_auto_storage_title }}</div>
<div class="ssl-storage-desc">{{ lang.ssl_auto_storage_desc }}</div>
<div v-if="sslSettings.challenge === 'dns'" class="ssl-dns-intro">
@ -413,6 +416,7 @@ const {
sslSettings,
dnsChecking,
port,
proxyHTTPS,
dnsChallengeNotice,
waitDesc,
next,

View File

@ -83,6 +83,10 @@
<span class="selected-section-label">{{ currentSection.label }}</span>
</div>
<p v-if="selectedSection === 'ssl' && status.tlsMode === 'proxy'" class="heading-note" role="note">
当前由反向代理提供 HTTPS,Smail 不自动申请或续期 SSL 证书。下方配置仅用于本地邮件服务证书;外部续期后请重新导入并应用,不能仅更新反代服务器上的证书。
</p>
<el-alert
v-if="sectionError"
:title="sectionError"

View File

@ -51,7 +51,7 @@ const baseGroups = [
]),
group('http', '网页访问服务', '端口和加密方式变更可能需要重启,并影响当前访问地址。', [
port('http.port', 'HTTP 端口', { hint: '填写 0 使用后端默认值。' }),
field('http.tlsMode', '加密连接方式', 'select', { options: options([['', '使用默认方式'], ['direct', '由 Smail 提供 HTTPS'], ['proxy', '由反向代理提供 HTTPS']]) }),
field('http.tlsMode', '加密连接方式', 'select', { options: options([['', '使用默认方式'], ['direct', '由 Smail 提供 HTTPS'], ['proxy', '由反向代理提供 HTTPS']]), hint: '选择反向代理后,Smail 不自动申请或续期 SSL 证书,由反代服务器负责;邮件协议仍需有效的本地证书。' }),
field('http.httpsEnabled', '启用内置 HTTPS', 'switch', { show: config => 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 网段,不要无条件信任所有来源。' })

View File

@ -42,6 +42,7 @@ export function usePage(pageKey = 'base') {
const auditError = ref('')
const status = reactive({
setupState: '',
tlsMode: '',
activeGeneration: 0,
lastValidAt: '',
pendingRestart: [],

View File

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

View File

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

View File

@ -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/)
}
}
})

View File

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

View File

@ -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 就绪探测未恢复")
}
}

View File

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

View File

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

View File

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