diff --git a/Makefile b/Makefile index 2f7d43f..620c629 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.6.3 +SEMVER ?= 0.6.5 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/auth/auth.go b/internal/auth/auth.go index ffb3410..6cf34ee 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -14,6 +14,8 @@ import ( "net/http" "strings" "time" + + "lmvpn/internal/transport" ) // 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 // 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 // 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}) if err != nil { return nil, err } url := strings.TrimRight(baseURL, "/") + "/api/login" - client := &http.Client{Timeout: 15 * time.Second} - if tlsCfg != nil { - client.Transport = &http.Transport{TLSClientConfig: tlsCfg} + httpTransport := &http.Transport{ + DialContext: transport.NewRaceDialer(ipPreference), } + 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)) if err != nil { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4b01992..7533921 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -165,6 +165,7 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) { TLSCAPath: req.Config.TLSCAPath, TLSInsecure: req.Config.TLSInsecure, TLSPinnedHash: req.Config.TLSPinnedHash, + IPPreference: req.Config.IPPreference, } ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/db/db.go b/internal/db/db.go index e54d355..4f54961 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -55,7 +55,10 @@ func (s *Store) migrate() error { if err := s.migrateV4(); err != nil { return err } - return s.migrateV5() + if err := s.migrateV5(); err != nil { + return err + } + return s.migrateV6() } func (s *Store) migrateV2() error { @@ -290,6 +293,20 @@ func (s *Store) migrateV5() error { 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 // IPv6 parts. Used for migration from the old custom_cidrs column. 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 '', mtu_override 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, last_connected_at DATETIME ); diff --git a/internal/db/profile.go b/internal/db/profile.go index fa0101d..0bc3f99 100644 --- a/internal/db/profile.go +++ b/internal/db/profile.go @@ -16,13 +16,15 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) { username, auth_mode, routing_mode, cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, mtu_override, auto_connect, - tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, + ip_preference) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Username, p.AuthMode, p.RoutingMode, p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs, p.MTUOverride, p.AutoConnect, p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, + p.IPPreference, ) if err != nil { 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, mtu_override, auto_connect, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, + ip_preference, created_at, last_connected_at FROM server_profiles WHERE id = ?`, id, ).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.MTUOverride, &p.AutoConnect, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, - &p.CreatedAt, &last) + &p.IPPreference, &p.CreatedAt, &last) if err != nil { 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, mtu_override, auto_connect, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, + ip_preference, created_at, last_connected_at FROM server_profiles ORDER BY name`) if err != nil { @@ -84,7 +88,7 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) { &p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs, &p.MTUOverride, &p.AutoConnect, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, - &p.CreatedAt, &last); err != nil { + &p.IPPreference, &p.CreatedAt, &last); err != nil { return nil, err } if last.Valid { @@ -103,13 +107,15 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error { username = ?, auth_mode = ?, routing_mode = ?, cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?, 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 = ?`, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Username, p.AuthMode, p.RoutingMode, p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs, 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 { return fmt.Errorf("update profile %d: %w", p.ID, err) } diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index d289adf..58bcce4 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -126,6 +126,11 @@ RoutingModeBypass = "Bypass CIDR" FetchTimingBefore = "Before Proxy" FetchTimingAfter = "After Proxy" +FieldIPPreference = "IP Preference" +IPPrefAuto = "Auto (Race)" +IPPrefV4 = "IPv4 Only" +IPPrefV6 = "IPv6 Only" + BtnAddURL = "Add URL" BtnRemoveURL = "Remove" diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index 3fa5e34..03e1af8 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -126,6 +126,11 @@ RoutingModeBypass = "绕过 CIDR" FetchTimingBefore = "代理前获取" FetchTimingAfter = "代理后获取" +FieldIPPreference = "IP 偏好" +IPPrefAuto = "自动(竞速)" +IPPrefV4 = "仅 IPv4" +IPPrefV6 = "仅 IPv6" + BtnAddURL = "添加 URL" BtnRemoveURL = "删除" diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 3388733..83bfb23 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -29,8 +29,8 @@ const ( CmdStop = "stop" CmdShutdown = "shutdown" CmdStats = "stats" - CmdVersion = "version" // query daemon build version - CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes + CmdVersion = "version" // query daemon build version + CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes ) // Event types sent from daemon to GUI. @@ -51,22 +51,23 @@ type Request struct { // package (which needs root-only TUN) into the GUI. type ClientConfig struct { ServerURL string `json:"server_url"` - SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN - ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover + SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN + ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover Username string `json:"username"` Password string `json:"password"` Token string `json:"token"` AuthMode string `json:"auth_mode"` - RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass" - CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs - CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs - CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources - CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources + RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass" + CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs + CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs + CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources + CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources MTUOverride int `json:"mtu_override"` TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only) TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only) TLSInsecure bool `json:"tls_insecure"` // skip cert verification (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 @@ -83,7 +84,7 @@ type Event struct { State string `json:"state,omitempty"` ConnectStep string `json:"connect_step,omitempty"` // current connection step (for EvState) Stats *stats.Snapshot `json:"stats,omitempty"` - Code string `json:"code,omitempty"` // stable auth-error code for EvError + Code string `json:"code,omitempty"` // stable auth-error code for EvError Message string `json:"message,omitempty"` } diff --git a/internal/model/model.go b/internal/model/model.go index 17e626d..6c156f9 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -19,6 +19,16 @@ const ( 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. type RoutingMode string @@ -61,10 +71,11 @@ type ServerProfile struct { CIDRV6URLs string `json:"cidr_v6_urls"` // JSON array of CIDRURLSource for IPv6 MTUOverride int `json:"mtu_override"` // 0 = use server MTU AutoConnect bool `json:"auto_connect"` - TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (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) - TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only) + TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (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) + 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"` LastConnectedAt *time.Time `json:"last_connected_at"` } diff --git a/internal/paths/paths.go b/internal/paths/paths.go index d377bbc..a8367ef 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -56,5 +56,3 @@ func LogFile() string { return Paths.Log + "/lmvpn.log" } // DaemonLogFile returns the path to the daemon log file. func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" } - - diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 5c2fb0a..ed57972 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -26,14 +26,16 @@ const ( // counters in Snapshot, so callers that only need the total still // work. type Stats struct { - RxBytesV4 atomic.Int64 - RxBytesV6 atomic.Int64 - TxBytesV4 atomic.Int64 - TxBytesV6 atomic.Int64 - ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected - state atomic.Value // State - assignedIP atomic.Value // string (IPv4) - assignedIP6 atomic.Value // string (IPv6, may be empty) + RxBytesV4 atomic.Int64 + RxBytesV6 atomic.Int64 + TxBytesV4 atomic.Int64 + TxBytesV6 atomic.Int64 + ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected + state atomic.Value // State + assignedIP atomic.Value // string (IPv4) + 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 connectStep atomic.Value // string (human-readable connection step) cidrError atomic.Value // string (CIDR fetch error message, empty = no error) @@ -79,6 +81,8 @@ func New() *Stats { s.state.Store(StateDisconnected) s.assignedIP.Store("") s.assignedIP6.Store("") + s.serverHost.Store("") + s.connectedIP.Store("") s.connectStep.Store("") s.cidrError.Store("") return s @@ -90,12 +94,16 @@ func (s *Stats) SetState(st State) { s.state.Store(st) } // State returns the current state. func (s *Stats) State() State { return s.state.Load().(State) } -// SetConnected marks the session as connected, recording the time and -// assigned IP addresses. ip6 may be empty for an IPv4-only server. -func (s *Stats) SetConnected(ip, ip6 string) { +// SetConnected marks the session as connected, recording the time, +// assigned IP addresses, and server connection info. ip6 may be empty +// 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.assignedIP.Store(ip) s.assignedIP6.Store(ip6) + s.serverHost.Store(serverHost) + s.connectedIP.Store(connectedIP) s.state.Store(StateConnected) } @@ -104,6 +112,8 @@ func (s *Stats) SetDisconnected() { s.ConnectedAt.Store(0) s.assignedIP.Store("") s.assignedIP6.Store("") + s.serverHost.Store("") + s.connectedIP.Store("") s.state.Store(StateDisconnected) s.routeLoading.Store(false) 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). 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. // // Per-family byte counters are read directly. The combined RxBytes/ @@ -174,6 +190,8 @@ type Snapshot struct { ConnectedAt time.Time AssignedIP 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 Uptime time.Duration @@ -184,8 +202,8 @@ type Snapshot struct { CIDRV6Total int `json:"cidr_v6_total,omitempty"` CIDRV6Hits int `json:"cidr_v6_hits,omitempty"` RouteLoading bool `json:"route_loading,omitempty"` // deferred routes being applied - ConnectStep string `json:"connect_step,omitempty"` // current connection step - CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message + ConnectStep string `json:"connect_step,omitempty"` // current connection step + CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message } // Snapshot returns a point-in-time copy of the statistics. @@ -203,6 +221,8 @@ func (s *Stats) Snapshot() Snapshot { TxBytes: txv4 + txv6, AssignedIP: s.AssignedIP(), AssignedIP6: s.AssignedIP6(), + ServerHost: s.ServerHost(), + ConnectedIP: s.ConnectedIP(), State: s.State(), } ts := s.ConnectedAt.Load() diff --git a/internal/transport/racedial.go b/internal/transport/racedial.go new file mode 100644 index 0000000..1cde120 --- /dev/null +++ b/internal/transport/racedial.go @@ -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 +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 8569e18..391349e 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -18,6 +18,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "net" "net/http" "net/url" "sync" @@ -30,13 +31,14 @@ import ( // HandshakeConfig configures a single connection attempt. type HandshakeConfig struct { - ServerURL string // e.g. wss://vpn.example.com/ws - SNIHost string // TLS SNI hostname for CDN edge connections - Token string // JWT; if non-empty, used via ?token= (method A) - Username 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 - TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost) + ServerURL string // e.g. wss://vpn.example.com/ws + SNIHost string // TLS SNI hostname for CDN edge connections + Token string // JWT; if non-empty, used via ?token= (method A) + Username 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 + 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. @@ -53,6 +55,7 @@ type Conn struct { // error occurs. func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) { dialer := websocket.Dialer{ + NetDialContext: NewRaceDialer(cfg.IPPreference), HandshakeTimeout: 15 * time.Second, ReadBufferSize: 4096, // match server (handler.go:17) 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). 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. func (c *Conn) Close() error { c.mu.Lock() diff --git a/internal/tun/tun_windows.go b/internal/tun/tun_windows.go index 7387edb..ffa8437 100644 --- a/internal/tun/tun_windows.go +++ b/internal/tun/tun_windows.go @@ -58,8 +58,8 @@ func createTUN(name string) (Device, error) { }, nil } -func (d *wintunDevice) Name() string { return d.name } -func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() } +func (d *wintunDevice) Name() string { return d.name } +func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() } func (d *wintunDevice) Read(p []byte) (int, error) { for { @@ -153,5 +153,3 @@ func execCmd(name string, arg ...string) error { } return nil } - - diff --git a/internal/ui/app.go b/internal/ui/app.go index d989b13..71eb186 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -35,32 +35,32 @@ type App struct { listSelectedIndex int // UI widgets - profileSelect *widget.Select - stateLabel *widget.Label - ipLabel *widget.Label - ip6Label *widget.Label - uptimeLabel *widget.Label - rxV4Label *widget.Label - txV4Label *widget.Label - rxV6Label *widget.Label - txV6Label *widget.Label - rxTotalLabel *widget.Label - txTotalLabel *widget.Label + profileSelect *widget.Select + stateLabel *widget.Label + ipLabel *widget.Label + ip6Label *widget.Label + uptimeLabel *widget.Label + rxV4Label *widget.Label + txV4Label *widget.Label + rxV6Label *widget.Label + txV6Label *widget.Label + rxTotalLabel *widget.Label + txTotalLabel *widget.Label routingModeLabel *widget.Label cidrV4Label *widget.Label cidrV6Label *widget.Label refreshCIDRBtn *widget.Button - connectBtn *widget.Button - disconnectBtn *widget.Button + connectBtn *widget.Button + disconnectBtn *widget.Button // State - mu sync.Mutex - ipcClient *ipc.Client - profiles []model.ServerProfile - currentProfile *model.ServerProfile + mu sync.Mutex + ipcClient *ipc.Client + profiles []model.ServerProfile + currentProfile *model.ServerProfile defaultProfileID int64 - langSetting string - windowHidden bool + langSetting string + windowHidden bool } // Run initialises and starts the GUI application. @@ -328,17 +328,17 @@ func (a *App) onResetDB() { // Reset UI state. a.currentProfile = nil a.loadProfiles() - a.stateLabel.SetText(i18n.T("StateDisconnected")) - a.ipLabel.SetText(i18n.T("IpNone")) - a.ip6Label.SetText(i18n.T("Ip6None")) - a.uptimeLabel.SetText(i18n.T("UptimeNone")) - a.rxV4Label.SetText(i18n.T("RxV4Zero")) - a.txV4Label.SetText(i18n.T("TxV4Zero")) - a.rxV6Label.SetText(i18n.T("RxV6Zero")) - a.txV6Label.SetText(i18n.T("TxV6Zero")) - a.rxTotalLabel.SetText(i18n.T("RxTotalZero")) - a.txTotalLabel.SetText(i18n.T("TxTotalZero")) - a.setConnButtons(true, false) + a.stateLabel.SetText(i18n.T("StateDisconnected")) + a.ipLabel.SetText(i18n.T("IpNone")) + a.ip6Label.SetText(i18n.T("Ip6None")) + a.uptimeLabel.SetText(i18n.T("UptimeNone")) + a.rxV4Label.SetText(i18n.T("RxV4Zero")) + a.txV4Label.SetText(i18n.T("TxV4Zero")) + a.rxV6Label.SetText(i18n.T("RxV6Zero")) + a.txV6Label.SetText(i18n.T("TxV6Zero")) + a.rxTotalLabel.SetText(i18n.T("RxTotalZero")) + a.txTotalLabel.SetText(i18n.T("TxTotalZero")) + a.setConnButtons(true, false) }, a.window).Show() } diff --git a/internal/ui/daemon.go b/internal/ui/daemon.go index e5bc8d3..cf9f8a3 100644 --- a/internal/ui/daemon.go +++ b/internal/ui/daemon.go @@ -86,5 +86,3 @@ func ensureDaemon() (*ipc.Client, error) { } return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile) } - - diff --git a/internal/ui/elevation_darwin.go b/internal/ui/elevation_darwin.go index 8cc7838..b18f073 100644 --- a/internal/ui/elevation_darwin.go +++ b/internal/ui/elevation_darwin.go @@ -29,7 +29,7 @@ func launchElevated(exe, daemonBin, home string, uid, gid int) error { } // 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 { result := "'" for _, r := range s { diff --git a/internal/ui/profile.go b/internal/ui/profile.go index 6c06be4..913c8fe 100644 --- a/internal/ui/profile.go +++ b/internal/ui/profile.go @@ -25,6 +25,8 @@ var ( protoCodes = []string{"wss", "ws"} fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)} + + ipPrefCodes = []string{string(model.IPPrefAuto), string(model.IPPrefV4), string(model.IPPrefV6)} ) func authModeLabels() []string { @@ -43,6 +45,10 @@ func fetchTimingLabels() []string { 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. func codeIndex(codes []string, code string) int { for i, c := range codes { @@ -63,14 +69,14 @@ func selectedCode(codes []string, idx int) string { // urlEntryRow holds the widgets for a single CIDR URL source row. type urlEntryRow struct { - urlEntry *widget.Entry + urlEntry *widget.Entry timingSelect *widget.Select - container *fyne.Container + container *fyne.Container } // cidrURLList manages a dynamic list of CIDR URL source rows. type cidrURLList struct { - rows []*urlEntryRow + rows []*urlEntryRow container *fyne.Container parent fyne.Window } @@ -198,6 +204,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { authSelect := widget.NewSelect(authModeLabels(), nil) routeSelect := widget.NewSelect(routeModeLabels(), nil) + ipPrefSelect := widget.NewSelect(ipPrefLabels(), nil) cidrV4Entry := widget.NewMultiLineEntry() cidrV4Entry.Wrapping = fyne.TextWrapOff @@ -300,6 +307,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { userEntry.SetText(editing.Username) authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode))) routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode))) + ipPrefSelect.SetSelectedIndex(codeIndex(ipPrefCodes, editing.IPPreference)) cidrV4Entry.SetText(editing.CIDRV4) cidrV6Entry.SetText(editing.CIDRV6) v4URLList.loadFromJSON(editing.CIDRV4URLs) @@ -316,6 +324,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { pathEntry.SetText("/ws") authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth))) routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull))) + ipPrefSelect.SetSelectedIndex(0) // auto mtuEntry.SetText("0") } @@ -349,9 +358,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { 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("FieldRoutingMode")), routeSelect), + container.NewVBox(widget.NewLabel(i18n.T("FieldIPPreference")), ipPrefSelect), ), cidrSection, @@ -394,7 +404,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { mtuEntry.Text, tlsCaPEMEntry.Text, tlsCaPathEntry.Text, tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked, - isNew) { + isNew, + selectedCode(ipPrefCodes, ipPrefSelect.SelectedIndex())) { profileWin.Close() } }) @@ -414,7 +425,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { func (a *App) saveProfile(editing *model.ServerProfile, name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, 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 == "" { showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window) return false @@ -454,6 +466,7 @@ func (a *App) saveProfile(editing *model.ServerProfile, TLSCAPath: tlsCaPath, TLSInsecure: tlsInsecure, TLSPinnedHash: tlsPinnedHash, + IPPreference: ipPreference, } id, err := a.db.CreateProfile(p) if err != nil { @@ -486,6 +499,7 @@ func (a *App) saveProfile(editing *model.ServerProfile, editing.TLSCAPath = tlsCaPath editing.TLSInsecure = tlsInsecure editing.TLSPinnedHash = tlsPinnedHash + editing.IPPreference = ipPreference if err := a.db.UpdateProfile(editing); err != nil { showError(i18n.T("DlgSaveError"), err.Error(), a.window) return false diff --git a/internal/ui/tray.go b/internal/ui/tray.go index 012b3b1..9fce008 100644 --- a/internal/ui/tray.go +++ b/internal/ui/tray.go @@ -46,20 +46,20 @@ func (a *App) setupTray() { autoItem, enItem, zhItem, ) - connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() { - a.onConnect() - }) - disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() { - a.onDisconnect() - }) - if a.connectBtn != nil { - connectItem.Disabled = a.connectBtn.Disabled() - } - if a.disconnectBtn != nil { - disconnectItem.Disabled = a.disconnectBtn.Disabled() - } + connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() { + a.onConnect() + }) + disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() { + a.onDisconnect() + }) + if a.connectBtn != nil { + connectItem.Disabled = a.connectBtn.Disabled() + } + if a.disconnectBtn != nil { + disconnectItem.Disabled = a.disconnectBtn.Disabled() + } - menu := fyne.NewMenu(i18n.T("WindowTitle"), + menu := fyne.NewMenu(i18n.T("WindowTitle"), fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() { a.windowHidden = false activateApp() @@ -67,9 +67,9 @@ func (a *App) setupTray() { a.window.Show() a.window.RequestFocus() }), - fyne.NewMenuItemSeparator(), - connectItem, - disconnectItem, + fyne.NewMenuItemSeparator(), + connectItem, + disconnectItem, fyne.NewMenuItemSeparator(), langItem, fyne.NewMenuItemSeparator(), diff --git a/internal/ui/view.go b/internal/ui/view.go index 9736874..870d709 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -215,6 +215,7 @@ func (a *App) onConnect() { TLSCAPath: p.TLSCAPath, TLSInsecure: p.TLSInsecure, TLSPinnedHash: p.TLSPinnedHash, + IPPreference: p.IPPreference, } if err := ipc.SendStart(client, cfg); err != nil { fyne.Do(func() { @@ -428,6 +429,9 @@ func (a *App) applyStats(s stats.Snapshot) { stepLabel := connectStepLabel(s.ConnectStep) if 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 { a.stateLabel.SetText(i18n.T("StateConnected")) } diff --git a/internal/vpn/session.go b/internal/vpn/session.go index 241f0ac..abddfa6 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -33,23 +33,24 @@ import ( // SessionConfig describes how to connect to a VPN server. type SessionConfig struct { - ServerURL string - SNIHost string // TLS SNI hostname for CDN - ServerIPs []string // CDN edge IPs for failover - Username string - Password string - AuthMode model.AuthMode - Token string // pre-obtained JWT (empty = fetch via HTTP login) - RoutingMode route.Mode - CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode) - CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode) - CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources - CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources - MTUOverride int // 0 = use server MTU - TLSCACert string // inline CA cert PEM (wss only) - TLSCAPath string // CA cert file path (wss only) - TLSInsecure bool // skip cert verification (wss only) - TLSPinnedHash string // SHA-256 cert pin (wss only) + ServerURL string + SNIHost string // TLS SNI hostname for CDN + ServerIPs []string // CDN edge IPs for failover + Username string + Password string + AuthMode model.AuthMode + Token string // pre-obtained JWT (empty = fetch via HTTP login) + RoutingMode route.Mode + CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode) + CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode) + CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources + CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources + MTUOverride int // 0 = use server MTU + TLSCACert string // inline CA cert PEM (wss only) + TLSCAPath string // CA cert file path (wss only) + TLSInsecure bool // skip cert verification (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. @@ -212,8 +213,16 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { backoff := time.Second maxBackoff := 60 * time.Second - // Build the full target list: original host first, then CDN IPs. - targets := append([]string{""}, cfg.ServerIPs...) // "" = use base URL + // Build the full target list. When ServerIPs is empty, connect via + // 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 for { @@ -223,7 +232,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { } targetIP := "" - if ipIndex > 0 && ipIndex < len(targets) { + if ipIndex < len(targets) { targetIP = targets[ipIndex] } @@ -242,13 +251,13 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { sm.cleanupResources() // 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 + // hostname (ipIndex == 0, hostname mode) 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 the // TLS error likely means that IP points to a different // server; skip it and try the next target. if tlsconfig.IsTLSError(err) { - if ipIndex == 0 { + if usingHostname && ipIndex == 0 { log.L().Warn("fatal TLS error, stopping reconnect", "error", err) sm.setState(stats.StateError) if sm.onError != nil { @@ -258,7 +267,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { sm.cleanup() 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) } @@ -268,7 +277,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { // 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 ipIndex == 0 { + if usingHostname && ipIndex == 0 { log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg) sm.setState(stats.StateError) if sm.onError != nil { @@ -278,16 +287,16 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { sm.cleanup() 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) } sm.setState(stats.StateReconnecting) - // Try next CDN IP immediately. + // Try next target IP immediately. ipIndex++ 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 } // 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 { 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 cfg.AuthMode == model.AuthModeBoth { // Fall back to password auth. @@ -405,11 +414,12 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta // Prepare the TUN + route setup callback (called during handshake, // between receiving init and sending ready). handshake := transport.HandshakeConfig{ - ServerURL: serverURL, - SNIHost: cfg.SNIHost, - Token: token, - Username: cfg.Username, - Password: cfg.Password, + ServerURL: serverURL, + SNIHost: cfg.SNIHost, + Token: token, + Username: cfg.Username, + Password: cfg.Password, + IPPreference: cfg.IPPreference, OnInit: func(init protocol.InitMessage) error { return sm.setupTUN(init, cfg, beforeCIDRs) }, @@ -448,13 +458,16 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta sm.conn = conn 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.setState(stats.StateConnected) log.L().Info("VPN connected", "ip", conn.Init().IP, "server_ip", conn.Init().ServerIP, "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. statsDone := make(chan struct{}) diff --git a/resources/genicon/main.go b/resources/genicon/main.go index 3a11b5c..b894a5e 100644 --- a/resources/genicon/main.go +++ b/resources/genicon/main.go @@ -46,12 +46,12 @@ func main() { for x := sx - sw/2; x < sx+sw/2; x++ { // Shield shape: rounded top, pointed bottom. progress := float64(y-sy) / float64(sh) - halfW := float64(sw)/2 * (1.0 - 0.3*progress*progress) + halfW := float64(sw) / 2 * (1.0 - 0.3*progress*progress) if math.Abs(float64(x-sx)) < halfW && progress < 0.7 { img.SetRGBA(x, y, shieldColor) } else if progress >= 0.7 { tp := (progress - 0.7) / 0.3 - halfW2 := float64(sw)/2 * (1.0 - 0.3*0.49) * (1.0 - tp) + halfW2 := float64(sw) / 2 * (1.0 - 0.3*0.49) * (1.0 - tp) if math.Abs(float64(x-sx)) < halfW2 { img.SetRGBA(x, y, shieldColor) }