12 Commits
Author SHA1 Message Date
kevin 6fd29fa5b2 fix: 全隧道模式跳过无意义的CIDR URL下载
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
全隧道模式下before-proxy、after-proxy和手动刷新三处仍会从URL
下载CIDR表,但下载结果永远不会被使用。添加ModeFull短路返回,
消除无意义的网络请求和连接延迟。

bump version to 0.6.9
2026-07-10 15:01:36 +08:00
kevin dc3e9beb14 chore: bump version to 0.6.8
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
2026-07-09 23:22:22 +08:00
kevin 9d7d4c8287 fix: 设为纯托盘应用,启动不再在Dock显示图标
将Info.plist的LSUIElement改为true,应用以accessory模式启动,
永不显示Dock图标和系统菜单栏。移除运行时切换Dock可见性的
showDockIcon()/hideDockIcon()及其C实现setDockIconVisible,
这些调用在accessory模式下已无意义。activateApp()保留用于
窗口置于前台获取焦点。
2026-07-09 23:20:10 +08:00
kevin 469106d502 fix: 修复raceDial竞态条件导致daemon死锁无法连接
raceDial在首个连接成功后cancel剩余拨号goroutine时,dial goroutine
的select有两个同时就绪的case(缓冲channel可发送 vs raceCtx.Done()),
Go随机选择可能导致结果未发送,使drain循环永久阻塞,进而导致daemon
持有d.mu死锁,所有IPC命令(stats/stop/新start)全部阻塞。

DNS解析返回IPv4+IPv6双地址时触发此bug,约50%概率死锁。

