Scert/backend/internal/deployment/recovery_evidence.go

142 lines
4.7 KiB
Go

package deployment
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"reflect"
)
// RecoveryBaseline 只保存站点身份,不保存证书内容、私钥或登录凭据。
type RecoveryBaseline struct {
Endpoint string `json:"endpoint"`
Domains []string `json:"domains"`
PreviousFingerprint string `json:"previous_fingerprint"`
ExpectedFingerprint string `json:"expected_fingerprint"`
}
// RecoveryEvidence 保存恢复所需的不可变身份与已核验决策。
type RecoveryEvidence struct {
Version int `json:"version"`
LockToken string `json:"lock_token"`
Address string `json:"address"`
Port int `json:"port"`
HostKeyFingerprint string `json:"host_key_fingerprint"`
ValidationCommand []string `json:"validation_command"`
ReloadCommand []string `json:"reload_command"`
Files map[string]string `json:"files"`
TemporaryFiles map[string]string `json:"temporary_files,omitempty"`
Baseline RecoveryBaseline `json:"baseline"`
}
// EvidenceClient 通过原子符号链接保存锁归属,文件摘要不会将私钥传回业务层。
type EvidenceClient interface {
CreateOwnedLock(context.Context, string, string) error
ReadLockOwner(context.Context, string) (string, error)
FileDigest(context.Context, string) (string, error)
}
func contentDigest(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func validDigest(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size
}
func checkOwnedLock(ctx context.Context, client Client, path, token string, allowMissing bool) error {
reader, ok := client.(EvidenceClient)
if !ok || !validDigest(token) {
return errors.New("缺少可验证的部署锁归属信息")
}
owner, err := reader.ReadLockOwner(ctx, path)
if err != nil {
return err
}
if owner == "" && allowMissing {
return nil
}
if owner != token {
return errors.New("部署锁不属于本次事务,已保留现场")
}
return nil
}
func checkFileDigest(ctx context.Context, client Client, path, expected string) error {
reader, ok := client.(EvidenceClient)
if !ok || !validDigest(expected) {
return errors.New("缺少可信文件摘要")
}
actual, err := reader.FileDigest(ctx, path)
if err != nil {
return err
}
if actual != expected {
return fmt.Errorf("文件身份与事务记录不一致:%s", path)
}
return nil
}
func validateRecoveryEvidence(record TransactionRecord, server Server) error {
e := record.Evidence
if e == nil || e.Version != 1 || !validDigest(e.LockToken) {
return errors.New("历史事务缺少可信恢复证据,需核查现场,不自动接管")
}
if e.Address != server.Address || e.Port != server.Port || e.HostKeyFingerprint != server.HostKeyFingerprint ||
!reflect.DeepEqual(e.ValidationCommand, server.ValidationCommand) || !reflect.DeepEqual(e.ReloadCommand, server.ReloadCommand) {
return errors.New("服务器身份或恢复命令已变更,不自动接管历史事务")
}
if len(record.Paths) != 3 || len(record.LockPaths) != 3 || len(e.Files) != 3 {
return errors.New("部署事务目标集合不完整")
}
paths := make(map[string]bool)
for _, path := range record.Paths {
digest := e.Files[path]
if paths[path] || RemotePath(path) != path || len(path) < 2 || path[0] != '/' ||
!validDigest(digest) {
return errors.New("部署事务路径或文件摘要无效")
}
paths[path] = true
}
locks := make(map[string]bool)
for _, lock := range record.LockPaths {
matched := false
for path := range paths {
matched = matched || lock == path+".scert-lock"
}
if !matched || locks[lock] {
return errors.New("部署事务锁集合无效")
}
locks[lock] = true
}
temporaryPaths := make(map[string]bool)
for _, temporary := range record.TemporaryPaths {
matched := false
for path := range paths {
matched = matched || temporary == path+".scert-tmp-"+record.ID
}
if !matched || temporaryPaths[temporary] {
return errors.New("部署事务临时路径无效")
}
temporaryPaths[temporary] = true
}
for path, digest := range e.TemporaryFiles {
if !temporaryPaths[path] || !validDigest(digest) {
return errors.New("部署事务临时文件归属证据无效")
}
}
seen := make(map[string]bool)
for _, backup := range record.Backups {
if !paths[backup.Original] || seen[backup.Original] || backup.Saved != backup.Original+".scert-backup-"+record.ID ||
(backup.OriginalExists && !validDigest(backup.OriginalDigest)) {
return errors.New("部署事务备份身份无效")
}
seen[backup.Original] = true
}
return nil
}