From 6ad6f449cacf3dbea0948b0e7fd8f353910dcc7e Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 7 Sep 2026 15:49:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96SSL=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E4=B8=89=E7=A7=8D=E7=AE=A1=E7=90=86=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/migrate.go | 6 +- backend/config/model.go | 1 + backend/config/partitioned_compatibility.go | 2 +- backend/config/ssl_management_test.go | 58 +++++++++++++++++++ backend/config/ssl_storage.go | 24 +++++++- backend/config/validator.go | 3 + backend/controllers/config.go | 4 +- backend/services/setup/ssl/ssl.go | 13 +++++ .../ConfigSettings/ConfigForm/ConfigForm.js | 37 ++++++++++-- test/frontend/config-form.test.mjs | 24 ++++++-- 10 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 backend/config/ssl_management_test.go diff --git a/backend/config/migrate.go b/backend/config/migrate.go index 8600655..6e6d53c 100644 --- a/backend/config/migrate.go +++ b/backend/config/migrate.go @@ -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" } diff --git a/backend/config/model.go b/backend/config/model.go index 0cb9754..476af07 100644 --- a/backend/config/model.go +++ b/backend/config/model.go @@ -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"` diff --git a/backend/config/partitioned_compatibility.go b/backend/config/partitioned_compatibility.go index 5330e3c..09d1d34 100644 --- a/backend/config/partitioned_compatibility.go +++ b/backend/config/partitioned_compatibility.go @@ -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 { diff --git a/backend/config/ssl_management_test.go b/backend/config/ssl_management_test.go new file mode 100644 index 0000000..c7fa29a --- /dev/null +++ b/backend/config/ssl_management_test.go @@ -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) + } +} diff --git a/backend/config/ssl_storage.go b/backend/config/ssl_storage.go index eedb5de..0353f6d 100644 --- a/backend/config/ssl_storage.go +++ b/backend/config/ssl_storage.go @@ -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") diff --git a/backend/config/validator.go b/backend/config/validator.go index 350c30a..6a87d0b 100644 --- a/backend/config/validator.go +++ b/backend/config/validator.go @@ -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 } diff --git a/backend/controllers/config.go b/backend/controllers/config.go index a79f36a..cb02f80 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -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 { diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index 2261ce7..7c72cc0 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -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, diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 414f4da..02df45d 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -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'] : [])]) { diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index e8a92cf..13e8bf2 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -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 加密方式自动匹配端口,手动端口和读取过程不改变原配置', () => {