diff --git a/backend/internal/app/certificate_workflow.go b/backend/internal/app/certificate_workflow.go index 95ca591..0617bc1 100644 --- a/backend/internal/app/certificate_workflow.go +++ b/backend/internal/app/certificate_workflow.go @@ -242,7 +242,7 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss if resolveErr != nil { return deployment.Report{}, fmt.Errorf("解析服务器 %s 的证书路径失败:%w", server.Name, resolveErr) } - targets = append(targets, deployment.Target{Server: resolved}) + targets = append(targets, deployment.Target{Server: resolved, DomainName: domainItem.Name}) } remoteVerificationEnabled := w.RemoteVerifier != nil || w.RemoteVerifierForServer != nil expectedFingerprint := "" diff --git a/backend/internal/deployment/diagnostics.go b/backend/internal/deployment/diagnostics.go index 18d0cf9..501fac6 100644 --- a/backend/internal/deployment/diagnostics.go +++ b/backend/internal/deployment/diagnostics.go @@ -3,41 +3,43 @@ package deployment type FailureCode string const ( - FailureUnavailable FailureCode = "unavailable" - FailureArtifact FailureCode = "artifact_invalid" - FailureConfiguration FailureCode = "configuration_invalid" - FailureConnection FailureCode = "connection_failed" - FailureDirectory FailureCode = "directory_failed" - FailureLock FailureCode = "lock_unavailable" - FailureUpload FailureCode = "upload_failed" - FailureReplace FailureCode = "replace_failed" - FailureValidation FailureCode = "validation_failed" - FailureRollback FailureCode = "rollback_failed" - FailureRuntimeUnknown FailureCode = "runtime_unconfirmed" - FailureCleanup FailureCode = "cleanup_failed" - FailureLockRelease FailureCode = "lock_release_failed" - FailureRecoveryPending FailureCode = "recovery_pending" - FailureRecoveryUnknown FailureCode = "recovery_unconfirmed" - FailureRecoveryRestored FailureCode = "recovery_restored" + FailureUnavailable FailureCode = "unavailable" + FailureArtifact FailureCode = "artifact_invalid" + FailureConfiguration FailureCode = "configuration_invalid" + FailureConnection FailureCode = "connection_failed" + FailureDirectory FailureCode = "directory_failed" + FailureLock FailureCode = "lock_unavailable" + FailureUpload FailureCode = "upload_failed" + FailureReplace FailureCode = "replace_failed" + FailureValidation FailureCode = "validation_failed" + FailureRollback FailureCode = "rollback_failed" + FailureRuntimeUnknown FailureCode = "runtime_unconfirmed" + FailureCleanup FailureCode = "cleanup_failed" + FailureLockRelease FailureCode = "lock_release_failed" + FailureRecoveryPending FailureCode = "recovery_pending" + FailureRecoveryUnknown FailureCode = "recovery_unconfirmed" + FailureRecoveryRestored FailureCode = "recovery_restored" + FailureManagementConflict FailureCode = "management_domain_conflict" ) // 只公开固定诊断,不从远端输出、错误文本或文件路径推断可展示的消息。 var publicFailureMessages = map[FailureCode]string{ - FailureRecoveryUnknown: "本次部署未完成,已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁;请管理员核查,勿直接重试或删除锁", - FailureRecoveryRestored: "本次部署未完成,旧文件已恢复并通过配置校验及重载,站点已恢复部署前证书;请核查失败原因后再重试", - FailureUnavailable: "部署服务不可用,请联系管理员检查服务配置", - FailureArtifact: "待部署的证书或私钥校验失败,尚未推送到服务器", - FailureConfiguration: "服务器部署配置无效,请检查认证、证书路径和命令配置", - FailureConnection: "连接部署服务器失败,请检查网络、SSH 认证及主机身份配置", - FailureDirectory: "无法创建证书目录,请检查部署账户的目录权限", - FailureLock: "无法取得部署锁,可能有任务正在执行或存在中断遗留;请管理员核查远端文件、部署锁及服务状态后再重试,勿直接删除锁", - FailureUpload: "证书上传失败,已尝试回滚;请检查受保护的服务日志后再重试", - FailureReplace: "证书文件替换失败,已尝试回滚;请检查受保护的服务日志后再重试", - FailureValidation: "服务配置校验失败,文件已回滚,未执行重载;请修正配置后再重试", - FailureRollback: "部署失败且文件回滚未完成,已保留部署锁;请管理员核查远端文件及服务状态,勿直接重试或删除锁", - FailureRuntimeUnknown: "服务重载失败,文件已回滚,但服务运行状态未确认;请管理员核对配置并恢复服务后再重试", - FailureCleanup: "证书已推送并重载,但旧文件清理未完成;请管理员核查现场,不能据此确认站点新证书已生效", - FailureLockRelease: "部署锁释放未完成;请管理员核查远端文件、部署锁及服务状态后再重试", + FailureRecoveryUnknown: "本次部署未完成,已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁;请管理员核查,勿直接重试或删除锁", + FailureRecoveryRestored: "本次部署未完成,旧文件已恢复并通过配置校验及重载,站点已恢复部署前证书;请核查失败原因后再重试", + FailureUnavailable: "部署服务不可用,请联系管理员检查服务配置", + FailureArtifact: "待部署的证书或私钥校验失败,尚未推送到服务器", + FailureConfiguration: "服务器部署配置无效,请检查认证、证书路径和命令配置", + FailureConnection: "连接部署服务器失败,请检查网络、SSH 认证及主机身份配置", + FailureDirectory: "无法创建证书目录,请检查部署账户的目录权限", + FailureLock: "无法取得部署锁,可能有任务正在执行或存在中断遗留;请管理员核查远端文件、部署锁及服务状态后再重试,勿直接删除锁", + FailureUpload: "证书上传失败,已尝试回滚;请检查受保护的服务日志后再重试", + FailureReplace: "证书文件替换失败,已尝试回滚;请检查受保护的服务日志后再重试", + FailureValidation: "服务配置校验失败,文件已回滚,未执行重载;请修正配置后再重试", + FailureRollback: "部署失败且文件回滚未完成,已保留部署锁;请管理员核查远端文件及服务状态,勿直接重试或删除锁", + FailureRuntimeUnknown: "服务重载失败,文件已回滚,但服务运行状态未确认;请管理员核对配置并恢复服务后再重试", + FailureCleanup: "证书已推送并重载,但旧文件清理未完成;请管理员核查现场,不能据此确认站点新证书已生效", + FailureLockRelease: "部署锁释放未完成;请管理员核查远端文件、部署锁及服务状态后再重试", + FailureManagementConflict: "该服务器的管理域名由受限激活服务接管,不能使用普通业务证书路径部署;请使用管理证书续期流程,或为业务域名配置独立 Nginx 站点", } // PublicResult 保留结果事实,同时只允许固定错误码及中文诊断进入公开响应。 diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index efcd993..b182377 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -1,9 +1,11 @@ package deployment import ( + "bytes" "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" "os" @@ -13,8 +15,9 @@ import ( ) type Target struct { - Server Server - Baseline RecoveryBaseline + Server Server + DomainName string + Baseline RecoveryBaseline } type Artifact struct { @@ -95,7 +98,7 @@ func (s *Service) deploy(ctx context.Context, targets []Target, artifact Artifac return report } for index, target := range targets { - result := s.deployOne(ctx, target.Server, artifact, pending, index, target.Baseline) + result := s.deployOne(ctx, target.Server, target.DomainName, artifact, pending, index, target.Baseline) report.Results = append(report.Results, result) if result.Status == "success" { report.Success++ @@ -138,7 +141,7 @@ func loadArtifact(saved SavedFiles) (Artifact, error) { return Artifact{Certificate: certificateData, FullChain: fullChainData, PrivateKey: privateKeyData}, nil } -func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifact, pending *[]*pendingDeployment, index int, baseline RecoveryBaseline) (result Result) { +func (s *Service) deployOne(ctx context.Context, server Server, domainName string, artifact Artifact, pending *[]*pendingDeployment, index int, baseline RecoveryBaseline) (result Result) { ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() server = server.Normalize() @@ -159,6 +162,15 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac _ = client.Close() } }() + if err := checkManagementDomainConflict(ctx, client, domainName); err != nil { + if errors.Is(err, ErrManagementDomainConflict) { + result.FailureCode = FailureManagementConflict + } else { + result.FailureCode = FailureConfiguration + } + result.Message = err.Error() + return result + } paths := []remoteFile{ {path: RemotePath(server.CertificatePath), data: artifact.Certificate, mode: 0o644}, {path: RemotePath(server.CertificateChainPath), data: artifact.FullChain, mode: 0o644}, @@ -577,6 +589,56 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac return result } +const managementPolicyPath = "/etc/scert-nginx/policy.json" + +var ErrManagementDomainConflict = errors.New("该服务器的管理域名由受限激活服务接管,不能使用普通业务证书路径部署。请使用管理证书续期流程,或为业务域名配置独立 Nginx 站点") + +type managementPolicyProbe struct { + Version int `json:"version"` + Hostname string `json:"hostname"` +} + +func checkManagementDomainConflict(ctx context.Context, client Client, domainName string) error { + domainName = normalizeDeploymentDomain(domainName) + if domainName == "" { + return nil + } + probe, err := client.Run(ctx, []string{"test", "-f", managementPolicyPath}) + if err != nil { + return fmt.Errorf("核查服务器管理入口失败:%w", err) + } + if probe.ExitCode == 1 { + return nil + } + if probe.ExitCode != 0 { + return fmt.Errorf("核查服务器管理入口失败:命令退出码 %d", probe.ExitCode) + } + read, err := client.Run(ctx, []string{"cat", managementPolicyPath}) + if err != nil { + return fmt.Errorf("读取服务器管理入口策略失败:%w", err) + } + if read.ExitCode != 0 { + return fmt.Errorf("读取服务器管理入口策略失败:命令退出码 %d", read.ExitCode) + } + var policy managementPolicyProbe + decoder := json.NewDecoder(bytes.NewReader(read.Output)) + if err := decoder.Decode(&policy); err != nil || policy.Version != 1 { + return errors.New("服务器管理入口策略无效,已停止普通证书部署") + } + managementDomain := normalizeDeploymentDomain(policy.Hostname) + if managementDomain == "" { + return errors.New("服务器管理入口策略缺少管理域名,已停止普通证书部署") + } + if managementDomain == domainName { + return ErrManagementDomainConflict + } + return nil +} + +func normalizeDeploymentDomain(value string) string { + return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(value), ".")) +} + func (s *Service) newTransactionRecord(ctx context.Context, server Server, paths []remoteFile, lockPaths []string, id string, index int, baseline RecoveryBaseline) (*TransactionRecord, bool) { transactionContext, ok := TransactionContextFromContext(ctx) if !ok || s == nil || s.journal == nil { diff --git a/backend/internal/deployment/service_test.go b/backend/internal/deployment/service_test.go index e2e6f92..66659a3 100644 --- a/backend/internal/deployment/service_test.go +++ b/backend/internal/deployment/service_test.go @@ -192,6 +192,33 @@ func TestDeployReplacesFilesAndCleansBackups(t *testing.T) { } } +func TestDeployRejectsProtectedManagementDomainBeforeChangingFiles(t *testing.T) { + client := newMemoryClient() + server := testServer("management-server") + client.runResults = []CommandResult{ + {ExitCode: 0}, + {Output: []byte(`{"version":1,"hostname":"odacm.cn"}`), ExitCode: 0}, + } + service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) + before := map[string]memoryFile{} + for path, file := range client.files { + before[path] = file + } + report := service.Deploy(context.Background(), []Target{{Server: server, DomainName: "odacm.cn"}}, testArtifact(t)) + if report.Status != "failed" || report.Failed != 1 { + t.Fatalf("管理域名冲突应拒绝部署:%+v", report) + } + if report.Results[0].FailureCode != FailureManagementConflict { + t.Fatalf("失败码错误:%+v", report.Results[0]) + } + if len(client.files) != len(before) { + t.Fatalf("管理域名冲突前不应修改远端文件:before=%v after=%v", before, client.files) + } + if len(client.commands) != 2 || client.commands[0][0] != "test" || client.commands[1][0] != "cat" { + t.Fatalf("管理域名冲突检查命令错误:%v", client.commands) + } +} + func TestDeployReportsBackupCleanupFailure(t *testing.T) { client := newMemoryClient() client.removeError = ".scert-backup-"