feat: 添加IP竞速拨号器与IP偏好设置,连接状态显示域名与实际IP
- 新增竞速拨号器(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:
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ RoutingModeBypass = "绕过 CIDR"
|
|||||||
FetchTimingBefore = "代理前获取"
|
FetchTimingBefore = "代理前获取"
|
||||||
FetchTimingAfter = "代理后获取"
|
FetchTimingAfter = "代理后获取"
|
||||||
|
|
||||||
|
FieldIPPreference = "IP 偏好"
|
||||||
|
IPPrefAuto = "自动(竞速)"
|
||||||
|
IPPrefV4 = "仅 IPv4"
|
||||||
|
IPPrefV6 = "仅 IPv6"
|
||||||
|
|
||||||
BtnAddURL = "添加 URL"
|
BtnAddURL = "添加 URL"
|
||||||
BtnRemoveURL = "删除"
|
BtnRemoveURL = "删除"
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -29,8 +29,8 @@ const (
|
|||||||
CmdStop = "stop"
|
CmdStop = "stop"
|
||||||
CmdShutdown = "shutdown"
|
CmdShutdown = "shutdown"
|
||||||
CmdStats = "stats"
|
CmdStats = "stats"
|
||||||
CmdVersion = "version" // query daemon build version
|
CmdVersion = "version" // query daemon build version
|
||||||
CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes
|
CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event types sent from daemon to GUI.
|
// Event types sent from daemon to GUI.
|
||||||
@@ -51,22 +51,23 @@ type Request struct {
|
|||||||
// package (which needs root-only TUN) into the GUI.
|
// package (which needs root-only TUN) into the GUI.
|
||||||
type ClientConfig struct {
|
type ClientConfig struct {
|
||||||
ServerURL string `json:"server_url"`
|
ServerURL string `json:"server_url"`
|
||||||
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
|
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
|
||||||
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
|
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
AuthMode string `json:"auth_mode"`
|
AuthMode string `json:"auth_mode"`
|
||||||
RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass"
|
RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass"
|
||||||
CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs
|
CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs
|
||||||
CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs
|
CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs
|
||||||
CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources
|
CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources
|
||||||
CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources
|
CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources
|
||||||
MTUOverride int `json:"mtu_override"`
|
MTUOverride int `json:"mtu_override"`
|
||||||
TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only)
|
TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only)
|
||||||
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
|
||||||
@@ -83,7 +84,7 @@ type Event struct {
|
|||||||
State string `json:"state,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
ConnectStep string `json:"connect_step,omitempty"` // current connection step (for EvState)
|
ConnectStep string `json:"connect_step,omitempty"` // current connection step (for EvState)
|
||||||
Stats *stats.Snapshot `json:"stats,omitempty"`
|
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"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -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
|
||||||
|
|
||||||
@@ -61,10 +71,11 @@ type ServerProfile struct {
|
|||||||
CIDRV6URLs string `json:"cidr_v6_urls"` // JSON array of CIDRURLSource for IPv6
|
CIDRV6URLs string `json:"cidr_v6_urls"` // JSON array of CIDRURLSource for IPv6
|
||||||
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
|
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
|
||||||
AutoConnect bool `json:"auto_connect"`
|
AutoConnect bool `json:"auto_connect"`
|
||||||
TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (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)
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+33
-13
@@ -26,14 +26,16 @@ const (
|
|||||||
// counters in Snapshot, so callers that only need the total still
|
// counters in Snapshot, so callers that only need the total still
|
||||||
// work.
|
// work.
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
RxBytesV4 atomic.Int64
|
RxBytesV4 atomic.Int64
|
||||||
RxBytesV6 atomic.Int64
|
RxBytesV6 atomic.Int64
|
||||||
TxBytesV4 atomic.Int64
|
TxBytesV4 atomic.Int64
|
||||||
TxBytesV6 atomic.Int64
|
TxBytesV6 atomic.Int64
|
||||||
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
|
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
|
||||||
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
|
||||||
|
|
||||||
@@ -184,8 +202,8 @@ type Snapshot struct {
|
|||||||
CIDRV6Total int `json:"cidr_v6_total,omitempty"`
|
CIDRV6Total int `json:"cidr_v6_total,omitempty"`
|
||||||
CIDRV6Hits int `json:"cidr_v6_hits,omitempty"`
|
CIDRV6Hits int `json:"cidr_v6_hits,omitempty"`
|
||||||
RouteLoading bool `json:"route_loading,omitempty"` // deferred routes being applied
|
RouteLoading bool `json:"route_loading,omitempty"` // deferred routes being applied
|
||||||
ConnectStep string `json:"connect_step,omitempty"` // current connection step
|
ConnectStep string `json:"connect_step,omitempty"` // current connection step
|
||||||
CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message
|
CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns a point-in-time copy of the statistics.
|
// Snapshot returns a point-in-time copy of the statistics.
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -30,13 +31,14 @@ import (
|
|||||||
|
|
||||||
// HandshakeConfig configures a single connection attempt.
|
// HandshakeConfig configures a single connection attempt.
|
||||||
type HandshakeConfig struct {
|
type HandshakeConfig struct {
|
||||||
ServerURL string // e.g. wss://vpn.example.com/ws
|
ServerURL string // e.g. wss://vpn.example.com/ws
|
||||||
SNIHost string // TLS SNI hostname for CDN edge connections
|
SNIHost string // TLS SNI hostname for CDN edge connections
|
||||||
Token string // JWT; if non-empty, used via ?token= (method A)
|
Token string // JWT; if non-empty, used via ?token= (method A)
|
||||||
Username string // for password auth (method B), or fallback
|
Username string // for password auth (method B), or fallback
|
||||||
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()
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ func createTUN(name string) (Device, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *wintunDevice) Name() string { return d.name }
|
func (d *wintunDevice) Name() string { return d.name }
|
||||||
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
|
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
|
||||||
|
|
||||||
func (d *wintunDevice) Read(p []byte) (int, error) {
|
func (d *wintunDevice) Read(p []byte) (int, error) {
|
||||||
for {
|
for {
|
||||||
@@ -153,5 +153,3 @@ func execCmd(name string, arg ...string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+30
-30
@@ -35,32 +35,32 @@ type App struct {
|
|||||||
listSelectedIndex int
|
listSelectedIndex int
|
||||||
|
|
||||||
// UI widgets
|
// UI widgets
|
||||||
profileSelect *widget.Select
|
profileSelect *widget.Select
|
||||||
stateLabel *widget.Label
|
stateLabel *widget.Label
|
||||||
ipLabel *widget.Label
|
ipLabel *widget.Label
|
||||||
ip6Label *widget.Label
|
ip6Label *widget.Label
|
||||||
uptimeLabel *widget.Label
|
uptimeLabel *widget.Label
|
||||||
rxV4Label *widget.Label
|
rxV4Label *widget.Label
|
||||||
txV4Label *widget.Label
|
txV4Label *widget.Label
|
||||||
rxV6Label *widget.Label
|
rxV6Label *widget.Label
|
||||||
txV6Label *widget.Label
|
txV6Label *widget.Label
|
||||||
rxTotalLabel *widget.Label
|
rxTotalLabel *widget.Label
|
||||||
txTotalLabel *widget.Label
|
txTotalLabel *widget.Label
|
||||||
routingModeLabel *widget.Label
|
routingModeLabel *widget.Label
|
||||||
cidrV4Label *widget.Label
|
cidrV4Label *widget.Label
|
||||||
cidrV6Label *widget.Label
|
cidrV6Label *widget.Label
|
||||||
refreshCIDRBtn *widget.Button
|
refreshCIDRBtn *widget.Button
|
||||||
connectBtn *widget.Button
|
connectBtn *widget.Button
|
||||||
disconnectBtn *widget.Button
|
disconnectBtn *widget.Button
|
||||||
|
|
||||||
// State
|
// State
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ipcClient *ipc.Client
|
ipcClient *ipc.Client
|
||||||
profiles []model.ServerProfile
|
profiles []model.ServerProfile
|
||||||
currentProfile *model.ServerProfile
|
currentProfile *model.ServerProfile
|
||||||
defaultProfileID int64
|
defaultProfileID int64
|
||||||
langSetting string
|
langSetting string
|
||||||
windowHidden bool
|
windowHidden bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run initialises and starts the GUI application.
|
// Run initialises and starts the GUI application.
|
||||||
@@ -328,17 +328,17 @@ func (a *App) onResetDB() {
|
|||||||
// Reset UI state.
|
// Reset UI state.
|
||||||
a.currentProfile = nil
|
a.currentProfile = nil
|
||||||
a.loadProfiles()
|
a.loadProfiles()
|
||||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||||
a.setConnButtons(true, false)
|
a.setConnButtons(true, false)
|
||||||
}, a.window).Show()
|
}, a.window).Show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+20
-6
@@ -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 {
|
||||||
@@ -63,14 +69,14 @@ func selectedCode(codes []string, idx int) string {
|
|||||||
|
|
||||||
// urlEntryRow holds the widgets for a single CIDR URL source row.
|
// urlEntryRow holds the widgets for a single CIDR URL source row.
|
||||||
type urlEntryRow struct {
|
type urlEntryRow struct {
|
||||||
urlEntry *widget.Entry
|
urlEntry *widget.Entry
|
||||||
timingSelect *widget.Select
|
timingSelect *widget.Select
|
||||||
container *fyne.Container
|
container *fyne.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
// cidrURLList manages a dynamic list of CIDR URL source rows.
|
// cidrURLList manages a dynamic list of CIDR URL source rows.
|
||||||
type cidrURLList struct {
|
type cidrURLList struct {
|
||||||
rows []*urlEntryRow
|
rows []*urlEntryRow
|
||||||
container *fyne.Container
|
container *fyne.Container
|
||||||
parent fyne.Window
|
parent fyne.Window
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+16
-16
@@ -46,20 +46,20 @@ func (a *App) setupTray() {
|
|||||||
autoItem, enItem, zhItem,
|
autoItem, enItem, zhItem,
|
||||||
)
|
)
|
||||||
|
|
||||||
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||||
a.onConnect()
|
a.onConnect()
|
||||||
})
|
})
|
||||||
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||||
a.onDisconnect()
|
a.onDisconnect()
|
||||||
})
|
})
|
||||||
if a.connectBtn != nil {
|
if a.connectBtn != nil {
|
||||||
connectItem.Disabled = a.connectBtn.Disabled()
|
connectItem.Disabled = a.connectBtn.Disabled()
|
||||||
}
|
}
|
||||||
if a.disconnectBtn != nil {
|
if a.disconnectBtn != nil {
|
||||||
disconnectItem.Disabled = a.disconnectBtn.Disabled()
|
disconnectItem.Disabled = a.disconnectBtn.Disabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||||
a.windowHidden = false
|
a.windowHidden = false
|
||||||
activateApp()
|
activateApp()
|
||||||
@@ -67,9 +67,9 @@ func (a *App) setupTray() {
|
|||||||
a.window.Show()
|
a.window.Show()
|
||||||
a.window.RequestFocus()
|
a.window.RequestFocus()
|
||||||
}),
|
}),
|
||||||
fyne.NewMenuItemSeparator(),
|
fyne.NewMenuItemSeparator(),
|
||||||
connectItem,
|
connectItem,
|
||||||
disconnectItem,
|
disconnectItem,
|
||||||
fyne.NewMenuItemSeparator(),
|
fyne.NewMenuItemSeparator(),
|
||||||
langItem,
|
langItem,
|
||||||
fyne.NewMenuItemSeparator(),
|
fyne.NewMenuItemSeparator(),
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-37
@@ -33,23 +33,24 @@ import (
|
|||||||
|
|
||||||
// SessionConfig describes how to connect to a VPN server.
|
// SessionConfig describes how to connect to a VPN server.
|
||||||
type SessionConfig struct {
|
type SessionConfig struct {
|
||||||
ServerURL string
|
ServerURL string
|
||||||
SNIHost string // TLS SNI hostname for CDN
|
SNIHost string // TLS SNI hostname for CDN
|
||||||
ServerIPs []string // CDN edge IPs for failover
|
ServerIPs []string // CDN edge IPs for failover
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
AuthMode model.AuthMode
|
AuthMode model.AuthMode
|
||||||
Token string // pre-obtained JWT (empty = fetch via HTTP login)
|
Token string // pre-obtained JWT (empty = fetch via HTTP login)
|
||||||
RoutingMode route.Mode
|
RoutingMode route.Mode
|
||||||
CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode)
|
CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode)
|
||||||
CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode)
|
CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode)
|
||||||
CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources
|
CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources
|
||||||
CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources
|
CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources
|
||||||
MTUOverride int // 0 = use server MTU
|
MTUOverride int // 0 = use server MTU
|
||||||
TLSCACert string // inline CA cert PEM (wss only)
|
TLSCACert string // inline CA cert PEM (wss only)
|
||||||
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.
|
||||||
@@ -405,11 +414,12 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
|||||||
// Prepare the TUN + route setup callback (called during handshake,
|
// Prepare the TUN + route setup callback (called during handshake,
|
||||||
// between receiving init and sending ready).
|
// between receiving init and sending ready).
|
||||||
handshake := transport.HandshakeConfig{
|
handshake := transport.HandshakeConfig{
|
||||||
ServerURL: serverURL,
|
ServerURL: serverURL,
|
||||||
SNIHost: cfg.SNIHost,
|
SNIHost: cfg.SNIHost,
|
||||||
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{})
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ func main() {
|
|||||||
for x := sx - sw/2; x < sx+sw/2; x++ {
|
for x := sx - sw/2; x < sx+sw/2; x++ {
|
||||||
// Shield shape: rounded top, pointed bottom.
|
// Shield shape: rounded top, pointed bottom.
|
||||||
progress := float64(y-sy) / float64(sh)
|
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 {
|
if math.Abs(float64(x-sx)) < halfW && progress < 0.7 {
|
||||||
img.SetRGBA(x, y, shieldColor)
|
img.SetRGBA(x, y, shieldColor)
|
||||||
} else if progress >= 0.7 {
|
} else if progress >= 0.7 {
|
||||||
tp := (progress - 0.7) / 0.3
|
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 {
|
if math.Abs(float64(x-sx)) < halfW2 {
|
||||||
img.SetRGBA(x, y, shieldColor)
|
img.SetRGBA(x, y, shieldColor)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user