Scert/backend/internal/deployment/recovery_review.go

236 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package deployment
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"strings"
"time"
"unicode"
)
type RecoveryAction string
const (
RecoveryActionRecord RecoveryAction = "record_inspection"
RecoveryActionCommit RecoveryAction = "finish_commit"
RecoveryActionRollback RecoveryAction = "restore_baseline"
RecoveryActionVerifyCommit RecoveryAction = "verify_commit"
RecoveryActionVerifyRollback RecoveryAction = "verify_rollback"
RecoveryReviewLifetime = time.Minute
)
var (
ErrRecoveryReviewInvalid = errors.New("人工处置准备参数无效")
ErrRecoveryReviewExpired = errors.New("人工处置准备已过期,请重新核查")
ErrRecoveryReviewChanged = errors.New("事务、服务器或现场已变化,请重新准备处置")
ErrRecoveryReviewEvidence = errors.New("历史恢复证据不足,只能记录核查结论,不能提交或回滚")
)
type RecoveryReviewSummary struct {
SourceDispositionID string `json:"source_disposition_id,omitempty"`
TransactionID string `json:"transaction_id"`
ServerID string `json:"server_id"`
Phase string `json:"phase"`
Action RecoveryAction `json:"action"`
HistoricalEvidence bool `json:"historical_evidence"`
ObservedAt time.Time `json:"observed_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// Validate 与服务端历史记录使用同一摘要边界,允许旧记录没有阶段,但不允许控制字符或超长字段。
func (r RecoveryReviewSummary) Validate() error {
if !validInspectionID(r.TransactionID) || !validRecoverySummary(r.ServerID) ||
(r.Phase != "" && !validRecoverySummary(r.Phase)) || !validRecoveryAction(r.Action) ||
r.ObservedAt.IsZero() || !r.ExpiresAt.After(r.ObservedAt) || r.ExpiresAt.Sub(r.ObservedAt) > RecoveryReviewLifetime ||
(r.Action != RecoveryActionRecord && !r.HistoricalEvidence) {
return ErrRecoveryReviewInvalid
}
if isRecoveryVerification(r.Action) != (r.SourceDispositionID != "") || (r.SourceDispositionID != "" && !validDispositionID(r.SourceDispositionID)) {
return ErrRecoveryReviewInvalid
}
return nil
}
// RecoveryReview 只保存只读准备摘要和不可公开的绑定值,不保存凭据或原始恢复证据。
// 它不是执行授权;调用者仍须取得持久化占用、验证动作条件并原子写入审计。
type RecoveryReview struct {
summary RecoveryReviewSummary
operator string
recordDigest [sha256.Size]byte
serverDigest [sha256.Size]byte
observationDigest [sha256.Size]byte
service *Service
}
func (r RecoveryReview) Summary() RecoveryReviewSummary { return r.summary }
func validReviewOperator(operator string) bool {
return strings.TrimSpace(operator) == operator && len(operator) > 0 && len(operator) <= 160 && !strings.ContainsFunc(operator, unicode.IsControl)
}
func validRecoveryAction(action RecoveryAction) bool {
return action == RecoveryActionRecord || action == RecoveryActionCommit || action == RecoveryActionRollback || isRecoveryVerification(action)
}
// PrepareRecoveryReview 在同一操作上限内读取前后记录,拒绝采样期间发生的配置或事务变化。
// 当前采样摘要只绑定本次准备,不补写到历史 Evidence,也不改变事务终态。
func (s *Service) PrepareRecoveryReview(ctx context.Context, id, operator string, action RecoveryAction, resolve func(context.Context, string) (Server, error)) (RecoveryReview, error) {
// 重新核查必须通过独立入口绑定原处置,不能作为普通动作绕过来源检查。
if isRecoveryVerification(action) {
return RecoveryReview{}, ErrRecoveryReviewInvalid
}
return s.prepareRecoveryReview(ctx, id, operator, action, resolve)
}
func (s *Service) prepareRecoveryReview(ctx context.Context, id, operator string, action RecoveryAction, resolve func(context.Context, string) (Server, error)) (RecoveryReview, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if !validReviewOperator(operator) || !validRecoveryAction(action) || !validInspectionID(id) {
return RecoveryReview{}, ErrRecoveryReviewInvalid
}
if s == nil || s.now == nil || s.journal == nil || s.factory == nil || resolve == nil {
return RecoveryReview{}, ErrRecoveryInspectionUnavailable
}
started := s.now()
record, recordDigest, serverDigest, err := s.reviewRecord(ctx, id, resolve)
if err != nil {
return RecoveryReview{}, err
}
if action != RecoveryActionRecord && record.Evidence == nil {
return RecoveryReview{}, ErrRecoveryReviewEvidence
}
inspection, err := s.inspectPending(ctx, id, resolve, func(sampleRecord TransactionRecord, sampleServer Server) error {
sampleRecordDigest, err := reviewDigest(sampleRecord)
if err != nil {
return err
}
sampleServerDigest, err := reviewServerDigest(sampleServer)
if err != nil {
return err
}
if recordDigest != sampleRecordDigest || serverDigest != sampleServerDigest {
return ErrRecoveryReviewChanged
}
return nil
})
if err != nil {
return RecoveryReview{}, err
}
_, currentRecord, currentServer, err := s.reviewRecord(ctx, id, resolve)
if err != nil {
return RecoveryReview{}, err
}
if recordDigest != currentRecord || serverDigest != currentServer {
return RecoveryReview{}, ErrRecoveryReviewChanged
}
observedAt := inspection.ObservedAt
inspection.ObservedAt = time.Time{}
observationDigest, err := reviewDigest(inspection)
if err != nil {
return RecoveryReview{}, err
}
if err := ctx.Err(); err != nil {
return RecoveryReview{}, err
}
expiresAt := started.Add(RecoveryReviewLifetime)
if !s.now().Before(expiresAt) {
return RecoveryReview{}, ErrRecoveryReviewExpired
}
return RecoveryReview{
summary: RecoveryReviewSummary{TransactionID: id, ServerID: record.ServerID, Phase: record.Phase,
Action: action, HistoricalEvidence: record.Evidence != nil, ObservedAt: observedAt, ExpiresAt: expiresAt},
operator: operator, recordDigest: recordDigest, serverDigest: serverDigest, observationDigest: observationDigest, service: s,
}, nil
}
// RecheckRecoveryReview 仅验证重新读取的状态与原准备一致,不消费确认凭据或执行远端变更。
// 后续执行器必须在持久化互斥范围内调用,并再次执行动作专属的锁、文件和严格 HTTPS 检查。
func (s *Service) RecheckRecoveryReview(ctx context.Context, review RecoveryReview, operator string, resolve func(context.Context, string) (Server, error)) error {
if s == nil || review.service != s || review.operator != operator || !validReviewOperator(operator) {
return ErrRecoveryReviewInvalid
}
if s.now == nil || !s.now().Before(review.summary.ExpiresAt) {
return ErrRecoveryReviewExpired
}
var current RecoveryReview
var err error
if isRecoveryVerification(review.summary.Action) {
current, err = s.prepareRecoveryReconciliation(ctx, review.summary.SourceDispositionID, operator, resolve)
} else {
current, err = s.PrepareRecoveryReview(ctx, review.summary.TransactionID, operator, review.summary.Action, resolve)
}
if err != nil {
return err
}
if !s.now().Before(review.summary.ExpiresAt) {
return ErrRecoveryReviewExpired
}
if current.recordDigest != review.recordDigest || current.serverDigest != review.serverDigest || current.observationDigest != review.observationDigest || current.summary.SourceDispositionID != review.summary.SourceDispositionID || current.summary.Action != review.summary.Action {
return ErrRecoveryReviewChanged
}
return ctx.Err()
}
func reviewDigest(value any) ([sha256.Size]byte, error) {
data, err := json.Marshal(value)
if err != nil {
return [sha256.Size]byte{}, ErrRecoveryReviewInvalid
}
return sha256.Sum256(data), nil
}
func (s *Service) reviewRecord(ctx context.Context, id string, resolve func(context.Context, string) (Server, error)) (TransactionRecord, [sha256.Size]byte, [sha256.Size]byte, error) {
fail := func(err error) (TransactionRecord, [sha256.Size]byte, [sha256.Size]byte, error) {
return TransactionRecord{}, [sha256.Size]byte{}, [sha256.Size]byte{}, err
}
if err := ctx.Err(); err != nil {
return fail(err)
}
records, err := s.journal.ListPendingDeploymentTransactions(ctx)
if err != nil {
return fail(inspectionError(ctx, ErrRecoveryInspectionUnavailable))
}
var found *TransactionRecord
for i := range records {
if records[i].ID == id {
if found != nil {
return fail(ErrRecoveryInspectionRecord)
}
found = &records[i]
}
}
if found == nil || validateInspectionRecord(*found) != nil || !validRecoverySummary(found.ServerID) ||
(found.Phase != "" && !validRecoverySummary(found.Phase)) {
return fail(ErrRecoveryInspectionRecord)
}
server, err := resolve(ctx, found.ServerID)
if err != nil {
return fail(inspectionError(ctx, ErrRecoveryInspectionUnavailable))
}
server = server.Normalize()
if server.ID != found.ServerID || server.Validate() != nil || (found.Evidence != nil && validateRecoveryEvidence(*found, server) != nil) {
return fail(ErrRecoveryInspectionIdentity)
}
recordDigest, err := reviewDigest(found)
if err != nil {
return fail(err)
}
serverDigest, err := reviewServerDigest(server)
if err != nil {
return fail(err)
}
if err := ctx.Err(); err != nil {
return fail(err)
}
return *found, recordDigest, serverDigest, nil
}
func reviewServerDigest(server Server) ([sha256.Size]byte, error) {
return reviewDigest(struct {
Server Server
CredentialReference string
}{Server: server, CredentialReference: server.PasswordRef})
}