diff --git a/backend/internal/deployment/recovery_inspection.go b/backend/internal/deployment/recovery_inspection.go index b5d5c55..7c13290 100644 --- a/backend/internal/deployment/recovery_inspection.go +++ b/backend/internal/deployment/recovery_inspection.go @@ -44,6 +44,8 @@ type RecoveryLockObservation struct { // InspectPending 只读取待恢复记录与远端文件,绝不运行命令、建立锁或修改事务。 // 缺失的历史证据保持缺失,当前观测摘要不能补写为当时的恢复证据。 func (s *Service) InspectPending(ctx context.Context, id string, resolve func(context.Context, string) (Server, error)) (RecoveryInspection, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() if err := ctx.Err(); err != nil { return RecoveryInspection{}, err } @@ -55,7 +57,7 @@ func (s *Service) InspectPending(ctx context.Context, id string, resolve func(co } records, err := s.journal.ListPendingDeploymentTransactions(ctx) if err != nil { - return RecoveryInspection{}, ErrRecoveryInspectionUnavailable + return RecoveryInspection{}, inspectionError(ctx, ErrRecoveryInspectionUnavailable) } var record *TransactionRecord for i := range records { @@ -74,7 +76,7 @@ func (s *Service) InspectPending(ctx context.Context, id string, resolve func(co } server, err := resolve(ctx, record.ServerID) if err != nil { - return RecoveryInspection{}, ErrRecoveryInspectionUnavailable + return RecoveryInspection{}, inspectionError(ctx, ErrRecoveryInspectionUnavailable) } server = server.Normalize() if server.ID != record.ServerID || server.Validate() != nil { @@ -88,7 +90,7 @@ func (s *Service) InspectPending(ctx context.Context, id string, resolve func(co } client, err := s.factory.Connect(ctx, server) if err != nil { - return RecoveryInspection{}, ErrRecoveryInspectionUnavailable + return RecoveryInspection{}, inspectionError(ctx, ErrRecoveryInspectionUnavailable) } defer client.Close() reader, ok := client.(EvidenceClient) @@ -121,13 +123,13 @@ func (s *Service) InspectPending(ctx context.Context, id string, resolve func(co } exists, err := client.Exists(ctx, item.path) if err != nil { - return RecoveryInspection{}, ErrRecoveryInspectionRead + return RecoveryInspection{}, inspectionError(ctx, 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 + return RecoveryInspection{}, inspectionError(ctx, ErrRecoveryInspectionRead) } observation.Digest, observation.Comparison = digest, "unrecorded" if validDigest(item.expected) { @@ -142,12 +144,12 @@ func (s *Service) InspectPending(ctx context.Context, id string, resolve func(co lock := RecoveryLockObservation{Path: target + ".scert-lock", Ownership: "missing"} lock.Exists, err = client.Exists(ctx, lock.Path) if err != nil { - return RecoveryInspection{}, ErrRecoveryInspectionRead + return RecoveryInspection{}, inspectionError(ctx, ErrRecoveryInspectionRead) } if lock.Exists { owner, err := reader.ReadLockOwner(ctx, lock.Path) if err != nil { - return RecoveryInspection{}, ErrRecoveryInspectionRead + return RecoveryInspection{}, inspectionError(ctx, ErrRecoveryInspectionRead) } lock.Ownership = "unproven" if record.Evidence != nil && validDigest(owner) { @@ -166,6 +168,13 @@ func (s *Service) InspectPending(ctx context.Context, id string, resolve func(co return result, nil } +func inspectionError(ctx context.Context, fallback error) error { + if err := ctx.Err(); err != nil { + return err + } + return fallback +} + func validInspectionID(id string) bool { if len(id) == 0 || len(id) > 160 { return false diff --git a/backend/internal/deployment/recovery_inspection_cancel_test.go b/backend/internal/deployment/recovery_inspection_cancel_test.go new file mode 100644 index 0000000..2b79303 --- /dev/null +++ b/backend/internal/deployment/recovery_inspection_cancel_test.go @@ -0,0 +1,90 @@ +package deployment + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +type inspectionCancelClient struct { + *inspectionClient + cancel context.CancelFunc + stage string +} + +func (c *inspectionCancelClient) Exists(ctx context.Context, path string) (bool, error) { + if c.stage == "exists" || (c.stage == "lock-exists" && strings.HasSuffix(path, ".scert-lock")) { + c.cancel() + return false, errors.New("sensitive-read-error") + } + return c.memoryClient.Exists(ctx, path) +} + +func (c *inspectionCancelClient) FileDigest(ctx context.Context, path string) (string, error) { + if c.stage == "digest" { + c.cancel() + return "", errors.New("sensitive-read-error") + } + return c.memoryClient.FileDigest(ctx, path) +} + +func (c *inspectionCancelClient) ReadLockOwner(ctx context.Context, path string) (string, error) { + if c.stage == "owner" { + c.cancel() + return "", errors.New("sensitive-read-error") + } + return c.memoryClient.ReadLockOwner(ctx, path) +} + +func TestRecoveryInspectionCancellationBoundaries(t *testing.T) { + for _, stage := range []string{"resolve", "exists", "digest", "lock-exists", "owner"} { + t.Run(stage, func(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := &inspectionCancelClient{inspectionClient: &inspectionClient{memoryClient: memory}, cancel: cancel, stage: stage} + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + factory := &inspectionFactory{client: client} + service := NewServiceWithJournal(factory, journal) + result, err := service.InspectPending(ctx, record.ID, func(ctx context.Context, id string) (Server, error) { + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > 30*time.Second { + t.Fatal("inspection lacks bounded deadline") + } + if stage == "resolve" { + cancel() + return Server{}, errors.New("sensitive-resolve-error") + } + return server, nil + }) + if !errors.Is(err, context.Canceled) || !reflect.DeepEqual(result, RecoveryInspection{}) { + t.Fatalf("cancellation lost: %+v %v", result, err) + } + if client.closed != factory.calls || client.mutations != 0 || len(journal.updated) != 0 || len(journal.finished) != 0 { + t.Fatal("cancellation changed state or leaked client") + } + }) + } +} + +func TestRecoveryInspectionPreservesEarlierDeadline(t *testing.T) { + server, memory, record := recoveryPreflightFixture() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + deadline, _ := ctx.Deadline() + client := &inspectionClient{memoryClient: memory} + service := NewServiceWithJournal(fixedClientFactory{client}, &memoryTransactionJournal{pending: []TransactionRecord{record}}) + _, err := service.InspectPending(ctx, record.ID, func(ctx context.Context, id string) (Server, error) { + actual, ok := ctx.Deadline() + if !ok || !actual.Equal(deadline) { + t.Fatal("caller deadline extended") + } + return server, nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/deployment/recovery_inspection_sftp_test.go b/backend/internal/deployment/recovery_inspection_sftp_test.go new file mode 100644 index 0000000..b0eb95d --- /dev/null +++ b/backend/internal/deployment/recovery_inspection_sftp_test.go @@ -0,0 +1,154 @@ +//go:build linux + +package deployment + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +func TestRecoveryInspectionOverPasswordSFTP(t *testing.T) { + signer := newTestECDSAHostSigner(t) + endpoint := startTestPasswordSSHServerWithHandler(t, func(password string) error { + if password != "test-password" { + return errors.New("invalid test password") + } + return nil + }, serveTestSFTP, signer) + for _, scenario := range []string{"trusted", "legacy", "target-symlink", "target-directory", "target-unreadable", "target-oversized", "owner-oversized", "owner-symlink", "lock-symlink"} { + t.Run(scenario, func(t *testing.T) { + root := t.TempDir() + server, memory, record := recoveryPreflightFixture(root) + model := testSSHServerModel(endpoint, "secret://ssh/inspection", ssh.FingerprintSHA256(signer.PublicKey())) + server.Address, server.Port, server.Username = model.Address, model.Port, model.Username + server.AuthMethod, server.PasswordRef, server.HostKeyFingerprint = model.AuthMethod, model.PasswordRef, model.HostKeyFingerprint + record.Evidence.Address, record.Evidence.Port, record.Evidence.HostKeyFingerprint = server.Address, server.Port, server.HostKeyFingerprint + for path, file := range memory.files { + if strings.HasSuffix(path, ".scert-lock") { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, file.data, 0o600); err != nil { + t.Fatal(err) + } + } + target := record.Paths[0] + lock := record.LockPaths[0] + owner := lock + "/owner" + switch scenario { + case "legacy": + record.Evidence = nil + case "target-symlink", "owner-symlink", "lock-symlink": + name := target + if scenario == "owner-symlink" { + name = owner + } + if scenario == "lock-symlink" { + name = lock + } + if err := os.Rename(name, name+".original"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(name+".original", name); err != nil { + t.Fatal(err) + } + case "target-directory": + if err := os.Rename(target, target+".original"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + case "target-unreadable": + if os.Geteuid() == 0 { + t.Skip("requires unprivileged Linux test user") + } + if err := os.Chmod(target, 0); err != nil { + t.Fatal(err) + } + case "target-oversized": + if err := os.Truncate(target, 16*1024*1024+1); err != nil { + t.Fatal(err) + } + case "owner-oversized": + if err := os.Truncate(owner, 129); err != nil { + t.Fatal(err) + } + } + before := snapshotInspectionFiles(t, root) + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + service := NewServiceWithJournal(SSHFactory{ + Secrets: staticSecretReader{server.PasswordRef: {"password": "test-password"}}, + Timeout: 5 * time.Second, + }, journal) + result, err := service.InspectPending(t.Context(), record.ID, func(context.Context, string) (Server, error) { return server, nil }) + if scenario == "trusted" || scenario == "legacy" { + if err != nil || len(result.Files) != 9 || len(result.Locks) != 3 || result.HistoricalEvidence != (scenario == "trusted") { + t.Fatalf("inspection failed: %+v %v", result, err) + } + for _, file := range result.Files { + want := "match" + if scenario == "legacy" { + want = "unrecorded" + } + if file.Comparison != want { + t.Fatalf("unexpected observation: %+v", file) + } + } + } else if !errors.Is(err, ErrRecoveryInspectionRead) || !reflect.DeepEqual(result, RecoveryInspection{}) { + t.Fatalf("unsafe or unreadable file was accepted: %+v %v", result, err) + } + if !reflect.DeepEqual(before, snapshotInspectionFiles(t, root)) || len(journal.updated) != 0 || len(journal.finished) != 0 { + t.Fatal("read-only inspection changed files or journal") + } + }) + } +} + +func snapshotInspectionFiles(t *testing.T, root string) map[string]string { + t.Helper() + result := make(map[string]string) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + value := info.Mode().String() + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return err + } + value += target + } else if info.Mode().IsRegular() && info.Mode().Perm() != 0 { + data, err := os.ReadFile(path) + if err != nil { + return err + } + value += contentDigest(data) + } + result[path] = value + return nil + }) + if err != nil { + t.Fatal(err) + } + return result +} diff --git a/backend/internal/deployment/ssh.go b/backend/internal/deployment/ssh.go index bcf6ef8..36a40ed 100644 --- a/backend/internal/deployment/ssh.go +++ b/backend/internal/deployment/ssh.go @@ -300,6 +300,26 @@ func (c *sshClientAdapter) CreateOwnedLock(ctx context.Context, path, token stri func (c *sshClientAdapter) ReadLockOwner(ctx context.Context, path string) (string, error) { return sshOperation(ctx, c, func() (string, error) { + info, err := c.sftp.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + if !info.IsDir() { + return "", ErrRecoveryInspectionRead + } + info, err = c.sftp.Lstat(path + "/owner") + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + if !info.Mode().IsRegular() || info.Size() > 128 { + return "", ErrRecoveryInspectionRead + } file, err := c.sftp.Open(path + "/owner") if errors.Is(err, os.ErrNotExist) { return "", nil @@ -308,7 +328,10 @@ func (c *sshClientAdapter) ReadLockOwner(ctx context.Context, path string) (stri return "", err } defer file.Close() - data, err := io.ReadAll(file) + data, err := io.ReadAll(io.LimitReader(file, 129)) + if len(data) > 128 { + return "", ErrRecoveryInspectionRead + } return strings.TrimSpace(string(data)), err }) } diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 03af713..3e9447b 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,7 @@ # Linux 生产流程测试记录 +> 本轮最新证据见文末「2026年10月3日17:22 只读恢复核查原生 Linux 回归」,不代表正式部署验收通过。 + ## 2026年10月2日21:30 手动全局续期后台化回归(未部署) - [x] 修改前基线 4ab3f93 已提交推送。本批继续只用密码 SSH/SCP 和严格主机密钥校验;第一台隔离目录 /var/tmp/scert-report-race-20261002-1801/renewal-source,以 nobody 运行原生测试,不创建辅助验收脚本。 @@ -2169,3 +2171,24 @@ SQLite 新测试程序 SHA-256:4c81656fae63a2eebad999d58e6a79e63e6f441810e02ce - [~] 补齐程序独立启动防护:Linux 默认及空值回退改为回环地址,CLI 配置入口、初始化入口、正式应用入口在建立资源前拒绝非回环监听;新增 Linux 回归用例待执行。 - [!] 12:26 正式构建因 GOFLAGS 覆盖被拒绝;12:30 重试移除覆盖后,12:31 被 install-linux.sh 的发布路径扫描拦截。两次均判失败,不以进程启动时的 Result=success 冒充构建成功,不绕过路径检查。 - [ ] 最新代码正式候选包、双机升级及严格 HTTPS 复测待完成。 +## 2026年10月3日17:22 只读恢复核查原生 Linux 回归(未部署) + +- [x] 修改前检查点 `80b6c0c` 已推送。第一台密码 SSH 使用既有固定主机密钥严格校验,未启用密钥登录,未绕过 TLS。 +- [x] 基线源码归档 `scert-inspection-80b6c0c.tar` 两端 SHA256 一致:`0032a7f3bbfc4d6de0258dddff50679490fd7cc11b5603c956f851407a476cf0`。归档仅包含已跟踪后端源码,不包含运行时数据库或秘密。 +- [x] 测试目录为 `/var/tmp/scert-inspection-20261003-1714/backend`;Go 为既有 1.26.6 linux/amd64;以 nobody 身份运行隔离文件测试,生产数据库未清理或参与测试。 +- [x] 基线 `TestRecoveryInspection` 通过。后续上传四份源文件修复和新增测试后,专项命令 `go test -count=1 -timeout=180s -v ./internal/deployment -run 'TestRecoveryInspection|TestRecoveryPreflightOverPasswordSFTP'` 退出码 0。 +- [x] 真实密码 SSH/SFTP 的 9 个核查场景全部通过,包括可信、无历史证据、目标符号链接、目录、不可读、超大文件、超大归属文件、归属文件符号链接和锁目录符号链接。 +- [x] `go test -race -count=1 -timeout=240s ./internal/deployment` 退出码 0,`go vet ./internal/deployment` 退出码 0。 +- [~] `go test -p 2 -count=1 -timeout=8m ./...` 已启动,另行记录终态;不得将上述专项扩称为后端全量通过。 + +日志在测试目录上一级:`inspection.log`、`deployment-race.log`、`deployment-vet.log`、`backend-full.log`。本轮没有创建辅助验收脚本,没有远端备份,没有修改业务事务终态,也未部署新增功能。只读采样不构成原子快照、Nginx/HTTPS 或历史提交/回滚证据。 + +### 17:35 全量测试环境修复与后续执行 + +- [x] 外部依赖代理连通性探测失败,curl 退出码 28;原联网下载进程已中止,并确认相关 PID 消失,未将该次测试记为通过。 +- [x] 复用第一台既有离线代理,保留项目 go.sum 校验;首次离线全量编译期间 SSH 响应变慢且新连接握手超时,已中止该次全量执行并确认测试 PID 消失,不能据此认定是代码错误或 OOM。 +- [x] 17:33 再次核实 Scert 与 nginx 均为 active;本机严格 TLS 校验的管理域名 `/readyz` 返回 200。此为服务器回环路径检查,不代表公共入口验收。核查时约 889 MiB 可用内存,1 GiB swap 未使用,指定时段内未检出内核 OOM 记录。 +- [x] 四份增量 Go 文件的服务器 SHA256 与本地逐一一致。 +- [~] 改为受限单元 `scert-inspection-full-20261003-1735.service` 执行同一份源码的后端全量测试:nobody、单编译并发、CPU 50%、MemoryHigh 500 MiB、MemoryMax 650 MiB、20 分钟运行上限,且 Go 内存软限制 350 MiB。日志为 `backend-full-bounded.log`,终态尚待获取。 + +这只是隔离测试进程的资源限制,没有调整生产服务配置、重启生产服务或清理业务数据。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index bd0034a..80a9208 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,7 @@ # Scert 开发任务清单 +> 当前进度见文末「2026年10月3日17:22 恢复核查开发状态补充」,总体开发和生产验收仍未完成。 + ## 2026年10月3日 当前开发与验收任务入口 本轮只处理 Linux,目标是在不放开额外公网端口、不降低 TLS 校验、不使用辅助验收脚本的前提下完成上线。 @@ -3872,3 +3874,14 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - [~] P0-10 其他任务、跨数据库实机矩阵和统一复审继续进行中。 - [ ] 真实生产数据库、密码 SSH、远端部署、HTTPS/DNS、ACME、Linux/Windows 服务和最终生产门禁仍未完成。 - [!] 不新增 `[✓]`,不以本地结果代替生产验收。 +## 2026年10月3日17:22 恢复核查开发状态补充 + +- [x] 只读核查底层实现完成并通过 Linux 专项,等待复审:9 个文件观察和 3 个锁观察,缺历史证据保持缺失,不回传敏感内容,不运行远端命令或更新事务。 +- [x] 新增整体 30 秒超时和调用者更短超时保留;取消发生在解析服务器、存在性检查、摘要和锁读取时均不返回半份结果。 +- [x] 锁目录和归属文件拒绝符号链接,归属文件限制为 128 字节;真实 SFTP 包含不可读、目录、超大文件等拒绝场景。 +- [x] 部署包竞态测试、静态检查通过;原生 Linux 项目内测试,不属于正式产品入口验收。 +- [ ] 本机 IPC/终端、人工处置记录和审计、并发确认与双机正式安装仍未完成,不宣布开发全部完成。 + +### 17:35 全量回归状态 + +后端全量因测试环境问题两次中止,尚未通过。当前使用离线依赖缓存和内存/CPU 受限 Linux 单元继续验证;没有因此缩减开发范围,也没有把 IPC、人工处置或正式部署标为完成。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 95f5560..f60ff52 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,7 @@ # Scert 整改任务执行清单 +> 当前进度见文末「2026年10月3日17:22 当前执行进度补充」,历史章节保留供追溯。 + ## 2026年10月3日 当前任务清单:Linux 双服务器上线冲刺 ### 当前需求与验收任务 @@ -2322,3 +2324,17 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 ### 当前结论 本批已经从“候选包未完成”推进到“最新候选包双机运行并完成基础安全复核”,但仍不能标记最终生产验收通过。当前状态保持 **P0-03:完成,等待复审**;下一阶段优先处理第一台管理证书 SAN、历史恢复现场核查和清库后的浏览器业务验收。 +## 2026年10月3日17:22 当前执行进度补充 + +- [x] 本轮修改前检查点 `80b6c0c` 已提交并推送;历史检查点 `ba57490` 也已成功推送。 +- [x] 第一台原生 Linux Go 1.26.6、nobody 隔离测试用户已核验;复用既有工具链,不新增公网端口,不创建辅助测试脚本。 +- [x] 部署只读核查增加整体超时、取消语义和锁归属读取限制;专项、部署包全量竞态与静态检查均通过。 +- [~] 后端全量回归已启动,结果未出之前不标通过。 +- [ ] 后续顺序:本机 IPC 和终端入口、人工处置审计和并发幂等、Linux 正式构建、双机及数据库/邮件/DNS/HTTPS/回滚完整验收。 + +生产服务和历史恢复现场未因隔离测试改变;总体上线门禁仍未通过。具体专项证据见《Linux生产流程测试记录》和《部署事务人工核查任务清单》。 + +### 17:35 验证执行状态 + +- [x] 已确认中止的联网及首次离线全量测试进程退出;复核生产服务和本机严格 TLS 就绪状态。 +- [~] 后端全量改用 `scert-inspection-full-20261003-1735.service` 受限运行,结果待获取;不得按前面的专项结果替代。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index aded6cd..aa849c8 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,7 @@ # Scert 整改、合并与优化清单 +> 当前进度以文末「2026年10月3日17:22 最新执行状态」为准:恢复核查底层专项已通过,本机入口和生产门禁仍未完成。 + ## 2026年10月3日 Linux上线冲刺任务清单(当前执行入口) 本轮只处理 Linux,不处理 Windows;目标是完成明日上线前的可验收闭环。生产结论必须来自两台真实服务器、真实 HTTPS、浏览器、数据库、邮件和回滚证据,不能用构建或单元测试替代。 @@ -2865,3 +2867,18 @@ P0-11 生产验收门禁 ### 当前结论 本批已经从“候选包未完成”推进到“最新候选包双机运行并完成基础安全复核”,但仍不能标记最终生产验收通过。当前状态保持 **P0-03:完成,等待复审**;下一阶段优先处理第一台管理证书 SAN、历史恢复现场核查和清库后的浏览器业务验收。 +## 2026年10月3日17:22 最新执行状态:恢复核查底层已通过 Linux 专项 + +本节是当前进度补充,后续历史章节不能覆盖本节。范围仍为 Linux 双机上线,Windows 不在本轮范围。 + +- [x] 第二台磁盘清理后于 17:08 实测:29 GB 总量、3.2 GB 已用、26 GB 可用、使用率 12%,inode 使用率 3%;Scert、nginx、mysql、scert-nginx、pmail 均为 active。 +- [x] P0-10 恢复核查子项 R1 已完成编码并通过原生 Linux 专项、部署包全量竞态测试和静态检查,等待完整流程复审。详见《部署事务人工核查任务清单》。 +- [ ] 恢复核查本机 IPC 和终端入口、独立人工处置及审计、幂等和并发门禁尚未完成;现有无历史证据事务仍不得自动转为成功。 +- [ ] 本轮新代码尚未打包上线;三数据库首次 Web、真实邮件与重置、双机正式域名正负向、DNS/ACME 和回滚验收继续保留。 +- [~] 后端全量回归正在执行,专项测试不能代替全量结果或生产验收。 + +历史章节中的第一台管理证书 SAN 结论本轮未重新验证,不能作为当前证书状态。总体生产验收仍未通过,不因磁盘空间恢复或专项测试通过而关闭 P0 门禁。 + +### 17:35 后端全量回归进度 + +前两次全量尝试分别因依赖代理不可达及 SSH 响应异常而中止,没有通过结论。服务 active 和本机严格 TLS 就绪检查已复核,当前以限制内存和 CPU 的 Linux 单元重新执行全量回归,等待终态。未修改生产配置或历史恢复记录,未关闭任何生产门禁。 diff --git a/doc/部署事务人工核查任务清单.md b/doc/部署事务人工核查任务清单.md index c393cf9..f44f108 100644 --- a/doc/部署事务人工核查任务清单.md +++ b/doc/部署事务人工核查任务清单.md @@ -1,5 +1,7 @@ # 部署事务人工核查任务清单 +> 当前状态以文末 2026年10月3日17:22 补充为准:R1 已完成等待复审,R2 至 R5 未完成,R6 持续同步。 + ## 2026年10月3日 需求确认与执行顺序 依据现有整改清单的 P0-10 恢复门禁继续开发,仅处理 Linux。人工核查不能用当前正常状态补造历史部署证据;不能直接修改生产数据库终态。 @@ -17,3 +19,23 @@ - 服务器身份或历史路径无效时拒绝读取;证据缺失时明确报告,不自动生成恢复证据、不删锁、不清理现场。 - 保留现有密码重置通道的身份校验及邮箱验证码要求,不因接入核查而降低密码重置安全边界。 - Git 修改前检查点为 ba57490;推送收到 Authentication failed,本地继续,远端同步仍待解决。 +## 2026年10月3日17:22 当前进度补充 + +本节优先于下方历史状态;保留历史原文,不替换中文字符。 + +- [x] R1:只读核查底层已实现,待产品入口和完整流程复审。整体操作限制为 30 秒,尊重调用者更短的超时;读取失败或取消时返回空结果,不把部分观察当成成功。 +- [x] 锁归属读取增加类型和大小检查:拒绝符号链接锁目录、符号链接归属文件及超过 128 字节的归属文件,限制实际读取长度。 +- [x] 原生 Linux 专项通过:5 个取消边界、较早超时保留、9 个真实密码 SSH/SFTP 场景、原有只读核查和恢复预检用例。不可读文件场景以 nobody 用户运行并通过,没有因 root 权限跳过。 +- [x] 部署包全量竞态测试通过:`go test -race -count=1 -timeout=240s ./internal/deployment`,退出码 0;`go vet ./internal/deployment` 退出码 0。 +- [ ] R2:本机 IPC、终端列表及核查菜单尚未接入,不能将底层方法描述为用户已经可以使用的功能。 +- [ ] R3、R4:人工处置、确认幂等、并发互斥和独立审计尚未完成;本轮取消测试不代表人工处置流程全部完成。 +- [ ] R5:此次新代码尚未进入双机正式候选包,不改变线上历史事务状态。 +- [~] R6:同步总清单、执行清单、开发清单和 Linux 测试记录;总体生产验收保持未通过。 + +修正历史 Git 状态:`ba57490` 后续已经推送成功;本轮修改前检查点 `80b6c0c` 也已推送成功,不再存在该检查点认证阻塞。 + +证据目录:第一台 `/var/tmp/scert-inspection-20261003-1714/`,专项 `inspection.log`、竞态 `deployment-race.log`、静态检查 `deployment-vet.log`。这里运行的是项目内 Go 测试,不是辅助验收脚本;未清理业务数据库、证书或恢复现场。后端全量测试已启动,结果另行记录。 + +### 17:35 全量验证状态跟进 + +联网全量运行因代理不可达中止;首次离线全量运行出现 SSH 响应异常后主动中止,均未记为通过。生产 Scert/nginx 已复核 active,本机严格 TLS 的 `/readyz` 返回 200。当前以受限 Linux 单元运行后端全量测试,内存上限 650 MiB、CPU 50%,结果仍待获取。R1 的专项、竞态和静态检查结论不变,R2 至 R5 仍未完成。