按证书托管方式统一自动续期策略并补充回归测试
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 15:05:25 +08:00
parent e8d4521965
commit 5f62762d97
9 changed files with 217 additions and 21 deletions

View File

@ -7,7 +7,30 @@ 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)
// EffectiveSSLType 以证书管理责任为准,仅在旧配置未记录管理方式时兼容旧类型。
func (c *Config) EffectiveSSLType() string {
if c == nil || c.UsesProxyHTTPS() {
return SSLTypeUser
}
management := c.SSL.Management
if management == "" && c.SSL.Mode != "" {
management = EffectiveSSLManagement(c.SSL)
}
switch management {
case SSLManagementProject:
if c.SSL.Challenge == SSLChallengeDNS || (c.SSL.Challenge == "" && c.SSLType == SSLTypeAutoDNS) {
return SSLTypeAutoDNS
}
return SSLTypeAutoHTTP
case "":
if c.SSLType == SSLTypeAutoHTTP || c.SSLType == SSLTypeAutoDNS {
return c.SSLType
}
}
return SSLTypeUser
}
// AutomaticSSLEnabled 仅允许项目自托管证书自动签发和续期。
func (c *Config) AutomaticSSLEnabled() bool {
return c.EffectiveSSLType() != SSLTypeUser
}

View File

@ -0,0 +1,36 @@
package config
import "testing"
func TestSSLPolicyFollowsManagement(t *testing.T) {
for _, test := range []struct {
name string
cfg *Config
want string
}{
{name: "nil", want: SSLTypeUser},
{name: "empty", cfg: &Config{}, want: SSLTypeUser},
{name: "legacy_http", cfg: &Config{SSLType: SSLTypeAutoHTTP}, want: SSLTypeAutoHTTP},
{name: "legacy_dns", cfg: &Config{SSLType: SSLTypeAutoDNS}, want: SSLTypeAutoDNS},
{name: "legacy_manual", cfg: &Config{SSLType: SSLTypeUser}, want: SSLTypeUser},
{name: "legacy_upload", cfg: &Config{SSLType: SSLTypeAutoHTTP, SSL: SSLConfig{Mode: ManagedSSLModeUpload}}, want: SSLTypeUser},
{name: "legacy_default", cfg: &Config{SSLType: SSLTypeUser, SSL: SSLConfig{Mode: ManagedSSLModeDefault}}, want: SSLTypeAutoHTTP},
{name: "project_http", cfg: &Config{SSLType: SSLTypeAutoDNS, SSL: SSLConfig{Management: SSLManagementProject, Challenge: SSLChallengeHTTP}}, want: SSLTypeAutoHTTP},
{name: "project_dns", cfg: &Config{SSLType: SSLTypeUser, SSL: SSLConfig{Management: SSLManagementProject, Challenge: SSLChallengeDNS}}, want: SSLTypeAutoDNS},
{name: "project_transition", cfg: &Config{SSLType: SSLTypeUser, SSL: SSLConfig{Management: SSLManagementProject, Mode: ManagedSSLModeUpload}}, want: SSLTypeAutoHTTP},
{name: "project_legacy_dns", cfg: &Config{SSLType: SSLTypeAutoDNS, SSL: SSLConfig{Management: SSLManagementProject}}, want: SSLTypeAutoDNS},
{name: "custom_stale_auto", cfg: &Config{SSLType: SSLTypeAutoHTTP, SSL: SSLConfig{Management: SSLManagementCustom, Mode: ManagedSSLModeDefault}}, want: SSLTypeUser},
{name: "web_server_stale_auto", cfg: &Config{SSLType: SSLTypeAutoDNS, SSL: SSLConfig{Management: SSLManagementWebServer}}, want: SSLTypeUser},
{name: "proxy_project", cfg: &Config{TLSMode: " PROXY ", SSL: SSLConfig{Management: SSLManagementProject}}, want: SSLTypeUser},
{name: "invalid_management", cfg: &Config{SSLType: SSLTypeAutoHTTP, SSL: SSLConfig{Management: "invalid"}}, want: SSLTypeUser},
} {
t.Run(test.name, func(t *testing.T) {
if got := test.cfg.EffectiveSSLType(); got != test.want {
t.Fatalf("证书策略错误:got=%s want=%s", got, test.want)
}
if test.cfg.AutomaticSSLEnabled() != (test.want != SSLTypeUser) {
t.Fatal("自动续期开关与托管策略不一致")
}
})
}
}

