统一根域名与泛域名证书校验并补充云端反例验收

This commit is contained in:
smail 2026-09-21 04:38:46 +08:00
parent ad771b3f22
commit 7d483cae36
5 changed files with 283 additions and 1 deletions

View File

@ -0,0 +1,179 @@
package config
import (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestProjectDNSRuntimeRequiresRootAndWildcard(t *testing.T) {
for _, test := range []struct {
name string
domains []string
valid bool
}{
{"root_and_wildcard", []string{"example.com", "*.example.com"}, true},
{"wildcard_only", []string{"*.example.com"}, false},
{"root_only", []string{"example.com"}, false},
} {
t.Run(test.name, func(t *testing.T) {
previousRoot := ROOT_PATH
ROOT_PATH = t.TempDir()
t.Cleanup(func() { ROOT_PATH = previousRoot })
key, certificate := testManagedCertificate(t, test.domains[0], time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, test.domains[1:]...)
// 故意不在准备阶段检查域名,模拟已有证书文件的加载与配置复验。
reference, err := stageFlatSSLAtRoot(ROOT_PATH, ManagedSSLModeDefault, key, certificate, nil, nil)
if err != nil {
t.Fatal(err)
}
defer ReleaseStagedSSL(reference)
candidate := RuntimeConfig{Root: RootConfig{Domains: DomainConfig{
Primary: "example.com", Web: "web.example.com", Accepted: []string{"example.com"},
}}}
candidate.SSL.TLS = SSLConfig{
Management: SSLManagementProject, Challenge: SSLChallengeDNS,
Mode: reference.Mode, CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef,
}
check := func(name string, err error) {
t.Helper()
if test.valid && err != nil {
t.Errorf("%s 拒绝完整域名覆盖:%v", name, err)
}
if !test.valid && !errors.Is(err, ErrSSLCertificateDomain) {
t.Errorf("%s 未拒绝不完整域名覆盖:%v", name, err)
}
}
check("配置复验", validateCustomSSLReferences(ROOT_PATH, candidate))
cfg := &Config{Domain: "example.com", WebDomain: "web.example.com", Domains: []string{"example.com"}, SSL: candidate.SSL.TLS}
_, err = loadValidatedRuntimeCertificate(cfg)
check("运行时证书", err)
cfg.SSL.Management, cfg.SSL.Challenge, cfg.SSLType = "", "", SSLTypeAutoDNS
_, err = loadValidatedRuntimeCertificate(cfg)
check("旧自动 DNS 配置", err)
})
}
}
func TestProjectDNSExportRejectsMissingCoverageAndKeepsBundle(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("证书导出需要 Linux 权限检查")
}
for _, domain := range []string{"*.example.com", "example.com"} {
t.Run(domain, func(t *testing.T) {
root, output := t.TempDir(), t.TempDir()
if err := os.Chmod(output, 0700); err != nil {
t.Fatal(err)
}
candidate := validRuntimeConfig()
candidate.Root.Domains = DomainConfig{Primary: "example.com", Web: "web.example.com", Accepted: []string{"example.com"}}
install := func(domains ...string) {
t.Helper()
key, certificate := testManagedCertificate(t, domains[0], time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, domains[1:]...)
reference, err := stageFlatSSLAtRoot(root, ManagedSSLModeDefault, key, certificate, nil, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { releaseManagedSSL(root, SSLConfig{CertificateRef: reference.CertificateRef}) })
candidate.SSL.TLS = SSLConfig{
Management: SSLManagementProject, Challenge: SSLChallengeDNS, Mode: ManagedSSLModeDefault,
DefaultDirectory: "ssl", CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef,
}
writeRuntimeFixture(t, root, candidate)
}
install("example.com", "*.example.com")
destination := filepath.Join(output, "tls.pem")
if err := ExportProjectTLS(root, destination); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(destination)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(destination)
if err != nil {
t.Fatal(err)
}
install(domain)
if err := ExportProjectTLS(root, destination); err == nil {
t.Error("导出接受了不完整的根域名与泛域名覆盖")
}
after, err := os.ReadFile(destination)
if err != nil || !bytes.Equal(before, after) {
t.Error("失败导出替换了有效证书内容")
}
current, err := os.Stat(destination)
if err != nil || !os.SameFile(info, current) || current.Mode().Perm() != 0600 {
t.Error("失败导出改变了有效证书文件或权限")
}
})
}
}
func TestProjectDNSReloadKeepsValidCertificateOnMissingRoot(t *testing.T) {
previousRoot := ROOT_PATH
previousConfig, previousPublished := publishedConfig.Load(), hasPublishedConfig.Load()
runtimeManager.Lock()
previousManager := runtimeManager.value
runtimeManager.value = nil
runtimeManager.Unlock()
liveTLS.Lock()
previousLiveRoot, previousCertificate := liveTLS.root, liveTLS.certificate
previousChecked, previousWarned := liveTLS.checked, liveTLS.warned
liveTLS.Unlock()
ROOT_PATH = t.TempDir()
t.Cleanup(func() {
ROOT_PATH = previousRoot
publishedConfig.Store(previousConfig)
hasPublishedConfig.Store(previousPublished)
runtimeManager.Lock()
runtimeManager.value = previousManager
runtimeManager.Unlock()
liveTLS.Lock()
liveTLS.root, liveTLS.certificate = previousLiveRoot, previousCertificate
liveTLS.checked, liveTLS.warned = previousChecked, previousWarned
liveTLS.Unlock()
})
install := func(domains ...string) {
t.Helper()
key, certificate := testManagedCertificate(t, domains[0], time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, domains[1:]...)
reference, err := stageFlatSSLAtRoot(ROOT_PATH, ManagedSSLModeDefault, key, certificate, nil, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ReleaseStagedSSL(reference) })
PublishConfigSnapshot(&Config{
Domain: "example.com", WebDomain: "web.example.com", Domains: []string{"example.com"},
SSL: SSLConfig{Management: SSLManagementProject, Challenge: SSLChallengeDNS, Mode: reference.Mode,
CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef},
})
}
install("example.com", "*.example.com")
configuration, err := NewRuntimeTLSConfig()
if err != nil {
t.Fatal(err)
}
before, err := configuration.GetCertificate(nil)
if err != nil {
t.Fatal(err)
}
install("*.example.com")
if err := ReloadRuntimeTLSCertificate(); !errors.Is(err, ErrSSLCertificateDomain) {
t.Fatalf("热加载未拒绝缺失根域名的新证书:%v", err)
}
after, err := configuration.GetCertificate(nil)
if err != nil || after == nil || !bytes.Equal(before.Certificate[0], after.Certificate[0]) {
t.Fatal("无效新证书替换了仍有效的内存证书")
}
install("example.com", "*.example.com")
if err := ReloadRuntimeTLSCertificate(); err != nil {
t.Fatal(err)
}
replaced, err := configuration.GetCertificate(nil)
if err != nil || replaced == nil || bytes.Equal(before.Certificate[0], replaced.Certificate[0]) {
t.Fatal("后续有效证书未能热加载")
}
}

