diff --git a/backend/internal/adminconsole/flow.go b/backend/internal/adminconsole/flow.go index 721a37f..51e5cab 100644 --- a/backend/internal/adminconsole/flow.go +++ b/backend/internal/adminconsole/flow.go @@ -57,6 +57,23 @@ func runConnected(ctx context.Context, conn net.Conn, screen terminal) error { return localreset.ErrUnavailable } defer client.Close() + if client.RecoveryAvailable() { + choice, err := screen.Read(ctx, "本机管理:1 重置超级管理员密码;2 只读核查部署事务;其他输入退出", false) + if err != nil { + return err + } + switch strings.TrimSpace(choice) { + case "1": + case "2": + if err := recoveryFlow(ctx, client, screen); err != nil { + _ = screen.Show("部署核查未完成:会话过期、输入无效或现场不可读取。未修改事务或远端文件。请重新启动本机管理。") + return err + } + return nil + default: + return nil + } + } if err := resetFlow(ctx, client, screen); err != nil { _ = screen.Show("密码重置未完成:操作已取消、验证失败、会话过期或保存失败。请检查服务日志后重新启动。") return localreset.ErrProtocol diff --git a/backend/internal/adminconsole/recovery.go b/backend/internal/adminconsole/recovery.go new file mode 100644 index 0000000..efbc534 --- /dev/null +++ b/backend/internal/adminconsole/recovery.go @@ -0,0 +1,132 @@ +package adminconsole + +import ( + "context" + "fmt" + "strconv" + "strings" + "unicode" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type recoveryClient interface { + ListRecoveries(string) (deployment.PendingRecoveryPage, error) + InspectRecovery(string) (deployment.RecoveryInspection, error) +} + +func recoveryFlow(ctx context.Context, client recoveryClient, screen terminal) error { + if err := screen.Show("只读核查:不写入文件、不执行远端命令、不删锁、不改变事务状态。结果是当前采样,不证明历史部署成功。每页显示 5 条。"); err != nil { + return err + } + cursor := "" + for { + if err := ctx.Err(); err != nil { + return err + } + page, err := client.ListRecoveries(cursor) + if err != nil { + return err + } + if len(page.Items) == 0 { + return screen.Show("当前没有更多待核查的部署事务。") + } + for index, item := range page.Items { + line := fmt.Sprintf("%d. 事务:%s;服务器:%s;阶段:%s;状态:%s;历史证据:%s", index+1, + terminalValue(item.ID), terminalValue(item.ServerID), recoveryLabel(item.Phase), recoveryLabel(item.Status), evidenceLabel(item.HistoricalEvidence)) + if err := screen.Show(line); err != nil { + return err + } + } + choice, err := screen.Read(ctx, "输入本页序号进行只读核查;输入 n 查看下一页;输入 q 退出", false) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + switch strings.TrimSpace(choice) { + case "q": + return nil + case "n": + if page.NextCursor == "" { + return screen.Show("已到最后一页;本次未执行任何写入操作。") + } + cursor = page.NextCursor + continue + } + index, err := strconv.Atoi(strings.TrimSpace(choice)) + if err != nil || index < 1 || index > len(page.Items) { + return localreset.ErrProtocol + } + result, err := client.InspectRecovery(page.Items[index-1].ID) + if err != nil { + return err + } + return showRecovery(screen, result) + } +} + +func showRecovery(screen terminal, result deployment.RecoveryInspection) error { + lines := []string{ + fmt.Sprintf("事务:%s;服务器:%s;采样时间:%s", terminalValue(result.TransactionID), terminalValue(result.ServerID), result.ObservedAt.UTC().Format("2006-01-02 15:04:05 UTC")), + "历史证据:" + evidenceLabel(result.HistoricalEvidence), + } + for _, file := range result.Files { + exists := "不存在" + if file.Exists { + exists = "存在" + } + lines = append(lines, fmt.Sprintf("%s:%s;%s;比较:%s;摘要:%s", recoveryLabel(file.Kind), terminalValue(file.Path), exists, recoveryLabel(file.Comparison), terminalValue(file.Digest))) + } + for _, lock := range result.Locks { + exists := "不存在" + if lock.Exists { + exists = "存在" + } + lines = append(lines, fmt.Sprintf("锁:%s;%s;归属:%s", terminalValue(lock.Path), exists, recoveryLabel(lock.Ownership))) + } + lines = append(lines, "只读采样已完成,未提交或回滚事务;缺少历史证据不能视为历史部署成功。") + for _, line := range lines { + if err := screen.Show(line); err != nil { + return err + } + } + return nil +} + +func evidenceLabel(present bool) string { + if present { + return "记录已保存(不代表本次部署通过验收)" + } + return "缺失,禁止据此认定历史成功" +} + +func terminalValue(value string) string { + var output strings.Builder + for _, c := range value { + if unicode.IsControl(c) || unicode.Is(unicode.Cf, c) || c == '\u2028' || c == '\u2029' { + output.WriteString(fmt.Sprintf("\\u%04x", c)) + } else { + output.WriteRune(c) + } + } + return output.String() +} + +func recoveryLabel(value string) string { + labels := map[string]string{ + "unrecorded": "无历史摘要", "unproven": "归属无法证明", + "pending": "待处理", "recovery_required": "需要恢复", "locked": "已锁定", "acquiring": "取得锁中", + "uploaded": "已上传", "replacing": "替换中", "installed": "已安装", "reload_attempted": "已尝试重载", + "committing": "提交中", "commit_verified": "提交已核验", "rollback_verified": "回滚已核验", + "committed": "已提交", "rolled_back": "已回滚", "target": "目标文件", "temporary": "临时文件", "backup": "备份文件", + "match": "匹配", "mismatch": "不匹配", "missing": "缺失", "unknown": "未知", "unverifiable": "无法验证", + "owned": "属于本事务", "foreign": "属于其他事务", "ownerless": "缺少归属", "absent": "不存在", + } + if label, ok := labels[value]; ok { + return label + } + return "未识别(" + terminalValue(value) + ")" +} diff --git a/backend/internal/adminconsole/recovery_linux_test.go b/backend/internal/adminconsole/recovery_linux_test.go new file mode 100644 index 0000000..771d03f --- /dev/null +++ b/backend/internal/adminconsole/recovery_linux_test.go @@ -0,0 +1,98 @@ +//go:build linux + +package adminconsole + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type consoleIPCSession struct { + selected atomic.Int32 + verified atomic.Int32 + resets atomic.Int32 + inspected atomic.Int32 +} + +func (*consoleIPCSession) Methods() []localreset.Method { + return []localreset.Method{{ID: "email", Label: "绑定邮箱验证码"}} +} +func (s *consoleIPCSession) Select(context.Context, string) error { s.selected.Add(1); return nil } +func (s *consoleIPCSession) Verify(context.Context, string) error { s.verified.Add(1); return nil } +func (s *consoleIPCSession) Reset(context.Context, string) error { s.resets.Add(1); return nil } +func (*consoleIPCSession) Close() {} +func (*consoleIPCSession) RecoveryAvailable() bool { return true } +func (*consoleIPCSession) ListRecoveries(context.Context, string) (deployment.PendingRecoveryPage, error) { + return deployment.PendingRecoveryPage{Items: []deployment.PendingRecovery{{ID: "tx-1", ServerID: "server-1"}}}, nil +} +func (s *consoleIPCSession) InspectRecovery(_ context.Context, id string) (deployment.RecoveryInspection, error) { + s.inspected.Add(1) + return deployment.RecoveryInspection{TransactionID: id, ServerID: "server-1", ObservedAt: time.Now(), + Files: make([]deployment.RecoveryFileObservation, 9), Locks: make([]deployment.RecoveryLockObservation, 3)}, nil +} + +func TestRecoveryConsoleOverProtectedUnixSocket(t *testing.T) { + for _, action := range []string{"inspect", "reset", "quit"} { + t.Run(action, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "control.sock") + server, err := localreset.Listen(path) + if err != nil { + t.Fatal(err) + } + defer server.Close() + info, err := os.Lstat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("unprotected socket") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + session := &consoleIPCSession{} + done := make(chan error, 1) + go func() { + done <- server.Serve(ctx, func(context.Context, localreset.Peer) (localreset.Session, error) { return session, nil }) + }() + t.Cleanup(func() { + cancel() + if err := <-done; err != nil { + t.Error(err) + } + }) + conn, err := localreset.Dial(ctx, path, uint32(os.Geteuid())) + if err != nil { + t.Fatal(err) + } + screen := &recoveryTestScreen{} + switch action { + case "inspect": + screen.values = []string{"2", "1"} + case "reset": + screen.values = []string{"1", "1", "01234567", "NewPass123", "NewPass123"} + case "quit": + screen.values = []string{"q"} + } + if err := runConnected(ctx, conn, screen); err != nil { + t.Fatal(err) + } + if action == "reset" { + if session.resets.Load() != 1 || session.selected.Load() != 1 || session.verified.Load() != 1 || session.inspected.Load() != 0 { + t.Fatal("reset verification flow changed") + } + } else { + if session.resets.Load() != 0 || session.selected.Load() != 0 || session.verified.Load() != 0 { + t.Fatal("non-reset menu invoked reset") + } + } + if action == "inspect" && (session.inspected.Load() != 1 || !strings.Contains(strings.Join(screen.lines, "|"), "只读采样已完成")) { + t.Fatal("inspection did not reach console") + } + }) + } +} diff --git a/backend/internal/adminconsole/recovery_test.go b/backend/internal/adminconsole/recovery_test.go new file mode 100644 index 0000000..a3f215d --- /dev/null +++ b/backend/internal/adminconsole/recovery_test.go @@ -0,0 +1,94 @@ +package adminconsole + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +type recoveryTestClient struct { + cursors []string + inspected []string + err error +} + +func (c *recoveryTestClient) ListRecoveries(cursor string) (deployment.PendingRecoveryPage, error) { + c.cursors = append(c.cursors, cursor) + if cursor == "" { + return deployment.PendingRecoveryPage{Items: []deployment.PendingRecovery{{ID: "tx-1", ServerID: "server-1"}}, NextCursor: "tx-1"}, nil + } + return deployment.PendingRecoveryPage{Items: []deployment.PendingRecovery{{ID: "tx-2", ServerID: "server-2"}}}, nil +} + +func (c *recoveryTestClient) InspectRecovery(id string) (deployment.RecoveryInspection, error) { + c.inspected = append(c.inspected, id) + return deployment.RecoveryInspection{TransactionID: id, ServerID: "server-2", ObservedAt: time.Now(), + Files: []deployment.RecoveryFileObservation{{Path: "/ssl/\x1b[2J\n\u202efile", Kind: "target", Comparison: "unrecorded"}}}, c.err +} + +type recoveryTestScreen struct { + testTerminalInput + lines []string + showError bool +} + +func (s *recoveryTestScreen) Show(line string) error { + if s.showError { + return errors.New("display-unavailable") + } + s.lines = append(s.lines, line) + return nil +} + +func TestRecoveryConsolePaginationAndSafeDisplay(t *testing.T) { + client := &recoveryTestClient{} + screen := &recoveryTestScreen{testTerminalInput: testTerminalInput{values: []string{"n", "1"}}} + if err := recoveryFlow(t.Context(), client, screen); err != nil { + t.Fatal(err) + } + if len(client.cursors) != 2 || client.cursors[1] != "tx-1" || len(client.inspected) != 1 || client.inspected[0] != "tx-2" { + t.Fatal("pagination or selection incorrect") + } + output := strings.Join(screen.lines, "|") + if strings.ContainsAny(output, "\x1b\n\u202e") || !strings.Contains(output, "\\u001b") || + !strings.Contains(output, "缺失,禁止据此认定历史成功") || !strings.Contains(output, "未提交或回滚事务") { + t.Fatal("unsafe display or missing evidence warning") + } +} + +func TestRecoveryConsoleCancellationAndFailures(t *testing.T) { + for _, scenario := range []string{"quit", "invalid", "cancelled", "display-error", "inspection-error"} { + t.Run(scenario, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := &recoveryTestClient{} + screen := &recoveryTestScreen{testTerminalInput: testTerminalInput{values: []string{"1"}}} + switch scenario { + case "quit": + screen.values[0] = "q" + case "invalid": + screen.values[0] = "0" + case "cancelled": + screen.readHook = func(int) { cancel() } + case "display-error": + screen.showError = true + case "inspection-error": + client.err = errors.New("unavailable") + } + err := recoveryFlow(ctx, client, screen) + if (err == nil) != (scenario == "quit") { + t.Fatalf("unexpected result: %v", err) + } + if scenario != "inspection-error" && len(client.inspected) != 0 { + t.Fatal("cancelled operation inspected") + } + if strings.Contains(strings.Join(screen.lines, "|"), "只读采样已完成") { + t.Fatal("failure reported success") + } + }) + } +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 75f44c4..ad5787f 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -177,6 +177,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string RetryInterval: time.Duration(cfg.ACME.RenewalRetryIntervalMins) * time.Minute, }, management) server = httpapi.New(httpapi.Dependencies{ + RecoveryInspection: deploymentService, Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore}, ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver), ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver), diff --git a/backend/internal/deployment/recovery_list.go b/backend/internal/deployment/recovery_list.go new file mode 100644 index 0000000..32ae984 --- /dev/null +++ b/backend/internal/deployment/recovery_list.go @@ -0,0 +1,78 @@ +package deployment + +import ( + "context" + "sort" + "strings" + "time" + "unicode" +) + +const RecoveryPageSize = 5 + +type PendingRecovery struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + Phase string `json:"phase"` + Status string `json:"status"` + HistoricalEvidence bool `json:"historical_evidence"` +} + +type PendingRecoveryPage struct { + Items []PendingRecovery `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// ListPendingRecoveries 只返回有界摘要,不返回现场路径、错误详情、凭据或恢复证据内容。 +// 游标按事务标识排序;分页期间现场可能变化,不提供一致性快照承诺。 +func (s *Service) ListPendingRecoveries(ctx context.Context, after string) (PendingRecoveryPage, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := ctx.Err(); err != nil { + return PendingRecoveryPage{}, err + } + if s == nil || s.journal == nil { + return PendingRecoveryPage{}, ErrRecoveryInspectionUnavailable + } + if after != "" && !validInspectionID(after) { + return PendingRecoveryPage{}, ErrRecoveryInspectionRecord + } + records, err := s.journal.ListPendingDeploymentTransactions(ctx) + if err != nil { + return PendingRecoveryPage{}, inspectionError(ctx, ErrRecoveryInspectionUnavailable) + } + if err := ctx.Err(); err != nil { + return PendingRecoveryPage{}, err + } + items := make([]PendingRecovery, 0, len(records)) + seen := make(map[string]bool, len(records)) + for _, record := range records { + if err := ctx.Err(); err != nil { + return PendingRecoveryPage{}, err + } + if !validInspectionID(record.ID) || seen[record.ID] || !validRecoverySummary(record.ServerID) || + (record.Phase != "" && !validRecoverySummary(record.Phase)) || + (record.Status != TransactionStatusPending && record.Status != TransactionStatusRecoveryRequired) { + return PendingRecoveryPage{}, ErrRecoveryInspectionRecord + } + seen[record.ID] = true + if record.ID > after { + items = append(items, PendingRecovery{ID: record.ID, ServerID: record.ServerID, + Phase: record.Phase, Status: record.Status, HistoricalEvidence: record.Evidence != nil}) + } + } + sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + if err := ctx.Err(); err != nil { + return PendingRecoveryPage{}, err + } + page := PendingRecoveryPage{Items: items} + if len(items) > RecoveryPageSize { + page.Items = items[:RecoveryPageSize] + page.NextCursor = page.Items[len(page.Items)-1].ID + } + return page, nil +} + +func validRecoverySummary(value string) bool { + return len(value) > 0 && len(value) <= 160 && !strings.ContainsFunc(value, unicode.IsControl) +} diff --git a/backend/internal/deployment/recovery_list_cancel_test.go b/backend/internal/deployment/recovery_list_cancel_test.go new file mode 100644 index 0000000..c5591d9 --- /dev/null +++ b/backend/internal/deployment/recovery_list_cancel_test.go @@ -0,0 +1,35 @@ +package deployment + +import ( + "context" + "errors" + "testing" +) + +type cancelledRecoveryListJournal struct { + memoryTransactionJournal + cancel context.CancelFunc +} + +func (j *cancelledRecoveryListJournal) ListPendingDeploymentTransactions(context.Context) ([]TransactionRecord, error) { + j.cancel() + return nil, nil +} + +func TestRecoveryListCancellationAfterEmptyRead(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + journal := &cancelledRecoveryListJournal{cancel: cancel} + page, err := NewServiceWithJournal(nil, journal).ListPendingRecoveries(ctx, "") + if !errors.Is(err, context.Canceled) || page.Items != nil { + t.Fatal("cancelled empty result accepted") + } +} + +func TestRecoveryListLegacyWithoutPhase(t *testing.T) { + journal := &memoryTransactionJournal{pending: []TransactionRecord{{ID: "legacy", ServerID: "server-1", Status: TransactionStatusRecoveryRequired}}} + page, err := NewServiceWithJournal(nil, journal).ListPendingRecoveries(t.Context(), "") + if err != nil || len(page.Items) != 1 || page.Items[0].HistoricalEvidence { + t.Fatal("legacy record omitted or evidence invented") + } +} diff --git a/backend/internal/deployment/recovery_list_test.go b/backend/internal/deployment/recovery_list_test.go new file mode 100644 index 0000000..5f40324 --- /dev/null +++ b/backend/internal/deployment/recovery_list_test.go @@ -0,0 +1,83 @@ +package deployment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" +) + +func TestRecoveryListPaginationAndRedaction(t *testing.T) { + journal := &memoryTransactionJournal{} + for i := 12; i > 0; i-- { + journal.pending = append(journal.pending, TransactionRecord{ + ID: fmt.Sprintf("tx-%02d", i), ServerID: "server-1", Phase: TransactionPhaseInstalled, + Status: TransactionStatusRecoveryRequired, Message: "secret-message", ServerName: "secret-name", + Paths: []string{"secret-path"}, Evidence: &RecoveryEvidence{}, + }) + } + factory := &inspectionFactory{} + service := NewServiceWithJournal(factory, journal) + cursor := "" + var ids []string + for _, size := range []int{5, 5, 2} { + page, err := service.ListPendingRecoveries(t.Context(), cursor) + if err != nil || len(page.Items) != size { + t.Fatalf("page: %+v, %v", page, err) + } + data, _ := json.Marshal(page) + if strings.Contains(string(data), "secret-") || len(data) > 16<<10 { + t.Fatal("summary leaked data or exceeded frame") + } + for _, item := range page.Items { + ids = append(ids, item.ID) + if !item.HistoricalEvidence { + t.Fatal("evidence presence lost") + } + } + cursor = page.NextCursor + } + if cursor != "" || len(ids) != 12 || ids[0] != "tx-01" || ids[11] != "tx-12" || + factory.calls != 0 || len(journal.updated) != 0 || len(journal.finished) != 0 { + t.Fatalf("list mutated state, connected, or lost ordering: %v", ids) + } +} + +func TestRecoveryListRejectsInvalidRecords(t *testing.T) { + for _, scenario := range []string{"duplicate", "id", "server", "phase", "status", "cursor", "cancelled"} { + t.Run(scenario, func(t *testing.T) { + record := TransactionRecord{ID: "tx-1", ServerID: "server-1", Phase: TransactionPhaseInstalled, Status: TransactionStatusPending} + journal := &memoryTransactionJournal{} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + cursor := "" + switch scenario { + case "duplicate": + journal.pending = append(journal.pending, record) + case "id": + record.ID = "../bad" + case "server": + record.ServerID = strings.Repeat("x", 161) + case "phase": + record.Phase = "\x1b[2J" + case "status": + record.Status = TransactionStatusCommitted + case "cursor": + cursor = "../bad" + case "cancelled": + cancel() + } + journal.pending = append(journal.pending, record) + page, err := NewServiceWithJournal(nil, journal).ListPendingRecoveries(ctx, cursor) + if err == nil || !reflect.DeepEqual(page, PendingRecoveryPage{}) { + t.Fatal("invalid list returned data") + } + if scenario == "cancelled" && !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + }) + } +} diff --git a/backend/internal/httpapi/local_recovery.go b/backend/internal/httpapi/local_recovery.go new file mode 100644 index 0000000..1b84f1c --- /dev/null +++ b/backend/internal/httpapi/local_recovery.go @@ -0,0 +1,55 @@ +package httpapi + +import ( + "context" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type localRecoveryReader interface { + ListPendingRecoveries(context.Context, string) (deployment.PendingRecoveryPage, error) + InspectPending(context.Context, string, func(context.Context, string) (deployment.Server, error)) (deployment.RecoveryInspection, error) +} + +func (r *localResetSession) RecoveryAvailable() bool { + return r.server.recoveryInspection != nil && r.server.servers != nil +} + +// enterRecovery 保留会话有效期和管理员配置校验,进入核查后不能切换到密码重置。 +func (r *localResetSession) enterRecovery(ctx context.Context) bool { + if (r.stage != 0 && r.stage != 3) || !r.current(ctx) || !r.RecoveryAvailable() { + r.stage = -1 + return false + } + r.stage = 3 + return true +} + +func (r *localResetSession) ListRecoveries(ctx context.Context, cursor string) (deployment.PendingRecoveryPage, error) { + r.mu.Lock() + defer r.mu.Unlock() + if !r.enterRecovery(ctx) { + return deployment.PendingRecoveryPage{}, localreset.ErrProtocol + } + page, err := r.server.recoveryInspection.ListPendingRecoveries(ctx, cursor) + if err != nil || !r.current(ctx) { + r.stage = -1 + return deployment.PendingRecoveryPage{}, localreset.ErrUnavailable + } + return page, nil +} + +func (r *localResetSession) InspectRecovery(ctx context.Context, id string) (deployment.RecoveryInspection, error) { + r.mu.Lock() + defer r.mu.Unlock() + if !r.enterRecovery(ctx) { + return deployment.RecoveryInspection{}, localreset.ErrProtocol + } + result, err := r.server.recoveryInspection.InspectPending(ctx, id, r.server.servers.GetServerByID) + if err != nil || !r.current(ctx) { + r.stage = -1 + return deployment.RecoveryInspection{}, localreset.ErrUnavailable + } + return result, nil +} diff --git a/backend/internal/httpapi/local_recovery_test.go b/backend/internal/httpapi/local_recovery_test.go new file mode 100644 index 0000000..34d5255 --- /dev/null +++ b/backend/internal/httpapi/local_recovery_test.go @@ -0,0 +1,180 @@ +package httpapi + +import ( + "context" + "errors" + "strings" + "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 localRecoveryServerRepo struct { + repository.ServerRepository +} + +func (localRecoveryServerRepo) GetServerByID(_ context.Context, id string) (deployment.Server, error) { + return deployment.Server{ID: id}, nil +} + +type testLocalRecoveryReader struct { + lists int + inspects int + hook func() + err error +} + +func (r *testLocalRecoveryReader) ListPendingRecoveries(context.Context, string) (deployment.PendingRecoveryPage, error) { + r.lists++ + if r.hook != nil { + r.hook() + } + return deployment.PendingRecoveryPage{Items: []deployment.PendingRecovery{{ID: "tx-1"}}}, r.err +} + +func (r *testLocalRecoveryReader) InspectPending(ctx context.Context, id string, resolve func(context.Context, string) (deployment.Server, error)) (deployment.RecoveryInspection, error) { + r.inspects++ + server, err := resolve(ctx, "server-1") + if err != nil { + return deployment.RecoveryInspection{}, err + } + if r.hook != nil { + r.hook() + } + return deployment.RecoveryInspection{TransactionID: id, ServerID: server.ID}, r.err +} + +func newLocalRecoverySession(t *testing.T, server *Server, reader *testLocalRecoveryReader) *localResetSession { + t.Helper() + server.recoveryInspection = reader + server.servers = localRecoveryServerRepo{} + session, err := server.localResetFactory(func(context.Context, string, string) error { + t.Error("read-only inspection attempted to send a reset code") + return errors.New("unexpected mail") + })(t.Context(), localreset.Peer{Identity: "local-admin"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(session.Close) + return session.(*localResetSession) +} + +func TestLocalRecoveryReadOnlyAndResetIsolation(t *testing.T) { + server := newLocalResetTestServer(t) + oldHash := server.config.Admin.PasswordHash + reader := &testLocalRecoveryReader{} + session := newLocalRecoverySession(t, server, reader) + if !session.RecoveryAvailable() { + t.Fatal("missing capability") + } + page, err := session.ListRecoveries(t.Context(), "") + if err != nil || len(page.Items) != 1 { + t.Fatal(err) + } + result, err := session.InspectRecovery(t.Context(), "tx-1") + if err != nil || result.TransactionID != "tx-1" || result.ServerID != "server-1" { + t.Fatalf("inspection: %+v %v", result, err) + } + if err := session.Select(t.Context(), "email"); err == nil { + t.Fatal("recovery switched to password reset") + } + if err := session.Verify(t.Context(), "12345678"); err == nil { + t.Fatal("recovery verified reset") + } + if err := session.Reset(t.Context(), "NewPass123!"); err == nil { + t.Fatal("recovery reset password") + } + if reader.lists != 1 || reader.inspects != 1 { + t.Fatal("unexpected business calls") + } + assertLocalResetUnchanged(t, server, oldHash) +} + +func TestLocalRecoveryRejectsInvalidSession(t *testing.T) { + for _, scenario := range []string{"expired", "cancelled", "changed-admin", "closed", "reset-stage", "missing-reader", "missing-repository"} { + t.Run(scenario, func(t *testing.T) { + server := newLocalResetTestServer(t) + reader := &testLocalRecoveryReader{} + session := newLocalRecoverySession(t, server, reader) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch scenario { + 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 "missing-reader": + server.recoveryInspection = nil + case "missing-repository": + server.servers = nil + } + if _, err := session.ListRecoveries(ctx, ""); err == nil { + t.Fatal("invalid session listed transactions") + } + if _, err := session.InspectRecovery(ctx, "tx-1"); err == nil { + t.Fatal("invalid session inspected files") + } + if reader.lists != 0 || reader.inspects != 0 { + t.Fatal("invalid session reached service") + } + }) + } +} + +func TestLocalRecoveryDiscardsStaleOrFailedResults(t *testing.T) { + for _, action := range []string{"list", "inspect"} { + for _, failure := range []string{"changed-admin", "cancelled", "read-error"} { + t.Run(action+"/"+failure, func(t *testing.T) { + server := newLocalResetTestServer(t) + reader := &testLocalRecoveryReader{} + session := newLocalRecoverySession(t, server, reader) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch failure { + case "changed-admin": + reader.hook = func() { + server.configMu.Lock() + defer server.configMu.Unlock() + server.config.Admin.Email = "changed@example.test" + } + case "cancelled": + reader.hook = cancel + case "read-error": + reader.err = errors.New("secret-error") + } + if action == "list" { + page, err := session.ListRecoveries(ctx, "") + if err != localreset.ErrUnavailable || len(page.Items) != 0 { + t.Fatal("stale list leaked") + } + } else { + result, err := session.InspectRecovery(ctx, "tx-1") + if err != localreset.ErrUnavailable || result.TransactionID != "" { + t.Fatal("stale inspection leaked") + } + } + if session.stage != -1 { + t.Fatal("failed session remains reusable") + } + }) + } + } +} + +func TestLocalRecoveryNotRegisteredAsHTTPRoute(t *testing.T) { + server := newLocalResetTestServer(t) + for _, route := range server.router.Routes() { + if strings.Contains(strings.ToLower(route.Path), "recovery") || strings.Contains(route.Handler, "InspectRecovery") || strings.Contains(route.Handler, "ListRecoveries") { + t.Fatalf("local recovery exposed over HTTP: %+v", route) + } + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 90cc284..d78b82c 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -53,6 +53,7 @@ type Dependencies struct { ProjectBindings repository.ProjectBindingRepository ProjectDomains repository.ProjectDomainRepository DeploymentFactory deployment.ClientFactory + RecoveryInspection localRecoveryReader SetupDNS setupDNSVerifier Secrets setupSecretWriter Firewall firewall.Manager @@ -97,6 +98,7 @@ type Server struct { projectBindings repository.ProjectBindingRepository projectDomains repository.ProjectDomainRepository deploymentFactory deployment.ClientFactory + recoveryInspection localRecoveryReader setupDNS setupDNSVerifier secrets setupSecretWriter firewall firewall.Manager @@ -171,6 +173,7 @@ func New(deps Dependencies) *Server { projectBindings: deps.ProjectBindings, projectDomains: deps.ProjectDomains, deploymentFactory: deps.DeploymentFactory, + recoveryInspection: deps.RecoveryInspection, issueCertificate: deps.IssueCertificate, issueSetupCertificate: deps.IssueSetupCertificate, rollbackSetupCertificate: deps.RollbackSetupCertificate, diff --git a/backend/internal/localreset/client.go b/backend/internal/localreset/client.go index 04eb0eb..adb884f 100644 --- a/backend/internal/localreset/client.go +++ b/backend/internal/localreset/client.go @@ -9,14 +9,15 @@ import ( ) type Client struct { - conn net.Conn - reader *bufio.Reader - nonce string - methods []Method - stage int - stop func() bool - mu sync.Mutex - once sync.Once + conn net.Conn + reader *bufio.Reader + nonce string + methods []Method + recoveryAvailable bool + stage int + stop func() bool + mu sync.Mutex + once sync.Once } // Begin 只接受已通过 Dial 校验身份的连接;调用过程不会通过参数或文件传递密码。 @@ -36,12 +37,13 @@ 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 { + len(response.Methods) == 0 || len(response.Methods) > 8 || response.RecoveryPage != nil || response.Inspection != nil { _ = client.Close() return nil, ErrProtocol } client.nonce = response.Nonce client.methods = response.Methods + client.recoveryAvailable = response.RecoveryAvailable return client, nil } @@ -75,6 +77,11 @@ func (c *Client) exchange(request Request, stage int) error { _ = c.Close() return ErrProtocol } + if response.RecoveryAvailable || response.RecoveryPage != nil || response.Inspection != nil { + c.stage = -1 + _ = c.Close() + return ErrProtocol + } if stage == 2 { c.stage = -1 _ = c.Close() diff --git a/backend/internal/localreset/protocol.go b/backend/internal/localreset/protocol.go index 406d4d4..65e9e23 100644 --- a/backend/internal/localreset/protocol.go +++ b/backend/internal/localreset/protocol.go @@ -13,6 +13,8 @@ import ( "net" "sync" "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" ) const ( @@ -38,20 +40,25 @@ 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"` + 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"` } 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"` + 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"` } // Session 由主服务创建,身份验证及密码持久化仍由主服务负责。 @@ -214,6 +221,8 @@ func (s *Server) handle(parent context.Context, conn net.Conn, factory Factory) return } reader := bufio.NewReaderSize(conn, MaxFrameSize+1) + recovery, hasRecovery := session.(RecoverySession) + hasRecovery = hasRecovery && recovery.RecoveryAvailable() for index, action := range []string{"select", "verify", "reset"} { nonce, err := newNonce() if err != nil { @@ -222,12 +231,17 @@ func (s *Server) handle(parent context.Context, conn net.Conn, factory Factory) response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: nonce} if index == 0 { response.Methods = methods + response.RecoveryAvailable = hasRecovery } if writeFrame(conn, response) != nil { return } var request Request err = readFrame(reader, &request) + if err == nil && index == 0 && hasRecovery && validRecoveryRequest(request, nonce) { + s.handleRecovery(ctx, conn, reader, peer, recovery, request) + return + } if err != nil || ctx.Err() != nil || !validRequest(request, nonce, action) { s.failed(peer.Identity) _ = writeFrame(conn, Response{Version: Version, Code: "INVALID_REQUEST"}) @@ -261,6 +275,9 @@ 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 != "" { + return false + } if request.Version != Version || request.Action != action || subtle.ConstantTimeCompare([]byte(request.Nonce), []byte(nonce)) != 1 { return false diff --git a/backend/internal/localreset/recovery.go b/backend/internal/localreset/recovery.go new file mode 100644 index 0000000..330c2bf --- /dev/null +++ b/backend/internal/localreset/recovery.go @@ -0,0 +1,104 @@ +package localreset + +import ( + "context" + "crypto/subtle" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +const MaxRecoveryRequests = 32 + +// RecoverySession 仅供受保护的本机连接使用,与邮箱密码重置阶段互斥。 +type RecoverySession interface { + RecoveryAvailable() bool + ListRecoveries(context.Context, string) (deployment.PendingRecoveryPage, error) + InspectRecovery(context.Context, string) (deployment.RecoveryInspection, error) +} + +func validRecoveryRequest(request Request, nonce string) bool { + if request.Version != Version || subtle.ConstantTimeCompare([]byte(request.Nonce), []byte(nonce)) != 1 || + request.Method != "" || request.Code != "" || request.Password != "" { + return false + } + switch request.Action { + case "recovery_list": + return request.TransactionID == "" && (request.Cursor == "" || validRecoveryID(request.Cursor)) + case "recovery_inspect": + return request.Cursor == "" && validRecoveryID(request.TransactionID) + } + return false +} + +func validRecoveryID(value string) bool { + if len(value) == 0 || len(value) > 160 || value == "." || value == ".." { + return false + } + for _, c := range value { + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' { + return false + } + } + return true +} + +func (c *Client) RecoveryAvailable() bool { return c.recoveryAvailable } + +func (c *Client) ListRecoveries(cursor string) (deployment.PendingRecoveryPage, error) { + response, err := c.recoveryExchange(Request{Action: "recovery_list", Cursor: cursor}) + if err != nil { + return deployment.PendingRecoveryPage{}, err + } + return *response.RecoveryPage, nil +} + +func (c *Client) InspectRecovery(id string) (deployment.RecoveryInspection, error) { + response, err := c.recoveryExchange(Request{Action: "recovery_inspect", TransactionID: id}) + if err != nil { + return deployment.RecoveryInspection{}, err + } + return *response.Inspection, nil +} + +func (c *Client) recoveryExchange(request Request) (Response, error) { + c.mu.Lock() + defer c.mu.Unlock() + fail := func() (Response, error) { + c.stage = -1 + _ = c.Close() + return Response{}, ErrProtocol + } + if !c.recoveryAvailable || (c.stage != 0 && c.stage != 3) { + 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 { + return fail() + } + if request.Action == "recovery_list" { + if response.RecoveryPage == nil || response.Inspection != nil || len(response.RecoveryPage.Items) > deployment.RecoveryPageSize { + return fail() + } + previous := request.Cursor + for _, item := range response.RecoveryPage.Items { + if !validRecoveryID(item.ID) || item.ID <= previous { + return fail() + } + previous = item.ID + } + if cursor := response.RecoveryPage.NextCursor; cursor != "" && + (len(response.RecoveryPage.Items) != deployment.RecoveryPageSize || cursor != previous) { + return fail() + } + } else if response.Inspection == nil || response.RecoveryPage != nil || response.Inspection.TransactionID != request.TransactionID || + len(response.Inspection.Files) != 9 || len(response.Inspection.Locks) != 3 || response.Inspection.ObservedAt.IsZero() { + return fail() + } + c.nonce, c.stage = response.Nonce, 3 + return response, nil +} diff --git a/backend/internal/localreset/recovery_response_test.go b/backend/internal/localreset/recovery_response_test.go new file mode 100644 index 0000000..b076b8a --- /dev/null +++ b/backend/internal/localreset/recovery_response_test.go @@ -0,0 +1,116 @@ +package localreset + +import ( + "bufio" + "net" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +func TestRecoveryClientRejectsMalformedResponses(t *testing.T) { + for _, scenario := range []string{"nonce", "methods", "capability", "missing-page", "large-page", "duplicate-id", "backward-cursor", "mixed-result", "wrong-id", "partial-files", "partial-locks", "missing-time"} { + t.Run(scenario, func(t *testing.T) { + serverConn, conn := net.Pipe() + defer conn.Close() + defer serverConn.Close() + _ = serverConn.SetDeadline(time.Now().Add(time.Second)) + done := make(chan struct{}) + go func() { + defer close(done) + defer serverConn.Close() + nonce := strings.Repeat("a", 64) + if writeFrame(serverConn, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: nonce, + Methods: []Method{{ID: "email"}}, RecoveryAvailable: true}) != nil { + return + } + var request Request + if readFrame(bufio.NewReaderSize(serverConn, MaxFrameSize+1), &request) != nil { + return + } + response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("b", 64)} + if request.Action == "recovery_list" { + response.RecoveryPage = &deployment.PendingRecoveryPage{Items: []deployment.PendingRecovery{{ID: "tx-1"}}} + } else { + response.Inspection = &deployment.RecoveryInspection{TransactionID: "tx-1", ObservedAt: time.Now(), + Files: make([]deployment.RecoveryFileObservation, 9), Locks: make([]deployment.RecoveryLockObservation, 3)} + } + switch scenario { + case "nonce": + response.Nonce = nonce + case "methods": + response.Methods = []Method{{ID: "email"}} + case "capability": + response.RecoveryAvailable = true + case "missing-page": + response.RecoveryPage = nil + case "large-page": + response.RecoveryPage.Items = make([]deployment.PendingRecovery, 6) + case "duplicate-id": + response.RecoveryPage.Items = append(response.RecoveryPage.Items, response.RecoveryPage.Items[0]) + case "backward-cursor": + response.RecoveryPage.NextCursor = "tx-0" + case "mixed-result": + response.Inspection = &deployment.RecoveryInspection{TransactionID: "tx-1"} + case "wrong-id": + response.Inspection.TransactionID = "tx-2" + case "partial-files": + response.Inspection.Files = response.Inspection.Files[:8] + case "partial-locks": + response.Inspection.Locks = response.Inspection.Locks[:2] + case "missing-time": + response.Inspection.ObservedAt = time.Time{} + } + _ = writeFrame(serverConn, response) + }() + client, err := Begin(t.Context(), conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + switch scenario { + case "wrong-id", "partial-files", "partial-locks", "missing-time": + result, err := client.InspectRecovery("tx-1") + if err == nil || result.TransactionID != "" { + t.Fatal("malformed observation accepted") + } + default: + page, err := client.ListRecoveries("") + if err == nil || len(page.Items) != 0 { + t.Fatal("malformed list accepted") + } + } + <-done + }) + } +} + +func TestRecoveryProtocolCannotEnterAfterResetSelection(t *testing.T) { + session := &recoveryTestSession{} + _, conn, done := recoveryConnection(t, session) + reader := bufio.NewReaderSize(conn, MaxFrameSize+1) + var response Response + if err := readFrame(reader, &response); err != nil { + t.Fatal(err) + } + if err := writeFrame(conn, Request{Version: Version, Nonce: response.Nonce, Action: "select", Method: "recovery"}); err != nil { + t.Fatal(err) + } + response = Response{} + if err := readFrame(reader, &response); err != nil { + t.Fatal(err) + } + if err := writeFrame(conn, Request{Version: Version, Nonce: response.Nonce, Action: "recovery_list"}); err != nil { + t.Fatal(err) + } + response = Response{} + if err := readFrame(reader, &response); err != nil || response.OK { + t.Fatal("reset switched into inspection") + } + <-done + if session.lists.Load() != 0 || session.inspects.Load() != 0 || session.resets.Load() != 0 { + t.Fatal("invalid cross-flow reached service") + } +} diff --git a/backend/internal/localreset/recovery_server.go b/backend/internal/localreset/recovery_server.go new file mode 100644 index 0000000..1c1baee --- /dev/null +++ b/backend/internal/localreset/recovery_server.go @@ -0,0 +1,50 @@ +package localreset + +import ( + "bufio" + "context" + "errors" + "io" + "net" +) + +func (s *Server) handleRecovery(ctx context.Context, conn net.Conn, reader *bufio.Reader, peer Peer, session RecoverySession, request Request) { + for count := 0; count < MaxRecoveryRequests; count++ { + if ctx.Err() != nil { + return + } + nonce, err := newNonce() + if err != nil { + return + } + response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: nonce} + switch request.Action { + case "recovery_list": + page, readErr := session.ListRecoveries(ctx, request.Cursor) + err, response.RecoveryPage = readErr, &page + case "recovery_inspect": + inspection, readErr := session.InspectRecovery(ctx, request.TransactionID) + err, response.Inspection = readErr, &inspection + default: + err = ErrProtocol + } + if err != nil || ctx.Err() != nil { + s.failed(peer.Identity) + _ = writeFrame(conn, Response{Version: Version, Code: "REJECTED"}) + return + } + if writeFrame(conn, response) != nil { + return + } + request = Request{} + if _, err := reader.Peek(1); errors.Is(err, io.EOF) { + return + } + if readFrame(reader, &request) != nil || !validRecoveryRequest(request, nonce) { + s.failed(peer.Identity) + _ = writeFrame(conn, Response{Version: Version, Code: "INVALID_REQUEST"}) + return + } + } + _ = writeFrame(conn, Response{Version: Version, Code: "LIMIT_REACHED"}) +} diff --git a/backend/internal/localreset/recovery_test.go b/backend/internal/localreset/recovery_test.go new file mode 100644 index 0000000..ffc834d --- /dev/null +++ b/backend/internal/localreset/recovery_test.go @@ -0,0 +1,202 @@ +package localreset + +import ( + "bufio" + "context" + "errors" + "net" + "strings" + "sync/atomic" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +type recoveryTestSession struct { + testSession + lists atomic.Int32 + inspects atomic.Int32 + fail bool + oversized bool +} + +func (*recoveryTestSession) RecoveryAvailable() bool { return true } +func (s *recoveryTestSession) ListRecoveries(context.Context, string) (deployment.PendingRecoveryPage, error) { + s.lists.Add(1) + return deployment.PendingRecoveryPage{Items: []deployment.PendingRecovery{{ID: "tx-1", ServerID: "s1"}}}, nil +} +func (s *recoveryTestSession) InspectRecovery(_ context.Context, id string) (deployment.RecoveryInspection, error) { + s.inspects.Add(1) + result := deployment.RecoveryInspection{TransactionID: id, ServerID: "s1", ObservedAt: time.Now(), + Files: make([]deployment.RecoveryFileObservation, 9), Locks: make([]deployment.RecoveryLockObservation, 3)} + if s.fail { + return result, errors.New("secret-error") + } + if s.oversized { + result.Files[0].Path = strings.Repeat("x", MaxFrameSize) + } + return result, nil +} + +func recoveryConnection(t *testing.T, session *recoveryTestSession) (*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) + server.timeout = 5 * time.Second + done := make(chan struct{}) + ctx, cancel := context.WithCancel(t.Context()) + go func() { + defer close(done) + defer serverConn.Close() + server.handle(ctx, serverConn, func(context.Context, Peer) (Session, error) { return session, nil }) + }() + t.Cleanup(func() { + cancel() + _ = clientConn.Close() + _ = serverConn.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Error("recovery session did not exit") + } + }) + return server, clientConn, done +} + +func TestRecoveryProtocolRoundTrip(t *testing.T) { + session := &recoveryTestSession{} + server, conn, done := recoveryConnection(t, session) + client, err := Begin(t.Context(), conn) + if err != nil || !client.RecoveryAvailable() { + t.Fatalf("begin: %v", err) + } + page, err := client.ListRecoveries("") + if err != nil || len(page.Items) != 1 { + t.Fatal(err) + } + result, err := client.InspectRecovery(page.Items[0].ID) + if err != nil || result.HistoricalEvidence || result.TransactionID != "tx-1" { + t.Fatalf("inspection: %+v, %v", result, err) + } + _ = client.Close() + <-done + if session.resets.Load() != 0 || session.selected.Load() != 0 || session.verified.Load() != 0 || + session.closed.Load() != 1 || session.lists.Load() != 1 || session.inspects.Load() != 1 || len(server.failures) != 0 { + t.Fatal("read-only flow mutated reset state, leaked session, or penalized clean close") + } +} + +func TestRecoveryProtocolRejectsReplayAndCrossFlow(t *testing.T) { + for _, scenario := range []string{"replay", "select", "verify", "reset", "mixed", "bad-id"} { + t.Run(scenario, func(t *testing.T) { + session := &recoveryTestSession{} + _, conn, done := recoveryConnection(t, session) + reader := bufio.NewReaderSize(conn, MaxFrameSize+1) + var response Response + if err := readFrame(reader, &response); err != nil { + t.Fatal(err) + } + oldNonce := response.Nonce + if err := writeFrame(conn, Request{Version: Version, Nonce: oldNonce, Action: "recovery_list"}); err != nil { + t.Fatal(err) + } + if err := readFrame(reader, &response); err != nil { + t.Fatal(err) + } + request := Request{Version: Version, Nonce: response.Nonce, Action: "recovery_inspect", TransactionID: "tx-1"} + switch scenario { + case "replay": + request.Nonce = oldNonce + case "select": + request = Request{Version: Version, Nonce: response.Nonce, Action: "select", Method: "recovery"} + case "verify": + request = Request{Version: Version, Nonce: response.Nonce, Action: "verify", Code: "test-code"} + case "reset": + request = Request{Version: Version, Nonce: response.Nonce, Action: "reset", Password: "Example123!"} + case "mixed": + request.Password = "secret-password" + case "bad-id": + request.TransactionID = "../bad" + } + if err := writeFrame(conn, request); err != nil { + t.Fatal(err) + } + response = Response{} + if err := readFrame(reader, &response); err != nil || response.OK || response.Inspection != nil { + t.Fatalf("accepted: %+v %v", response, err) + } + <-done + if session.inspects.Load() != 0 || session.resets.Load() != 0 || session.selected.Load() != 0 || session.verified.Load() != 0 { + t.Fatal("invalid request reached business operation") + } + }) + } +} + +func TestRecoveryClientAndResetMutuallyExclusive(t *testing.T) { + for _, first := range []string{"reset", "recovery"} { + t.Run(first, func(t *testing.T) { + session := &recoveryTestSession{} + _, conn, _ := recoveryConnection(t, session) + client, err := Begin(t.Context(), conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if first == "reset" { + if err := client.Select("recovery"); err != nil { + t.Fatal(err) + } + if _, err := client.ListRecoveries(""); err == nil { + t.Fatal("cross-flow accepted") + } + } else { + if _, err := client.ListRecoveries(""); err != nil { + t.Fatal(err) + } + if err := client.Select("recovery"); err == nil { + t.Fatal("cross-flow accepted") + } + } + }) + } +} + +func TestRecoveryProtocolFailureReturnsNoPartialData(t *testing.T) { + for _, oversized := range []bool{false, true} { + session := &recoveryTestSession{fail: !oversized, oversized: oversized} + _, conn, _ := recoveryConnection(t, session) + client, err := Begin(t.Context(), conn) + if err != nil { + t.Fatal(err) + } + result, err := client.InspectRecovery("tx-1") + if err == nil || result.TransactionID != "" || len(result.Files) != 0 || strings.Contains(err.Error(), "secret") { + t.Fatal("failure leaked partial data") + } + _ = client.Close() + } +} + +func TestRecoveryProtocolRequestLimit(t *testing.T) { + session := &recoveryTestSession{} + _, conn, done := recoveryConnection(t, session) + client, err := Begin(t.Context(), conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + for i := 0; i < MaxRecoveryRequests; i++ { + if _, err := client.ListRecoveries(""); err != nil { + t.Fatal(err) + } + } + if _, err := client.ListRecoveries(""); err == nil { + t.Fatal("request limit ignored") + } + <-done + if session.lists.Load() != MaxRecoveryRequests { + t.Fatal("extra list reached service") + } +} diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 3e9447b..0e86c4c 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -2192,3 +2192,15 @@ SQLite 新测试程序 SHA-256:4c81656fae63a2eebad999d58e6a79e63e6f441810e02ce - [~] 改为受限单元 `scert-inspection-full-20261003-1735.service` 执行同一份源码的后端全量测试:nobody、单编译并发、CPU 50%、MemoryHigh 500 MiB、MemoryMax 650 MiB、20 分钟运行上限,且 Go 内存软限制 350 MiB。日志为 `backend-full-bounded.log`,终态尚待获取。 这只是隔离测试进程的资源限制,没有调整生产服务配置、重启生产服务或清理业务数据。 +## 2026年10月3日18:06 本机核查入口专项 + +本节优先于后续历史状态。以下是隔离的项目 Go 测试,不是生产业务验收,也不是辅助验收脚本。 + +- 上一轮 `scert-inspection-full-20261003-1735.service` 已因运行上限终止:运行二十分钟,退出码一,`Result=timeout`,`MainPID=0`。没有全量通过结论。 +- 第一台新隔离源码目录:`/var/tmp/scert-local-recovery-20261003-1757/backend`。 +- 首批增量归档 SHA-256:`0a14755c3057978a3f84bd8a3bf92be47d4bd570a31e5c37e9a826364daddd76`,上传后核对一致。 +- `go test -p 1 -count=1 -timeout=180s ./internal/deployment ./internal/localreset ./internal/adminconsole` 原生 Linux 通过,退出码零;以 nobody 运行,使用既有离线依赖和独立测试数据。 +- HTTP 本机会话专项仍在运行。新增 Unix socket 菜单集成与异常响应等测试尚未上传复跑,不能算入首批通过范围。 +- 17:57 第一台 Scert/nginx 均为 active;18:02 再查均为 active。编译资源有内存、CPU 和运行时间限制;未因测试重启业务服务。 +- 未清理生产数据库、未修改证书及事务锁、未新增公网端口;双机正式候选包和公共 HTTPS 验收仍未完成。 + diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 80a9208..0132511 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3885,3 +3885,12 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 ### 17:35 全量回归状态 后端全量因测试环境问题两次中止,尚未通过。当前使用离线依赖缓存和内存/CPU 受限 Linux 单元继续验证;没有因此缩减开发范围,也没有把 IPC、人工处置或正式部署标为完成。 +## 2026年10月3日18:06 开发进度 + +本节优先于后续历史状态。 + +- [~] 本机部署核查入口 R2:已实现摘要分页、互斥会话阶段、逐次随机令牌、终端核查菜单和安全显示。首批原生 Linux 三包回归通过,最新增量与 HTTP 专项仍在验证,尚未进入正式候选包。 +- [ ] R3 人工处置、R4 原子审计与幂等并发、R5 双机正式流程仍未完成。 +- [!] 上一轮后端全量回归因运行时间上限结束,未通过,不能以专项测试替代。 +- [~] 持续同步整改与生产测试文档;仅处理 Linux,不扩展 Windows 开发。 + diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index f60ff52..a1f7860 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -2338,3 +2338,9 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - [x] 已确认中止的联网及首次离线全量测试进程退出;复核生产服务和本机严格 TLS 就绪状态。 - [~] 后端全量改用 `scert-inspection-full-20261003-1735.service` 受限运行,结果待获取;不得按前面的专项结果替代。 +## 2026年10月3日18:06 执行进度 + +本节优先于后续历史状态。P0-10 的 R2 本机核查入口已编码,原生 Linux 部署、本机协议、管理员终端首批回归通过;最新 Unix socket 菜单集成、异常协议响应和 HTTP 会话测试仍待完成验证,状态为 `[~] 进行中`。修改前检查点 `c9045a0` 已推送。 + +上一轮后端全量回归触发二十分钟上限,已终止且未通过。本轮不改变 R3 人工处置、R4 审计幂等及 R5 双机正式部署的未完成状态;总体生产验收继续保留。详细任务状态见《部署事务人工核查任务清单》18:06 章节。 + diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index aa849c8..a18d0cb 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -2882,3 +2882,14 @@ P0-11 生产验收门禁 ### 17:35 后端全量回归进度 前两次全量尝试分别因依赖代理不可达及 SSH 响应异常而中止,没有通过结论。服务 active 和本机严格 TLS 就绪检查已复核,当前以限制内存和 CPU 的 Linux 单元重新执行全量回归,等待终态。未修改生产配置或历史恢复记录,未关闭任何生产门禁。 +## 2026年10月3日18:06 最新进度 + +本节优先于后续历史记录。Linux 上线范围不变,总体生产验收仍未通过。 + +- [x] 第二台磁盘清理结果于 17:41 复核:根分区可用约 26 GB、使用率 12%,五个既有业务服务均运行。 +- [~] P0-10 / R2:已接入待恢复事务摘要分页、本机受保护协议及终端只读核查菜单;首批 Linux 三包回归通过,HTTP 边界、最新增量和竞态验证仍在推进。 +- [!] 上一轮受限后端全量回归因二十分钟上限终止,未通过;不得用专项结果替代全量结论。 +- [ ] 人工处置与审计、最新候选包双机部署、三数据库首次 Web、Smail 系统发信与重置、DNS/ACME 正负向和严格公共 HTTPS 验收继续保留。 + +详细状态见《部署事务人工核查任务清单》18:06 章节。本轮未增加公网监听、未改生产事务终态,也未放宽 TLS 或本机密码重置安全边界。 + diff --git a/doc/本机部署核查使用说明.md b/doc/本机部署核查使用说明.md new file mode 100644 index 0000000..e3cf00d --- /dev/null +++ b/doc/本机部署核查使用说明.md @@ -0,0 +1,38 @@ +# 本机部署核查使用说明 + +## 当前发布状态 + +2026年10月3日:功能正在原生 Linux 验证,尚未随正式候选包部署。以下说明适用于包含该功能的新版本,不能据此认定线上已经提供此菜单。 + +## 入口与权限 + +在 Scert 所在 Linux 主机的管理员交互终端执行: + +```bash +systemctl start Scert-admin +``` + +主服务必须已初始化并正常运行。本机管理员工具仍通过既有受保护的 Unix socket 通信,保留目录、权限和对端身份校验;不开放 HTTP 恢复接口,不增加 TCP 监听。密码重置仍必须通过绑定邮箱验证码,不因核查功能而跳过验证。 + +## 只读核查步骤 + +1. 在本机管理菜单输入 `2`,进入只读部署事务核查。 +2. 每页最多显示五条待处理或需要恢复的事务,包含事务标识、服务器标识、阶段、状态和历史证据是否存在。 +3. 输入 `n` 翻页,输入本页序号核查对应事务,输入 `q` 退出。 +4. 核查结果显示目标文件、临时文件、备份文件的存在性与摘要比较,以及锁的存在性和归属判断。不显示证书内容、密码、私钥或锁令牌。 +5. 一次核查完成后退出。若需要继续处理其他事务,重新启动本机管理员工具。 + +会话有效期为两分钟,每个会话最多处理三十二次核查请求;列表查询最多五秒,现场核查最多三十秒,并遵守更短的调用方期限。每个消息帧限制为十六 KiB,超限结果拒绝返回,不截取部分内容冒充完整核查。 + +## 结果边界 + +- 核查只读,不执行远端命令、不写文件、不建立或删除锁、不提交或回滚事务。 +- 列表分页和现场读取都不是数据库或远端文件的一致性快照,其他操作可能同时改变现场。 +- “历史证据已保存”仅说明记录存在,不代表部署已经通过生产验收;缺失的历史证据不会补造。 +- 摘要匹配不等于 Nginx 重载成功,也不等于公共 HTTPS、SNI 或业务流量已经验证通过。 +- 输入异常、过期、读取失败、响应超限或管理员配置发生变化时,终止会话,不将部分观察作为成功结果。 +- 核查与密码重置不能在同一会话互相切换;需要切换时退出后重新启动。 + +## 尚未提供的能力 + +独立人工处置、审计和基于可信证据的提交或回滚仍在开发。不要手工修改数据库事务终态,不要删锁后宣称历史部署成功。完整状态以整改清单和生产测试记录为准。 diff --git a/doc/部署事务人工核查任务清单.md b/doc/部署事务人工核查任务清单.md index f44f108..dd7b2ba 100644 --- a/doc/部署事务人工核查任务清单.md +++ b/doc/部署事务人工核查任务清单.md @@ -39,3 +39,21 @@ ### 17:35 全量验证状态跟进 联网全量运行因代理不可达中止;首次离线全量运行出现 SSH 响应异常后主动中止,均未记为通过。生产 Scert/nginx 已复核 active,本机严格 TLS 的 `/readyz` 返回 200。当前以受限 Linux 单元运行后端全量测试,内存上限 650 MiB、CPU 50%,结果仍待获取。R1 的专项、竞态和静态检查结论不变,R2 至 R5 仍未完成。 +## 2026年10月3日18:06 当前执行状态 + +本节优先于后续历史记录。本轮仅处理 Linux;修改前检查点 `c9045a0` 已提交并推送。 + +- [x] R1:只读现场核查底层已完成,仍等待产品完整流程复审。 +- [~] R2:本机入口已编码,正在执行原生 Linux 验证;尚未部署生产候选包。 + - [x] 待恢复事务按标识排序、每页五条,仅返回摘要;不返回凭据、原始错误或历史证据内容。 + - [x] 复用受保护的本机通道,未增加 HTTP 路由或 TCP 监听;核查与密码重置阶段互斥,逐次更换随机令牌。 + - [x] 终端增加只读核查菜单、翻页及选择,转义终端控制字符,明确区分当前采样与历史部署证据。 + - [x] 第一批原生 Linux 部署、本机协议、管理员终端三包测试退出码为零。 + - [~] 新增真实 Unix socket 菜单集成测试、异常响应拒绝测试和 HTTP 会话边界测试,等待最新版本复跑及竞态检查。 +- [ ] R3:独立人工处置记录、再次核验和可信证据限定的提交或回滚未完成。 +- [ ] R4:人工处置幂等、并发互斥与原子审计未完成;R2 协议测试不替代这些门禁。 +- [ ] R5:本轮修改未进入正式候选包和双机部署,不改变生产历史事务。 +- [~] R6:同步总清单、执行清单、开发清单和生产测试记录。 + +上一轮后端全量测试触发二十分钟运行上限,退出码为一,不能记为通过。已确认对应单元终止、主进程为零。第一批 R2 专项日志为第一台 `/var/tmp/scert-local-recovery-20261003-1757/unit.log`;HTTP 专项尚在同目录 `http.log` 对应的受限单元中运行。未清理生产数据库、证书或恢复现场,未创建辅助验收脚本。 +