diff --git a/backend/internal/bootstrap/database.go b/backend/internal/bootstrap/database.go index 977c3d7..4d0a0ac 100644 --- a/backend/internal/bootstrap/database.go +++ b/backend/internal/bootstrap/database.go @@ -248,22 +248,39 @@ func (m *DatabaseVerificationManager) resolveSession(token string) (databaseVeri return session, nil } +// DiscardAll 回收所有尚未消费的数据库验证令牌及其临时凭据。 func (m *DatabaseVerificationManager) DiscardAll() { + m.DiscardAllExcept() +} + +// DiscardAllExcept 回收全部尚未消费的数据库验证令牌,但保留已经成为正式配置依赖的凭据。 +func (m *DatabaseVerificationManager) DiscardAllExcept(protectedReferences ...string) { if m == nil { return } + protected := protectedCredentialReferences(protectedReferences) m.mu.Lock() references := make([]string, 0, len(m.sessions)) for token, session := range m.sessions { delete(m.sessions, token) - if session.CredentialRef != "" { - references = append(references, session.CredentialRef) + if reference := strings.TrimSpace(session.CredentialRef); reference != "" && !protected[reference] { + references = append(references, reference) } } m.mu.Unlock() m.deleteCredentials(references) } +func protectedCredentialReferences(references []string) map[string]bool { + protected := make(map[string]bool, len(references)) + for _, reference := range references { + if reference = strings.TrimSpace(reference); reference != "" { + protected[reference] = true + } + } + return protected +} + func (m *DatabaseVerificationManager) reserve() error { now := m.now().UTC() m.mu.Lock() diff --git a/backend/internal/bootstrap/database_test.go b/backend/internal/bootstrap/database_test.go index 697ca4d..8e5b894 100644 --- a/backend/internal/bootstrap/database_test.go +++ b/backend/internal/bootstrap/database_test.go @@ -200,6 +200,44 @@ func TestDatabaseVerificationDiscardOnlyRemovesSelectedToken(t *testing.T) { } } +func TestDatabaseVerificationDiscardAllExceptProtectsFormalCredential(t *testing.T) { + base := config.Default(t.TempDir()) + store := &memoryDatabaseStore{} + manager := NewDatabaseVerificationManager(base, store) + manager.verify = func(context.Context, config.DatabaseConfig) error { return nil } + + protected, err := manager.Verify(t.Context(), DatabaseInput{ + Driver: "mysql", Host: "db.example.test", Database: "scert", + Username: "formal_user", Password: "formal-password", TLSMode: "true", + }) + if err != nil { + t.Fatal(err) + } + pending, err := manager.Verify(t.Context(), DatabaseInput{ + Driver: "postgres", Host: "db.example.test", Database: "scert", + Username: "pending_user", Password: "pending-password", TLSMode: "verify-full", + }) + if err != nil { + t.Fatal(err) + } + + manager.DiscardAllExcept(" " + protected.CredentialRef + " ") + + if _, err := manager.Resolve(protected.VerificationToken); err == nil { + t.Fatal("批量清理后受保护凭据对应的验证令牌仍然有效") + } + if _, err := manager.Resolve(pending.VerificationToken); err == nil { + t.Fatal("批量清理后待处理凭据对应的验证令牌仍然有效") + } + store.mu.Lock() + _, protectedExists := store.values[protected.CredentialRef] + _, pendingExists := store.values[pending.CredentialRef] + store.mu.Unlock() + if !protectedExists || pendingExists { + t.Fatalf("数据库凭据保护范围错误:protected=%v pending=%v", protectedExists, pendingExists) + } +} + func TestDatabaseVerificationResolveIsServerOnlyAndDoesNotConsumeToken(t *testing.T) { base := config.Default(t.TempDir()) store := &memoryDatabaseStore{} diff --git a/backend/internal/setup/dns_verification.go b/backend/internal/setup/dns_verification.go index d6e6411..76c510b 100644 --- a/backend/internal/setup/dns_verification.go +++ b/backend/internal/setup/dns_verification.go @@ -225,15 +225,26 @@ func (m *DNSVerificationManager) Discard(token string) error { // DiscardAll 回收所有尚未消费的 DNS 验证令牌及其临时凭据。 func (m *DNSVerificationManager) DiscardAll() { + m.DiscardAllExcept() +} + +// DiscardAllExcept 回收全部尚未消费的 DNS 验证令牌,但保留已经成为正式配置依赖的凭据。 +func (m *DNSVerificationManager) DiscardAllExcept(protectedReferences ...string) { if m == nil { return } + protected := make(map[string]bool, len(protectedReferences)) + for _, reference := range protectedReferences { + if reference = strings.TrimSpace(reference); reference != "" { + protected[reference] = true + } + } m.mu.Lock() references := make([]string, 0, len(m.sessions)) for token, session := range m.sessions { delete(m.sessions, token) - if strings.TrimSpace(session.CredentialRef) != "" { - references = append(references, session.CredentialRef) + if reference := strings.TrimSpace(session.CredentialRef); reference != "" && !protected[reference] { + references = append(references, reference) } } m.mu.Unlock() diff --git a/backend/internal/setup/dns_verification_test.go b/backend/internal/setup/dns_verification_test.go index 2a69c1a..297a04f 100644 --- a/backend/internal/setup/dns_verification_test.go +++ b/backend/internal/setup/dns_verification_test.go @@ -191,6 +191,41 @@ func TestDNSVerificationDiscardAllRemovesPendingCredentials(t *testing.T) { } } +func TestDNSVerificationDiscardAllExceptProtectsFormalCredential(t *testing.T) { + store := &memorySecretWriter{} + manager := NewDNSVerificationManager(fakeDNSTester{}, store) + protected, err := manager.Verify(t.Context(), DNSVerificationInput{ + ProviderID: "aliyun", TestDomain: "formal.example.com", + Credentials: map[string]string{"access_key_id": "formal-id", "access_key_secret": "formal-secret"}, + }) + if err != nil { + t.Fatal(err) + } + pending, err := manager.Verify(t.Context(), DNSVerificationInput{ + ProviderID: "aliyun", TestDomain: "pending.example.com", + Credentials: map[string]string{"access_key_id": "pending-id", "access_key_secret": "pending-secret"}, + }) + if err != nil { + t.Fatal(err) + } + + manager.DiscardAllExcept(" " + protected.CredentialRef + " ") + + if _, err := manager.Resolve(protected.VerificationToken); err == nil { + t.Fatal("批量清理后受保护凭据对应的验证令牌仍然有效") + } + if _, err := manager.Resolve(pending.VerificationToken); err == nil { + t.Fatal("批量清理后待处理凭据对应的验证令牌仍然有效") + } + store.mu.Lock() + _, protectedExists := store.values[protected.CredentialRef] + _, pendingExists := store.values[pending.CredentialRef] + store.mu.Unlock() + if !protectedExists || pendingExists { + t.Fatalf("DNS 凭据保护范围错误:protected=%v pending=%v", protectedExists, pendingExists) + } +} + func TestDNSVerificationManagerRejectsExpiredToken(t *testing.T) { store := &memorySecretWriter{} manager := NewDNSVerificationManager(fakeDNSTester{}, store)