Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd29fa5b2 | ||
|
|
dc3e9beb14 | ||
|
|
9d7d4c8287 | ||
|
|
469106d502 | ||
|
|
e61d4fedd8 | ||
|
|
1886d9b07b | ||
|
|
b81b702433 | ||
|
|
7febed50ac | ||
|
|
4caaacb68f | ||
|
|
9b1cb668b1 | ||
|
|
b52db70015 |
@@ -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.
|
||||
@@ -11,12 +11,18 @@ GO = go
|
||||
CGO_ENABLED = 1
|
||||
WINDRES ?= x86_64-w64-mingw32-windres
|
||||
MINGW_CC ?= x86_64-w64-mingw32-gcc
|
||||
SEMVER ?= 0.6.1
|
||||
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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -86,5 +86,3 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
package ui
|
||||
|
||||
func showDockIcon() {}
|
||||
func hideDockIcon() {}
|
||||
var onAppActive func()
|
||||
|
||||
func activateApp() {}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>LSUIElement</key>
|
||||
<false/>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user