接入本机人工处置协议和终端并记录Linux回归进度

This commit is contained in:
xuans 2026-10-03 21:33:54 +08:00
parent bf4caebaae
commit 0f2e6bfcce
24 changed files with 1534 additions and 48 deletions

View File

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

View File

@ -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 "未知动作"
}

View File

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

View File

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

View File

@ -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 {

View File

@ -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) {

View File

@ -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)

View File

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

View File

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

View File

@ -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) {

View File

@ -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

View File

@ -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 ||

View File

@ -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

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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)

View File

@ -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 为真实临时数据库。线上业务数据库未清理、正式服务未升级。

View File

@ -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] 一次性确认、记录/提交/回滚执行器、只读脱敏结果查询已编码;执行器共用部署互斥,绑定持久化意图与原子审计,不在失败后切换动作。

View File

@ -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 共同操作门、持久占用、原子开始/结束审计;查询结果不触发重放。

View File

@ -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,生产验收仍未通过。

View File

@ -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] 确认对象持有不可公开的准备绑定、令牌摘要和持久请求标识,副本共享原子消费状态;随机令牌不入库。有效期沿用一分钟,失败确认亦消费,不能重放。

View File

@ -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。本节优先于下面历史进度。