修复管理域名普通证书部署冲突
普通证书部署前核查受保护的 scert-nginx 管理入口策略,阻止覆盖管理域名并补充回归测试。
This commit is contained in:
parent
dc7bf25571
commit
cbd8c587d8
@ -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 := ""
|
||||||
|
|||||||
@ -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 保留结果事实,同时只允许固定错误码及中文诊断进入公开响应。
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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-"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user