4 Commits
Author SHA1 Message Date
kevin b81b702433 feat: 添加IP竞速拨号器与IP偏好设置,连接状态显示域名与实际IP
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- 新增竞速拨号器(racedial.go):域名连接时并行解析所有A/AAAA记录,
  同时对所有IP发起TCP连接,第一个成功的胜出,确保选择延迟最低的地址
- ServerProfile新增IPPreference字段(auto/v4/v6),支持用户指定IP版本偏好
- 填写服务器IP时跳过域名解析直接使用IP,顺序failover
- 连接成功后状态栏显示「已连接 (域名 -> 实际IP)」
- DB迁移v6:server_profiles表添加ip_preference列
- WebSocket拨号与HTTP登录均使用竞速拨号器
- 补充中英文i18n翻译
2026-07-09 18:46:55 +08:00
kevin 7febed50ac docs: 添加 MIT 许可证 2026-07-09 14:53:04 +08:00
kevin 4caaacb68f up 2026-07-09 11:29:25 +08:00
kevin 9b1cb668b1 尝试使用mac的指纹识别来授权,但是似乎没有效果 2026-07-09 11:27:40 +08:00
28 changed files with 860 additions and 152 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 wuwenfengmi1998
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -11,7 +11,7 @@ GO = go
CGO_ENABLED = 1 CGO_ENABLED = 1
WINDRES ?= x86_64-w64-mingw32-windres WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc MINGW_CC ?= x86_64-w64-mingw32-gcc
SEMVER ?= 0.6.2 SEMVER ?= 0.6.5
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
VERSION = $(SEMVER)-$(GIT_HASH) VERSION = $(SEMVER)-$(GIT_HASH)
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
+12 -4
View File
@@ -14,6 +14,8 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"lmvpn/internal/transport"
) )
// LoginResult holds the response from a successful /api/login call. // LoginResult holds the response from a successful /api/login call.
@@ -49,19 +51,25 @@ type errorResponse struct {
// host will be an IP address, but the certificate must be verified // host will be an IP address, but the certificate must be verified
// against the real hostname (set tlsCfg.ServerName). // against the real hostname (set tlsCfg.ServerName).
// //
// ipPreference controls which IP address families are used when
// resolving the server hostname ("auto", "v4", "v6").
//
// ctx allows cancellation of the HTTP request (e.g. when the VPN // ctx allows cancellation of the HTTP request (e.g. when the VPN
// session is disconnected while login is in flight). // session is disconnected while login is in flight).
func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) { func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config, ipPreference string) (*LoginResult, error) {
body, err := json.Marshal(loginRequest{Username: username, Password: password}) body, err := json.Marshal(loginRequest{Username: username, Password: password})
if err != nil { if err != nil {
return nil, err return nil, err
} }
url := strings.TrimRight(baseURL, "/") + "/api/login" url := strings.TrimRight(baseURL, "/") + "/api/login"
client := &http.Client{Timeout: 15 * time.Second} httpTransport := &http.Transport{
if tlsCfg != nil { DialContext: transport.NewRaceDialer(ipPreference),
client.Transport = &http.Transport{TLSClientConfig: tlsCfg}
} }
if tlsCfg != nil {
httpTransport.TLSClientConfig = tlsCfg
}
client := &http.Client{Timeout: 15 * time.Second, Transport: httpTransport}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil { if err != nil {
+1
View File
@@ -165,6 +165,7 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
TLSCAPath: req.Config.TLSCAPath, TLSCAPath: req.Config.TLSCAPath,
TLSInsecure: req.Config.TLSInsecure, TLSInsecure: req.Config.TLSInsecure,
TLSPinnedHash: req.Config.TLSPinnedHash, TLSPinnedHash: req.Config.TLSPinnedHash,
IPPreference: req.Config.IPPreference,
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
+19 -1
View File
@@ -55,7 +55,10 @@ func (s *Store) migrate() error {
if err := s.migrateV4(); err != nil { if err := s.migrateV4(); err != nil {
return err return err
} }
return s.migrateV5() if err := s.migrateV5(); err != nil {
return err
}
return s.migrateV6()
} }
func (s *Store) migrateV2() error { func (s *Store) migrateV2() error {
@@ -290,6 +293,20 @@ func (s *Store) migrateV5() error {
return nil return nil
} }
// migrateV6 adds the ip_preference column to server_profiles for
// controlling IPv4/IPv6 address selection when connecting by hostname.
// Idempotent: skips if the column already exists.
func (s *Store) migrateV6() error {
if columnExists(s.db, "server_profiles", "ip_preference") {
return nil
}
_, err := s.db.Exec(`ALTER TABLE server_profiles ADD COLUMN ip_preference TEXT NOT NULL DEFAULT 'auto'`)
if err != nil {
return fmt.Errorf("migrate v6 add ip_preference: %w", err)
}
return nil
}
// splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and // splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and
// IPv6 parts. Used for migration from the old custom_cidrs column. // IPv6 parts. Used for migration from the old custom_cidrs column.
func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) { func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) {
@@ -354,6 +371,7 @@ CREATE TABLE IF NOT EXISTS server_profiles (
cidr_v6_urls TEXT NOT NULL DEFAULT '', cidr_v6_urls TEXT NOT NULL DEFAULT '',
mtu_override INTEGER NOT NULL DEFAULT 0, mtu_override INTEGER NOT NULL DEFAULT 0,
auto_connect INTEGER NOT NULL DEFAULT 0, auto_connect INTEGER NOT NULL DEFAULT 0,
ip_preference TEXT NOT NULL DEFAULT 'auto',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_connected_at DATETIME last_connected_at DATETIME
); );
+12 -6
View File
@@ -16,13 +16,15 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
username, auth_mode, routing_mode, username, auth_mode, routing_mode,
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect, mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash) tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ip_preference)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode, p.Username, p.AuthMode, p.RoutingMode,
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs, p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect, p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
p.IPPreference,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("insert profile: %w", err) return 0, fmt.Errorf("insert profile: %w", err)
@@ -43,6 +45,7 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect, mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
ip_preference,
created_at, last_connected_at created_at, last_connected_at
FROM server_profiles WHERE id = ?`, id, FROM server_profiles WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path, ).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path,
@@ -50,7 +53,7 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs, &p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect, &p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last) &p.IPPreference, &p.CreatedAt, &last)
if err != nil { if err != nil {
return nil, fmt.Errorf("get profile %d: %w", id, err) return nil, fmt.Errorf("get profile %d: %w", id, err)
} }
@@ -68,6 +71,7 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls, cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect, mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
ip_preference,
created_at, last_connected_at created_at, last_connected_at
FROM server_profiles ORDER BY name`) FROM server_profiles ORDER BY name`)
if err != nil { if err != nil {
@@ -84,7 +88,7 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs, &p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect, &p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last); err != nil { &p.IPPreference, &p.CreatedAt, &last); err != nil {
return nil, err return nil, err
} }
if last.Valid { if last.Valid {
@@ -103,13 +107,15 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error {
username = ?, auth_mode = ?, routing_mode = ?, username = ?, auth_mode = ?, routing_mode = ?,
cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?, cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?,
mtu_override = ?, auto_connect = ?, mtu_override = ?, auto_connect = ?,
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ? tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?,
ip_preference = ?
WHERE id = ?`, WHERE id = ?`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode, p.Username, p.AuthMode, p.RoutingMode,
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs, p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect, p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID) p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
p.IPPreference, p.ID)
if err != nil { if err != nil {
return fmt.Errorf("update profile %d: %w", p.ID, err) return fmt.Errorf("update profile %d: %w", p.ID, err)
} }
+8
View File
@@ -126,6 +126,11 @@ RoutingModeBypass = "Bypass CIDR"
FetchTimingBefore = "Before Proxy" FetchTimingBefore = "Before Proxy"
FetchTimingAfter = "After Proxy" FetchTimingAfter = "After Proxy"
FieldIPPreference = "IP Preference"
IPPrefAuto = "Auto (Race)"
IPPrefV4 = "IPv4 Only"
IPPrefV6 = "IPv6 Only"
BtnAddURL = "Add URL" BtnAddURL = "Add URL"
BtnRemoveURL = "Remove" BtnRemoveURL = "Remove"
@@ -141,3 +146,6 @@ PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
BtnBrowse = "Browse..." BtnBrowse = "Browse..."
DlgTLSError = "TLS Certificate Error" DlgTLSError = "TLS Certificate Error"
TLSErrorVerification = "Server certificate verification failed." TLSErrorVerification = "Server certificate verification failed."
DlgBiometricCanceled = "Authentication was canceled."
TouchIDPrompt = "authenticate to access your VPN password"
+8
View File
@@ -126,6 +126,11 @@ RoutingModeBypass = "绕过 CIDR"
FetchTimingBefore = "代理前获取" FetchTimingBefore = "代理前获取"
FetchTimingAfter = "代理后获取" FetchTimingAfter = "代理后获取"
FieldIPPreference = "IP 偏好"
IPPrefAuto = "自动(竞速)"
IPPrefV4 = "仅 IPv4"
IPPrefV6 = "仅 IPv6"
BtnAddURL = "添加 URL" BtnAddURL = "添加 URL"
BtnRemoveURL = "删除" BtnRemoveURL = "删除"
@@ -141,3 +146,6 @@ PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
BtnBrowse = "浏览..." BtnBrowse = "浏览..."
DlgTLSError = "TLS 证书错误" DlgTLSError = "TLS 证书错误"
TLSErrorVerification = "服务器证书验证失败。" TLSErrorVerification = "服务器证书验证失败。"
DlgBiometricCanceled = "认证已取消。"
TouchIDPrompt = "验证指纹以访问 VPN 密码"
+1
View File
@@ -67,6 +67,7 @@ type ClientConfig struct {
TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only) TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only) TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only) TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only)
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
} }
// CIDRURLSource describes a URL that provides a CIDR list. It mirrors // CIDRURLSource describes a URL that provides a CIDR list. It mirrors
+5
View File
@@ -19,6 +19,11 @@ const ServiceName = paths.BundleID
// ErrNotFound is returned when a secret is not present in the store. // ErrNotFound is returned when a secret is not present in the store.
var ErrNotFound = fmt.Errorf("secret not found") var ErrNotFound = fmt.Errorf("secret not found")
// ErrUserCanceled is returned when the user cancels a biometric
// authentication prompt (e.g. Touch ID) or the system cannot complete
// authentication.
var ErrUserCanceled = fmt.Errorf("user canceled authentication")
// Store is the secret storage interface. // Store is the secret storage interface.
type Store interface { type Store interface {
SetPassword(profileName, password string) error SetPassword(profileName, password string) error
+30 -1
View File
@@ -14,15 +14,34 @@ const (
tokenAccountPrefix = "token:" tokenAccountPrefix = "token:"
) )
// touchIDPrompt is the localized prompt shown in the Touch ID dialog.
// It is set by the UI layer at startup via SetTouchIDPrompt.
var touchIDPrompt = "authenticate to access your VPN password"
// SetTouchIDPrompt sets the localized prompt text shown in the Touch ID
// dialog when retrieving secrets from the keychain.
func SetTouchIDPrompt(prompt string) {
if prompt != "" {
touchIDPrompt = prompt
}
}
// DarwinStore implements Store using the macOS Keychain. // DarwinStore implements Store using the macOS Keychain.
type DarwinStore struct{} type DarwinStore struct{}
// New returns a macOS Keychain-backed Store. // New returns a macOS Keychain-backed Store. On Macs with Touch ID,
// secrets are stored with biometric (Touch ID) protection; on older
// Macs without a biometric sensor, the standard keychain accessibility
// is used as a fallback.
func New() Store { func New() Store {
return DarwinStore{} return DarwinStore{}
} }
func (DarwinStore) setItem(account, secret string) error { func (DarwinStore) setItem(account, secret string) error {
// Use biometric-protected storage when Touch ID is available.
if biometricAvailable() {
return storeBiometricItemGo(ServiceName, account, secret)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
@@ -38,6 +57,11 @@ func (DarwinStore) setItem(account, secret string) error {
} }
func (DarwinStore) getItem(account string) (string, error) { func (DarwinStore) getItem(account string) (string, error) {
// When Touch ID is available, use the direct CGo path which can
// show a biometric prompt for protected items.
if biometricAvailable() {
return getBiometricItemGo(ServiceName, account, touchIDPrompt)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
@@ -55,6 +79,11 @@ func (DarwinStore) getItem(account string) (string, error) {
} }
func (DarwinStore) deleteItem(account string) error { func (DarwinStore) deleteItem(account string) error {
// Use the direct CGo delete which works for both biometric and
// non-biometric items (and doesn't trigger a Touch ID prompt).
if biometricAvailable() {
return deleteBiometricItemGo(ServiceName, account)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
@@ -0,0 +1,360 @@
//go:build darwin
package keychain
/*
#cgo LDFLAGS: -framework CoreFoundation -framework Security -framework LocalAuthentication -framework Foundation
#include <CoreFoundation/CoreFoundation.h>
#include <Security/Security.h>
#include <objc/objc.h>
#include <objc/runtime.h>
#include <objc/message.h>
// LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1
// biometricAvailable returns 1 if Touch ID is available, 0 otherwise.
// Uses the Objective-C runtime to call LAContext without including
// Objective-C headers (which cannot be parsed by the C compiler).
static int biometricAvailable(void) {
Class cls = objc_getClass("LAContext");
if (!cls) return 0;
id alloc = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("alloc"));
if (!alloc) return 0;
id context = ((id (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("init"));
if (!context) {
((void (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("release"));
return 0;
}
// canEvaluatePolicy:error: returns BOOL, takes (NSInteger, NSError**)
// LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1
BOOL result = ((BOOL (*)(id, SEL, long, void *))objc_msgSend)(
context, sel_getUid("canEvaluatePolicy:error:"), 1, NULL);
((void (*)(id, SEL))objc_msgSend)(context, sel_getUid("release"));
return result ? 1 : 0;
}
// secAccessControlCreateBiometric creates a SecAccessControlRef with
// kSecAccessControlBiometryAny. Must be released with CFRelease by caller.
static SecAccessControlRef secAccessControlCreateBiometric(void) {
SecAccessControlRef acl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
kSecAccessControlBiometryAny,
NULL);
return acl;
}
// storeBiometricItem stores data in the keychain with Touch ID protection.
// Returns 0 on success, or a non-zero OSStatus error code on failure.
static int storeBiometricItem(CFStringRef service, CFStringRef account, CFDataRef data) {
SecAccessControlRef acl = secAccessControlCreateBiometric();
if (!acl) {
return errSecAllocate;
}
const void *keys[] = {
kSecClass,
kSecAttrService,
kSecAttrAccount,
kSecValueData,
kSecAttrAccessControl,
};
const void *values[] = {
kSecClassGenericPassword,
service,
account,
data,
acl,
};
CFDictionaryRef query = CFDictionaryCreate(
kCFAllocatorDefault,
keys, values, sizeof(keys) / sizeof(keys[0]),
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
// Delete any existing item first (Add fails on duplicates).
SecItemDelete(query);
OSStatus status = SecItemAdd(query, NULL);
CFRelease(query);
CFRelease(acl);
return (int)status;
}
// getBiometricItem retrieves data from the keychain, prompting for Touch ID
// via an LAContext with a localized reason. Returns:
// 0 on success (dataOut filled, caller must CFRelease)
// -1 if item not found
// -128 (errSecUserCanceled) if user canceled
// other positive OSStatus error codes on failure
//
// prompt is the localized Touch ID prompt string (CFStringRef).
static int getBiometricItem(CFStringRef service, CFStringRef account, CFStringRef prompt, CFDataRef *dataOut) {
// Create an LAContext and set its localizedReason for the Touch ID
// prompt. We use the objc runtime to avoid including Objective-C
// headers. CFStringRef is toll-free bridged to NSString*.
id laContext = NULL;
if (prompt) {
Class cls = objc_getClass("LAContext");
if (cls) {
id alloc = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("alloc"));
if (alloc) {
laContext = ((id (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("init"));
}
}
if (laContext) {
((void (*)(id, SEL, id))objc_msgSend)(
laContext, sel_getUid("setLocalizedReason:"), (id)prompt);
}
}
CFDictionaryRef query;
if (laContext) {
const void *keys[] = {
kSecClass,
kSecAttrService,
kSecAttrAccount,
kSecMatchLimit,
kSecReturnData,
kSecUseAuthenticationContext,
};
const void *values[] = {
kSecClassGenericPassword,
service,
account,
kSecMatchLimitOne,
kCFBooleanTrue,
laContext,
};
query = CFDictionaryCreate(
kCFAllocatorDefault,
keys, values, sizeof(keys) / sizeof(keys[0]),
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
} else {
// Fallback: no LAContext (e.g. LAContext class not available).
const void *keys[] = {
kSecClass,
kSecAttrService,
kSecAttrAccount,
kSecMatchLimit,
kSecReturnData,
};
const void *values[] = {
kSecClassGenericPassword,
service,
account,
kSecMatchLimitOne,
kCFBooleanTrue,
};
query = CFDictionaryCreate(
kCFAllocatorDefault,
keys, values, sizeof(keys) / sizeof(keys[0]),
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
}
CFTypeRef result = NULL;
OSStatus status = SecItemCopyMatching(query, &result);
CFRelease(query);
if (laContext) {
((void (*)(id, SEL))objc_msgSend)(laContext, sel_getUid("release"));
}
if (status == errSecItemNotFound) {
return -1;
}
if (status != errSecSuccess) {
return (int)status;
}
*dataOut = (CFDataRef)result;
return 0;
}
// deleteBiometricItem deletes a keychain item by service+account.
// Returns 0 on success (including "not found"), or OSStatus on error.
static int deleteBiometricItem(CFStringRef service, CFStringRef account) {
const void *keys[] = {
kSecClass,
kSecAttrService,
kSecAttrAccount,
};
const void *values[] = {
kSecClassGenericPassword,
service,
account,
};
CFDictionaryRef query = CFDictionaryCreate(
kCFAllocatorDefault,
keys, values, sizeof(keys) / sizeof(keys[0]),
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
OSStatus status = SecItemDelete(query);
CFRelease(query);
if (status == errSecItemNotFound) {
return 0;
}
return (int)status;
}
*/
import "C"
import (
"fmt"
"sync"
"unsafe"
)
// errSecUserCanceled is the macOS Security framework error code returned
// when the user cancels a Touch ID / password prompt (-128).
const errSecUserCanceled = -128
var (
biometricCacheOnce sync.Once
biometricCacheVal bool
)
// biometricAvailable reports whether Touch ID is available on this Mac.
// The result is computed once and cached.
func biometricAvailable() bool {
biometricCacheOnce.Do(func() {
biometricCacheVal = C.biometricAvailable() == 1
})
return biometricCacheVal
}
// cfRelease releases a CFTypeRef (CFString, CFData, etc.).
func cfRelease(ref C.CFTypeRef) {
if ref != 0 {
C.CFRelease(ref)
}
}
// cfString creates a CFStringRef from a Go string. The caller must
// CFRelease the result.
func cfString(s string) (C.CFStringRef, error) {
if s == "" {
return 0, nil
}
cs := C.CFStringCreateWithBytes(
C.kCFAllocatorDefault,
(*C.UInt8)(unsafe.Pointer(&[]byte(s)[0])),
C.CFIndex(len(s)),
C.kCFStringEncodingUTF8,
C.false)
if cs == 0 {
return 0, fmt.Errorf("CFStringCreateWithBytes failed for %q", s)
}
return cs, nil
}
// cfData creates a CFDataRef from a byte slice. The caller must
// CFRelease the result.
func cfData(b []byte) (C.CFDataRef, error) {
var p *C.UInt8
if len(b) > 0 {
p = (*C.UInt8)(unsafe.Pointer(&b[0]))
}
cd := C.CFDataCreate(C.kCFAllocatorDefault, p, C.CFIndex(len(b)))
if cd == 0 {
return 0, fmt.Errorf("CFDataCreate failed")
}
return cd, nil
}
// storeBiometricItemGo stores a secret in the keychain with Touch ID
// protection. It first deletes any existing item (which may or may not
// have biometric protection), then adds a new biometric-protected item.
func storeBiometricItemGo(service, account, secret string) error {
svcRef, err := cfString(service)
if err != nil {
return fmt.Errorf("keychain biometric store %s: %w", account, err)
}
defer cfRelease(C.CFTypeRef(svcRef))
accRef, err := cfString(account)
if err != nil {
return fmt.Errorf("keychain biometric store %s: %w", account, err)
}
defer cfRelease(C.CFTypeRef(accRef))
secretBytes := []byte(secret)
dataRef, err := cfData(secretBytes)
if err != nil {
return fmt.Errorf("keychain biometric store %s: %w", account, err)
}
defer cfRelease(C.CFTypeRef(dataRef))
status := C.storeBiometricItem(svcRef, accRef, dataRef)
if status != 0 {
return fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status)
}
return nil
}
// getBiometricItemGo retrieves a secret from the keychain, prompting
// for Touch ID if the item is biometric-protected. prompt is the
// localized text shown in the Touch ID dialog.
func getBiometricItemGo(service, account, prompt string) (string, error) {
svcRef, err := cfString(service)
if err != nil {
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
}
defer cfRelease(C.CFTypeRef(svcRef))
accRef, err := cfString(account)
if err != nil {
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
}
defer cfRelease(C.CFTypeRef(accRef))
promptRef, err := cfString(prompt)
if err != nil {
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
}
defer cfRelease(C.CFTypeRef(promptRef))
var dataOut C.CFDataRef
status := C.getBiometricItem(svcRef, accRef, promptRef, &dataOut)
if status == -1 {
return "", ErrNotFound
}
if status == errSecUserCanceled {
return "", ErrUserCanceled
}
if status != 0 {
return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status)
}
defer cfRelease(C.CFTypeRef(dataOut))
bytes := C.GoBytes(unsafe.Pointer(C.CFDataGetBytePtr(dataOut)), C.int(C.CFDataGetLength(dataOut)))
return string(bytes), nil
}
// deleteBiometricItemGo deletes a keychain item by service+account.
// It works regardless of whether the item has biometric protection.
func deleteBiometricItemGo(service, account string) error {
svcRef, err := cfString(service)
if err != nil {
return fmt.Errorf("keychain biometric delete %s: %w", account, err)
}
defer cfRelease(C.CFTypeRef(svcRef))
accRef, err := cfString(account)
if err != nil {
return fmt.Errorf("keychain biometric delete %s: %w", account, err)
}
defer cfRelease(C.CFTypeRef(accRef))
status := C.deleteBiometricItem(svcRef, accRef)
if status != 0 {
return fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
}
return nil
}
+11
View File
@@ -19,6 +19,16 @@ const (
AuthModePassword AuthMode = "password" // {type:auth} first message AuthModePassword AuthMode = "password" // {type:auth} first message
) )
// IPPreference controls which IP address family is used when connecting
// to a server by hostname (ServerIPs empty).
type IPPreference string
const (
IPPrefAuto IPPreference = "auto" // race all resolved addresses (v4 + v6)
IPPrefV4 IPPreference = "v4" // only IPv4 addresses
IPPrefV6 IPPreference = "v6" // only IPv6 addresses
)
// RoutingMode selects which traffic goes through the VPN tunnel. // RoutingMode selects which traffic goes through the VPN tunnel.
type RoutingMode string type RoutingMode string
@@ -65,6 +75,7 @@ type ServerProfile struct {
TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only) TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only) TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only) TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only)
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastConnectedAt *time.Time `json:"last_connected_at"` LastConnectedAt *time.Time `json:"last_connected_at"`
} }
-2
View File
@@ -56,5 +56,3 @@ func LogFile() string { return Paths.Log + "/lmvpn.log" }
// DaemonLogFile returns the path to the daemon log file. // DaemonLogFile returns the path to the daemon log file.
func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" } func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" }
+23 -3
View File
@@ -34,6 +34,8 @@ type Stats struct {
state atomic.Value // State state atomic.Value // State
assignedIP atomic.Value // string (IPv4) assignedIP atomic.Value // string (IPv4)
assignedIP6 atomic.Value // string (IPv6, may be empty) assignedIP6 atomic.Value // string (IPv6, may be empty)
serverHost atomic.Value // string (server hostname or IP from URL)
connectedIP atomic.Value // string (actual remote IP of the connection)
routeLoading atomic.Bool // true while deferred routes are being applied routeLoading atomic.Bool // true while deferred routes are being applied
connectStep atomic.Value // string (human-readable connection step) connectStep atomic.Value // string (human-readable connection step)
cidrError atomic.Value // string (CIDR fetch error message, empty = no error) cidrError atomic.Value // string (CIDR fetch error message, empty = no error)
@@ -79,6 +81,8 @@ func New() *Stats {
s.state.Store(StateDisconnected) s.state.Store(StateDisconnected)
s.assignedIP.Store("") s.assignedIP.Store("")
s.assignedIP6.Store("") s.assignedIP6.Store("")
s.serverHost.Store("")
s.connectedIP.Store("")
s.connectStep.Store("") s.connectStep.Store("")
s.cidrError.Store("") s.cidrError.Store("")
return s return s
@@ -90,12 +94,16 @@ func (s *Stats) SetState(st State) { s.state.Store(st) }
// State returns the current state. // State returns the current state.
func (s *Stats) State() State { return s.state.Load().(State) } func (s *Stats) State() State { return s.state.Load().(State) }
// SetConnected marks the session as connected, recording the time and // SetConnected marks the session as connected, recording the time,
// assigned IP addresses. ip6 may be empty for an IPv4-only server. // assigned IP addresses, and server connection info. ip6 may be empty
func (s *Stats) SetConnected(ip, ip6 string) { // for an IPv4-only server. serverHost is the hostname/IP from the
// server URL; connectedIP is the actual remote IP of the connection.
func (s *Stats) SetConnected(ip, ip6, serverHost, connectedIP string) {
s.ConnectedAt.Store(time.Now().Unix()) s.ConnectedAt.Store(time.Now().Unix())
s.assignedIP.Store(ip) s.assignedIP.Store(ip)
s.assignedIP6.Store(ip6) s.assignedIP6.Store(ip6)
s.serverHost.Store(serverHost)
s.connectedIP.Store(connectedIP)
s.state.Store(StateConnected) s.state.Store(StateConnected)
} }
@@ -104,6 +112,8 @@ func (s *Stats) SetDisconnected() {
s.ConnectedAt.Store(0) s.ConnectedAt.Store(0)
s.assignedIP.Store("") s.assignedIP.Store("")
s.assignedIP6.Store("") s.assignedIP6.Store("")
s.serverHost.Store("")
s.connectedIP.Store("")
s.state.Store(StateDisconnected) s.state.Store(StateDisconnected)
s.routeLoading.Store(false) s.routeLoading.Store(false)
s.connectStep.Store("") s.connectStep.Store("")
@@ -149,6 +159,12 @@ func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
// AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty). // AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty).
func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) } func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) }
// ServerHost returns the server hostname/IP from the connection URL.
func (s *Stats) ServerHost() string { return s.serverHost.Load().(string) }
// ConnectedIP returns the actual remote IP of the connection.
func (s *Stats) ConnectedIP() string { return s.connectedIP.Load().(string) }
// Snapshot returns a point-in-time copy of all counters. // Snapshot returns a point-in-time copy of all counters.
// //
// Per-family byte counters are read directly. The combined RxBytes/ // Per-family byte counters are read directly. The combined RxBytes/
@@ -174,6 +190,8 @@ type Snapshot struct {
ConnectedAt time.Time ConnectedAt time.Time
AssignedIP string AssignedIP string
AssignedIP6 string AssignedIP6 string
ServerHost string `json:"server_host,omitempty"` // server hostname/IP from URL
ConnectedIP string `json:"connected_ip,omitempty"` // actual remote IP of the connection
State State State State
Uptime time.Duration Uptime time.Duration
@@ -203,6 +221,8 @@ func (s *Stats) Snapshot() Snapshot {
TxBytes: txv4 + txv6, TxBytes: txv4 + txv6,
AssignedIP: s.AssignedIP(), AssignedIP: s.AssignedIP(),
AssignedIP6: s.AssignedIP6(), AssignedIP6: s.AssignedIP6(),
ServerHost: s.ServerHost(),
ConnectedIP: s.ConnectedIP(),
State: s.State(), State: s.State(),
} }
ts := s.ConnectedAt.Load() ts := s.ConnectedAt.Load()
+135
View File
@@ -0,0 +1,135 @@
package transport
import (
"context"
"fmt"
"net"
"time"
)
// NewRaceDialer returns a dial function suitable for use as a
// websocket.Dialer.NetDialContext or http.Transport.DialContext.
//
// When the host in addr is a domain name, it resolves all A/AAAA
// records and dials them concurrently, returning the first successful
// connection (true parallel racing, not Happy Eyeballs staggered start).
//
// preference controls which address families participate:
// - "auto": race all resolved addresses (v4 + v6)
// - "v4": only IPv4 addresses
// - "v6": only IPv6 addresses
//
// When the host is an IP literal (CDN failover or direct IP mode), it
// dials directly without racing — there is only one target.
func NewRaceDialer(preference string) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("split host port: %w", err)
}
// IP literal: dial directly, no racing.
if ip := net.ParseIP(host); ip != nil {
d := &net.Dialer{}
return d.DialContext(ctx, network, addr)
}
// Domain name: resolve and race.
ips, err := resolveAndFilter(ctx, host, preference)
if err != nil {
return nil, err
}
if len(ips) == 1 {
d := &net.Dialer{}
return d.DialContext(ctx, network, net.JoinHostPort(ips[0], port))
}
return raceDial(ctx, network, ips, port)
}
}
// resolveAndFilter resolves a hostname and filters by preference.
func resolveAndFilter(ctx context.Context, host, preference string) ([]string, error) {
resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
addrs, err := net.DefaultResolver.LookupIPAddr(resolveCtx, host)
if err != nil {
return nil, fmt.Errorf("lookup %s: %w", host, err)
}
var ips []string
for _, a := range addrs {
isV4 := a.IP.To4() != nil
switch preference {
case "v4":
if isV4 {
ips = append(ips, a.IP.String())
}
case "v6":
if !isV4 {
ips = append(ips, a.IP.String())
}
default: // "auto" or unspecified
ips = append(ips, a.IP.String())
}
}
if len(ips) == 0 {
return nil, fmt.Errorf("lookup %s: no addresses for preference %q", host, preference)
}
return ips, nil
}
// raceDial dials all IPs concurrently and returns the first successful
// connection. Losing connections are closed and their errors discarded.
func raceDial(ctx context.Context, network string, ips []string, port string) (net.Conn, error) {
type result struct {
conn net.Conn
err error
}
raceCtx, cancel := context.WithCancel(ctx)
defer cancel()
resultCh := make(chan result, len(ips))
for _, ip := range ips {
go func(target string) {
d := &net.Dialer{}
c, err := d.DialContext(raceCtx, network, net.JoinHostPort(target, port))
select {
case resultCh <- result{conn: c, err: err}:
case <-raceCtx.Done():
if c != nil {
c.Close()
}
}
}(ip)
}
var firstErr error
for i := 0; i < len(ips); i++ {
r := <-resultCh
if r.err == nil {
// Winner. Cancel remaining dialers; drain and close
// any late successful connections.
cancel()
for j := i + 1; j < len(ips); j++ {
if late := <-resultCh; late.conn != nil {
late.conn.Close()
}
}
return r.conn, nil
}
if firstErr == nil {
firstErr = r.err
}
}
if firstErr == nil {
firstErr = fmt.Errorf("all dial attempts failed")
}
return nil, firstErr
}
+17
View File
@@ -18,6 +18,7 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"sync" "sync"
@@ -37,6 +38,7 @@ type HandshakeConfig struct {
Password string // for password auth (method B), or fallback Password string // for password auth (method B), or fallback
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost) TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
} }
// Conn is an established VPN tunnel connection. // Conn is an established VPN tunnel connection.
@@ -53,6 +55,7 @@ type Conn struct {
// error occurs. // error occurs.
func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) { func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
dialer := websocket.Dialer{ dialer := websocket.Dialer{
NetDialContext: NewRaceDialer(cfg.IPPreference),
HandshakeTimeout: 15 * time.Second, HandshakeTimeout: 15 * time.Second,
ReadBufferSize: 4096, // match server (handler.go:17) ReadBufferSize: 4096, // match server (handler.go:17)
WriteBufferSize: 4096, // match server (handler.go:18) WriteBufferSize: 4096, // match server (handler.go:18)
@@ -269,6 +272,20 @@ func (c *Conn) AssignedIP() string { return c.init.IP }
// AssignedIP6 returns the IPv6 assigned by the server (empty if none). // AssignedIP6 returns the IPv6 assigned by the server (empty if none).
func (c *Conn) AssignedIP6() string { return c.init.IP6 } func (c *Conn) AssignedIP6() string { return c.init.IP6 }
// RemoteIP returns the remote IP address of the underlying TCP
// connection (the actual server/CDN IP the WebSocket is connected to).
// Returns an empty string if the connection is not established.
func (c *Conn) RemoteIP() string {
if c.ws == nil {
return ""
}
addr := c.ws.RemoteAddr()
if tcpAddr, ok := addr.(*net.TCPAddr); ok {
return tcpAddr.IP.String()
}
return addr.String()
}
// Close terminates the connection. // Close terminates the connection.
func (c *Conn) Close() error { func (c *Conn) Close() error {
c.mu.Lock() c.mu.Lock()
-2
View File
@@ -153,5 +153,3 @@ func execCmd(name string, arg ...string) error {
} }
return nil return nil
} }
+6
View File
@@ -98,6 +98,9 @@ func Run() {
log.L().Error("init i18n", "error", err) log.L().Error("init i18n", "error", err)
} }
// Set the localized Touch ID prompt for keychain access (macOS).
setTouchIDPromptFromI18n()
a := &App{ a := &App{
fyneApp: app.NewWithID(paths.BundleID), fyneApp: app.NewWithID(paths.BundleID),
db: store, db: store,
@@ -358,6 +361,9 @@ func (a *App) changeLanguage(lang string) {
// Switch the active localizer. // Switch the active localizer.
i18n.SetLanguage(lang) i18n.SetLanguage(lang)
// Update the Touch ID prompt text for the new language.
setTouchIDPromptFromI18n()
// Rebuild everything that holds cached strings. // Rebuild everything that holds cached strings.
a.rebuildUI() a.rebuildUI()
} }
-2
View File
@@ -86,5 +86,3 @@ func ensureDaemon() (*ipc.Client, error) {
} }
return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile) return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile)
} }
+1 -1
View File
@@ -29,7 +29,7 @@ func launchElevated(exe, daemonBin, home string, uid, gid int) error {
} }
// shellQuote wraps a string in single quotes for shell safety. // shellQuote wraps a string in single quotes for shell safety.
// Embedded single quotes are escaped using the '\'' pattern. // Embedded single quotes are escaped using the '\ pattern.
func shellQuote(s string) string { func shellQuote(s string) string {
result := "'" result := "'"
for _, r := range s { for _, r := range s {
+17 -3
View File
@@ -25,6 +25,8 @@ var (
protoCodes = []string{"wss", "ws"} protoCodes = []string{"wss", "ws"}
fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)} fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)}
ipPrefCodes = []string{string(model.IPPrefAuto), string(model.IPPrefV4), string(model.IPPrefV6)}
) )
func authModeLabels() []string { func authModeLabels() []string {
@@ -43,6 +45,10 @@ func fetchTimingLabels() []string {
return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")} return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")}
} }
func ipPrefLabels() []string {
return []string{i18n.T("IPPrefAuto"), i18n.T("IPPrefV4"), i18n.T("IPPrefV6")}
}
// codeIndex returns the position of code in codes, or 0 if not found. // codeIndex returns the position of code in codes, or 0 if not found.
func codeIndex(codes []string, code string) int { func codeIndex(codes []string, code string) int {
for i, c := range codes { for i, c := range codes {
@@ -198,6 +204,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
authSelect := widget.NewSelect(authModeLabels(), nil) authSelect := widget.NewSelect(authModeLabels(), nil)
routeSelect := widget.NewSelect(routeModeLabels(), nil) routeSelect := widget.NewSelect(routeModeLabels(), nil)
ipPrefSelect := widget.NewSelect(ipPrefLabels(), nil)
cidrV4Entry := widget.NewMultiLineEntry() cidrV4Entry := widget.NewMultiLineEntry()
cidrV4Entry.Wrapping = fyne.TextWrapOff cidrV4Entry.Wrapping = fyne.TextWrapOff
@@ -300,6 +307,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
userEntry.SetText(editing.Username) userEntry.SetText(editing.Username)
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode))) authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode))) routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
ipPrefSelect.SetSelectedIndex(codeIndex(ipPrefCodes, editing.IPPreference))
cidrV4Entry.SetText(editing.CIDRV4) cidrV4Entry.SetText(editing.CIDRV4)
cidrV6Entry.SetText(editing.CIDRV6) cidrV6Entry.SetText(editing.CIDRV6)
v4URLList.loadFromJSON(editing.CIDRV4URLs) v4URLList.loadFromJSON(editing.CIDRV4URLs)
@@ -316,6 +324,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
pathEntry.SetText("/ws") pathEntry.SetText("/ws")
authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth))) authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull))) routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull)))
ipPrefSelect.SetSelectedIndex(0) // auto
mtuEntry.SetText("0") mtuEntry.SetText("0")
} }
@@ -349,9 +358,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry), container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry),
), ),
container.NewGridWithColumns(2, container.NewGridWithColumns(3,
container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect), container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect),
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect), container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
container.NewVBox(widget.NewLabel(i18n.T("FieldIPPreference")), ipPrefSelect),
), ),
cidrSection, cidrSection,
@@ -394,7 +404,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
mtuEntry.Text, mtuEntry.Text,
tlsCaPEMEntry.Text, tlsCaPathEntry.Text, tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked, tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
isNew) { isNew,
selectedCode(ipPrefCodes, ipPrefSelect.SelectedIndex())) {
profileWin.Close() profileWin.Close()
} }
}) })
@@ -414,7 +425,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
func (a *App) saveProfile(editing *model.ServerProfile, func (a *App) saveProfile(editing *model.ServerProfile,
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode,
cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr, cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr,
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool { tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool,
ipPreference string) bool {
if name == "" || host == "" || user == "" { if name == "" || host == "" || user == "" {
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window) showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
return false return false
@@ -454,6 +466,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
TLSCAPath: tlsCaPath, TLSCAPath: tlsCaPath,
TLSInsecure: tlsInsecure, TLSInsecure: tlsInsecure,
TLSPinnedHash: tlsPinnedHash, TLSPinnedHash: tlsPinnedHash,
IPPreference: ipPreference,
} }
id, err := a.db.CreateProfile(p) id, err := a.db.CreateProfile(p)
if err != nil { if err != nil {
@@ -486,6 +499,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
editing.TLSCAPath = tlsCaPath editing.TLSCAPath = tlsCaPath
editing.TLSInsecure = tlsInsecure editing.TLSInsecure = tlsInsecure
editing.TLSPinnedHash = tlsPinnedHash editing.TLSPinnedHash = tlsPinnedHash
editing.IPPreference = ipPreference
if err := a.db.UpdateProfile(editing); err != nil { if err := a.db.UpdateProfile(editing); err != nil {
showError(i18n.T("DlgSaveError"), err.Error(), a.window) showError(i18n.T("DlgSaveError"), err.Error(), a.window)
return false return false
+14
View File
@@ -0,0 +1,14 @@
//go:build darwin
package ui
import (
"lmvpn/internal/i18n"
"lmvpn/internal/keychain"
)
// setTouchIDPromptFromI18n configures the localized Touch ID prompt
// text on the keychain store for the current language.
func setTouchIDPromptFromI18n() {
keychain.SetTouchIDPrompt(i18n.T("TouchIDPrompt"))
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !darwin
package ui
// setTouchIDPromptFromI18n is a no-op on non-darwin platforms where
// Touch ID is not available.
func setTouchIDPromptFromI18n() {}
+12
View File
@@ -3,12 +3,14 @@ package ui
import ( import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"time" "time"
"lmvpn/internal/i18n" "lmvpn/internal/i18n"
"lmvpn/internal/ipc" "lmvpn/internal/ipc"
"lmvpn/internal/keychain"
"lmvpn/internal/model" "lmvpn/internal/model"
"lmvpn/internal/stats" "lmvpn/internal/stats"
@@ -172,11 +174,17 @@ func (a *App) onConnect() {
password, err := a.kc.GetPassword(a.currentProfile.Name) password, err := a.kc.GetPassword(a.currentProfile.Name)
if err != nil { if err != nil {
fyne.Do(func() { fyne.Do(func() {
if errors.Is(err, keychain.ErrUserCanceled) {
// User canceled the Touch ID / password prompt.
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.setConnButtons(true, false)
} else {
showError(i18n.T("DlgCredentialError"), showError(i18n.T("DlgCredentialError"),
i18n.T("DlgCredentialErrorMsg"), i18n.T("DlgCredentialErrorMsg"),
a.window) a.window)
a.stateLabel.SetText(i18n.T("StateDisconnected")) a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.setConnButtons(true, false) a.setConnButtons(true, false)
}
}) })
return return
} }
@@ -207,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() {
@@ -420,6 +429,9 @@ func (a *App) applyStats(s stats.Snapshot) {
stepLabel := connectStepLabel(s.ConnectStep) stepLabel := connectStepLabel(s.ConnectStep)
if stepLabel != "" { if stepLabel != "" {
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")") a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
} else if s.ServerHost != "" && s.ConnectedIP != "" {
a.stateLabel.SetText(fmt.Sprintf("%s (%s -> %s)",
i18n.T("StateConnected"), s.ServerHost, s.ConnectedIP))
} else { } else {
a.stateLabel.SetText(i18n.T("StateConnected")) a.stateLabel.SetText(i18n.T("StateConnected"))
} }
+28 -15
View File
@@ -50,6 +50,7 @@ type SessionConfig struct {
TLSCAPath string // CA cert file path (wss only) TLSCAPath string // CA cert file path (wss only)
TLSInsecure bool // skip cert verification (wss only) TLSInsecure bool // skip cert verification (wss only)
TLSPinnedHash string // SHA-256 cert pin (wss only) TLSPinnedHash string // SHA-256 cert pin (wss only)
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
} }
// SessionManager manages a single VPN session with auto-reconnect. // SessionManager manages a single VPN session with auto-reconnect.
@@ -212,8 +213,16 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
backoff := time.Second backoff := time.Second
maxBackoff := 60 * time.Second maxBackoff := 60 * time.Second
// Build the full target list: original host first, then CDN IPs. // Build the full target list. When ServerIPs is empty, connect via
targets := append([]string{""}, cfg.ServerIPs...) // "" = use base URL // hostname (IPPreference + race dialer apply). When ServerIPs is
// non-empty, skip hostname and use IPs directly with failover.
usingHostname := len(cfg.ServerIPs) == 0
var targets []string
if usingHostname {
targets = []string{""} // "" = use base URL (hostname)
} else {
targets = cfg.ServerIPs // direct IP mode: sequential failover
}
ipIndex := 0 ipIndex := 0
for { for {
@@ -223,7 +232,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
} }
targetIP := "" targetIP := ""
if ipIndex > 0 && ipIndex < len(targets) { if ipIndex < len(targets) {
targetIP = targets[ipIndex] targetIP = targets[ipIndex]
} }
@@ -242,13 +251,13 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanupResources() sm.cleanupResources()
// A TLS certificate verification failure on the original // A TLS certificate verification failure on the original
// hostname (ipIndex == 0) is not retryable: the cert won't // hostname (ipIndex == 0, hostname mode) is not retryable:
// change between attempts, so stop the loop and surface the // the cert won't change between attempts, so stop the loop
// reason to the user. On a CDN edge IP (ipIndex > 0) the // and surface the reason to the user. On a CDN edge IP the
// TLS error likely means that IP points to a different // TLS error likely means that IP points to a different
// server; skip it and try the next target. // server; skip it and try the next target.
if tlsconfig.IsTLSError(err) { if tlsconfig.IsTLSError(err) {
if ipIndex == 0 { if usingHostname && ipIndex == 0 {
log.L().Warn("fatal TLS error, stopping reconnect", "error", err) log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
sm.setState(stats.StateError) sm.setState(stats.StateError)
if sm.onError != nil { if sm.onError != nil {
@@ -258,7 +267,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanup() sm.cleanup()
return return
} }
log.L().Warn("TLS error on CDN IP, skipping", log.L().Warn("TLS error on IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "error", err) "index", ipIndex, "ip", targets[ipIndex], "error", err)
} }
@@ -268,7 +277,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
// means the IP points to a different server that returned // means the IP points to a different server that returned
// 401/403, so skip it instead of stopping the loop. // 401/403, so skip it instead of stopping the loop.
if code, msg, isFatal := fatalAuthError(err); isFatal { if code, msg, isFatal := fatalAuthError(err); isFatal {
if ipIndex == 0 { if usingHostname && ipIndex == 0 {
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg) log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
sm.setState(stats.StateError) sm.setState(stats.StateError)
if sm.onError != nil { if sm.onError != nil {
@@ -278,16 +287,16 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanup() sm.cleanup()
return return
} }
log.L().Warn("auth error on CDN IP, skipping", log.L().Warn("auth error on IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "code", code) "index", ipIndex, "ip", targets[ipIndex], "code", code)
} }
sm.setState(stats.StateReconnecting) sm.setState(stats.StateReconnecting)
// Try next CDN IP immediately. // Try next target IP immediately.
ipIndex++ ipIndex++
if ipIndex < len(targets) { if ipIndex < len(targets) {
log.L().Info("trying next CDN IP", "index", ipIndex, "ip", targets[ipIndex]) log.L().Info("trying next server IP", "index", ipIndex, "ip", targets[ipIndex])
continue continue
} }
// All targets exhausted; reset and wait with backoff. // All targets exhausted; reset and wait with backoff.
@@ -389,7 +398,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
if err != nil { if err != nil {
return fmt.Errorf("parse server URL: %w", err) return fmt.Errorf("parse server URL: %w", err)
} }
result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg) result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg, cfg.IPPreference)
if err != nil { if err != nil {
if cfg.AuthMode == model.AuthModeBoth { if cfg.AuthMode == model.AuthModeBoth {
// Fall back to password auth. // Fall back to password auth.
@@ -410,6 +419,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
Token: token, Token: token,
Username: cfg.Username, Username: cfg.Username,
Password: cfg.Password, Password: cfg.Password,
IPPreference: cfg.IPPreference,
OnInit: func(init protocol.InitMessage) error { OnInit: func(init protocol.InitMessage) error {
return sm.setupTUN(init, cfg, beforeCIDRs) return sm.setupTUN(init, cfg, beforeCIDRs)
}, },
@@ -448,13 +458,16 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
sm.conn = conn sm.conn = conn
sm.mu.Unlock() sm.mu.Unlock()
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6) serverHost := serverHostFromURL(cfg.ServerURL)
connectedIP := conn.RemoteIP()
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6, serverHost, connectedIP)
sm.stats.SetConnectStep("load_routes") sm.stats.SetConnectStep("load_routes")
sm.setState(stats.StateConnected) sm.setState(stats.StateConnected)
log.L().Info("VPN connected", log.L().Info("VPN connected",
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP, "ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
"ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6, "ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6,
"mtu", conn.Init().MTU) "mtu", conn.Init().MTU,
"server_host", serverHost, "connected_ip", connectedIP)
// Start stats reporter. // Start stats reporter.
statsDone := make(chan struct{}) statsDone := make(chan struct{})
+2 -2
View File
@@ -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)
} }