保存只读部署核查初稿并开始Linux专项验证
This commit is contained in:
parent
ba57490c03
commit
80b6c0c707
202
backend/internal/deployment/recovery_inspection.go
Normal file
202
backend/internal/deployment/recovery_inspection.go
Normal file
@ -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
|
||||
}
|
||||
251
backend/internal/deployment/recovery_inspection_test.go
Normal file
251
backend/internal/deployment/recovery_inspection_test.go
Normal file
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
19
doc/部署事务人工核查任务清单.md
Normal file
19
doc/部署事务人工核查任务清单.md
Normal file
@ -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,本地继续,远端同步仍待解决。
|
||||
Loading…
Reference in New Issue
Block a user