将授权探测纳入执行租约保护并补充撤权回归

This commit is contained in:
xuans 2026-10-04 07:51:41 +08:00
parent 88aa5bb940
commit 9872298e14
9 changed files with 152 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@ -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` 已提交推送,未跟踪文件 `=` 保留。本批不操作第二台业务、不干扰浏览器发信配置。

View File

@ -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 模块通过。

View File

@ -2,6 +2,8 @@
## 2026年10月4日07:40 DNS 执行链进展
07:51 补充:补齐授权探测临时 TXT 的逐域名授权和不确定清理引用保护,本地三包及 vet、十轮专项通过;本轮第一台旧提交验证仍在运行,不能提前计入探测增量。
- [x] 独立账户执行租约已接通证书任务与自动续期执行器,重试依真实尝试序号重新授权,不允许系统凭据回退。
- [x] 新建域名首次绑定原子保存;完整配置快照、底层写入租约保护及 409 响应完成编码。
- [✓] 本地五包全量与 vet、仓储十轮和接口五轮专项、前端 339 项及构建通过。MySQL/PostgreSQL 本地跳过,不能作为实库结果。

View File

@ -2,6 +2,8 @@
## 2026年10月4日07:40 执行租约边界复核
07:51 补充:授权探测的临时 TXT 与 ACME 挑战一样受租约保护,每域名重新授权,探测失败保留未知外部状态引用。本地三包及 vet、专项十轮通过,第一台增量待验证。
- [x] 完成租约迁移二十九、真实任务授权、固定凭据引用、取消清理及活动引用保护;新建域名与首次绑定改为原子事务。
- [x] 完整签发配置比较避免同秒修改漏检,底层更新/删除加锁并拒绝活动租约;接口忙状态映射 409。
- [✓] 本地五包全量与 vet、租约十轮及接口五轮专项、前端 339 项和构建通过;首轮 SAN 测试夹具错误已修正并保留失败说明。

View File

@ -2,6 +2,8 @@
## 2026年10月4日07:40 执行租约与原子创建进度
07:51 补充:授权探测也已纳入逐域名租约校验及不确定清理引用保护,本地三包全量与 vet、独立账户十轮专项通过。第一台 `0743` 单元验证固定 `f17ea6c`,仍待终态;探测增量需另行 Linux 复审。
- [x] P1-03 已接入真实任务尝试的 DNS 执行租约,固定账户版本及秘密引用;取消后使用原凭据清理,清理或释放不确定则保留引用。未实现的崩溃遗留核对与安全回收仍是门禁。
- [x] 创建域名、初始 DNS 绑定及审计原子保存;前端一次创建并核对返回绑定,不再先创建再绑定。完整配置快照比较及底层更新、删除租约保护已补齐,忙状态接口返回 409。
- [✓] 本地五包全量、vet、租约十轮专项、接口五轮专项通过;前端再次全量 339 项与构建通过。本地实库未启用,跳过不计三库结果。

View File

@ -2,6 +2,7 @@
## 2026年10月4日07:40 DNS 执行与原子绑定契约
- 授权探测同样可能写临时 TXT,每个域名调用前必须核验取消状态和真实租约。探测错误无法确认外部清理时保留引用,即使 ACME Provider 尚未创建;之后的成功探测不得清除先前未知状态。
- 新建域名可携带 `dns_account_id`、`dns_account_revision`;绑定标识由服务端生成,域名、绑定、审计共用事务。普通资料更新拒绝这些初始绑定字段,编辑仍使用独立绑定接口及真实旧令牌。
- 租约绑定真实运行中的任务、尝试、序号、项目、域名、账户版本及秘密引用。自动续期必须同时满足服务端调度标记、真实无操作者续期任务和域名启用续期;手工重试额外要求重试权限。
- 获取租约在域名行锁内比较完整签发配置,不依赖秒级时间戳;SAN 按实际数组比较。活动租约阻止账户变更、绑定切换及两层域名资料更新/删除,证书状态更新不受影响。