修复管理域名普通证书部署冲突

普通证书部署前核查受保护的 scert-nginx 管理入口策略,阻止覆盖管理域名并补充回归测试。
This commit is contained in:
xuans 2026-10-03 10:51:23 +08:00
parent dc7bf25571
commit cbd8c587d8
4 changed files with 127 additions and 36 deletions

View File

@ -242,7 +242,7 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss
if resolveErr != nil { if resolveErr != nil {
return deployment.Report{}, fmt.Errorf("解析服务器 %s 的证书路径失败:%w", server.Name, resolveErr) 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 remoteVerificationEnabled := w.RemoteVerifier != nil || w.RemoteVerifierForServer != nil
expectedFingerprint := "" expectedFingerprint := ""

View File

@ -3,41 +3,43 @@ package deployment
type FailureCode string type FailureCode string
const ( const (
FailureUnavailable FailureCode = "unavailable" FailureUnavailable FailureCode = "unavailable"
FailureArtifact FailureCode = "artifact_invalid" FailureArtifact FailureCode = "artifact_invalid"
FailureConfiguration FailureCode = "configuration_invalid" FailureConfiguration FailureCode = "configuration_invalid"
FailureConnection FailureCode = "connection_failed" FailureConnection FailureCode = "connection_failed"
FailureDirectory FailureCode = "directory_failed" FailureDirectory FailureCode = "directory_failed"
FailureLock FailureCode = "lock_unavailable" FailureLock FailureCode = "lock_unavailable"
FailureUpload FailureCode = "upload_failed" FailureUpload FailureCode = "upload_failed"
FailureReplace FailureCode = "replace_failed" FailureReplace FailureCode = "replace_failed"
FailureValidation FailureCode = "validation_failed" FailureValidation FailureCode = "validation_failed"
FailureRollback FailureCode = "rollback_failed" FailureRollback FailureCode = "rollback_failed"
FailureRuntimeUnknown FailureCode = "runtime_unconfirmed" FailureRuntimeUnknown FailureCode = "runtime_unconfirmed"
FailureCleanup FailureCode = "cleanup_failed" FailureCleanup FailureCode = "cleanup_failed"
FailureLockRelease FailureCode = "lock_release_failed" FailureLockRelease FailureCode = "lock_release_failed"
FailureRecoveryPending FailureCode = "recovery_pending" FailureRecoveryPending FailureCode = "recovery_pending"
FailureRecoveryUnknown FailureCode = "recovery_unconfirmed" FailureRecoveryUnknown FailureCode = "recovery_unconfirmed"
FailureRecoveryRestored FailureCode = "recovery_restored" FailureRecoveryRestored FailureCode = "recovery_restored"
FailureManagementConflict FailureCode = "management_domain_conflict"
) )
// 只公开固定诊断,不从远端输出、错误文本或文件路径推断可展示的消息。 // 只公开固定诊断,不从远端输出、错误文本或文件路径推断可展示的消息。
var publicFailureMessages = map[FailureCode]string{ var publicFailureMessages = map[FailureCode]string{
FailureRecoveryUnknown: "本次部署未完成,已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁;请管理员核查,勿直接重试或删除锁", FailureRecoveryUnknown: "本次部署未完成,已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁;请管理员核查,勿直接重试或删除锁",
FailureRecoveryRestored: "本次部署未完成,旧文件已恢复并通过配置校验及重载,站点已恢复部署前证书;请核查失败原因后再重试", FailureRecoveryRestored: "本次部署未完成,旧文件已恢复并通过配置校验及重载,站点已恢复部署前证书;请核查失败原因后再重试",
FailureUnavailable: "部署服务不可用,请联系管理员检查服务配置", FailureUnavailable: "部署服务不可用,请联系管理员检查服务配置",
FailureArtifact: "待部署的证书或私钥校验失败,尚未推送到服务器", FailureArtifact: "待部署的证书或私钥校验失败,尚未推送到服务器",
FailureConfiguration: "服务器部署配置无效,请检查认证、证书路径和命令配置", FailureConfiguration: "服务器部署配置无效,请检查认证、证书路径和命令配置",
FailureConnection: "连接部署服务器失败,请检查网络、SSH 认证及主机身份配置", FailureConnection: "连接部署服务器失败,请检查网络、SSH 认证及主机身份配置",
FailureDirectory: "无法创建证书目录,请检查部署账户的目录权限", FailureDirectory: "无法创建证书目录,请检查部署账户的目录权限",
FailureLock: "无法取得部署锁,可能有任务正在执行或存在中断遗留;请管理员核查远端文件、部署锁及服务状态后再重试,勿直接删除锁", FailureLock: "无法取得部署锁,可能有任务正在执行或存在中断遗留;请管理员核查远端文件、部署锁及服务状态后再重试,勿直接删除锁",
FailureUpload: "证书上传失败,已尝试回滚;请检查受保护的服务日志后再重试", FailureUpload: "证书上传失败,已尝试回滚;请检查受保护的服务日志后再重试",
FailureReplace: "证书文件替换失败,已尝试回滚;请检查受保护的服务日志后再重试", FailureReplace: "证书文件替换失败,已尝试回滚;请检查受保护的服务日志后再重试",
FailureValidation: "服务配置校验失败,文件已回滚,未执行重载;请修正配置后再重试", FailureValidation: "服务配置校验失败,文件已回滚,未执行重载;请修正配置后再重试",
FailureRollback: "部署失败且文件回滚未完成,已保留部署锁;请管理员核查远端文件及服务状态,勿直接重试或删除锁", FailureRollback: "部署失败且文件回滚未完成,已保留部署锁;请管理员核查远端文件及服务状态,勿直接重试或删除锁",
FailureRuntimeUnknown: "服务重载失败,文件已回滚,但服务运行状态未确认;请管理员核对配置并恢复服务后再重试", FailureRuntimeUnknown: "服务重载失败,文件已回滚,但服务运行状态未确认;请管理员核对配置并恢复服务后再重试",
FailureCleanup: "证书已推送并重载,但旧文件清理未完成;请管理员核查现场,不能据此确认站点新证书已生效", FailureCleanup: "证书已推送并重载,但旧文件清理未完成;请管理员核查现场,不能据此确认站点新证书已生效",
FailureLockRelease: "部署锁释放未完成;请管理员核查远端文件、部署锁及服务状态后再重试", FailureLockRelease: "部署锁释放未完成;请管理员核查远端文件、部署锁及服务状态后再重试",
FailureManagementConflict: "该服务器的管理域名由受限激活服务接管,不能使用普通业务证书路径部署;请使用管理证书续期流程,或为业务域名配置独立 Nginx 站点",
} }
// PublicResult 保留结果事实,同时只允许固定错误码及中文诊断进入公开响应。 // PublicResult 保留结果事实,同时只允许固定错误码及中文诊断进入公开响应。

View File

@ -1,9 +1,11 @@
package deployment package deployment
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@ -13,8 +15,9 @@ import (
) )
type Target struct { type Target struct {
Server Server Server Server
Baseline RecoveryBaseline DomainName string
Baseline RecoveryBaseline
} }
type Artifact struct { type Artifact struct {
@ -95,7 +98,7 @@ func (s *Service) deploy(ctx context.Context, targets []Target, artifact Artifac
return report return report
} }
for index, target := range targets { 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) report.Results = append(report.Results, result)
if result.Status == "success" { if result.Status == "success" {
report.Success++ report.Success++
@ -138,7 +141,7 @@ func loadArtifact(saved SavedFiles) (Artifact, error) {
return Artifact{Certificate: certificateData, FullChain: fullChainData, PrivateKey: privateKeyData}, nil 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) ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel() defer cancel()
server = server.Normalize() server = server.Normalize()
@ -159,6 +162,15 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
_ = client.Close() _ = 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{ paths := []remoteFile{
{path: RemotePath(server.CertificatePath), data: artifact.Certificate, mode: 0o644}, {path: RemotePath(server.CertificatePath), data: artifact.Certificate, mode: 0o644},
{path: RemotePath(server.CertificateChainPath), data: artifact.FullChain, 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 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) { 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) transactionContext, ok := TransactionContextFromContext(ctx)
if !ok || s == nil || s.journal == nil { if !ok || s == nil || s.journal == nil {

View File

@ -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) { func TestDeployReportsBackupCleanupFailure(t *testing.T) {
client := newMemoryClient() client := newMemoryClient()
client.removeError = ".scert-backup-" client.removeError = ".scert-backup-"