This commit is contained in:
parent
7ffb1e751d
commit
369f502c10
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
13
backend/config/ssl_policy.go
Normal file
13
backend/config/ssl_policy.go
Normal 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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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。
|
||||
- [ ] 本地邮件服务证书保持有效;外部续期后已重新导入、应用并重启相关服务。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 网段,不要无条件信任所有来源。' })
|
||||
|
||||
@ -42,6 +42,7 @@ export function usePage(pageKey = 'base') {
|
||||
const auditError = ref('')
|
||||
const status = reactive({
|
||||
setupState: '',
|
||||
tlsMode: '',
|
||||
activeGeneration: 0,
|
||||
lastValidAt: '',
|
||||
pendingRestart: [],
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
|
||||
104
test/frontend/setup-ssl-proxy.test.mjs
Normal file
104
test/frontend/setup-ssl-proxy.test.mjs
Normal 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/)
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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())
|
||||
|
||||
52
test/review/cron_ssl_test.go
Normal file
52
test/review/cron_ssl_test.go
Normal 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 就绪探测未恢复")
|
||||
}
|
||||
}
|
||||
58
test/review/proxy_controller_test.go
Normal file
58
test/review/proxy_controller_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
163
test/review/ssl_proxy_test.go
Normal file
163
test/review/ssl_proxy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user