diff --git a/Makefile b/Makefile index bcfa0bb..3f0edfd 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GO = go CGO_ENABLED = 1 WINDRES ?= x86_64-w64-mingw32-windres MINGW_CC ?= x86_64-w64-mingw32-gcc -SEMVER ?= 0.4.3 +SEMVER ?= 0.4.4 GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) VERSION = $(SEMVER)-$(GIT_HASH) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index b597cf0..820cbf5 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -45,6 +45,7 @@ DlgDeleteProfileMsg = 'Delete profile "{{.name}}" and its stored credentials?' DlgProfileTitle = "Profile" DlgValidationTitle = "Validation" DlgValidationMsg = "Name, Host, and Username are required." +DlgInvalidIPMsg = "Invalid IP address(es): {{.ips}}" DlgDaemonError = "Daemon Error" DlgCredentialError = "Credential Error" DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it." @@ -93,7 +94,7 @@ FieldMTUOverride = "MTU Override" PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12" PlaceholderMTU = "0 = use server MTU" PlaceholderPasswordUnchanged = "(unchanged)" -PlaceholderServerIPs = "e.g. 1.2.3.4, 5.6.7.8" +PlaceholderServerIPs = "IPv4/IPv6 supported, e.g. 1.2.3.4, 5.6.7.8" AuthModeBoth = "Both (JWT + Password)" AuthModeJWT = "JWT" diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index fbdcb7b..e92cbcc 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -45,6 +45,7 @@ DlgDeleteProfileMsg = '删除配置"{{.name}}"及其存储的凭据?' DlgProfileTitle = "配置" DlgValidationTitle = "验证" DlgValidationMsg = "名称、主机名和用户名为必填项。" +DlgInvalidIPMsg = "以下 IP 地址格式无效:{{.ips}}" DlgDaemonError = "守护进程错误" DlgCredentialError = "凭据错误" DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。" @@ -93,7 +94,7 @@ FieldMTUOverride = "MTU 覆盖" PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12" PlaceholderMTU = "0 = 使用服务器 MTU" PlaceholderPasswordUnchanged = "(未更改)" -PlaceholderServerIPs = "例: 1.2.3.4, 5.6.7.8" +PlaceholderServerIPs = "支持 IPv4/IPv6,例: 1.2.3.4, 5.6.7.8" AuthModeBoth = "全部(JWT + 密码)" AuthModeJWT = "JWT" diff --git a/internal/model/model.go b/internal/model/model.go index e0b84a2..888ed5e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -4,6 +4,7 @@ package model import ( "fmt" + "net" "strings" "time" ) @@ -85,20 +86,31 @@ func (p *ServerProfile) BuildServerURL(ip ...string) string { return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path) } -// GetServerIPList parses ServerIPs into a string slice. -func (p *ServerProfile) GetServerIPList() []string { +// ValidateServerIPs parses ServerIPs, returning valid IP addresses +// and any invalid entries (for UI error reporting). +func (p *ServerProfile) ValidateServerIPs() (valid []string, invalid []string) { if p.ServerIPs == "" { - return nil + return nil, nil } - parts := strings.Split(p.ServerIPs, ",") - var out []string - for _, part := range parts { + for _, part := range strings.Split(p.ServerIPs, ",") { s := strings.TrimSpace(part) - if s != "" { - out = append(out, s) + if s == "" { + continue + } + if net.ParseIP(s) != nil { + valid = append(valid, s) + } else { + invalid = append(invalid, s) } } - return out + return +} + +// GetServerIPList returns only valid IP addresses from ServerIPs, +// silently filtering out any malformed entries. +func (p *ServerProfile) GetServerIPList() []string { + valid, _ := p.ValidateServerIPs() + return valid } // ConnectionStatus records the outcome of a connection attempt. diff --git a/internal/ui/profile.go b/internal/ui/profile.go index 8692ee8..46b71d5 100644 --- a/internal/ui/profile.go +++ b/internal/ui/profile.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "strconv" + "strings" "lmvpn/internal/i18n" "lmvpn/internal/model" @@ -270,6 +271,17 @@ func (a *App) saveProfile(editing *model.ServerProfile, return false } + if ips != "" { + tmp := &model.ServerProfile{ServerIPs: ips} + _, invalid := tmp.ValidateServerIPs() + if len(invalid) > 0 { + showError(i18n.T("DlgValidationTitle"), + fmt.Sprintf(i18n.T("DlgInvalidIPMsg"), strings.Join(invalid, ", ")), + a.window) + return false + } + } + port := parseIntDefault(portStr, 443) mtu := parseIntDefault(mtuStr, 0) diff --git a/internal/vpn/session.go b/internal/vpn/session.go index f76d74b..5a3168b 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -185,33 +185,45 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { if err != nil { log.L().Error("VPN connection failed", "error", err) - // A TLS certificate verification failure is not retryable: - // the cert won't change between attempts, so stop the - // loop and surface the reason to the user. + // A TLS certificate verification failure on the original + // hostname (ipIndex == 0) is not retryable: the cert won't + // change between attempts, so stop the loop and surface the + // reason to the user. On a CDN edge IP (ipIndex > 0) the + // TLS error likely means that IP points to a different + // server; skip it and try the next target. if tlsconfig.IsTLSError(err) { - log.L().Warn("fatal TLS error, stopping reconnect", "error", err) - sm.setState(stats.StateError) - if sm.onError != nil { - sm.onError("tls_error", err.Error()) + if ipIndex == 0 { + log.L().Warn("fatal TLS error, stopping reconnect", "error", err) + sm.setState(stats.StateError) + if sm.onError != nil { + sm.onError("tls_error", err.Error()) + } + fatal = true + sm.cleanup() + return } - fatal = true - sm.cleanup() - return + log.L().Warn("TLS error on CDN IP, skipping", + "index", ipIndex, "ip", targets[ipIndex], "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. + // account, expired token, rate limit) on the original + // hostname is not retryable. On a CDN edge IP it likely + // means the IP points to a different server that returned + // 401/403, so skip it instead of stopping the loop. 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) + if ipIndex == 0 { + 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 } - fatal = true - sm.cleanup() - return + log.L().Warn("auth error on CDN IP, skipping", + "index", ipIndex, "ip", targets[ipIndex], "code", code) } sm.setState(stats.StateReconnecting) @@ -673,7 +685,14 @@ func wsURLToHTTP(wsURL string) (string, error) { // replaceHost substitutes the host portion of a URL string. // e.g. wss://host:443/ws with 1.2.3.4 → wss://1.2.3.4:443/ws +// Bare IPv6 addresses are automatically bracketed: +// wss://host:443/ws with 2001:db8::1 → wss://[2001:db8::1]:443/ws func replaceHost(rawURL, newHost string) string { + // Auto-bracket bare IPv6 addresses so the colons in the address + // are not confused with the port separator. + if ip := net.ParseIP(newHost); ip != nil && ip.To4() == nil && !strings.HasPrefix(newHost, "[") { + newHost = "[" + newHost + "]" + } u := rawURL for _, prefix := range []string{"wss://", "ws://"} { if len(u) > len(prefix) && u[:len(prefix)] == prefix {