实现首次初始化提交事务
This commit is contained in:
parent
642a7c0399
commit
8443c712ff
521
backend/internal/bootstrap/complete.go
Normal file
521
backend/internal/bootstrap/complete.go
Normal 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}
|
||||
}
|
||||
409
backend/internal/bootstrap/complete_test.go
Normal file
409
backend/internal/bootstrap/complete_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user