diff --git a/backend/cmd/smail-admin/main.go b/backend/cmd/smail-admin/main.go index 0eeb46c..99d431a 100644 --- a/backend/cmd/smail-admin/main.go +++ b/backend/cmd/smail-admin/main.go @@ -39,16 +39,19 @@ func run(args []string) int { defer cancel() conn, err := adminreset.DialControl(ctx, adminreset.DefaultControlPath) if err != nil { - fmt.Fprintln(os.Stderr, "超级管理员重置通道不可用") - return 1 + return finishReset("超级管理员重置通道不可用", 1) } defer conn.Close() - if err := adminreset.SendVerifiedReset(ctx, conn, adminreset.ReadVisibleInput, adminreset.ReadProtectedPassword); err != nil { - fmt.Fprintln(os.Stderr, "超级管理员重置失败:", safeError(err)) - return 1 + if err := adminreset.SendVerifiedResetWithInput(ctx, conn, adminreset.ReadVisibleInputContext, adminreset.ReadProtectedPassword); err != nil { + return finishReset("超级管理员重置失败:"+safeError(err), 1) } - fmt.Println("超级管理员密码重置成功") - return 0 + return finishReset("超级管理员密码重置成功,请使用新密码登录", 0) +} + +func finishReset(message string, code int) int { + fmt.Fprintln(os.Stderr, message) + adminreset.ShowResult(message) + return code } func runDoctor() int { @@ -91,6 +94,9 @@ func safeError(err error) string { if errors.Is(err, adminreset.ErrVerificationFailed) { return "安全验证失败、已过期或请求过于频繁,请稍后重新启动重置" } + if errors.Is(err, adminreset.ErrEmailDeliveryFailed) { + return "邮件验证码发送失败或请求过于频繁,请稍后重试或选择其他验证方式" + } if errors.Is(err, adminreset.ErrExpiredSession) { return "重置会话已过期,请重新启动重置" } diff --git a/backend/cmd/smail-admin/result_linux_test.go b/backend/cmd/smail-admin/result_linux_test.go new file mode 100644 index 0000000..0a375b7 --- /dev/null +++ b/backend/cmd/smail-admin/result_linux_test.go @@ -0,0 +1,36 @@ +//go:build linux + +package main + +import ( + "context" + "os" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/services/adminreset" +) + +// 由隔离的 systemd 临时服务人工检查终端显示,不连接生产重置通道。 +func TestSystemdResultPrompt(t *testing.T) { + if os.Getenv("SMAIL_TEST_RESULT_PROMPT") != "1" || os.Getenv("INVOCATION_ID") == "" { + t.Skip("需要隔离的 systemd 交互测试服务") + } + if code := finishReset("显示测试:重置结果提示正常", 0); code != 0 { + t.Fatalf("退出状态:%d", code) + } +} + +func TestSystemdCancelledInputResult(t *testing.T) { + if os.Getenv("SMAIL_TEST_RESULT_PROMPT") != "1" || os.Getenv("INVOCATION_ID") == "" { + t.Skip("需要隔离的 systemd 交互测试服务") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := adminreset.ReadVisibleInputContext(ctx, "取消测试:请等待自动结束"); err == nil { + t.Fatal("取消输入后未返回错误") + } + if code := finishReset("显示测试:发送失败后输入已取消", 1); code != 1 { + t.Fatalf("退出状态:%d", code) + } +} diff --git a/backend/services/adminreset/client.go b/backend/services/adminreset/client.go index 9cf1091..28ab273 100644 --- a/backend/services/adminreset/client.go +++ b/backend/services/adminreset/client.go @@ -14,6 +14,7 @@ import ( var ( ErrVerificationUnavailable = errors.New("no verification method available") ErrVerificationFailed = errors.New("verification failed") + ErrEmailDeliveryFailed = errors.New("verification email delivery failed") ) func responseError(code string) error { @@ -22,6 +23,8 @@ func responseError(code string) error { return ErrVerificationUnavailable case "VERIFICATION_FAILED": return ErrVerificationFailed + case "EMAIL_DELIVERY_FAILED": + return ErrEmailDeliveryFailed case "REPLAY_OR_EXPIRED": return ErrExpiredSession default: @@ -30,6 +33,16 @@ func responseError(code string) error { } func SendVerifiedReset(ctx context.Context, conn net.Conn, input func(string) (string, error), readPassword func() (string, error)) error { + if input == nil { + return ErrInvalidRequest + } + return SendVerifiedResetWithInput(ctx, conn, func(_ context.Context, prompt string) (string, error) { + return input(prompt) + }, readPassword) +} + +// SendVerifiedResetWithInput 允许邮件发送与验证码输入并行,发送失败会取消输入。 +func SendVerifiedResetWithInput(ctx context.Context, conn net.Conn, input func(context.Context, string) (string, error), readPassword func() (string, error)) error { if conn == nil || input == nil || readPassword == nil { return ErrInvalidRequest } @@ -57,7 +70,7 @@ func SendVerifiedReset(ctx context.Context, conn net.Conn, input func(string) (s fmt.Fprintf(&prompt, "%d. %s\n", i+1, method.Label) } prompt.WriteString("\n选项:") - choice, err := input(prompt.String()) + choice, err := input(ctx, prompt.String()) if err != nil { return err } @@ -79,10 +92,17 @@ func SendVerifiedReset(ctx context.Context, conn net.Conn, input func(string) (s } return nil } - if err := exchange(Request{Action: "select", Method: challenge.Methods[index-1].ID}); err != nil { - return err + method := challenge.Methods[index-1].ID + request := Request{Version: ProtocolVersion, Nonce: challenge.Nonce, Action: "select", Method: method} + var code string + if strings.HasPrefix(method, "email:") { + code, err = selectEmailAndReadCode(ctx, conn, request, input) + } else { + if err = exchange(request); err != nil { + return err + } + code, err = input(ctx, "请输入所选方式的验证码或安全码") } - code, err := input("请输入所选方式的验证码或安全码") if err != nil { return err } @@ -108,3 +128,36 @@ func SendVerifiedReset(ctx context.Context, conn net.Conn, input func(string) (s } return nil } + +func selectEmailAndReadCode(ctx context.Context, conn net.Conn, request Request, input func(context.Context, string) (string, error)) (string, error) { + if err := json.NewEncoder(conn).Encode(request); err != nil { + return "", err + } + inputContext, cancel := context.WithCancel(ctx) + defer cancel() + result := make(chan error, 1) + go func() { + response, err := DecodeResponse(conn) + if err == nil && !response.OK { + err = responseError(response.Code) + } + if err != nil { + cancel() + } + result <- err + }() + code, inputErr := input(inputContext, "正在发送邮件验证码,收到后输入\n请输入邮件验证码") + if inputErr != nil { + // 取消输入时关闭本次连接,同时解除等待投递结果的读取。 + _ = conn.Close() + } + deliveryErr := <-result + if deliveryErr != nil || inputErr != nil { + clearPassword(&code) + if deliveryErr != nil { + return "", deliveryErr + } + return "", inputErr + } + return code, nil +} diff --git a/backend/services/adminreset/client_test.go b/backend/services/adminreset/client_test.go new file mode 100644 index 0000000..5a5e7d7 --- /dev/null +++ b/backend/services/adminreset/client_test.go @@ -0,0 +1,111 @@ +package adminreset + +import ( + "context" + "errors" + "net" + "strings" + "testing" + "time" +) + +type delayedEmailFlow struct { + testResetFlow + start func() error +} + +func (f *delayedEmailFlow) Methods() []Method { + return []Method{{ID: "email:1", Label: "发送验证码到 root@example.com"}} +} + +func (f *delayedEmailFlow) Start(method string) error { + if method != "email:1" { + return ErrInvalidRequest + } + if err := f.start(); err != nil { + return err + } + f.selected = true + return nil +} + +func TestEmailPromptDoesNotWaitForDelivery(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + inputShown := make(chan struct{}) + reset := false + flow := &delayedEmailFlow{ + testResetFlow: testResetFlow{reset: func(string) error { reset = true; return nil }}, + start: func() error { + select { + case <-inputShown: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }, + } + server, client := net.Pipe() + defer client.Close() + done := make(chan struct{}) + go func() { + defer close(done) + defer server.Close() + handleResetConnection(server, server.SetDeadline, func() (ResetSession, error) { return flow, nil }, NewSessionGuard(time.Minute)) + }() + steps := 0 + err := SendVerifiedResetWithInput(ctx, client, func(_ context.Context, prompt string) (string, error) { + steps++ + if steps == 1 { + return "1", nil + } + if !strings.Contains(prompt, "正在发送") || !strings.Contains(prompt, "请输入邮件验证码") { + t.Errorf("缺少邮件发送状态或输入提示:%q", prompt) + } + close(inputShown) + return "fixture-code", nil + }, func() (string, error) { return "NewPass123", nil }) + <-done + if err != nil || !reset { + t.Fatalf("邮件输入与发送未正确衔接:reset=%v err=%v", reset, err) + } +} + +func TestEmailDeliveryFailureCancelsInputAndNeverAsksPassword(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + inputShown := make(chan struct{}) + flow := &delayedEmailFlow{start: func() error { + select { + case <-inputShown: + return errors.New("mail unavailable") + case <-ctx.Done(): + return ctx.Err() + } + }} + server, client := net.Pipe() + defer client.Close() + done := make(chan struct{}) + go func() { + defer close(done) + defer server.Close() + handleResetConnection(server, server.SetDeadline, func() (ResetSession, error) { return flow, nil }, NewSessionGuard(time.Minute)) + }() + steps := 0 + err := SendVerifiedResetWithInput(ctx, client, func(inputContext context.Context, _ string) (string, error) { + steps++ + if steps == 1 { + return "1", nil + } + close(inputShown) + <-inputContext.Done() + return "", inputContext.Err() + }, func() (string, error) { + t.Error("邮件发送失败后不应进入密码输入") + return "NewPass123", nil + }) + <-done + if !errors.Is(err, ErrEmailDeliveryFailed) || ctx.Err() != nil { + t.Fatalf("邮件发送失败未及时取消输入:%v", err) + } +} diff --git a/backend/services/adminreset/password_linux.go b/backend/services/adminreset/password_linux.go index 00732de..93f791d 100644 --- a/backend/services/adminreset/password_linux.go +++ b/backend/services/adminreset/password_linux.go @@ -3,10 +3,13 @@ package adminreset import ( + "context" "errors" "io" "os/exec" "strings" + "syscall" + "time" "git.pcux.cn/xuans/Smail/utils/password" ) @@ -42,14 +45,31 @@ func askPassword(prompt string) (string, error) { } func ReadVisibleInput(prompt string) (string, error) { - return readSystemdInput(prompt, "yes") + return ReadVisibleInputContext(context.Background(), prompt) +} + +func ReadVisibleInputContext(ctx context.Context, prompt string) (string, error) { + return readSystemdInputContext(ctx, prompt, "yes") +} + +// ShowResult 使用当前 systemctl 的密码代理显示结果,避免提示只进入系统日志。 +func ShowResult(message string) { + _, _ = readSystemdInputContext(context.Background(), message+"\n按回车结束", "yes", "--timeout=30") } func readSystemdInput(prompt, echo string) (string, error) { + return readSystemdInputContext(context.Background(), prompt, echo) +} + +func readSystemdInputContext(ctx context.Context, prompt, echo string, options ...string) (string, error) { prompt = strings.TrimRight(prompt, ":: ") + ":" // systemd 代理把提示保存在单行配置中,用终端换行控制显示列表,避免配置被拆行。 prompt = strings.ReplaceAll(strings.ReplaceAll(prompt, "\r\n", "\n"), "\n", "\x1bE") - command := exec.Command("systemd-ask-password", "--no-tty", "--echo="+echo, prompt) + args := append([]string{"--no-tty", "--echo=" + echo}, options...) + command := exec.CommandContext(ctx, "systemd-ask-password", append(args, prompt)...) + // 先允许代理清理请求文件,避免取消后继续弹出已经失效的输入提示。 + command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) } + command.WaitDelay = time.Second command.Stderr = io.Discard output, err := command.Output() if err != nil { diff --git a/backend/services/adminreset/password_unsupported.go b/backend/services/adminreset/password_unsupported.go index 3a0d033..7283583 100644 --- a/backend/services/adminreset/password_unsupported.go +++ b/backend/services/adminreset/password_unsupported.go @@ -2,5 +2,11 @@ package adminreset +import "context" + func ReadProtectedPassword() (string, error) { return "", ErrUnsupported } func ReadVisibleInput(string) (string, error) { return "", ErrUnsupported } +func ReadVisibleInputContext(context.Context, string) (string, error) { + return "", ErrUnsupported +} +func ShowResult(string) {} diff --git a/backend/services/adminreset/password_windows.go b/backend/services/adminreset/password_windows.go index b70a700..a35673e 100644 --- a/backend/services/adminreset/password_windows.go +++ b/backend/services/adminreset/password_windows.go @@ -4,6 +4,7 @@ package adminreset import ( "bufio" + "context" "errors" "fmt" "os" @@ -67,3 +68,27 @@ func ReadVisibleInput(prompt string) (string, error) { } return strings.TrimSuffix(strings.TrimSuffix(value, "\n"), "\r"), nil } + +func ReadVisibleInputContext(ctx context.Context, prompt string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + type result struct { + value string + err error + } + done := make(chan result, 1) + go func() { + value, err := ReadVisibleInput(prompt) + done <- result{value: value, err: err} + }() + select { + case value := <-done: + return value.value, value.err + case <-ctx.Done(): + return "", ctx.Err() + } +} + +// Windows 控制台直接显示标准输出,无需额外的密码代理确认。 +func ShowResult(string) {} diff --git a/backend/services/adminreset/protocol.go b/backend/services/adminreset/protocol.go index 2359fbf..a96cd92 100644 --- a/backend/services/adminreset/protocol.go +++ b/backend/services/adminreset/protocol.go @@ -276,6 +276,10 @@ func handleResetConnection(conn io.ReadWriter, setDeadline func(time.Time) error } clearPassword(&request.Code) if err != nil { + if action == "select" && strings.HasPrefix(request.Method, "email:") { + _ = EncodeResponse(conn, Response{Code: "EMAIL_DELIVERY_FAILED", Message: "邮件验证码发送失败或请求过于频繁,请稍后重试或选择其他验证方式"}) + return + } _ = EncodeResponse(conn, Response{Code: "VERIFICATION_FAILED", Message: "安全验证失败、已过期或请求过于频繁,请稍后重试"}) return } diff --git a/backend/utils/send/routing.go b/backend/utils/send/routing.go index 4207830..9d41f9d 100644 --- a/backend/utils/send/routing.go +++ b/backend/utils/send/routing.go @@ -70,7 +70,8 @@ func deliverMX(hosts []string, attempt func(host, port string) error) error { func deliverMXContext(ctx stdcontext.Context, hosts []string, attempt func(host, port string) error) error { var lastErr error for _, host := range hosts { - for _, port := range []string{"25", "587", "465"} { + // 标准投递端口不可达时优先尝试隐式 TLS,避免先等待不提供欢迎响应的提交端口。 + for _, port := range []string{"25", "465", "587"} { if err := ctx.Err(); err != nil { return err } diff --git a/backend/utils/send/routing_test.go b/backend/utils/send/routing_test.go index 9215f60..e73d7d7 100644 --- a/backend/utils/send/routing_test.go +++ b/backend/utils/send/routing_test.go @@ -93,3 +93,17 @@ func TestRetryable(t *testing.T) { } } } + +func TestDeliverMXUsesTLSFallbackBeforeSubmissionPort(t *testing.T) { + var calls []string + err := deliverMX([]string{"mx.example.com"}, func(host, port string) error { + calls = append(calls, port) + if port == "465" { + return nil + } + return &net.OpError{Op: "dial", Err: io.EOF} + }) + if err != nil || !reflect.DeepEqual(calls, []string{"25", "465"}) { + t.Fatalf("加密路由可用时不应等待提交端口:calls=%v err=%v", calls, err) + } +}