From 80b6c0c70753baad2d233aa4be06234decadb36e Mon Sep 17 00:00:00 2001 From: xuans Date: Sat, 3 Oct 2026 17:11:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=8F=AA=E8=AF=BB=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=A0=B8=E6=9F=A5=E5=88=9D=E7=A8=BF=E5=B9=B6=E5=BC=80?= =?UTF-8?q?=E5=A7=8BLinux=E4=B8=93=E9=A1=B9=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../deployment/recovery_inspection.go | 202 ++++++++++++++ .../deployment/recovery_inspection_test.go | 251 ++++++++++++++++++ doc/部署事务人工核查任务清单.md | 19 ++ 3 files changed, 472 insertions(+) create mode 100644 backend/internal/deployment/recovery_inspection.go create mode 100644 backend/internal/deployment/recovery_inspection_test.go create mode 100644 doc/部署事务人工核查任务清单.md diff --git a/backend/internal/deployment/recovery_inspection.go b/backend/internal/deployment/recovery_inspection.go new file mode 100644 index 0000000..b5d5c55 --- /dev/null +++ b/backend/internal/deployment/recovery_inspection.go @@ -0,0 +1,202 @@ +package deployment + +import ( + "context" + "errors" + "strings" + "time" + "unicode" +) + +var ( + ErrRecoveryInspectionUnavailable = errors.New("部署现场核查不可用") + ErrRecoveryInspectionRecord = errors.New("部署现场核查记录无效或不存在") + ErrRecoveryInspectionIdentity = errors.New("服务器身份与历史事务不一致,拒绝核查") + ErrRecoveryInspectionRead = errors.New("部署现场读取失败,不能认定核查完成") +) + +// RecoveryInspection 仅为只读采样,不是原子快照,也不是提交或回滚的授权凭据。 +type RecoveryInspection struct { + TransactionID string `json:"transaction_id"` + ServerID string `json:"server_id"` + Phase string `json:"phase"` + Status string `json:"status"` + HistoricalEvidence bool `json:"historical_evidence"` + ObservedAt time.Time `json:"observed_at"` + Files []RecoveryFileObservation `json:"files"` + Locks []RecoveryLockObservation `json:"locks"` +} + +type RecoveryFileObservation struct { + Path string `json:"path"` + Kind string `json:"kind"` + Exists bool `json:"exists"` + Digest string `json:"digest,omitempty"` + Comparison string `json:"comparison"` +} + +type RecoveryLockObservation struct { + Path string `json:"path"` + Exists bool `json:"exists"` + Ownership string `json:"ownership"` +} + +// InspectPending 只读取待恢复记录与远端文件,绝不运行命令、建立锁或修改事务。 +// 缺失的历史证据保持缺失,当前观测摘要不能补写为当时的恢复证据。 +func (s *Service) InspectPending(ctx context.Context, id string, resolve func(context.Context, string) (Server, error)) (RecoveryInspection, error) { + if err := ctx.Err(); err != nil { + return RecoveryInspection{}, err + } + if s == nil || s.journal == nil || s.factory == nil || resolve == nil { + return RecoveryInspection{}, ErrRecoveryInspectionUnavailable + } + if !validInspectionID(id) { + return RecoveryInspection{}, ErrRecoveryInspectionRecord + } + records, err := s.journal.ListPendingDeploymentTransactions(ctx) + if err != nil { + return RecoveryInspection{}, ErrRecoveryInspectionUnavailable + } + var record *TransactionRecord + for i := range records { + if records[i].ID == id { + if record != nil { + return RecoveryInspection{}, ErrRecoveryInspectionRecord + } + record = &records[i] + } + } + if record == nil || validateInspectionRecord(*record) != nil { + return RecoveryInspection{}, ErrRecoveryInspectionRecord + } + if err := ctx.Err(); err != nil { + return RecoveryInspection{}, err + } + server, err := resolve(ctx, record.ServerID) + if err != nil { + return RecoveryInspection{}, ErrRecoveryInspectionUnavailable + } + server = server.Normalize() + if server.ID != record.ServerID || server.Validate() != nil { + return RecoveryInspection{}, ErrRecoveryInspectionIdentity + } + if record.Evidence != nil && validateRecoveryEvidence(*record, server) != nil { + return RecoveryInspection{}, ErrRecoveryInspectionIdentity + } + if err := ctx.Err(); err != nil { + return RecoveryInspection{}, err + } + client, err := s.factory.Connect(ctx, server) + if err != nil { + return RecoveryInspection{}, ErrRecoveryInspectionUnavailable + } + defer client.Close() + reader, ok := client.(EvidenceClient) + if !ok { + return RecoveryInspection{}, ErrRecoveryInspectionUnavailable + } + result := RecoveryInspection{ + TransactionID: record.ID, ServerID: record.ServerID, Phase: record.Phase, Status: record.Status, + HistoricalEvidence: record.Evidence != nil, + Files: make([]RecoveryFileObservation, 0, 9), Locks: make([]RecoveryLockObservation, 0, 3), + } + for _, target := range record.Paths { + paths := []struct{ path, kind, expected string }{ + {target, "target", ""}, + {target + ".scert-tmp-" + record.ID, "temporary", ""}, + {target + ".scert-backup-" + record.ID, "backup", ""}, + } + if record.Evidence != nil { + paths[0].expected = record.Evidence.Files[target] + paths[1].expected = record.Evidence.TemporaryFiles[paths[1].path] + for _, backup := range record.Backups { + if backup.Original == target && backup.OriginalExists { + paths[2].expected = backup.OriginalDigest + } + } + } + for _, item := range paths { + if err := ctx.Err(); err != nil { + return RecoveryInspection{}, err + } + exists, err := client.Exists(ctx, item.path) + if err != nil { + return RecoveryInspection{}, ErrRecoveryInspectionRead + } + observation := RecoveryFileObservation{Path: item.path, Kind: item.kind, Exists: exists, Comparison: "missing"} + if exists { + digest, err := reader.FileDigest(ctx, item.path) + if err != nil || !validDigest(digest) { + return RecoveryInspection{}, ErrRecoveryInspectionRead + } + observation.Digest, observation.Comparison = digest, "unrecorded" + if validDigest(item.expected) { + observation.Comparison = "mismatch" + if digest == item.expected { + observation.Comparison = "match" + } + } + } + result.Files = append(result.Files, observation) + } + lock := RecoveryLockObservation{Path: target + ".scert-lock", Ownership: "missing"} + lock.Exists, err = client.Exists(ctx, lock.Path) + if err != nil { + return RecoveryInspection{}, ErrRecoveryInspectionRead + } + if lock.Exists { + owner, err := reader.ReadLockOwner(ctx, lock.Path) + if err != nil { + return RecoveryInspection{}, ErrRecoveryInspectionRead + } + lock.Ownership = "unproven" + if record.Evidence != nil && validDigest(owner) { + lock.Ownership = "foreign" + if owner == record.Evidence.LockToken { + lock.Ownership = "match" + } + } + } + result.Locks = append(result.Locks, lock) + } + if err := ctx.Err(); err != nil { + return RecoveryInspection{}, err + } + result.ObservedAt = time.Now().UTC() + return result, nil +} + +func validInspectionID(id string) bool { + if len(id) == 0 || len(id) > 160 { + return false + } + for _, c := range id { + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' { + return false + } + } + return id != "." && id != ".." +} + +func validateInspectionRecord(record TransactionRecord) error { + if !validInspectionID(record.ID) || record.ServerID == "" || len(record.Paths) != 3 || + (record.Status != TransactionStatusPending && record.Status != TransactionStatusRecoveryRequired) { + return ErrRecoveryInspectionRecord + } + allowed := make(map[string]bool, 12) + for _, target := range record.Paths { + if len(target) < 2 || len(target) > 4096 || target[0] != '/' || RemotePath(target) != target || + strings.ContainsFunc(target, unicode.IsControl) { + return ErrRecoveryInspectionRecord + } + for _, name := range []string{target, target + ".scert-lock", target + ".scert-tmp-" + record.ID, target + ".scert-backup-" + record.ID} { + for previous := range allowed { + if previous == name || strings.HasPrefix(name, previous+"/") || strings.HasPrefix(previous, name+"/") { + return ErrRecoveryInspectionRecord + } + } + allowed[name] = true + } + } + return nil +} diff --git a/backend/internal/deployment/recovery_inspection_test.go b/backend/internal/deployment/recovery_inspection_test.go new file mode 100644 index 0000000..e479159 --- /dev/null +++ b/backend/internal/deployment/recovery_inspection_test.go @@ -0,0 +1,251 @@ +package deployment + +import ( + "context" + "encoding/json" + "errors" + "maps" + "reflect" + "strings" + "testing" +) + +type inspectionClient struct { + *memoryClient + closed int + mutations int + readError bool + afterDigest func() +} + +func (c *inspectionClient) Close() error { c.closed++; return nil } +func (c *inspectionClient) Lock(context.Context, string) error { + c.mutations++ + return errors.New("mutation") +} +func (c *inspectionClient) Unlock(context.Context, string) error { + c.mutations++ + return errors.New("mutation") +} +func (c *inspectionClient) MkdirAll(context.Context, string) error { + c.mutations++ + return errors.New("mutation") +} +func (c *inspectionClient) Upload(context.Context, string, []byte, uint32) error { + c.mutations++ + return errors.New("mutation") +} +func (c *inspectionClient) Rename(context.Context, string, string) error { + c.mutations++ + return errors.New("mutation") +} +func (c *inspectionClient) Remove(context.Context, string) error { + c.mutations++ + return errors.New("mutation") +} +func (c *inspectionClient) Run(context.Context, []string) (CommandResult, error) { + c.mutations++ + return CommandResult{}, errors.New("mutation") +} +func (c *inspectionClient) CreateOwnedLock(context.Context, string, string) error { + c.mutations++ + return errors.New("mutation") +} +func (c *inspectionClient) FileDigest(ctx context.Context, path string) (string, error) { + if c.readError { + return "", errors.New("sensitive-read-error") + } + digest, err := c.memoryClient.FileDigest(ctx, path) + if c.afterDigest != nil { + c.afterDigest() + } + return digest, err +} + +type inspectionFactory struct { + client Client + calls int +} + +func (f *inspectionFactory) Connect(context.Context, Server) (Client, error) { + f.calls++ + return f.client, nil +} + +func TestRecoveryInspectionReadOnly(t *testing.T) { + for _, scenario := range []string{"trusted", "legacy", "missing", "mismatch", "foreign-lock", "ownerless-lock", "read-failure", "cancelled-read"} { + t.Run(scenario, func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + if scenario == "legacy" { + record.Evidence = nil + } + if scenario == "missing" { + clear(memory.files) + } + if scenario == "mismatch" { + memory.files[record.Paths[0]] = memoryFile{data: []byte("untrusted-content")} + } + if scenario == "foreign-lock" { + memory.files[record.LockPaths[0]+"/owner"] = memoryFile{data: []byte(contentDigest([]byte("foreign")))} + } + if scenario == "ownerless-lock" { + delete(memory.files, record.LockPaths[0]+"/owner") + } + before := maps.Clone(memory.files) + encodedBefore, _ := json.Marshal(record) + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + client := &inspectionClient{memoryClient: memory, readError: scenario == "read-failure"} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if scenario == "cancelled-read" { + client.afterDigest = cancel + } + service := NewServiceWithJournal(fixedClientFactory{client}, journal) + result, err := service.InspectPending(ctx, record.ID, func(context.Context, string) (Server, error) { return server, nil }) + if scenario == "read-failure" || scenario == "cancelled-read" { + if err == nil || !reflect.DeepEqual(result, RecoveryInspection{}) { + t.Fatal("incomplete inspection returned observations") + } + if strings.Contains(err.Error(), "sensitive-read-error") { + t.Fatal("raw client error leaked") + } + if scenario == "cancelled-read" && !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + } else { + if err != nil { + t.Fatal(err) + } + if len(result.Files) != 9 || len(result.Locks) != 3 || result.ObservedAt.IsZero() { + t.Fatalf("incomplete result: %+v", result) + } + switch scenario { + case "trusted": + for _, file := range result.Files { + if file.Comparison != "match" { + t.Fatalf("unexpected file: %+v", file) + } + } + for _, lock := range result.Locks { + if lock.Ownership != "match" { + t.Fatal(lock) + } + } + case "legacy": + if result.HistoricalEvidence { + t.Fatal("legacy observations became historical evidence") + } + for _, file := range result.Files { + if file.Comparison != "unrecorded" { + t.Fatal(file) + } + } + for _, lock := range result.Locks { + if lock.Ownership != "unproven" { + t.Fatal(lock) + } + } + case "missing": + for _, file := range result.Files { + if file.Exists || file.Comparison != "missing" || file.Digest != "" { + t.Fatal(file) + } + } + for _, lock := range result.Locks { + if lock.Exists || lock.Ownership != "missing" { + t.Fatal(lock) + } + } + case "mismatch": + if result.Files[0].Comparison != "mismatch" { + t.Fatal(result.Files[0]) + } + case "foreign-lock": + if result.Locks[0].Ownership != "foreign" { + t.Fatal(result.Locks[0]) + } + case "ownerless-lock": + if result.Locks[0].Ownership != "unproven" { + t.Fatal(result.Locks[0]) + } + } + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{server.PasswordRef, contentDigest([]byte("preflight-owner")), "untrusted-content", "sensitive-read-error"} { + if strings.Contains(string(data), secret) { + t.Fatalf("secret leaked: %s", secret) + } + } + } + encodedAfter, _ := json.Marshal(journal.pending[0]) + if !reflect.DeepEqual(before, memory.files) || string(encodedBefore) != string(encodedAfter) || client.mutations != 0 || len(journal.finished) != 0 || len(journal.updated) != 0 { + t.Fatal("inspection modified remote files or transaction state") + } + if client.closed != 1 { + t.Fatalf("client close count: %d", client.closed) + } + }) + } +} + +func TestRecoveryInspectionRejectsBeforeConnection(t *testing.T) { + for _, scenario := range []string{"unknown-id", "unsafe-id", "duplicate-id", "cancelled", "resolver-error", "wrong-server", "changed-host", "changed-command", "bad-path", "control-path", "duplicate-path", "nested-path", "overlap-lock", "bad-digest", "terminal-record"} { + t.Run(scenario, func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + id := record.ID + switch scenario { + case "unknown-id": + id = "unknown" + case "unsafe-id": + id = "../escape" + case "cancelled": + cancel() + case "wrong-server": + server.ID = "other" + case "changed-host": + server.HostKeyFingerprint = "SHA256:changed" + case "changed-command": + server.ReloadCommand = []string{"sh", "-c", "unexpected"} + case "bad-path": + record.Paths[0] = "/etc/scert/../shadow" + case "control-path": + record.Paths[0] = "/etc/scert/\x1bfile" + case "duplicate-path": + record.Paths[0] = record.Paths[1] + case "nested-path": + record.Paths[0] = record.Paths[1] + "/child" + case "overlap-lock": + record.Paths[0] = record.Paths[1] + ".scert-lock" + case "bad-digest": + record.Evidence.Files[record.Paths[0]] = "bad" + case "terminal-record": + record.Status = TransactionStatusCommitted + } + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + if scenario == "duplicate-id" { + journal.pending = append(journal.pending, record) + } + factory := &inspectionFactory{client: memory} + service := NewServiceWithJournal(factory, journal) + result, err := service.InspectPending(ctx, id, func(context.Context, string) (Server, error) { + if scenario == "resolver-error" { + return Server{}, errors.New("secret-resolver-error") + } + return server, nil + }) + if err == nil || !reflect.DeepEqual(result, RecoveryInspection{}) || factory.calls != 0 { + t.Fatalf("unsafe connection or result: %+v, %v, %d", result, err, factory.calls) + } + if strings.Contains(err.Error(), "secret-resolver-error") { + t.Fatal("resolver error leaked") + } + if len(journal.finished) != 0 || len(journal.updated) != 0 { + t.Fatal("rejected request modified journal") + } + }) + } +} diff --git a/doc/部署事务人工核查任务清单.md b/doc/部署事务人工核查任务清单.md new file mode 100644 index 0000000..c393cf9 --- /dev/null +++ b/doc/部署事务人工核查任务清单.md @@ -0,0 +1,19 @@ +# 部署事务人工核查任务清单 + +## 2026年10月3日 需求确认与执行顺序 + +依据现有整改清单的 P0-10 恢复门禁继续开发,仅处理 Linux。人工核查不能用当前正常状态补造历史部署证据;不能直接修改生产数据库终态。 + +- [~] R1:实现只读现场核查,读取目标、临时文件、备份及锁的状态,返回摘要与匹配情况,不返回私钥、密码或锁令牌。 +- [ ] R2:通过现有受保护本机 IPC 接入列表与核查入口,不注册公网 HTTP 路由,不增加 TCP 监听。 +- [ ] R3:设计独立人工处置记录、明确确认及再次核验;有可信历史证据才允许提交或回滚。缺历史证据的事务不得伪装为历史提交成功。 +- [ ] R4:补齐确认幂等、并发部署互斥、审计原子落库、取消及失败保留现场测试。 +- [ ] R5:构建 Linux 候选包,经正式安装器部署后,用两台服务器核查旧事务,验证真实 HTTPS 和恢复结果。 +- [ ] R6:更新总整改清单和生产测试记录;人工核查功能完整复审之前,P0-10 和总体生产验收不得标为通过。 + +## 本轮边界 + +- 只读核查结果属于采样观察,不是事务快照,不构成远端 Nginx reload、HTTPS 或部署终态证据。 +- 服务器身份或历史路径无效时拒绝读取;证据缺失时明确报告,不自动生成恢复证据、不删锁、不清理现场。 +- 保留现有密码重置通道的身份校验及邮箱验证码要求,不因接入核查而降低密码重置安全边界。 +- Git 修改前检查点为 ba57490;推送收到 Authentication failed,本地继续,远端同步仍待解决。