This commit is contained in:
parent
cfe4401b70
commit
6ad6f449ca
@ -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"
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
58
backend/config/ssl_management_test.go
Normal file
58
backend/config/ssl_management_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'] : [])]) {
|
||||
|
||||
@ -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 加密方式自动匹配端口,手动端口和读取过程不改变原配置', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user