领取初始化验证凭据并防止过期清理误删

This commit is contained in:
xuans 2026-09-16 19:13:25 +08:00
parent 8615296565
commit efb405ea11
6 changed files with 316 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@ -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("过期且未领取的数据库凭据未回收")
}
}

View File

@ -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 凭据未回收")
}
}

View File

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