diff --git a/backend/config/config.go b/backend/config/config.go index afc2dda7..a8c3316c 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -30,6 +30,7 @@ type Config struct { Domain string `json:"domain"` Domains []string `json:"domains"` //多域名设置,把所有收信域名都填进去 WebDomain string `json:"webDomain"` + AdminRecoveryDomain string `json:"adminRecoveryDomain,omitempty"` AccessHosts []string `json:"accessHosts,omitempty"` DkimPrivateKeyPath string `json:"dkimPrivateKeyPath"` SSLType string `json:"sslType"` // 0表示自动生成证书,HTTP挑战模式,1表示用户上传证书,2表示自动-DNS挑战模式 diff --git a/backend/config/migrate.go b/backend/config/migrate.go index 7c31c2bf..5ecaa9dc 100644 --- a/backend/config/migrate.go +++ b/backend/config/migrate.go @@ -49,6 +49,7 @@ type legacyMigrationConfig struct { Domain string `json:"domain"` Domains []string `json:"domains"` WebDomain string `json:"webDomain"` + AdminRecoveryDomain string `json:"adminRecoveryDomain"` AccessHosts []string `json:"accessHosts"` DkimPrivateKeyPath string `json:"dkimPrivateKeyPath"` SSLType string `json:"sslType"` @@ -88,7 +89,7 @@ type legacyMigrationConfig struct { } var legacyMigrationKnownFields = map[string]struct{}{ - "logLevel": {}, "domain": {}, "domains": {}, "webDomain": {}, "accessHosts": {}, + "logLevel": {}, "domain": {}, "domains": {}, "webDomain": {}, "adminRecoveryDomain": {}, "accessHosts": {}, "dkimPrivateKeyPath": {}, "sslType": {}, "SSLPrivateKeyPath": {}, "SSLPublicKeyPath": {}, "dbDSN": {}, "dbType": {}, "httpsEnabled": {}, "spamFilterLevel": {}, "httpPort": {}, "httpsPort": {}, "tlsMode": {}, "trustedProxyCidrs": {}, "weChatPushAppId": {}, "weChatPushSecret": {}, "weChatPushTemplateId": {}, "weChatPushUserId": {}, @@ -499,10 +500,11 @@ func legacyRuntimeConfig(root string, legacy legacyMigrationConfig) (RuntimeConf LogLevel: legacy.LogLevel, Database: database, Domains: DomainConfig{ - Primary: legacy.Domain, - Accepted: accepted, - Web: legacy.WebDomain, - AccessHosts: append([]string(nil), legacy.AccessHosts...), + Primary: legacy.Domain, + Accepted: accepted, + Web: legacy.WebDomain, + AdminRecovery: legacy.AdminRecoveryDomain, + AccessHosts: append([]string(nil), legacy.AccessHosts...), }, HTTP: HTTPConfig{ Port: legacy.HttpPort, diff --git a/backend/config/partitioned_compatibility.go b/backend/config/partitioned_compatibility.go index e5a24040..c69408aa 100644 --- a/backend/config/partitioned_compatibility.go +++ b/backend/config/partitioned_compatibility.go @@ -52,8 +52,9 @@ func runtimeCompatibility(runtime RuntimeConfig) (*Config, error) { cfg := &Config{ LogLevel: root.LogLevel, Domain: root.Domains.Primary, Domains: append([]string(nil), root.Domains.Accepted...), WebDomain: root.Domains.Web, - AccessHosts: append([]string(nil), root.Domains.AccessHosts...), - DbType: driver, DbDSN: dsn, SetupState: runtime.SetupState, + AdminRecoveryDomain: root.Domains.AdminRecovery, + AccessHosts: append([]string(nil), root.Domains.AccessHosts...), + DbType: driver, DbDSN: dsn, SetupState: runtime.SetupState, IsInit: runtime.SetupState == SetupStateReady, HttpPort: root.HTTP.Port, HttpsPort: root.HTTP.HTTPSPort, HttpsEnabled: 2, TLSMode: root.HTTP.TLSMode, diff --git a/backend/config/partitioned_compatibility_test.go b/backend/config/partitioned_compatibility_test.go index 0524b2b7..8f10a840 100644 --- a/backend/config/partitioned_compatibility_test.go +++ b/backend/config/partitioned_compatibility_test.go @@ -55,7 +55,7 @@ func TestPartitionedCompatibilityStartupAndWriteProtection(t *testing.T) { PublishConfigSnapshot(oldSnapshot) }) writeLegacyMigrationFixture(t, root, map[string]any{ - "logLevel": "info", "domain": "mail.example.com", "webDomain": "mail.example.com", + "logLevel": "info", "domain": "mail.example.com", "webDomain": "mail.example.com", "adminRecoveryDomain": "recovery.mail.example.com", "dbType": "sqlite", "dbDSN": "./config/existing.db", "isInit": true, "sslType": "1", "SSLPublicKeyPath": "./config/ssl/site.crt", "SSLPrivateKeyPath": "./config/ssl/site.key", "httpPort": 80, "httpsPort": 443, "httpsEnabled": 1, @@ -76,7 +76,7 @@ func TestPartitionedCompatibilityStartupAndWriteProtection(t *testing.T) { if !cfg.IsSetupReady() || cfg.DbType != DBTypeSQLite || cfg.DbDSN != filepath.Join(root, "data", "existing.db") { t.Fatal("partitioned startup lost initialization or database identity") } - if cfg.WebDomain != "mail.example.com" || cfg.SSL.CertificateRef != "data/ssl/site.crt" { + if cfg.WebDomain != "mail.example.com" || cfg.AdminRecoveryDomain != "recovery.mail.example.com" || cfg.SSL.CertificateRef != "data/ssl/site.crt" { t.Fatal("partitioned startup lost domain or certificate path") } } diff --git a/backend/config/runtime_access.go b/backend/config/runtime_access.go index f76afa3d..28ec161b 100644 --- a/backend/config/runtime_access.go +++ b/backend/config/runtime_access.go @@ -133,6 +133,28 @@ func CurrentWebDomain() string { return cfg.WebDomain } +// CurrentAdminRecoveryDomain 返回超级管理员本机恢复通道的部署标识域名。 +// 此值不用于提供公网重置路由;未配置时沿用主邮件域名。 +func CurrentAdminRecoveryDomain() string { + if manager := CurrentManager(); manager != nil { + runtime := manager.Snapshot() + if runtime.Generation != 0 { + if domain := runtime.Root.Domains.AdminRecovery; domain != "" { + return domain + } + return runtime.Root.Domains.Primary + } + } + cfg := CurrentConfig() + if cfg == nil { + return "" + } + if cfg.AdminRecoveryDomain != "" { + return cfg.AdminRecoveryDomain + } + return cfg.Domain +} + // SetRuntimeSetupPort stores the temporary setup listener port outside the persisted configuration. func SetRuntimeSetupPort(port int) { setupPort.Store(int64(port)) diff --git a/backend/config/runtime_access_test.go b/backend/config/runtime_access_test.go index 2ab41c82..402f9212 100644 --- a/backend/config/runtime_access_test.go +++ b/backend/config/runtime_access_test.go @@ -216,6 +216,42 @@ func TestCurrentMailDomainsFallsBackToLegacyConfig(t *testing.T) { } } +func TestCurrentAdminRecoveryDomainUsesConfiguredValueAndSafeFallback(t *testing.T) { + previousManager := CurrentManager() + previousInstance := Instance + defer func() { + SetRuntimeManager(previousManager) + Instance = previousInstance + PublishConfigSnapshot(previousInstance) + }() + + runtime := validRuntimeConfig() + runtime.Root.Domains.AdminRecovery = "recovery.example.com" + SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}}) + if got := CurrentAdminRecoveryDomain(); got != "recovery.example.com" { + t.Fatalf("configured recovery domain = %q", got) + } + + runtime.Root.Domains.AdminRecovery = "" + runtime.Root.Domains.Primary = "example.com" + SetRuntimeManager(&ConfigManager{snapshots: runtimeAccessSnapshotStore{runtime: runtime}}) + if got := CurrentAdminRecoveryDomain(); got != "example.com" { + t.Fatalf("partition fallback recovery domain = %q", got) + } + + SetRuntimeManager(nil) + Instance = &Config{Domain: "legacy.example.com", AdminRecoveryDomain: "recovery.legacy.example.com"} + PublishConfigSnapshot(Instance) + if got := CurrentAdminRecoveryDomain(); got != "recovery.legacy.example.com" { + t.Fatalf("legacy configured recovery domain = %q", got) + } + Instance = &Config{Domain: "legacy.example.com"} + PublishConfigSnapshot(Instance) + if got := CurrentAdminRecoveryDomain(); got != "legacy.example.com" { + t.Fatalf("legacy fallback recovery domain = %q", got) + } +} + func TestCurrentSMTPRuntimeSettingsReadsPartitionSnapshot(t *testing.T) { previousManager := CurrentManager() defer SetRuntimeManager(previousManager) diff --git a/backend/config/runtime_validator.go b/backend/config/runtime_validator.go index 2e38c44b..1251b809 100644 --- a/backend/config/runtime_validator.go +++ b/backend/config/runtime_validator.go @@ -83,7 +83,7 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio } func validateDomains(domains DomainConfig) error { - if domains.Primary == "" && domains.Web == "" && len(domains.Accepted) == 0 && len(domains.AccessHosts) == 0 { + if domains.Primary == "" && domains.Web == "" && domains.AdminRecovery == "" && len(domains.Accepted) == 0 && len(domains.AccessHosts) == 0 { return nil } primary, err := normalizeConfigDomain(domains.Primary) @@ -94,6 +94,12 @@ func validateDomains(domains DomainConfig) error { if err != nil || web != strings.ToLower(strings.TrimSuffix(domains.Web, ".")) { return ErrInvalidDomainConfig } + if domains.AdminRecovery != "" { + recovery, recoveryErr := normalizeConfigDomain(domains.AdminRecovery) + if recoveryErr != nil || recovery != strings.ToLower(strings.TrimSuffix(domains.AdminRecovery, ".")) { + return ErrInvalidDomainConfig + } + } seen := make(map[string]struct{}, len(domains.Accepted)) for _, item := range domains.Accepted { domain, normalizeErr := normalizeConfigDomain(item) diff --git a/backend/config/runtime_validator_test.go b/backend/config/runtime_validator_test.go index 1438e576..a40401bb 100644 --- a/backend/config/runtime_validator_test.go +++ b/backend/config/runtime_validator_test.go @@ -65,6 +65,9 @@ func TestValidateRuntimeConfigRejectsCrossSectionViolations(t *testing.T) { {name: "primary_domain_missing", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { runtime.Root.Domains.Accepted = []string{"example.com"} }, want: ErrInvalidDomainConfig}, + {name: "invalid_admin_recovery_domain", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { + runtime.Root.Domains.AdminRecovery = "https://recovery.example.com/reset" + }, want: ErrInvalidDomainConfig}, {name: "generation_mismatch", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { runtime.Email.Meta.Generation++ }, want: ErrSectionGenerationMismatch}, {name: "missing_super_admin", mutate: func(_ *RuntimeConfig, options *RuntimeValidationOptions) { options.HasSuperAdmin = false }, want: ErrInvalidRuntimeConfig}, {name: "network_without_capability", mutate: func(runtime *RuntimeConfig, _ *RuntimeValidationOptions) { diff --git a/backend/config/sections.go b/backend/config/sections.go index e4bc717c..1d2a2c3b 100644 --- a/backend/config/sections.go +++ b/backend/config/sections.go @@ -35,10 +35,11 @@ type SectionMeta struct { } type DomainConfig struct { - Primary string `json:"primary,omitempty"` - Accepted []string `json:"accepted,omitempty"` - Web string `json:"web,omitempty"` - AccessHosts []string `json:"accessHosts,omitempty"` + Primary string `json:"primary,omitempty"` + Accepted []string `json:"accepted,omitempty"` + Web string `json:"web,omitempty"` + AdminRecovery string `json:"adminRecovery,omitempty"` + AccessHosts []string `json:"accessHosts,omitempty"` } type HTTPConfig struct { diff --git a/backend/config/ssl_custom.go b/backend/config/ssl_custom.go index 12c6ef77..8a067502 100644 --- a/backend/config/ssl_custom.go +++ b/backend/config/ssl_custom.go @@ -192,6 +192,9 @@ func validationCertificateDomains(candidate *RuntimeConfig) []string { if candidate.Root.Domains.Web != "" { domains = append(domains, candidate.Root.Domains.Web) } + if candidate.Root.Domains.AdminRecovery != "" { + domains = append(domains, candidate.Root.Domains.AdminRecovery) + } for _, domain := range candidate.Root.Domains.Accepted { domains = append(domains, "smtp."+domain, "imap."+domain, "pop."+domain) } @@ -201,11 +204,11 @@ func validationCertificateDomains(candidate *RuntimeConfig) []string { } func runtimeCertificateDomains(candidate *RuntimeConfig) []string { - return SSLCertificateDomains(candidate.Root.Domains.Primary, candidate.Root.Domains.Web, candidate.Root.Domains.Accepted) + return SSLCertificateDomains(candidate.Root.Domains.Primary, candidate.Root.Domains.Web, candidate.Root.Domains.Accepted, candidate.Root.Domains.AdminRecovery) } -// SSLCertificateDomains 统一初始化和配置管理所需的 Web、主域及邮件协议域名。 -func SSLCertificateDomains(primary, web string, accepted []string) []string { +// SSLCertificateDomains 统一初始化和配置管理所需的 Web、主域、邮件协议及恢复通道域名。 +func SSLCertificateDomains(primary, web string, accepted []string, adminRecovery ...string) []string { seen := make(map[string]bool) var domains []string appendDomain := func(value string) { @@ -217,6 +220,9 @@ func SSLCertificateDomains(primary, web string, accepted []string) []string { } appendDomain(web) appendDomain(primary) + for _, domain := range adminRecovery { + appendDomain(domain) + } for _, domain := range accepted { domain = strings.TrimSpace(domain) if domain == "" { diff --git a/backend/config/ssl_live.go b/backend/config/ssl_live.go index 5f8613f8..ff1baca9 100644 --- a/backend/config/ssl_live.go +++ b/backend/config/ssl_live.go @@ -37,6 +37,7 @@ func CurrentCertificateConfig() *Config { cfg.SSL = runtime.SSL.TLS cfg.Domain, cfg.WebDomain = runtime.Root.Domains.Primary, runtime.Root.Domains.Web cfg.Domains = runtime.Root.Domains.Accepted + cfg.AdminRecoveryDomain = runtime.Root.Domains.AdminRecovery cfg.TLSMode = runtime.Root.HTTP.TLSMode cfg.HttpsEnabled = 2 if runtime.Root.HTTP.HTTPSEnabled { @@ -98,7 +99,12 @@ func loadValidatedRuntimeCertificate(cfg *Config) (tls.Certificate, error) { if err != nil { return tls.Certificate{}, err } - runtime := RuntimeConfig{Root: RootConfig{Domains: DomainConfig{Primary: cfg.Domain, Web: cfg.WebDomain, Accepted: cfg.Domains}}} + runtime := RuntimeConfig{Root: RootConfig{Domains: DomainConfig{ + Primary: cfg.Domain, + Web: cfg.WebDomain, + Accepted: cfg.Domains, + AdminRecovery: cfg.AdminRecoveryDomain, + }}} runtime.SSL.TLS = cfg.SSL if cfg.SSLType == SSLTypeAutoDNS { runtime.SSL.TLS.Management = SSLManagementProject diff --git a/backend/config/ssl_storage_test.go b/backend/config/ssl_storage_test.go index 63bf54d0..b8a0f344 100644 --- a/backend/config/ssl_storage_test.go +++ b/backend/config/ssl_storage_test.go @@ -191,6 +191,22 @@ func TestValidateSSLMaterialRejectsDomainExpiryAndWeakKeys(t *testing.T) { } } +func TestSSLCertificateDomainsIncludesConfiguredAdminRecoveryDomain(t *testing.T) { + domains := SSLCertificateDomains("example.com", "mail.example.com", []string{"example.com"}, "recovery.example.com") + for _, want := range []string{"example.com", "mail.example.com", "recovery.example.com", "smtp.example.com", "imap.example.com", "pop.example.com"} { + found := false + for _, got := range domains { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("certificate domains %v do not include %q", domains, want) + } + } +} + func TestPrepareCustomSSLPathsKeepsDirectFilesWithoutCopying(t *testing.T) { root := t.TempDir() previousRoot := ROOT_PATH diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index 99118ca4..5edbfa7e 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -306,7 +306,7 @@ func sslDomains(cfg *config.Config) []string { if cfg == nil { return nil } - return config.SSLCertificateDomains(cfg.Domain, cfg.WebDomain, cfg.Domains) + return config.SSLCertificateDomains(cfg.Domain, cfg.WebDomain, cfg.Domains, cfg.AdminRecoveryDomain) } func certificateMatchesConfiguredDomains(cert *x509.Certificate, cfg *config.Config) bool { @@ -343,6 +343,9 @@ func validateWildcardConfiguration(cfg *config.Config) error { if cfg.WebDomain != "" && !covered(cfg.WebDomain) { return newUserCertificateError("Web 域名必须是主域名的一级子域名,泛域名证书不覆盖根域名或多级子域名") } + if cfg.AdminRecoveryDomain != "" && !covered(cfg.AdminRecoveryDomain) { + return newUserCertificateError("超级管理员恢复域名必须是主邮件域名的一级子域名,才能由 DNS 泛域名证书覆盖") + } for _, domain := range cfg.Domains { if !covered("smtp."+domain) || !covered("imap."+domain) || !covered("pop."+domain) { return newUserCertificateError("附加邮箱域名的服务地址不在主域泛域名证书覆盖范围内") diff --git a/backend/services/setup/ssl/wildcard_test.go b/backend/services/setup/ssl/wildcard_test.go index 4a3a721b..2ec95552 100644 --- a/backend/services/setup/ssl/wildcard_test.go +++ b/backend/services/setup/ssl/wildcard_test.go @@ -52,17 +52,18 @@ func TestWildcardCertificateDomains(t *testing.T) { func TestCertificateDomainsFollowChallengeMode(t *testing.T) { cfg := &config.Config{ - Domain: "xuans.com", - WebDomain: "web.xuans.com", - Domains: []string{"xuans.com"}, + Domain: "xuans.com", + WebDomain: "web.xuans.com", + AdminRecoveryDomain: "recovery.xuans.com", + Domains: []string{"xuans.com"}, } cfg.SSLType = config.SSLTypeAutoHTTP httpDomains := certificateDomains(cfg) - if len(httpDomains) != 5 { - t.Fatalf("got %d HTTP domains, want 5: %v", len(httpDomains), httpDomains) + if len(httpDomains) != 6 { + t.Fatalf("got %d HTTP domains, want 6: %v", len(httpDomains), httpDomains) } - if httpDomains[0] != "web.xuans.com" || httpDomains[1] != "xuans.com" { + if httpDomains[0] != "web.xuans.com" || httpDomains[1] != "xuans.com" || httpDomains[2] != "recovery.xuans.com" { t.Fatalf("unexpected HTTP domains: %v", httpDomains) } @@ -73,6 +74,26 @@ func TestCertificateDomainsFollowChallengeMode(t *testing.T) { } } +func TestDNSWildcardRejectsUncoveredAdminRecoveryDomain(t *testing.T) { + base := &config.Config{ + Domain: "example.com", + WebDomain: "mail.example.com", + Domains: []string{"example.com"}, + SSLType: config.SSLTypeAutoDNS, + AdminRecoveryDomain: "recovery.example.com", + } + if err := validateWildcardConfiguration(base); err != nil { + t.Fatalf("covered recovery domain was rejected: %v", err) + } + for _, value := range []string{"example.com", "a.recovery.example.com", "recovery.other.example"} { + candidate := *base + candidate.AdminRecoveryDomain = value + if err := validateWildcardConfiguration(&candidate); err == nil { + t.Fatalf("uncovered recovery domain %q was accepted", value) + } + } +} + func TestDNSChallengeWildcardProducesSingleRecord(t *testing.T) { challenge := &DNSChallenge{authInfo: map[string]*authInfo{}} if err := challenge.Present("*.xuans.com", "token", "key-auth"); err != nil { diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 082ddc66..23db7028 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -88,6 +88,7 @@ const baseGroups = [ group('site', '站点与域名', '', [ field('domains.primary', '主邮件域名', 'text', { required: true, placeholder: 'example.com' }), field('domains.web', '网页访问域名', 'text', { required: true, placeholder: 'mail.example.com' }), + field('domains.adminRecovery', '超级管理员恢复域名', 'text', { placeholder: 'recovery.example.com', hint: '留空时使用主邮件域名。该配置仅用于本机恢复通道的部署标识与证书规划,不开放公网重置页面。' }), field('domains.accepted', '接收邮件的域名', 'list', { required: true, wide: false, hint: '需包含主邮件域名,每个域名单独填写。', placeholder: 'example.com' }), field('logLevel', '日志级别', 'select', { options: options([['', '默认级别'], ['debug', '调试'], ['info', '信息'], ['warn', '警告'], ['error', '错误']]) }) ]), @@ -326,6 +327,9 @@ export function validateFormConfig(section, config) { for (const name of ['primary', 'web']) { if (!validDomain(domains[name])) errors[`domains.${name}`] = '请填写有效域名,不包含协议、路径或空格' } + if (domains.adminRecovery !== undefined && domains.adminRecovery !== '' && !validDomain(domains.adminRecovery)) { + errors['domains.adminRecovery'] = '请填写有效域名,不包含协议、路径或空格' + } if (Array.isArray(domains.accepted) && domains.accepted.every(validDomain)) { const accepted = domains.accepted.map(normalizedDomain) if (new Set(accepted).size !== accepted.length) errors['domains.accepted'] = '接收域名不能重复,大小写和末尾点视为同一域名' diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index aa071431..6414f31d 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -7,7 +7,10 @@ import { createRequire } from 'node:module' const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url) const source = readFileSync(new URL('ConfigForm/ConfigForm.js', root), 'utf8') const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = compileFunction( - source.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }' + source + .replaceAll('export function ', 'function ') + .replaceAll('export const ', 'const ') + + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }' )() test('基础配置不再展示冗余安全选项与开关,通行密钥由系统默认解析,用户权限仍由账号设置管理', () => { @@ -274,12 +277,26 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正 test('域名校验兼容大小写和末尾点,拒绝重复项、空白和路径', () => { const valid = { database: { type: 'sqlite' }, domains: { primary: 'Example.com.', web: 'mail.example.com', accepted: ['example.com'] } } assert.deepEqual(validateFormConfig('base', valid), {}) + const fields = getFieldGroups('base', valid).flatMap(group => group.fields) + const recovery = fields.find(field => field.path.join('.') === 'domains.adminRecovery') + assert.ok(recovery) + assert.match(recovery.hint, /不开放公网重置页面/) + assert.deepEqual(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery: '' } }), {}) + assert.deepEqual(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery: 'recovery.example.com' } }), {}) for (const accepted of [[], ['example.com', 'Example.com.'], [''], [' https://example.com'], ['other.example.com'], 'example.com', [null]]) { assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, accepted } })['domains.accepted']) } for (const primary of ['https://example.com', 'example.com/path', 'bad..com', '-bad.com', '127.0.0.1', '例子.com']) { assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, primary } })['domains.primary']) } + for (const adminRecovery of ['https://example.com', 'example.com/path', 'bad..com', '-bad.com', '127.0.0.1', '例子.com', ' recovery.example.com']) { + assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery } })['domains.adminRecovery']) + } + const text = JSON.stringify({ ...valid, domains: { ...valid.domains, accepted: ['example.com'], accessHosts: ['192.0.2.10'], adminRecovery: '' } }) + const updated = readFormConfig(updateFormConfig(text, 'base', ['domains', 'adminRecovery'], 'recovery.example.com')) + assert.equal(updated.domains.adminRecovery, 'recovery.example.com') + assert.deepEqual(updated.domains.accepted, ['example.com']) + assert.deepEqual(updated.domains.accessHosts, ['192.0.2.10']) }) test('插件网络列表拒绝无效和重复目标,空设置可编辑且非标准设置不覆盖', () => {