完善管理员重置结果提示并缩短邮件验证等待
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-19 22:53:58 +08:00
parent 11c0b14b12
commit b183aef0c7
10 changed files with 290 additions and 14 deletions

View File

@ -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 "重置会话已过期,请重新启动重置"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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