From 0f2e6bfccef5292305ca4a1271f7d743db74d216 Mon Sep 17 00:00:00 2001 From: xuans Date: Sat, 3 Oct 2026 21:33:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=9C=AC=E6=9C=BA=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E5=A4=84=E7=BD=AE=E5=8D=8F=E8=AE=AE=E5=92=8C=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E5=B9=B6=E8=AE=B0=E5=BD=95Linux=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/adminconsole/flow.go | 14 +- .../adminconsole/recovery_disposition.go | 155 +++++++++ .../recovery_disposition_linux_test.go | 96 ++++++ .../adminconsole/recovery_disposition_test.go | 154 +++++++++ .../internal/deployment/recovery_review.go | 11 + .../deployment/recovery_review_test.go | 32 ++ .../httpapi/local_recovery_disposition.go | 89 +++++ .../local_recovery_disposition_linux_test.go | 125 +++++++ .../local_recovery_disposition_test.go | 250 ++++++++++++++ backend/internal/httpapi/local_reset.go | 21 +- backend/internal/localreset/client.go | 24 +- backend/internal/localreset/protocol.go | 45 +-- backend/internal/localreset/recovery.go | 24 +- .../localreset/recovery_disposition.go | 118 +++++++ .../localreset/recovery_disposition_test.go | 305 ++++++++++++++++++ .../internal/localreset/recovery_frames.go | 4 +- .../internal/localreset/recovery_server.go | 2 + backend/internal/localreset/recovery_test.go | 2 +- doc/Linux生产流程测试记录.md | 19 ++ doc/Scert开发任务清单.md | 15 + doc/Scert整改任务执行清单.md | 16 + doc/Scert整改合并优化清单.md | 20 +- doc/部署事务人工处置设计.md | 16 + doc/部署事务人工核查任务清单.md | 25 ++ 24 files changed, 1534 insertions(+), 48 deletions(-) create mode 100644 backend/internal/adminconsole/recovery_disposition.go create mode 100644 backend/internal/adminconsole/recovery_disposition_linux_test.go create mode 100644 backend/internal/adminconsole/recovery_disposition_test.go create mode 100644 backend/internal/httpapi/local_recovery_disposition.go create mode 100644 backend/internal/httpapi/local_recovery_disposition_linux_test.go create mode 100644 backend/internal/httpapi/local_recovery_disposition_test.go create mode 100644 backend/internal/localreset/recovery_disposition.go create mode 100644 backend/internal/localreset/recovery_disposition_test.go diff --git a/backend/internal/adminconsole/flow.go b/backend/internal/adminconsole/flow.go index 51e5cab..7008350 100644 --- a/backend/internal/adminconsole/flow.go +++ b/backend/internal/adminconsole/flow.go @@ -58,7 +58,11 @@ func runConnected(ctx context.Context, conn net.Conn, screen terminal) error { } defer client.Close() if client.RecoveryAvailable() { - choice, err := screen.Read(ctx, "本机管理:1 重置超级管理员密码;2 只读核查部署事务;其他输入退出", false) + prompt := "本机管理:1 重置超级管理员密码;2 只读核查部署事务;其他输入退出" + if client.DispositionAvailable() { + prompt = "本机管理:1 重置超级管理员密码;2 只读核查部署事务;3 人工处置部署事务;4 查询人工处置结果;其他输入退出" + } + choice, err := screen.Read(ctx, prompt, false) if err != nil { return err } @@ -70,6 +74,14 @@ func runConnected(ctx context.Context, conn net.Conn, screen terminal) error { return err } return nil + case "3", "4": + if !client.DispositionAvailable() { + return localreset.ErrUnavailable + } + if strings.TrimSpace(choice) == "4" { + return recoveryResultFlow(ctx, client, screen) + } + return recoveryDispositionFlow(ctx, client, screen) default: return nil } diff --git a/backend/internal/adminconsole/recovery_disposition.go b/backend/internal/adminconsole/recovery_disposition.go new file mode 100644 index 0000000..9115e01 --- /dev/null +++ b/backend/internal/adminconsole/recovery_disposition.go @@ -0,0 +1,155 @@ +package adminconsole + +import ( + "context" + "fmt" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type recoveryDispositionClient interface { + InspectRecovery(string) (deployment.RecoveryInspection, error) + PrepareRecovery(string, deployment.RecoveryAction) (localreset.RecoveryPreparation, error) + ExecuteRecovery(string, string) (deployment.RecoveryDispositionSummary, error) + GetRecoveryDisposition(string) (deployment.RecoveryDispositionSummary, error) +} + +func recoveryDispositionFlow(ctx context.Context, client recoveryDispositionClient, screen terminal) error { + id, err := screen.Read(ctx, "请输入待处置事务编号(可先使用只读核查获取);输入 q 退出", false) + if err != nil { + return err + } + id = strings.TrimSpace(id) + if id == "q" { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + inspection, err := client.InspectRecovery(id) + if err != nil { + _ = screen.Show("无法核查事务,未提交人工处置请求。请重新启动本机管理。") + return err + } + if err := showRecovery(screen, inspection); err != nil { + return err + } + choice, err := screen.Read(ctx, "选择明确动作:1 仅记录核查;2 完成提交(保留新证书并清理备份);3 恢复基线(恢复旧证书并重载);其他输入取消", false) + if err != nil { + return err + } + actions := map[string]deployment.RecoveryAction{ + "1": deployment.RecoveryActionRecord, "2": deployment.RecoveryActionCommit, "3": deployment.RecoveryActionRollback, + } + action, ok := actions[strings.TrimSpace(choice)] + if !ok { + return screen.Show("已取消,未提交人工处置请求。") + } + if err := ctx.Err(); err != nil { + return err + } + prepared, err := client.PrepareRecovery(id, action) + if err != nil { + _ = screen.Show("准备失败或历史证据不足,未提交执行请求。请重新核查,不要手动删锁。") + return err + } + defer func() { prepared.Token = "" }() + if prepared.Summary.Validate() != nil || prepared.Summary.TransactionID != id || prepared.Summary.Action != action || !time.Now().Before(prepared.Summary.ExpiresAt) { + return localreset.ErrProtocol + } + lines := []string{ + fmt.Sprintf("处置编号:%s(请保留,用于断连后查询结果)", terminalValue(prepared.DispositionID)), + fmt.Sprintf("事务:%s;服务器:%s;阶段:%s;动作:%s", terminalValue(id), terminalValue(prepared.Summary.ServerID), recoveryLabel(prepared.Summary.Phase), dispositionActionLabel(action)), + "确认截止时间:" + prepared.Summary.ExpiresAt.Local().Format("2006-01-02 15:04:05 MST"), + "仅记录只保存核查审计,不结束原事务;提交或回滚会修改远端文件,可能清理备份或重载服务。动作失败不会自动切换方向。", + "断连或结果不确定时,禁止重复执行;重新启动本机管理,选择查询人工处置结果。", + } + for _, line := range lines { + if err := screen.Show(line); err != nil { + return err + } + } + expected := "确认 " + id + " " + dispositionActionLabel(action) + confirmation, err := screen.Read(ctx, "如确认执行,请完整输入:"+expected+";其他输入取消", false) + if err != nil { + return err + } + if confirmation != expected { + return screen.Show("已取消,未发送执行请求。") + } + if err := ctx.Err(); err != nil { + return err + } + if !time.Now().Before(prepared.Summary.ExpiresAt) { + return screen.Show("确认已过期,未发送执行请求;请重新核查。") + } + result, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token) + prepared.Token = "" + if err != nil { + _ = screen.Show("未取得可靠结果,不能断言未执行或执行成功。请重新连接,按处置编号查询;不要重复提交或删除远端锁。") + return err + } + if result.ID != prepared.DispositionID || result.TransactionID != id || result.ServerID != prepared.Summary.ServerID || result.Action != action { + _ = screen.Show("响应与确认内容不一致,不能认定成功;请按处置编号查询。") + return localreset.ErrProtocol + } + return showRecoveryDisposition(screen, result) +} + +func recoveryResultFlow(ctx context.Context, client recoveryDispositionClient, screen terminal) error { + id, err := screen.Read(ctx, "请输入人工处置编号(不是事务编号);本操作只查询,不重放、不释放占用", false) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + id = strings.TrimSpace(id) + result, err := client.GetRecoveryDisposition(id) + if err != nil || result.ID != id { + _ = screen.Show("未取得有效处置结果;记录不存在或通道不可用不代表未执行,请保留编号继续核查。") + return localreset.ErrUnavailable + } + return showRecoveryDisposition(screen, result) +} + +func showRecoveryDisposition(screen terminal, result deployment.RecoveryDispositionSummary) error { + if result.Validate() != nil { + return localreset.ErrProtocol + } + message := "处置仍在运行或结束审计尚未完成;保留占用,禁止重复执行。" + switch result.Status { + case "succeeded": + switch result.Action { + case deployment.RecoveryActionRecord: + message = "核查结论已记录;未改变原事务终态,不代表历史部署成功。" + case deployment.RecoveryActionCommit: + message = "本次人工提交核验及结束审计完成;不代表项目全部生产验收通过。" + case deployment.RecoveryActionRollback: + message = "本次恢复基线核验及结束审计完成;不代表项目全部生产验收通过。" + } + case "rejected": + message = "处置已拒绝,未执行本次远端变更;请重新核查拒绝原因。" + case "uncertain": + message = "处置结果不确定,可能已修改远端;占用继续保留,禁止重复执行或手动删锁。" + } + if err := screen.Show(fmt.Sprintf("处置:%s;事务:%s;服务器:%s;动作:%s", terminalValue(result.ID), terminalValue(result.TransactionID), terminalValue(result.ServerID), dispositionActionLabel(result.Action))); err != nil { + return err + } + return screen.Show(message) +} + +func dispositionActionLabel(action deployment.RecoveryAction) string { + switch action { + case deployment.RecoveryActionRecord: + return "仅记录核查" + case deployment.RecoveryActionCommit: + return "完成提交" + case deployment.RecoveryActionRollback: + return "恢复基线" + } + return "未知动作" +} diff --git a/backend/internal/adminconsole/recovery_disposition_linux_test.go b/backend/internal/adminconsole/recovery_disposition_linux_test.go new file mode 100644 index 0000000..5dca1b7 --- /dev/null +++ b/backend/internal/adminconsole/recovery_disposition_linux_test.go @@ -0,0 +1,96 @@ +//go:build linux + +package adminconsole + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type dispositionConsoleSession struct { + consoleIPCSession + actions dispositionConsoleClient +} + +func (*dispositionConsoleSession) DispositionAvailable() bool { return true } +func (s *dispositionConsoleSession) PrepareRecovery(_ context.Context, id string, action deployment.RecoveryAction) (localreset.RecoveryPreparation, error) { + return s.actions.PrepareRecovery(id, action) +} +func (s *dispositionConsoleSession) ExecuteRecovery(_ context.Context, id, token string) (deployment.RecoveryDispositionSummary, error) { + return s.actions.ExecuteRecovery(id, token) +} +func (s *dispositionConsoleSession) GetRecoveryDisposition(_ context.Context, id string) (deployment.RecoveryDispositionSummary, error) { + return s.actions.GetRecoveryDisposition(id) +} + +func TestDispositionConsoleUnixMenus(t *testing.T) { + for _, mode := range []string{"record", "commit", "rollback", "query", "cancel"} { + t.Run(mode, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "control.sock") + server, err := localreset.Listen(path) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + session := &dispositionConsoleSession{} + done := make(chan error, 1) + screen := &recoveryTestScreen{} + switch mode { + case "record": + screen.values = []string{"3", "tx-1", "1", "确认 tx-1 仅记录核查"} + case "commit": + screen.values = []string{"3", "tx-1", "2", "确认 tx-1 完成提交"} + case "rollback": + screen.values = []string{"3", "tx-1", "3", "确认 tx-1 恢复基线"} + case "query": + screen.values = []string{"4", strings.Repeat("b", 64)} + session.actions.action = deployment.RecoveryActionRecord + case "cancel": + screen.values = []string{"3", "tx-1", "2", "取消"} + } + go func() { + done <- server.Serve(ctx, func(context.Context, localreset.Peer) (localreset.Session, error) { return session, nil }) + }() + defer server.Close() + defer cancel() + conn, err := localreset.Dial(ctx, path, uint32(os.Geteuid())) + if err != nil { + t.Fatal(err) + } + err = runConnected(ctx, conn, screen) + cancel() + if serveErr := <-done; serveErr != nil { + t.Fatal(serveErr) + } + if err != nil { + t.Fatal(err) + } + if session.resets.Load() != 0 || session.selected.Load() != 0 || session.verified.Load() != 0 { + t.Fatal("recovery menu invoked password reset") + } + if mode == "query" { + if session.actions.queries != 1 || session.actions.prepares != 0 || session.actions.executes != 0 { + t.Fatal("query replayed") + } + } else { + expected := 1 + if mode == "cancel" { + expected = 0 + } + if session.actions.prepares != 1 || session.actions.executes != expected || session.actions.queries != 0 { + t.Fatal("wrong menu dispatch") + } + } + if strings.Contains(strings.Join(screen.lines, "|"), strings.Repeat("c", 64)) { + t.Fatal("token printed") + } + }) + } +} diff --git a/backend/internal/adminconsole/recovery_disposition_test.go b/backend/internal/adminconsole/recovery_disposition_test.go new file mode 100644 index 0000000..dd46b9b --- /dev/null +++ b/backend/internal/adminconsole/recovery_disposition_test.go @@ -0,0 +1,154 @@ +package adminconsole + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type dispositionConsoleClient struct { + recoveryTestClient + prepares int + executes int + queries int + action deployment.RecoveryAction + executeError bool + expired bool + status string +} + +func (c *dispositionConsoleClient) PrepareRecovery(id string, action deployment.RecoveryAction) (localreset.RecoveryPreparation, error) { + c.prepares++ + c.action = action + expires := time.Now().Add(time.Minute) + if c.expired { + expires = time.Now().Add(-time.Second) + } + return localreset.RecoveryPreparation{DispositionID: strings.Repeat("b", 64), Token: strings.Repeat("c", 64), Summary: deployment.RecoveryReviewSummary{ + TransactionID: id, ServerID: "server-1", Phase: "reload_attempted", Action: action, HistoricalEvidence: true, ObservedAt: time.Now(), ExpiresAt: expires, + }}, nil +} +func (c *dispositionConsoleClient) ExecuteRecovery(id, token string) (deployment.RecoveryDispositionSummary, error) { + c.executes++ + if c.executeError { + return deployment.RecoveryDispositionSummary{}, errors.New("connection-lost") + } + return c.result(id), nil +} +func (c *dispositionConsoleClient) GetRecoveryDisposition(id string) (deployment.RecoveryDispositionSummary, error) { + c.queries++ + return c.result(id), nil +} +func (c *dispositionConsoleClient) result(id string) deployment.RecoveryDispositionSummary { + now := time.Now().UTC() + status := c.status + if status == "" { + status = "succeeded" + } + codes := map[deployment.RecoveryAction]string{deployment.RecoveryActionRecord: "observed", deployment.RecoveryActionCommit: "committed", deployment.RecoveryActionRollback: "rolled_back"} + code := codes[c.action] + if status == "running" { + code = "" + } else if status != "succeeded" { + code = status + } + return deployment.RecoveryDispositionSummary{ID: id, TransactionID: "tx-1", ServerID: "server-1", Action: c.action, Status: status, ResultCode: code, CreatedAt: now, UpdatedAt: now} +} + +func TestDispositionConsoleExplicitActions(t *testing.T) { + for _, row := range []struct { + choice string + action deployment.RecoveryAction + message string + }{ + {"1", deployment.RecoveryActionRecord, "核查结论已记录"}, + {"2", deployment.RecoveryActionCommit, "本次人工提交核验"}, + {"3", deployment.RecoveryActionRollback, "本次恢复基线核验"}, + } { + t.Run(row.choice, func(t *testing.T) { + client := &dispositionConsoleClient{} + screen := &recoveryTestScreen{testTerminalInput: testTerminalInput{values: []string{"tx-1", row.choice, "确认 tx-1 " + dispositionActionLabel(row.action)}}} + if err := recoveryDispositionFlow(t.Context(), client, screen); err != nil { + t.Fatal(err) + } + output := strings.Join(screen.lines, "|") + if client.action != row.action || client.prepares != 1 || client.executes != 1 || client.queries != 0 || !strings.Contains(output, row.message) { + t.Fatal("wrong action or result") + } + if strings.Contains(output, strings.Repeat("c", 64)) || !strings.Contains(output, strings.Repeat("b", 64)) || !strings.Contains(output, "禁止重复执行") { + t.Fatal("token leaked or recovery guidance absent") + } + }) + } +} + +func TestDispositionConsoleCancellationAndUncertainResult(t *testing.T) { + for _, mode := range []string{"quit", "cancel-action", "wrong-confirmation", "expired", "cancelled", "display-error", "disconnect"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := &dispositionConsoleClient{} + screen := &recoveryTestScreen{testTerminalInput: testTerminalInput{values: []string{"tx-1", "2", "确认 tx-1 完成提交"}}} + switch mode { + case "quit": + screen.values[0] = "q" + case "cancel-action": + screen.values[1] = "q" + case "wrong-confirmation": + screen.values[2] = "确认 tx-1 恢复基线" + case "expired": + client.expired = true + case "cancelled": + screen.readHook = func(index int) { + if index == 3 { + cancel() + } + } + case "display-error": + screen.showError = true + case "disconnect": + client.executeError = true + } + _ = recoveryDispositionFlow(ctx, client, screen) + expected := 0 + if mode == "disconnect" { + expected = 1 + } + if client.executes != expected || client.queries != 0 { + t.Fatalf("unexpected retry or cancelled execution: %d", client.executes) + } + output := strings.Join(screen.lines, "|") + if strings.Contains(output, "本次人工提交核验及结束审计完成") { + t.Fatal("failure reported success") + } + if mode == "disconnect" && !strings.Contains(output, "不能断言未执行或执行成功") { + t.Fatal("missing uncertainty warning") + } + }) + } +} + +func TestDispositionConsoleQueryOnlyAndStatusWarnings(t *testing.T) { + for _, status := range []string{"running", "uncertain", "rejected", "succeeded"} { + client := &dispositionConsoleClient{status: status, action: deployment.RecoveryActionRecord} + screen := &recoveryTestScreen{testTerminalInput: testTerminalInput{values: []string{strings.Repeat("b", 64)}}} + if err := recoveryResultFlow(t.Context(), client, screen); err != nil { + t.Fatal(err) + } + if client.prepares != 0 || client.executes != 0 || client.queries != 1 { + t.Fatal("query changed state") + } + output := strings.Join(screen.lines, "|") + if (status == "running" || status == "uncertain") && !strings.Contains(output, "禁止重复执行") { + t.Fatal("missing warning") + } + if status == "succeeded" && !strings.Contains(output, "未改变原事务终态") { + t.Fatal("record action falsely claims recovery") + } + } +} diff --git a/backend/internal/deployment/recovery_review.go b/backend/internal/deployment/recovery_review.go index d777ed4..430d035 100644 --- a/backend/internal/deployment/recovery_review.go +++ b/backend/internal/deployment/recovery_review.go @@ -36,6 +36,17 @@ type RecoveryReviewSummary struct { ExpiresAt time.Time `json:"expires_at"` } +// Validate 与服务端历史记录使用同一摘要边界,允许旧记录没有阶段,但不允许控制字符或超长字段。 +func (r RecoveryReviewSummary) Validate() error { + if !validInspectionID(r.TransactionID) || !validRecoverySummary(r.ServerID) || + (r.Phase != "" && !validRecoverySummary(r.Phase)) || !validRecoveryAction(r.Action) || + r.ObservedAt.IsZero() || !r.ExpiresAt.After(r.ObservedAt) || r.ExpiresAt.Sub(r.ObservedAt) > RecoveryReviewLifetime || + (r.Action != RecoveryActionRecord && !r.HistoricalEvidence) { + return ErrRecoveryReviewInvalid + } + return nil +} + // RecoveryReview 只保存只读准备摘要和不可公开的绑定值,不保存凭据或原始恢复证据。 // 它不是执行授权;调用者仍须取得持久化占用、验证动作条件并原子写入审计。 type RecoveryReview struct { diff --git a/backend/internal/deployment/recovery_review_test.go b/backend/internal/deployment/recovery_review_test.go index 2634e39..1840e40 100644 --- a/backend/internal/deployment/recovery_review_test.go +++ b/backend/internal/deployment/recovery_review_test.go @@ -11,6 +11,38 @@ import ( "time" ) +func TestRecoveryReviewSummaryBoundary(t *testing.T) { + now := time.Now().UTC() + valid := RecoveryReviewSummary{TransactionID: "tx-1", ServerID: "历史服务器", Action: RecoveryActionRecord, ObservedAt: now, ExpiresAt: now.Add(time.Minute)} + if err := valid.Validate(); err != nil { + t.Fatalf("valid legacy summary rejected: %v", err) + } + for _, mode := range []string{"id", "server", "phase", "action", "evidence", "observed", "expired", "lifetime"} { + value := valid + switch mode { + case "id": + value.TransactionID = "../bad" + case "server": + value.ServerID = strings.Repeat("s", 161) + case "phase": + value.Phase = "phase\n" + case "action": + value.Action = "unknown" + case "evidence": + value.Action = RecoveryActionCommit + case "observed": + value.ObservedAt = time.Time{} + case "expired": + value.ExpiresAt = now + case "lifetime": + value.ExpiresAt = now.Add(time.Hour) + } + if value.Validate() == nil { + t.Fatalf("invalid summary accepted: %s", mode) + } + } +} + func TestRecoveryReviewReadOnlyBindings(t *testing.T) { for _, action := range []RecoveryAction{RecoveryActionRecord, RecoveryActionCommit, RecoveryActionRollback} { t.Run(string(action), func(t *testing.T) { diff --git a/backend/internal/httpapi/local_recovery_disposition.go b/backend/internal/httpapi/local_recovery_disposition.go new file mode 100644 index 0000000..07b9b43 --- /dev/null +++ b/backend/internal/httpapi/local_recovery_disposition.go @@ -0,0 +1,89 @@ +package httpapi + +import ( + "context" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type localRecoveryExecutor interface { + PrepareRecoveryConfirmation(context.Context, string, string, deployment.RecoveryAction, func(context.Context, string) (deployment.Server, error)) (deployment.RecoveryConfirmation, string, error) + ExecuteRecoveryDisposition(context.Context, deployment.RecoveryConfirmation, string, string, func(context.Context, string) (deployment.Server, error)) (deployment.RecoveryDisposition, error) + GetRecoveryDisposition(context.Context, string) (deployment.RecoveryDispositionSummary, error) +} + +func (r *localResetSession) DispositionAvailable() bool { + _, ok := r.server.recoveryInspection.(localRecoveryExecutor) + return ok && r.RecoveryAvailable() +} + +func (r *localResetSession) recoveryServer(ctx context.Context, id string) (deployment.Server, error) { + if !r.current(ctx) { + return deployment.Server{}, localreset.ErrUnavailable + } + return r.server.servers.GetServerByID(ctx, id) +} + +func (r *localResetSession) PrepareRecovery(ctx context.Context, id string, action deployment.RecoveryAction) (localreset.RecoveryPreparation, error) { + r.mu.Lock() + defer r.mu.Unlock() + // 新准备替换旧准备;任何失败也不能继续使用上次的确认对象。 + r.recoveryConfirmation = deployment.RecoveryConfirmation{} + executor, ok := r.server.recoveryInspection.(localRecoveryExecutor) + if !r.enterRecovery(ctx) || !ok { + return localreset.RecoveryPreparation{}, localreset.ErrProtocol + } + confirmation, token, err := executor.PrepareRecoveryConfirmation(ctx, id, r.peer.Identity, action, r.recoveryServer) + if err != nil || !r.current(ctx) { + r.stage = -1 + return localreset.RecoveryPreparation{}, localreset.ErrUnavailable + } + r.recoveryConfirmation = confirmation + return localreset.RecoveryPreparation{DispositionID: confirmation.DispositionID(), Token: token, Summary: confirmation.Summary()}, nil +} + +func (r *localResetSession) ExecuteRecovery(ctx context.Context, id, token string) (deployment.RecoveryDispositionSummary, error) { + r.mu.Lock() + defer r.mu.Unlock() + confirmation := r.recoveryConfirmation + r.recoveryConfirmation = deployment.RecoveryConfirmation{} + executor, ok := r.server.recoveryInspection.(localRecoveryExecutor) + if !r.enterRecovery(ctx) || !ok || id == "" || id != confirmation.DispositionID() { + r.stage = -1 + return deployment.RecoveryDispositionSummary{}, localreset.ErrProtocol + } + item, err := executor.ExecuteRecoveryDisposition(ctx, confirmation, r.peer.Identity, token, r.recoveryServer) + if !r.current(ctx) { + r.stage = -1 + return deployment.RecoveryDispositionSummary{}, localreset.ErrUnavailable + } + // 执行失败但意图已落库时,返回明确的拒绝、不确定或运行中状态;不将错误伪装成成功,也不自动重试。 + summary := item.Summary() + if summary.ID != id || summary.Validate() != nil { + r.stage = -1 + return deployment.RecoveryDispositionSummary{}, localreset.ErrUnavailable + } + if err != nil && summary.Status == "succeeded" { + r.stage = -1 + return deployment.RecoveryDispositionSummary{}, localreset.ErrUnavailable + } + return summary, nil +} + +func (r *localResetSession) GetRecoveryDisposition(ctx context.Context, id string) (deployment.RecoveryDispositionSummary, error) { + r.mu.Lock() + defer r.mu.Unlock() + executor, ok := r.server.recoveryInspection.(localRecoveryExecutor) + if !r.enterRecovery(ctx) || !ok { + return deployment.RecoveryDispositionSummary{}, localreset.ErrProtocol + } + result, err := executor.GetRecoveryDisposition(ctx, id) + if err != nil || !r.current(ctx) || result.ID != id || result.Validate() != nil { + r.stage = -1 + return deployment.RecoveryDispositionSummary{}, localreset.ErrUnavailable + } + return result, nil +} + +var _ localreset.RecoveryDispositionSession = (*localResetSession)(nil) diff --git a/backend/internal/httpapi/local_recovery_disposition_linux_test.go b/backend/internal/httpapi/local_recovery_disposition_linux_test.go new file mode 100644 index 0000000..a27829a --- /dev/null +++ b/backend/internal/httpapi/local_recovery_disposition_linux_test.go @@ -0,0 +1,125 @@ +//go:build linux + +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +func TestLocalDispositionUnixIdentityAndLostResponse(t *testing.T) { + for _, mode := range []string{"normal", "lost-response", "foreign-session"} { + t.Run(mode, func(t *testing.T) { + server, _, journal := localDispositionFixture(t) + path := filepath.Join(t.TempDir(), "control.sock") + listener, err := localreset.Listen(path) + if err != nil { + t.Fatal(err) + } + info, err := os.Lstat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("socket is not protected") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + done := make(chan error, 1) + go func() { + done <- listener.Serve(ctx, server.localResetFactory(func(context.Context, string, string) error { return nil })) + }() + defer func() { + cancel() + _ = listener.Close() + if err := <-done; err != nil { + t.Error(err) + } + }() + conn, err := localreset.Dial(ctx, path, uint32(os.Geteuid())) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + reader, writer := json.NewDecoder(conn), json.NewEncoder(conn) + var challenge localreset.Response + if err := reader.Decode(&challenge); err != nil || !challenge.DispositionAvailable { + t.Fatalf("challenge: %v", err) + } + if err := writer.Encode(localreset.Request{Version: localreset.Version, Nonce: challenge.Nonce, Action: "recovery_prepare", TransactionID: "tx-1", RecoveryAction: deployment.RecoveryActionRecord}); err != nil { + t.Fatal(err) + } + var preparation localreset.Response + if err := reader.Decode(&preparation); err != nil || preparation.Preparation == nil { + t.Fatalf("prepare: %+v %v", preparation, err) + } + prepared := preparation.Preparation + if mode == "foreign-session" { + _ = conn.Close() + other, err := localreset.Dial(ctx, path, uint32(os.Geteuid())) + if err != nil { + t.Fatal(err) + } + client, err := localreset.Begin(ctx, other) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if _, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token); err == nil { + t.Fatal("confirmation escaped original session") + } + journal.mu.Lock() + defer journal.mu.Unlock() + if journal.begins != 0 { + t.Fatal("foreign session wrote intent") + } + return + } + if err := writer.Encode(localreset.Request{Version: localreset.Version, Nonce: preparation.Nonce, Action: "recovery_execute", DispositionID: prepared.DispositionID, ConfirmationToken: prepared.Token}); err != nil { + t.Fatal(err) + } + if mode == "normal" { + var result localreset.Response + if err := reader.Decode(&result); err != nil || result.Disposition == nil || result.Disposition.ResultCode != "observed" { + t.Fatalf("execute: %+v %v", result, err) + } + } + _ = conn.Close() + for { + journal.mu.Lock() + finished := journal.finishes == 1 + journal.mu.Unlock() + if finished { + break + } + select { + case <-ctx.Done(): + t.Fatal("execution never reached durable result") + case <-time.After(5 * time.Millisecond): + } + } + other, err := localreset.Dial(ctx, path, uint32(os.Geteuid())) + if err != nil { + t.Fatal(err) + } + client, err := localreset.Begin(ctx, other) + if err != nil { + t.Fatal(err) + } + defer client.Close() + result, err := client.GetRecoveryDisposition(prepared.DispositionID) + if err != nil || result.Status != "succeeded" || result.ResultCode != "observed" { + t.Fatalf("reconnect query: %+v %v", result, err) + } + journal.mu.Lock() + defer journal.mu.Unlock() + if journal.begins != 1 || journal.finishes != 1 || journal.queries != 1 || journal.item.Intent.Operator != fmt.Sprintf("uid:%d", os.Geteuid()) { + t.Fatal("identity not from socket or query replayed") + } + }) + } +} diff --git a/backend/internal/httpapi/local_recovery_disposition_test.go b/backend/internal/httpapi/local_recovery_disposition_test.go new file mode 100644 index 0000000..a717a36 --- /dev/null +++ b/backend/internal/httpapi/local_recovery_disposition_test.go @@ -0,0 +1,250 @@ +package httpapi + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +type localDispositionJournal struct { + deployment.TransactionJournal + mu sync.Mutex + record deployment.TransactionRecord + item deployment.RecoveryDisposition + begins int + finishes int + queries int + failFinish bool +} + +func (j *localDispositionJournal) ListPendingDeploymentTransactions(context.Context) ([]deployment.TransactionRecord, error) { + return []deployment.TransactionRecord{j.record}, nil +} +func (j *localDispositionJournal) BeginRecoveryDisposition(_ context.Context, intent deployment.RecoveryDispositionIntent) (deployment.RecoveryDisposition, bool, error) { + j.mu.Lock() + defer j.mu.Unlock() + j.begins++ + if j.item.Intent.ID != "" { + return j.item, false, nil + } + now := time.Now().UTC() + j.item = deployment.RecoveryDisposition{Intent: intent, Status: "running", CreatedAt: now, UpdatedAt: now} + return j.item, true, nil +} +func (j *localDispositionJournal) FinishRecoveryDisposition(_ context.Context, id, result string) (deployment.RecoveryDisposition, error) { + j.mu.Lock() + defer j.mu.Unlock() + j.finishes++ + if j.failFinish { + return deployment.RecoveryDisposition{}, errors.New("secret-audit-error") + } + j.item.Status, j.item.ResultCode, j.item.UpdatedAt = "succeeded", result, time.Now().UTC() + if result == "rejected" || result == "uncertain" { + j.item.Status = result + } + return j.item, nil +} +func (j *localDispositionJournal) GetRecoveryDisposition(context.Context, string) (deployment.RecoveryDisposition, error) { + j.mu.Lock() + defer j.mu.Unlock() + j.queries++ + return j.item, nil +} +func (*localDispositionJournal) HasRecoveryClaim(context.Context, string) (bool, error) { + return false, nil +} + +type localDispositionRemote struct{ deployment.Client } + +func (localDispositionRemote) Exists(context.Context, string) (bool, error) { return false, nil } +func (localDispositionRemote) Close() error { return nil } +func (localDispositionRemote) CreateOwnedLock(context.Context, string, string) error { + return errors.New("unexpected mutation") +} +func (localDispositionRemote) ReadLockOwner(context.Context, string) (string, error) { + return "", errors.New("absent") +} +func (localDispositionRemote) FileDigest(context.Context, string) (string, error) { + return "", errors.New("absent") +} + +type localDispositionFactory struct{} + +func (localDispositionFactory) Connect(context.Context, deployment.Server) (deployment.Client, error) { + return localDispositionRemote{}, nil +} + +type localDispositionServerRepo struct { + repository.ServerRepository + server deployment.Server +} + +func (r localDispositionServerRepo) GetServerByID(context.Context, string) (deployment.Server, error) { + return r.server, nil +} + +func localDispositionFixture(t *testing.T) (*Server, *localResetSession, *localDispositionJournal) { + t.Helper() + server := newLocalResetTestServer(t) + target := deployment.Server{ID: "server-1", Name: "server-1", Address: "192.0.2.1", Port: 22, Username: "deploy", AuthMethod: deployment.SSHAuthPassword, + PasswordRef: "secret://ssh/password/test", HostKeyFingerprint: "SHA256:test", CertificatePath: "/cert.pem", CertificateChainPath: "/chain.pem", PrivateKeyPath: "/key.pem", WebServer: deployment.WebServerNginx}.Normalize() + journal := &localDispositionJournal{record: deployment.TransactionRecord{ID: "tx-1", ServerID: target.ID, Phase: "locked", Status: deployment.TransactionStatusRecoveryRequired, + Paths: []string{target.CertificatePath, target.CertificateChainPath, target.PrivateKeyPath}}} + server.recoveryInspection = deployment.NewServiceWithJournal(localDispositionFactory{}, journal) + server.servers = localDispositionServerRepo{server: target} + session := localDispositionNewSession(t, server) + return server, session, journal +} +func localDispositionNewSession(t *testing.T, server *Server) *localResetSession { + t.Helper() + session, err := server.localResetFactory(func(context.Context, string, string) error { t.Error("unexpected mail"); return nil })(t.Context(), localreset.Peer{Identity: "uid:0"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(session.Close) + return session.(*localResetSession) +} + +func TestLocalDispositionRecordAndReconnectQuery(t *testing.T) { + server, session, journal := localDispositionFixture(t) + if !session.DispositionAvailable() { + t.Fatal("missing capability") + } + prepared, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord) + if err != nil || len(prepared.Token) != 64 { + t.Fatalf("prepare: %+v %v", prepared, err) + } + result, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token) + if err != nil || result.Status != "succeeded" || result.ResultCode != "observed" { + t.Fatalf("execute: %+v %v", result, err) + } + if journal.item.Intent.Operator != "uid:0" || journal.record.Status != deployment.TransactionStatusRecoveryRequired || journal.record.Evidence != nil { + t.Fatal("identity or original transaction changed") + } + if _, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token); err == nil { + t.Fatal("confirmation replayed") + } + next := localDispositionNewSession(t, server) + queried, err := next.GetRecoveryDisposition(t.Context(), prepared.DispositionID) + if err != nil || queried != result { + t.Fatalf("query: %+v %v", queried, err) + } + if journal.begins != 1 || journal.finishes != 1 || journal.queries != 1 { + t.Fatal("query replayed execution") + } + if err := next.Select(t.Context(), "email"); err == nil { + t.Fatal("query switched to password reset") + } +} + +func TestLocalDispositionInvalidSessionAndConfirmationNeverBegin(t *testing.T) { + for _, mode := range []string{"expired", "cancelled", "changed-admin", "closed", "reset-stage", "wrong-id", "wrong-token", "changed-peer", "replaced-confirmation"} { + t.Run(mode, func(t *testing.T) { + server, session, journal := localDispositionFixture(t) + prepared, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch mode { + case "expired": + session.expires = time.Time{} + case "cancelled": + cancel() + case "changed-admin": + server.config.Admin.Email = "changed@example.test" + case "closed": + session.Close() + case "reset-stage": + session.stage = 1 + case "wrong-id": + prepared.DispositionID = strings.Repeat("a", 64) + case "wrong-token": + prepared.Token = strings.Repeat("a", 64) + case "changed-peer": + session.peer.Identity = "uid:123" + case "replaced-confirmation": + if _, err := session.PrepareRecovery(ctx, "tx-1", deployment.RecoveryActionRecord); err != nil { + t.Fatal(err) + } + } + if _, err := session.ExecuteRecovery(ctx, prepared.DispositionID, prepared.Token); err == nil { + t.Fatal("invalid execution accepted") + } + if _, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token); err == nil { + t.Fatal("failed confirmation reusable") + } + if journal.begins != 0 || journal.finishes != 0 { + t.Fatal("invalid request wrote intent") + } + if session.recoveryConfirmation.DispositionID() != "" { + t.Fatal("confirmation retained") + } + }) + } +} + +func TestLocalDispositionConcurrentExecuteOnce(t *testing.T) { + _, session, journal := localDispositionFixture(t) + prepared, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token) + }() + } + wg.Wait() + if journal.begins != 1 || journal.finishes != 1 { + t.Fatal("concurrent execution duplicated") + } +} + +func TestLocalDispositionAuditFailureReturnsRunning(t *testing.T) { + server, session, journal := localDispositionFixture(t) + journal.failFinish = true + prepared, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord) + if err != nil { + t.Fatal(err) + } + result, err := session.ExecuteRecovery(t.Context(), prepared.DispositionID, prepared.Token) + if err != nil || result.Status != "running" || result.ResultCode != "" { + t.Fatalf("false success: %+v %v", result, err) + } + next := localDispositionNewSession(t, server) + if result, err := next.GetRecoveryDisposition(t.Context(), prepared.DispositionID); err != nil || result.Status != "running" { + t.Fatalf("query: %+v %v", result, err) + } +} + +func TestLocalDispositionMissingEvidenceAndReaderOnly(t *testing.T) { + for _, action := range []deployment.RecoveryAction{deployment.RecoveryActionCommit, deployment.RecoveryActionRollback} { + _, session, journal := localDispositionFixture(t) + if _, err := session.PrepareRecovery(t.Context(), "tx-1", action); err == nil { + t.Fatal("missing evidence authorized mutation") + } + if journal.begins != 0 || session.recoveryConfirmation.DispositionID() != "" { + t.Fatal("invalid confirmation retained") + } + } + server := newLocalResetTestServer(t) + session := newLocalRecoverySession(t, server, &testLocalRecoveryReader{}) + if session.DispositionAvailable() { + t.Fatal("reader advertised writes") + } + if _, err := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionRecord); err == nil { + t.Fatal("reader accepted action") + } +} diff --git a/backend/internal/httpapi/local_reset.go b/backend/internal/httpapi/local_reset.go index 464c8d9..7494e76 100644 --- a/backend/internal/httpapi/local_reset.go +++ b/backend/internal/httpapi/local_reset.go @@ -13,6 +13,7 @@ import ( "time" "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/deployment" "git.pcux.cn/xuans/Scert/backend/internal/localreset" "git.pcux.cn/xuans/Scert/backend/internal/notification" "git.pcux.cn/xuans/Scert/backend/internal/passwordpolicy" @@ -23,15 +24,16 @@ import ( type resetCodeSender func(context.Context, string, string) error type localResetSession struct { - server *Server - send resetCodeSender - reserve func() bool - admin config.AdminConfig - expires time.Time - peer localreset.Peer - mu sync.Mutex - stage int - codeHash [32]byte + server *Server + send resetCodeSender + reserve func() bool + admin config.AdminConfig + expires time.Time + peer localreset.Peer + mu sync.Mutex + stage int + codeHash [32]byte + recoveryConfirmation deployment.RecoveryConfirmation } // LocalResetFactory 只交给受保护的本机通道,不注册到普通 HTTP 路由。 @@ -187,6 +189,7 @@ func (r *localResetSession) Close() { defer r.mu.Unlock() r.stage = -1 r.codeHash = [32]byte{} + r.recoveryConfirmation = deployment.RecoveryConfirmation{} } func (r *localResetSession) audit(status, message string) { diff --git a/backend/internal/localreset/client.go b/backend/internal/localreset/client.go index fedfd93..f573b63 100644 --- a/backend/internal/localreset/client.go +++ b/backend/internal/localreset/client.go @@ -9,15 +9,16 @@ import ( ) type Client struct { - conn net.Conn - reader *bufio.Reader - nonce string - methods []Method - recoveryAvailable bool - stage int - stop func() bool - mu sync.Mutex - once sync.Once + conn net.Conn + reader *bufio.Reader + nonce string + methods []Method + recoveryAvailable bool + dispositionAvailable bool + stage int + stop func() bool + mu sync.Mutex + once sync.Once } // Begin 只接受已通过 Dial 校验身份的连接;调用过程不会通过参数或文件传递密码。 @@ -37,13 +38,14 @@ func Begin(ctx context.Context, conn net.Conn) (*Client, error) { client.stop = context.AfterFunc(ctx, func() { _ = conn.Close() }) var response Response if err := readFrame(client.reader, &response); err != nil || !validChallenge(response) || - len(response.Methods) == 0 || len(response.Methods) > 8 || response.RecoveryPage != nil || response.Inspection != nil || response.InspectionPart != nil { + len(response.Methods) == 0 || len(response.Methods) > 8 || response.RecoveryPage != nil || response.Inspection != nil || response.InspectionPart != nil || response.Preparation != nil || response.Disposition != nil || (response.DispositionAvailable && !response.RecoveryAvailable) { _ = client.Close() return nil, ErrProtocol } client.nonce = response.Nonce client.methods = response.Methods client.recoveryAvailable = response.RecoveryAvailable + client.dispositionAvailable = response.DispositionAvailable return client, nil } @@ -77,7 +79,7 @@ func (c *Client) exchange(request Request, stage int) error { _ = c.Close() return ErrProtocol } - if response.RecoveryAvailable || response.RecoveryPage != nil || response.Inspection != nil || response.InspectionPart != nil { + if response.RecoveryAvailable || response.RecoveryPage != nil || response.Inspection != nil || response.InspectionPart != nil || hasDispositionPayload(response) { c.stage = -1 _ = c.Close() return ErrProtocol diff --git a/backend/internal/localreset/protocol.go b/backend/internal/localreset/protocol.go index a102a21..7ee7ede 100644 --- a/backend/internal/localreset/protocol.go +++ b/backend/internal/localreset/protocol.go @@ -40,26 +40,32 @@ type Method struct { } type Request struct { - Version int `json:"version"` - Nonce string `json:"nonce"` - Action string `json:"action"` - Method string `json:"method,omitempty"` - Code string `json:"code,omitempty"` - Password string `json:"password,omitempty"` - Cursor string `json:"cursor,omitempty"` - TransactionID string `json:"transaction_id,omitempty"` + Version int `json:"version"` + Nonce string `json:"nonce"` + Action string `json:"action"` + Method string `json:"method,omitempty"` + Code string `json:"code,omitempty"` + Password string `json:"password,omitempty"` + Cursor string `json:"cursor,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + RecoveryAction deployment.RecoveryAction `json:"recovery_action,omitempty"` + DispositionID string `json:"disposition_id,omitempty"` + ConfirmationToken string `json:"confirmation_token,omitempty"` } type Response struct { - Version int `json:"version"` - OK bool `json:"ok"` - Code string `json:"code"` - Nonce string `json:"nonce,omitempty"` - Methods []Method `json:"methods,omitempty"` - RecoveryAvailable bool `json:"recovery_available,omitempty"` - RecoveryPage *deployment.PendingRecoveryPage `json:"recovery_page,omitempty"` - Inspection *deployment.RecoveryInspection `json:"inspection,omitempty"` - InspectionPart *inspectionPart `json:"inspection_part,omitempty"` + Version int `json:"version"` + OK bool `json:"ok"` + Code string `json:"code"` + Nonce string `json:"nonce,omitempty"` + Methods []Method `json:"methods,omitempty"` + RecoveryAvailable bool `json:"recovery_available,omitempty"` + RecoveryPage *deployment.PendingRecoveryPage `json:"recovery_page,omitempty"` + Inspection *deployment.RecoveryInspection `json:"inspection,omitempty"` + InspectionPart *inspectionPart `json:"inspection_part,omitempty"` + DispositionAvailable bool `json:"disposition_available,omitempty"` + Preparation *RecoveryPreparation `json:"preparation,omitempty"` + Disposition *deployment.RecoveryDispositionSummary `json:"disposition,omitempty"` } // Session 由主服务创建,身份验证及密码持久化仍由主服务负责。 @@ -233,6 +239,9 @@ func (s *Server) handle(parent context.Context, conn net.Conn, factory Factory) if index == 0 { response.Methods = methods response.RecoveryAvailable = hasRecovery + if actions, ok := session.(RecoveryDispositionSession); ok && hasRecovery { + response.DispositionAvailable = actions.DispositionAvailable() + } } if writeFrame(conn, response) != nil { return @@ -276,7 +285,7 @@ func (s *Server) handle(parent context.Context, conn net.Conn, factory Factory) } func validRequest(request Request, nonce, action string) bool { - if request.Cursor != "" || request.TransactionID != "" { + if request.Cursor != "" || request.TransactionID != "" || request.RecoveryAction != "" || request.DispositionID != "" || request.ConfirmationToken != "" { return false } if request.Version != Version || request.Action != action || diff --git a/backend/internal/localreset/recovery.go b/backend/internal/localreset/recovery.go index f6763cd..5b2d810 100644 --- a/backend/internal/localreset/recovery.go +++ b/backend/internal/localreset/recovery.go @@ -23,9 +23,15 @@ func validRecoveryRequest(request Request, nonce string) bool { } switch request.Action { case "recovery_list": - return request.TransactionID == "" && (request.Cursor == "" || validRecoveryID(request.Cursor)) + return request.TransactionID == "" && (request.Cursor == "" || validRecoveryID(request.Cursor)) && noDispositionRequest(request) case "recovery_inspect": - return request.Cursor == "" && validRecoveryID(request.TransactionID) + return request.Cursor == "" && validRecoveryID(request.TransactionID) && noDispositionRequest(request) + case "recovery_prepare": + return request.Cursor == "" && validRecoveryID(request.TransactionID) && validRecoveryAction(request.RecoveryAction) && request.DispositionID == "" && request.ConfirmationToken == "" + case "recovery_execute": + return request.Cursor == "" && request.TransactionID == "" && request.RecoveryAction == "" && validRecoverySecret(request.DispositionID) && validRecoverySecret(request.ConfirmationToken) + case "recovery_result": + return request.Cursor == "" && request.TransactionID == "" && request.RecoveryAction == "" && validRecoverySecret(request.DispositionID) && request.ConfirmationToken == "" } return false } @@ -71,17 +77,20 @@ func (c *Client) recoveryExchange(request Request) (Response, error) { if !c.recoveryAvailable || (c.stage != 0 && c.stage != 3) { return fail() } + if request.Action != "recovery_list" && request.Action != "recovery_inspect" && !c.dispositionAvailable { + return fail() + } request.Version, request.Nonce = Version, c.nonce if !validRecoveryRequest(request, c.nonce) || writeFrame(c.conn, request) != nil { return fail() } var response Response if readFrame(c.reader, &response) != nil || !validChallenge(response) || response.Nonce == c.nonce || - len(response.Methods) != 0 || response.RecoveryAvailable { + len(response.Methods) != 0 || response.RecoveryAvailable || response.DispositionAvailable { return fail() } if request.Action == "recovery_list" { - if response.RecoveryPage == nil || response.Inspection != nil || response.InspectionPart != nil || len(response.RecoveryPage.Items) > deployment.RecoveryPageSize { + if response.RecoveryPage == nil || response.Inspection != nil || response.InspectionPart != nil || hasDispositionPayload(response) || len(response.RecoveryPage.Items) > deployment.RecoveryPageSize { return fail() } previous := request.Cursor @@ -95,7 +104,10 @@ func (c *Client) recoveryExchange(request Request) (Response, error) { (len(response.RecoveryPage.Items) != deployment.RecoveryPageSize || cursor != previous) { return fail() } - } else { + } else if request.Action == "recovery_inspect" { + if hasDispositionPayload(response) { + return fail() + } if response.InspectionPart != nil { inspection, err := readInspectionParts(c.reader, response) if err != nil { @@ -106,6 +118,8 @@ func (c *Client) recoveryExchange(request Request) (Response, error) { if !validInspectionPayload(response.Inspection) || response.RecoveryPage != nil || response.Inspection.TransactionID != request.TransactionID { return fail() } + } else if !c.dispositionAvailable || !validDispositionResponse(request, response) { + return fail() } c.nonce, c.stage = response.Nonce, 3 return response, nil diff --git a/backend/internal/localreset/recovery_disposition.go b/backend/internal/localreset/recovery_disposition.go new file mode 100644 index 0000000..c614568 --- /dev/null +++ b/backend/internal/localreset/recovery_disposition.go @@ -0,0 +1,118 @@ +package localreset + +import ( + "context" + "strings" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +// RecoveryPreparation 只用于受保护的本机通道,不包含可重构服务端确认对象的绑定数据。 +type RecoveryPreparation struct { + DispositionID string `json:"disposition_id"` + Token string `json:"token"` + Summary deployment.RecoveryReviewSummary `json:"summary"` +} + +type RecoveryDispositionSession interface { + DispositionAvailable() bool + PrepareRecovery(context.Context, string, deployment.RecoveryAction) (RecoveryPreparation, error) + ExecuteRecovery(context.Context, string, string) (deployment.RecoveryDispositionSummary, error) + GetRecoveryDisposition(context.Context, string) (deployment.RecoveryDispositionSummary, error) +} + +func validRecoveryAction(action deployment.RecoveryAction) bool { + return action == deployment.RecoveryActionRecord || action == deployment.RecoveryActionCommit || action == deployment.RecoveryActionRollback +} + +func validRecoverySecret(value string) bool { + if len(value) != 64 || value == strings.Repeat("0", 64) { + return false + } + for _, c := range value { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func noDispositionRequest(request Request) bool { + return request.RecoveryAction == "" && request.DispositionID == "" && request.ConfirmationToken == "" +} + +func hasDispositionPayload(response Response) bool { + return response.DispositionAvailable || response.Preparation != nil || response.Disposition != nil +} + +func validPreparation(value *RecoveryPreparation) bool { + if value == nil || !validRecoverySecret(value.DispositionID) || !validRecoverySecret(value.Token) { + return false + } + return value.Summary.Validate() == nil +} + +func validDispositionResponse(request Request, response Response) bool { + if response.RecoveryPage != nil || response.Inspection != nil || response.InspectionPart != nil { + return false + } + if request.Action == "recovery_prepare" { + return response.Disposition == nil && validPreparation(response.Preparation) && + response.Preparation.Summary.TransactionID == request.TransactionID && response.Preparation.Summary.Action == request.RecoveryAction + } + return response.Preparation == nil && response.Disposition != nil && response.Disposition.Validate() == nil && response.Disposition.ID == request.DispositionID +} + +func dispatchRecoveryDisposition(ctx context.Context, session RecoverySession, request Request, response *Response) error { + actions, ok := session.(RecoveryDispositionSession) + if !ok || !actions.DispositionAvailable() { + return ErrUnavailable + } + var err error + switch request.Action { + case "recovery_prepare": + var value RecoveryPreparation + value, err = actions.PrepareRecovery(ctx, request.TransactionID, request.RecoveryAction) + response.Preparation = &value + case "recovery_execute": + var value deployment.RecoveryDispositionSummary + value, err = actions.ExecuteRecovery(ctx, request.DispositionID, request.ConfirmationToken) + response.Disposition = &value + case "recovery_result": + var value deployment.RecoveryDispositionSummary + value, err = actions.GetRecoveryDisposition(ctx, request.DispositionID) + response.Disposition = &value + default: + return ErrProtocol + } + if err != nil || !validDispositionResponse(request, *response) { + return ErrUnavailable + } + return nil +} + +func (c *Client) DispositionAvailable() bool { return c.dispositionAvailable } + +func (c *Client) PrepareRecovery(id string, action deployment.RecoveryAction) (RecoveryPreparation, error) { + response, err := c.recoveryExchange(Request{Action: "recovery_prepare", TransactionID: id, RecoveryAction: action}) + if err != nil { + return RecoveryPreparation{}, err + } + return *response.Preparation, nil +} + +func (c *Client) ExecuteRecovery(id, token string) (deployment.RecoveryDispositionSummary, error) { + response, err := c.recoveryExchange(Request{Action: "recovery_execute", DispositionID: id, ConfirmationToken: token}) + if err != nil { + return deployment.RecoveryDispositionSummary{}, err + } + return *response.Disposition, nil +} + +func (c *Client) GetRecoveryDisposition(id string) (deployment.RecoveryDispositionSummary, error) { + response, err := c.recoveryExchange(Request{Action: "recovery_result", DispositionID: id}) + if err != nil { + return deployment.RecoveryDispositionSummary{}, err + } + return *response.Disposition, nil +} diff --git a/backend/internal/localreset/recovery_disposition_test.go b/backend/internal/localreset/recovery_disposition_test.go new file mode 100644 index 0000000..a02fa95 --- /dev/null +++ b/backend/internal/localreset/recovery_disposition_test.go @@ -0,0 +1,305 @@ +package localreset + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "net" + "strings" + "sync/atomic" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +type dispositionTestSession struct { + recoveryTestSession + prepares atomic.Int32 + executes atomic.Int32 + queries atomic.Int32 + prepared RecoveryPreparation + result deployment.RecoveryDispositionSummary + fail bool +} + +func (*dispositionTestSession) DispositionAvailable() bool { return true } +func (s *dispositionTestSession) PrepareRecovery(_ context.Context, id string, action deployment.RecoveryAction) (RecoveryPreparation, error) { + s.prepares.Add(1) + now := time.Now().UTC() + s.prepared = RecoveryPreparation{DispositionID: strings.Repeat("b", 64), Token: strings.Repeat("c", 64), + Summary: deployment.RecoveryReviewSummary{TransactionID: id, ServerID: "server-1", Phase: "reload_attempted", Action: action, HistoricalEvidence: true, ObservedAt: now, ExpiresAt: now.Add(time.Minute)}} + return s.prepared, nil +} +func (s *dispositionTestSession) ExecuteRecovery(_ context.Context, id, token string) (deployment.RecoveryDispositionSummary, error) { + s.executes.Add(1) + if id != s.prepared.DispositionID || token != s.prepared.Token || s.fail { + return deployment.RecoveryDispositionSummary{}, errors.New("secret-executor-error") + } + now := time.Now().UTC() + results := map[deployment.RecoveryAction]string{deployment.RecoveryActionRecord: "observed", deployment.RecoveryActionCommit: "committed", deployment.RecoveryActionRollback: "rolled_back"} + s.result = deployment.RecoveryDispositionSummary{ID: id, TransactionID: s.prepared.Summary.TransactionID, ServerID: "server-1", Action: s.prepared.Summary.Action, Status: "succeeded", ResultCode: results[s.prepared.Summary.Action], CreatedAt: now, UpdatedAt: now} + s.prepared = RecoveryPreparation{} + return s.result, nil +} +func (s *dispositionTestSession) GetRecoveryDisposition(context.Context, string) (deployment.RecoveryDispositionSummary, error) { + s.queries.Add(1) + return s.result, nil +} + +func TestDispositionProtocolThreeActionsAndQuery(t *testing.T) { + for _, action := range []deployment.RecoveryAction{deployment.RecoveryActionRecord, deployment.RecoveryActionCommit, deployment.RecoveryActionRollback} { + t.Run(string(action), func(t *testing.T) { + session := &dispositionTestSession{} + _, conn, done := recoveryConnection(t, session) + client, err := Begin(t.Context(), conn) + if err != nil || !client.DispositionAvailable() { + t.Fatalf("capability: %v", err) + } + prepared, err := client.PrepareRecovery("tx-1", action) + if err != nil { + t.Fatal(err) + } + result, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token) + if err != nil || result.Action != action || result.Status != "succeeded" { + t.Fatalf("execute: %+v %v", result, err) + } + query, err := client.GetRecoveryDisposition(result.ID) + if err != nil || query != result { + t.Fatalf("query: %+v %v", query, err) + } + if _, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token); err == nil { + t.Fatal("replay accepted") + } + _ = client.Close() + <-done + if session.prepares.Load() != 1 || session.executes.Load() != 2 || session.queries.Load() != 1 || session.resets.Load() != 0 { + t.Fatal("unexpected business calls") + } + }) + } +} + +func TestDispositionRequestFieldIsolation(t *testing.T) { + nonce := strings.Repeat("a", 64) + requests := []Request{ + {Action: "recovery_list"}, {Action: "recovery_inspect", TransactionID: "tx-1"}, + {Action: "recovery_prepare", TransactionID: "tx-1", RecoveryAction: deployment.RecoveryActionRecord}, + {Action: "recovery_execute", DispositionID: strings.Repeat("b", 64), ConfirmationToken: strings.Repeat("c", 64)}, + {Action: "recovery_result", DispositionID: strings.Repeat("b", 64)}, + } + fields := []struct { + name string + set func(*Request) + allowed map[string]bool + }{ + {"method", func(r *Request) { r.Method = "email" }, nil}, + {"password", func(r *Request) { r.Password = "secret" }, nil}, + {"code", func(r *Request) { r.Code = "12345678" }, nil}, + {"cursor", func(r *Request) { r.Cursor = "tx-0" }, map[string]bool{"recovery_list": true}}, + {"transaction", func(r *Request) { r.TransactionID = "tx-1" }, map[string]bool{"recovery_inspect": true, "recovery_prepare": true}}, + {"action", func(r *Request) { r.RecoveryAction = deployment.RecoveryActionRecord }, map[string]bool{"recovery_prepare": true}}, + {"id", func(r *Request) { r.DispositionID = strings.Repeat("b", 64) }, map[string]bool{"recovery_execute": true, "recovery_result": true}}, + {"token", func(r *Request) { r.ConfirmationToken = strings.Repeat("c", 64) }, map[string]bool{"recovery_execute": true}}, + } + for _, request := range requests { + request.Version, request.Nonce = Version, nonce + if !validRecoveryRequest(request, nonce) { + t.Fatalf("valid request rejected: %s", request.Action) + } + for _, field := range fields { + if field.allowed[request.Action] { + continue + } + mixed := request + field.set(&mixed) + if validRecoveryRequest(mixed, nonce) { + t.Fatalf("mixed %s/%s accepted", request.Action, field.name) + } + } + if validRecoveryRequest(request, strings.Repeat("d", 64)) { + t.Fatal("old nonce accepted") + } + } + for _, field := range fields[5:] { + request := Request{Version: Version, Nonce: nonce, Action: "select", Method: "email"} + field.set(&request) + if validRequest(request, nonce, "select") { + t.Fatalf("reset accepts %s", field.name) + } + } + for _, bad := range []string{"", ".", "..", strings.Repeat("0", 64), strings.Repeat("A", 64), strings.Repeat("b", 65)} { + if validRecoverySecret(bad) { + t.Fatal("bad secret accepted") + } + } +} + +func TestDispositionReplayNonceAndMixedPayloadNeverExecute(t *testing.T) { + for _, mode := range []string{"old-nonce", "password", "cursor", "transaction", "action", "prepare-token", "query-token"} { + t.Run(mode, func(t *testing.T) { + session := &dispositionTestSession{} + _, conn, done := recoveryConnection(t, session) + reader := bufio.NewReaderSize(conn, MaxFrameSize+1) + var first Response + if err := readFrame(reader, &first); err != nil { + t.Fatal(err) + } + if err := writeFrame(conn, Request{Version: Version, Nonce: first.Nonce, Action: "recovery_prepare", TransactionID: "tx-1", RecoveryAction: deployment.RecoveryActionRecord}); err != nil { + t.Fatal(err) + } + var prepared Response + if err := readFrame(reader, &prepared); err != nil { + t.Fatal(err) + } + request := Request{Version: Version, Nonce: prepared.Nonce, Action: "recovery_execute", DispositionID: prepared.Preparation.DispositionID, ConfirmationToken: prepared.Preparation.Token} + switch mode { + case "old-nonce": + request.Nonce = first.Nonce + case "password": + request.Password = "secret" + case "cursor": + request.Cursor = "tx-0" + case "transaction": + request.TransactionID = "tx-1" + case "action": + request.RecoveryAction = deployment.RecoveryActionRollback + case "prepare-token": + request.Action, request.TransactionID, request.RecoveryAction = "recovery_prepare", "tx-1", deployment.RecoveryActionRecord + case "query-token": + request.Action = "recovery_result" + } + if err := writeFrame(conn, request); err != nil { + t.Fatal(err) + } + var response Response + if err := readFrame(reader, &response); err != nil || response.OK || response.Code != "INVALID_REQUEST" { + t.Fatalf("response: %+v %v", response, err) + } + <-done + if session.executes.Load() != 0 || session.queries.Load() != 0 || session.prepares.Load() != 1 { + t.Fatal("invalid request reached service") + } + }) + } +} + +func TestDispositionClientRejectsMalformedPrepare(t *testing.T) { + for _, mode := range []string{"token", "id", "transaction", "action", "lifetime", "missing-evidence", "mixed-result", "mixed-page", "capability"} { + t.Run(mode, func(t *testing.T) { + server, conn := net.Pipe() + defer server.Close() + _ = server.SetDeadline(time.Now().Add(2 * time.Second)) + done := make(chan struct{}) + go func() { + defer close(done) + defer server.Close() + _ = writeFrame(server, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("a", 64), Methods: []Method{{ID: "email"}}, RecoveryAvailable: true, DispositionAvailable: true}) + var request Request + if readFrame(bufio.NewReaderSize(server, MaxFrameSize+1), &request) != nil { + return + } + session := &dispositionTestSession{} + value, _ := session.PrepareRecovery(t.Context(), "tx-1", deployment.RecoveryActionCommit) + response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("b", 64), Preparation: &value} + switch mode { + case "token": + value.Token = "bad" + case "id": + value.DispositionID = strings.Repeat("0", 64) + case "transaction": + value.Summary.TransactionID = "tx-2" + case "action": + value.Summary.Action = deployment.RecoveryActionRollback + case "lifetime": + value.Summary.ExpiresAt = value.Summary.ObservedAt.Add(time.Hour) + case "missing-evidence": + value.Summary.HistoricalEvidence = false + case "mixed-result": + response.Disposition = &deployment.RecoveryDispositionSummary{} + case "mixed-page": + response.RecoveryPage = &deployment.PendingRecoveryPage{} + case "capability": + response.DispositionAvailable = true + } + _ = writeFrame(server, response) + }() + client, err := Begin(t.Context(), conn) + if err != nil { + t.Fatal(err) + } + if value, err := client.PrepareRecovery("tx-1", deployment.RecoveryActionCommit); err == nil || value.Token != "" { + t.Fatal("malformed preparation returned") + } + _ = client.Close() + <-done + }) + } +} + +func TestDispositionFieldsRejectedInInspectionParts(t *testing.T) { + inspection := longInspectionFixture() + var raw bytes.Buffer + if err := writeRecoveryResponse(t.Context(), &raw, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("a", 64), Inspection: &inspection}); err != nil { + t.Fatal(err) + } + for _, field := range []string{"preparation", "disposition", "capability"} { + for _, index := range []int{0, 1} { + lines := bytes.Split(bytes.TrimSuffix(raw.Bytes(), []byte{'\n'}), []byte{'\n'}) + var changed Response + if err := json.Unmarshal(lines[index], &changed); err != nil { + t.Fatal(err) + } + switch field { + case "preparation": + changed.Preparation = &RecoveryPreparation{} + case "disposition": + changed.Disposition = &deployment.RecoveryDispositionSummary{} + case "capability": + changed.DispositionAvailable = true + } + lines[index], _ = json.Marshal(changed) + reader := bufio.NewReaderSize(bytes.NewReader(append(bytes.Join(lines, []byte{'\n'}), '\n')), MaxFrameSize+1) + var first Response + if err := readFrame(reader, &first); err != nil { + t.Fatal(err) + } + if value, err := readInspectionParts(reader, first); err == nil || value != nil { + t.Fatalf("mixed fragment %s/%d accepted", field, index) + } + } + } +} + +func TestDispositionQueryAfterReconnectDoesNotExecute(t *testing.T) { + session := &dispositionTestSession{} + _, conn, done := recoveryConnection(t, session) + client, err := Begin(t.Context(), conn) + if err != nil { + t.Fatal(err) + } + prepared, err := client.PrepareRecovery("tx-1", deployment.RecoveryActionRecord) + if err != nil { + t.Fatal(err) + } + if _, err := client.ExecuteRecovery(prepared.DispositionID, prepared.Token); err != nil { + t.Fatal(err) + } + _ = client.Close() + <-done + next := &dispositionTestSession{result: session.result} + _, conn, _ = recoveryConnection(t, next) + client, err = Begin(t.Context(), conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if result, err := client.GetRecoveryDisposition(prepared.DispositionID); err != nil || result.Status != "succeeded" { + t.Fatalf("query: %+v %v", result, err) + } + if next.prepares.Load() != 0 || next.executes.Load() != 0 || next.queries.Load() != 1 { + t.Fatal("query replayed operation") + } +} diff --git a/backend/internal/localreset/recovery_frames.go b/backend/internal/localreset/recovery_frames.go index 020e7f5..fdb9efa 100644 --- a/backend/internal/localreset/recovery_frames.go +++ b/backend/internal/localreset/recovery_frames.go @@ -50,7 +50,7 @@ func writeRecoveryResponse(ctx context.Context, writer io.Writer, response Respo if response.Inspection == nil { return writeFrame(writer, response) } - if !validInspectionPayload(response.Inspection) || response.InspectionPart != nil || response.RecoveryPage != nil { + if !validInspectionPayload(response.Inspection) || response.InspectionPart != nil || response.RecoveryPage != nil || hasDispositionPayload(response) { return ErrProtocol } encoded, err := json.Marshal(response) @@ -95,7 +95,7 @@ func readInspectionParts(reader *bufio.Reader, first Response) (*deployment.Reco } } part := response.InspectionPart - if !validChallenge(response) || response.Nonce != first.Nonce || len(response.Methods) != 0 || response.RecoveryAvailable || + if !validChallenge(response) || response.Nonce != first.Nonce || len(response.Methods) != 0 || response.RecoveryAvailable || hasDispositionPayload(response) || response.Inspection != nil || response.RecoveryPage != nil || part == nil || part.Index != index || part.Total != total || len(part.Data) == 0 || len(part.Data) > inspectionPartSize || (index < total-1 && len(part.Data) != inspectionPartSize) { return nil, ErrProtocol diff --git a/backend/internal/localreset/recovery_server.go b/backend/internal/localreset/recovery_server.go index 1df559a..4dde813 100644 --- a/backend/internal/localreset/recovery_server.go +++ b/backend/internal/localreset/recovery_server.go @@ -25,6 +25,8 @@ func (s *Server) handleRecovery(ctx context.Context, conn net.Conn, reader *bufi case "recovery_inspect": inspection, readErr := session.InspectRecovery(ctx, request.TransactionID) err, response.Inspection = readErr, &inspection + case "recovery_prepare", "recovery_execute", "recovery_result": + err = dispatchRecoveryDisposition(ctx, session, request, &response) default: err = ErrProtocol } diff --git a/backend/internal/localreset/recovery_test.go b/backend/internal/localreset/recovery_test.go index a6d46a9..955895d 100644 --- a/backend/internal/localreset/recovery_test.go +++ b/backend/internal/localreset/recovery_test.go @@ -43,7 +43,7 @@ func (s *recoveryTestSession) InspectRecovery(_ context.Context, id string) (dep return result, nil } -func recoveryConnection(t *testing.T, session *recoveryTestSession) (*Server, net.Conn, <-chan struct{}) { +func recoveryConnection(t *testing.T, session Session) (*Server, net.Conn, <-chan struct{}) { t.Helper() serverConn, clientConn := net.Pipe() server := newServer(nil, func(net.Conn) (Peer, error) { return Peer{Identity: "test"}, nil }, nil) diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index c6e0fc1..7db5cf5 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,24 @@ # Linux 生产流程测试记录 +## 2026年10月3日21:32 本机接入修复版回归 + +- [x] 首轮任务 `scert-local-actions-20261003-2116.service` 已失败终止,`LOCAL_ACTIONS_RACE_EXIT=1`。分片测试夹具使用默认四 KiB 读取器导致过早失败;已改用协议最大帧大小的读取器,没有放宽生产协议。HTTP 全包竞态五分钟超时,堆栈位于既有 SQLite 迁移,不能记为通过。 +- [x] 修复版源码归档 SHA-256 为 `c767ae48b4518f5edeb8ce416d9180a9e12d18a284c6c03977b75be5a06580e7`,已核对本地与第一台一致。 +- [✓] `scert-local-actions-fixed-20261003-2125.service` 日志 `local-actions-fixed.log`:`LOCAL_ACTIONS_FIXED_RACE_EXIT=0`、`LOCAL_ACTIONS_FIXED_HTTP_EXIT=0`、`LOCAL_ACTIONS_FIXED_VET_EXIT=0`、`LOCAL_ACTIONS_FIXED_FULL_EXIT=0`。三包竞态通过,HTTP 本机专项约 61.428 秒,普通全量 HTTP 约 46.288 秒。 +- [~] 21:31 实查该单元仍为 active、MainPID 为 126858,最后一项 HTTP 全包竞态正在执行,不能以此时的 Result=success 宣布整组通过。 +- [x] Unix socket 用例使用实际会话与执行服务,但持久仓储及远端文件为测试实现;终端 socket 菜单为测试服务端,不扩大为实库、SSH 或生产证据。 +- [x] 21:28 第二台指纹匹配,根分区可用 26 GB、使用率 11%、inode 3%,五项服务 active,Nginx 配置通过;本轮无删除。21:31 第一台 Scert/Nginx active、可用约 9.4 GB,未重启业务或清理业务数据库。 +- [ ] 必要外部实库、正式 Linux 候选包及完整双机生产验收未完成。 + + +## 2026年10月3日21:00 最新执行器终态确认 + +- [✓] 实查 `scert-executor-final-20261003-2052.service` 为 `inactive`、`MainPID=0`、`Result=success`。 +- [✓] 日志 `executor-final.log` 的部署竞态、SQLite 五场景、完整静态检查及后端全量四项退出码均为零;SQLite 竞态耗时 5.802 秒。 +- [x] 本批执行器代码已提交并推送 `bf4caeb`。 +- [~] 下一批本机协议和终端接入;本次是隔离自动回归,不是正式包、外部实库或第二台真实推送验收。 + + ## 2026年10月3日20:52 一次性确认与执行器回归 范围为第一台隔离源码目录 `/var/tmp/scert-local-recovery-20261003-1828`,不替代正式安装包;远端文件和 HTTPS 回调在本批 Go 测试中使用测试实现,SQLite 为真实临时数据库。线上业务数据库未清理、正式服务未升级。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 6fd2122..8ece455 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,20 @@ # Scert 开发任务清单 +## 2026年10月3日21:32 当前开发进度 + +- [x] 受保护本机会话、协议和终端三动作及结果查询已编码;确认绑定会话和真实对端,一次消费,失败或断连不重放。 +- [✓] Linux 三包竞态、HTTP 本机专项竞态、静态检查、后端普通全量通过;真实 Unix socket 的失去响应与跨会话拒绝用例已包含在专项内。 +- [~] HTTP 全包竞态仍在执行;外部实库和正式候选包是后续门禁,不据专项宣布生产通过。 +- [ ] R4 外部数据库处置实测和 R5 完整双机生产流程尚未完成。 + + +## 2026年10月3日21:00 当前开发进度 + +- [✓] 最新执行器 Linux 竞态、SQLite 五场景、静态检查及后端全量通过;检查点 `bf4caeb` 已推送。 +- [~] 接入受保护本机会话的准备、一次性执行和查询,完善协议与终端;具体任务见人工核查任务清单。 +- [ ] 必要外部实库和正式包生产闭环仍未完成。 + + ## 2026年10月3日20:52 当前开发进度 - [x] 一次性确认、记录/提交/回滚执行器、只读脱敏结果查询已编码;执行器共用部署互斥,绑定持久化意图与原子审计,不在失败后切换动作。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 5d34f97..caf4a78 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,21 @@ # Scert 整改任务执行清单 +## 2026年10月3日21:32 最新执行结论 + +- [x] R3-F/G/H 本机会话、协议隔离、终端风险确认及断连查询已接入。 +- [✓] 修复版 Linux 三包竞态、HTTP 本机专项竞态、静态检查和后端全量退出码零。 +- [~] HTTP 全包竞态仍运行,不能把运行中的整组标通过;首次夹具失败和 HTTP 五分钟超时不隐去。 +- [~] 继续 R4 必要外部数据库处置原子性与重开查询;后续为 R5 正式 Linux 包与双机验收。 +- [!] 第二台磁盘已实查剩余 26 GB,不再属于阻塞;系统邮件和完整生产门禁仍未通过。 + + +## 2026年10月3日21:00 最新执行结论 + +- [✓] 最新执行器竞态、SQLite 五场景、后端静态检查及全量通过,任务正常结束;`bf4caeb` 已推送。 +- [~] 本机协议、管理员终端和相关隔离测试正在接入。 +- [ ] 外部实库、正式 Linux 包和双机生产门禁未完成,不能宣布上线验收通过。 + + ## 2026年10月3日20:52 最新执行结论 - [x] R3 确认和三动作执行器已编码,并接入 R4 共同操作门、持久占用、原子开始/结束审计;查询结果不触发重放。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index f53ca29..6b6d445 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,6 +1,24 @@ # Scert 整改、合并与优化清单 -## 2026年10月3日20:52 最新整改进度 +## 2026年10月3日21:32 最新整改进度 + +本节优先于历史状态;仅 Linux,生产验收仍未通过。 + +- [x] R3-F/G/H 本机会话、严格协议与管理员终端已接入准备、一次性执行和查询;只接受受保护本机身份,断连不重放,不新增 HTTP/TCP 恢复入口。 +- [✓] 最新 Linux 本机协议、终端、部署三包竞态,HTTP 本机恢复专项竞态,完整静态检查和后端普通全量均通过。 +- [~] 同一后台任务的 HTTP 全包竞态仍在执行,整组尚未结束;首轮分片测试夹具失败与五分钟 HTTP 超时记录保留。 +- [~] 下一批完成 R4 外部实库的处置原子性、审计故障和重开查询,随后构建正式 Linux 候选包。 +- [ ] R5 三数据库首次 Web、系统邮件、DNS/ACME、公共 HTTPS、多对多推送与失败恢复仍需直接生产证据。 + + +## 2026年10月3日21:00 最新整改进度 + +- [✓] 最新执行器部署竞态、SQLite 五场景、后端静态检查及全量已通过,后台任务已正常结束,四项退出码均为零。 +- [x] 执行器底层已提交并推送 `bf4caeb`。 +- [~] 开始受保护本机会话、协议和管理员终端接入,具体任务见《部署事务人工核查任务清单》21:00 节。 +- [ ] 必要外部实库、正式包和双机生产验收仍未完成;不将自动测试等同于生产验收。 + +## 2026年10月3日20:52 历史整改进度 本节覆盖下面历史状态;仅 Linux,生产验收仍未通过。 diff --git a/doc/部署事务人工处置设计.md b/doc/部署事务人工处置设计.md index 4ae5367..9307e6e 100644 --- a/doc/部署事务人工处置设计.md +++ b/doc/部署事务人工处置设计.md @@ -1,5 +1,21 @@ # 部署事务人工处置设计 +## 2026年10月3日21:32 本机接入实现 + +- [x] 准备确认对象仅留在服务端会话,终端只取得短期令牌和脱敏摘要;执行先清空确认,所有失败尝试均不能再次使用。 +- [x] 请求动作字段互斥;准备、结果与只读分片不可混合。旧服务未广告能力时客户端拒绝执行类请求。 +- [x] 终端要求完整确认事务与动作,不显示令牌。执行断连不等于未执行;新连接只能按处置编号查询持久状态,不自动重新准备或执行。 +- [✓] 三包和本机 HTTP 专项竞态、真实 Unix socket 失去响应/跨会话测试、静态检查与普通全量通过。 +- [~] HTTP 全包竞态尚未结束;外部实库和正式产品入口验证仍是独立门禁。 + + +## 2026年10月3日21:00 本机接入边界 + +- [✓] 最新底层执行器完整 Linux 回归已通过。 +- [~] 准备对象只保留在本机会话,终端只能取得令牌、处置编号和脱敏摘要;执行使用会话对象及对端身份,不能接受客户端自建意图或身份。 +- [ ] 三类动作必须明确展示风险并确认;响应丢失只能按处置编号查询,不自动重放。协议、终端和正式包尚待验证。 + + ## 2026年10月3日20:52 确认和执行器实现边界 - [x] 确认对象持有不可公开的准备绑定、令牌摘要和持久请求标识,副本共享原子消费状态;随机令牌不入库。有效期沿用一分钟,失败确认亦消费,不能重放。 diff --git a/doc/部署事务人工核查任务清单.md b/doc/部署事务人工核查任务清单.md index ac70c5a..53f7e92 100644 --- a/doc/部署事务人工核查任务清单.md +++ b/doc/部署事务人工核查任务清单.md @@ -1,5 +1,30 @@ # 部署事务人工核查任务清单 +## 2026年10月3日21:32 本机接入与下一批任务 + +沿用已确认需求,不增加恢复网络端口,不处理 Windows。 + +- [x] R3-F 会话准备、单次执行、持久结果查询;真实对端身份、过期、取消、关闭、管理员变化、错误令牌和跨会话执行拒绝。 +- [x] R3-G 各动作字段互斥、严格响应验证、分片混合载荷拒绝、nonce 轮换和旧请求拒绝。 +- [x] R3-H 终端明确选择三动作、展示风险、完整确认;令牌不输出,断连按处置编号查询而不自动重试。 +- [✓] Linux 三包竞态、HTTP 本机恢复专项竞态、完整静态检查及普通全量通过,包含真实 Unix socket;不是 SSH 文件动作或真实数据库验收。 +- [~] HTTP 全包竞态继续运行,等待权威终态。 +- [ ] R4-D PostgreSQL 隔离实库:严格 TLS、仅回环、自建数据库;验证迁移、三动作、审计故障原子性、幂等冲突与重开占用/查询。 +- [ ] R4-E MySQL 必要执行器实库补充;R5 正式 Linux 包、双机生产闭环和邮件门禁。 + + +## 2026年10月3日21:00 本机协议接入任务 + +本节覆盖下方历史进度,需求沿用已确认范围:只处理 Linux,受保护本机通道,不增加公网恢复入口。 + +- [✓] 最新八份源码的部署竞态、SQLite 五场景、完整静态检查及后端全量通过;任务 `scert-executor-final-20261003-2052.service` 已结束,退出码全部为零。 +- [x] 底层执行器检查点 `bf4caeb` 已提交并推送;根目录未跟踪文件保持不动。 +- [~] R3-F 本机会话接入准备、执行和查询;只取对端身份,确认对象留在服务端,一次尝试即清空。 +- [ ] R3-G 协议字段隔离、严格响应校验、分片混合载荷拒绝、防重放和会话失效测试。 +- [ ] R3-H 管理员终端展示动作风险、事务和处置编号,明确确认后执行;断连不自动重放,支持重新连接后查询。 +- [ ] R4/R5 必要外部数据库、正式 Linux 包、双机真实生产闭环;整体生产验收未通过。 + + ## 2026年10月3日20:52 本轮执行任务 沿用已确认的 Linux 整改范围,不增加公网接口,不处理 Windows。本节优先于下面历史进度。