feat: 认证失败时停止重连并提示用户

密码错误、账户禁用、令牌过期或限流等永久性认证错误不再无限
重连,改为置 StateError 并通过 EvError 弹窗告知用户具体原因
(中英文本地化)。网络错误仍正常指数退避重试。

- protocol: 新增 AuthErrorCode 类型与消息映射
- transport: AuthError/ServerError 携带 Code 字段
- vpn: run() 循环识别致命认证错误并停止,新增 onError 回调
- ipc/daemon/ui: 打通错误码到 UI 的本地化展示
This commit is contained in:
2026-07-07 14:37:26 +08:00
parent 6daa73fdb0
commit 56102589f6
8 changed files with 150 additions and 8 deletions
+64 -3
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
@@ -44,6 +45,7 @@ type SessionManager struct {
stats *stats.Stats
onState func(stats.State)
onStats func(stats.Snapshot)
onError func(code string, msg string)
mu sync.Mutex
running bool
@@ -67,12 +69,15 @@ type SessionManager struct {
// New creates a SessionManager. The onState callback (if non-nil) is
// invoked on every state transition. The onStats callback (if non-nil)
// is invoked periodically while connected.
func New(onState func(stats.State), onStats func(stats.Snapshot)) *SessionManager {
// is invoked periodically while connected. The onError callback (if
// non-nil) is invoked once when a fatal, non-retryable error (such as
// an authentication failure) terminates the session.
func New(onState func(stats.State), onStats func(stats.Snapshot), onError func(string, string)) *SessionManager {
return &SessionManager{
stats: stats.New(),
onState: onState,
onStats: onStats,
onError: onError,
}
}
@@ -126,7 +131,12 @@ func (sm *SessionManager) Disconnect() {
// run is the main session loop with exponential-backoff reconnection
// and CDN IP failover.
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
defer sm.setState(stats.StateDisconnected)
fatal := false
defer func() {
if !fatal {
sm.setState(stats.StateDisconnected)
}
}()
backoff := time.Second
maxBackoff := 60 * time.Second
@@ -153,6 +163,22 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
if err != nil {
log.L().Error("VPN connection failed", "error", err)
// A fatal authentication failure (wrong password, disabled
// account, expired token, rate limit) is not retryable:
// stop the loop and surface the reason to the user instead
// of hammering the server forever.
if code, msg, isFatal := fatalAuthError(err); isFatal {
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
sm.setState(stats.StateError)
if sm.onError != nil {
sm.onError(string(code), msg)
}
fatal = true
sm.cleanup()
return
}
sm.setState(stats.StateReconnecting)
// Try next CDN IP immediately.
@@ -181,6 +207,41 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
}
}
// fatalAuthError inspects err and, if it represents a permanent
// authentication failure that should not be retried, returns the
// stable error code, the raw server message, and true. It recognises
// all three auth-failure shapes produced by the transport/auth layers:
// - *transport.AuthError (WebSocket auth_err at the auth stage)
// - *transport.ServerError (auth_err at the init stage, JWT path)
// - *auth.LoginError (HTTP /api/login failure, JWT path)
//
// errors.As transparently unwraps fmt.Errorf("...: %w", err) chains,
// so the wrapped LoginError returned by connectOnce is matched too.
// A non-empty code is required: an auth_err with an unrecognized
// message is treated as non-fatal so the loop falls back to retrying
// (the server still closed the connection, but we lack a categorical
// reason to give up).
func fatalAuthError(err error) (protocol.AuthErrorCode, string, bool) {
var authErr *transport.AuthError
if errors.As(err, &authErr) {
return authErr.Code, authErr.Message, authErr.Code != ""
}
var serverErr *transport.ServerError
if errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr {
return serverErr.Code, serverErr.Message, serverErr.Code != ""
}
var loginErr *auth.LoginError
if errors.As(err, &loginErr) {
switch loginErr.Code {
case http.StatusTooManyRequests:
return protocol.AuthCodeRateLimited, loginErr.Message, true
case http.StatusUnauthorized, http.StatusForbidden:
return protocol.AuthCodeWrongCredentials, loginErr.Message, true
}
}
return "", "", false
}
// connectOnce performs a single connection lifecycle: authenticate,
// handshake, configure TUN, apply routes, pump packets until failure.
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error {