This commit is contained in:
parent
11c0b14b12
commit
b183aef0c7
@ -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 "重置会话已过期,请重新启动重置"
|
||||
}
|
||||
|
||||
36
backend/cmd/smail-admin/result_linux_test.go
Normal file
36
backend/cmd/smail-admin/result_linux_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
111
backend/services/adminreset/client_test.go
Normal file
111
backend/services/adminreset/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user