优化SSL证书三种管理模式
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-07 15:49:25 +08:00
parent cfe4401b70
commit 6ad6f449ca
10 changed files with 157 additions and 15 deletions

View File

@ -736,7 +736,7 @@ func legacySMTPConfig(root string, vault *credential.Vault, legacy legacyMigrati
func legacySSLConfig(legacy legacyMigrationConfig) (SSLConfig, []string) {
certificateRef, certificateOK := legacyControlledReference(legacy.SSLPublicKeyPath)
privateKeyRef, privateKeyOK := legacyControlledReference(legacy.SSLPrivateKeyPath)
ssl := SSLConfig{Mode: ManagedSSLModeDefault, DefaultDirectory: "config/ssl"}
ssl := SSLConfig{Management: SSLManagementProject, Mode: ManagedSSLModeDefault, DefaultDirectory: "config/ssl"}
switch legacy.SSLType {
case SSLTypeAutoDNS:
ssl.Challenge = SSLChallengeDNS
@ -744,6 +744,10 @@ func legacySSLConfig(legacy legacyMigrationConfig) (SSLConfig, []string) {
ssl.Challenge = SSLChallengeHTTP
}
if legacy.SSLType == SSLTypeUser {
ssl.Management = SSLManagementCustom
if strings.EqualFold(strings.TrimSpace(legacy.TLSMode), HTTPSTLSModeProxy) {
ssl.Management = SSLManagementWebServer
}
ssl.Mode = ManagedSSLModeUpload
ssl.CustomDirectory = "config/ssl"
}

View File

@ -104,6 +104,7 @@ type EmailConfig struct {
}
type SSLConfig struct {
Management string `json:"management,omitempty"`
Mode string `json:"mode"`
DefaultDirectory string `json:"defaultDirectory"`
CustomDirectory string `json:"customDirectory,omitempty"`

View File

@ -78,7 +78,7 @@ func runtimeCompatibility(runtime RuntimeConfig) (*Config, error) {
}
}
tls := runtime.SSL.TLS
if tls.Mode == ManagedSSLModeDefault {
if EffectiveSSLManagement(tls) == SSLManagementProject {
if tls.Challenge == SSLChallengeDNS {
cfg.SSLType = SSLTypeAutoDNS
} else {

View File

@ -0,0 +1,58 @@
package config
import "testing"
func TestEffectiveSSLManagementSupportsThreeBusinessModes(t *testing.T) {
for _, test := range []struct {
name string
ssl SSLConfig
want string
}{
{name: "web_server", ssl: SSLConfig{Management: SSLManagementWebServer, Mode: ManagedSSLModeUpload}, want: SSLManagementWebServer},
{name: "project", ssl: SSLConfig{Management: SSLManagementProject, Mode: ManagedSSLModeDefault}, want: SSLManagementProject},
{name: "custom", ssl: SSLConfig{Management: SSLManagementCustom, Mode: ManagedSSLModeUpload}, want: SSLManagementCustom},
{name: "legacy_project", ssl: SSLConfig{Mode: "default"}, want: SSLManagementProject},
{name: "legacy_custom", ssl: SSLConfig{Mode: "custom"}, want: SSLManagementCustom},
} {
t.Run(test.name, func(t *testing.T) {
if got := EffectiveSSLManagement(test.ssl); got != test.want {
t.Fatalf("management = %q, want %q", got, test.want)
}
})
}
}
func TestValidateSSLManagementMatchesStorageMode(t *testing.T) {
valid := SSLConfig{
Management: SSLManagementProject,
Mode: ManagedSSLModeDefault,
DefaultDirectory: "config/ssl",
Challenge: SSLChallengeHTTP,
}
if err := validateSSL(valid); err != nil {
t.Fatalf("project managed SSL was rejected: %v", err)
}
valid.Management = SSLManagementWebServer
valid.Mode = ManagedSSLModeUpload
valid.CustomDirectory = "config/ssl"
if err := validateSSL(valid); err != nil {
t.Fatalf("web server managed SSL was rejected: %v", err)
}
valid.Management = SSLManagementCustom
if err := validateSSL(valid); err != nil {
t.Fatalf("custom managed SSL was rejected: %v", err)
}
// 管理责任切换时继续使用当前证书,待项目签发成功后再切换底层存储引用。
valid.Management = SSLManagementProject
if err := validateSSL(valid); err != nil {
t.Fatalf("project management rejected the existing certificate during transition: %v", err)
}
valid.Management = "unsupported"
if err := validateSSL(valid); err == nil {
t.Fatal("unsupported SSL management was accepted")
}
legacy := SSLConfig{Mode: "default", DefaultDirectory: "config/ssl"}
if err := validateSSL(legacy); err != nil {
t.Fatalf("legacy SSL configuration without management was rejected: %v", err)
}
}

View File

@ -19,13 +19,33 @@ import (
)
const (
ManagedSSLModeDefault = "managed_default"
ManagedSSLModeUpload = "managed_upload"
ManagedSSLModeDefault = "managed_default"
ManagedSSLModeUpload = "managed_upload"
SSLManagementWebServer = "web_server"
SSLManagementProject = "project"
SSLManagementCustom = "custom"
managedSSLRelativeRoot = "config/ssl/managed"
maxManagedSSLFileSize = 1 << 20
)
// EffectiveSSLManagement 返回面向用户的证书管理方式,并兼容旧配置中的底层存储模式。
func EffectiveSSLManagement(ssl SSLConfig) string {
switch ssl.Management {
case SSLManagementWebServer, SSLManagementProject, SSLManagementCustom:
return ssl.Management
}
if ssl.Mode == ManagedSSLModeDefault || ssl.Mode == "default" {
return SSLManagementProject
}
return SSLManagementCustom
}
// ValidSSLManagement 判断证书管理责任是否为当前支持的三种业务模式。
func ValidSSLManagement(management string) bool {
return management == SSLManagementWebServer || management == SSLManagementProject || management == SSLManagementCustom
}
var (
ErrInvalidSSLMode = errors.New("invalid SSL managed mode")
ErrInvalidSSLMaterial = errors.New("invalid SSL certificate material")

View File

@ -171,6 +171,9 @@ func validateSSL(ssl SSLConfig) error {
if !isControlledRelativePath(ssl.DefaultDirectory) {
return ErrInvalidSSL
}
if ssl.Management != "" && !ValidSSLManagement(ssl.Management) {
return ErrInvalidSSL
}
if ssl.Challenge != "" && ssl.Challenge != SSLChallengeHTTP && ssl.Challenge != SSLChallengeDNS {
return ErrInvalidSSL
}

View File

@ -491,7 +491,9 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
value.POP3 = config.POP3ListenerConfig{Port: ports.POP3, TLSPort: ports.POP3TLS}
return value, nil
case config.SectionSSL:
return runtime.SSL, nil
value := runtime.SSL
value.TLS.Management = config.EffectiveSSLManagement(value.TLS)
return value, nil
case config.SectionPlugins:
plugins := make(map[string]any, len(runtime.Plugins.Plugins))
for id, item := range runtime.Plugins.Plugins {

View File

@ -114,6 +114,7 @@ func SetSSL(sslType, priKey, crtKey string) error {
if cfg.SSLType != config.SSLTypeUser {
clearLegacySSLPaths(cfg)
cfg.SSL = config.SSLConfig{
Management: config.SSLManagementProject,
Mode: config.ManagedSSLModeDefault,
DefaultDirectory: "config/ssl",
}
@ -127,6 +128,11 @@ func SetSSL(sslType, priKey, crtKey string) error {
}
}
if cfg.SSLType == config.SSLTypeUser {
if cfg.UsesProxyHTTPS() {
cfg.SSL.Management = config.SSLManagementWebServer
} else {
cfg.SSL.Management = config.SSLManagementCustom
}
privatePath, publicPath, err := validateUserCertificatePaths(priKey, crtKey)
if err != nil {
return err
@ -175,7 +181,14 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef
return
}
clearLegacySSLPaths(cfg)
management := cfg.SSL.Management
if reference.Mode == config.ManagedSSLModeDefault {
management = config.SSLManagementProject
} else if !config.ValidSSLManagement(management) || management == config.SSLManagementProject {
management = config.SSLManagementCustom
}
cfg.SSL = config.SSLConfig{
Management: management,
Mode: reference.Mode,
DefaultDirectory: "config/ssl",
CertificateRef: reference.CertificateRef,

View File

@ -67,6 +67,13 @@ const localSMTPHost = value => {
const networkDatabase = config => config.database?.type !== 'sqlite'
const uploadedCertificate = config => ['managed_upload', 'custom'].includes(config.tls?.mode)
const certificateManagement = config => {
const management = config.tls?.management
if (['web_server', 'project', 'custom'].includes(management)) return management
return ['managed_default', 'default'].includes(config.tls?.mode) ? 'project' : 'custom'
}
const projectManagedCertificate = config => certificateManagement(config) === 'project'
const externalManagedCertificate = config => !projectManagedCertificate(config)
const normalizedDomain = value => value.toLowerCase().replace(/\.$/, '')
const validDomain = value => {
if (typeof value !== 'string' || value !== value.trim()) return false
@ -151,11 +158,27 @@ const securityPolicyGroups = [
const sslGroups = [
group('certificate', '证书与存储', '', [
field('tls.mode', '证书来源', 'select', { options: options([['managed_default', '系统管理证书'], ['managed_upload', '已导入的自定义证书'], ['default', '默认目录(兼容模式)'], ['custom', '自定义目录(兼容模式)']]) }),
field('tls.defaultDirectory', '默认证书目录', 'text', { required: true, placeholder: 'config/ssl' }),
field('tls.customDirectory', '自定义证书目录', 'text', { required: true, show: uploadedCertificate, placeholder: 'config/ssl/example.com' }),
field('tls.certificateRef', '证书文件引用', 'text', { placeholder: 'config/ssl/example.com/fullchain.pem', hint: '必须位于当前证书目录内,与私钥成对填写。' }),
field('tls.privateKeyRef', '私钥文件引用', 'text', { placeholder: 'config/ssl/example.com/privkey.pem', hint: '填写文件引用,不是私钥内容。' })
field('tls.management', '证书管理方式', 'select', {
fallback: 'custom',
options: options([
['web_server', 'Web 服务器管理'],
['project', '项目自身管理'],
['custom', '自定义']
])
}),
field('tls.challenge', '证书验证方式', 'select', {
show: projectManagedCertificate,
fallback: 'http',
options: options([['http', 'HTTP 验证'], ['dns', 'DNS 验证']])
}),
field('tls.certificateRef', '证书文件', 'text', {
show: externalManagedCertificate,
placeholder: 'config/ssl/example.com/fullchain.pem'
}),
field('tls.privateKeyRef', '私钥文件', 'text', {
show: externalManagedCertificate,
placeholder: 'config/ssl/example.com/privkey.pem'
})
])
]
@ -244,6 +267,7 @@ export function updateFormConfig(text, section, path, value) {
if (key === 'http.httpsEnabled') {
config.http.httpsPort = value ? (validPort(config.http.httpsPort) ? config.http.httpsPort : 443) : 0
}
if (key === 'tls.management' && value === 'project') config.tls.challenge ||= 'http'
}
if (key === 'tls.mode' && ['managed_default', 'default'].includes(value)) config.tls.customDirectory = ''
return JSON.stringify(config, null, 2)
@ -309,6 +333,9 @@ export function validateFormConfig(section, config) {
}
if (section === 'ssl') {
const tls = config.tls || {}
const management = certificateManagement(config)
if (!['web_server', 'project', 'custom'].includes(management)) errors['tls.management'] = '请选择证书管理方式'
if (management === 'project' && !['http', 'dns'].includes(tls.challenge || 'http')) errors['tls.challenge'] = '请选择证书验证方式'
const directory = uploadedCertificate(config) ? tls.customDirectory : tls.defaultDirectory
const controlled = value => typeof value === 'string' && !!value && !/^(\/|[a-z]:)/i.test(value) && !/[\\:]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..')
for (const name of ['defaultDirectory', ...(uploadedCertificate(config) ? ['customDirectory'] : [])]) {

View File

@ -268,17 +268,31 @@ test('不同插件超时字段使用各自实际单位,不把毫秒当作秒',
assert.deepEqual(validateFormConfig('plugins', { plugins: { spam_block: { settings: { timeout: 10000 } } } }), {})
})
test('证书路径始终受管且引用成对,切回默认来源清除自定义目录', () => {
const tls = { mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl/imported', certificateRef: 'config/ssl/imported/cert.pem', privateKeyRef: 'config/ssl/imported/key.pem' }
test('证书管理方式只展示三种业务模式并同步底层托管配置', () => {
const tls = { management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl/imported', certificateRef: 'config/ssl/imported/cert.pem', privateKeyRef: 'config/ssl/imported/key.pem' }
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', '自定义']
])
for (const customDirectory of ['C:/certs', '/etc/ssl', '../certs', 'config/../certs', 'config\\ssl', 'config//ssl']) {
assert.ok(validateFormConfig('ssl', { tls: { ...tls, customDirectory } })['tls.customDirectory'])
}
assert.ok(validateFormConfig('ssl', { tls: { ...tls, privateKeyRef: '' } })['tls.privateKeyRef'])
assert.ok(validateFormConfig('ssl', { tls: { ...tls, certificateRef: 'config/ssl/outside.pem' } })['tls.certificateRef'])
const updated = readFormConfig(updateFormConfig(JSON.stringify({ tls }), 'ssl', ['tls', 'mode'], 'managed_default'))
assert.equal(updated.tls.customDirectory, '')
assert.equal(updated.tls.privateKeyRef, tls.privateKeyRef)
let updated = readFormConfig(updateFormConfig(JSON.stringify({ tls }), 'ssl', ['tls', 'management'], 'project'))
assert.equal(updated.tls.mode, tls.mode)
assert.equal(updated.tls.customDirectory, tls.customDirectory)
assert.equal(updated.tls.certificateRef, tls.certificateRef)
assert.equal(updated.tls.challenge, 'http')
assert.ok(getFieldGroups('ssl', updated).flatMap(group => group.fields).some(field => field.path.join('.') === 'tls.challenge'))
assert.ok(!getFieldGroups('ssl', updated).flatMap(group => group.fields).some(field => field.path.join('.') === 'tls.certificateRef'))
updated = readFormConfig(updateFormConfig(JSON.stringify(updated), 'ssl', ['tls', 'management'], 'web_server'))
assert.equal(updated.tls.mode, tls.mode)
assert.equal(updated.tls.customDirectory, tls.customDirectory)
assert.ok(getFieldGroups('ssl', updated).flatMap(group => group.fields).some(field => field.path.join('.') === 'tls.certificateRef'))
})
test('SMTP 加密方式自动匹配端口,手动端口和读取过程不改变原配置', () => {