完善超级管理员恢复域名配置
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-15 22:53:57 +08:00
parent bb2477b8b9
commit 56f4abc632
16 changed files with 171 additions and 26 deletions

View File

@ -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挑战模式

View File

@ -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,

View File

@ -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,

View File

@ -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")
}
}

View File

@ -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))

View File

@ -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)

View File

@ -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)

View File

@ -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) {

View File

@ -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 {

View File

@ -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 == "" {

View File

@ -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

View File

@ -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

View File

@ -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("附加邮箱域名的服务地址不在主域泛域名证书覆盖范围内")

View File

@ -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 {

View File

@ -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'] = '接收域名不能重复,大小写和末尾点视为同一域名'

View File

@ -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('插件网络列表拒绝无效和重复目标,空设置可编辑且非标准设置不覆盖', () => {