保存只读部署核查初稿并开始Linux专项验证

This commit is contained in:
xuans 2026-10-03 17:11:23 +08:00
parent ba57490c03
commit 80b6c0c707
3 changed files with 472 additions and 0 deletions

View 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
}

View 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")
}
})
}
}

View 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,本地继续,远端同步仍待解决。