From 4ec81eecab90ed133a7ec827ac7ddb18fd8d19b1 Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 30 Sep 2026 12:05:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84DNS=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E6=B8=85=E7=90=86=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在单个和批量DNS临时凭据删除失败时保留可重试会话,保持正式配置凭据保护语义,补充回归测试并更新整改清单。 --- backend/internal/setup/dns_verification.go | 35 ++++++++-- .../internal/setup/dns_verification_test.go | 64 +++++++++++++++++++ doc/Scert开发任务清单.md | 11 ++++ doc/Scert整改任务执行清单.md | 11 ++++ doc/Scert整改合并优化清单.md | 11 ++++ doc/基础整改任务规格.md | 10 +++ 6 files changed, 137 insertions(+), 5 deletions(-) diff --git a/backend/internal/setup/dns_verification.go b/backend/internal/setup/dns_verification.go index 4cbcd5a..48b2f7a 100644 --- a/backend/internal/setup/dns_verification.go +++ b/backend/internal/setup/dns_verification.go @@ -243,7 +243,7 @@ func (m *DNSVerificationManager) Discard(token string) error { } now := m.now().UTC() m.mu.Lock() - expiredRefs := m.clearExpiredLocked(now) + expiredRefs := m.clearExpiredExceptLocked(now, token) session, ok := m.sessions[token] if ok { delete(m.sessions, token) @@ -254,6 +254,7 @@ func (m *DNSVerificationManager) Discard(token string) error { return errors.New("DNS 验证令牌无效或已经过期") } if err := m.deleteCredential(session.CredentialRef); err != nil { + m.restoreSession(token, session) m.logCleanupFailure() return errors.New("DNS 临时凭据回收失败") } @@ -277,15 +278,21 @@ func (m *DNSVerificationManager) DiscardAllExcept(protectedReferences ...string) } } m.mu.Lock() - references := make([]string, 0, len(m.sessions)) + sessions := make(map[string]dnsVerificationSession, len(m.sessions)) for token, session := range m.sessions { delete(m.sessions, token) - if reference := strings.TrimSpace(session.CredentialRef); reference != "" && !protected[reference] { - references = append(references, reference) + if reference := strings.TrimSpace(session.CredentialRef); reference != "" && protected[reference] { + continue } + sessions[token] = session } m.mu.Unlock() - m.deleteCredentials(references) + for token, session := range sessions { + if err := m.deleteCredential(session.CredentialRef); err != nil { + m.restoreSession(token, session) + m.logCleanupFailure() + } + } } func (m *DNSVerificationManager) validateSession(token, providerID, credentialRef, testDomain string, consume bool) error { @@ -378,8 +385,15 @@ func (m *DNSVerificationManager) releaseVerificationSlot() { } func (m *DNSVerificationManager) clearExpiredLocked(now time.Time) []string { + return m.clearExpiredExceptLocked(now, "") +} + +func (m *DNSVerificationManager) clearExpiredExceptLocked(now time.Time, excludedToken string) []string { var references []string for token, session := range m.sessions { + if token == excludedToken { + continue + } if !session.ExpiresAt.After(now) { delete(m.sessions, token) references = append(references, session.CredentialRef) @@ -388,6 +402,17 @@ func (m *DNSVerificationManager) clearExpiredLocked(now time.Time) []string { return references } +func (m *DNSVerificationManager) restoreSession(token string, session dnsVerificationSession) { + if m == nil || strings.TrimSpace(token) == "" { + return + } + m.mu.Lock() + if _, exists := m.sessions[token]; !exists { + m.sessions[token] = session + } + m.mu.Unlock() +} + func (m *DNSVerificationManager) deleteCredentials(references []string) { for _, reference := range references { if err := m.deleteCredential(reference); err != nil { diff --git a/backend/internal/setup/dns_verification_test.go b/backend/internal/setup/dns_verification_test.go index 11fad47..c448dc4 100644 --- a/backend/internal/setup/dns_verification_test.go +++ b/backend/internal/setup/dns_verification_test.go @@ -61,6 +61,11 @@ type cancellingSecretWriter struct { cancel context.CancelFunc } +type retryableSecretWriter struct { + memorySecretWriter + failDelete bool +} + func (s *cancellingSecretWriter) Put(ctx context.Context, reference string, value map[string]string) error { if err := s.memorySecretWriter.Put(ctx, reference, value); err != nil { return err @@ -69,6 +74,13 @@ func (s *cancellingSecretWriter) Put(ctx context.Context, reference string, valu return nil } +func (s *retryableSecretWriter) Delete(ctx context.Context, reference string) error { + if s.failDelete { + return errors.New("delete failed") + } + return s.memorySecretWriter.Delete(ctx, reference) +} + func (m *memorySecretWriter) Put(_ context.Context, reference string, value map[string]string) error { m.mu.Lock() defer m.mu.Unlock() @@ -320,6 +332,58 @@ func TestDNSVerificationManagerCleansCredentialWhenContextCancelsAfterStore(t *t } } +func TestDNSVerificationManagerRetainsTokenWhenDiscardCleanupFails(t *testing.T) { + store := &retryableSecretWriter{failDelete: true} + manager := NewDNSVerificationManager(fakeDNSTester{}, store) + result, err := manager.Verify(t.Context(), DNSVerificationInput{ + ProviderID: "cloudflare", TestDomain: "example.com", + Credentials: map[string]string{"api_token": "secret"}, + }) + if err != nil { + t.Fatal(err) + } + if err := manager.Discard(result.VerificationToken); err == nil { + t.Fatal("临时凭据回收失败时应返回错误") + } + if _, err := manager.Resolve(result.VerificationToken); err != nil { + t.Fatalf("回收失败后应保留验证令牌以便重试:%v", err) + } + if len(store.values) != 1 { + t.Fatalf("回收失败后不应丢失临时凭据:%v", store.values) + } + + store.failDelete = false + if err := manager.Discard(result.VerificationToken); err != nil { + t.Fatalf("临时凭据回收重试失败:%v", err) + } + if len(store.values) != 0 { + t.Fatalf("回收重试后仍有临时凭据:%v", store.values) + } +} + +func TestDNSVerificationManagerRetriesDiscardAllCleanupFailures(t *testing.T) { + store := &retryableSecretWriter{failDelete: true} + manager := NewDNSVerificationManager(fakeDNSTester{}, store) + for range 2 { + if _, err := manager.Verify(t.Context(), DNSVerificationInput{ + ProviderID: "cloudflare", TestDomain: "example.com", + Credentials: map[string]string{"api_token": "secret"}, + }); err != nil { + t.Fatal(err) + } + } + + manager.DiscardAll() + if len(manager.sessions) != 2 || len(store.values) != 2 { + t.Fatalf("批量回收失败后应保留可重试会话:sessions=%d values=%d", len(manager.sessions), len(store.values)) + } + store.failDelete = false + manager.DiscardAll() + if len(manager.sessions) != 0 || len(store.values) != 0 { + t.Fatalf("批量回收重试后仍有临时状态:sessions=%d values=%d", len(manager.sessions), len(store.values)) + } +} + type countingDNSTester struct { mu sync.Mutex calls int diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 3c5cd54..b8b26bf 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3758,3 +3758,14 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - [~] 进行中:DNS 清理失败与初始化回滚矩阵、其他 P0-10 任务链、跨数据库实机验证和统一复审。 - [ ] 未开始:P0-12、真实数据库清理初始化、密码 SSH、远端多服务器部署及最终生产门禁。 - [!] 不得标记 `[✓]`,总体验收仍为 **P0-03:完成,待复审**。 + +## 本轮开发进度(2026-09-30,P0-10 DNS 临时凭据清理失败可重试) + +总体验收继续保持 **P0-03:完成,待复审**,本轮继续先开发、后统一测试。 + +- [x] 已完成,等待复审:单个 DNS 临时凭据回收失败时保留验证会话,下一次清理可以重试。 +- [x] 已完成,等待复审:批量 DNS 临时凭据回收失败时仅恢复失败会话,已成功回收的会话不重复恢复。 +- [x] 已完成:新增清理失败重试回归测试;后端全量测试、静态检查和差异检查均通过。 +- [~] 进行中:DNS 过期清理与初始化回滚矩阵、其他 P0-10 任务链、跨数据库实机验证和统一复审。 +- [ ] 未开始:P0-12、真实数据库清理初始化、密码 SSH、远端多服务器部署及最终生产门禁。 +- [!] 不得标记 `[✓]`,总体验收仍为 **P0-03:完成,待复审**。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index f8a64e4..9a78270 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1063,3 +1063,14 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - [~] 继续执行:DNS 清理失败、令牌生命周期、初始化回滚、任务链、三数据库实机矩阵和统一复审。 - [ ] 尚未执行:P0-12、真实生产数据库清理、密码 SSH、远端部署、真实 HTTPS/DNS/ACME 和最终生产门禁。 - [!] 不新增 `[✓]`;总体验收继续为 **P0-03:完成,待复审**。 + +## 本轮执行记录(2026-09-30,P0-10 DNS 临时凭据清理失败可重试) + +本轮按“先开发、后统一测试”执行,总体验收仍保持 **P0-03:完成,待复审**。 + +- [x] 单个 DNS 临时凭据回收失败后保留验证令牌,修复为可重试清理。 +- [x] 批量 DNS 临时凭据回收失败后仅恢复失败会话,受保护正式凭据仍不受影响。 +- [x] `go test ./...`、`go vet ./...`、`git diff --check` 已通过;本轮仅完成本地开发与自动化回归。 +- [~] 继续执行:DNS 过期清理、初始化回滚、令牌生命周期、任务链、三数据库实机矩阵和统一复审。 +- [ ] 尚未执行:P0-12、真实生产数据库清理、密码 SSH、远端部署、真实 HTTPS/DNS/ACME 和最终生产门禁。 +- [!] 不新增 `[✓]`;总体验收继续为 **P0-03:完成,待复审**。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 38f0bbe..df80d3c 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1711,3 +1711,14 @@ P0-11 生产验收门禁 - [~] 进行中:P0-10 DNS 清理失败、令牌消费、任务链、跨数据库实机矩阵、崩溃恢复、重试幂等和统一复审仍需继续。 - [ ] 未开始:P0-12 备份恢复、浏览器清缓存、密码 SSH、远端部署、真实 HTTPS/DNS/ACME 和最终生产门禁仍未完成。 - [!] 总体验收不得升级:仍为 **P0-03:完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 + +## 本轮开发进度(2026-09-30,P0-10 DNS 临时凭据清理失败可重试) + +总体验收位置继续保持 **P0-03:完成,待复审**;本轮仅修复 DNS 临时凭据删除失败后的重试边界,不代表 P0-10 统一复审通过或生产部署验收通过。 + +- [x] 已完成,等待复审:单个 DNS 验证会话清理失败时保留令牌和临时凭据,后续可再次调用清理,不再形成不可恢复的残留状态。 +- [x] 已完成,等待复审:批量清理对删除失败的会话逐项恢复,成功删除的会话不恢复;受保护正式凭据仍只保留正式配置,不保留验证令牌。 +- [x] 已完成:新增单个和批量清理失败重试回归测试;后端 `go test ./...`、`go vet ./...` 和 `git diff --check` 通过。 +- [~] 进行中:DNS 过期清理、初始化回滚、令牌消费、其他 P0-10 任务链、跨数据库实机矩阵和统一复审。 +- [ ] 未开始:P0-12 备份恢复、密码 SSH、远端部署、真实 HTTPS/DNS/ACME 和最终生产门禁。 +- [!] 总体验收不得升级:仍为 **P0-03:完成,待复审**,本轮没有新增 `[✓]` 复审通过项。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index 1e01f84..cb440d0 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -637,3 +637,13 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收 - [x] 已新增对应回归测试,并通过后端全量测试、静态检查和差异检查。 - [~] DNS 清理失败、初始化回滚、令牌消费及 P0-10 其他任务链继续开发和统一复审。 - [!] 本地自动化结果不得替代真实 DNS/ACME、密码 SSH、远端多服务器部署和最终生产门禁;不得新增 `[✓]`。 + +## 本轮规格执行记录(2026-09-30,P0-10 DNS 临时凭据清理失败可重试) + +本轮仅补齐 DNS 临时凭据清理失败后的可重试要求,总体验收继续保持 **P0-03:完成,待复审**。 + +- [x] 单个临时凭据删除失败时必须保留验证会话,允许后续重试,不能静默丢失清理入口。 +- [x] 批量删除失败时必须逐项恢复失败会话,成功删除的临时凭据不得恢复;正式配置依赖的凭据不得删除。 +- [x] 已新增对应回归测试,并通过后端全量测试、静态检查和差异检查。 +- [~] DNS 过期清理、初始化回滚、令牌消费及 P0-10 其他任务链继续开发和统一复审。 +- [!] 本地自动化结果不得替代真实 DNS/ACME、密码 SSH、远端多服务器部署和最终生产门禁;不得新增 `[✓]`。