From 9872298e14c404fc4a0ab76f5f7193d503135570 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 4 Oct 2026 07:51:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E6=8E=88=E6=9D=83=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E6=89=A7=E8=A1=8C=E7=A7=9F=E7=BA=A6=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E5=B9=B6=E8=A1=A5=E5=85=85=E6=92=A4=E6=9D=83=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/certificate/dns_execution.go | 35 +++++++++ .../certificate/dns_execution_test.go | 74 ++++++++++++++++++- backend/internal/certificate/service.go | 24 +++++- doc/DNS多账户整改任务清单.md | 10 +++ doc/Linux生产流程测试记录.md | 7 ++ doc/Scert开发任务清单.md | 2 + doc/Scert整改任务执行清单.md | 2 + doc/Scert整改合并优化清单.md | 2 + doc/核心能力任务规格.md | 1 + 9 files changed, 152 insertions(+), 5 deletions(-) diff --git a/backend/internal/certificate/dns_execution.go b/backend/internal/certificate/dns_execution.go index 6488aef..2071b8a 100644 --- a/backend/internal/certificate/dns_execution.go +++ b/backend/internal/certificate/dns_execution.go @@ -28,6 +28,41 @@ func (s *Service) SetDNSExecutionLeaseStore(store DNSExecutionLeaseStore) { } } +// 授权探测也会写临时 TXT;错误可能表示远端写入或清理结果未知。 +// 不以缺少 ACME 挑战记录作为释放依据,成功探测才确认本次引用可以收尾。 +type leasedDNSAuthorization struct { + checker DNSAuthorizationChecker + store DNSExecutionLeaseStore + lease domain.DNSExecutionLease + mu sync.Mutex + pending int +} + +func (a *leasedDNSAuthorization) VerifyDomain(ctx context.Context, providerID, name string, credentials map[string]string) error { + if err := ctx.Err(); err != nil { + return err + } + if err := a.store.ValidateDNSExecutionLease(ctx, a.lease); err != nil { + return errors.New("DNS 执行授权已失效,未执行授权探测") + } + a.mu.Lock() + a.pending++ + a.mu.Unlock() + if err := a.checker.VerifyDomain(ctx, providerID, name, credentials); err != nil { + return err + } + a.mu.Lock() + a.pending-- + a.mu.Unlock() + return nil +} + +func (a *leasedDNSAuthorization) clean() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.pending == 0 +} + // 每次写挑战前再次检查任务和权限;清理只能使用原 Provider 和固定凭据。 // 失败的 Present 也可能已写入远端,因此必须等待匹配的 CleanUp 成功才释放引用。 type leasedDNSProvider struct { diff --git a/backend/internal/certificate/dns_execution_test.go b/backend/internal/certificate/dns_execution_test.go index 2d764d8..a1b1a13 100644 --- a/backend/internal/certificate/dns_execution_test.go +++ b/backend/internal/certificate/dns_execution_test.go @@ -85,8 +85,14 @@ func (i executionIssuer) Obtain(ctx context.Context, _ ObtainRequest) (Resource, return i.run(ctx) } +type executionAuthorization func(context.Context, string, string, map[string]string) error + +func (a executionAuthorization) VerifyDomain(ctx context.Context, provider, name string, credentials map[string]string) error { + return a(ctx, provider, name, credentials) +} + func TestIndependentDNSExecutionLifecycle(t *testing.T) { - for _, scenario := range []string{"success", "acquire-failed", "validate-failed", "credential-failed", "present-failed", "cleanup-failed", "missing-cleanup", "release-failed", "cancelled", "revoked-before-present", "revoked-before-cleanup"} { + for _, scenario := range []string{"success", "acquire-failed", "validate-failed", "credential-failed", "present-failed", "cleanup-failed", "missing-cleanup", "release-failed", "cancelled", "revoked-before-present", "revoked-before-cleanup", "authorization-failed", "revoked-before-authorization", "cancelled-before-authorization"} { t.Run(scenario, func(t *testing.T) { cfg := certificateTestConfig(t.TempDir()) cfg.DNS.Provider, cfg.DNS.CredentialRef, cfg.DNS.VerificationState = "aliyun", "secret://dns/system-must-not-read", "unverified" @@ -108,6 +114,28 @@ func TestIndependentDNSExecutionLifecycle(t *testing.T) { defer cancel() ctx = WithDNSExecutionIdentity(ctx, domain.DNSExecutionIdentity{TaskID: "task", AttemptID: "attempt", AttemptNumber: 2}) failure := errors.New("测试失败") + authorizationCalls := 0 + service.authorization = executionAuthorization(func(ctx context.Context, id, name string, credentials map[string]string) error { + authorizationCalls++ + if id != "cloudflare" || name != item.Name || credentials["api_token"] != "independent-test-only" { + t.Fatal("授权探测未使用租约的固定账户") + } + if scenario == "authorization-failed" { + return failure + } + return nil + }) + ctx = WithProgressReporter(ctx, func(_ context.Context, _, message string) error { + if strings.HasPrefix(message, "正在验证域名 ") { + if scenario == "revoked-before-authorization" { + store.validateErr = failure + } + if scenario == "cancelled-before-authorization" { + cancel() + } + } + return nil + }) switch scenario { case "acquire-failed": store.acquireErr = failure @@ -165,7 +193,7 @@ func TestIndependentDNSExecutionLifecycle(t *testing.T) { if scenario != "acquire-failed" && scenario != "validate-failed" && reader.reference != "secret://dns/independent" { t.Fatal("未读取固定凭据") } - retained := scenario == "present-failed" || scenario == "cleanup-failed" || scenario == "missing-cleanup" || scenario == "acquire-failed" + retained := scenario == "present-failed" || scenario == "cleanup-failed" || scenario == "missing-cleanup" || scenario == "acquire-failed" || scenario == "authorization-failed" if (store.released == 0) != retained { t.Fatalf("不确定挑战清理时释放引用,或正常收尾未释放:%d", store.released) } @@ -178,10 +206,52 @@ func TestIndependentDNSExecutionLifecycle(t *testing.T) { if scenario == "release-failed" && (err == nil || !strings.Contains(err.Error(), "租约释放失败")) { t.Fatal("释放失败未上报") } + if scenario == "authorization-failed" && (authorizationCalls != 1 || !strings.Contains(err.Error(), "授权探测清理尚未确认")) { + t.Fatal("探测失败后未保留不确定清理引用") + } + if (scenario == "revoked-before-authorization" || scenario == "cancelled-before-authorization") && authorizationCalls != 0 { + t.Fatal("撤权或取消后仍发起远端授权探测") + } }) } } +func TestIndependentDNSAuthorizationEachDomainRevalidates(t *testing.T) { + store := &executionLeaseStub{} + calls := 0 + checker := &leasedDNSAuthorization{store: store, checker: executionAuthorization(func(context.Context, string, string, map[string]string) error { + calls++ + store.validateErr = errors.New("权限已撤回") + return nil + })} + service := &Service{} + err := service.verifyDNSAuthorizationUsing(t.Context(), "cloudflare", []string{"one.example.test", "two.example.test"}, nil, checker) + if err == nil || calls != 1 || store.validated != 2 || !checker.clean() { + t.Fatal("第二个域名前撤权应停止探测,已确认清理的引用可释放", err, calls, store.validated) + } +} + +func TestIndependentDNSAuthorizationFailureCannotBeClearedByLaterSuccess(t *testing.T) { + store := &executionLeaseStub{} + calls := 0 + checker := &leasedDNSAuthorization{store: store, checker: executionAuthorization(func(context.Context, string, string, map[string]string) error { + calls++ + if calls == 1 { + return errors.New("探测远端状态不确定") + } + return nil + })} + if err := checker.VerifyDomain(t.Context(), "cloudflare", "one.example.test", nil); err == nil { + t.Fatal("首次探测应失败") + } + if err := checker.VerifyDomain(t.Context(), "cloudflare", "two.example.test", nil); err != nil { + t.Fatal(err) + } + if checker.clean() { + t.Fatal("后续域名成功不能清除先前未知外部状态") + } +} + func TestIndependentDNSExecutionRequiresTaskContext(t *testing.T) { item, _ := domain.NewDomain("lease.example.test", domain.CertificateSingle, nil) item.DNSAccountID, item.DNSBindingID = "account", "binding" diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 0a40ede..5a6d7f3 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -170,6 +170,7 @@ func (s *Service) Issue(ctx context.Context, domainID string) (result IssueResul credentialRef := s.config.DNS.CredentialRef var dnsLease domain.DNSExecutionLease var guardedProvider *leasedDNSProvider + var guardedAuthorization *leasedDNSAuthorization var lease *httpchallenge.Lease if independent { if challengeType != domain.ChallengeDNS01 || s.credentials == nil || s.providers == nil { @@ -184,6 +185,10 @@ func (s *Service) Issue(ctx context.Context, domainID string) (result IssueResul return IssueResult{}, errors.New("获取独立 DNS 账户执行租约失败,未使用系统凭据") } defer func() { + if guardedAuthorization != nil && !guardedAuthorization.clean() { + returnErr = errors.Join(returnErr, errors.New("DNS 授权探测清理尚未确认,执行租约及原凭据引用已保留")) + return + } if guardedProvider != nil && !guardedProvider.clean() { returnErr = errors.Join(returnErr, errors.New("DNS 挑战清理尚未确认,执行租约及原凭据引用已保留")) return @@ -240,7 +245,12 @@ func (s *Service) Issue(ctx context.Context, domainID string) (result IssueResul if err := ReportProgress(ctx, "dns_validation", "正在验证 DNS 凭据权限"); err != nil { return fail(err) } - if err := s.verifyDNSAuthorization(ctx, providerID, domains, credentials); err != nil { + checker := s.authorization + if independent && checker != nil { + guardedAuthorization = &leasedDNSAuthorization{checker: checker, store: s.dnsLeases, lease: dnsLease} + checker = guardedAuthorization + } + if err := s.verifyDNSAuthorizationUsing(ctx, providerID, domains, credentials, checker); err != nil { return fail(err) } if err := ReportProgress(ctx, "certificate_issue", "DNS 凭据验证通过,正在申请证书"); err != nil { @@ -379,11 +389,19 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq } func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, domains []string, credentials map[string]string) error { + var checker DNSAuthorizationChecker + if s != nil { + checker = s.authorization + } + return s.verifyDNSAuthorizationUsing(ctx, providerID, domains, credentials, checker) +} + +func (s *Service) verifyDNSAuthorizationUsing(ctx context.Context, providerID string, domains []string, credentials map[string]string, checker DNSAuthorizationChecker) error { // 必须先校验全部 SAN;后续域名越界时也不能先写入前面的域名。 if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil { return err } - if s == nil || s.authorization == nil { + if checker == nil { if strings.EqualFold(strings.TrimSpace(providerID), dnsprovider.Aliyun) { return errors.New("阿里云权限探测器未初始化,已阻止证书申请") } @@ -393,7 +411,7 @@ func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, if err := ReportProgress(ctx, "dns_validation", fmt.Sprintf("正在验证域名 %s 的 DNS 权限", requestedDomain)); err != nil { return err } - if err := s.authorization.VerifyDomain(ctx, providerID, requestedDomain, credentials); err != nil { + if err := checker.VerifyDomain(ctx, providerID, requestedDomain, credentials); err != nil { return fmt.Errorf("DNS 凭据无权操作域名 %s:%w", requestedDomain, err) } } diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md index 987bf2b..c2140ad 100644 --- a/doc/DNS多账户整改任务清单.md +++ b/doc/DNS多账户整改任务清单.md @@ -1,5 +1,15 @@ # DNS 多账户整改任务清单 +## 2026年10月4日07:45 授权探测引用保护 + +第一台 `scert-dns-leases-20261004-0743.service` 正在验证固定提交 `f17ea6c`,不停止、不重启该任务。复核发现授权探测同样可能写临时 TXT,不能只保护 ACME Provider 的挑战记录。 + +- [✓] 修改前检查点 `88aa5bb` 已推送;首次鉴权失败后重推成功。 +- [x] 每个域名授权探测前重新验证租约及取消状态,防止读取凭据后撤权仍写 DNS。 +- [x] 探测返回错误而外部清理未知时保留租约,不能以尚未创建 ACME Provider 为由释放引用;后续域名成功也不能清除先前未知状态。 +- [✓] 本地证书、HTTP、app 三包全量和 vet 通过;独立账户专项十轮通过,补取消、撤权、多域名与清理未知测试。源码换行检查通过。 +- [~] 第一台增量复审待执行;原 `0743` 单元仍在验证固定旧提交,不将其结果算作探测保护增量已通过。 + ## 2026年10月4日07:30 执行租约边界复核 沿用已确认的 Linux 与 Web 整改范围。修改前检查点 `7a3b78d` 已提交推送,未跟踪文件 `=` 保留。本批不操作第二台业务、不干扰浏览器发信配置。 diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 6b13a67..f0d0a84 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -2,6 +2,13 @@ ## 2026年10月4日07:40 执行租约复审准备 +### 07:51 运行状态与授权探测增量 + +- 第一台干净 `f17ea6c99e3bd4472813853294347bdfad0b8b17` 已于 07:43:33 运行 `scert-dns-leases-20261004-0743.service`;观察 07:49:16 为 active/running,已进入 MySQL 租约专项,仍待最终结果。 +- 源码 `/var/tmp/scert-dns-leases-20261004-0743/source`;日志同级 `review.log`。bundle 双端 SHA-256 一致:`3b362fd536022ccefcf1ac808a80fb750e83921e92e29f17e8c2e4ca3b84f2ef`。首次按 main 读取 bundle 失败,核对实际只含 HEAD 后成功导入,不改写验证结果。 +- 授权探测增量在本地单独验证:证书全量 6.524 秒、HTTP 26.459 秒、app 11.122 秒及 vet 通过;独立账户十轮专项 8.896 秒通过。后端 761、前端 119 个已跟踪源码换行检查通过。 +- 原 `0743` 单元不包含授权探测增量,必须追加固定新提交复审;未因观察等待重启任务。 + - 修改前检查点 `7a3b78d` 已提交推送,之后补齐完整配置快照、底层域名租约保护及接口冲突回归。 - 本地 `go test ./internal/repository ./internal/certificate ./internal/database ./internal/httpapi ./internal/app` 和同范围 vet 通过;仓储 8.790 秒、HTTP 26.759 秒、app 11.943 秒,certificate/database 为缓存结果。 - 租约专项十轮通过,原子创建及租约冲突接口专项五轮通过;前端重新执行 339 项,零失败零跳过,构建 103 模块通过。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 551e68d..001b740 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -2,6 +2,8 @@ ## 2026年10月4日07:40 DNS 执行链进展 +07:51 补充:补齐授权探测临时 TXT 的逐域名授权和不确定清理引用保护,本地三包及 vet、十轮专项通过;本轮第一台旧提交验证仍在运行,不能提前计入探测增量。 + - [x] 独立账户执行租约已接通证书任务与自动续期执行器,重试依真实尝试序号重新授权,不允许系统凭据回退。 - [x] 新建域名首次绑定原子保存;完整配置快照、底层写入租约保护及 409 响应完成编码。 - [✓] 本地五包全量与 vet、仓储十轮和接口五轮专项、前端 339 项及构建通过。MySQL/PostgreSQL 本地跳过,不能作为实库结果。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index f46d650..b19a967 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -2,6 +2,8 @@ ## 2026年10月4日07:40 执行租约边界复核 +07:51 补充:授权探测的临时 TXT 与 ACME 挑战一样受租约保护,每域名重新授权,探测失败保留未知外部状态引用。本地三包及 vet、专项十轮通过,第一台增量待验证。 + - [x] 完成租约迁移二十九、真实任务授权、固定凭据引用、取消清理及活动引用保护;新建域名与首次绑定改为原子事务。 - [x] 完整签发配置比较避免同秒修改漏检,底层更新/删除加锁并拒绝活动租约;接口忙状态映射 409。 - [✓] 本地五包全量与 vet、租约十轮及接口五轮专项、前端 339 项和构建通过;首轮 SAN 测试夹具错误已修正并保留失败说明。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 9fb59b3..b209463 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -2,6 +2,8 @@ ## 2026年10月4日07:40 执行租约与原子创建进度 +07:51 补充:授权探测也已纳入逐域名租约校验及不确定清理引用保护,本地三包全量与 vet、独立账户十轮专项通过。第一台 `0743` 单元验证固定 `f17ea6c`,仍待终态;探测增量需另行 Linux 复审。 + - [x] P1-03 已接入真实任务尝试的 DNS 执行租约,固定账户版本及秘密引用;取消后使用原凭据清理,清理或释放不确定则保留引用。未实现的崩溃遗留核对与安全回收仍是门禁。 - [x] 创建域名、初始 DNS 绑定及审计原子保存;前端一次创建并核对返回绑定,不再先创建再绑定。完整配置快照比较及底层更新、删除租约保护已补齐,忙状态接口返回 409。 - [✓] 本地五包全量、vet、租约十轮专项、接口五轮专项通过;前端再次全量 339 项与构建通过。本地实库未启用,跳过不计三库结果。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index e3b1e2c..5e3edd2 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -2,6 +2,7 @@ ## 2026年10月4日07:40 DNS 执行与原子绑定契约 +- 授权探测同样可能写临时 TXT,每个域名调用前必须核验取消状态和真实租约。探测错误无法确认外部清理时保留引用,即使 ACME Provider 尚未创建;之后的成功探测不得清除先前未知状态。 - 新建域名可携带 `dns_account_id`、`dns_account_revision`;绑定标识由服务端生成,域名、绑定、审计共用事务。普通资料更新拒绝这些初始绑定字段,编辑仍使用独立绑定接口及真实旧令牌。 - 租约绑定真实运行中的任务、尝试、序号、项目、域名、账户版本及秘密引用。自动续期必须同时满足服务端调度标记、真实无操作者续期任务和域名启用续期;手工重试额外要求重试权限。 - 获取租约在域名行锁内比较完整签发配置,不依赖秒级时间戳;SAN 按实际数组比较。活动租约阻止账户变更、绑定切换及两层域名资料更新/删除,证书状态更新不受影响。