View File

@ -0,0 +1,58 @@
package cron_server
import (
"io"
"net/http"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
)
type policyHTTPTransport struct {
calls int
}
func (transport *policyHTTPTransport) 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 TestMaintenanceFollowsManagementSwitch(t *testing.T) {
previousRoot, previousSnapshot := config.ROOT_PATH, config.CurrentConfig()
previousManager, previousTransport := config.CurrentManager(), http.DefaultTransport
config.ROOT_PATH = t.TempDir()
config.SetRuntimeManager(nil)
transport := &policyHTTPTransport{}
http.DefaultTransport = transport
t.Cleanup(func() {
config.ROOT_PATH = previousRoot
config.PublishConfigSnapshot(previousSnapshot)
config.SetRuntimeManager(previousManager)
http.DefaultTransport = previousTransport
})
cfg := &config.Config{
HttpPort: 8000, SSLType: config.SSLTypeAutoHTTP, SetupState: config.SetupStateReady,
SSL: config.SSLConfig{Management: config.SSLManagementProject},
}
config.PublishConfigSnapshot(cfg)
if !waitHTTPReady() || transport.calls != 1 {
t.Fatal("项目托管未启用 HTTP 就绪检查")
}
for _, management := range []string{config.SSLManagementCustom, config.SSLManagementWebServer} {
cfg.SSL.Management = management
config.PublishConfigSnapshot(cfg)
if waitHTTPReady() {
t.Fatal("外部托管仍等待 HTTP 验证")
}
maintainSSL()
if transport.calls != 1 {
t.Fatal("切换为外部托管后仍发起了网络请求")
}
}
cfg.SSL.Management = config.SSLManagementProject
config.PublishConfigSnapshot(cfg)
if !waitHTTPReady() || transport.calls != 2 {
t.Fatal("切回项目托管未恢复 HTTP 就绪检查")
}
}

View File

