保存本机只读核查入口并开始全量失败修复

本机协议及菜单已实现,Linux专项通过;全量存在环境缺失和SQLite截止时间失败,尚未通过生产验收。
This commit is contained in:
xuans 2026-10-03 18:23:48 +08:00
parent c9045a084f
commit 978f023a7f
23 changed files with 1386 additions and 20 deletions

View File

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

View 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) + ")"
}

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

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

View File

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

View 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)
}

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

View 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)
}
})
}
}

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

View 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)
}
}
}

View File

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

View File

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

View File

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

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

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

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

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

View File

@ -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 验收仍未完成。

View File

@ -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 开发。

View File

@ -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 章节。

View File

@ -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 或本机密码重置安全边界。

View 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 或业务流量已经验证通过。
- 输入异常、过期、读取失败、响应超限或管理员配置发生变化时,终止会话,不将部分观察作为成功结果。
- 核查与密码重置不能在同一会话互相切换;需要切换时退出后重新启动。
## 尚未提供的能力
独立人工处置、审计和基于可信证据的提交或回滚仍在开发。不要手工修改数据库事务终态,不要删锁后宣称历史部署成功。完整状态以整改清单和生产测试记录为准。

View File

@ -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` 对应的受限单元中运行。未清理生产数据库、证书或恢复现场,未创建辅助验收脚本。