完善DNS临时凭据清理重试

在单个和批量DNS临时凭据删除失败时保留可重试会话,保持正式配置凭据保护语义,补充回归测试并更新整改清单。
This commit is contained in:
xuans 2026-09-30 12:05:18 +08:00
parent debcc67fb7
commit 4ec81eecab
6 changed files with 137 additions and 5 deletions

View File

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

View File

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

View File

@ -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:完成,待复审**。

View File

@ -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:完成,待复审**。

View File

@ -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:完成,待复审**,本轮没有新增 `[✓]` 复审通过项。

View File

@ -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、远端多服务器部署和最终生产门禁;不得新增 `[✓]`。