From efb405ea11bde24f2efe71aa8b2dc7df1f419ad9 Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 16 Sep 2026 19:13:25 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=86=E5=8F=96=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=87=AD=E6=8D=AE=E5=B9=B6=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E6=B8=85=E7=90=86=E8=AF=AF=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/bootstrap/complete.go | 46 +++----- backend/internal/bootstrap/complete_test.go | 61 ++++++++++ backend/internal/bootstrap/database.go | 20 +++- .../internal/bootstrap/database_claim_test.go | 100 +++++++++++++++++ backend/internal/setup/dns_claim_test.go | 104 ++++++++++++++++++ backend/internal/setup/dns_verification.go | 21 +++- 6 files changed, 316 insertions(+), 36 deletions(-) create mode 100644 backend/internal/bootstrap/database_claim_test.go create mode 100644 backend/internal/setup/dns_claim_test.go diff --git a/backend/internal/bootstrap/complete.go b/backend/internal/bootstrap/complete.go index bd22cb0..304d928 100644 --- a/backend/internal/bootstrap/complete.go +++ b/backend/internal/bootstrap/complete.go @@ -82,16 +82,12 @@ type CompleteResult struct { } type completionDatabaseSessions interface { - Resolve(string) (DatabaseVerificationSession, error) - Consume(string, string) (config.DatabaseConfig, error) - Discard(string) error + Claim(string) (DatabaseVerificationSession, func() error, error) DiscardAllExcept(...string) } type completionDNSSessions interface { - Resolve(string) (setup.DNSVerificationSession, error) - Consume(string, string, string, string) error - Discard(string) error + Claim(string) (setup.DNSVerificationSession, func() error, error) DiscardAllExcept(...string) } @@ -220,30 +216,28 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques return result, completeError(CompleteErrorInput, "初始化参数无效", err) } - databaseSession, err := completer.databaseSessions.Resolve(request.DatabaseVerificationToken) + databaseSession, discardDatabase, err := completer.databaseSessions.Claim(request.DatabaseVerificationToken) if err != nil { return result, completeError(CompleteErrorVerification, "数据库验证结果无效或已经过期,请重新测试", err) } - databaseResolved := true - dnsResolved := false + var discardDNS func() error committed := false defer func() { if committed { return } - if dnsResolved { - completer.discardDNS(request.DNSVerificationToken) + if discardDNS != nil { + completer.cleanupCredential(discardDNS, "失败初始化的 DNS 临时凭据回收未确认") } - if databaseResolved { - completer.discardDatabase(request.DatabaseVerificationToken) + if discardDatabase != nil { + completer.cleanupCredential(discardDatabase, "失败初始化的数据库临时凭据回收未确认") } }() - dnsSession, err := completer.dnsSessions.Resolve(request.DNSVerificationToken) + dnsSession, discardDNS, err := completer.dnsSessions.Claim(request.DNSVerificationToken) if err != nil { return result, completeError(CompleteErrorVerification, "DNS 验证结果无效或已经过期,请重新测试", err) } - dnsResolved = true if err := validateResolvedDNS(request, dnsSession); err != nil { return result, completeError(CompleteErrorVerification, "DNS 验证结果与管理入口不匹配,请重新验证", err) } @@ -340,7 +334,7 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques completer.completed = true completer.base = candidate result = CompleteResult{Completed: true, ManagementURL: managementURL(candidate)} - result.Warnings = completer.afterCommit(request, candidate, dnsSession, databaseSession) + result.Warnings = completer.afterCommit(candidate) return result, nil } @@ -462,7 +456,7 @@ func validateCompletionCertificatePaths(saved completionCertificate) error { return nil } -func (completer *Completer) afterCommit(request CompleteRequest, candidate config.Config, dnsSession setup.DNSVerificationSession, databaseSession DatabaseVerificationSession) []string { +func (completer *Completer) afterCommit(candidate config.Config) []string { warnings := make([]string, 0, 4) appendWarning := func(message string) { warnings = append(warnings, message) @@ -470,12 +464,6 @@ func (completer *Completer) afterCommit(request CompleteRequest, candidate confi completer.warn(message) } } - if _, err := completer.databaseSessions.Consume(request.DatabaseVerificationToken, databaseSession.CredentialRef); err != nil { - appendWarning("正式配置已保存,但数据库验证令牌清理未确认") - } - if err := completer.dnsSessions.Consume(request.DNSVerificationToken, dnsSession.ProviderID, dnsSession.CredentialRef, dnsSession.TestDomain); err != nil { - appendWarning("正式配置已保存,但 DNS 验证令牌清理未确认") - } completer.sessions.RevokeAll() completer.databaseSessions.DiscardAllExcept(candidate.Database.CredentialRef) completer.dnsSessions.DiscardAllExcept(candidate.DNS.CredentialRef) @@ -487,15 +475,9 @@ func (completer *Completer) afterCommit(request CompleteRequest, candidate confi return warnings } -func (completer *Completer) discardDatabase(token string) { - if err := completer.databaseSessions.Discard(token); err != nil && completer.warn != nil { - completer.warn("失败初始化的数据库临时凭据回收未确认") - } -} - -func (completer *Completer) discardDNS(token string) { - if err := completer.dnsSessions.Discard(token); err != nil && completer.warn != nil { - completer.warn("失败初始化的 DNS 临时凭据回收未确认") +func (completer *Completer) cleanupCredential(cleanup func() error, message string) { + if err := cleanup(); err != nil && completer.warn != nil { + completer.warn(message) } } diff --git a/backend/internal/bootstrap/complete_test.go b/backend/internal/bootstrap/complete_test.go index a6ce839..152dfc4 100644 --- a/backend/internal/bootstrap/complete_test.go +++ b/backend/internal/bootstrap/complete_test.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" + "database/sql" "encoding/pem" "errors" "math/big" @@ -22,6 +23,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/database" "git.pcux.cn/xuans/Scert/backend/internal/dnsverification" "git.pcux.cn/xuans/Scert/backend/internal/firewall" + "git.pcux.cn/xuans/Scert/backend/internal/repository" "git.pcux.cn/xuans/Scert/backend/internal/setup" ) @@ -169,6 +171,12 @@ func newCompletionFixture(t *testing.T, domain string) completionFixture { t.Helper() baseDir := t.TempDir() base := config.Default(baseDir) + // Linux 默认路径指向真实安装目录,测试必须显式隔离所有可写路径。 + base.Paths = config.PathsConfig{ + Install: baseDir, Config: filepath.Join(baseDir, "conf"), Data: filepath.Join(baseDir, "data"), + Certificate: filepath.Join(baseDir, "ssl"), Log: filepath.Join(baseDir, "log"), Backup: filepath.Join(baseDir, "backups"), + } + base.Database.DSN = filepath.Join(base.Paths.Data, "scert.db") configPath := filepath.Join(base.Paths.Config, "scert.json") store := newCompletionMemorySecrets() databaseManager := NewDatabaseVerificationManager(base, store) @@ -377,6 +385,59 @@ func TestCompleterSupportsPublicIPManagementCertificate(t *testing.T) { } } +func TestCompleterOwnsCredentialsDuringSlowSubmission(t *testing.T) { + for _, failSave := range []bool{false, true} { + name := "success" + if failSave { + name = "failure" + } + t.Run(name, func(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + verified, err := fixture.database.Verify(t.Context(), DatabaseInput{ + Driver: "mysql", Host: "db.example.test", Database: "scert", Username: "owner", Password: "test-password", + }) + if err != nil { + t.Fatal(err) + } + request := fixture.request("admin.example.test") + request.DatabaseVerificationToken = verified.VerificationToken + result, err := fixture.completer(func(dependencies *CompleteDependencies) { + // 外部数据库仅模拟连接适配;这里检验凭据生命周期,不冒充 MySQL 实机验收。 + dependencies.OpenDatabase = func(ctx context.Context, _ config.DatabaseConfig) (*database.Database, error) { + return database.Open(ctx, fixture.base.Database) + } + dependencies.Migrate = func(ctx context.Context, db *sql.DB, _ string) error { + return database.Migrate(ctx, db, "sqlite") + } + dependencies.EnsureAdmin = func(ctx context.Context, db *sql.DB, _ string, user repository.User) error { + return repository.NewSQLUserRepository(db, "sqlite").EnsureSuperAdmin(ctx, user) + } + dependencies.SaveConfig = func(candidate config.Config, path string) error { + fixture.database.now = func() time.Time { return verified.ExpiresAt.Add(time.Second) } + _, _ = fixture.database.Resolve(verified.VerificationToken) + fixture.database.DiscardAll() + fixture.dns.DiscardAll() + if !fixture.store.has(verified.CredentialRef) || !fixture.store.has(fixture.dnsRef) { + t.Fatal("提交期间清理删除了事务持有的凭据") + } + if failSave { + return errors.New("save failed") + } + return candidate.Save(path) + } + }).Complete(t.Context(), request) + if failSave { + if err == nil || fixture.store.has(verified.CredentialRef) || fixture.store.has(fixture.dnsRef) { + t.Fatalf("失败提交必须回收已领取凭据:%v", err) + } + } else if err != nil || !result.Completed || len(result.Warnings) != 0 || + !fixture.store.has(verified.CredentialRef) || !fixture.store.has(fixture.dnsRef) { + t.Fatalf("成功提交未保留凭据:%+v,%v", result, err) + } + }) + } +} + func completionDomainResource(name string) certificate.Resource { privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { diff --git a/backend/internal/bootstrap/database.go b/backend/internal/bootstrap/database.go index 4d0a0ac..e509df1 100644 --- a/backend/internal/bootstrap/database.go +++ b/backend/internal/bootstrap/database.go @@ -160,7 +160,7 @@ func (m *DatabaseVerificationManager) Consume(token, credentialRef string) (conf // Resolve 按验证令牌读取完整初始化所需的服务端状态,但不消费令牌。 func (m *DatabaseVerificationManager) Resolve(token string) (DatabaseVerificationSession, error) { - session, err := m.resolveSession(token) + session, err := m.resolveSession(token, false) if err != nil { return DatabaseVerificationSession{}, err } @@ -171,6 +171,19 @@ func (m *DatabaseVerificationManager) Resolve(token string) (DatabaseVerificatio }, nil } +// Claim 原子领取验证结果,并把凭据回收责任移交给初始化事务。 +// 领取后令牌立即失效,不再受过期或批量清理影响;提交失败时必须调用返回的回收函数。 +func (m *DatabaseVerificationManager) Claim(token string) (DatabaseVerificationSession, func() error, error) { + session, err := m.resolveSession(token, true) + if err != nil { + return DatabaseVerificationSession{}, nil, err + } + cleanup := sync.OnceValue(func() error { return m.deleteCredential(session.CredentialRef) }) + return DatabaseVerificationSession{ + Config: session.Config, CredentialRef: session.CredentialRef, ExpiresAt: session.ExpiresAt, + }, cleanup, nil +} + // Discard 按验证令牌精确回收尚未消费的数据库凭据,不影响其他初始化会话。 func (m *DatabaseVerificationManager) Discard(token string) error { if m == nil { @@ -228,7 +241,7 @@ func (m *DatabaseVerificationManager) session(token, credentialRef string, consu return session.Config, nil } -func (m *DatabaseVerificationManager) resolveSession(token string) (databaseVerificationSession, error) { +func (m *DatabaseVerificationManager) resolveSession(token string, claim bool) (databaseVerificationSession, error) { if m == nil { return databaseVerificationSession{}, errors.New("数据库验证服务尚未初始化") } @@ -240,6 +253,9 @@ func (m *DatabaseVerificationManager) resolveSession(token string) (databaseVeri m.mu.Lock() expired := m.clearExpiredLocked(now) session, ok := m.sessions[token] + if ok && claim { + delete(m.sessions, token) + } m.mu.Unlock() m.deleteCredentials(expired) if !ok { diff --git a/backend/internal/bootstrap/database_claim_test.go b/backend/internal/bootstrap/database_claim_test.go new file mode 100644 index 0000000..797ef64 --- /dev/null +++ b/backend/internal/bootstrap/database_claim_test.go @@ -0,0 +1,100 @@ +package bootstrap + +import ( + "context" + "sync" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestDatabaseClaimProtectsCredentialUntilTransactionCleanup(t *testing.T) { + store := newCompletionMemorySecrets() + manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), store) + manager.verify = func(context.Context, config.DatabaseConfig) error { return nil } + result, err := manager.Verify(t.Context(), DatabaseInput{ + Driver: "mysql", Host: "db.example.test", Database: "scert", Username: "owner", Password: "test-password", + }) + if err != nil { + t.Fatal(err) + } + session, cleanup, err := manager.Claim(result.VerificationToken) + if err != nil || session.CredentialRef != result.CredentialRef || cleanup == nil { + t.Fatalf("领取数据库凭据失败:%v", err) + } + manager.now = func() time.Time { return result.ExpiresAt.Add(time.Second) } + if _, err := manager.Resolve(result.VerificationToken); err == nil { + t.Fatal("已领取令牌仍可读取") + } + if _, _, err := manager.Claim(result.VerificationToken); err == nil { + t.Fatal("已领取令牌仍可重复领取") + } + manager.DiscardAll() + if !store.has(result.CredentialRef) { + t.Fatal("过期或批量清理误删了事务持有的数据库凭据") + } + var workers sync.WaitGroup + for range 32 { + workers.Go(func() { + if err := cleanup(); err != nil { + t.Errorf("事务回收失败:%v", err) + } + }) + } + workers.Wait() + store.mu.Lock() + defer store.mu.Unlock() + if len(store.values) != 0 || len(store.deleted) != 1 { + t.Fatalf("事务回收必须且只能删除一次:剩余=%d,删除=%d", len(store.values), len(store.deleted)) + } +} + +func TestDatabaseClaimHasOnlyOneConcurrentOwner(t *testing.T) { + manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), newCompletionMemorySecrets()) + manager.verify = func(context.Context, config.DatabaseConfig) error { return nil } + result, err := manager.Verify(t.Context(), DatabaseInput{Driver: "sqlite"}) + if err != nil { + t.Fatal(err) + } + owners := make(chan func() error, 32) + var workers sync.WaitGroup + for range 32 { + workers.Go(func() { + if _, cleanup, err := manager.Claim(result.VerificationToken); err == nil { + owners <- cleanup + } + }) + } + workers.Wait() + close(owners) + count := 0 + for cleanup := range owners { + count++ + if err := cleanup(); err != nil { + t.Fatal(err) + } + } + if count != 1 { + t.Fatalf("数据库验证结果存在多个事务拥有者:%d", count) + } +} + +func TestDatabaseClaimRejectsExpiredToken(t *testing.T) { + store := newCompletionMemorySecrets() + manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), store) + manager.verify = func(context.Context, config.DatabaseConfig) error { return nil } + result, err := manager.Verify(t.Context(), DatabaseInput{ + Driver: "postgres", Host: "db.example.test", Database: "scert", Username: "owner", Password: "test-password", + }) + if err != nil { + t.Fatal(err) + } + manager.now = func() time.Time { return result.ExpiresAt } + if _, cleanup, err := manager.Claim(result.VerificationToken); err == nil || cleanup != nil { + t.Fatal("已过期的数据库令牌不得被领取") + } + if store.has(result.CredentialRef) { + t.Fatal("过期且未领取的数据库凭据未回收") + } +} diff --git a/backend/internal/setup/dns_claim_test.go b/backend/internal/setup/dns_claim_test.go new file mode 100644 index 0000000..2ff9300 --- /dev/null +++ b/backend/internal/setup/dns_claim_test.go @@ -0,0 +1,104 @@ +package setup + +import ( + "sync" + "testing" + "time" +) + +func TestDNSClaimProtectsCredentialUntilTransactionCleanup(t *testing.T) { + store := &memorySecretWriter{} + manager := NewDNSVerificationManager(fakeDNSTester{}, store) + result, err := manager.Verify(t.Context(), DNSVerificationInput{ + ProviderID: "aliyun", TestDomain: "admin.example.test", + Credentials: map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}, + }) + if err != nil { + t.Fatal(err) + } + session, cleanup, err := manager.Claim(result.VerificationToken) + if err != nil || cleanup == nil || session.CredentialRef != result.CredentialRef || session.TestDomain != "admin.example.test" { + t.Fatalf("领取 DNS 凭据失败:%v", err) + } + manager.now = func() time.Time { return result.ExpiresAt.Add(time.Second) } + if _, err := manager.Resolve(result.VerificationToken); err == nil { + t.Fatal("已领取 DNS 令牌仍可读取") + } + if _, _, err := manager.Claim(result.VerificationToken); err == nil { + t.Fatal("已领取 DNS 令牌仍可重复领取") + } + manager.DiscardAll() + store.mu.Lock() + _, exists := store.values[result.CredentialRef] + store.mu.Unlock() + if !exists { + t.Fatal("过期或批量清理误删了事务持有的 DNS 凭据") + } + var workers sync.WaitGroup + for range 32 { + workers.Go(func() { + if err := cleanup(); err != nil { + t.Errorf("事务回收失败:%v", err) + } + }) + } + workers.Wait() + store.mu.Lock() + defer store.mu.Unlock() + if len(store.values) != 0 || len(store.deleted) != 1 { + t.Fatalf("DNS 事务回收必须且只能删除一次:剩余=%d,删除=%d", len(store.values), len(store.deleted)) + } +} + +func TestDNSClaimHasOnlyOneConcurrentOwner(t *testing.T) { + manager := NewDNSVerificationManager(fakeDNSTester{}, &memorySecretWriter{}) + result, err := manager.Verify(t.Context(), DNSVerificationInput{ + ProviderID: "aliyun", TestDomain: "admin.example.test", + Credentials: map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}, + }) + if err != nil { + t.Fatal(err) + } + owners := make(chan func() error, 32) + var workers sync.WaitGroup + for range 32 { + workers.Go(func() { + if _, cleanup, err := manager.Claim(result.VerificationToken); err == nil { + owners <- cleanup + } + }) + } + workers.Wait() + close(owners) + count := 0 + for cleanup := range owners { + count++ + if err := cleanup(); err != nil { + t.Fatal(err) + } + } + if count != 1 { + t.Fatalf("DNS 验证结果存在多个事务拥有者:%d", count) + } +} + +func TestDNSClaimRejectsExpiredToken(t *testing.T) { + store := &memorySecretWriter{} + manager := NewDNSVerificationManager(fakeDNSTester{}, store) + result, err := manager.Verify(t.Context(), DNSVerificationInput{ + ProviderID: "aliyun", TestDomain: "admin.example.test", + Credentials: map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}, + }) + if err != nil { + t.Fatal(err) + } + manager.now = func() time.Time { return result.ExpiresAt } + if _, cleanup, err := manager.Claim(result.VerificationToken); err == nil || cleanup != nil { + t.Fatal("已过期的 DNS 令牌不得被领取") + } + store.mu.Lock() + defer store.mu.Unlock() + if len(store.values) != 0 { + t.Fatal("过期且未领取的 DNS 凭据未回收") + } +} diff --git a/backend/internal/setup/dns_verification.go b/backend/internal/setup/dns_verification.go index 76c510b..6226565 100644 --- a/backend/internal/setup/dns_verification.go +++ b/backend/internal/setup/dns_verification.go @@ -183,7 +183,7 @@ func (m *DNSVerificationManager) Consume(token, providerID, credentialRef, testD // Resolve 按验证令牌读取完整初始化所需的服务端状态,但不消费令牌。 func (m *DNSVerificationManager) Resolve(token string) (DNSVerificationSession, error) { - session, err := m.resolveSession(token) + session, err := m.resolveSession(token, false) if err != nil { return DNSVerificationSession{}, err } @@ -195,6 +195,20 @@ func (m *DNSVerificationManager) Resolve(token string) (DNSVerificationSession, }, nil } +// Claim 原子领取验证结果,并把凭据回收责任移交给初始化事务。 +// 领取后令牌立即失效,不再受过期或批量清理影响;提交失败时必须调用返回的回收函数。 +func (m *DNSVerificationManager) Claim(token string) (DNSVerificationSession, func() error, error) { + session, err := m.resolveSession(token, true) + if err != nil { + return DNSVerificationSession{}, nil, err + } + cleanup := sync.OnceValue(func() error { return m.deleteCredential(session.CredentialRef) }) + return DNSVerificationSession{ + ProviderID: session.ProviderID, TestDomain: session.TestDomain, + CredentialRef: session.CredentialRef, ExpiresAt: session.ExpiresAt, + }, cleanup, nil +} + // Discard 按验证令牌精确回收尚未消费的 DNS 临时凭据,不影响其他初始化会话。 func (m *DNSVerificationManager) Discard(token string) error { if m == nil { @@ -283,7 +297,7 @@ func (m *DNSVerificationManager) validateSession(token, providerID, credentialRe return nil } -func (m *DNSVerificationManager) resolveSession(token string) (dnsVerificationSession, error) { +func (m *DNSVerificationManager) resolveSession(token string, claim bool) (dnsVerificationSession, error) { if m == nil { return dnsVerificationSession{}, errors.New("DNS 在线验证服务未初始化") } @@ -295,6 +309,9 @@ func (m *DNSVerificationManager) resolveSession(token string) (dnsVerificationSe m.mu.Lock() expiredRefs := m.clearExpiredLocked(now) session, ok := m.sessions[token] + if ok && claim { + delete(m.sessions, token) + } m.mu.Unlock() m.deleteCredentials(expiredRefs) if !ok {