From ed0c97b9579d8770dedd57b5965370eaa979fc4d Mon Sep 17 00:00:00 2001 From: xuans Date: Sat, 3 Oct 2026 19:23:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=BA=BA=E5=B7=A5=E5=A4=84?= =?UTF-8?q?=E7=BD=AE=E5=87=86=E5=A4=87=E7=BB=91=E5=AE=9A=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E6=A0=B8=E9=AA=8C=E5=BC=80=E5=8F=91=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../deployment/recovery_inspection.go | 9 + .../internal/deployment/recovery_review.go | 204 ++++++++++++++ .../deployment/recovery_review_test.go | 266 ++++++++++++++++++ doc/部署事务人工处置设计.md | 11 + 4 files changed, 490 insertions(+) create mode 100644 backend/internal/deployment/recovery_review.go create mode 100644 backend/internal/deployment/recovery_review_test.go diff --git a/backend/internal/deployment/recovery_inspection.go b/backend/internal/deployment/recovery_inspection.go index 7c13290..09142ba 100644 --- a/backend/internal/deployment/recovery_inspection.go +++ b/backend/internal/deployment/recovery_inspection.go @@ -44,6 +44,10 @@ type RecoveryLockObservation struct { // InspectPending 只读取待恢复记录与远端文件,绝不运行命令、建立锁或修改事务。 // 缺失的历史证据保持缺失,当前观测摘要不能补写为当时的恢复证据。 func (s *Service) InspectPending(ctx context.Context, id string, resolve func(context.Context, string) (Server, error)) (RecoveryInspection, error) { + return s.inspectPending(ctx, id, resolve, nil) +} + +func (s *Service) inspectPending(ctx context.Context, id string, resolve func(context.Context, string) (Server, error), bind func(TransactionRecord, Server) error) (RecoveryInspection, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() if err := ctx.Err(); err != nil { @@ -85,6 +89,11 @@ func (s *Service) InspectPending(ctx context.Context, id string, resolve func(co if record.Evidence != nil && validateRecoveryEvidence(*record, server) != nil { return RecoveryInspection{}, ErrRecoveryInspectionIdentity } + if bind != nil { + if err := bind(*record, server); err != nil { + return RecoveryInspection{}, err + } + } if err := ctx.Err(); err != nil { return RecoveryInspection{}, err } diff --git a/backend/internal/deployment/recovery_review.go b/backend/internal/deployment/recovery_review.go new file mode 100644 index 0000000..d777ed4 --- /dev/null +++ b/backend/internal/deployment/recovery_review.go @@ -0,0 +1,204 @@ +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" + RecoveryReviewLifetime = time.Minute +) + +var ( + ErrRecoveryReviewInvalid = errors.New("人工处置准备参数无效") + ErrRecoveryReviewExpired = errors.New("人工处置准备已过期,请重新核查") + ErrRecoveryReviewChanged = errors.New("事务、服务器或现场已变化,请重新准备处置") + ErrRecoveryReviewEvidence = errors.New("历史恢复证据不足,只能记录核查结论,不能提交或回滚") +) + +type RecoveryReviewSummary struct { + 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"` +} + +// 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 +} + +// PrepareRecoveryReview 在同一操作上限内读取前后记录,拒绝采样期间发生的配置或事务变化。 +// 当前采样摘要只绑定本次准备,不补写到历史 Evidence,也不改变事务终态。 +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 + } + 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 { + 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}) +} diff --git a/backend/internal/deployment/recovery_review_test.go b/backend/internal/deployment/recovery_review_test.go new file mode 100644 index 0000000..2634e39 --- /dev/null +++ b/backend/internal/deployment/recovery_review_test.go @@ -0,0 +1,266 @@ +package deployment + +import ( + "context" + "encoding/json" + "errors" + "maps" + "reflect" + "strings" + "testing" + "time" +) + +func TestRecoveryReviewReadOnlyBindings(t *testing.T) { + for _, action := range []RecoveryAction{RecoveryActionRecord, RecoveryActionCommit, RecoveryActionRollback} { + t.Run(string(action), func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + before := maps.Clone(memory.files) + encodedBefore, _ := json.Marshal(record) + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + client := &inspectionClient{memoryClient: memory} + service := NewServiceWithJournal(fixedClientFactory{client}, journal) + resolve := func(context.Context, string) (Server, error) { return server, nil } + review, err := service.PrepareRecoveryReview(t.Context(), record.ID, "unix:0", action, resolve) + if err != nil { + t.Fatal(err) + } + summary := review.Summary() + if summary.TransactionID != record.ID || summary.ServerID != record.ServerID || summary.Action != action || + !summary.HistoricalEvidence || summary.ObservedAt.IsZero() || !summary.ExpiresAt.After(summary.ObservedAt) { + t.Fatal("invalid preparation summary") + } + summary.TransactionID = "tampered" + if err := service.RecheckRecoveryReview(t.Context(), review, "unix:0", resolve); err != nil { + t.Fatal(err) + } + encodedAfter, _ := json.Marshal(journal.pending[0]) + if client.mutations != 0 || !reflect.DeepEqual(before, memory.files) || string(encodedAfter) != string(encodedBefore) { + t.Fatal("review changed original transaction or remote state") + } + encoded, _ := json.Marshal(review) + if string(encoded) != "{}" { + t.Fatal("private bindings serialized") + } + encoded, _ = json.Marshal(review.Summary()) + if strings.Contains(string(encoded), record.Evidence.LockToken) || strings.Contains(string(encoded), "credential") { + t.Fatal("public summary contains secret evidence") + } + }) + } +} + +func TestRecoveryReviewLegacyCannotPrepareWrites(t *testing.T) { + for _, action := range []RecoveryAction{RecoveryActionRecord, RecoveryActionCommit, RecoveryActionRollback} { + t.Run(string(action), func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + record.Evidence = nil + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + client := &inspectionClient{memoryClient: memory} + factory := &inspectionFactory{client: client} + service := NewServiceWithJournal(factory, journal) + review, err := service.PrepareRecoveryReview(t.Context(), record.ID, "unix:0", action, func(context.Context, string) (Server, error) { return server, nil }) + if action == RecoveryActionRecord { + if err != nil || review.Summary().HistoricalEvidence { + t.Fatalf("legacy read failed: %v", err) + } + } else if !errors.Is(err, ErrRecoveryReviewEvidence) || factory.calls != 0 || review.Summary().TransactionID != "" { + t.Fatal("legacy write preparation accepted or connected remotely") + } + if journal.pending[0].Evidence != nil || client.mutations != 0 { + t.Fatal("legacy evidence manufactured") + } + }) + } +} + +func TestRecoveryReviewRejectsChanges(t *testing.T) { + for _, scenario := range []string{"phase", "message", "time", "target", "backup", "temporary", "lock", "server-user", "server-password-reference", "server-fingerprint", "server-command", "server-path", "record-missing"} { + t.Run(scenario, func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + client := &inspectionClient{memoryClient: memory} + service := NewServiceWithJournal(fixedClientFactory{client}, journal) + resolve := func(context.Context, string) (Server, error) { return server, nil } + review, err := service.PrepareRecoveryReview(t.Context(), record.ID, "unix:0", RecoveryActionRollback, resolve) + if err != nil { + t.Fatal(err) + } + switch scenario { + case "phase": + journal.pending[0].Phase = TransactionPhaseCommitting + case "message": + journal.pending[0].Message = "changed" + case "time": + journal.pending[0].UpdatedAt = time.Now().UTC() + case "target": + memory.files[record.Paths[0]] = memoryFile{data: []byte("changed")} + case "backup": + memory.files[record.Backups[0].Saved] = memoryFile{data: []byte("changed")} + case "temporary": + delete(memory.files, record.TemporaryPaths[0]) + case "lock": + memory.files[record.LockPaths[0]+"/owner"] = memoryFile{data: []byte(contentDigest([]byte("foreign")))} + case "server-user": + server.Username = "other" + case "server-password-reference": + server.PasswordRef = "changed-reference" + case "server-fingerprint": + server.HostKeyFingerprint = "SHA256:" + strings.Repeat("B", 43) + case "server-command": + server.ReloadCommand = []string{"systemctl", "reload", "other"} + case "server-path": + server.CertificatePath = "/different/cert.pem" + case "record-missing": + journal.pending = nil + } + if err := service.RecheckRecoveryReview(t.Context(), review, "unix:0", resolve); err == nil { + t.Fatal("changed state reused old preparation") + } + if client.mutations != 0 { + t.Fatal("recheck modified remote state") + } + }) + } +} + +func TestRecoveryReviewRejectsChangesDuringSampling(t *testing.T) { + for _, scenario := range []string{"record", "server", "cancel"} { + t.Run(scenario, func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + client := &inspectionClient{memoryClient: memory} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client.afterDigest = func() { + switch scenario { + case "record": + journal.pending[0].Message = "changed" + case "server": + server.PasswordRef = "changed-reference" + case "cancel": + cancel() + } + } + service := NewServiceWithJournal(fixedClientFactory{client}, journal) + review, err := service.PrepareRecoveryReview(ctx, record.ID, "unix:0", RecoveryActionRecord, func(context.Context, string) (Server, error) { return server, nil }) + want := ErrRecoveryReviewChanged + if scenario == "cancel" { + want = context.Canceled + } + if !errors.Is(err, want) || review.Summary().TransactionID != "" || client.mutations != 0 { + t.Fatalf("sampling change was not rejected: %v", err) + } + }) + } +} + +func TestRecoveryReviewExpiryAndIdentity(t *testing.T) { + for _, scenario := range []string{"expired", "expires-during-recheck", "operator", "other-service", "zero", "canceled"} { + t.Run(scenario, func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + client := &inspectionClient{memoryClient: memory} + factory := &inspectionFactory{client: client} + service := NewServiceWithJournal(factory, journal) + now := time.Now() + service.now = func() time.Time { return now } + resolve := func(context.Context, string) (Server, error) { return server, nil } + review, err := service.PrepareRecoveryReview(t.Context(), record.ID, "unix:0", RecoveryActionRecord, resolve) + if err != nil { + t.Fatal(err) + } + calls := factory.calls + operator := "unix:0" + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + want := ErrRecoveryReviewInvalid + switch scenario { + case "expired": + now = review.Summary().ExpiresAt + want = ErrRecoveryReviewExpired + case "expires-during-recheck": + client.afterDigest = func() { now = review.Summary().ExpiresAt } + want = ErrRecoveryReviewExpired + case "operator": + operator = "unix:1" + case "other-service": + service = NewServiceWithJournal(factory, journal) + case "zero": + review = RecoveryReview{} + case "canceled": + cancel() + want = context.Canceled + } + if err := service.RecheckRecoveryReview(ctx, review, operator, resolve); !errors.Is(err, want) { + t.Fatalf("wrong expiry or identity result: %v", err) + } + if scenario != "expires-during-recheck" && factory.calls != calls { + t.Fatal("rejected identity or expiry read remote state") + } + if client.mutations != 0 { + t.Fatal("rejected review mutated remote state") + } + }) + } +} + +func TestRecoveryReviewBindsActualSampleConfiguration(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + client := &inspectionClient{memoryClient: memory} + factory := &inspectionFactory{client: client} + service := NewServiceWithJournal(factory, journal) + reads := 0 + resolve := func(context.Context, string) (Server, error) { + reads++ + value := server + if reads == 2 { + value.PasswordRef = "transient-configuration" + } + return value, nil + } + review, err := service.PrepareRecoveryReview(t.Context(), record.ID, "unix:0", RecoveryActionRecord, resolve) + if !errors.Is(err, ErrRecoveryReviewChanged) || review.Summary().TransactionID != "" || factory.calls != 0 { + t.Fatal("sample used a different configuration from its binding") + } +} + +func TestRecoveryReviewRejectsInvalidPreparation(t *testing.T) { + for _, scenario := range []string{"operator-empty", "operator-space", "operator-control", "operator-large", "action", "id", "nil-resolver", "nil-service", "canceled"} { + t.Run(scenario, func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + factory := &inspectionFactory{client: &inspectionClient{memoryClient: memory}} + service := NewServiceWithJournal(factory, journal) + resolve := func(context.Context, string) (Server, error) { return server, nil } + operator, id, action := "unix:0", record.ID, RecoveryActionRecord + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch scenario { + case "operator-empty": + operator = "" + case "operator-space": + operator = " unix:0" + case "operator-control": + operator = "unix:0\n" + case "operator-large": + operator = strings.Repeat("x", 161) + case "action": + action = "unknown" + case "id": + id = "../bad" + case "nil-resolver": + resolve = nil + case "nil-service": + service = nil + case "canceled": + cancel() + } + review, err := service.PrepareRecoveryReview(ctx, id, operator, action, resolve) + if err == nil || review.Summary().TransactionID != "" || factory.calls != 0 { + t.Fatal("invalid preparation reached remote state or returned a review") + } + }) + } +} diff --git a/doc/部署事务人工处置设计.md b/doc/部署事务人工处置设计.md index 9f7002c..8fff0b5 100644 --- a/doc/部署事务人工处置设计.md +++ b/doc/部署事务人工处置设计.md @@ -12,6 +12,17 @@ - 不允许删除无归属、归属不符的锁,不允许覆盖与新旧摘要均不匹配的目标文件;出现不一致即保留现场。 - 远端文件动作不能与数据库组成原子事务。必须先持久化处置意图与审计,再执行有界动作;最终状态与完成审计原子落库。进程崩溃必须留下可识别的未完成处置,而不是假成功。 +## 2026年10月3日19:07 准备与重新核验实现任务 + +修改前检查点 `6b77730` 已推送。当前先实现 R3-B 的只读绑定基础,不把它当作执行授权或完整处置功能。 + +- [~] 绑定受信任操作者、明确动作、事务记录、服务器配置和完整现场采样;公开摘要不携带历史证据、凭据或锁归属令牌。 +- [ ] 一分钟有效期,准备及重新核验共用三十秒操作上限;取消、过期、配置变化、事务变化和现场变化均拒绝。 +- [ ] 缺可信历史证据的事务只允许准备记录核查结论,不允许准备提交或回滚,不改变历史状态或现场。 +- [ ] 完成单元及竞态验证;再接入一次性本机确认凭据、数据库处置占用和原子审计。 + +准备对象及重新核验结果只是执行器的前置输入,不代表已经写入处置意图、取得互斥、完成动作资格核验或获得远端写入授权;在持久化和完整执行链路接入前,不对用户开放写操作入口。 + ## 需求和任务清单 - [~] R3-A:确定动作模型、独立审计以及失败边界。本设计已落文档,代码尚未开始。