142 lines
4.7 KiB
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
|
|
}
|