修复:移除select,直接发送到缓冲channel(容量=len(ips)永不阻塞),
多余连接由drain循环负责关闭。
2026-07-09 23:19:46 +08:00
kevin e61d4fedd8 fix: 修复钥匙串保存配置时errSecMissingEntitlement(-34018)错误
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
biometric钥匙串路径在缺少代码签名entitlement时自动降级到普通钥匙串;
添加LMVPN.entitlements文件和Makefile可选codesign步骤以支持未来Touch ID。
2026-07-09 19:23:09 +08:00
kevin 1886d9b07b up 2026-07-09 19:07:40 +08:00
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
kevin b52db70015 fix: 修复Windows/Linux编译缺失onAppActive符号的问题
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
onAppActive 仅在 dock_callback_darwin.go 中定义,导致非 darwin
平台 go vet/编译失败。在 dock_other.go 补充同名声明。
2026-07-09 10:48:23 +08:00
kevin 1c4906c266 chore: bump version to 0.6.1
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
2026-07-09 09:46:45 +08:00
33 changed files with 1148 additions and 174 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.
+39 -2
View File
@@ -11,12 +11,18 @@ GO = go
CGO_ENABLED = 1
WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc
SEMVER ?= 0.5.9
SEMVER ?= 0.6.9
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
VERSION = $(SEMVER)-$(GIT_HASH)
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows
# Code signing (optional). Set these to sign the .app bundle with a
# Developer ID certificate, enabling biometric (Touch ID) keychain access.
# Example: make app CODESIGN_IDENTITY="Developer ID Application: Name (ABCDE12345)" TEAM_ID=ABCDE12345
CODESIGN_IDENTITY ?=
TEAM_ID ?=
.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows sign
## all: build the .app bundle (default)
all: app
@@ -41,8 +47,39 @@ app: build
@if [ -f resources/icon.icns ]; then \
cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \
else echo " (no icon.icns found, skipping icon)"; fi
@if [ -n "$(CODESIGN_IDENTITY)" ]; then $(MAKE) sign; \
else echo " (skipping code signing - set CODESIGN_IDENTITY and TEAM_ID to enable Touch ID keychain)"; fi
@echo "Built $(APP_BUNDLE)"
## sign: code-sign the .app bundle with a Developer ID certificate
## Requires CODESIGN_IDENTITY and TEAM_ID to be set.
sign:
@if [ -z "$(CODESIGN_IDENTITY)" ]; then \
echo "ERROR: CODESIGN_IDENTITY not set."; \
echo "Usage: make sign CODESIGN_IDENTITY='Developer ID Application: Name (TEAMID)' TEAM_ID=TEAMID"; \
exit 1; \
fi
@if [ -z "$(TEAM_ID)" ]; then \
echo "ERROR: TEAM_ID not set."; \
exit 1; \
fi
@tmp=$$(mktemp LMVPN.entitlements.XXXXXX); \
sed 's/\$$(AppIdentifierPrefix)/$(TEAM_ID)./' resources/LMVPN.entitlements > $$tmp; \
codesign --force --options runtime \
--sign "$(CODESIGN_IDENTITY)" \
--entitlements $$tmp \
$(APP_BUNDLE)/Contents/MacOS/$(GUI_BIN); \
codesign --force --options runtime \
--sign "$(CODESIGN_IDENTITY)" \
--entitlements $$tmp \
$(APP_BUNDLE)/Contents/MacOS/$(DAEMON_BIN); \
codesign --force --options runtime \
--sign "$(CODESIGN_IDENTITY)" \
--entitlements $$tmp \
$(APP_BUNDLE); \
rm -f $$tmp
@echo "Signed $(APP_BUNDLE) with identity: $(CODESIGN_IDENTITY)"
## icon: generate icon.icns from resources/icon.png (or resources/logo.svg)
icon:
@if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \
+12 -4
View File
@@ -14,6 +14,8 @@ import (
"net/http"
"strings"
"time"
"lmvpn/internal/transport"
)
// LoginResult holds the response from a successful /api/login call.
@@ -49,19 +51,25 @@ type errorResponse struct {
// host will be an IP address, but the certificate must be verified
// against the real hostname (set tlsCfg.ServerName).
//
// ipPreference controls which IP address families are used when
// resolving the server hostname ("auto", "v4", "v6").
//
// ctx allows cancellation of the HTTP request (e.g. when the VPN
// session is disconnected while login is in flight).
func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) {
func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config, ipPreference string) (*LoginResult, error) {
body, err := json.Marshal(loginRequest{Username: username, Password: password})
if err != nil {
return nil, err
}
url := strings.TrimRight(baseURL, "/") + "/api/login"
client := &http.Client{Timeout: 15 * time.Second}
if tlsCfg != nil {
client.Transport = &http.Transport{TLSClientConfig: tlsCfg}
httpTransport := &http.Transport{
DialContext: transport.NewRaceDialer(ipPreference),
}
if tlsCfg != nil {
httpTransport.TLSClientConfig = tlsCfg
}
client := &http.Client{Timeout: 15 * time.Second, Transport: httpTransport}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
+1
View File
@@ -165,6 +165,7 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
TLSCAPath: req.Config.TLSCAPath,
TLSInsecure: req.Config.TLSInsecure,
TLSPinnedHash: req.Config.TLSPinnedHash,
IPPreference: req.Config.IPPreference,
}
ctx, cancel := context.WithCancel(context.Background())
+19 -1
View File
@@ -55,7 +55,10 @@ func (s *Store) migrate() error {
if err := s.migrateV4(); err != nil {
return err
}
return s.migrateV5()
if err := s.migrateV5(); err != nil {
return err
}
return s.migrateV6()
}
func (s *Store) migrateV2() error {
@@ -290,6 +293,20 @@ func (s *Store) migrateV5() error {
return nil
}
// migrateV6 adds the ip_preference column to server_profiles for
// controlling IPv4/IPv6 address selection when connecting by hostname.
// Idempotent: skips if the column already exists.
func (s *Store) migrateV6() error {
if columnExists(s.db, "server_profiles", "ip_preference") {
return nil
}
_, err := s.db.Exec(`ALTER TABLE server_profiles ADD COLUMN ip_preference TEXT NOT NULL DEFAULT 'auto'`)
if err != nil {
return fmt.Errorf("migrate v6 add ip_preference: %w", err)
}
return nil
}
// splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and
// IPv6 parts. Used for migration from the old custom_cidrs column.
func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) {
@@ -354,6 +371,7 @@ CREATE TABLE IF NOT EXISTS server_profiles (
cidr_v6_urls TEXT NOT NULL DEFAULT '',
mtu_override INTEGER NOT NULL DEFAULT 0,
auto_connect INTEGER NOT NULL DEFAULT 0,
ip_preference TEXT NOT NULL DEFAULT 'auto',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_connected_at DATETIME
);
+12 -6
View File
@@ -16,13 +16,15 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
username, auth_mode, routing_mode,
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
ip_preference)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode,
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
p.IPPreference,
)
if err != nil {
return 0, fmt.Errorf("insert profile: %w", err)
@@ -43,6 +45,7 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
ip_preference,
created_at, last_connected_at
FROM server_profiles WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path,
@@ -50,7 +53,7 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last)
&p.IPPreference, &p.CreatedAt, &last)
if err != nil {
return nil, fmt.Errorf("get profile %d: %w", id, err)
}
@@ -68,6 +71,7 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
ip_preference,
created_at, last_connected_at
FROM server_profiles ORDER BY name`)
if err != nil {
@@ -84,7 +88,7 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last); err != nil {
&p.IPPreference, &p.CreatedAt, &last); err != nil {
return nil, err
}
if last.Valid {
@@ -103,13 +107,15 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error {
username = ?, auth_mode = ?, routing_mode = ?,
cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?,
mtu_override = ?, auto_connect = ?,
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?,
ip_preference = ?
WHERE id = ?`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode,
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID)
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
p.IPPreference, p.ID)
if err != nil {
return fmt.Errorf("update profile %d: %w", p.ID, err)
}
+8
View File
@@ -126,6 +126,11 @@ RoutingModeBypass = "Bypass CIDR"
FetchTimingBefore = "Before Proxy"
FetchTimingAfter = "After Proxy"
FieldIPPreference = "IP Preference"
IPPrefAuto = "Auto (Race)"
IPPrefV4 = "IPv4 Only"
IPPrefV6 = "IPv6 Only"
BtnAddURL = "Add URL"
BtnRemoveURL = "Remove"
@@ -141,3 +146,6 @@ PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
BtnBrowse = "Browse..."
DlgTLSError = "TLS Certificate Error"
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 = "代理前获取"
FetchTimingAfter = "代理后获取"
FieldIPPreference = "IP 偏好"
IPPrefAuto = "自动(竞速)"
IPPrefV4 = "仅 IPv4"
IPPrefV6 = "仅 IPv6"
BtnAddURL = "添加 URL"
BtnRemoveURL = "删除"
@@ -141,3 +146,6 @@ PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
BtnBrowse = "浏览..."
DlgTLSError = "TLS 证书错误"
TLSErrorVerification = "服务器证书验证失败。"
DlgBiometricCanceled = "认证已取消。"
TouchIDPrompt = "验证指纹以访问 VPN 密码"
+11 -10
View File
@@ -29,8 +29,8 @@ const (
CmdStop = "stop"
CmdShutdown = "shutdown"
CmdStats = "stats"
CmdVersion = "version" // query daemon build version
CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes
CmdVersion = "version" // query daemon build version
CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes
)
// Event types sent from daemon to GUI.
@@ -51,22 +51,23 @@ type Request struct {
// package (which needs root-only TUN) into the GUI.
type ClientConfig struct {
ServerURL string `json:"server_url"`
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
AuthMode string `json:"auth_mode"`
RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass"
CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs
CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs
CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources
CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources
RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass"
CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs
CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs
CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources
CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources
MTUOverride int `json:"mtu_override"`
TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only)
TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only)
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
}
// CIDRURLSource describes a URL that provides a CIDR list. It mirrors
@@ -83,7 +84,7 @@ type Event struct {
State string `json:"state,omitempty"`
ConnectStep string `json:"connect_step,omitempty"` // current connection step (for EvState)
Stats *stats.Snapshot `json:"stats,omitempty"`
Code string `json:"code,omitempty"` // stable auth-error code for EvError
Code string `json:"code,omitempty"` // stable auth-error code for EvError
Message string `json:"message,omitempty"`
}
+11
View File
@@ -19,6 +19,17 @@ const ServiceName = paths.BundleID
// ErrNotFound is returned when a secret is not present in the store.
var ErrNotFound = fmt.Errorf("secret not found")
// ErrSecMissingEntitlement is returned when the biometric keychain path
// fails because the app lacks the required code-signing entitlements
// (errSecMissingEntitlement, OSStatus -34018). Callers should fall back
// to the non-biometric keychain path when this error is encountered.
var ErrSecMissingEntitlement = fmt.Errorf("missing keychain entitlement")
// 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.
type Store interface {
SetPassword(profileName, password string) error
+106 -5
View File
@@ -3,7 +3,9 @@
package keychain
import (
"errors"
"fmt"
"sync"
"github.com/keybase/go-keychain"
)
@@ -14,22 +16,68 @@ const (
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
}
}
// biometricDisabled tracks whether the biometric keychain path has been
// disabled at runtime. This happens when a biometric operation fails with
// errSecMissingEntitlement (-34018), indicating the app lacks the required
// code-signing entitlements. Once disabled, all subsequent operations use
// the non-biometric file-based keychain path.
var (
biometricDisabled bool
biometricDisabledMu sync.Mutex
)
// shouldUseBiometric reports whether the biometric keychain path should be
// used. It returns false if biometric storage has been disabled at runtime
// (e.g. due to missing entitlements on an ad-hoc signed build).
func shouldUseBiometric() bool {
if !biometricAvailable() {
return false
}
biometricDisabledMu.Lock()
defer biometricDisabledMu.Unlock()
return !biometricDisabled
}
// disableBiometric disables the biometric keychain path for all subsequent
// operations, forcing a fallback to the non-biometric file-based keychain.
func disableBiometric() {
biometricDisabledMu.Lock()
defer biometricDisabledMu.Unlock()
biometricDisabled = true
}
// DarwinStore implements Store using the macOS Keychain.
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 {
return DarwinStore{}
}
func (DarwinStore) setItem(account, secret string) error {
// setItemPlain stores a secret in the file-based keychain (no biometric
// protection) using the keybase/go-keychain library.
func setItemPlain(account, secret string) error {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
item.SetAccount(account)
item.SetData([]byte(secret))
item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
// Delete any existing item first (Add fails on duplicates).
_ = keychain.DeleteItem(item)
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("keychain add %s: %w", account, err)
@@ -37,7 +85,8 @@ func (DarwinStore) setItem(account, secret string) error {
return nil
}
func (DarwinStore) getItem(account string) (string, error) {
// getItemPlain retrieves a secret from the file-based keychain.
func getItemPlain(account string) (string, error) {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
@@ -54,7 +103,8 @@ func (DarwinStore) getItem(account string) (string, error) {
return string(results[0].Data), nil
}
func (DarwinStore) deleteItem(account string) error {
// deleteItemPlain deletes a secret from the file-based keychain.
func deleteItemPlain(account string) error {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
@@ -62,6 +112,57 @@ func (DarwinStore) deleteItem(account string) error {
return keychain.DeleteItem(item)
}
func (DarwinStore) setItem(account, secret string) error {
if shouldUseBiometric() {
err := storeBiometricItemGo(ServiceName, account, secret)
if err == nil {
return nil
}
if errors.Is(err, ErrSecMissingEntitlement) {
disableBiometric()
// Fall through to non-biometric path.
} else {
return err
}
}
return setItemPlain(account, secret)
}
func (DarwinStore) getItem(account string) (string, error) {
if shouldUseBiometric() {
secret, err := getBiometricItemGo(ServiceName, account, touchIDPrompt)
if err == nil {
return secret, nil
}
if errors.Is(err, ErrSecMissingEntitlement) {
disableBiometric()
// Fall through to non-biometric path.
} else if errors.Is(err, ErrNotFound) {
// Item not in the Data Protection Keychain; it may have
// been stored via the non-biometric path. Fall through.
} else {
return "", err
}
}
return getItemPlain(account)
}
func (DarwinStore) deleteItem(account string) error {
if shouldUseBiometric() {
err := deleteBiometricItemGo(ServiceName, account)
if err == nil {
return nil
}
if errors.Is(err, ErrSecMissingEntitlement) {
disableBiometric()
// Fall through to non-biometric path.
} else {
return err
}
}
return deleteItemPlain(account)
}
func (s DarwinStore) SetPassword(profileName, password string) error {
return s.setItem(passwordAccountPrefix+profileName, password)
}
@@ -0,0 +1,374 @@
//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
// errSecMissingEntitlementCode is errSecMissingEntitlement (-34018),
// returned by the Data Protection Keychain when the app is not properly
// code-signed with keychain-access-groups entitlement.
const errSecMissingEntitlementCode = -34018
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 {
if status == errSecMissingEntitlementCode {
return fmt.Errorf("keychain biometric store %s: %w", account, ErrSecMissingEntitlement)
}
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 {
if status == errSecMissingEntitlementCode {
return "", fmt.Errorf("keychain biometric get %s: %w", account, ErrSecMissingEntitlement)
}
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 {
if status == errSecMissingEntitlementCode {
return fmt.Errorf("keychain biometric delete %s: %w", account, ErrSecMissingEntitlement)
}
return fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
}
return nil
}
+15 -4
View File
@@ -19,6 +19,16 @@ const (
AuthModePassword AuthMode = "password" // {type:auth} first message
)
// IPPreference controls which IP address family is used when connecting
// to a server by hostname (ServerIPs empty).
type IPPreference string
const (
IPPrefAuto IPPreference = "auto" // race all resolved addresses (v4 + v6)
IPPrefV4 IPPreference = "v4" // only IPv4 addresses
IPPrefV6 IPPreference = "v6" // only IPv6 addresses
)
// RoutingMode selects which traffic goes through the VPN tunnel.
type RoutingMode string
@@ -61,10 +71,11 @@ type ServerProfile struct {
CIDRV6URLs string `json:"cidr_v6_urls"` // JSON array of CIDRURLSource for IPv6
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
AutoConnect bool `json:"auto_connect"`
TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (wss only)
TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only)
TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (wss only)
TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only)
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
CreatedAt time.Time `json:"created_at"`
LastConnectedAt *time.Time `json:"last_connected_at"`
}
-2
View File
@@ -56,5 +56,3 @@ func LogFile() string { return Paths.Log + "/lmvpn.log" }
// DaemonLogFile returns the path to the daemon log file.
func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" }
+33 -13
View File
@@ -26,14 +26,16 @@ const (
// counters in Snapshot, so callers that only need the total still
// work.
type Stats struct {
RxBytesV4 atomic.Int64
RxBytesV6 atomic.Int64
TxBytesV4 atomic.Int64
TxBytesV6 atomic.Int64
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
state atomic.Value // State
assignedIP atomic.Value // string (IPv4)
assignedIP6 atomic.Value // string (IPv6, may be empty)
RxBytesV4 atomic.Int64
RxBytesV6 atomic.Int64
TxBytesV4 atomic.Int64
TxBytesV6 atomic.Int64
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
state atomic.Value // State
assignedIP atomic.Value // string (IPv4)
assignedIP6 atomic.Value // string (IPv6, may be empty)
serverHost atomic.Value // string (server hostname or IP from URL)
connectedIP atomic.Value // string (actual remote IP of the connection)
routeLoading atomic.Bool // true while deferred routes are being applied
connectStep atomic.Value // string (human-readable connection step)
cidrError atomic.Value // string (CIDR fetch error message, empty = no error)
@@ -79,6 +81,8 @@ func New() *Stats {
s.state.Store(StateDisconnected)
s.assignedIP.Store("")
s.assignedIP6.Store("")
s.serverHost.Store("")
s.connectedIP.Store("")
s.connectStep.Store("")
s.cidrError.Store("")
return s
@@ -90,12 +94,16 @@ func (s *Stats) SetState(st State) { s.state.Store(st) }
// State returns the current state.
func (s *Stats) State() State { return s.state.Load().(State) }
// SetConnected marks the session as connected, recording the time and
// assigned IP addresses. ip6 may be empty for an IPv4-only server.
func (s *Stats) SetConnected(ip, ip6 string) {
// SetConnected marks the session as connected, recording the time,
// assigned IP addresses, and server connection info. ip6 may be empty
// for an IPv4-only server. serverHost is the hostname/IP from the
// server URL; connectedIP is the actual remote IP of the connection.
func (s *Stats) SetConnected(ip, ip6, serverHost, connectedIP string) {
s.ConnectedAt.Store(time.Now().Unix())
s.assignedIP.Store(ip)
s.assignedIP6.Store(ip6)
s.serverHost.Store(serverHost)
s.connectedIP.Store(connectedIP)
s.state.Store(StateConnected)
}
@@ -104,6 +112,8 @@ func (s *Stats) SetDisconnected() {
s.ConnectedAt.Store(0)
s.assignedIP.Store("")
s.assignedIP6.Store("")
s.serverHost.Store("")
s.connectedIP.Store("")
s.state.Store(StateDisconnected)
s.routeLoading.Store(false)
s.connectStep.Store("")
@@ -149,6 +159,12 @@ func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
// AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty).
func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) }
// ServerHost returns the server hostname/IP from the connection URL.
func (s *Stats) ServerHost() string { return s.serverHost.Load().(string) }
// ConnectedIP returns the actual remote IP of the connection.
func (s *Stats) ConnectedIP() string { return s.connectedIP.Load().(string) }
// Snapshot returns a point-in-time copy of all counters.
//
// Per-family byte counters are read directly. The combined RxBytes/
@@ -174,6 +190,8 @@ type Snapshot struct {
ConnectedAt time.Time
AssignedIP string
AssignedIP6 string
ServerHost string `json:"server_host,omitempty"` // server hostname/IP from URL
ConnectedIP string `json:"connected_ip,omitempty"` // actual remote IP of the connection
State State
Uptime time.Duration
@@ -184,8 +202,8 @@ type Snapshot struct {
CIDRV6Total int `json:"cidr_v6_total,omitempty"`
CIDRV6Hits int `json:"cidr_v6_hits,omitempty"`
RouteLoading bool `json:"route_loading,omitempty"` // deferred routes being applied
ConnectStep string `json:"connect_step,omitempty"` // current connection step
CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message
ConnectStep string `json:"connect_step,omitempty"` // current connection step
CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message
}
// Snapshot returns a point-in-time copy of the statistics.
@@ -203,6 +221,8 @@ func (s *Stats) Snapshot() Snapshot {
TxBytes: txv4 + txv6,
AssignedIP: s.AssignedIP(),
AssignedIP6: s.AssignedIP6(),
ServerHost: s.ServerHost(),
ConnectedIP: s.ConnectedIP(),
State: s.State(),
}
ts := s.ConnectedAt.Load()
+136
View File
@@ -0,0 +1,136 @@
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))
// Always send the result: resultCh is buffered (len(ips)),
// so this never blocks. Do NOT use a select with
// <-raceCtx.Done() here - when the context is cancelled
// both cases would be ready and Go's random select pick
// could skip the send, causing the drain loop below to
// block forever.
resultCh <- result{conn: c, err: err}
}(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++ {
late := <-resultCh
if 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
}
+133
View File
@@ -0,0 +1,133 @@
package transport
import (
"context"
"net"
"sync"
"testing"
"time"
)
// TestRaceDialNoDeadlock is a regression test for a select race
// condition that caused raceDial to deadlock ~50% of the time when
// multiple IPs were raced and the first succeeded. The bug was a
// select with two simultaneously-ready cases (send result vs
// <-raceCtx.Done()); Go's random selection could skip the send,
// leaving the drain loop blocked forever.
//
// We run many iterations because the bug was probabilistic.
func TestRaceDialNoDeadlock(t *testing.T) {
const iterations = 200
for n := 0; n < iterations; n++ {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
// Accept both connections in background (both IPs dial the
// same listener since raceDial uses a single port).
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Close()
}
}()
port := portOf(ln.Addr())
ips := []string{"127.0.0.1", "127.0.0.1"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
conn, err := raceDial(ctx, "tcp", ips, port)
cancel()
if err == nil && conn != nil {
conn.Close()
}
ln.Close()
// If we get here without the 5s timeout, the iteration passed.
// A deadlock would trigger the test-wide 60s timeout.
}
// If we reach here, no iteration deadlocked.
}
// TestRaceDialAllFail verifies that when all dials fail, raceDial
// returns an error instead of blocking.
func TestRaceDialAllFail(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Port 1: connection refused on most systems.
ips := []string{"127.0.0.1", "127.0.0.1"}
_, err := raceDial(ctx, "tcp", ips, "1")
if err == nil {
t.Fatal("expected error when all dials fail")
}
}
// TestRaceDialSingleIP verifies the single-IP path still works.
func TestRaceDialSingleIP(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
c, _ := ln.Accept()
if c != nil {
c.Close()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := raceDial(ctx, "tcp", []string{"127.0.0.1"}, portOf(ln.Addr()))
if err != nil {
t.Fatalf("raceDial single IP: %v", err)
}
conn.Close()
wg.Wait()
}
// TestRaceDialContextCancelled ensures raceDial returns promptly when
// the parent context is cancelled while dials are in flight.
func TestRaceDialContextCancelled(t *testing.T) {
// Dial a non-routable address so the dial hangs until context
// cancellation.
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(200 * time.Millisecond)
cancel()
}()
done := make(chan error, 1)
go func() {
_, err := raceDial(ctx, "tcp", []string{"10.255.255.1", "10.255.255.2"}, "80")
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("expected error on cancelled context")
}
case <-time.After(5 * time.Second):
t.Fatal("raceDial did not return after context cancellation")
}
}
// portOf extracts the port from a net.Addr.
func portOf(addr net.Addr) string {
_, port, _ := net.SplitHostPort(addr.String())
return port
}
+24 -7
View File
@@ -18,6 +18,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"sync"
@@ -30,13 +31,14 @@ import (
// HandshakeConfig configures a single connection attempt.
type HandshakeConfig struct {
ServerURL string // e.g. wss://vpn.example.com/ws
SNIHost string // TLS SNI hostname for CDN edge connections
Token string // JWT; if non-empty, used via ?token= (method A)
Username string // for password auth (method B), or fallback
Password string // for password auth (method B), or fallback
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
ServerURL string // e.g. wss://vpn.example.com/ws
SNIHost string // TLS SNI hostname for CDN edge connections
Token string // JWT; if non-empty, used via ?token= (method A)
Username string // for password auth (method B), or fallback
Password string // for password auth (method B), or fallback
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
}
// Conn is an established VPN tunnel connection.
@@ -53,6 +55,7 @@ type Conn struct {
// error occurs.
func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
dialer := websocket.Dialer{
NetDialContext: NewRaceDialer(cfg.IPPreference),
HandshakeTimeout: 15 * time.Second,
ReadBufferSize: 4096, // match server (handler.go:17)
WriteBufferSize: 4096, // match server (handler.go:18)
@@ -269,6 +272,20 @@ func (c *Conn) AssignedIP() string { return c.init.IP }
// AssignedIP6 returns the IPv6 assigned by the server (empty if none).
func (c *Conn) AssignedIP6() string { return c.init.IP6 }
// RemoteIP returns the remote IP address of the underlying TCP
// connection (the actual server/CDN IP the WebSocket is connected to).
// Returns an empty string if the connection is not established.
func (c *Conn) RemoteIP() string {
if c.ws == nil {
return ""
}
addr := c.ws.RemoteAddr()
if tcpAddr, ok := addr.(*net.TCPAddr); ok {
return tcpAddr.IP.String()
}
return addr.String()
}
// Close terminates the connection.
func (c *Conn) Close() error {
c.mu.Lock()
+2 -4
View File
@@ -58,8 +58,8 @@ func createTUN(name string) (Device, error) {
}, nil
}
func (d *wintunDevice) Name() string { return d.name }
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
func (d *wintunDevice) Name() string { return d.name }
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
func (d *wintunDevice) Read(p []byte) (int, error) {
for {
@@ -153,5 +153,3 @@ func execCmd(name string, arg ...string) error {
}
return nil
}
+36 -33
View File
@@ -35,32 +35,32 @@ type App struct {
listSelectedIndex int
// UI widgets
profileSelect *widget.Select
stateLabel *widget.Label
ipLabel *widget.Label
ip6Label *widget.Label
uptimeLabel *widget.Label
rxV4Label *widget.Label
txV4Label *widget.Label
rxV6Label *widget.Label
txV6Label *widget.Label
rxTotalLabel *widget.Label
txTotalLabel *widget.Label
profileSelect *widget.Select
stateLabel *widget.Label
ipLabel *widget.Label
ip6Label *widget.Label
uptimeLabel *widget.Label
rxV4Label *widget.Label
txV4Label *widget.Label
rxV6Label *widget.Label
txV6Label *widget.Label
rxTotalLabel *widget.Label
txTotalLabel *widget.Label
routingModeLabel *widget.Label
cidrV4Label *widget.Label
cidrV6Label *widget.Label
refreshCIDRBtn *widget.Button
connectBtn *widget.Button
disconnectBtn *widget.Button
connectBtn *widget.Button
disconnectBtn *widget.Button
// State
mu sync.Mutex
ipcClient *ipc.Client
profiles []model.ServerProfile
currentProfile *model.ServerProfile
mu sync.Mutex
ipcClient *ipc.Client
profiles []model.ServerProfile
currentProfile *model.ServerProfile
defaultProfileID int64
langSetting string
windowHidden bool
langSetting string
windowHidden bool
}
// Run initialises and starts the GUI application.
@@ -98,6 +98,9 @@ func Run() {
log.L().Error("init i18n", "error", err)
}
// Set the localized Touch ID prompt for keychain access (macOS).
setTouchIDPromptFromI18n()
a := &App{
fyneApp: app.NewWithID(paths.BundleID),
db: store,
@@ -127,7 +130,6 @@ func Run() {
fyne.Do(func() {
a.windowHidden = false
activateApp()
showDockIcon()
a.window.Show()
a.window.RequestFocus()
})
@@ -146,7 +148,6 @@ func Run() {
if a.windowHidden {
a.windowHidden = false
activateApp()
showDockIcon()
fyne.Do(func() {
if a.windowHidden {
return
@@ -170,7 +171,6 @@ func Run() {
a.window.SetCloseIntercept(func() {
if cfg.CloseToTray {
a.windowHidden = true
hideDockIcon()
a.window.Hide()
} else {
a.quit()
@@ -325,17 +325,17 @@ func (a *App) onResetDB() {
// Reset UI state.
a.currentProfile = nil
a.loadProfiles()
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.ipLabel.SetText(i18n.T("IpNone"))
a.ip6Label.SetText(i18n.T("Ip6None"))
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
a.txV4Label.SetText(i18n.T("TxV4Zero"))
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
a.txV6Label.SetText(i18n.T("TxV6Zero"))
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
a.setConnButtons(true, false)
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.ipLabel.SetText(i18n.T("IpNone"))
a.ip6Label.SetText(i18n.T("Ip6None"))
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
a.txV4Label.SetText(i18n.T("TxV4Zero"))
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
a.txV6Label.SetText(i18n.T("TxV6Zero"))
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
a.setConnButtons(true, false)
}, a.window).Show()
}
@@ -358,6 +358,9 @@ func (a *App) changeLanguage(lang string) {
// Switch the active localizer.
i18n.SetLanguage(lang)
// Update the Touch ID prompt text for the new language.
setTouchIDPromptFromI18n()
// Rebuild everything that holds cached strings.
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)
}
-9
View File
@@ -25,12 +25,6 @@ static BOOL appShouldHandleReopen(id self, SEL cmd, id sender, BOOL flag) {
return YES;
}
static void setDockIconVisible(int visible) {
Class cls = objc_getClass("NSApplication");
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
((void (*)(id, SEL, long))objc_msgSend)(app, sel_getUid("setActivationPolicy:"), visible ? 0 : 1);
}
static void cmActivateApp(void) {
Class cls = objc_getClass("NSApplication");
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
@@ -53,9 +47,6 @@ static void cmRegisterReopenHandler(void) {
*/
import "C"
func showDockIcon() { C.setDockIconVisible(1) }
func hideDockIcon() { C.setDockIconVisible(0) }
func activateApp() { C.cmActivateApp() }
func registerReopenHandler() { C.cmRegisterReopenHandler() }
+1 -2
View File
@@ -2,8 +2,7 @@
package ui
func showDockIcon() {}
func hideDockIcon() {}
var onAppActive func()
func activateApp() {}
+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.
// Embedded single quotes are escaped using the '\'' pattern.
// Embedded single quotes are escaped using the '\ pattern.
func shellQuote(s string) string {
result := "'"
for _, r := range s {
+20 -6
View File
@@ -25,6 +25,8 @@ var (
protoCodes = []string{"wss", "ws"}
fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)}
ipPrefCodes = []string{string(model.IPPrefAuto), string(model.IPPrefV4), string(model.IPPrefV6)}
)
func authModeLabels() []string {
@@ -43,6 +45,10 @@ func fetchTimingLabels() []string {
return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")}
}
func ipPrefLabels() []string {
return []string{i18n.T("IPPrefAuto"), i18n.T("IPPrefV4"), i18n.T("IPPrefV6")}
}
// codeIndex returns the position of code in codes, or 0 if not found.
func codeIndex(codes []string, code string) int {
for i, c := range codes {
@@ -63,14 +69,14 @@ func selectedCode(codes []string, idx int) string {
// urlEntryRow holds the widgets for a single CIDR URL source row.
type urlEntryRow struct {
urlEntry *widget.Entry
urlEntry *widget.Entry
timingSelect *widget.Select
container *fyne.Container
container *fyne.Container
}
// cidrURLList manages a dynamic list of CIDR URL source rows.
type cidrURLList struct {
rows []*urlEntryRow
rows []*urlEntryRow
container *fyne.Container
parent fyne.Window
}
@@ -198,6 +204,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
authSelect := widget.NewSelect(authModeLabels(), nil)
routeSelect := widget.NewSelect(routeModeLabels(), nil)
ipPrefSelect := widget.NewSelect(ipPrefLabels(), nil)
cidrV4Entry := widget.NewMultiLineEntry()
cidrV4Entry.Wrapping = fyne.TextWrapOff
@@ -300,6 +307,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
userEntry.SetText(editing.Username)
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
ipPrefSelect.SetSelectedIndex(codeIndex(ipPrefCodes, editing.IPPreference))
cidrV4Entry.SetText(editing.CIDRV4)
cidrV6Entry.SetText(editing.CIDRV6)
v4URLList.loadFromJSON(editing.CIDRV4URLs)
@@ -316,6 +324,7 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
pathEntry.SetText("/ws")
authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull)))
ipPrefSelect.SetSelectedIndex(0) // auto
mtuEntry.SetText("0")
}
@@ -349,9 +358,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry),
),
container.NewGridWithColumns(2,
container.NewGridWithColumns(3,
container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect),
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
container.NewVBox(widget.NewLabel(i18n.T("FieldIPPreference")), ipPrefSelect),
),
cidrSection,
@@ -394,7 +404,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
mtuEntry.Text,
tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
isNew) {
isNew,
selectedCode(ipPrefCodes, ipPrefSelect.SelectedIndex())) {
profileWin.Close()
}
})
@@ -414,7 +425,8 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
func (a *App) saveProfile(editing *model.ServerProfile,
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode,
cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr,
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool {
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool,
ipPreference string) bool {
if name == "" || host == "" || user == "" {
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
return false
@@ -454,6 +466,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
TLSCAPath: tlsCaPath,
TLSInsecure: tlsInsecure,
TLSPinnedHash: tlsPinnedHash,
IPPreference: ipPreference,
}
id, err := a.db.CreateProfile(p)
if err != nil {
@@ -486,6 +499,7 @@ func (a *App) saveProfile(editing *model.ServerProfile,
editing.TLSCAPath = tlsCaPath
editing.TLSInsecure = tlsInsecure
editing.TLSPinnedHash = tlsPinnedHash
editing.IPPreference = ipPreference
if err := a.db.UpdateProfile(editing); err != nil {
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
return false
+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() {}
+16 -17
View File
@@ -46,30 +46,29 @@ func (a *App) setupTray() {
autoItem, enItem, zhItem,
)
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
a.onConnect()
})
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
a.onDisconnect()
})
if a.connectBtn != nil {
connectItem.Disabled = a.connectBtn.Disabled()
}
if a.disconnectBtn != nil {
disconnectItem.Disabled = a.disconnectBtn.Disabled()
}
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
a.onConnect()
})
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
a.onDisconnect()
})
if a.connectBtn != nil {
connectItem.Disabled = a.connectBtn.Disabled()
}
if a.disconnectBtn != nil {
disconnectItem.Disabled = a.disconnectBtn.Disabled()
}
menu := fyne.NewMenu(i18n.T("WindowTitle"),
menu := fyne.NewMenu(i18n.T("WindowTitle"),
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
a.windowHidden = false
activateApp()
showDockIcon()
a.window.Show()
a.window.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
connectItem,
disconnectItem,
fyne.NewMenuItemSeparator(),
connectItem,
disconnectItem,
fyne.NewMenuItemSeparator(),
langItem,
fyne.NewMenuItemSeparator(),
+17 -5
View File
@@ -3,12 +3,14 @@ package ui
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/url"
"time"
"lmvpn/internal/i18n"
"lmvpn/internal/ipc"
"lmvpn/internal/keychain"
"lmvpn/internal/model"
"lmvpn/internal/stats"
@@ -172,11 +174,17 @@ func (a *App) onConnect() {
password, err := a.kc.GetPassword(a.currentProfile.Name)
if err != nil {
fyne.Do(func() {
showError(i18n.T("DlgCredentialError"),
i18n.T("DlgCredentialErrorMsg"),
a.window)
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.setConnButtons(true, false)
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"),
i18n.T("DlgCredentialErrorMsg"),
a.window)
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.setConnButtons(true, false)
}
})
return
}
@@ -207,6 +215,7 @@ func (a *App) onConnect() {
TLSCAPath: p.TLSCAPath,
TLSInsecure: p.TLSInsecure,
TLSPinnedHash: p.TLSPinnedHash,
IPPreference: p.IPPreference,
}
if err := ipc.SendStart(client, cfg); err != nil {
fyne.Do(func() {
@@ -420,6 +429,9 @@ func (a *App) applyStats(s stats.Snapshot) {
stepLabel := connectStepLabel(s.ConnectStep)
if stepLabel != "" {
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
} else if s.ServerHost != "" && s.ConnectedIP != "" {
a.stateLabel.SetText(fmt.Sprintf("%s (%s -> %s)",
i18n.T("StateConnected"), s.ServerHost, s.ConnectedIP))
} else {
a.stateLabel.SetText(i18n.T("StateConnected"))
}
+58 -38
View File
@@ -33,23 +33,24 @@ import (
// SessionConfig describes how to connect to a VPN server.
type SessionConfig struct {
ServerURL string
SNIHost string // TLS SNI hostname for CDN
ServerIPs []string // CDN edge IPs for failover
Username string
Password string
AuthMode model.AuthMode
Token string // pre-obtained JWT (empty = fetch via HTTP login)
RoutingMode route.Mode
CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode)
CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode)
CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources
CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources
MTUOverride int // 0 = use server MTU
TLSCACert string // inline CA cert PEM (wss only)
TLSCAPath string // CA cert file path (wss only)
TLSInsecure bool // skip cert verification (wss only)
TLSPinnedHash string // SHA-256 cert pin (wss only)
ServerURL string
SNIHost string // TLS SNI hostname for CDN
ServerIPs []string // CDN edge IPs for failover
Username string
Password string
AuthMode model.AuthMode
Token string // pre-obtained JWT (empty = fetch via HTTP login)
RoutingMode route.Mode
CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode)
CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode)
CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources
CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources
MTUOverride int // 0 = use server MTU
TLSCACert string // inline CA cert PEM (wss only)
TLSCAPath string // CA cert file path (wss only)
TLSInsecure bool // skip cert verification (wss only)
TLSPinnedHash string // SHA-256 cert pin (wss only)
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
}
// SessionManager manages a single VPN session with auto-reconnect.
@@ -196,7 +197,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
// to avoid consuming the server's 30s ReadyTimeout budget.
var beforeCIDRs []string
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
if len(allURLSources) > 0 {
if len(allURLSources) > 0 && cfg.RoutingMode != route.ModeFull {
sm.stats.SetConnectStep("fetch_cidrs")
sm.setState(stats.StateConnecting)
log.L().Info("fetching before-proxy CIDR lists", "url_count", len(allURLSources))
@@ -212,8 +213,16 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
backoff := time.Second
maxBackoff := 60 * time.Second
// Build the full target list: original host first, then CDN IPs.
targets := append([]string{""}, cfg.ServerIPs...) // "" = use base URL
// Build the full target list. When ServerIPs is empty, connect via
// hostname (IPPreference + race dialer apply). When ServerIPs is
// non-empty, skip hostname and use IPs directly with failover.
usingHostname := len(cfg.ServerIPs) == 0
var targets []string
if usingHostname {
targets = []string{""} // "" = use base URL (hostname)
} else {
targets = cfg.ServerIPs // direct IP mode: sequential failover
}
ipIndex := 0
for {
@@ -223,7 +232,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
}
targetIP := ""
if ipIndex > 0 && ipIndex < len(targets) {
if ipIndex < len(targets) {
targetIP = targets[ipIndex]
}
@@ -242,13 +251,13 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanupResources()
// A TLS certificate verification failure on the original
// hostname (ipIndex == 0) is not retryable: the cert won't
// change between attempts, so stop the loop and surface the
// reason to the user. On a CDN edge IP (ipIndex > 0) the
// hostname (ipIndex == 0, hostname mode) is not retryable:
// the cert won't change between attempts, so stop the loop
// and surface the reason to the user. On a CDN edge IP the
// TLS error likely means that IP points to a different
// server; skip it and try the next target.
if tlsconfig.IsTLSError(err) {
if ipIndex == 0 {
if usingHostname && ipIndex == 0 {
log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
sm.setState(stats.StateError)
if sm.onError != nil {
@@ -258,7 +267,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanup()
return
}
log.L().Warn("TLS error on CDN IP, skipping",
log.L().Warn("TLS error on IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "error", err)
}
@@ -268,7 +277,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
// means the IP points to a different server that returned
// 401/403, so skip it instead of stopping the loop.
if code, msg, isFatal := fatalAuthError(err); isFatal {
if ipIndex == 0 {
if usingHostname && ipIndex == 0 {
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
sm.setState(stats.StateError)
if sm.onError != nil {
@@ -278,16 +287,16 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
sm.cleanup()
return
}
log.L().Warn("auth error on CDN IP, skipping",
log.L().Warn("auth error on IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "code", code)
}
sm.setState(stats.StateReconnecting)
// Try next CDN IP immediately.
// Try next target IP immediately.
ipIndex++
if ipIndex < len(targets) {
log.L().Info("trying next CDN IP", "index", ipIndex, "ip", targets[ipIndex])
log.L().Info("trying next server IP", "index", ipIndex, "ip", targets[ipIndex])
continue
}
// All targets exhausted; reset and wait with backoff.
@@ -389,7 +398,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
if err != nil {
return fmt.Errorf("parse server URL: %w", err)
}
result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg)
result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg, cfg.IPPreference)
if err != nil {
if cfg.AuthMode == model.AuthModeBoth {
// Fall back to password auth.
@@ -405,11 +414,12 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
// Prepare the TUN + route setup callback (called during handshake,
// between receiving init and sending ready).
handshake := transport.HandshakeConfig{
ServerURL: serverURL,
SNIHost: cfg.SNIHost,
Token: token,
Username: cfg.Username,
Password: cfg.Password,
ServerURL: serverURL,
SNIHost: cfg.SNIHost,
Token: token,
Username: cfg.Username,
Password: cfg.Password,
IPPreference: cfg.IPPreference,
OnInit: func(init protocol.InitMessage) error {
return sm.setupTUN(init, cfg, beforeCIDRs)
},
@@ -448,13 +458,16 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
sm.conn = conn
sm.mu.Unlock()
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6)
serverHost := serverHostFromURL(cfg.ServerURL)
connectedIP := conn.RemoteIP()
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6, serverHost, connectedIP)
sm.stats.SetConnectStep("load_routes")
sm.setState(stats.StateConnected)
log.L().Info("VPN connected",
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
"ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6,
"mtu", conn.Init().MTU)
"mtu", conn.Init().MTU,
"server_host", serverHost, "connected_ip", connectedIP)
// Start stats reporter.
statsDone := make(chan struct{})
@@ -575,6 +588,9 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig,
// (via the tunnel) and dynamically adds their routes to the route
// manager. This is called in a goroutine after the data plane is up.
func (sm *SessionManager) fetchAfterProxyCIDRs(ctx context.Context, cfg SessionConfig) {
if cfg.RoutingMode == route.ModeFull {
return
}
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
// Count only "after" sources for logging.
afterCount := 0
@@ -646,6 +662,10 @@ func (sm *SessionManager) RefreshCIDRs() {
return
}
if cfg.RoutingMode == route.ModeFull {
return
}
// Use a background context with 30s timeout so the refresh works
// even if the session context is in a weird state.
refreshCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+1 -1
View File
@@ -32,7 +32,7 @@
<key>NSHighResolutionCapable</key>
<true/>
<key>LSUIElement</key>
<false/>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.lmvpn.client</string>
</array>
</dict>
</plist>
+2 -2
View File
@@ -46,12 +46,12 @@ func main() {
for x := sx - sw/2; x < sx+sw/2; x++ {
// Shield shape: rounded top, pointed bottom.
progress := float64(y-sy) / float64(sh)
halfW := float64(sw)/2 * (1.0 - 0.3*progress*progress)
halfW := float64(sw) / 2 * (1.0 - 0.3*progress*progress)
if math.Abs(float64(x-sx)) < halfW && progress < 0.7 {
img.SetRGBA(x, y, shieldColor)
} else if progress >= 0.7 {
tp := (progress - 0.7) / 0.3
halfW2 := float64(sw)/2 * (1.0 - 0.3*0.49) * (1.0 - tp)
halfW2 := float64(sw) / 2 * (1.0 - 0.3*0.49) * (1.0 - tp)
if math.Abs(float64(x-sx)) < halfW2 {
img.SetRGBA(x, y, shieldColor)
}