@ -46,7 +46,7 @@ func maintainSSL() {
sslCheck()
return
}
if cfg.SSLType == config.SSLTypeAutoHTTP && !waitHTTPReady() {
if cfg.EffectiveSSLType() == config.SSLTypeAutoHTTP && !waitHTTPReady() {
return
}
ssl.Update(true)

View File

@ -89,11 +89,11 @@ func GetSSL() (string, error) {
if cfg.UsesProxyHTTPS() {
return config.SSLTypeUser, nil
}
if cfg.SSLType == "" {
if cfg.SSLType == "" && cfg.SSL.Management == "" && cfg.SSL.Mode == "" {
return config.SSLTypeAutoHTTP, nil
}
return cfg.SSLType, nil
return cfg.EffectiveSSLType(), nil
}
func SetSSL(sslType, priKey, crtKey string) error {
@ -173,6 +173,7 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef
return
}
clearLegacySSLPaths(cfg)
sslType := cfg.EffectiveSSLType()
management := cfg.SSL.Management
if reference.Mode == config.ManagedSSLModeDefault {
management = config.SSLManagementProject
@ -188,7 +189,7 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef
}
if reference.Mode == config.ManagedSSLModeUpload {
cfg.SSL.CustomDirectory = "ssl"
} else if cfg.SSLType == config.SSLTypeAutoDNS {
} else if sslType == config.SSLTypeAutoDNS {
cfg.SSL.Challenge = config.SSLChallengeDNS
} else {
cfg.SSL.Challenge = config.SSLChallengeHTTP
@ -330,7 +331,7 @@ func certificateMatchesConfiguredDomains(cert *x509.Certificate, cfg *config.Con
// 自动 DNS 只申请主域泛域名;不覆盖的服务地址是配置错误,不能靠重复续期修复。
func validateWildcardConfiguration(cfg *config.Config) error {
if cfg == nil || cfg.SSLType != config.SSLTypeAutoDNS {
if cfg == nil || cfg.EffectiveSSLType() != config.SSLTypeAutoDNS {
return nil
}
domains := wildcardCertificateDomains(cfg)
@ -370,7 +371,7 @@ func wildcardCertificateDomains(cfg *config.Config) []string {
}
func certificateDomains(cfg *config.Config) []string {
if cfg != nil && cfg.SSLType == config.SSLTypeAutoDNS {
if cfg != nil && cfg.EffectiveSSLType() == config.SSLTypeAutoDNS {
return wildcardCertificateDomains(cfg)
}
return sslDomains(cfg)
@ -393,12 +394,12 @@ func renewCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config) error {
return errors.Wrap(err)
}
if cfg.SSLType == config.SSLTypeAutoHTTP {
if cfg.EffectiveSSLType() == config.SSLTypeAutoHTTP {
err = client.Challenge.SetHTTP01Provider(GetHttpChallengeInstance())
if err != nil {
return errors.Wrap(err)
}
} else if cfg.SSLType == config.SSLTypeAutoDNS {
} else if cfg.EffectiveSSLType() == config.SSLTypeAutoDNS {
err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute))
if err != nil {
return errors.Wrap(err)
@ -456,12 +457,12 @@ func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAc
return errors.Wrap(err)
}
if cfg.SSLType == config.SSLTypeAutoHTTP {
if cfg.EffectiveSSLType() == config.SSLTypeAutoHTTP {
err = client.Challenge.SetHTTP01Provider(GetHttpChallengeInstance())
if err != nil {
return errors.Wrap(err)
}
} else if cfg.SSLType == config.SSLTypeAutoDNS {
} else if cfg.EffectiveSSLType() == config.SSLTypeAutoDNS {
err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute))
if err != nil {
return errors.Wrap(err)

View File

@ -0,0 +1,78 @@
package ssl
import (
"bytes"
"errors"
"net/http"
"os"
"path/filepath"
"testing"
"git.pcux.cn/xuans/Smail/config"
)
type renewalBlockedTransport struct {
calls int
}
func (transport *renewalBlockedTransport) RoundTrip(*http.Request) (*http.Response, error) {
transport.calls++
return nil, errors.New("测试禁止外部网络请求")
}
func TestExternalManagementBlocksAutomaticIssuance(t *testing.T) {
previousRoot, previousInstance := config.ROOT_PATH, config.Instance
previousSnapshot, previousManager := config.CurrentConfig(), config.CurrentManager()
previousTransport := http.DefaultTransport
config.SetRuntimeManager(nil)
t.Cleanup(func() {
config.ROOT_PATH, config.Instance = previousRoot, previousInstance
config.PublishConfigSnapshot(previousSnapshot)
config.SetRuntimeManager(previousManager)
http.DefaultTransport = previousTransport
})
for _, management := range []string{config.SSLManagementCustom, config.SSLManagementWebServer} {
for _, sslType := range []string{config.SSLTypeAutoHTTP, config.SSLTypeAutoDNS} {
t.Run(management+sslType, func(t *testing.T) {
config.ROOT_PATH = t.TempDir()
cfg := &config.Config{
SSLType: sslType, SetupState: config.SetupStateReady,
SSL: config.SSLConfig{Management: management},
}
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 := &renewalBlockedTransport{}
http.DefaultTransport = transport
if selected, err := GetSSL(); err != nil || selected != config.SSLTypeUser {
t.Fatalf("外部托管仍显示自动签发:type=%s err=%v", selected, err)
}
for _, update := range []bool{false, true} {
if err := GenSSL(update); UserCertificateErrorMessage(err) != "当前证书由外部维护,不能自动申请或续期" {
t.Fatalf("外部托管未拒绝签发或续期:%v", err)
}
}
Update(true)
after, err := os.ReadFile(path)
if err != nil || !bytes.Equal(before, after) || transport.calls != 0 {
t.Fatalf("外部托管产生了配置写入或网络请求:calls=%d err=%v", transport.calls, err)
}
})
}
}
}
func TestProjectDNSReferencePreservesChallenge(t *testing.T) {
cfg := &config.Config{SSLType: config.SSLTypeUser, SSL: config.SSLConfig{
Management: config.SSLManagementProject, Challenge: config.SSLChallengeDNS,
}}
applyManagedSSLReference(cfg, config.ManagedSSLReference{Mode: config.ManagedSSLModeDefault})
if cfg.SSL.Challenge != config.SSLChallengeDNS || cfg.EffectiveSSLType() != config.SSLTypeAutoDNS {
t.Fatal("保存签发结果丢失了项目托管的 DNS 验证方式")
}
}

View File

@ -124,14 +124,14 @@ const lang = {
"ssl_auto_http": "HTTP请求",
"ssl_auto_dns": "DNS记录",
"ssl_challenge_type": "验证方式",
"ssl_manuallyf": "手动配置SSL证书",
"ssl_manuallyf": "手动配置SSL证书(不自动续期)",
"challenge_typ_desc": "HTTP验证要求域名可以从公网访问80端口;无法开放80端口时,请选择DNS验证并添加页面生成的TXT记录。",
"wait_desc": "请稍等",
"dns_challenge_wait": "DNS传播和缓存刷新时间较长,此处可能等待10-30分钟",
"ssl_key_path": "SSL 私钥路径",
"ssl_crt_path": "SSL 证书路径",
"ssl_auto_storage_title": "自动证书保存位置",
"ssl_auto_storage_desc": "自动申请成功后,证书由系统统一保存和轮换,不需要手动填写证书路径。",
"ssl_auto_storage_desc": "证书由项目自身托管,启用内置自动续期;到期前 30 天内检查并续期,续期时仍需完成所选验证方式。",
"ssl_auto_certificate_path": "证书:./data/ssl/managed/default/current/public.crt",
"ssl_auto_private_key_path": "私钥:./data/ssl/managed/default/current/private.key",
"ssl_auto_account_key_path": "ACME 账户密钥:./data/ssl/account_private.pem",

View File

@ -169,9 +169,9 @@ const sslGroups = [
wide: true,
fallback: 'custom',
options: options([
['web_server', 'Web 服务器管理'],
['project', '项目自身管理'],
['custom', '自定义']
['web_server', '代理服务器托管(不自动续期)'],
['project', '项目自身托管(自动续期)'],
['custom', '手动配置(不自动续期)']
])
}),
field('tls.challenge', '证书验证方式', 'select', {

View File

@ -343,9 +343,9 @@ test('证书管理方式只展示三种业务模式并同步底层托管配置',
assert.deepEqual(validateFormConfig('ssl', { tls }), {})
const management = getFieldGroups('ssl', { tls }).flatMap(group => group.fields).find(field => field.path.join('.') === 'tls.management')
assert.deepEqual(management.options.map(option => [option.value, option.label]), [
['web_server', 'Web 服务器管理'],
['project', '项目自身管理'],
['custom', '自定义']
['web_server', '代理服务器托管(不自动续期)'],
['project', '项目自身托管(自动续期)'],
['custom', '手动配置(不自动续期)']
])
for (const customDirectory of ['C:/certs', '/etc/ssl', '../certs', 'config/../certs', 'config\\ssl', 'config//ssl']) {
assert.ok(validateFormConfig('ssl', { tls: { ...tls, customDirectory } })['tls.customDirectory'])