实现首次初始化提交事务

This commit is contained in:
xuans 2026-09-16 18:49:48 +08:00
parent 642a7c0399
commit 8443c712ff
2 changed files with 930 additions and 0 deletions

View File

@ -0,0 +1,521 @@
package bootstrap
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
var (
ErrCompleteBusy = errors.New("初始化正在执行")
ErrCompleteDone = errors.New("初始化已经完成")
)
type CompleteErrorKind string
const (
CompleteErrorInput CompleteErrorKind = "input"
CompleteErrorVerification CompleteErrorKind = "verification"
CompleteErrorDependency CompleteErrorKind = "dependency"
CompleteErrorInternal CompleteErrorKind = "internal"
)
// CompleteError 只向 HTTP 层公开安全分类和通用说明,底层错误不得直接返回浏览器。
type CompleteError struct {
Kind CompleteErrorKind
Message string
Err error
}
func (err *CompleteError) Error() string {
if err == nil {
return "初始化失败"
}
return err.Message
}
func (err *CompleteError) Unwrap() error {
if err == nil {
return nil
}
return err.Err
}
type CompleteRequest struct {
DatabaseVerificationToken string `json:"database_verification_token"`
DNSVerificationToken string `json:"dns_verification_token"`
AdminUsername string `json:"admin_username"`
AdminEmail string `json:"admin_email"`
AdminPassword string `json:"admin_password"`
ServerPort int `json:"server_port"`
WebHostname string `json:"web_hostname"`
WebPublicIP string `json:"web_public_ip"`
WebEnabled bool `json:"web_enabled"`
HTTPSMode string `json:"https_mode"`
ACMEEnvironment string `json:"acme_environment"`
PropagationTimeoutSecs int `json:"propagation_timeout_secs"`
PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"`
}
type CompleteResult struct {
Completed bool `json:"completed"`
ManagementURL string `json:"management_url"`
Warnings []string `json:"warnings,omitempty"`
}
type completionDatabaseSessions interface {
Resolve(string) (DatabaseVerificationSession, error)
Consume(string, string) (config.DatabaseConfig, error)
Discard(string) error
DiscardAllExcept(...string)
}
type completionDNSSessions interface {
Resolve(string) (setup.DNSVerificationSession, error)
Consume(string, string, string, string) error
Discard(string) error
DiscardAllExcept(...string)
}
type setupCertificateIssuer interface {
IssueForSetup(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
}
type completionDatabaseOpener func(context.Context, config.DatabaseConfig) (*database.Database, error)
type completionMigrator func(context.Context, *sql.DB, string) error
type completionAdminEnsurer func(context.Context, *sql.DB, string, repository.User) error
type completionIPIssuer func(context.Context, string, string) (ManagementCertificate, error)
type completionConfigSaver func(config.Config, string) error
type completionCertificateValidator func(context.Context, string, string, string, []string) error
type CompleteDependencies struct {
BaseConfig config.Config
ConfigPath string
BootstrapURLPath string
DatabaseSessions completionDatabaseSessions
DNSSessions completionDNSSessions
Credentials database.CredentialReader
Sessions *SessionManager
Firewall firewall.Manager
Certificate setupCertificateIssuer
OpenDatabase completionDatabaseOpener
Migrate completionMigrator
EnsureAdmin completionAdminEnsurer
IssueIP completionIPIssuer
SaveConfig completionConfigSaver
ValidateFiles completionCertificateValidator
RollbackDomain func(context.Context, certificate.SavedResource) error
RollbackIP func(context.Context, ManagementCertificate) error
RemoveFile func(string) error
Warn func(string)
}
// Completer 串行执行首次初始化,并以配置原子保存作为唯一提交点。
type Completer struct {
mu sync.Mutex
base config.Config
configPath string
bootstrapURLPath string
databaseSessions completionDatabaseSessions
dnsSessions completionDNSSessions
credentials database.CredentialReader
sessions *SessionManager
firewall firewall.Manager
certificate setupCertificateIssuer
openDatabase completionDatabaseOpener
migrate completionMigrator
ensureAdmin completionAdminEnsurer
issueIP completionIPIssuer
saveConfig completionConfigSaver
validateFiles completionCertificateValidator
rollbackDomain func(context.Context, certificate.SavedResource) error
rollbackIP func(context.Context, ManagementCertificate) error
removeFile func(string) error
warn func(string)
completed bool
}
func NewCompleter(dependencies CompleteDependencies) *Completer {
if dependencies.OpenDatabase == nil {
dependencies.OpenDatabase = database.Open
}
if dependencies.Migrate == nil {
dependencies.Migrate = database.Migrate
}
if dependencies.EnsureAdmin == nil {
dependencies.EnsureAdmin = func(ctx context.Context, db *sql.DB, driver string, user repository.User) error {
return repository.NewSQLUserRepository(db, driver).EnsureSuperAdmin(ctx, user)
}
}
if dependencies.IssueIP == nil {
dependencies.IssueIP = GenerateManagementCertificate
}
if dependencies.SaveConfig == nil {
dependencies.SaveConfig = func(candidate config.Config, path string) error { return candidate.Save(path) }
}
if dependencies.ValidateFiles == nil {
dependencies.ValidateFiles = func(ctx context.Context, certificatePath, fullChainPath, privateKeyPath string, expected []string) error {
_, err := (certificate.LocalValidator{}).ValidateFiles(ctx, certificatePath, fullChainPath, privateKeyPath, expected)
return err
}
}
if dependencies.RollbackDomain == nil {
dependencies.RollbackDomain = certificate.RollbackSavedResource
}
if dependencies.RollbackIP == nil {
dependencies.RollbackIP = RollbackManagementCertificate
}
if dependencies.RemoveFile == nil {
dependencies.RemoveFile = os.Remove
}
return &Completer{
base: dependencies.BaseConfig, configPath: strings.TrimSpace(dependencies.ConfigPath),
bootstrapURLPath: strings.TrimSpace(dependencies.BootstrapURLPath),
databaseSessions: dependencies.DatabaseSessions, dnsSessions: dependencies.DNSSessions,
credentials: dependencies.Credentials, sessions: dependencies.Sessions,
firewall: dependencies.Firewall, certificate: dependencies.Certificate,
openDatabase: dependencies.OpenDatabase, migrate: dependencies.Migrate,
ensureAdmin: dependencies.EnsureAdmin, issueIP: dependencies.IssueIP,
saveConfig: dependencies.SaveConfig, validateFiles: dependencies.ValidateFiles,
rollbackDomain: dependencies.RollbackDomain, rollbackIP: dependencies.RollbackIP,
removeFile: dependencies.RemoveFile, warn: dependencies.Warn,
completed: dependencies.BaseConfig.SetupReady,
}
}
func (completer *Completer) Complete(ctx context.Context, request CompleteRequest) (result CompleteResult, err error) {
if completer == nil {
return result, completeError(CompleteErrorInternal, "初始化服务尚未就绪", nil)
}
if !completer.mu.TryLock() {
return result, ErrCompleteBusy
}
defer completer.mu.Unlock()
if completer.completed || completer.base.SetupReady {
return result, ErrCompleteDone
}
if err := completer.validateDependencies(); err != nil {
return result, err
}
request = normalizeCompleteRequest(request)
if err := validateCompleteRequest(request); err != nil {
return result, completeError(CompleteErrorInput, "初始化参数无效", err)
}
databaseSession, err := completer.databaseSessions.Resolve(request.DatabaseVerificationToken)
if err != nil {
return result, completeError(CompleteErrorVerification, "数据库验证结果无效或已经过期,请重新测试", err)
}
databaseResolved := true
dnsResolved := false
committed := false
defer func() {
if committed {
return
}
if dnsResolved {
completer.discardDNS(request.DNSVerificationToken)
}
if databaseResolved {
completer.discardDatabase(request.DatabaseVerificationToken)
}
}()
dnsSession, err := completer.dnsSessions.Resolve(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)
}
candidate := completer.base
candidate.Normalize()
candidate.ACME.Environment = request.ACMEEnvironment
if err := setup.Apply(&candidate, setup.Input{
AdminUsername: request.AdminUsername, AdminEmail: request.AdminEmail, AdminPassword: request.AdminPassword,
ServerPort: request.ServerPort, DNSProvider: dnsSession.ProviderID,
DNSCredentialRef: dnsSession.CredentialRef, DNSVerified: true,
PropagationTimeoutSecs: request.PropagationTimeoutSecs,
PropagationPollingIntervalSecs: request.PropagationPollingIntervalSecs,
CertificateRoot: candidate.Paths.Certificate,
WebHostname: request.WebHostname, WebPublicIP: request.WebPublicIP,
WebEnabled: request.WebEnabled, HTTPSMode: request.HTTPSMode,
}); err != nil {
return result, completeError(CompleteErrorInput, "初始化参数校验失败", err)
}
candidate.Database = databaseSession.Config
if err := database.ValidateConfig(candidate.Database); err != nil {
return result, completeError(CompleteErrorVerification, "数据库验证结果不可用于正式配置", err)
}
runtimeDatabase, err := database.ResolveConfig(ctx, candidate.Database, completer.credentials)
if err != nil {
return result, completeError(CompleteErrorDependency, "读取数据库凭据失败,请重新测试数据库连接", err)
}
db, err := completer.openDatabase(ctx, runtimeDatabase)
if err != nil {
return result, completeError(CompleteErrorDependency, "连接数据库失败,请重新检查数据库状态", err)
}
defer db.Close()
if err := completer.migrate(ctx, db.DB, runtimeDatabase.Driver); err != nil {
return result, completeError(CompleteErrorDependency, "初始化数据库结构失败", err)
}
if err := completer.ensureAdmin(ctx, db.DB, runtimeDatabase.Driver, repository.User{
ID: "primary-super-admin", Username: candidate.Admin.Username, Email: candidate.Admin.Email,
PasswordHash: candidate.Admin.PasswordHash,
}); err != nil {
return result, completeError(CompleteErrorDependency, "同步超级管理员失败", err)
}
certificateResult, err := completer.issueManagementCertificate(ctx, candidate)
if err != nil {
return result, err
}
certificateSaved := true
var firewallResult firewall.Result
defer func() {
if committed {
return
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if firewallResult.Created {
if rollbackErr := completer.firewall.Rollback(rollbackCtx, firewallResult); rollbackErr != nil {
err = errors.Join(err, completeError(CompleteErrorInternal, "初始化失败且防火墙规则回滚未完成", rollbackErr))
}
}
if certificateSaved {
if rollbackErr := certificateResult.rollback(rollbackCtx); rollbackErr != nil {
err = errors.Join(err, completeError(CompleteErrorInternal, "初始化失败且管理证书回滚未完成", rollbackErr))
}
}
}()
if err := validateCompletionCertificatePaths(certificateResult); err != nil {
return result, completeError(CompleteErrorInternal, "管理证书文件状态无效", err)
}
if err := completer.validateFiles(ctx, certificateResult.certificatePath, certificateResult.fullChainPath, certificateResult.privateKeyPath, []string{certificateResult.target}); err != nil {
return result, completeError(CompleteErrorDependency, "管理证书校验失败", err)
}
candidate.Server.TLSCert = certificateResult.fullChainPath
candidate.Server.TLSKey = certificateResult.privateKeyPath
firewallResult, err = completer.firewall.OpenTCP(ctx, candidate.Server.Port)
if err != nil {
return result, completeError(CompleteErrorDependency, "配置主服务防火墙规则失败", err)
}
candidate.SetupReady = true
candidate.Web.InitReady = true
if err := candidate.Validate(); err != nil {
return result, completeError(CompleteErrorInternal, "最终配置校验失败", err)
}
if err := ctx.Err(); err != nil {
return result, completeError(CompleteErrorInternal, "初始化请求已取消", err)
}
if err := completer.saveConfig(candidate, completer.configPath); err != nil {
return result, completeError(CompleteErrorInternal, "保存正式配置失败", err)
}
committed = true
completer.completed = true
completer.base = candidate
result = CompleteResult{Completed: true, ManagementURL: managementURL(candidate)}
result.Warnings = completer.afterCommit(request, candidate, dnsSession, databaseSession)
return result, nil
}
type completionCertificate struct {
target string
certificatePath string
fullChainPath string
privateKeyPath string
rollback func(context.Context) error
}
func (completer *Completer) issueManagementCertificate(ctx context.Context, candidate config.Config) (completionCertificate, error) {
if candidate.Web.Hostname != "" {
issueCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
saved, err := completer.certificate.IssueForSetup(issueCtx, certificate.SetupCertificateRequest{
Domains: []string{candidate.Web.Hostname}, CertificateType: "single",
ProviderID: candidate.DNS.Provider, CredentialRef: candidate.DNS.CredentialRef,
AccountEmail: candidate.Admin.Email, Environment: candidate.ACME.Environment,
CertificateRoot: candidate.Paths.Certificate, BackupRoot: candidate.Paths.Backup,
})
if err != nil {
return completionCertificate{}, completeError(CompleteErrorDependency, "申请管理域名证书失败", err)
}
return completionCertificate{
target: candidate.Web.Hostname, certificatePath: saved.CertificatePath,
fullChainPath: saved.FullChainPath, privateKeyPath: saved.PrivateKeyPath,
rollback: func(ctx context.Context) error { return completer.rollbackDomain(ctx, saved) },
}, nil
}
saved, err := completer.issueIP(ctx, candidate.Paths.Certificate, candidate.Web.PublicIP)
if err != nil {
return completionCertificate{}, completeError(CompleteErrorDependency, "生成公网 IP 管理证书失败", err)
}
return completionCertificate{
target: candidate.Web.PublicIP, certificatePath: saved.CertificatePath,
fullChainPath: saved.FullChainPath, privateKeyPath: saved.PrivateKeyPath,
rollback: func(ctx context.Context) error { return completer.rollbackIP(ctx, saved) },
}, nil
}
func (completer *Completer) validateDependencies() error {
if completer.databaseSessions == nil || completer.dnsSessions == nil || completer.credentials == nil ||
completer.sessions == nil || completer.firewall == nil || completer.certificate == nil ||
completer.openDatabase == nil || completer.migrate == nil || completer.ensureAdmin == nil ||
completer.issueIP == nil || completer.saveConfig == nil || completer.validateFiles == nil ||
completer.rollbackDomain == nil || completer.rollbackIP == nil || completer.removeFile == nil {
return completeError(CompleteErrorInternal, "初始化服务依赖尚未就绪", nil)
}
if completer.configPath == "" || !filepath.IsAbs(completer.configPath) {
return completeError(CompleteErrorInternal, "正式配置路径无效", nil)
}
return nil
}
func normalizeCompleteRequest(request CompleteRequest) CompleteRequest {
request.DatabaseVerificationToken = strings.TrimSpace(request.DatabaseVerificationToken)
request.DNSVerificationToken = strings.TrimSpace(request.DNSVerificationToken)
request.AdminUsername = strings.TrimSpace(request.AdminUsername)
request.AdminEmail = strings.ToLower(strings.TrimSpace(request.AdminEmail))
request.WebHostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(request.WebHostname), "."))
request.WebPublicIP = strings.Trim(strings.TrimSpace(request.WebPublicIP), "[]")
request.HTTPSMode = strings.ToLower(strings.TrimSpace(request.HTTPSMode))
request.ACMEEnvironment = strings.ToLower(strings.TrimSpace(request.ACMEEnvironment))
if request.HTTPSMode == "" {
request.HTTPSMode = "direct"
}
if request.ACMEEnvironment == "" {
request.ACMEEnvironment = "staging"
}
return request
}
func validateCompleteRequest(request CompleteRequest) error {
if request.DatabaseVerificationToken == "" || request.DNSVerificationToken == "" {
return errors.New("必须先完成数据库和 DNS 在线验证")
}
if request.WebHostname == "" && request.WebPublicIP == "" {
return errors.New("必须配置管理域名或公网 IP")
}
if request.WebHostname != "" && request.WebPublicIP != "" {
return errors.New("管理域名和公网 IP 不能同时配置")
}
if request.WebHostname != "" && !domain.IsValidName(request.WebHostname) {
return errors.New("管理域名格式无效")
}
if request.WebPublicIP != "" {
if _, err := normalizePublicManagementIP(request.WebPublicIP); err != nil {
return err
}
}
if request.ACMEEnvironment != "staging" && request.ACMEEnvironment != "production" {
return errors.New("ACME 环境只能是 staging 或 production")
}
return nil
}
func validateResolvedDNS(request CompleteRequest, session setup.DNSVerificationSession) error {
if !strings.EqualFold(strings.TrimSpace(session.ProviderID), dnsprovider.Aliyun) {
return errors.New("首次初始化仅支持阿里云 DNS")
}
testDomain := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(session.TestDomain), "."))
if err := dnsprovider.ValidateAliyunTargetDomains(session.ProviderID, []string{testDomain}); err != nil {
return err
}
if request.WebHostname != "" && testDomain != request.WebHostname {
return errors.New("DNS 验证域名必须与管理域名完全一致")
}
return nil
}
func validateCompletionCertificatePaths(saved completionCertificate) error {
for _, path := range []string{saved.certificatePath, saved.fullChainPath, saved.privateKeyPath} {
info, err := os.Lstat(path)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("管理证书路径必须是普通文件:%s", path)
}
}
return nil
}
func (completer *Completer) afterCommit(request CompleteRequest, candidate config.Config, dnsSession setup.DNSVerificationSession, databaseSession DatabaseVerificationSession) []string {
warnings := make([]string, 0, 4)
appendWarning := func(message string) {
warnings = append(warnings, message)
if completer.warn != nil {
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)
if completer.bootstrapURLPath != "" {
if err := completer.removeFile(completer.bootstrapURLPath); err != nil && !errors.Is(err, os.ErrNotExist) {
appendWarning("正式配置已保存,但 Bootstrap 地址文件清理失败")
}
}
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 managementURL(candidate config.Config) string {
host := strings.TrimSpace(candidate.Web.Hostname)
if host == "" {
host = strings.TrimSpace(candidate.Web.PublicIP)
}
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil && ip.To4() == nil {
host = "[" + ip.String() + "]"
}
if candidate.Server.Port != 443 {
host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(candidate.Server.Port))
if strings.HasPrefix(host, "[") && candidate.Server.Port == 443 {
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
}
}
return (&url.URL{Scheme: "https", Host: host, Path: "/"}).String()
}
func completeError(kind CompleteErrorKind, message string, err error) error {
return &CompleteError{Kind: kind, Message: message, Err: err}
}

View File

@ -0,0 +1,409 @@
package bootstrap
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"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/setup"
)
type completionMemorySecrets struct {
mu sync.Mutex
values map[string]map[string]string
deleted []string
}
func newCompletionMemorySecrets() *completionMemorySecrets {
return &completionMemorySecrets{values: make(map[string]map[string]string)}
}
func (store *completionMemorySecrets) Put(_ context.Context, reference string, value map[string]string) error {
store.mu.Lock()
defer store.mu.Unlock()
store.values[reference] = cloneCompletionSecrets(value)
return nil
}
func (store *completionMemorySecrets) Get(_ context.Context, reference string) (map[string]string, error) {
store.mu.Lock()
defer store.mu.Unlock()
value, ok := store.values[reference]
if !ok {
return nil, errors.New("secret not found")
}
return cloneCompletionSecrets(value), nil
}
func (store *completionMemorySecrets) Delete(_ context.Context, reference string) error {
store.mu.Lock()
defer store.mu.Unlock()
delete(store.values, reference)
store.deleted = append(store.deleted, reference)
return nil
}
func (store *completionMemorySecrets) has(reference string) bool {
store.mu.Lock()
defer store.mu.Unlock()
_, ok := store.values[reference]
return ok
}
func cloneCompletionSecrets(value map[string]string) map[string]string {
result := make(map[string]string, len(value))
for key, item := range value {
result[key] = item
}
return result
}
type completionDNSTester struct{}
func (completionDNSTester) Verify(_ context.Context, _, testDomain string, _ map[string]string) (setup.DNSVerificationReport, error) {
return dnsverification.Report{RecordName: "_scert." + testDomain}, nil
}
type completionCertificateIssuer struct {
root string
saved certificate.SavedResource
err error
calls int
}
func (issuer *completionCertificateIssuer) IssueForSetup(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
issuer.calls++
if issuer.err != nil {
return certificate.SavedResource{}, issuer.err
}
resource := completionDomainResource(request.Domains[0])
store := certificate.FileStore{Root: issuer.root, BackupRoot: filepath.Join(filepath.Dir(issuer.root), "backups")}
saved, err := store.SaveForType(ctx, request.Domains[0], "single", resource)
issuer.saved = saved
return saved, err
}
type completionFirewall struct {
mu sync.Mutex
result firewall.Result
openErr error
rollbackErr error
openCalls int
rollbackCalls int
block chan struct{}
}
func (manager *completionFirewall) OpenTCP(_ context.Context, port int) (firewall.Result, error) {
manager.mu.Lock()
manager.openCalls++
result := manager.result
openErr := manager.openErr
block := manager.block
manager.mu.Unlock()
if block != nil {
<-block
}
if result.Port == 0 {
result = firewall.Result{Backend: "test", Port: port, Created: true}
manager.mu.Lock()
manager.result = result
manager.mu.Unlock()
}
return result, openErr
}
func (manager *completionFirewall) Rollback(_ context.Context, _ firewall.Result) error {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.rollbackCalls++
return manager.rollbackErr
}
func (manager *completionFirewall) counts() (int, int) {
manager.mu.Lock()
defer manager.mu.Unlock()
return manager.openCalls, manager.rollbackCalls
}
func (manager *completionFirewall) setBlock(block chan struct{}) {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.block = block
}
type completionFixture struct {
base config.Config
configPath string
urlPath string
store *completionMemorySecrets
database *DatabaseVerificationManager
dns *setup.DNSVerificationManager
databaseToken string
dnsToken string
databaseRef string
dnsRef string
sessions *SessionManager
sessionCookie string
issuer *completionCertificateIssuer
firewall *completionFirewall
}
func newCompletionFixture(t *testing.T, domain string) completionFixture {
t.Helper()
baseDir := t.TempDir()
base := config.Default(baseDir)
configPath := filepath.Join(base.Paths.Config, "scert.json")
store := newCompletionMemorySecrets()
databaseManager := NewDatabaseVerificationManager(base, store)
databaseManager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
databaseResult, err := databaseManager.Verify(context.Background(), DatabaseInput{Driver: "sqlite"})
if err != nil {
t.Fatal(err)
}
dnsManager := setup.NewDNSVerificationManager(completionDNSTester{}, store)
dnsResult, err := dnsManager.Verify(context.Background(), setup.DNSVerificationInput{
ProviderID: "aliyun", TestDomain: domain,
Credentials: map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"},
})
if err != nil {
t.Fatal(err)
}
sessions, oneTimeToken, err := NewSessionManager()
if err != nil {
t.Fatal(err)
}
session, err := sessions.Exchange(oneTimeToken, "192.0.2.1:5000")
if err != nil {
t.Fatal(err)
}
return completionFixture{
base: base, configPath: configPath, urlPath: filepath.Join(base.Paths.Data, "bootstrap-url"),
store: store, database: databaseManager, dns: dnsManager,
databaseToken: databaseResult.VerificationToken, dnsToken: dnsResult.VerificationToken,
databaseRef: databaseResult.CredentialRef, dnsRef: dnsResult.CredentialRef,
sessions: sessions, sessionCookie: session.Token,
issuer: &completionCertificateIssuer{root: base.Paths.Certificate},
firewall: &completionFirewall{},
}
}
func (fixture completionFixture) completer(overrides func(*CompleteDependencies)) *Completer {
dependencies := CompleteDependencies{
BaseConfig: fixture.base, ConfigPath: fixture.configPath, BootstrapURLPath: fixture.urlPath,
DatabaseSessions: fixture.database, DNSSessions: fixture.dns, Credentials: fixture.store,
Sessions: fixture.sessions, Firewall: fixture.firewall, Certificate: fixture.issuer,
}
if overrides != nil {
overrides(&dependencies)
}
return NewCompleter(dependencies)
}
func (fixture completionFixture) request(domain string) CompleteRequest {
return CompleteRequest{
DatabaseVerificationToken: fixture.databaseToken,
DNSVerificationToken: fixture.dnsToken,
AdminUsername: "owner", AdminEmail: "owner@example.test", AdminPassword: "Admin2026!",
ServerPort: 8443, WebHostname: domain, WebEnabled: true,
HTTPSMode: "direct", ACMEEnvironment: "staging",
PropagationTimeoutSecs: 60, PropagationPollingIntervalSecs: 2,
}
}
func TestCompleterCommitsFullInitializationAndRevokesBootstrapSession(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
if err := os.MkdirAll(filepath.Dir(fixture.urlPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fixture.urlPath, []byte("temporary"), 0o600); err != nil {
t.Fatal(err)
}
result, err := fixture.completer(nil).Complete(context.Background(), fixture.request("admin.example.test"))
if err != nil {
t.Fatal(err)
}
if !result.Completed || result.ManagementURL != "https://admin.example.test:8443/" || len(result.Warnings) != 0 {
t.Fatalf("初始化结果不正确:%+v", result)
}
stored, err := config.Load(fixture.configPath)
if err != nil {
t.Fatal(err)
}
if !stored.SetupReady || !stored.Web.InitReady || stored.Database.Driver != "sqlite" ||
stored.DNS.CredentialRef != fixture.dnsRef || stored.Server.TLSCert != fixture.issuer.saved.FullChainPath {
t.Fatalf("正式配置未完整提交:%+v", stored)
}
if fixture.sessions.Authenticate(fixture.sessionCookie) {
t.Fatal("初始化成功后旧 Bootstrap 会话仍然有效")
}
if !fixture.store.has(fixture.dnsRef) {
t.Fatal("正式 DNS 凭据被批量清理误删")
}
if _, err := os.Stat(fixture.urlPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Bootstrap 地址文件未清理:%v", err)
}
_, rollbackCalls := fixture.firewall.counts()
if rollbackCalls != 0 {
t.Fatal("初始化成功后不应回滚防火墙")
}
}
func TestCompleterRejectsDNSDomainMismatchBeforeDatabaseSideEffects(t *testing.T) {
fixture := newCompletionFixture(t, "verified.example.test")
openCalls := 0
_, err := fixture.completer(func(dependencies *CompleteDependencies) {
dependencies.OpenDatabase = func(context.Context, config.DatabaseConfig) (*database.Database, error) {
openCalls++
return nil, errors.New("must not run")
}
}).Complete(context.Background(), fixture.request("other.example.test"))
firewallOpenCalls, _ := fixture.firewall.counts()
if err == nil || openCalls != 0 || fixture.issuer.calls != 0 || firewallOpenCalls != 0 {
t.Fatalf("域名不匹配未在副作用前拒绝:err=%v open=%d", err, openCalls)
}
if fixture.store.has(fixture.dnsRef) {
t.Fatal("失败初始化的 DNS 临时凭据未回收")
}
}
func TestCompleterRollsBackCertificateAndFirewallWhenConfigSaveFails(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
_, err := fixture.completer(func(dependencies *CompleteDependencies) {
dependencies.SaveConfig = func(config.Config, string) error { return errors.New("save failed") }
}).Complete(context.Background(), fixture.request("admin.example.test"))
if err == nil {
t.Fatal("配置保存失败未中止初始化")
}
_, rollbackCalls := fixture.firewall.counts()
if rollbackCalls != 1 {
t.Fatalf("新防火墙规则未回滚:%d", rollbackCalls)
}
for _, path := range []string{fixture.issuer.saved.CertificatePath, fixture.issuer.saved.FullChainPath, fixture.issuer.saved.PrivateKeyPath} {
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("失败初始化的证书未回滚:%s err=%v", path, statErr)
}
}
if fixture.store.has(fixture.dnsRef) {
t.Fatal("配置保存失败后 DNS 临时凭据未回收")
}
if _, statErr := os.Stat(fixture.configPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("配置保存失败后仍留下正式配置:%v", statErr)
}
}
func TestCompleterAllowsOnlyOneConcurrentCompletion(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
block := make(chan struct{})
fixture.firewall.setBlock(block)
completer := fixture.completer(nil)
firstDone := make(chan error, 1)
go func() {
_, err := completer.Complete(context.Background(), fixture.request("admin.example.test"))
firstDone <- err
}()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
openCalls, _ := fixture.firewall.counts()
if openCalls != 0 {
break
}
time.Sleep(time.Millisecond)
}
_, secondErr := completer.Complete(context.Background(), fixture.request("admin.example.test"))
if !errors.Is(secondErr, ErrCompleteBusy) {
t.Fatalf("并发初始化未返回 Busy:%v", secondErr)
}
close(block)
if err := <-firstDone; err != nil {
t.Fatal(err)
}
if _, err := completer.Complete(context.Background(), fixture.request("admin.example.test")); !errors.Is(err, ErrCompleteDone) {
t.Fatalf("初始化完成后未返回 Done:%v", err)
}
}
func TestCompleterPostCommitCleanupFailureDoesNotRollbackFormalConfig(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
result, err := fixture.completer(func(dependencies *CompleteDependencies) {
dependencies.RemoveFile = func(string) error { return errors.New("remove failed") }
}).Complete(context.Background(), fixture.request("admin.example.test"))
if err != nil {
t.Fatal(err)
}
if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "Bootstrap") {
t.Fatalf("提交后清理失败没有返回安全警告:%+v", result)
}
stored, err := config.Load(fixture.configPath)
_, rollbackCalls := fixture.firewall.counts()
if err != nil || !stored.SetupReady || rollbackCalls != 0 {
t.Fatalf("提交后清理失败错误回滚了正式配置:cfg=%+v err=%v", stored, err)
}
}
func TestCompleterSupportsPublicIPManagementCertificate(t *testing.T) {
fixture := newCompletionFixture(t, "certificate-zone.example.test")
request := fixture.request("")
request.WebPublicIP = "203.0.113.70"
result, err := fixture.completer(nil).Complete(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if result.ManagementURL != "https://203.0.113.70:8443/" {
t.Fatalf("公网 IP 管理地址不正确:%s", result.ManagementURL)
}
stored, err := config.Load(fixture.configPath)
if err != nil {
t.Fatal(err)
}
if stored.Web.PublicIP != "203.0.113.70" || stored.Web.Hostname != "" || !strings.Contains(stored.Server.TLSCert, "management-ip") {
t.Fatalf("公网 IP 初始化配置不正确:%+v", stored)
}
}
func completionDomainResource(name string) certificate.Resource {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
panic(err)
}
now := time.Now().UTC()
template := &x509.Certificate{
SerialNumber: serial, Subject: pkix.Name{CommonName: name}, DNSNames: []string{name},
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
panic(err)
}
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
panic(err)
}
return certificate.Resource{
Domains: []string{name}, Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyDER}),
NotBefore: template.NotBefore, NotAfter: template.NotAfter,
}
}