feat: 添加IP竞速拨号器与IP偏好设置,连接状态显示域名与实际IP
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s

- 新增竞速拨号器(racedial.go):域名连接时并行解析所有A/AAAA记录,
  同时对所有IP发起TCP连接,第一个成功的胜出,确保选择延迟最低的地址
- ServerProfile新增IPPreference字段(auto/v4/v6),支持用户指定IP版本偏好
- 填写服务器IP时跳过域名解析直接使用IP,顺序failover
- 连接成功后状态栏显示「已连接 (域名 -> 实际IP)」
- DB迁移v6:server_profiles表添加ip_preference列
- WebSocket拨号与HTTP登录均使用竞速拨号器
- 补充中英文i18n翻译
This commit is contained in:
2026-07-09 18:46:55 +08:00
parent 7febed50ac
commit b81b702433
22 changed files with 398 additions and 146 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.6.3 SEMVER ?= 0.6.5
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)
+12 -4
View File
@@ -14,6 +14,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"lmvpn/internal/transport"
) )
// LoginResult holds the response from a successful /api/login call. // LoginResult holds the response from a successful /api/login call.
@@ -49,19 +51,25 @@ type errorResponse struct {
// host will be an IP address, but the certificate must be verified // host will be an IP address, but the certificate must be verified
// against the real hostname (set tlsCfg.ServerName). // against the real hostname (set tlsCfg.ServerName).
// //
// ipPreference controls which IP address families are used when
// resolving the server hostname ("auto", "v4", "v6").
//
// ctx allows cancellation of the HTTP request (e.g. when the VPN // ctx allows cancellation of the HTTP request (e.g. when the VPN
// session is disconnected while login is in flight). // session is disconnected while login is in flight).
func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) { func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config, ipPreference string) (*LoginResult, error) {
body, err := json.Marshal(loginRequest{Username: username, Password: password}) body, err := json.Marshal(loginRequest{Username: username, Password: password})
if err != nil { if err != nil {
return nil, err return nil, err
} }
url := strings.TrimRight(baseURL, "/") + "/api/login" url := strings.TrimRight(baseURL, "/") + "/api/login"
client := &http.Client{Timeout: 15 * time.Second} httpTransport := &http.Transport{
if tlsCfg != nil { DialContext: transport.NewRaceDialer(ipPreference),
client.Transport = &http.Transport{TLSClientConfig: tlsCfg}
} }
if tlsCfg != nil {
httpTransport.TLSClientConfig = tlsCfg
}
client := &http.Client{Timeout: 15 * time.Second, Transport: httpTransport}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil { if err != nil {
+1
View File
@@ -165,6 +165,7 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
TLSCAPath: req.Config.TLSCAPath, TLSCAPath: req.Config.TLSCAPath,
TLSInsecure: req.Config.TLSInsecure, TLSInsecure: req.Config.TLSInsecure,
TLSPinnedHash: req.Config.TLSPinnedHash, TLSPinnedHash: req.Config.TLSPinnedHash,
IPPreference: req.Config.IPPreference,
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
+19 -1
View File
@@ -55,7 +55,10 @@ func (s *Store) migrate() error {
if err := s.migrateV4(); err != nil { if err := s.migrateV4(); err != nil {
return err return err
} }
return s.migrateV5() if err := s.migrateV5(); err != nil {
return err
}
return s.migrateV6()
} }
func (s *Store) migrateV2() error { func (s *Store) migrateV2() error {
@@ -290,6 +293,20 @@ func (s *Store) migrateV5() error {
return nil return nil
} }
// migrateV6 adds the ip_preference column to server_profiles for
// controlling IPv4/IPv6 address selection when connecting by hostname.
// Idempotent: skips if the column already exists.
func (s *Store) migrateV6() error {
if columnExists(s.db, "server_profiles", "ip_preference") {
return nil
}
_, err := s.db.Exec(`ALTER TABLE server_profiles ADD COLUMN ip_preference TEXT NOT NULL DEFAULT 'auto'`)
if err != nil {
return fmt.Errorf("migrate v6 add ip_preference: %w", err)
}
return nil
}
// splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and // splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and
// IPv6 parts. Used for migration from the old custom_cidrs column. // IPv6 parts. Used for migration from the old custom_cidrs column.
func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) { func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) {
@@ -354,6 +371,7 @@ CREATE TABLE IF NOT EXISTS server_profiles (
cidr_v6_urls TEXT NOT NULL DEFAULT '', cidr_v6_urls TEXT NOT NULL DEFAULT '',
mtu_override INTEGER NOT NULL DEFAULT 0, mtu_override INTEGER NOT NULL DEFAULT 0,
auto_connect INTEGER NOT NULL DEFAULT 0, auto_connect INTEGER NOT NULL DEFAULT 0,
ip_preference TEXT NOT NULL DEFAULT 'auto',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_connected_at DATETIME last_connected_at DATETIME
); );
+12 -6
View File
@@ -16,13 +16,15 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
username, auth_mode, routing_mode, username, auth_mode, routing_mode,
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect, mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash) tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ip_preference)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode, p.Username, p.AuthMode, p.RoutingMode,
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs, p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect, p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
p.IPPreference,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("insert profile: %w", err) return 0, fmt.Errorf("insert profile: %w", err)
@@ -43,6 +45,7 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect, mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
ip_preference,
created_at, last_connected_at created_at, last_connected_at
FROM server_profiles WHERE id = ?`, id, FROM server_profiles WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path, ).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path,
@@ -50,7 +53,7 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs, &p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect, &p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last) &p.IPPreference, &p.CreatedAt, &last)
if err != nil { if err != nil {
return nil, fmt.Errorf("get profile %d: %w", id, err) return nil, fmt.Errorf("get profile %d: %w", id, err)
} }
@@ -68,6 +71,7 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect, mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
ip_preference,
created_at, last_connected_at created_at, last_connected_at
FROM server_profiles ORDER BY name`) FROM server_profiles ORDER BY name`)
if err != nil { if err != nil {
@@ -84,7 +88,7 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs, &p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect, &p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last); err != nil { &p.IPPreference, &p.CreatedAt, &last); err != nil {
return nil, err return nil, err
} }
if last.Valid { if last.Valid {
@@ -103,13 +107,15 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error {
username = ?, auth_mode = ?, routing_mode = ?, username = ?, auth_mode = ?, routing_mode = ?,
cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?, cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?,
mtu_override = ?, auto_connect = ?, mtu_override = ?, auto_connect = ?,
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ? tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?,
ip_preference = ?
WHERE id = ?`, WHERE id = ?`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode, p.Username, p.AuthMode, p.RoutingMode,
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs, p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect, p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID) p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
p.IPPreference, p.ID)
if err != nil { if err != nil {
return fmt.Errorf("update profile %d: %w", p.ID, err) return fmt.Errorf("update profile %d: %w", p.ID, err)
} }
+5
View File
@@ -126,6 +126,11 @@ RoutingModeBypass = "Bypass CIDR"
FetchTimingBefore = "Before Proxy" FetchTimingBefore = "Before Proxy"
FetchTimingAfter = "After Proxy" FetchTimingAfter = "After Proxy"
FieldIPPreference = "IP Preference"
IPPrefAuto = "Auto (Race)"
IPPrefV4 = "IPv4 Only"
IPPrefV6 = "IPv6 Only"
BtnAddURL = "Add URL" BtnAddURL = "Add URL"
BtnRemoveURL = "Remove" BtnRemoveURL = "Remove"
+5
View File
@@ -126,6 +126,11 @@ RoutingModeBypass = "绕过 CIDR"
FetchTimingBefore = "代理前获取" FetchTimingBefore = "代理前获取"
FetchTimingAfter = "代理后获取" FetchTimingAfter = "代理后获取"
FieldIPPreference = "IP 偏好"
IPPrefAuto = "自动(竞速)"
IPPrefV4 = "仅 IPv4"
IPPrefV6 = "仅 IPv6"
BtnAddURL = "添加 URL" BtnAddURL = "添加 URL"
BtnRemoveURL = "删除" BtnRemoveURL = "删除"
+1
View File
@@ -67,6 +67,7 @@ type ClientConfig struct {
TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only) TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only) TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only) TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only)
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
} }
// CIDRURLSource describes a URL that provides a CIDR list. It mirrors // CIDRURLSource describes a URL that provides a CIDR list. It mirrors
+11
View File
@@ -19,6 +19,16 @@ const (
AuthModePassword AuthMode = "password" // {type:auth} first message AuthModePassword AuthMode = "password" // {type:auth} first message
) )
// IPPreference controls which IP address family is used when connecting
// to a server by hostname (ServerIPs empty).
type IPPreference string
const (
IPPrefAuto IPPreference = "auto" // race all resolved addresses (v4 + v6)
IPPrefV4 IPPreference = "v4" // only IPv4 addresses
IPPrefV6 IPPreference = "v6" // only IPv6 addresses
)
// RoutingMode selects which traffic goes through the VPN tunnel. // RoutingMode selects which traffic goes through the VPN tunnel.
type RoutingMode string type RoutingMode string
@@ -65,6 +75,7 @@ type ServerProfile struct {
TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only) TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only) TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only) TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only)
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastConnectedAt *time.Time `json:"last_connected_at"` LastConnectedAt *time.Time `json:"last_connected_at"`
} }
-2
View File
@@ -56,5 +56,3 @@ func LogFile() string { return Paths.Log + "/lmvpn.log" }
// DaemonLogFile returns the path to the daemon log file. // DaemonLogFile returns the path to the daemon log file.
func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" } func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" }
+23 -3
View File
@@ -34,6 +34,8 @@ type Stats struct {
state atomic.Value // State state atomic.Value // State
assignedIP atomic.Value // string (IPv4) assignedIP atomic.Value // string (IPv4)
assignedIP6 atomic.Value // string (IPv6, may be empty) assignedIP6 atomic.Value // string (IPv6, may be empty)
serverHost atomic.Value // string (server hostname or IP from URL)
connectedIP atomic.Value // string (actual remote IP of the connection)
routeLoading atomic.Bool // true while deferred routes are being applied routeLoading atomic.Bool // true while deferred routes are being applied
connectStep atomic.Value // string (human-readable connection step) connectStep atomic.Value // string (human-readable connection step)
cidrError atomic.Value // string (CIDR fetch error message, empty = no error) cidrError atomic.Value // string (CIDR fetch error message, empty = no error)
@@ -79,6 +81,8 @@ func New() *Stats {
s.state.Store(StateDisconnected) s.state.Store(StateDisconnected)
s.assignedIP.Store("") s.assignedIP.Store("")
s.assignedIP6.Store("") s.assignedIP6.Store("")
s.serverHost.Store("")
s.connectedIP.Store("")
s.connectStep.Store("") s.connectStep.Store("")
s.cidrError.Store("") s.cidrError.Store("")
return s return s
@@ -90,12 +94,16 @@ func (s *Stats) SetState(st State) { s.state.Store(st) }
// State returns the current state. // State returns the current state.
func (s *Stats) State() State { return s.state.Load().(State) } func (s *Stats) State() State { return s.state.Load().(State) }
// SetConnected marks the session as connected, recording the time and // SetConnected marks the session as connected, recording the time,
// assigned IP addresses. ip6 may be empty for an IPv4-only server. // assigned IP addresses, and server connection info. ip6 may be empty
func (s *Stats) SetConnected(ip, ip6 string) { // for an IPv4-only server. serverHost is the hostname/IP from the
// server URL; connectedIP is the actual remote IP of the connection.
func (s *Stats) SetConnected(ip, ip6, serverHost, connectedIP string) {
s.ConnectedAt.Store(time.Now().Unix()) s.ConnectedAt.Store(time.Now().Unix())
s.assignedIP.Store(ip) s.assignedIP.Store(ip)
s.assignedIP6.Store(ip6) s.assignedIP6.Store(ip6)
s.serverHost.Store(serverHost)
s.connectedIP.Store(connectedIP)
s.state.Store(StateConnected) s.state.Store(StateConnected)
} }
@@ -104,6 +112,8 @@ func (s *Stats) SetDisconnected() {
s.ConnectedAt.Store(0) s.ConnectedAt.Store(0)
s.assignedIP.Store("") s.assignedIP.Store("")
s.assignedIP6.Store("") s.assignedIP6.Store("")
s.serverHost.Store("")
s.connectedIP.Store("")
s.state.Store(StateDisconnected) s.state.Store(StateDisconnected)
s.routeLoading.Store(false) s.routeLoading.Store(false)
s.connectStep.Store("") s.connectStep.Store("")
@@ -149,6 +159,12 @@ func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
// AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty). // AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty).
func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) } func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) }
// ServerHost returns the server hostname/IP from the connection URL.
func (s *Stats) ServerHost() string { return s.serverHost.Load().(string) }
// ConnectedIP returns the actual remote IP of the connection.
func (s *Stats) ConnectedIP() string { return s.connectedIP.Load().(string) }
// Snapshot returns a point-in-time copy of all counters. // Snapshot returns a point-in-time copy of all counters.
// //
// Per-family byte counters are read directly. The combined RxBytes/ // Per-family byte counters are read directly. The combined RxBytes/
@@ -174,6 +190,8 @@ type Snapshot struct {
ConnectedAt time.Time ConnectedAt time.Time
AssignedIP string AssignedIP string
AssignedIP6 string AssignedIP6 string
ServerHost string `json:"server_host,omitempty"` // server hostname/IP from URL
ConnectedIP string `json:"connected_ip,omitempty"` // actual remote IP of the connection
State State State State
Uptime time.Duration Uptime time.Duration
@@ -203,6 +221,8 @@ func (s *Stats) Snapshot() Snapshot {
TxBytes: txv4 + txv6, TxBytes: txv4 + txv6,
AssignedIP: s.AssignedIP(), AssignedIP: s.AssignedIP(),
AssignedIP6: s.AssignedIP6(), AssignedIP6: s.AssignedIP6(),
ServerHost: s.ServerHost(),
ConnectedIP: s.ConnectedIP(),
State: s.State(), State: s.State(),
} }
ts := s.ConnectedAt.Load() ts := s.ConnectedAt.Load()
+135
View File
@@ -0,0 +1,135 @@
package transport
import (
"context"
"fmt"
"net"
"time"
)
// NewRaceDialer returns a dial function suitable for use as a
// websocket.Dialer.NetDialContext or http.Transport.DialContext.
//
// When the host in addr is a domain name, it resolves all A/AAAA
// records and dials them concurrently, returning the first successful
// connection (true parallel racing, not Happy Eyeballs staggered start).
//
// preference controls which address families participate:
// - "auto": race all resolved addresses (v4 + v6)
// - "v4": only IPv4 addresses
// - "v6": only IPv6 addresses
//
// When the host is an IP literal (CDN failover or direct IP mode), it
// dials directly without racing — there is only one target.
func NewRaceDialer(preference string) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("split host port: %w", err)
}
// IP literal: dial directly, no racing.
if ip := net.ParseIP(host); ip != nil {
d := &net.Dialer{}
return d.DialContext(ctx, network, addr)
}
// Domain name: resolve and race.
ips, err := resolveAndFilter(ctx, host, preference)
if err != nil {
return nil, err
}
if len(ips) == 1 {
d := &net.Dialer{}
return d.DialContext(ctx, network, net.JoinHostPort(ips[0], port))
}
return raceDial(ctx, network, ips, port)
}
}
// resolveAndFilter resolves a hostname and filters by preference.
func resolveAndFilter(ctx context.Context, host, preference string) ([]string, error) {
resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
addrs, err := net.DefaultResolver.LookupIPAddr(resolveCtx, host)
if err != nil {
return nil, fmt.Errorf("lookup %s: %w", host, err)
}
var ips []string
for _, a := range addrs {
isV4 := a.IP.To4() != nil
switch preference {
case "v4":
if isV4 {
ips = append(ips, a.IP.String())
}
case "v6":
if !isV4 {
ips = append(ips, a.IP.String())
}
default: // "auto" or unspecified
ips = append(ips, a.IP.String())
}
}
if len(ips) == 0 {
return nil, fmt.Errorf("lookup %s: no addresses for preference %q", host, preference)
}
return ips, nil
}
// raceDial dials all IPs concurrently and returns the first successful
// connection. Losing connections are closed and their errors discarded.
func raceDial(ctx context.Context, network string, ips []string, port string) (net.Conn, error) {
type result struct {
conn net.Conn
err error
}
raceCtx, cancel := context.WithCancel(ctx)
defer cancel()
resultCh := make(chan result, len(ips))
for _, ip := range ips {
go func(target string) {
d := &net.Dialer{}
c, err := d.DialContext(raceCtx, network, net.JoinHostPort(target, port))
select {
case resultCh <- result{conn: c, err: err}:
case <-raceCtx.Done():
if c != nil {
c.Close()
}
}
}(ip)
}
var firstErr error
for i := 0; i < len(ips); i++ {
r := <-resultCh
if r.err == nil {
// Winner. Cancel remaining dialers; drain and close
// any late successful connections.
cancel()
for j := i + 1; j < len(ips); j++ {
if late := <-resultCh; late.conn != nil {
late.conn.Close()
}
}
return r.conn, nil
}
if firstErr == nil {
firstErr = r.err
}
}
if firstErr == nil {
firstErr = fmt.Errorf("all dial attempts failed")
}
return nil, firstErr
}
+17
View File
@@ -18,6 +18,7 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"sync" "sync"
@@ -37,6 +38,7 @@ type HandshakeConfig struct {
Password string // for password auth (method B), or fallback Password string // for password auth (method B), or fallback
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost) TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
} }
// Conn is an established VPN tunnel connection. // Conn is an established VPN tunnel connection.
@@ -53,6 +55,7 @@ type Conn struct {
// error occurs. // error occurs.
func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) { func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
dialer := websocket.Dialer{ dialer := websocket.Dialer{
NetDialContext: NewRaceDialer(cfg.IPPreference),
HandshakeTimeout: 15 * time.Second, HandshakeTimeout: 15 * time.Second,
ReadBufferSize: 4096, // match server (handler.go:17) ReadBufferSize: 4096, // match server (handler.go:17)
WriteBufferSize: 4096, // match server (handler.go:18) WriteBufferSize: 4096, // match server (handler.go:18)
@@ -269,6 +272,20 @@ func (c *Conn) AssignedIP() string { return c.init.IP }
// AssignedIP6 returns the IPv6 assigned by the server (empty if none). // AssignedIP6 returns the IPv6 assigned by the server (empty if none).
func (c *Conn) AssignedIP6() string { return c.init.IP6 } func (c *Conn) AssignedIP6() string { return c.init.IP6 }
// RemoteIP returns the remote IP address of the underlying TCP
// connection (the actual server/CDN IP the WebSocket is connected to).
// Returns an empty string if the connection is not established.
func (c *Conn) RemoteIP() string {
if c.ws == nil {
return ""
}
addr := c.ws.RemoteAddr()
if tcpAddr, ok := addr.(*net.TCPAddr); ok {
return tcpAddr.IP.String()
}
return addr.String()
}
// Close terminates the connection. // Close terminates the connection.
func (c *Conn) Close() error { func (c *Conn) Close() error {
c.mu.Lock() c.mu.Lock()
-2
View File
@@ -153,5 +153,3 @@ func execCmd(name string, arg ...string) error {
} }
return nil return nil
} }
-2
View File
@@ -86,5 +86,3 @@ func ensureDaemon() (*ipc.Client, error) {
} }
return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile) return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile)
} }
+1 -1
View File
@@ -29,7 +29,7 @@ func launchElevated(exe, daemonBin, home string, uid, gid int) error {
} }
// shellQuote wraps a string in single quotes for shell safety. // shellQuote wraps a string in single quotes for shell safety.
// Embedded single quotes are escaped using the '\'' pattern. // Embedded single quotes are escaped using the '\ pattern.
func shellQuote(s string) string { func shellQuote(s string) string {
result := "'" result := "'"
for _, r := range s { for _, r := range s {
+17 -3
View File
@@ -25,6 +25,8 @@ var (
protoCodes = []string{"wss", "ws"} protoCodes = []string{"wss", "ws"}
fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)} fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)}
ipPrefCodes = []string{string(model.IPPrefAuto), string(model.IPPrefV4), string(model.IPPrefV6)}
) )
func authModeLabels() []string { func authModeLabels() []string {
@@ -43,6 +45,10 @@ func fetchTimingLabels() []string {
return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")} return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")}
} }
func ipPrefLabels() []string {
return []string{i18n.T("IPPrefAuto"), i18n.T("IPPrefV4"), i18n.T("IPPrefV6")}
}
// codeIndex returns the position of code in codes, or 0 if not found. // codeIndex returns the position of code in codes, or 0 if not found.
func codeIndex(codes []string, code string) int { func codeIndex(codes []string, code string) int {
for i, c := range codes { for i, c := range codes {
@@ -198,6 +204,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
authSelect := widget.NewSelect(authModeLabels(), nil) authSelect := widget.NewSelect(authModeLabels(), nil)
routeSelect := widget.NewSelect(routeModeLabels(), nil) routeSelect := widget.NewSelect(routeModeLabels(), nil)
ipPrefSelect := widget.NewSelect(ipPrefLabels(), nil)
cidrV4Entry := widget.NewMultiLineEntry() cidrV4Entry := widget.NewMultiLineEntry()
cidrV4Entry.Wrapping = fyne.TextWrapOff cidrV4Entry.Wrapping = fyne.TextWrapOff
@@ -300,6 +307,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
userEntry.SetText(editing.Username) userEntry.SetText(editing.Username)
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode))) authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode))) routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
ipPrefSelect.SetSelectedIndex(codeIndex(ipPrefCodes, editing.IPPreference))
cidrV4Entry.SetText(editing.CIDRV4) cidrV4Entry.SetText(editing.CIDRV4)
cidrV6Entry.SetText(editing.CIDRV6) cidrV6Entry.SetText(editing.CIDRV6)
v4URLList.loadFromJSON(editing.CIDRV4URLs) v4URLList.loadFromJSON(editing.CIDRV4URLs)
@@ -316,6 +324,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
pathEntry.SetText("/ws") pathEntry.SetText("/ws")
authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth))) authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull))) routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull)))
ipPrefSelect.SetSelectedIndex(0) // auto
mtuEntry.SetText("0") mtuEntry.SetText("0")
} }
@@ -349,9 +358,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry), container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry),
), ),
container.NewGridWithColumns(2, container.NewGridWithColumns(3,
container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect), container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect),
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect), container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
container.NewVBox(widget.NewLabel(i18n.T("FieldIPPreference")), ipPrefSelect),
), ),
cidrSection, cidrSection,
@@ -394,7 +404,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
mtuEntry.Text, mtuEntry.Text,
tlsCaPEMEntry.Text, tlsCaPathEntry.Text, tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked, tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
isNew) { isNew,
selectedCode(ipPrefCodes, ipPrefSelect.SelectedIndex())) {
profileWin.Close() profileWin.Close()
} }
}) })
@@ -414,7 +425,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
func (a *App) saveProfile(editing *model.ServerProfile, func (a *App) saveProfile(editing *model.ServerProfile,
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode,
cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr, cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr,
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool { tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool,
ipPreference string) bool {
if name == "" || host == "" || user == "" { if name == "" || host == "" || user == "" {
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window) showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
return false return false
@@ -454,6 +466,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
TLSCAPath: tlsCaPath, TLSCAPath: tlsCaPath,
TLSInsecure: tlsInsecure, TLSInsecure: tlsInsecure,
TLSPinnedHash: tlsPinnedHash, TLSPinnedHash: tlsPinnedHash,
IPPreference: ipPreference,
} }
id, err := a.db.CreateProfile(p) id, err := a.db.CreateProfile(p)
if err != nil { if err != nil {
@@ -486,6 +499,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
editing.TLSCAPath = tlsCaPath editing.TLSCAPath = tlsCaPath
editing.TLSInsecure = tlsInsecure editing.TLSInsecure = tlsInsecure
editing.TLSPinnedHash = tlsPinnedHash editing.TLSPinnedHash = tlsPinnedHash
editing.IPPreference = ipPreference
if err := a.db.UpdateProfile(editing); err != nil { if err := a.db.UpdateProfile(editing); err != nil {
showError(i18n.T("DlgSaveError"), err.Error(), a.window) showError(i18n.T("DlgSaveError"), err.Error(), a.window)
return false return false
+4
View File
@@ -215,6 +215,7 @@ func (a *App) onConnect() {
TLSCAPath: p.TLSCAPath, TLSCAPath: p.TLSCAPath,
TLSInsecure: p.TLSInsecure, TLSInsecure: p.TLSInsecure,
TLSPinnedHash: p.TLSPinnedHash, TLSPinnedHash: p.TLSPinnedHash,
IPPreference: p.IPPreference,
} }
if err := ipc.SendStart(client, cfg); err != nil { if err := ipc.SendStart(client, cfg); err != nil {
fyne.Do(func() { fyne.Do(func() {
@@ -428,6 +429,9 @@ func (a *App) applyStats(s stats.Snapshot) {
stepLabel := connectStepLabel(s.ConnectStep) stepLabel := connectStepLabel(s.ConnectStep)
if stepLabel != "" { if stepLabel != "" {
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")") a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
} else if s.ServerHost != "" && s.ConnectedIP != "" {
a.stateLabel.SetText(fmt.Sprintf("%s (%s -> %s)",
i18n.T("StateConnected"), s.ServerHost, s.ConnectedIP))
} else { } else {
a.stateLabel.SetText(i18n.T("StateConnected")) a.stateLabel.SetText(i18n.T("StateConnected"))
} }
+28 -15
View File
@@ -50,6 +50,7 @@ type SessionConfig struct {
TLSCAPath string // CA cert file path (wss only) TLSCAPath string // CA cert file path (wss only)
TLSInsecure bool // skip cert verification (wss only) TLSInsecure bool // skip cert verification (wss only)
TLSPinnedHash string // SHA-256 cert pin (wss only) TLSPinnedHash string // SHA-256 cert pin (wss only)
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
} }
// SessionManager manages a single VPN session with auto-reconnect. // SessionManager manages a single VPN session with auto-reconnect.
@@ -212,8 +213,16 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
backoff := time.Second backoff := time.Second
maxBackoff := 60 * time.Second maxBackoff := 60 * time.Second
// Build the full target list: original host first, then CDN IPs. // Build the full target list. When ServerIPs is empty, connect via
targets := append([]string{""}, cfg.ServerIPs...) // "" = use base URL // hostname (IPPreference + race dialer apply). When ServerIPs is
// non-empty, skip hostname and use IPs directly with failover.
usingHostname := len(cfg.ServerIPs) == 0
var targets []string
if usingHostname {
targets = []string{""} // "" = use base URL (hostname)
} else {
targets = cfg.ServerIPs // direct IP mode: sequential failover
}
ipIndex := 0 ipIndex := 0
for { for {
@@ -223,7 +232,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
} }
targetIP := "" targetIP := ""
if ipIndex > 0 && ipIndex < len(targets) { if ipIndex < len(targets) {
targetIP = targets[ipIndex] targetIP = targets[ipIndex]
} }
@@ -242,13 +251,13 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanupResources() sm.cleanupResources()
// A TLS certificate verification failure on the original // A TLS certificate verification failure on the original
// hostname (ipIndex == 0) is not retryable: the cert won't // hostname (ipIndex == 0, hostname mode) is not retryable:
// change between attempts, so stop the loop and surface the // the cert won't change between attempts, so stop the loop
// reason to the user. On a CDN edge IP (ipIndex > 0) the // and surface the reason to the user. On a CDN edge IP the
// TLS error likely means that IP points to a different // TLS error likely means that IP points to a different
// server; skip it and try the next target. // server; skip it and try the next target.
if tlsconfig.IsTLSError(err) { if tlsconfig.IsTLSError(err) {
if ipIndex == 0 { if usingHostname && 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 {
@@ -258,7 +267,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanup() sm.cleanup()
return return
} }
log.L().Warn("TLS error on CDN IP, skipping", log.L().Warn("TLS error on IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "error", err) "index", ipIndex, "ip", targets[ipIndex], "error", err)
} }
@@ -268,7 +277,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
// means the IP points to a different server that returned // means the IP points to a different server that returned
// 401/403, so skip it instead of stopping the loop. // 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 { if usingHostname && 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 {
@@ -278,16 +287,16 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanup() sm.cleanup()
return return
} }
log.L().Warn("auth error on CDN IP, skipping", log.L().Warn("auth error on IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "code", code) "index", ipIndex, "ip", targets[ipIndex], "code", code)
} }
sm.setState(stats.StateReconnecting) sm.setState(stats.StateReconnecting)
// Try next CDN IP immediately. // Try next target IP immediately.
ipIndex++ ipIndex++
if ipIndex < len(targets) { if ipIndex < len(targets) {
log.L().Info("trying next CDN IP", "index", ipIndex, "ip", targets[ipIndex]) log.L().Info("trying next server IP", "index", ipIndex, "ip", targets[ipIndex])
continue continue
} }
// All targets exhausted; reset and wait with backoff. // All targets exhausted; reset and wait with backoff.
@@ -389,7 +398,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
if err != nil { if err != nil {
return fmt.Errorf("parse server URL: %w", err) return fmt.Errorf("parse server URL: %w", err)
} }
result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg) result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg, cfg.IPPreference)
if err != nil { if err != nil {
if cfg.AuthMode == model.AuthModeBoth { if cfg.AuthMode == model.AuthModeBoth {
// Fall back to password auth. // Fall back to password auth.
@@ -410,6 +419,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
Token: token, Token: token,
Username: cfg.Username, Username: cfg.Username,
Password: cfg.Password, Password: cfg.Password,
IPPreference: cfg.IPPreference,
OnInit: func(init protocol.InitMessage) error { OnInit: func(init protocol.InitMessage) error {
return sm.setupTUN(init, cfg, beforeCIDRs) return sm.setupTUN(init, cfg, beforeCIDRs)
}, },
@@ -448,13 +458,16 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
sm.conn = conn sm.conn = conn
sm.mu.Unlock() sm.mu.Unlock()
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6) serverHost := serverHostFromURL(cfg.ServerURL)
connectedIP := conn.RemoteIP()
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6, serverHost, connectedIP)
sm.stats.SetConnectStep("load_routes") sm.stats.SetConnectStep("load_routes")
sm.setState(stats.StateConnected) sm.setState(stats.StateConnected)
log.L().Info("VPN connected", log.L().Info("VPN connected",
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP, "ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
"ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6, "ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6,
"mtu", conn.Init().MTU) "mtu", conn.Init().MTU,
"server_host", serverHost, "connected_ip", connectedIP)
// Start stats reporter. // Start stats reporter.
statsDone := make(chan struct{}) statsDone := make(chan struct{})