5 Commits
Author SHA1 Message Date
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
12 changed files with 302 additions and 45 deletions
+39 -2
View File
@@ -11,12 +11,18 @@ GO = go
CGO_ENABLED = 1 CGO_ENABLED = 1
WINDRES ?= x86_64-w64-mingw32-windres WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc MINGW_CC ?= x86_64-w64-mingw32-gcc
SEMVER ?= 0.6.5 SEMVER ?= 0.6.8
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
VERSION = $(SEMVER)-$(GIT_HASH) VERSION = $(SEMVER)-$(GIT_HASH)
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
.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: build the .app bundle (default)
all: app all: app
@@ -41,8 +47,39 @@ app: build
@if [ -f resources/icon.icns ]; then \ @if [ -f resources/icon.icns ]; then \
cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \ cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \
else echo " (no icon.icns found, skipping icon)"; fi 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)" @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: generate icon.icns from resources/icon.png (or resources/logo.svg)
icon: icon:
@if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \ @if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \
+6
View File
@@ -19,6 +19,12 @@ const ServiceName = paths.BundleID
// ErrNotFound is returned when a secret is not present in the store. // ErrNotFound is returned when a secret is not present in the store.
var ErrNotFound = fmt.Errorf("secret not found") var ErrNotFound = fmt.Errorf("secret not found")
// 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 // ErrUserCanceled is returned when the user cancels a biometric
// authentication prompt (e.g. Touch ID) or the system cannot complete // authentication prompt (e.g. Touch ID) or the system cannot complete
// authentication. // authentication.
+90 -18
View File
@@ -3,7 +3,9 @@
package keychain package keychain
import ( import (
"errors"
"fmt" "fmt"
"sync"
"github.com/keybase/go-keychain" "github.com/keybase/go-keychain"
) )
@@ -26,6 +28,36 @@ func SetTouchIDPrompt(prompt string) {
} }
} }
// 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. // DarwinStore implements Store using the macOS Keychain.
type DarwinStore struct{} type DarwinStore struct{}
@@ -37,18 +69,15 @@ func New() Store {
return DarwinStore{} return DarwinStore{}
} }
func (DarwinStore) setItem(account, secret string) error { // setItemPlain stores a secret in the file-based keychain (no biometric
// Use biometric-protected storage when Touch ID is available. // protection) using the keybase/go-keychain library.
if biometricAvailable() { func setItemPlain(account, secret string) error {
return storeBiometricItemGo(ServiceName, account, secret)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
item.SetAccount(account) item.SetAccount(account)
item.SetData([]byte(secret)) item.SetData([]byte(secret))
item.SetAccessible(keychain.AccessibleAfterFirstUnlock) item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
// Delete any existing item first (Add fails on duplicates).
_ = keychain.DeleteItem(item) _ = keychain.DeleteItem(item)
if err := keychain.AddItem(item); err != nil { if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("keychain add %s: %w", account, err) return fmt.Errorf("keychain add %s: %w", account, err)
@@ -56,12 +85,8 @@ func (DarwinStore) setItem(account, secret string) error {
return nil return nil
} }
func (DarwinStore) getItem(account string) (string, error) { // getItemPlain retrieves a secret from the file-based keychain.
// When Touch ID is available, use the direct CGo path which can func getItemPlain(account string) (string, error) {
// show a biometric prompt for protected items.
if biometricAvailable() {
return getBiometricItemGo(ServiceName, account, touchIDPrompt)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
@@ -78,12 +103,8 @@ func (DarwinStore) getItem(account string) (string, error) {
return string(results[0].Data), nil return string(results[0].Data), nil
} }
func (DarwinStore) deleteItem(account string) error { // deleteItemPlain deletes a secret from the file-based keychain.
// Use the direct CGo delete which works for both biometric and func deleteItemPlain(account string) error {
// non-biometric items (and doesn't trigger a Touch ID prompt).
if biometricAvailable() {
return deleteBiometricItemGo(ServiceName, account)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
@@ -91,6 +112,57 @@ func (DarwinStore) deleteItem(account string) error {
return keychain.DeleteItem(item) 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 { func (s DarwinStore) SetPassword(profileName, password string) error {
return s.setItem(passwordAccountPrefix+profileName, password) return s.setItem(passwordAccountPrefix+profileName, password)
} }
@@ -215,6 +215,11 @@ import (
// when the user cancels a Touch ID / password prompt (-128). // when the user cancels a Touch ID / password prompt (-128).
const errSecUserCanceled = -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 ( var (
biometricCacheOnce sync.Once biometricCacheOnce sync.Once
biometricCacheVal bool biometricCacheVal bool
@@ -293,6 +298,9 @@ func storeBiometricItemGo(service, account, secret string) error {
status := C.storeBiometricItem(svcRef, accRef, dataRef) status := C.storeBiometricItem(svcRef, accRef, dataRef)
if status != 0 { 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 fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status)
} }
return nil return nil
@@ -329,6 +337,9 @@ func getBiometricItemGo(service, account, prompt string) (string, error) {
return "", ErrUserCanceled return "", ErrUserCanceled
} }
if status != 0 { 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) return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status)
} }
defer cfRelease(C.CFTypeRef(dataOut)) defer cfRelease(C.CFTypeRef(dataOut))
@@ -354,6 +365,9 @@ func deleteBiometricItemGo(service, account string) error {
status := C.deleteBiometricItem(svcRef, accRef) status := C.deleteBiometricItem(svcRef, accRef)
if status != 0 { 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 fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
} }
return nil return nil
+9 -8
View File
@@ -99,13 +99,13 @@ func raceDial(ctx context.Context, network string, ips []string, port string) (n
go func(target string) { go func(target string) {
d := &net.Dialer{} d := &net.Dialer{}
c, err := d.DialContext(raceCtx, network, net.JoinHostPort(target, port)) c, err := d.DialContext(raceCtx, network, net.JoinHostPort(target, port))
select { // Always send the result: resultCh is buffered (len(ips)),
case resultCh <- result{conn: c, err: err}: // so this never blocks. Do NOT use a select with
case <-raceCtx.Done(): // <-raceCtx.Done() here - when the context is cancelled
if c != nil { // both cases would be ready and Go's random select pick
c.Close() // could skip the send, causing the drain loop below to
} // block forever.
} resultCh <- result{conn: c, err: err}
}(ip) }(ip)
} }
@@ -117,7 +117,8 @@ func raceDial(ctx context.Context, network string, ips []string, port string) (n
// any late successful connections. // any late successful connections.
cancel() cancel()
for j := i + 1; j < len(ips); j++ { for j := i + 1; j < len(ips); j++ {
if late := <-resultCh; late.conn != nil { late := <-resultCh
if late.conn != nil {
late.conn.Close() late.conn.Close()
} }
} }
+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
}
-3
View File
@@ -130,7 +130,6 @@ func Run() {
fyne.Do(func() { fyne.Do(func() {
a.windowHidden = false a.windowHidden = false
activateApp() activateApp()
showDockIcon()
a.window.Show() a.window.Show()
a.window.RequestFocus() a.window.RequestFocus()
}) })
@@ -149,7 +148,6 @@ func Run() {
if a.windowHidden { if a.windowHidden {
a.windowHidden = false a.windowHidden = false
activateApp() activateApp()
showDockIcon()
fyne.Do(func() { fyne.Do(func() {
if a.windowHidden { if a.windowHidden {
return return
@@ -173,7 +171,6 @@ func Run() {
a.window.SetCloseIntercept(func() { a.window.SetCloseIntercept(func() {
if cfg.CloseToTray { if cfg.CloseToTray {
a.windowHidden = true a.windowHidden = true
hideDockIcon()
a.window.Hide() a.window.Hide()
} else { } else {
a.quit() a.quit()
-9
View File
@@ -25,12 +25,6 @@ static BOOL appShouldHandleReopen(id self, SEL cmd, id sender, BOOL flag) {
return YES; 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) { static void cmActivateApp(void) {
Class cls = objc_getClass("NSApplication"); Class cls = objc_getClass("NSApplication");
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication")); id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
@@ -53,9 +47,6 @@ static void cmRegisterReopenHandler(void) {
*/ */
import "C" import "C"
func showDockIcon() { C.setDockIconVisible(1) }
func hideDockIcon() { C.setDockIconVisible(0) }
func activateApp() { C.cmActivateApp() } func activateApp() { C.cmActivateApp() }
func registerReopenHandler() { C.cmRegisterReopenHandler() } func registerReopenHandler() { C.cmRegisterReopenHandler() }
-3
View File
@@ -4,9 +4,6 @@ package ui
var onAppActive func() var onAppActive func()
func showDockIcon() {}
func hideDockIcon() {}
func activateApp() {} func activateApp() {}
func registerReopenHandler() {} func registerReopenHandler() {}
-1
View File
@@ -63,7 +63,6 @@ func (a *App) setupTray() {
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() { fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
a.windowHidden = false a.windowHidden = false
activateApp() activateApp()
showDockIcon()
a.window.Show() a.window.Show()
a.window.RequestFocus() a.window.RequestFocus()
}), }),
+1 -1
View File
@@ -32,7 +32,7 @@
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<true/> <true/>
<key>LSUIElement</key> <key>LSUIElement</key>
<false/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <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>