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

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 { type completionDatabaseSessions interface {
Resolve(string) (DatabaseVerificationSession, error) Claim(string) (DatabaseVerificationSession, func() error, error)
Consume(string, string) (config.DatabaseConfig, error)
Discard(string) error
DiscardAllExcept(...string) DiscardAllExcept(...string)
} }
type completionDNSSessions interface { type completionDNSSessions interface {
Resolve(string) (setup.DNSVerificationSession, error) Claim(string) (setup.DNSVerificationSession, func() error, error)
Consume(string, string, string, string) error
Discard(string) error
DiscardAllExcept(...string) DiscardAllExcept(...string)
} }
@ -220,30 +216,28 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
return result, completeError(CompleteErrorInput, "初始化参数无效", err) return result, completeError(CompleteErrorInput, "初始化参数无效", err)
} }
databaseSession, err := completer.databaseSessions.Resolve(request.DatabaseVerificationToken) databaseSession, discardDatabase, err := completer.databaseSessions.Claim(request.DatabaseVerificationToken)
if err != nil { if err != nil {
return result, completeError(CompleteErrorVerification, "数据库验证结果无效或已经过期,请重新测试", err) return result, completeError(CompleteErrorVerification, "数据库验证结果无效或已经过期,请重新测试", err)
} }
databaseResolved := true var discardDNS func() error
dnsResolved := false
committed := false committed := false
defer func() { defer func() {
if committed { if committed {
return return
} }
if dnsResolved { if discardDNS != nil {
completer.discardDNS(request.DNSVerificationToken) completer.cleanupCredential(discardDNS, "失败初始化的 DNS 临时凭据回收未确认")
} }
if databaseResolved { if discardDatabase != nil {
completer.discardDatabase(request.DatabaseVerificationToken) completer.cleanupCredential(discardDatabase, "失败初始化的数据库临时凭据回收未确认")
} }
}() }()
dnsSession, err := completer.dnsSessions.Resolve(request.DNSVerificationToken) dnsSession, discardDNS, err := completer.dnsSessions.Claim(request.DNSVerificationToken)
if err != nil { if err != nil {
return result, completeError(CompleteErrorVerification, "DNS 验证结果无效或已经过期,请重新测试", err) return result, completeError(CompleteErrorVerification, "DNS 验证结果无效或已经过期,请重新测试", err)
} }
dnsResolved = true
if err := validateResolvedDNS(request, dnsSession); err != nil { if err := validateResolvedDNS(request, dnsSession); err != nil {
return result, completeError(CompleteErrorVerification, "DNS 验证结果与管理入口不匹配,请重新验证", err) return result, completeError(CompleteErrorVerification, "DNS 验证结果与管理入口不匹配,请重新验证", err)
} }
@ -340,7 +334,7 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
completer.completed = true completer.completed = true
completer.base = candidate completer.base = candidate
result = CompleteResult{Completed: true, ManagementURL: managementURL(candidate)} result = CompleteResult{Completed: true, ManagementURL: managementURL(candidate)}
result.Warnings = completer.afterCommit(request, candidate, dnsSession, databaseSession) result.Warnings = completer.afterCommit(candidate)
return result, nil return result, nil
} }
@ -462,7 +456,7 @@ func validateCompletionCertificatePaths(saved completionCertificate) error {
return nil 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) warnings := make([]string, 0, 4)
appendWarning := func(message string) { appendWarning := func(message string) {
warnings = append(warnings, message) warnings = append(warnings, message)
@ -470,12 +464,6 @@ func (completer *Completer) afterCommit(request CompleteRequest, candidate confi
completer.warn(message) 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.sessions.RevokeAll()
completer.databaseSessions.DiscardAllExcept(candidate.Database.CredentialRef) completer.databaseSessions.DiscardAllExcept(candidate.Database.CredentialRef)
completer.dnsSessions.DiscardAllExcept(candidate.DNS.CredentialRef) completer.dnsSessions.DiscardAllExcept(candidate.DNS.CredentialRef)
@ -487,15 +475,9 @@ func (completer *Completer) afterCommit(request CompleteRequest, candidate confi
return warnings return warnings
} }
func (completer *Completer) discardDatabase(token string) { func (completer *Completer) cleanupCredential(cleanup func() error, message string) {
if err := completer.databaseSessions.Discard(token); err != nil && completer.warn != nil { if err := cleanup(); err != nil && completer.warn != nil {
completer.warn("失败初始化的数据库临时凭据回收未确认") completer.warn(message)
}
}
func (completer *Completer) discardDNS(token string) {
if err := completer.dnsSessions.Discard(token); err != nil && completer.warn != nil {
completer.warn("失败初始化的 DNS 临时凭据回收未确认")
} }
} }

View File

@ -7,6 +7,7 @@ import (
"crypto/rand" "crypto/rand"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"database/sql"
"encoding/pem" "encoding/pem"
"errors" "errors"
"math/big" "math/big"
@ -22,6 +23,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/database" "git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/dnsverification" "git.pcux.cn/xuans/Scert/backend/internal/dnsverification"
"git.pcux.cn/xuans/Scert/backend/internal/firewall" "git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
"git.pcux.cn/xuans/Scert/backend/internal/setup" "git.pcux.cn/xuans/Scert/backend/internal/setup"
) )
@ -169,6 +171,12 @@ func newCompletionFixture(t *testing.T, domain string) completionFixture {
t.Helper() t.Helper()
baseDir := t.TempDir() baseDir := t.TempDir()
base := config.Default(baseDir) 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") configPath := filepath.Join(base.Paths.Config, "scert.json")
store := newCompletionMemorySecrets() store := newCompletionMemorySecrets()
databaseManager := NewDatabaseVerificationManager(base, store) 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 { func completionDomainResource(name string) certificate.Resource {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {

View File

@ -160,7 +160,7 @@ func (m *DatabaseVerificationManager) Consume(token, credentialRef string) (conf
// Resolve 按验证令牌读取完整初始化所需的服务端状态,但不消费令牌。 // Resolve 按验证令牌读取完整初始化所需的服务端状态,但不消费令牌。
func (m *DatabaseVerificationManager) Resolve(token string) (DatabaseVerificationSession, error) { func (m *DatabaseVerificationManager) Resolve(token string) (DatabaseVerificationSession, error) {
session, err := m.resolveSession(token) session, err := m.resolveSession(token, false)
if err != nil { if err != nil {
return DatabaseVerificationSession{}, err return DatabaseVerificationSession{}, err
} }
@ -171,6 +171,19 @@ func (m *DatabaseVerificationManager) Resolve(token string) (DatabaseVerificatio
}, nil }, 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 按验证令牌精确回收尚未消费的数据库凭据,不影响其他初始化会话。 // Discard 按验证令牌精确回收尚未消费的数据库凭据,不影响其他初始化会话。
func (m *DatabaseVerificationManager) Discard(token string) error { func (m *DatabaseVerificationManager) Discard(token string) error {
if m == nil { if m == nil {
@ -228,7 +241,7 @@ func (m *DatabaseVerificationManager) session(token, credentialRef string, consu
return session.Config, nil 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 { if m == nil {
return databaseVerificationSession{}, errors.New("数据库验证服务尚未初始化") return databaseVerificationSession{}, errors.New("数据库验证服务尚未初始化")
} }
@ -240,6 +253,9 @@ func (m *DatabaseVerificationManager) resolveSession(token string) (databaseVeri
m.mu.Lock() m.mu.Lock()
expired := m.clearExpiredLocked(now) expired := m.clearExpiredLocked(now)
session, ok := m.sessions[token] session, ok := m.sessions[token]
if ok && claim {
delete(m.sessions, token)
}
m.mu.Unlock() m.mu.Unlock()
m.deleteCredentials(expired) m.deleteCredentials(expired)
if !ok { 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 按验证令牌读取完整初始化所需的服务端状态,但不消费令牌。 // Resolve 按验证令牌读取完整初始化所需的服务端状态,但不消费令牌。
func (m *DNSVerificationManager) Resolve(token string) (DNSVerificationSession, error) { func (m *DNSVerificationManager) Resolve(token string) (DNSVerificationSession, error) {
session, err := m.resolveSession(token) session, err := m.resolveSession(token, false)
if err != nil { if err != nil {
return DNSVerificationSession{}, err return DNSVerificationSession{}, err
} }
@ -195,6 +195,20 @@ func (m *DNSVerificationManager) Resolve(token string) (DNSVerificationSession,
}, nil }, 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 临时凭据,不影响其他初始化会话。 // Discard 按验证令牌精确回收尚未消费的 DNS 临时凭据,不影响其他初始化会话。
func (m *DNSVerificationManager) Discard(token string) error { func (m *DNSVerificationManager) Discard(token string) error {
if m == nil { if m == nil {
@ -283,7 +297,7 @@ func (m *DNSVerificationManager) validateSession(token, providerID, credentialRe
return nil return nil
} }
func (m *DNSVerificationManager) resolveSession(token string) (dnsVerificationSession, error) { func (m *DNSVerificationManager) resolveSession(token string, claim bool) (dnsVerificationSession, error) {
if m == nil { if m == nil {
return dnsVerificationSession{}, errors.New("DNS 在线验证服务未初始化") return dnsVerificationSession{}, errors.New("DNS 在线验证服务未初始化")
} }
@ -295,6 +309,9 @@ func (m *DNSVerificationManager) resolveSession(token string) (dnsVerificationSe
m.mu.Lock() m.mu.Lock()
expiredRefs := m.clearExpiredLocked(now) expiredRefs := m.clearExpiredLocked(now)
session, ok := m.sessions[token] session, ok := m.sessions[token]
if ok && claim {
delete(m.sessions, token)
}
m.mu.Unlock() m.mu.Unlock()
m.deleteCredentials(expiredRefs) m.deleteCredentials(expiredRefs)
if !ok { if !ok {