From 56102589f6abd06fac74376988c0b4c0cf5ecfef Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 7 Jul 2026 14:37:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E5=81=9C=E6=AD=A2=E9=87=8D=E8=BF=9E=E5=B9=B6=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 密码错误、账户禁用、令牌过期或限流等永久性认证错误不再无限 重连,改为置 StateError 并通过 EvError 弹窗告知用户具体原因 (中英文本地化)。网络错误仍正常指数退避重试。 - protocol: 新增 AuthErrorCode 类型与消息映射 - transport: AuthError/ServerError 携带 Code 字段 - vpn: run() 循环识别致命认证错误并停止,新增 onError 回调 - ipc/daemon/ui: 打通错误码到 UI 的本地化展示 --- internal/daemon/daemon.go | 3 ++ internal/i18n/en.toml | 6 +++ internal/i18n/zh-Hans.toml | 6 +++ internal/ipc/ipc.go | 1 + internal/protocol/protocol.go | 36 ++++++++++++++++++ internal/transport/transport.go | 14 +++++-- internal/ui/view.go | 25 +++++++++++- internal/vpn/session.go | 67 +++++++++++++++++++++++++++++++-- 8 files changed, 150 insertions(+), 8 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index f567e40..39c5664 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -153,6 +153,9 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) { s := snap d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &s}) }, + func(code string, msg string) { + d.server.Broadcast(ipc.Event{Event: ipc.EvError, Code: code, Message: msg}) + }, ) if err := d.session.Connect(ctx, cfg); err != nil { diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index aa701bc..5bf47f6 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -53,6 +53,12 @@ DlgVPNError = "VPN Error" DlgSaveError = "Save Error" DlgKeychainError = "Keychain Error" DlgError = "Error" +DlgAuthError = "Authentication Failed" +AuthErrWrongCredentials = "Wrong username or password. Please check your credentials." +AuthErrUserDisabled = "This user account does not exist or has been disabled." +AuthErrTokenInvalid = "The authentication token is invalid or expired." +AuthErrRateLimited = "Too many authentication attempts. Please try again later." +AuthErrMalformed = "Authentication message format error." BtnResetDB = "Reset Database" DlgResetDBTitle = "Reset Database" diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index 8f82a97..d1e14df 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -53,6 +53,12 @@ DlgVPNError = "VPN 错误" DlgSaveError = "保存错误" DlgKeychainError = "钥匙串错误" DlgError = "错误" +DlgAuthError = "认证失败" +AuthErrWrongCredentials = "用户名或密码错误,请检查凭据。" +AuthErrUserDisabled = "用户不存在或已被禁用。" +AuthErrTokenInvalid = "认证令牌无效或已过期。" +AuthErrRateLimited = "认证尝试过于频繁,请稍后再试。" +AuthErrMalformed = "认证消息格式错误。" BtnResetDB = "重置数据库" DlgResetDBTitle = "重置数据库" diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 7fddacb..bc98a4d 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -66,6 +66,7 @@ type Event struct { Event string `json:"event"` State string `json:"state,omitempty"` Stats *stats.Snapshot `json:"stats,omitempty"` + Code string `json:"code,omitempty"` // stable auth-error code for EvError Message string `json:"message,omitempty"` } diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index 30343a3..dbdef71 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -69,3 +69,39 @@ type AuthResponse struct { func IsError(msgType string) bool { return msgType == TypeAuthErr || msgType == TypeError } + +// AuthErrorCode is a stable, locale-independent identifier for a fatal +// authentication failure. It is derived from the server's auth_err +// message (or HTTP status for the JWT login path) and carried over IPC +// to the GUI, which maps it to a localized user-facing string. +type AuthErrorCode string + +const ( + AuthCodeWrongCredentials AuthErrorCode = "wrong_credentials" // 用户名或密码错误 / HTTP 401,403 + AuthCodeUserDisabled AuthErrorCode = "user_disabled" // 用户不存在或已禁用 + AuthCodeTokenInvalid AuthErrorCode = "token_invalid" // 令牌无效或已过期 + AuthCodeRateLimited AuthErrorCode = "rate_limited" // 认证尝试过于频繁 / HTTP 429 + AuthCodeMalformed AuthErrorCode = "malformed" // 消息格式错误 +) + +// AuthErrorCodeFromMessage maps a server auth_err message string to a +// stable AuthErrorCode. It returns the empty string for an unrecognized +// message, in which case the caller should treat the failure as +// non-categorical (and typically still fatal, since the server closes +// the connection after auth_err). +func AuthErrorCodeFromMessage(msg string) AuthErrorCode { + switch msg { + case "用户名或密码错误": + return AuthCodeWrongCredentials + case "用户不存在或已禁用": + return AuthCodeUserDisabled + case "令牌无效或已过期": + return AuthCodeTokenInvalid + case "认证尝试过于频繁,请稍后再试": + return AuthCodeRateLimited + case "消息格式错误": + return AuthCodeMalformed + default: + return "" + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go index dceb5b3..0e5dcf8 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -158,7 +158,7 @@ func (c *Conn) passwordAuth(username, password string) error { return fmt.Errorf("parse auth response: %w", err) } if resp.Type == protocol.TypeAuthErr { - return &AuthError{Message: resp.Message} + return &AuthError{Message: resp.Message, Code: protocol.AuthErrorCodeFromMessage(resp.Message)} } if resp.Type != protocol.TypeAuthOK { return fmt.Errorf("unexpected auth response type: %s", resp.Type) @@ -186,7 +186,11 @@ func (c *Conn) readInit() (protocol.InitMessage, error) { return protocol.InitMessage{}, fmt.Errorf("parse init/error: %w (raw: %s)", err, data) } if ctrl.Type == protocol.TypeError || ctrl.Type == protocol.TypeAuthErr { - return protocol.InitMessage{}, &ServerError{Type: ctrl.Type, Message: ctrl.Message} + return protocol.InitMessage{}, &ServerError{ + Type: ctrl.Type, + Message: ctrl.Message, + Code: protocol.AuthErrorCodeFromMessage(ctrl.Message), + } } return protocol.InitMessage{}, fmt.Errorf("unexpected message type: %s", ctrl.Type) } @@ -254,7 +258,10 @@ func (c *Conn) Close() error { } // AuthError indicates authentication failure (auth_err from server). -type AuthError struct{ Message string } +type AuthError struct { + Message string + Code protocol.AuthErrorCode +} func (e *AuthError) Error() string { return "auth failed: " + e.Message } @@ -262,6 +269,7 @@ func (e *AuthError) Error() string { return "auth failed: " + e.Message } type ServerError struct { Type string Message string + Code protocol.AuthErrorCode } func (e *ServerError) Error() string { diff --git a/internal/ui/view.go b/internal/ui/view.go index b23d7f2..b0f3a4b 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -241,14 +241,35 @@ func (a *App) eventLoop() { } case ipc.EvError: fyne.Do(func() { - if ev.Message != "" { - showError(i18n.T("DlgVPNError"), ev.Message, a.window) + msg := authErrorMessage(ev.Code, ev.Message) + if msg != "" { + showError(i18n.T("DlgAuthError"), msg, a.window) } }) } } } +// authErrorMessage maps a stable auth-error code (carried by the EvError +// IPC event from the daemon) to a localized user-facing string. For an +// unknown or empty code it falls back to the raw server message. +func authErrorMessage(code, fallback string) string { + switch code { + case "wrong_credentials": + return i18n.T("AuthErrWrongCredentials") + case "user_disabled": + return i18n.T("AuthErrUserDisabled") + case "token_invalid": + return i18n.T("AuthErrTokenInvalid") + case "rate_limited": + return i18n.T("AuthErrRateLimited") + case "malformed": + return i18n.T("AuthErrMalformed") + default: + return fallback + } +} + // applyState updates UI elements for a state change. func (a *App) applyState(state string) { switch stats.State(state) { diff --git a/internal/vpn/session.go b/internal/vpn/session.go index 85edf87..34177bc 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -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 {