View File

@ -188,7 +188,9 @@ func validateCustomSSLReferences(root string, candidate RuntimeConfig) error {
func validationCertificateDomains(candidate *RuntimeConfig) []string {
if EffectiveSSLManagement(candidate.SSL.TLS) == SSLManagementProject && candidate.SSL.TLS.Challenge == SSLChallengeDNS {
domains := []string{"*." + strings.TrimPrefix(candidate.Root.Domains.Primary, "*.")}
primary := strings.TrimPrefix(candidate.Root.Domains.Primary, "*.")
// 加载、导出和配置复验与自动申请一致,同时要求根域名和泛域名。
domains := []string{primary, "*." + primary}
if candidate.Root.Domains.Web != "" {
domains = append(domains, candidate.Root.Domains.Web)
}

View File

@ -0,0 +1,88 @@
# 根域名证书校验补充验收
## 结论与范围
北京时间 2026 年 9 月 21 日,在已提交并推送的 `37bb937` 基线上补充验收。发现并修复自动 DNS 证书的校验不一致:申请时要求根域名和泛域名,但运行时共用的域名列表遗漏根域名。正式程序没有替换,其他项目没有停止,用户原有云端测试报告没有修改。
**这是一个已实机复现并修复的证书覆盖缺口,不代表真实 DNS 续期或整体验收通过。**
## 需求与任务清单
- [✓] 自动 DNS 证书在申请、配置复验、加载和导出阶段均要求根域名与泛域名。
- [✓] 在云服务器保留整改前失败反例,再用新编译测试程序验证修复。
- [✓] 无效新证书不替换有效导出文件;热加载失败时保留尚有效的内存证书。
- [✓] 后续完整证书仍可热加载,外部证书管理模式的既有兼容测试通过。
- [✓] 云端配置证书专项 21 个顶层测试、SSL 初始化包 26 个顶层测试,均零跳过通过。
- [✓] 本地运行时校验与热加载专项竞态回归通过。
- [✓] 最新前端、插件和 Linux 程序已编译,三库空数据实机回归通过。
- [✓] 配置包静态安全复扫完成,原有七条告警未增加;扫描退出 1,不计为安全门禁通过。
- [!] 公网 DNS 挑战、自动续期、真实 CA 换证和全协议热加载仍需单独实机验收。
## 整改前反例
测试通过托管存储准备有效期内、密钥配对正确但仅包含 `*.example.com` 的测试证书,故意不在准备阶段校验域名,模拟已有文件的加载场景。没有使用正式私钥或修改正式证书。
云端目录:`/var/tmp/smail-review-ssl-apex-20260921/20260921-042413-before/`。
- 配置引用复验错误接受缺少根域名的证书。
- 当前及旧自动 DNS 配置的运行时加载均错误接受。
- 导出错误接受该证书,并替换已导出的有效证书包。
- 根域名加泛域名的正向用例通过;只有根域名的反例已能正确拒绝。
本轮两个顶层测试失败,零跳过,云端测试退出 1。日志完整保留在 `test-log/production-remediation-20260921/ssl-apex-before/`。这是一个共享校验列表缺口的多路径表现,不按断言数宣传漏洞数量。
## 修复和回归
`backend/config/ssl_custom.go` 的 `validationCertificateDomains` 在自动 DNS 项目托管模式明确加入根域名,继续要求泛域名及已配置的服务域名。保留既有文件安全、证书密钥匹配、有效期和强度校验,不改变手动证书入口、管理责任或 DNS 记录。
新增 `backend/config/ssl_apex_test.go`,覆盖配置复验、当前及旧配置加载、导出文件内容和身份保留,以及内存证书的拒绝与后续恢复。热加载测试调用实际运行时证书回调,但不是外部客户端对全部邮件协议的换证演练。
| 云端轮次 | 目录后缀 | 结果 |
| --- | --- | --- |
| 最终配置证书专项 | `20260921-042721-final` | 21 个顶层测试通过,零跳过,退出零 |
| SSL 初始化完整包 | `20260921-042935-setup-all` | 26 个顶层测试通过,零跳过,退出零 |
测试使用非特权 `smail`、独立临时目录及私有网络,正式目录不可访问;内存上限 256 MiB、CPU 配额 50%、禁用交换空间、运行时间上限 300 秒。最终配置证书专项执行输出记录内存峰值 8.1 MiB,不据此推导生产容量。
本地命令为 `node test/run.mjs backend -race -count=1 -p 1 -run '^TestProjectDNS(Runtime|Reload)' ./config`,通过。日志目录 `test-log/run-2026-09-20T20-28-01-801Z-38632/` 使用 UTC 文件名,不是北京时间日期。
## 候选产物
前端、Telegram 界面、三个 Linux 插件、Linux 主程序与管理员工具重新编译。版本为 `review-apex-20260921`,内嵌源码标识为 `37bb937c4040ae35a9393c418b9d7679f7bd436d-dirty`,不冒充已发布版本。
| 产物 | SHA-256 |
| --- | --- |
| 主程序 | `bb451caf67f6421253c5b4edddc6fce9961ac04613c4fe3166db36a465291939` |
| 管理员工具 | `e5b540a23f598f892f23b17f59e2f97c7560f8c3dca317ba6b6cdf2d67485821` |
| 最终配置测试程序 | `6d2cda0c4f3483bb05de86b8067588e27689f80043bd17b3e501e38cbbedacb4` |
| SSL 初始化测试程序 | `263126767560b5e09eccbc415eff5de4192c9c5babc6b16d90417f904a81db4d` |
构建日志和摘要位于 `test-log/production-remediation-20260921/build-apex.log`、`apex-build-manifest.json`;配置目录 94 个 Go 文件的摘要记录在 `apex-config-source-manifest.json`。
## 最新候选三库实机回归
同一主程序上传至 `/var/tmp/smail-review-apex-20260921/`,每轮创建新的空数据隔离实例,以 192 MiB 内存限额验证直接 TLS 拓扑。
| 数据库 | 实例目录后缀 | 结果 |
| --- | --- | --- |
| SQLite | `smail-review-runtime-20260921-043114` | 通过;只有 `smail.db`,完整性检查正常 |
| MySQL | `smail-review-runtime-20260921-043229` | 通过;配置历史与业务同库,SQLite 文件零个 |
| PostgreSQL | `smail-review-runtime-20260921-043411` | 通过;配置历史与业务同库,SQLite 文件零个 |
覆盖空库初始化、中文邮件和附件、IMAP APPEND/EXPUNGE、HTTP 旧会话撤销、协议码及客户端码撤销后的旧连接拒绝、过载恢复。三轮均无 OOM、资源采集错误或清理错误;SQL 测试账号已锁定,桥接已停止。
证据为 `apex-runtime-results.json`、`apex-runtime-evidence/` 和 `apex-evidence-index.json`,共十二份下载文件的摘要已经逐一复核。`apex-live-final.json` 再次确认六个保护服务和正式程序摘要未变,没有运行中的本轮隔离服务。该回归使用隔离域名和手动测试证书,不冒充公网域名验收、真实自动续期或新候选的 Nginx 正向复验。
## 静态扫描边界
Windows 主机设置 Linux/amd64、禁用 CGO,对 `./config` 运行原有 `gosec v2.22.10`。55 个文件、11,378 行,包加载错误零,原有 `nosec` 为 4;七条告警均为中等级,G304 五条、G302 两条,退出码 1。逐条按规则、文件、行号和代码片段与上一轮全项目报告中的配置包比较,集合完全一致。
原始日志为 `gosec-apex-config.log`,完整提取的 JSON 为 `gosec-apex-config.json`,SHA-256 为 `7c0eed5bd0cb48ab5fd926c431a8155671c5f8418aaeaa9830e49512c2ca7db9`。扫描后配置目录九十四个源码文件与构建时清单一致。这里只复扫变更包,不把它说成又一次全项目扫描;上一候选全项目三十八条仍是未通过记录,其他待办没有关闭。
## 保留的工具失败与证据边界
首次修复后,两个测试已经退出零,但验收辅助工具无条件再次停止已被 systemd 回收的短时单元,收到退出 5,将该轮判为失败;原始记录保留在 `ssl-apex-after/`,没有改成通过。
后续辅助工具额外读取 `LoadState`、`ActiveState`、`MainPID`,仅在进程为零、状态不活动且明确 `not-found` 时接受该停止返回值;其他停止错误仍拒绝。新建隔离轮次重新执行后通过。失败记录没有重置,正式服务进程标识和正式主程序摘要均保持不变。
原有浏览器、凭据轮换、外部邮件最终收件、迁移进程重启、容量和异机灾备等门禁仍保留,详见 `生产补充验收记录.md`。

View File

@ -29,6 +29,7 @@
- [✓] 新候选在三种数据库下验证协议码和客户端码撤销:三协议旧连接操作及新登录均被拒绝。
- [✓] HTTP 退出后原 Cookie 重放、账号禁用及修改密码后的旧会话拒绝;浏览器操作另行验收。
- [✓] 管理员恢复整数边界及系统程序路径整改,云端 Linux 包 41 个顶层测试零跳过通过;新候选三库空数据实机复验通过。
- [✓] 修复自动 DNS 证书加载与导出遗漏根域名校验,保留整改前云端失败反例;新候选的证书专项与三库回归通过。
- [ ] 真正应用进程切库重启后的业务验收、故障注入和中断恢复。
- [ ] 最新候选浏览器初始化、账号恢复及完整用户操作闭环。
- [!] Windows 提权本地通道验收需要管理员会话,当前终端未提权。
@ -291,5 +292,11 @@ Windows 受保护本地通道验收需要提权会话,不得降级成公开 TC
全项目静态复扫实际结果为 38 条,退出码仍为 1;不增加 `nosec`,不以降低六条告警替代生产放行。独立浏览器启动被执行策略拒绝,准备实例和隧道已经停止,既未复用 Scert 浏览器,也没有浏览器通过证据。正式程序、六个保护服务及用户原有报告均保持不变。
### 9 月 21 日根域名证书校验补充
详见 `根域名证书校验补充验收.md`。云端反例证实仅有泛域名的证书会被自动 DNS 配置复验、加载及导出错误接受;现统一加入根域名要求,并验证失败时保留有效导出文件及内存证书。云端配置证书专项 21 项、SSL 初始化包 26 项零跳过通过,最新主程序 `bb451caf67f6421253c5b4edddc6fce9961ac04613c4fe3166db36a465291939` 的三库空数据回归也已通过。
本次配置包静态复扫仍为原有七条,退出码 1;不是再次全项目复扫。测试仍不替代真实 DNS 自动续期、全协议换证、浏览器或正式实例升级。
本轮整理明确列出的应用整改、回归测试、验收工具、持续集成和本记录;原有 `docs/云端全项目测试报告.md` 的用户修改不编辑、不加入本轮提交。
本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。

View File

@ -43,6 +43,12 @@ JSON 从上述日志末尾完整提取至同目录 `gosec-revocation-linux.json`
原始输出为 `test-log/production-remediation-20260921/gosec-admin-final-linux.log`,完整提取 JSON 为同名 `.json`。管理员恢复的三条 G115、一条 G204 和两条 G104 已不再出现;其中关闭错误的显式忽略仅澄清意图,不能宣传为修复了两个已确认漏洞。旧七条专项证据仍有效,但扫描本身没有消除它们;其余三十一条仍未关闭。
### 根域名证书校验修复后的配置包复扫
随后在 `37bb937` 基线上修复共享证书校验遗漏根域名的问题,详见 `根域名证书校验补充验收.md`。本次仅重新扫描变更的 `./config` 包:55 个文件、11,378 行、七条中等级告警(G304 五条、G302 两条),包加载错误零,原有 `nosec` 为 4,退出码 1。
原始文件为 `gosec-apex-config.log`,JSON 从同一完整日志提取。七条告警按规则、文件、行号、代码片段与上述全项目报告的配置包逐项比较完全一致;不增加忽略、不更改失败码。不能把变更包复扫描述为又一次全项目扫描,也不能认为未变告警已经排除。
## 已形成专项证据的七条告警
下表行号相对当前候选的 `backend/`。结论限于所列风险和代码路径,不涵盖整个迁移事务、所有输入来源、并发一致性或插件隔离能力。