From 8443c712ffffcb36e89893cf15a1e6878e3cb4cd Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 16 Sep 2026 18:49:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E9=A6=96=E6=AC=A1=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E6=8F=90=E4=BA=A4=E4=BA=8B=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/bootstrap/complete.go | 521 ++++++++++++++++++++ backend/internal/bootstrap/complete_test.go | 409 +++++++++++++++ 2 files changed, 930 insertions(+) create mode 100644 backend/internal/bootstrap/complete.go create mode 100644 backend/internal/bootstrap/complete_test.go diff --git a/backend/internal/bootstrap/complete.go b/backend/internal/bootstrap/complete.go new file mode 100644 index 0000000..bd22cb0 --- /dev/null +++ b/backend/internal/bootstrap/complete.go @@ -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} +} diff --git a/backend/internal/bootstrap/complete_test.go b/backend/internal/bootstrap/complete_test.go new file mode 100644 index 0000000..a6ce839 --- /dev/null +++ b/backend/internal/bootstrap/complete_test.go @@ -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, + } +}