This commit is contained in:
parent
c82be24178
commit
b062ca645e
@ -329,16 +329,16 @@ func certificateMatchesConfiguredDomains(cert *x509.Certificate, cfg *config.Con
|
||||
return true
|
||||
}
|
||||
|
||||
// 自动 DNS 只申请主域泛域名;不覆盖的服务地址是配置错误,不能靠重复续期修复。
|
||||
// 自动 DNS 同时申请主域名和泛域名;不覆盖的服务地址不能靠重复续期修复。
|
||||
func validateWildcardConfiguration(cfg *config.Config) error {
|
||||
if cfg == nil || cfg.EffectiveSSLType() != config.SSLTypeAutoDNS {
|
||||
return nil
|
||||
}
|
||||
domains := wildcardCertificateDomains(cfg)
|
||||
if len(domains) != 1 {
|
||||
if len(domains) != 2 {
|
||||
return newUserCertificateError("自动 DNS 证书需要有效的主域名")
|
||||
}
|
||||
base := strings.TrimPrefix(domains[0], "*.")
|
||||
base := domains[0]
|
||||
covered := func(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
suffix := "." + strings.ToLower(base)
|
||||
@ -346,7 +346,10 @@ func validateWildcardConfiguration(cfg *config.Config) error {
|
||||
return strings.HasSuffix(host, suffix) && label != "" && !strings.Contains(label, ".")
|
||||
}
|
||||
if cfg.WebDomain != "" && !covered(cfg.WebDomain) {
|
||||
return newUserCertificateError("Web 域名必须是主域名的一级子域名,泛域名证书不覆盖根域名或多级子域名")
|
||||
webDomain := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(cfg.WebDomain), "."))
|
||||
if webDomain != base {
|
||||
return newUserCertificateError("Web 域名必须是主域名或其一级子域名,泛域名证书不覆盖多级子域名")
|
||||
}
|
||||
}
|
||||
for _, domain := range cfg.Domains {
|
||||
if !covered("smtp."+domain) || !covered("imap."+domain) || !covered("pop."+domain) {
|
||||
@ -360,14 +363,14 @@ func wildcardCertificateDomains(cfg *config.Config) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
domain := strings.TrimSpace(cfg.Domain)
|
||||
domain := strings.ToLower(strings.TrimSpace(cfg.Domain))
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
domain = strings.TrimPrefix(domain, "*.")
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{"*." + domain}
|
||||
return []string{domain, "*." + domain}
|
||||
}
|
||||
|
||||
func certificateDomains(cfg *config.Config) []string {
|
||||
|
||||
@ -68,3 +68,29 @@ func createCertificateForDomains(t *testing.T, domains []string) []byte {
|
||||
}
|
||||
return der
|
||||
}
|
||||
|
||||
func TestDNSCertificateRequiresRootAndWildcardCoverage(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
domains []string
|
||||
valid bool
|
||||
}{
|
||||
{name: "root and wildcard", domains: []string{"example.com", "*.example.com"}, valid: true},
|
||||
{name: "legacy wildcard only", domains: []string{"*.example.com"}},
|
||||
{name: "root only", domains: []string{"example.com"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
der := createCertificateForDomains(t, test.domains)
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, webDomain := range []string{"example.com", "web.example.com"} {
|
||||
cfg := &config.Config{Domain: "example.com", WebDomain: webDomain, SSLType: config.SSLTypeAutoDNS}
|
||||
if matched := certificateMatchesConfiguredDomains(cert, cfg); matched != test.valid {
|
||||
t.Fatalf("证书覆盖校验结果不符:web=%s matched=%t", webDomain, matched)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,12 +16,17 @@ func TestWildcardCertificateDomains(t *testing.T) {
|
||||
{
|
||||
name: "configured domain",
|
||||
config: &config.Config{Domain: "xuans.com", WebDomain: "web.xuans.com", Domains: []string{"xuans.com", "other.example"}},
|
||||
want: []string{"*.xuans.com"},
|
||||
want: []string{"xuans.com", "*.xuans.com"},
|
||||
},
|
||||
{
|
||||
name: "trimmed wildcard domain",
|
||||
config: &config.Config{Domain: " *.xuans.com. "},
|
||||
want: []string{"*.xuans.com"},
|
||||
want: []string{"xuans.com", "*.xuans.com"},
|
||||
},
|
||||
{
|
||||
name: "case normalized domain",
|
||||
config: &config.Config{Domain: "Example.COM"},
|
||||
want: []string{"example.com", "*.example.com"},
|
||||
},
|
||||
{
|
||||
name: "empty domain",
|
||||
@ -68,11 +73,56 @@ func TestCertificateDomainsFollowChallengeMode(t *testing.T) {
|
||||
|
||||
cfg.SSLType = config.SSLTypeAutoDNS
|
||||
dnsDomains := certificateDomains(cfg)
|
||||
if len(dnsDomains) != 1 || dnsDomains[0] != "*.xuans.com" {
|
||||
if len(dnsDomains) != 2 || dnsDomains[0] != "xuans.com" || dnsDomains[1] != "*.xuans.com" {
|
||||
t.Fatalf("unexpected DNS domains: %v", dnsDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSWildcardConfigurationCoverage(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
webDomain string
|
||||
domains []string
|
||||
valid bool
|
||||
}{
|
||||
{name: "root", webDomain: "example.com", valid: true},
|
||||
{name: "normalized root", webDomain: "Example.COM.", valid: true},
|
||||
{name: "subdomain", webDomain: "web.example.com", domains: []string{"example.com"}, valid: true},
|
||||
{name: "nested web", webDomain: "a.web.example.com"},
|
||||
{name: "foreign web", webDomain: "other.example"},
|
||||
{name: "nested mail services", webDomain: "web.example.com", domains: []string{"mail.example.com"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := &config.Config{Domain: "example.com", WebDomain: test.webDomain, Domains: test.domains, SSLType: config.SSLTypeAutoDNS}
|
||||
if err := validateWildcardConfiguration(cfg); (err == nil) != test.valid {
|
||||
t.Fatalf("证书域名覆盖校验结果不符:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSChallengeRootAndWildcardKeepBothRecords(t *testing.T) {
|
||||
challenge := &DNSChallenge{authInfo: map[string]*authInfo{}}
|
||||
for _, item := range []struct{ domain, token, key string }{
|
||||
{"example.com", "root-token", "root-key"},
|
||||
{"*.example.com", "wildcard-token", "wildcard-key"},
|
||||
} {
|
||||
if err := challenge.Present(item.domain, item.token, item.key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
records := challenge.GetDNSSettings(nil)
|
||||
if len(records) != 2 || records[0].Host != records[1].Host || records[0].Value == records[1].Value {
|
||||
t.Fatalf("根域名和泛域名必须保留同一主机名的两条独立 TXT 记录:%v", records)
|
||||
}
|
||||
if err := challenge.CleanUp("example.com", "root-token", "root-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if records = challenge.GetDNSSettings(nil); len(records) != 1 {
|
||||
t.Fatal("清理根域名验证不能删除泛域名验证记录")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSWildcardIgnoresLegacyAdminRecoveryDomain(t *testing.T) {
|
||||
base := &config.Config{
|
||||
Domain: "example.com",
|
||||
|
||||
21
docs/泛域名证书根域名覆盖.md
Normal file
21
docs/泛域名证书根域名覆盖.md
Normal file
@ -0,0 +1,21 @@
|
||||
# 泛域名证书根域名覆盖
|
||||
|
||||
## 行为
|
||||
|
||||
项目托管证书选择 DNS 验证时,签发和续期统一申请主域名与对应泛域名。例如主域名为 `example.com` 时,证书包含 `example.com` 和 `*.example.com`。主域名由部署配置提供,不固定任何测试域名。
|
||||
|
||||
Web 地址允许使用主域名或一级子域名;多级子域名、其他主域名及超出覆盖范围的附加邮箱服务地址仍会被拒绝。
|
||||
|
||||
根域名与泛域名可能在同一 `_acme-challenge` 主机名生成不同的 TXT 值。页面展示全部记录,必须同时保留,不能把其中一条覆盖成另一条。清理一个挑战时只清理对应记录。
|
||||
|
||||
旧的仅泛域名证书缺少主域名覆盖,项目托管模式的证书检查会识别为覆盖不足,进入既有续期流程。手动及代理托管仍不自动续期。DNS 模式仍需要完成 TXT 设置;本次功能不新增 DNS 服务商凭据或外部续期脚本。
|
||||
|
||||
## 验证
|
||||
|
||||
- [x] 主域名与泛域名申请集合、空配置和大小写规范化回归。
|
||||
- [x] 根域名与一级子域名可用,多级及无关域名拒绝。
|
||||
- [x] 两条同名 TXT 独立保留与清理。
|
||||
- [x] 真实 X.509 校验识别完整覆盖、缺少根域名、缺少泛域名。
|
||||
- [ ] 云服务器真实 CA 签发及可信 TLS 验收。
|
||||
|
||||
本地回归通过不代表真实签发已完成;云端结果需另行记录。
|
||||
@ -132,15 +132,15 @@ const lang = {
|
||||
"ssl_crt_path": "SSL 证书路径",
|
||||
"ssl_auto_storage_title": "自动证书保存位置",
|
||||
"ssl_auto_storage_desc": "证书由项目自身托管,启用内置自动续期;到期前 30 天内检查并续期,续期时仍需完成所选验证方式。",
|
||||
"ssl_auto_certificate_path": "证书:./data/ssl/managed/default/current/public.crt",
|
||||
"ssl_auto_private_key_path": "私钥:./data/ssl/managed/default/current/private.key",
|
||||
"ssl_auto_account_key_path": "ACME 账户密钥:./data/ssl/account_private.pem",
|
||||
"ssl_auto_certificate_path": "证书:./ssl/smail-tls-版本标识.crt",
|
||||
"ssl_auto_private_key_path": "私钥:./ssl/smail-tls-版本标识.key",
|
||||
"ssl_auto_account_key_path": "ACME 账户密钥:./ssl/account_private.pem",
|
||||
"ssl_manual_key_desc": "可填写服务器上任意可读取的完整路径,也可填写相对于 Smail 目录的路径;系统校验后会导入受控目录。",
|
||||
"ssl_manual_crt_desc": "可填写服务器上任意可读取的完整路径,也可填写相对于 Smail 目录的路径;证书必须与私钥匹配。",
|
||||
"ssl_dns_title": "DNS验证记录",
|
||||
"ssl_dns_desc": "选择DNS验证后,点击“下一步”生成本次申请所需的TXT记录。添加记录并等待DNS生效后,再继续验证。",
|
||||
"ssl_dns_waiting": "正在生成DNS验证记录,请稍候……",
|
||||
"ssl_dns_ready": "请将以下TXT记录添加到域名DNS中,确认解析已生效后点击“继续验证”。",
|
||||
"ssl_dns_ready": "证书同时覆盖主域名和泛域名。请添加下方全部 TXT 记录,同名记录需同时保留;生效后点击“继续验证”。",
|
||||
"ssl_verify": "继续验证",
|
||||
"group_settings": "分组",
|
||||
"add_group": "新建分组",
|
||||
|
||||
@ -97,7 +97,7 @@ func TestAuditACMEBundledChainMustBeImportable(t *testing.T) {
|
||||
func TestAuditFreshWildcardCertificateMustNotRequireRenewal(t *testing.T) {
|
||||
auditSSLRoot(t)
|
||||
cfg := &config.Config{Domain: "example.test", WebDomain: "web.example.test", SSLType: config.SSLTypeAutoDNS}
|
||||
resource := auditCertificateChain(t, "*.example.test")
|
||||
resource := auditCertificateChain(t, "example.test", "*.example.test")
|
||||
// 不再附加 issuer,以隔离完整链重复问题和续期判断问题。
|
||||
if err := writeCertificateFiles(cfg, resource.PrivateKey, resource.Certificate, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user