完善DNS清理重试与容量边界

This commit is contained in:
xuans 2026-09-30 12:22:35 +08:00
parent 4a4946979b
commit 1aaa16d103
6 changed files with 192 additions and 4 deletions

View File

@ -101,6 +101,7 @@ type DNSVerificationManager struct {
now func() time.Time
sessions map[string]dnsVerificationSession
expiredSession map[string]expiredDNSVerificationSession
pendingCleanup map[string]struct{}
inFlight int
}
@ -108,7 +109,9 @@ func NewDNSVerificationManager(tester DNSCredentialTester, store SecretWriter) *
deleter, _ := store.(SecretDeleter)
return &DNSVerificationManager{
tester: tester, store: store, deleter: deleter, now: time.Now,
sessions: make(map[string]dnsVerificationSession), expiredSession: make(map[string]expiredDNSVerificationSession),
sessions: make(map[string]dnsVerificationSession),
expiredSession: make(map[string]expiredDNSVerificationSession),
pendingCleanup: make(map[string]struct{}),
}
}
@ -116,6 +119,7 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
if m == nil || m.tester == nil || m.store == nil {
return DNSVerificationResult{}, errors.New("DNS 在线验证服务未初始化")
}
m.retryPendingCredentialCleanup()
providerID := strings.ToLower(strings.TrimSpace(input.ProviderID))
testDomain := normalizeTestDomain(input.TestDomain)
if providerID == "" {
@ -164,7 +168,7 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
}
credentialRef := fmt.Sprintf("secret://dns/%s/%s", providerID, credentialID)
if err := m.store.Put(ctx, credentialRef, cleanedCredentials); err != nil {
cleanupErr := m.deleteCredential(credentialRef)
cleanupErr := m.deleteOrQueueCredential(credentialRef)
if cleanupErr != nil {
m.logCleanupFailure()
return DNSVerificationResult{}, errors.New("加密保存 DNS 凭据失败,临时凭据回收未确认")
@ -172,7 +176,7 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
return DNSVerificationResult{}, fmt.Errorf("加密保存 DNS 凭据失败:%w", err)
}
if err := ctx.Err(); err != nil {
cleanupErr := m.deleteCredential(credentialRef)
cleanupErr := m.deleteOrQueueCredential(credentialRef)
if cleanupErr != nil {
m.logCleanupFailure()
return DNSVerificationResult{TestReport: testReport}, errors.Join(
@ -243,6 +247,7 @@ func (m *DNSVerificationManager) Discard(token string) error {
if m == nil {
return errors.New("DNS 在线验证服务未初始化")
}
m.retryPendingCredentialCleanup()
token = strings.TrimSpace(token)
if token == "" {
return errors.New("DNS 验证令牌不能为空")
@ -277,6 +282,7 @@ func (m *DNSVerificationManager) DiscardAllExcept(protectedReferences ...string)
if m == nil {
return
}
m.retryPendingCredentialCleanup()
protected := make(map[string]bool, len(protectedReferences))
for _, reference := range protectedReferences {
if reference = strings.TrimSpace(reference); reference != "" {
@ -309,6 +315,7 @@ func (m *DNSVerificationManager) validateSession(token, providerID, credentialRe
if token == "" {
return errors.New("必须先完成 DNS API 在线验证")
}
m.retryPendingCredentialCleanup()
now := m.now().UTC()
m.mu.Lock()
expiredRefs := m.clearExpiredLocked(now)
@ -341,6 +348,7 @@ func (m *DNSVerificationManager) resolveSession(token string, claim bool) (dnsVe
if token == "" {
return dnsVerificationSession{}, errors.New("必须先完成 DNS API 在线验证")
}
m.retryPendingCredentialCleanup()
now := m.now().UTC()
m.mu.Lock()
expiredRefs := m.clearExpiredLocked(now)
@ -368,7 +376,7 @@ func (m *DNSVerificationManager) reserveVerificationSlot() error {
m.deleteCredentials(expiredRefs)
return errors.New("DNS 在线验证请求过多,请稍后重试")
}
if len(m.sessions)+m.inFlight >= dnsVerificationSessionCapacity {
if len(m.sessions)+len(m.expiredSession)+m.inFlight >= dnsVerificationSessionCapacity {
m.mu.Unlock()
m.deleteCredentials(expiredRefs)
return errors.New("DNS 验证令牌数量已达上限,请等待现有验证完成或过期后重试")
@ -458,6 +466,52 @@ func (m *DNSVerificationManager) forgetExpiredSession(reference string) {
m.mu.Unlock()
}
func (m *DNSVerificationManager) rememberPendingCleanup(reference string) {
if m == nil || strings.TrimSpace(reference) == "" {
return
}
m.mu.Lock()
m.pendingCleanup[reference] = struct{}{}
m.mu.Unlock()
}
func (m *DNSVerificationManager) forgetPendingCleanup(reference string) {
if m == nil || strings.TrimSpace(reference) == "" {
return
}
m.mu.Lock()
delete(m.pendingCleanup, reference)
m.mu.Unlock()
}
func (m *DNSVerificationManager) retryPendingCredentialCleanup() {
if m == nil {
return
}
m.mu.Lock()
references := make([]string, 0, len(m.pendingCleanup))
for reference := range m.pendingCleanup {
references = append(references, reference)
}
m.mu.Unlock()
for _, reference := range references {
if err := m.deleteCredential(reference); err != nil {
m.logCleanupFailure()
continue
}
m.forgetPendingCleanup(reference)
}
}
func (m *DNSVerificationManager) deleteOrQueueCredential(reference string) error {
if err := m.deleteCredential(reference); err != nil {
m.rememberPendingCleanup(reference)
return err
}
m.forgetPendingCleanup(reference)
return nil
}
func (m *DNSVerificationManager) logCleanupFailure() {
log.Print("DNS 临时凭据回收失败")
}

View File

@ -66,6 +66,11 @@ type retryableSecretWriter struct {
failDelete bool
}
type cancellingRetryableSecretWriter struct {
retryableSecretWriter
cancel context.CancelFunc
}
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
@ -81,6 +86,14 @@ func (s *retryableSecretWriter) Delete(ctx context.Context, reference string) er
return s.memorySecretWriter.Delete(ctx, reference)
}
func (s *cancellingRetryableSecretWriter) Put(ctx context.Context, reference string, value map[string]string) error {
if err := s.retryableSecretWriter.Put(ctx, reference, value); err != nil {
return err
}
s.cancel()
return nil
}
func (m *memorySecretWriter) Put(_ context.Context, reference string, value map[string]string) error {
m.mu.Lock()
defer m.mu.Unlock()
@ -290,6 +303,62 @@ func TestDNSVerificationManagerDeletesCredentialWhenPutReportsFailure(t *testing
}
}
func TestDNSVerificationManagerRetriesCleanupAfterPutFailure(t *testing.T) {
store := &retryableSecretWriter{failDelete: true}
store.putErr = errors.New("write failed")
manager := NewDNSVerificationManager(fakeDNSTester{}, store)
_, err := manager.Verify(t.Context(), DNSVerificationInput{
ProviderID: "cloudflare", TestDomain: "example.com",
Credentials: map[string]string{"api_token": "secret"},
})
if err == nil {
t.Fatal("保存失败时应返回错误")
}
if len(store.values) != 1 || len(manager.pendingCleanup) != 1 {
t.Fatalf("删除失败后应保留待清理凭据:values=%d pending=%d", len(store.values), len(manager.pendingCleanup))
}
store.putErr = nil
store.failDelete = false
if _, err := manager.Verify(t.Context(), DNSVerificationInput{
ProviderID: "cloudflare", TestDomain: "example.com",
Credentials: map[string]string{"api_token": "next-secret"},
}); err != nil {
t.Fatalf("下一次验证应先重试待清理凭据:%v", err)
}
if len(manager.pendingCleanup) != 0 || len(store.values) != 1 {
t.Fatalf("待清理凭据重试后状态错误:pending=%d values=%d", len(manager.pendingCleanup), len(store.values))
}
}
func TestDNSVerificationManagerRetriesCleanupAfterCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
store := &cancellingRetryableSecretWriter{
retryableSecretWriter: retryableSecretWriter{failDelete: true},
cancel: cancel,
}
manager := NewDNSVerificationManager(fakeDNSTester{}, store)
_, err := manager.Verify(ctx, DNSVerificationInput{
ProviderID: "cloudflare", TestDomain: "example.com",
Credentials: map[string]string{"api_token": "secret"},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("保存后取消应返回 context.Canceled:%v", err)
}
if len(store.values) != 1 || len(manager.pendingCleanup) != 1 {
t.Fatalf("取消后的删除失败应进入待清理状态:values=%d pending=%d", len(store.values), len(manager.pendingCleanup))
}
store.failDelete = false
if _, err := manager.Resolve("missing-token"); err == nil {
t.Fatal("不存在的令牌应返回错误")
}
if len(store.values) != 0 || len(manager.pendingCleanup) != 0 {
t.Fatalf("取消后的凭据重试未完成:values=%d pending=%d", len(store.values), len(manager.pendingCleanup))
}
}
func TestDNSVerificationManagerRejectsAlreadyCancelledContextBeforeTesting(t *testing.T) {
tester := &countingDNSTester{}
store := &memorySecretWriter{}
@ -423,6 +492,29 @@ func TestDNSVerificationManagerRejectsCapacityBeforeTestingOrStoring(t *testing.
}
}
func TestDNSVerificationManagerCountsExpiredCleanupAgainstCapacity(t *testing.T) {
tester := &countingDNSTester{}
store := &memorySecretWriter{}
manager := NewDNSVerificationManager(tester, store)
manager.sessions = make(map[string]dnsVerificationSession, dnsVerificationSessionCapacity-1)
manager.expiredSession = make(map[string]expiredDNSVerificationSession, 1)
expiresAt := time.Now().UTC().Add(time.Hour)
for index := 0; index < dnsVerificationSessionCapacity-1; index++ {
manager.sessions[string(rune(index))] = dnsVerificationSession{CredentialRef: "secret://existing", ExpiresAt: expiresAt}
}
manager.expiredSession["secret://pending"] = expiredDNSVerificationSession{
Token: "pending-token",
Session: dnsVerificationSession{CredentialRef: "secret://pending", ExpiresAt: expiresAt},
}
_, err := manager.Verify(t.Context(), DNSVerificationInput{
ProviderID: "cloudflare", TestDomain: "example.com", Credentials: map[string]string{"api_token": "secret"},
})
if err == nil || tester.CallCount() != 0 || len(store.values) != 0 {
t.Fatalf("待清理凭据应计入容量限制:err=%v calls=%d values=%v", err, tester.CallCount(), store.values)
}
}
func TestDNSVerificationManagerRejectsInFlightCapacityBeforeTestingOrStoring(t *testing.T) {
tester := &countingDNSTester{}
store := &memorySecretWriter{}

View File

@ -3780,3 +3780,13 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无
- [~] 进行中:DNS 初始化回滚矩阵、令牌生命周期、其他 P0-10 任务链、跨数据库实机验证和统一复审。
- [ ] 未开始:P0-12、真实数据库清理初始化、密码 SSH、远端多服务器部署及最终生产门禁。
- [!] 不得标记 `[✓]`,总体验收仍为 **P0-03:完成,待复审**。
## 本轮开发进度(2026-09-30,P0-10 DNS 清理状态与容量边界)
总体验收继续保持 **P0-03:完成,待复审**。本轮补齐 DNS 临时凭据在保存失败、请求取消和过期清理失败时的待清理状态,并将清理中的过期会话计入容量限制。
- [x] 已完成,等待复审:临时凭据删除失败时保留可重试引用,后续管理操作自动重试。
- [x] 已完成,等待复审:待清理过期会话占用验证容量,防止清理竞态导致超限。
- [x] 已完成:回归测试、竞态测试、全量测试、静态检查和差异检查通过。
- [~] 继续执行:DNS 初始化回滚、令牌消费、P0-10 任务链、三数据库实机矩阵和统一复审。
- [!] 不新增 `[✓]`,不得以本地测试结果替代真实生产验收。

View File

@ -1085,3 +1085,13 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全
- [~] 继续执行:DNS 初始化回滚、令牌消费、任务链、三数据库实机矩阵和统一复审。
- [ ] 尚未执行:P0-12、真实生产数据库清理、密码 SSH、远端部署、真实 HTTPS/DNS/ACME 和最终生产门禁。
- [!] 不新增 `[✓]`;总体验收继续为 **P0-03:完成,待复审**。
## 本轮执行记录(2026-09-30,P0-10 DNS 清理状态与容量边界)
本轮开发仍处于 **P0-03:完成,待复审**,未进入生产部署验收通过状态。
- [x] 待清理凭据:保存失败或请求取消后的删除失败引用进入重试集合,后续操作自动执行补偿清理。
- [x] 容量边界:过期会话清理失败并等待重试期间,继续计入会话容量,避免并发超限。
- [x] 验证结果:`go test ./...`、`go test -race ./internal/setup`、`go vet ./...`、`git diff --check` 已通过。
- [~] 继续执行:初始化回滚、令牌消费、任务链、三数据库实机矩阵、生产环境测试和统一复审。
- [!] 本地自动化验证不得替代密码 SSH、远端部署、真实 DNS/ACME 和最终生产门禁;不得新增 `[✓]`。

View File

@ -1733,3 +1733,14 @@ P0-11 生产验收门禁
- [~] 进行中:DNS 初始化回滚、令牌消费、其他 P0-10 任务链、跨数据库实机矩阵和统一复审。
- [ ] 未开始:P0-12 备份恢复、密码 SSH、远端部署、真实 HTTPS/DNS/ACME 和最终生产门禁。
- [!] 总体验收不得升级:仍为 **P0-03:完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
## 本轮开发进度(2026-09-30,P0-10 DNS 清理状态与容量边界)
总体验收位置继续保持 **P0-03:完成,待复审**;本轮仅补齐 DNS 临时凭据清理状态和并发容量边界,不代表 P0-10 统一复审通过或生产部署验收通过。
- [x] 已完成,等待复审:保存失败或请求取消后的临时凭据删除失败时,记录待清理引用,并在后续验证、令牌解析或清理操作前自动重试。
- [x] 已完成,等待复审:过期清理失败的会话和待清理状态均计入验证会话容量,避免并发新建验证超过容量上限。
- [x] 已完成:新增保存失败重试、取消后重试和容量边界回归测试;`go test ./...`、`go test -race ./internal/setup`、`go vet ./...` 和 `git diff --check` 均通过。
- [~] 进行中:DNS 初始化回滚、令牌消费、其他 P0-10 任务链、跨数据库实机矩阵和统一复审。
- [ ] 未开始:P0-12、真实生产数据库清理与初始化、密码 SSH、远端多服务器部署、真实 HTTPS/DNS/ACME、Linux/Windows 服务运行验证和最终生产门禁。
- [!] 不得标记 `[✓]`;总体验收仍为 **P0-03:完成,待复审**。

View File

@ -657,3 +657,14 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收
- [x] 已新增对应回归测试,并通过后端全量测试、静态检查和差异检查。
- [~] DNS 初始化回滚、令牌消费及 P0-10 其他任务链继续开发和统一复审。
- [!] 本地自动化结果不得替代真实 DNS/ACME、密码 SSH、远端多服务器部署和最终生产门禁;不得新增 `[✓]`。
## 本轮规格执行记录(2026-09-30,P0-10 DNS 清理状态与容量边界)
本轮仅完成 DNS 临时凭据清理状态和容量边界的开发与本地回归,总体验收继续保持 **P0-03:完成,待复审**。
- [x] 删除失败的临时凭据必须保留待清理引用,并在后续管理操作中重试,不能形成不可追踪残留。
- [x] 请求取消后若临时凭据回收失败,后续操作必须能够完成补偿清理。
- [x] 清理失败的过期会话必须计入验证容量,避免恢复会话时突破容量上限。
- [x] 已新增对应回归测试,并通过全量测试、竞态测试、静态检查和差异检查。
- [~] DNS 初始化回滚、令牌消费及 P0-10 其他任务链、实机矩阵和统一复审继续进行。
- [!] 本地结果不得替代真实 DNS/ACME、密码 SSH、远端多服务器部署和最终生产门禁;不得新增 `[✓]`。