fix: CDN优选IP支持IPv6、格式校验及错误降级
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s

- replaceHost() 自动为裸 IPv6 地址包裹方括号,修复 IPv6 URL 畸形问题
- 新增 ValidateServerIPs() 校验 IP 格式,保存配置时拦截非法输入
- GetServerIPList() 运行时自动过滤非法 IP 条目
- CDN IP 尝试时 TLS/认证错误降级为非致命,跳过该 IP 继续故障转移
  而非终止整个连接循环(仅原始域名 ipIndex==0 时才判为致命)
- 占位符提示支持 IPv4/IPv6
This commit is contained in:
2026-07-08 23:09:26 +08:00
parent bb4e4552d8
commit c56fc0c21c
6 changed files with 77 additions and 32 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ GO = go
CGO_ENABLED = 1 CGO_ENABLED = 1
WINDRES ?= x86_64-w64-mingw32-windres WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc 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) GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
VERSION = $(SEMVER)-$(GIT_HASH) VERSION = $(SEMVER)-$(GIT_HASH)
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
+2 -1
View File
@@ -45,6 +45,7 @@ DlgDeleteProfileMsg = 'Delete profile "{{.name}}" and its stored credentials?'
DlgProfileTitle = "Profile" DlgProfileTitle = "Profile"
DlgValidationTitle = "Validation" DlgValidationTitle = "Validation"
DlgValidationMsg = "Name, Host, and Username are required." DlgValidationMsg = "Name, Host, and Username are required."
DlgInvalidIPMsg = "Invalid IP address(es): {{.ips}}"
DlgDaemonError = "Daemon Error" DlgDaemonError = "Daemon Error"
DlgCredentialError = "Credential Error" DlgCredentialError = "Credential Error"
DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it." 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" PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
PlaceholderMTU = "0 = use server MTU" PlaceholderMTU = "0 = use server MTU"
PlaceholderPasswordUnchanged = "(unchanged)" 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)" AuthModeBoth = "Both (JWT + Password)"
AuthModeJWT = "JWT" AuthModeJWT = "JWT"
+2 -1
View File
@@ -45,6 +45,7 @@ DlgDeleteProfileMsg = '删除配置"{{.name}}"及其存储的凭据?'
DlgProfileTitle = "配置" DlgProfileTitle = "配置"
DlgValidationTitle = "验证" DlgValidationTitle = "验证"
DlgValidationMsg = "名称、主机名和用户名为必填项。" DlgValidationMsg = "名称、主机名和用户名为必填项。"
DlgInvalidIPMsg = "以下 IP 地址格式无效:{{.ips}}"
DlgDaemonError = "守护进程错误" DlgDaemonError = "守护进程错误"
DlgCredentialError = "凭据错误" DlgCredentialError = "凭据错误"
DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。" DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。"
@@ -93,7 +94,7 @@ FieldMTUOverride = "MTU 覆盖"
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12" PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
PlaceholderMTU = "0 = 使用服务器 MTU" PlaceholderMTU = "0 = 使用服务器 MTU"
PlaceholderPasswordUnchanged = "(未更改)" PlaceholderPasswordUnchanged = "(未更改)"
PlaceholderServerIPs = "例: 1.2.3.4, 5.6.7.8" PlaceholderServerIPs = "支持 IPv4/IPv6例: 1.2.3.4, 5.6.7.8"
AuthModeBoth = "全部(JWT + 密码)" AuthModeBoth = "全部(JWT + 密码)"
AuthModeJWT = "JWT" AuthModeJWT = "JWT"
+21 -9
View File
@@ -4,6 +4,7 @@ package model
import ( import (
"fmt" "fmt"
"net"
"strings" "strings"
"time" "time"
) )
@@ -85,20 +86,31 @@ func (p *ServerProfile) BuildServerURL(ip ...string) string {
return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path) return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path)
} }
// GetServerIPList parses ServerIPs into a string slice. // ValidateServerIPs parses ServerIPs, returning valid IP addresses
func (p *ServerProfile) GetServerIPList() []string { // and any invalid entries (for UI error reporting).
func (p *ServerProfile) ValidateServerIPs() (valid []string, invalid []string) {
if p.ServerIPs == "" { if p.ServerIPs == "" {
return nil return nil, nil
} }
parts := strings.Split(p.ServerIPs, ",") for _, part := range strings.Split(p.ServerIPs, ",") {
var out []string
for _, part := range parts {
s := strings.TrimSpace(part) s := strings.TrimSpace(part)
if s != "" { if s == "" {
out = append(out, 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. // ConnectionStatus records the outcome of a connection attempt.
+12
View File
@@ -3,6 +3,7 @@ package ui
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"lmvpn/internal/i18n" "lmvpn/internal/i18n"
"lmvpn/internal/model" "lmvpn/internal/model"
@@ -270,6 +271,17 @@ func (a *App) saveProfile(editing *model.ServerProfile,
return false 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) port := parseIntDefault(portStr, 443)
mtu := parseIntDefault(mtuStr, 0) mtu := parseIntDefault(mtuStr, 0)
+25 -6
View File
@@ -185,10 +185,14 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
if err != nil { if err != nil {
log.L().Error("VPN connection failed", "error", err) log.L().Error("VPN connection failed", "error", err)
// A TLS certificate verification failure is not retryable: // A TLS certificate verification failure on the original
// the cert won't change between attempts, so stop the // hostname (ipIndex == 0) is not retryable: the cert won't
// loop and surface the reason to the user. // 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) { if tlsconfig.IsTLSError(err) {
if ipIndex == 0 {
log.L().Warn("fatal TLS error, stopping reconnect", "error", err) log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
sm.setState(stats.StateError) sm.setState(stats.StateError)
if sm.onError != nil { if sm.onError != nil {
@@ -198,12 +202,17 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanup() sm.cleanup()
return return
} }
log.L().Warn("TLS error on CDN IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "error", err)
}
// A fatal authentication failure (wrong password, disabled // A fatal authentication failure (wrong password, disabled
// account, expired token, rate limit) is not retryable: // account, expired token, rate limit) on the original
// stop the loop and surface the reason to the user instead // hostname is not retryable. On a CDN edge IP it likely
// of hammering the server forever. // 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 { if code, msg, isFatal := fatalAuthError(err); isFatal {
if ipIndex == 0 {
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg) log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
sm.setState(stats.StateError) sm.setState(stats.StateError)
if sm.onError != nil { if sm.onError != nil {
@@ -213,6 +222,9 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanup() sm.cleanup()
return return
} }
log.L().Warn("auth error on CDN IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "code", code)
}
sm.setState(stats.StateReconnecting) sm.setState(stats.StateReconnecting)
@@ -673,7 +685,14 @@ func wsURLToHTTP(wsURL string) (string, error) {
// replaceHost substitutes the host portion of a URL string. // 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 // 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 { 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 u := rawURL
for _, prefix := range []string{"wss://", "ws://"} { for _, prefix := range []string{"wss://", "ws://"} {
if len(u) > len(prefix) && u[:len(prefix)] == prefix { if len(u) > len(prefix) && u[:len(prefix)] == prefix {