diff --git a/server/internal/agent/client.go b/server/internal/agent/client.go index 7864b24..08751ea 100644 --- a/server/internal/agent/client.go +++ b/server/internal/agent/client.go @@ -190,6 +190,7 @@ type RestoreSpec struct { Storage StorageTargetConfig `json:"storage"` StoragePath string `json:"storagePath"` FileName string `json:"fileName"` + Checksum string `json:"checksum,omitempty"` } // RestoreUpdate 与 service.AgentRestoreUpdate 对齐 diff --git a/server/internal/agent/executor.go b/server/internal/agent/executor.go index a6ff6f0..24d606d 100644 --- a/server/internal/agent/executor.go +++ b/server/internal/agent/executor.go @@ -379,6 +379,24 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err return err } + // 2.5) 完整性校验:还原前比对下载对象的 SHA-256(与 Master 本地恢复路径一致)。 + // 拒绝还原损坏或被篡改的备份;早期无 checksum 的备份跳过(向后兼容)。 + if strings.TrimSpace(spec.Checksum) != "" { + e.appendRestoreLog(ctx, restoreRecordID, "[agent] 校验备份完整性(SHA-256)\n") + actual, sumErr := computeFileSHA256(artifactPath) + if sumErr != nil { + msg := fmt.Sprintf("计算校验和失败: %v", sumErr) + e.reportRestoreFailure(ctx, restoreRecordID, msg) + return fmt.Errorf("%s", msg) + } + if !strings.EqualFold(actual, spec.Checksum) { + msg := "备份文件完整性校验失败:SHA-256 不匹配,文件可能已损坏或被篡改" + e.reportRestoreFailure(ctx, restoreRecordID, msg) + return fmt.Errorf("%s(期望 %s,实际 %s)", msg, spec.Checksum, actual) + } + e.appendRestoreLog(ctx, restoreRecordID, "[agent] 完整性校验通过\n") + } + // 3) 解压(Agent 不支持加密,遇到 .enc 会直接失败) preparedPath := artifactPath if strings.HasSuffix(strings.ToLower(preparedPath), ".enc") { diff --git a/server/internal/service/restore_service.go b/server/internal/service/restore_service.go index 64b28a2..e7be9b5 100644 --- a/server/internal/service/restore_service.go +++ b/server/internal/service/restore_service.go @@ -463,6 +463,8 @@ type AgentRestoreSpec struct { Storage AgentStorageTargetConfig `json:"storage"` StoragePath string `json:"storagePath"` FileName string `json:"fileName"` + // Checksum 源备份对象的 SHA-256(小写 hex);Agent 在还原前据此校验完整性。 + Checksum string `json:"checksum,omitempty"` } // AgentRestoreUpdate Agent 回传的增量更新。 @@ -549,6 +551,7 @@ func (s *RestoreService) GetAgentRestoreSpec(ctx context.Context, node *model.No }, StoragePath: backupRecord.StoragePath, FileName: backupRecord.FileName, + Checksum: backupRecord.Checksum, }, nil } diff --git a/server/internal/service/restore_service_test.go b/server/internal/service/restore_service_test.go index 6f0c5cc..733fc3f 100644 --- a/server/internal/service/restore_service_test.go +++ b/server/internal/service/restore_service_test.go @@ -379,6 +379,7 @@ func TestRestoreServiceAgentRestoreAccessUsesRestoreRecordNode(t *testing.T) { Status: model.BackupRecordStatusSuccess, FileName: "remote.tar.gz", StoragePath: "file/2026/05/09/remote.tar.gz", + Checksum: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", StartedAt: startedAt, CompletedAt: &completedAt, } @@ -404,6 +405,10 @@ func TestRestoreServiceAgentRestoreAccessUsesRestoreRecordNode(t *testing.T) { if spec.RestoreRecordID != restore.ID || spec.StoragePath != backupRecord.StoragePath { t.Fatalf("unexpected restore spec: %#v", spec) } + // Agent 端完整性校验依赖 spec 透传源备份 checksum。 + if spec.Checksum != backupRecord.Checksum { + t.Fatalf("expected spec.Checksum=%q, got %q", backupRecord.Checksum, spec.Checksum) + } if _, err := h.service.GetAgentRestoreSpec(ctx, other, restore.ID); err == nil { t.Fatal("expected non-owner node to be forbidden from restore spec") }