保存本机只读核查入口并开始全量失败修复
本机协议及菜单已实现,Linux专项通过;全量存在环境缺失和SQLite截止时间失败,尚未通过生产验收。
This commit is contained in:
parent
c9045a084f
commit
978f023a7f
@ -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
|
||||
|
||||
132
backend/internal/adminconsole/recovery.go
Normal file
132
backend/internal/adminconsole/recovery.go
Normal file
@ -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) + ")"
|
||||
}
|
||||
98
backend/internal/adminconsole/recovery_linux_test.go
Normal file
98
backend/internal/adminconsole/recovery_linux_test.go
Normal file
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
94
backend/internal/adminconsole/recovery_test.go
Normal file
94
backend/internal/adminconsole/recovery_test.go
Normal file
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
78
backend/internal/deployment/recovery_list.go
Normal file
78
backend/internal/deployment/recovery_list.go
Normal file
@ -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)
|
||||
}
|
||||
35
backend/internal/deployment/recovery_list_cancel_test.go
Normal file
35
backend/internal/deployment/recovery_list_cancel_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
83
backend/internal/deployment/recovery_list_test.go
Normal file
83
backend/internal/deployment/recovery_list_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
55
backend/internal/httpapi/local_recovery.go
Normal file
55
backend/internal/httpapi/local_recovery.go
Normal file
@ -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
|
||||
}
|
||||
180
backend/internal/httpapi/local_recovery_test.go
Normal file
180
backend/internal/httpapi/local_recovery_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
104
backend/internal/localreset/recovery.go
Normal file
104
backend/internal/localreset/recovery.go
Normal file
@ -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
|
||||
}
|
||||
116
backend/internal/localreset/recovery_response_test.go
Normal file
116
backend/internal/localreset/recovery_response_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
50
backend/internal/localreset/recovery_server.go
Normal file
50
backend/internal/localreset/recovery_server.go
Normal file
@ -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"})
|
||||
}
|
||||
202
backend/internal/localreset/recovery_test.go
Normal file
202
backend/internal/localreset/recovery_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 验收仍未完成。
|
||||
|
||||
|
||||
@ -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 开发。
|
||||
|
||||
|
||||
@ -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 章节。
|
||||
|
||||
|
||||
@ -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 或本机密码重置安全边界。
|
||||
|
||||
|
||||
38
doc/本机部署核查使用说明.md
Normal file
38
doc/本机部署核查使用说明.md
Normal file
@ -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 或业务流量已经验证通过。
|
||||
- 输入异常、过期、读取失败、响应超限或管理员配置发生变化时,终止会话,不将部分观察作为成功结果。
|
||||
- 核查与密码重置不能在同一会话互相切换;需要切换时退出后重新启动。
|
||||
|
||||
## 尚未提供的能力
|
||||
|
||||
独立人工处置、审计和基于可信证据的提交或回滚仍在开发。不要手工修改数据库事务终态,不要删锁后宣称历史部署成功。完整状态以整改清单和生产测试记录为准。
|
||||
@ -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` 对应的受限单元中运行。未清理生产数据库、证书或恢复现场,未创建辅助验收脚本。
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user