8 Commits
Author SHA1 Message Date
kevin bb4e4552d8 feat: 记住上次选中的配置,下次启动自动选中
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- 启用 AppConfig.DefaultProfileID 字段(此前声明但未使用)
- 启动时 loadProfiles() 优先选中上次记录的配置,找不到则回退到第一个
- 切换配置时立即持久化到 config.yml,防止崩溃丢失
- 使用 Profile ID 而非名称,避免重命名后失效
2026-07-08 21:05:32 +08:00
kevin 3e7df0f4d8 fix: 修复连接状态下切换配置后断开重连导致异常的问题
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
问题现象:连接中切换配置 -> 断开 -> 连接,会出现连接后瞬间断开、
长时间无法重连。而先断开再切换配置则正常。

根因分析(三个层面的资源泄漏叠加):

1. pumpPackets 死锁 (session.go)
   Disconnect() 只关闭 WebSocket transport,不关闭 TUN 设备。
   TUN->WS goroutine 阻塞在 dev.Read(),pumpPackets 的 wg.Wait()
   永远不返回,cleanup()(关闭 TUN/删路由)无法执行,导致
   TUN 设备和路由泄漏。

2. 僵尸 eventLoop + 僵尸 IPC 连接 (view.go)
   onDisconnect 不清空 ipcClient、不关闭 IPC 连接,旧 eventLoop
   持续运行接收广播事件。eventLoop 正常事件处理无身份校验,
   旧 session 的 disconnected 广播会覆盖新 session 的 connected 状态。

3. stopSession 不等待 goroutine 退出 (daemon.go)
   d.session = nil 在 goroutine 仍在运行时即设置,新旧 session
   并发执行导致 TUN/路由冲突。无 mutex 保护并发访问。

修复内容(10 项,3 个文件):

session.go:
- Disconnect() 新增 dev.Close() 解除 pumpPackets 死锁
- 新增 done channel,Disconnect() 阻塞等待 run goroutine 完全退出
- run() deferred setState 跳过用户主动断开时的冗余广播
- run() 顶部 ctx.Err() 检查后补 cleanup()

daemon.go:
- 新增 sync.Mutex 保护 session/cancel 并发访问
- 拆分 stopSession/stopSessionLocked 避免死锁

view.go:
- onDisconnect 置 ipcClient=nil + client.Close() 终止僵尸 eventLoop
- eventLoop 三个事件分支均加 if current == client 身份校验
- onConnect 覆盖前清理旧 IPC client
- setConnButtons 连接时禁用配置下拉框
2026-07-08 20:57:50 +08:00
kevin bf4744bb1d feat: 添加服务端证书合法性验证(自定义CA/跳过验证/证书固定)
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- 新增 internal/tlsconfig 包,集中构建 TLS 配置
- 修复 CDN 边缘 IP 故障转移时 HTTP 登录 TLS 验证失败的问题
- 支持自定义 CA 证书(内联 PEM + 文件路径,合并生效)
- 支持 InsecureSkipVerify 跳过证书验证
- 支持证书固定(SHA-256 指纹校验)
- TLS 验证错误设为不可恢复,避免无限重试
- Profile 编辑界面新增 TLS 设置区域,协议联动启用/禁用
- DB schema v4 迁移,新增 4 个 TLS 字段
2026-07-08 11:33:36 +08:00
kevin 97cd9d4553 fix: 提交 Windows 图标资源(icon.ico/.syso)修复 CI
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
2026-07-07 20:40:48 +08:00
kevin 56c802c385 fix: 禁用 MSYS 路径转换修复 ISCC /D 参数 2026-07-07 20:30:16 +08:00
kevin 6026beb107 fix: 修正 macOS ditto 打包选项为 --keepParent 2026-07-07 20:22:22 +08:00
kevin 8c903860c2 docs: README 添加服务端项目地址与 CI 说明 2026-07-07 20:13:50 +08:00
kevin 7abbf9e6cc feat: 添加 GitHub Actions 多平台自动发布工作流 2026-07-07 20:12:09 +08:00
21 changed files with 675 additions and 82 deletions
+114
View File
@@ -0,0 +1,114 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version (e.g. 0.3.9, 不带 v 前缀)'
required: true
permissions:
contents: write
jobs:
build-macos:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve version
id: ver
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
- name: Vet
run: make vet
- name: Build .app
run: make app SEMVER=${{ steps.ver.outputs.version }}
- name: Package .app as zip
run: |
VERSION="${{ steps.ver.outputs.version }}-$(git rev-parse --short HEAD)"
ditto -c -k --sequesterRsrc --keepParent LMVPN.app "LMVPN-${VERSION}-macos.zip"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: macos-build
path: LMVPN-*-macos.zip
build-windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install make and Inno Setup
shell: pwsh
run: choco install make innosetup -y --no-progress
- name: Add MinGW to PATH
shell: bash
run: echo "C:/msys64/mingw64/bin" >> "$GITHUB_PATH"
- name: Resolve version
id: ver
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
- name: Vet
shell: bash
run: make vet
- name: Build installer
shell: bash
env:
MSYS_NO_PATHCONV: '1'
run: make installer-windows SEMVER=${{ steps.ver.outputs.version }} MINGW_CC=gcc SHELL=bash
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: windows-build
path: build/LMVPN-Setup-*.exe
release:
needs: [build-macos, build-windows]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
-2
View File
@@ -8,8 +8,6 @@
*.app *.app
*.dmg *.dmg
*.exe *.exe
*.syso
resources/icon.ico
# Go # Go
/vendor/ /vendor/
+9 -6
View File
@@ -9,7 +9,9 @@ APP_BUNDLE = $(APP_NAME).app
GO = go GO = go
CGO_ENABLED = 1 CGO_ENABLED = 1
SEMVER = 0.3.9 WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc
SEMVER ?= 0.4.3
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)
@@ -88,16 +90,17 @@ clean:
## icon-windows: generate icon.ico and compile Windows resource (.syso) ## icon-windows: generate icon.ico and compile Windows resource (.syso)
icon-windows: icon-windows:
go run resources/genico/main.go go run resources/genico/main.go
x86_64-w64-mingw32-windres -i resources/lmvpn.rc -O coff -o cmd/lmvpn/resource_windows_amd64.syso $(WINDRES) -i resources/lmvpn.rc -O coff -o cmd/lmvpn/resource_windows_amd64.syso
x86_64-w64-mingw32-windres -i resources/lmvpn.rc -O coff -o cmd/lmvpnd/resource_windows_amd64.syso $(WINDRES) -i resources/lmvpn.rc -O coff -o cmd/lmvpnd/resource_windows_amd64.syso
@echo "Generated Windows icon resources" @echo "Generated Windows icon resources"
## build-windows: cross-compile Windows x86_64 exes (requires mingw-w64) ## build-windows: cross-compile Windows x86_64 exes (requires mingw-w64)
build-windows: icon-windows ## The .syso icon resources are committed in cmd/*/; run `make icon-windows` to regenerate.
build-windows:
mkdir -p $(BUILD_DIR) mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \ CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=$(MINGW_CC) \
$(GO) build -ldflags "$(LDFLAGS) -H windowsgui" -o $(BUILD_DIR)/$(GUI_BIN).exe ./cmd/lmvpn $(GO) build -ldflags "$(LDFLAGS) -H windowsgui" -o $(BUILD_DIR)/$(GUI_BIN).exe ./cmd/lmvpn
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \ CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=$(MINGW_CC) \
$(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(DAEMON_BIN).exe ./cmd/lmvpnd $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(DAEMON_BIN).exe ./cmd/lmvpnd
@echo "Built $(BUILD_DIR)/$(GUI_BIN).exe and $(BUILD_DIR)/$(DAEMON_BIN).exe" @echo "Built $(BUILD_DIR)/$(GUI_BIN).exe and $(BUILD_DIR)/$(DAEMON_BIN).exe"
+3 -1
View File
@@ -4,6 +4,8 @@ LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的**三层(网络
客户端使用 [Go](https://go.dev/) + [Fyne](https://fyne.io/) 开发,支持 macOS、Windows、Linux 三大桌面平台。 客户端使用 [Go](https://go.dev/) + [Fyne](https://fyne.io/) 开发,支持 macOS、Windows、Linux 三大桌面平台。
> **服务端项目**<https://github.com/wuwenfengmi1998/lmvpn_server>
--- ---
## 目录 ## 目录
@@ -336,7 +338,7 @@ make clean
- **不支持移动端**:虽 Fyne 支持 Android/iOS,本项目未配置移动端构建目标与适配代码 - **不支持移动端**:虽 Fyne 支持 Android/iOS,本项目未配置移动端构建目标与适配代码
- **Windows 仅 x64**:交叉编译仅目标 `GOARCH=amd64`,内置的 `wintun.dll` 为 x64 版本 - **Windows 仅 x64**:交叉编译仅目标 `GOARCH=amd64`,内置的 `wintun.dll` 为 x64 版本
- **Linux 密钥链待完善**:当前为内存存储,凭据不持久化,生产环境建议接入 Secret Service - **Linux 密钥链待完善**:当前为内存存储,凭据不持久化,生产环境建议接入 Secret Service
- **CI/CD**所有构建均为本地手动执行,无自动构建矩阵 - **CI/CD**通过 GitHub Actions 自动构建 macOS / Windows 产物并发布 Release(见 [`.github/workflows/release.yml`](.github/workflows/release.yml)),打 `v*` tag 即触发
- **CGO 必开**:任何平台都不可关闭 CGO,否则 Fyne 无法编译 - **CGO 必开**:任何平台都不可关闭 CGO,否则 Fyne 无法编译
- **版本一致性**:GUI 与守护进程共享同一版本字符串,若版本不一致通常意味着旧守护进程仍在运行 - **版本一致性**:GUI 与守护进程共享同一版本字符串,若版本不一致通常意味着旧守护进程仍在运行
Binary file not shown.
Binary file not shown.
+10 -1
View File
@@ -6,6 +6,7 @@ package auth
import ( import (
"bytes" "bytes"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -41,7 +42,12 @@ type errorResponse struct {
// baseURL should be the HTTP(S) origin derived from the WebSocket URL // baseURL should be the HTTP(S) origin derived from the WebSocket URL
// (e.g. "http://localhost:8080" for ws://, "https://vpn.example.com" // (e.g. "http://localhost:8080" for ws://, "https://vpn.example.com"
// for wss://). See WSURLToHTTP. // for wss://). See WSURLToHTTP.
func Login(baseURL, username, password string) (*LoginResult, error) { //
// tlsCfg, if non-nil, is used as the TLS configuration for the HTTP
// client. This is essential when connecting via CDN edge IPs: the URL
// host will be an IP address, but the certificate must be verified
// against the real hostname (set tlsCfg.ServerName).
func Login(baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) {
body, err := json.Marshal(loginRequest{Username: username, Password: password}) body, err := json.Marshal(loginRequest{Username: username, Password: password})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -49,6 +55,9 @@ func Login(baseURL, username, password string) (*LoginResult, error) {
url := strings.TrimRight(baseURL, "/") + "/api/login" url := strings.TrimRight(baseURL, "/") + "/api/login"
client := &http.Client{Timeout: 15 * time.Second} client := &http.Client{Timeout: 15 * time.Second}
if tlsCfg != nil {
client.Transport = &http.Transport{TLSClientConfig: tlsCfg}
}
resp, err := client.Post(url, "application/json", bytes.NewReader(body)) resp, err := client.Post(url, "application/json", bytes.NewReader(body))
if err != nil { if err != nil {
return nil, fmt.Errorf("login request: %w", err) return nil, fmt.Errorf("login request: %w", err)
+30 -13
View File
@@ -19,6 +19,7 @@ import (
"net" "net"
"os" "os"
"os/signal" "os/signal"
"sync"
"syscall" "syscall"
"lmvpn/internal/ipc" "lmvpn/internal/ipc"
@@ -92,6 +93,7 @@ func chownToUser(path string, uid, gid int) {
type daemon struct { type daemon struct {
server *ipc.Server server *ipc.Server
mu sync.Mutex
session *vpn.SessionManager session *vpn.SessionManager
cancel context.CancelFunc cancel context.CancelFunc
} }
@@ -109,8 +111,11 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
d.server.Close() d.server.Close()
os.Exit(0) os.Exit(0)
case ipc.CmdStats: case ipc.CmdStats:
if d.session != nil { d.mu.Lock()
snap := d.session.Stats().Snapshot() sess := d.session
d.mu.Unlock()
if sess != nil {
snap := sess.Stats().Snapshot()
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap}) d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
} }
_ = ipc.WriteOK(conn) _ = ipc.WriteOK(conn)
@@ -122,25 +127,31 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
} }
func (d *daemon) startSession(conn net.Conn, req ipc.Request) { func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
d.mu.Lock()
defer d.mu.Unlock()
if req.Config == nil { if req.Config == nil {
_ = ipc.WriteErr(conn, "missing config") _ = ipc.WriteErr(conn, "missing config")
return return
} }
if d.session != nil { if d.session != nil {
d.stopSession() d.stopSessionLocked()
} }
cfg := vpn.SessionConfig{ cfg := vpn.SessionConfig{
ServerURL: req.Config.ServerURL, ServerURL: req.Config.ServerURL,
SNIHost: req.Config.SNIHost, SNIHost: req.Config.SNIHost,
ServerIPs: req.Config.ServerIPs, ServerIPs: req.Config.ServerIPs,
Username: req.Config.Username, Username: req.Config.Username,
Password: req.Config.Password, Password: req.Config.Password,
Token: req.Config.Token, Token: req.Config.Token,
AuthMode: model.AuthMode(req.Config.AuthMode), AuthMode: model.AuthMode(req.Config.AuthMode),
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode), RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
CustomCIDRs: req.Config.CustomCIDRs, CustomCIDRs: req.Config.CustomCIDRs,
MTUOverride: req.Config.MTUOverride, MTUOverride: req.Config.MTUOverride,
TLSCACert: req.Config.TLSCACert,
TLSCAPath: req.Config.TLSCAPath,
TLSInsecure: req.Config.TLSInsecure,
TLSPinnedHash: req.Config.TLSPinnedHash,
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -166,6 +177,12 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
} }
func (d *daemon) stopSession() { func (d *daemon) stopSession() {
d.mu.Lock()
defer d.mu.Unlock()
d.stopSessionLocked()
}
func (d *daemon) stopSessionLocked() {
if d.cancel != nil { if d.cancel != nil {
d.cancel() d.cancel()
d.cancel = nil d.cancel = nil
+27 -1
View File
@@ -48,7 +48,10 @@ func (s *Store) migrate() error {
if err := s.migrateV2(); err != nil { if err := s.migrateV2(); err != nil {
return err return err
} }
return s.migrateV3() if err := s.migrateV3(); err != nil {
return err
}
return s.migrateV4()
} }
func (s *Store) migrateV2() error { func (s *Store) migrateV2() error {
@@ -181,6 +184,29 @@ func (s *Store) migrateV3() error {
return nil return nil
} }
// migrateV4 adds TLS certificate verification columns to
// server_profiles for custom CA, insecure mode, and cert pinning.
// Idempotent: skips columns that already exist.
func (s *Store) migrateV4() error {
cols := []struct {
name string
sql string
}{
{"tls_ca_cert", "ALTER TABLE server_profiles ADD COLUMN tls_ca_cert TEXT NOT NULL DEFAULT ''"},
{"tls_ca_path", "ALTER TABLE server_profiles ADD COLUMN tls_ca_path TEXT NOT NULL DEFAULT ''"},
{"tls_insecure", "ALTER TABLE server_profiles ADD COLUMN tls_insecure INTEGER NOT NULL DEFAULT 0"},
{"tls_pinned_hash", "ALTER TABLE server_profiles ADD COLUMN tls_pinned_hash TEXT NOT NULL DEFAULT ''"},
}
for _, c := range cols {
if !columnExists(s.db, "server_profiles", c.name) {
if _, err := s.db.Exec(c.sql); err != nil {
return fmt.Errorf("migrate v4 add %s: %w", c.name, err)
}
}
}
return nil
}
// columnExists reports whether a column exists on a table. // columnExists reports whether a column exists on a table.
func columnExists(db *sql.DB, table, column string) bool { func columnExists(db *sql.DB, table, column string) bool {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
+19 -8
View File
@@ -14,11 +14,13 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
`INSERT INTO server_profiles `INSERT INTO server_profiles
(name, protocol, host, server_ips, port, path, (name, protocol, host, server_ips, port, path,
username, auth_mode, routing_mode, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect) custom_cidrs, mtu_override, auto_connect,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode, p.Username, p.AuthMode, p.RoutingMode,
p.CustomCIDRs, p.MTUOverride, p.AutoConnect, p.CustomCIDRs, p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("insert profile: %w", err) return 0, fmt.Errorf("insert profile: %w", err)
@@ -36,11 +38,14 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
err := s.db.QueryRow( err := s.db.QueryRow(
`SELECT id, name, protocol, host, server_ips, port, path, `SELECT id, name, protocol, host, server_ips, port, path,
username, auth_mode, routing_mode, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at custom_cidrs, mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
created_at, last_connected_at
FROM server_profiles WHERE id = ?`, id, FROM server_profiles WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path, ).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path,
&p.Username, &p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride, &p.Username, &p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride,
&p.AutoConnect, &p.CreatedAt, &last) &p.AutoConnect, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last)
if err != nil { if err != nil {
return nil, fmt.Errorf("get profile %d: %w", id, err) return nil, fmt.Errorf("get profile %d: %w", id, err)
} }
@@ -55,7 +60,9 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, name, protocol, host, server_ips, port, path, `SELECT id, name, protocol, host, server_ips, port, path,
username, auth_mode, routing_mode, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at custom_cidrs, mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
created_at, last_connected_at
FROM server_profiles ORDER BY name`) FROM server_profiles ORDER BY name`)
if err != nil { if err != nil {
return nil, fmt.Errorf("list profiles: %w", err) return nil, fmt.Errorf("list profiles: %w", err)
@@ -68,7 +75,9 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
var last sql.NullTime var last sql.NullTime
if err := rows.Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, if err := rows.Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs,
&p.Port, &p.Path, &p.Username, &p.AuthMode, &p.RoutingMode, &p.Port, &p.Path, &p.Username, &p.AuthMode, &p.RoutingMode,
&p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect, &p.CreatedAt, &last); err != nil { &p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last); err != nil {
return nil, err return nil, err
} }
if last.Valid { if last.Valid {
@@ -85,11 +94,13 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error {
`UPDATE server_profiles SET `UPDATE server_profiles SET
name = ?, protocol = ?, host = ?, server_ips = ?, port = ?, path = ?, name = ?, protocol = ?, host = ?, server_ips = ?, port = ?, path = ?,
username = ?, auth_mode = ?, routing_mode = ?, username = ?, auth_mode = ?, routing_mode = ?,
custom_cidrs = ?, mtu_override = ?, auto_connect = ? custom_cidrs = ?, mtu_override = ?, auto_connect = ?,
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?
WHERE id = ?`, WHERE id = ?`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode, p.Username, p.AuthMode, p.RoutingMode,
p.CustomCIDRs, p.MTUOverride, p.AutoConnect, p.ID) p.CustomCIDRs, p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID)
if err != nil { if err != nil {
return fmt.Errorf("update profile %d: %w", p.ID, err) return fmt.Errorf("update profile %d: %w", p.ID, err)
} }
+13
View File
@@ -102,3 +102,16 @@ AuthModePassword = "Password"
RoutingModeFull = "Full Tunnel" RoutingModeFull = "Full Tunnel"
RoutingModeSplit = "Split Tunnel" RoutingModeSplit = "Split Tunnel"
RoutingModeCustom = "Custom" RoutingModeCustom = "Custom"
FieldTLS = "TLS Certificate"
FieldTLSCACert = "CA Certificate (PEM)"
FieldTLSCAPath = "CA Certificate Path"
HintTLSCAReplacesSystem = "Setting a custom CA replaces system root certificates. Public CA servers (e.g. Let's Encrypt) will fail verification."
FieldTLSInsecure = "Skip Verification (Insecure)"
FieldTLSPinnedHash = "Certificate Pin (SHA-256)"
PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..."
PlaceholderTLSCAPath = "/path/to/ca.crt"
PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
BtnBrowse = "Browse..."
DlgTLSError = "TLS Certificate Error"
TLSErrorVerification = "Server certificate verification failed."
+13
View File
@@ -102,3 +102,16 @@ AuthModePassword = "密码"
RoutingModeFull = "全隧道" RoutingModeFull = "全隧道"
RoutingModeSplit = "分离隧道" RoutingModeSplit = "分离隧道"
RoutingModeCustom = "自定义" RoutingModeCustom = "自定义"
FieldTLS = "TLS 证书"
FieldTLSCACert = "CA 证书 (PEM)"
FieldTLSCAPath = "CA 证书路径"
HintTLSCAReplacesSystem = "设置自定义 CA 将替换系统根证书,使用公共 CA(如 Let's Encrypt)的服务器将验证失败。"
FieldTLSInsecure = "跳过验证 (不安全)"
FieldTLSPinnedHash = "证书固定 (SHA-256)"
PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..."
PlaceholderTLSCAPath = "/path/to/ca.crt"
PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
BtnBrowse = "浏览..."
DlgTLSError = "TLS 证书错误"
TLSErrorVerification = "服务器证书验证失败。"
+14 -10
View File
@@ -49,16 +49,20 @@ type Request struct {
// vpn.SessionConfig but is kept separate to avoid importing the vpn // vpn.SessionConfig but is kept separate to avoid importing the vpn
// package (which needs root-only TUN) into the GUI. // package (which needs root-only TUN) into the GUI.
type ClientConfig struct { type ClientConfig struct {
ServerURL string `json:"server_url"` ServerURL string `json:"server_url"`
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Token string `json:"token"` Token string `json:"token"`
AuthMode string `json:"auth_mode"` AuthMode string `json:"auth_mode"`
RoutingMode string `json:"routing_mode"` RoutingMode string `json:"routing_mode"`
CustomCIDRs []string `json:"custom_cidrs"` CustomCIDRs []string `json:"custom_cidrs"`
MTUOverride int `json:"mtu_override"` 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)
} }
// Event is a notification from the daemon to the GUI. // Event is a notification from the daemon to the GUI.
+4
View File
@@ -41,6 +41,10 @@ type ServerProfile struct {
CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom
MTUOverride int `json:"mtu_override"` // 0 = use server MTU MTUOverride int `json:"mtu_override"` // 0 = use server MTU
AutoConnect bool `json:"auto_connect"` AutoConnect bool `json:"auto_connect"`
TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (wss only)
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)
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastConnectedAt *time.Time `json:"last_connected_at"` LastConnectedAt *time.Time `json:"last_connected_at"`
} }
+157
View File
@@ -0,0 +1,157 @@
// Package tlsconfig builds *tls.Config instances from user-supplied
// verification settings (custom CA, insecure mode, certificate
// pinning). It is shared by the WebSocket transport and the HTTP
// login client so both paths enforce identical TLS policy.
package tlsconfig
import (
"bytes"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
)
// Config holds TLS verification settings for a server connection.
type Config struct {
ServerName string // TLS SNI hostname (for CDN edge IP connections)
CACertPEM string // inline CA certificate PEM content
CACertPath string // path to a CA certificate file
InsecureSkipVerify bool // skip chain verification entirely
PinnedCertHash string // SHA-256 fingerprint of the leaf cert (hex, optional "sha256:" prefix)
}
// Build creates a *tls.Config from the given settings.
//
// Verification behaviour matrix:
//
// Pin set | Insecure | Behaviour
// --------+----------+-------------------------------------------
// no | no | Normal chain + hostname verification (Go default)
// no | yes | All verification skipped
// yes | no | Normal chain verification, then pin check
// yes | yes | Chain skipped, pin check only
func Build(cfg Config) (*tls.Config, error) {
tlsCfg := &tls.Config{
ServerName: cfg.ServerName,
}
if cfg.CACertPEM != "" || cfg.CACertPath != "" {
pool, err := buildCertPool(cfg.CACertPEM, cfg.CACertPath)
if err != nil {
return nil, fmt.Errorf("load CA cert: %w", err)
}
tlsCfg.RootCAs = pool
}
var pin []byte
if cfg.PinnedCertHash != "" {
var err error
pin, err = decodePin(cfg.PinnedCertHash)
if err != nil {
return nil, fmt.Errorf("invalid pinned cert hash: %w", err)
}
}
if pin != nil {
if cfg.InsecureSkipVerify {
tlsCfg.InsecureSkipVerify = true
}
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
return verifyPin(rawCerts, pin)
}
} else if cfg.InsecureSkipVerify {
tlsCfg.InsecureSkipVerify = true
}
return tlsCfg, nil
}
// PinMismatchError indicates the server leaf certificate does not
// match the pinned SHA-256 fingerprint.
type PinMismatchError struct {
Expected []byte
Got []byte
}
func (e *PinMismatchError) Error() string {
return fmt.Sprintf("certificate pin mismatch: expected %x, got %x", e.Expected, e.Got)
}
// IsTLSError reports whether err represents a TLS certificate
// verification failure that will not resolve on retry.
func IsTLSError(err error) bool {
var certInvalid *x509.CertificateInvalidError
if errors.As(err, &certInvalid) {
return true
}
var hostErr *x509.HostnameError
if errors.As(err, &hostErr) {
return true
}
var unknownAuth *x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return true
}
var verifyErr *tls.CertificateVerificationError
if errors.As(err, &verifyErr) {
return true
}
var pinErr *PinMismatchError
if errors.As(err, &pinErr) {
return true
}
return false
}
func buildCertPool(pemData, path string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
if pemData != "" {
if !pool.AppendCertsFromPEM([]byte(pemData)) {
return nil, errors.New("failed to parse inline CA certificate PEM")
}
}
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read CA cert file %s: %w", path, err)
}
if !pool.AppendCertsFromPEM(data) {
return nil, errors.New("failed to parse CA certificate file")
}
}
return pool, nil
}
func decodePin(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "sha256:") {
s = s[len("sha256:"):]
}
pin, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(pin) != sha256.Size {
return nil, fmt.Errorf("expected %d bytes, got %d", sha256.Size, len(pin))
}
return pin, nil
}
func verifyPin(rawCerts [][]byte, pin []byte) error {
if len(rawCerts) == 0 {
return errors.New("no server certificate provided")
}
h := sha256.Sum256(rawCerts[0])
if !bytes.Equal(h[:], pin) {
return &PinMismatchError{Expected: pin, Got: h[:]}
}
return nil
}
+4 -1
View File
@@ -36,6 +36,7 @@ type HandshakeConfig struct {
Username string // for password auth (method B), or fallback Username string // for password auth (method B), or fallback
Password string // for password auth (method B), or fallback Password string // for password auth (method B), or fallback
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
} }
// Conn is an established VPN tunnel connection. // Conn is an established VPN tunnel connection.
@@ -57,7 +58,9 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
WriteBufferSize: 4096, // match server (handler.go:18) WriteBufferSize: 4096, // match server (handler.go:18)
} }
if cfg.SNIHost != "" { if cfg.TLSConfig != nil {
dialer.TLSClientConfig = cfg.TLSConfig
} else if cfg.SNIHost != "" {
dialer.TLSClientConfig = &tls.Config{ dialer.TLSClientConfig = &tls.Config{
ServerName: cfg.SNIHost, ServerName: cfg.SNIHost,
} }
+42 -7
View File
@@ -48,11 +48,12 @@ type App struct {
disconnectBtn *widget.Button disconnectBtn *widget.Button
// State // State
mu sync.Mutex mu sync.Mutex
ipcClient *ipc.Client ipcClient *ipc.Client
profiles []model.ServerProfile profiles []model.ServerProfile
currentProfile *model.ServerProfile currentProfile *model.ServerProfile
langSetting string defaultProfileID int64
langSetting string
} }
// Run initialises and starts the GUI application. // Run initialises and starts the GUI application.
@@ -84,6 +85,7 @@ func Run() {
db: store, db: store,
kc: keychain.New(), kc: keychain.New(),
langSetting: cfg.Language, langSetting: cfg.Language,
defaultProfileID: cfg.DefaultProfileID,
listSelectedIndex: -1, listSelectedIndex: -1,
} }
@@ -144,8 +146,21 @@ func (a *App) loadProfiles() {
names := a.profileNames() names := a.profileNames()
a.profileSelect.Options = names a.profileSelect.Options = names
if len(names) > 0 { if len(names) > 0 {
a.profileSelect.SetSelectedIndex(0) selected := false
a.selectProfileByName(names[0]) if a.defaultProfileID > 0 {
for i := range a.profiles {
if a.profiles[i].ID == a.defaultProfileID {
a.profileSelect.SetSelectedIndex(i)
a.selectProfileByName(a.profiles[i].Name)
selected = true
break
}
}
}
if !selected {
a.profileSelect.SetSelectedIndex(0)
a.selectProfileByName(names[0])
}
} else { } else {
a.currentProfile = nil a.currentProfile = nil
a.profileSelect.SetSelected("") a.profileSelect.SetSelected("")
@@ -182,6 +197,26 @@ func (a *App) selectProfileByName(name string) {
} }
} }
// saveDefaultProfile persists the currently selected profile ID to the
// config file so it can be restored on the next launch.
func (a *App) saveDefaultProfile() {
if a.currentProfile == nil {
return
}
a.defaultProfileID = a.currentProfile.ID
cfg, err := config.Load()
if err != nil {
cfg = config.Default()
}
if cfg.DefaultProfileID == a.currentProfile.ID {
return
}
cfg.DefaultProfileID = a.currentProfile.ID
if err := config.Save(cfg); err != nil {
log.L().Error("save default profile", "error", err)
}
}
// onAddProfile shows a dialog to create a new profile. // onAddProfile shows a dialog to create a new profile.
func (a *App) onAddProfile() { func (a *App) onAddProfile() {
a.showProfileDialog(nil) a.showProfileDialog(nil)
+92 -15
View File
@@ -108,6 +108,52 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
mtuEntry.Scroll = container.ScrollNone mtuEntry.Scroll = container.ScrollNone
mtuEntry.SetPlaceHolder(i18n.T("PlaceholderMTU")) mtuEntry.SetPlaceHolder(i18n.T("PlaceholderMTU"))
tlsCaPEMEntry := widget.NewMultiLineEntry()
tlsCaPEMEntry.Wrapping = fyne.TextWrapOff
tlsCaPEMEntry.Scroll = container.ScrollNone
tlsCaPEMEntry.SetMinRowsVisible(4)
tlsCaPEMEntry.SetPlaceHolder(i18n.T("PlaceholderTLSCACert"))
tlsCaPathEntry := widget.NewEntry()
tlsCaPathEntry.Wrapping = fyne.TextWrapOff
tlsCaPathEntry.Scroll = container.ScrollNone
tlsCaPathEntry.SetPlaceHolder(i18n.T("PlaceholderTLSCAPath"))
tlsInsecureCheck := widget.NewCheck(i18n.T("FieldTLSInsecure"), nil)
tlsPinnedHashEntry := widget.NewEntry()
tlsPinnedHashEntry.Wrapping = fyne.TextWrapOff
tlsPinnedHashEntry.Scroll = container.ScrollNone
tlsPinnedHashEntry.SetPlaceHolder(i18n.T("PlaceholderTLSPinnedHash"))
var profileWin fyne.Window
browseBtn := widget.NewButton(i18n.T("BtnBrowse"), func() {
dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
tlsCaPathEntry.SetText(reader.URI().Path())
}, profileWin).Show()
})
setTLSEnabled := func(enabled bool) {
if enabled {
tlsCaPEMEntry.Enable()
tlsCaPathEntry.Enable()
browseBtn.Enable()
tlsInsecureCheck.Enable()
tlsPinnedHashEntry.Enable()
} else {
tlsCaPEMEntry.Disable()
tlsCaPathEntry.Disable()
browseBtn.Disable()
tlsInsecureCheck.Disable()
tlsPinnedHashEntry.Disable()
}
}
if !isNew { if !isNew {
nameEntry.SetText(editing.Name) nameEntry.SetText(editing.Name)
protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol)) protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol))
@@ -122,6 +168,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode))) routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
cidrEntry.SetText(editing.CustomCIDRs) cidrEntry.SetText(editing.CustomCIDRs)
mtuEntry.SetText(fmtInt(editing.MTUOverride)) mtuEntry.SetText(fmtInt(editing.MTUOverride))
tlsCaPEMEntry.SetText(editing.TLSCACert)
tlsCaPathEntry.SetText(editing.TLSCAPath)
tlsInsecureCheck.SetChecked(editing.TLSInsecure)
tlsPinnedHashEntry.SetText(editing.TLSPinnedHash)
passEntry.SetPlaceHolder(i18n.T("PlaceholderPasswordUnchanged")) passEntry.SetPlaceHolder(i18n.T("PlaceholderPasswordUnchanged"))
} else { } else {
protoSelect.SetSelectedIndex(0) // wss protoSelect.SetSelectedIndex(0) // wss
@@ -132,6 +182,11 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
mtuEntry.SetText("0") mtuEntry.SetText("0")
} }
protoSelect.OnChanged = func(value string) {
setTLSEnabled(value == "wss")
}
setTLSEnabled(selectedCode(protoCodes, protoSelect.SelectedIndex()) == "wss")
form := container.NewVBox( form := container.NewVBox(
widget.NewLabel(i18n.T("FieldName")), widget.NewLabel(i18n.T("FieldName")),
nameEntry, nameEntry,
@@ -159,9 +214,19 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry, widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry,
widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry, widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry,
widget.NewLabel(i18n.T("FieldTLS")),
widget.NewLabel(i18n.T("FieldTLSCAPath")),
container.NewBorder(nil, nil, nil, browseBtn, tlsCaPathEntry),
widget.NewLabel(i18n.T("FieldTLSCACert")),
tlsCaPEMEntry,
widget.NewLabel(i18n.T("HintTLSCAReplacesSystem")),
tlsInsecureCheck,
widget.NewLabel(i18n.T("FieldTLSPinnedHash")),
tlsPinnedHashEntry,
) )
profileWin := a.fyneApp.NewWindow(i18n.T("DlgProfileTitle")) profileWin = a.fyneApp.NewWindow(i18n.T("DlgProfileTitle"))
a.profileWindow = profileWin a.profileWindow = profileWin
profileWin.SetOnClosed(func() { profileWin.SetOnClosed(func() {
@@ -177,7 +242,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
userEntry.Text, passEntry.Text, userEntry.Text, passEntry.Text,
selectedCode(authCodes, authSelect.SelectedIndex()), selectedCode(authCodes, authSelect.SelectedIndex()),
selectedCode(routeCodes, routeSelect.SelectedIndex()), selectedCode(routeCodes, routeSelect.SelectedIndex()),
cidrEntry.Text, mtuEntry.Text, isNew) { cidrEntry.Text, mtuEntry.Text,
tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
isNew) {
profileWin.Close() profileWin.Close()
} }
}) })
@@ -188,14 +256,15 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
}) })
profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, container.NewVScroll(form))) profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, container.NewVScroll(form)))
profileWin.Resize(fyne.NewSize(460, 560)) profileWin.Resize(fyne.NewSize(460, 760))
profileWin.Show() profileWin.Show()
} }
// saveProfile creates or updates a profile and stores credentials. // saveProfile creates or updates a profile and stores credentials.
// Returns true on success, false if validation or DB operation failed. // Returns true on success, false if validation or DB operation failed.
func (a *App) saveProfile(editing *model.ServerProfile, func (a *App) saveProfile(editing *model.ServerProfile,
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) bool { name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, cidrs, mtuStr,
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool {
if name == "" || host == "" || user == "" { if name == "" || host == "" || user == "" {
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window) showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
return false return false
@@ -206,17 +275,21 @@ func (a *App) saveProfile(editing *model.ServerProfile,
if isNew { if isNew {
p := &model.ServerProfile{ p := &model.ServerProfile{
Name: name, Name: name,
Protocol: protocol, Protocol: protocol,
Host: host, Host: host,
ServerIPs: ips, ServerIPs: ips,
Port: port, Port: port,
Path: pathStr, Path: pathStr,
Username: user, Username: user,
AuthMode: model.AuthMode(authMode), AuthMode: model.AuthMode(authMode),
RoutingMode: model.RoutingMode(routeMode), RoutingMode: model.RoutingMode(routeMode),
CustomCIDRs: cidrs, CustomCIDRs: cidrs,
MTUOverride: mtu, MTUOverride: mtu,
TLSCACert: tlsCaPEM,
TLSCAPath: tlsCaPath,
TLSInsecure: tlsInsecure,
TLSPinnedHash: tlsPinnedHash,
} }
id, err := a.db.CreateProfile(p) id, err := a.db.CreateProfile(p)
if err != nil { if err != nil {
@@ -242,6 +315,10 @@ func (a *App) saveProfile(editing *model.ServerProfile,
editing.RoutingMode = model.RoutingMode(routeMode) editing.RoutingMode = model.RoutingMode(routeMode)
editing.CustomCIDRs = cidrs editing.CustomCIDRs = cidrs
editing.MTUOverride = mtu editing.MTUOverride = mtu
editing.TLSCACert = tlsCaPEM
editing.TLSCAPath = tlsCaPath
editing.TLSInsecure = tlsInsecure
editing.TLSPinnedHash = tlsPinnedHash
if err := a.db.UpdateProfile(editing); err != nil { if err := a.db.UpdateProfile(editing); err != nil {
showError(i18n.T("DlgSaveError"), err.Error(), a.window) showError(i18n.T("DlgSaveError"), err.Error(), a.window)
return false return false
+63 -14
View File
@@ -39,6 +39,13 @@ func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) {
} else { } else {
a.disconnectBtn.Disable() a.disconnectBtn.Disable()
} }
if a.profileSelect != nil {
if connectEnabled {
a.profileSelect.Enable()
} else {
a.profileSelect.Disable()
}
}
a.setupTray() a.setupTray()
} }
@@ -47,6 +54,7 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
// Profile selector. // Profile selector.
a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) { a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) {
a.selectProfileByName(sel) a.selectProfileByName(sel)
a.saveDefaultProfile()
}) })
// Status display. // Status display.
@@ -132,8 +140,13 @@ func (a *App) onConnect() {
} }
a.mu.Lock() a.mu.Lock()
oldClient := a.ipcClient
a.ipcClient = client a.ipcClient = client
a.mu.Unlock() a.mu.Unlock()
if oldClient != nil {
_ = ipc.SendStop(oldClient)
oldClient.Close()
}
// Get password from keychain. // Get password from keychain.
password, err := a.kc.GetPassword(a.currentProfile.Name) password, err := a.kc.GetPassword(a.currentProfile.Name)
@@ -158,15 +171,19 @@ func (a *App) onConnect() {
// Build and send the start command. // Build and send the start command.
cfg := ipc.ClientConfig{ cfg := ipc.ClientConfig{
ServerURL: serverURL, ServerURL: serverURL,
SNIHost: sniHost, SNIHost: sniHost,
ServerIPs: serverIPs, ServerIPs: serverIPs,
Username: p.Username, Username: p.Username,
Password: password, Password: password,
AuthMode: string(p.AuthMode), AuthMode: string(p.AuthMode),
RoutingMode: string(p.RoutingMode), RoutingMode: string(p.RoutingMode),
CustomCIDRs: splitCIDRs(p.CustomCIDRs), CustomCIDRs: splitCIDRs(p.CustomCIDRs),
MTUOverride: p.MTUOverride, MTUOverride: p.MTUOverride,
TLSCACert: p.TLSCACert,
TLSCAPath: p.TLSCAPath,
TLSInsecure: p.TLSInsecure,
TLSPinnedHash: p.TLSPinnedHash,
} }
if err := ipc.SendStart(client, cfg); err != nil { if err := ipc.SendStart(client, cfg); err != nil {
fyne.Do(func() { fyne.Do(func() {
@@ -186,13 +203,24 @@ func (a *App) onConnect() {
func (a *App) onDisconnect() { func (a *App) onDisconnect() {
a.mu.Lock() a.mu.Lock()
client := a.ipcClient client := a.ipcClient
a.ipcClient = nil
a.mu.Unlock() a.mu.Unlock()
if client == nil { if client == nil {
return return
} }
_ = ipc.SendStop(client) _ = ipc.SendStop(client)
client.Close()
a.setConnButtons(true, false) a.setConnButtons(true, false)
a.stateLabel.SetText(i18n.T("StateDisconnected")) 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"))
} }
// eventLoop reads IPC events from the daemon and updates the UI. // eventLoop reads IPC events from the daemon and updates the UI.
@@ -230,20 +258,41 @@ func (a *App) eventLoop() {
switch ev.Event { switch ev.Event {
case ipc.EvState: case ipc.EvState:
fyne.Do(func() { fyne.Do(func() {
a.applyState(ev.State) a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current == client {
a.applyState(ev.State)
}
}) })
case ipc.EvStats: case ipc.EvStats:
if ev.Stats != nil { if ev.Stats != nil {
s := *ev.Stats s := *ev.Stats
fyne.Do(func() { fyne.Do(func() {
a.applyStats(s) a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current == client {
a.applyStats(s)
}
}) })
} }
case ipc.EvError: case ipc.EvError:
fyne.Do(func() { fyne.Do(func() {
msg := authErrorMessage(ev.Code, ev.Message) a.mu.Lock()
if msg != "" { current := a.ipcClient
showError(i18n.T("DlgAuthError"), msg, a.window) a.mu.Unlock()
if current != client {
return
}
if ev.Code == "tls_error" {
showError(i18n.T("DlgTLSError"),
i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window)
} else {
msg := authErrorMessage(ev.Code, ev.Message)
if msg != "" {
showError(i18n.T("DlgAuthError"), msg, a.window)
}
} }
}) })
} }
+61 -3
View File
@@ -9,10 +9,12 @@ package vpn
import ( import (
"context" "context"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -22,6 +24,7 @@ import (
"lmvpn/internal/protocol" "lmvpn/internal/protocol"
"lmvpn/internal/route" "lmvpn/internal/route"
"lmvpn/internal/stats" "lmvpn/internal/stats"
"lmvpn/internal/tlsconfig"
"lmvpn/internal/transport" "lmvpn/internal/transport"
"lmvpn/internal/tun" "lmvpn/internal/tun"
) )
@@ -38,6 +41,10 @@ type SessionConfig struct {
RoutingMode route.Mode RoutingMode route.Mode
CustomCIDRs []string CustomCIDRs []string
MTUOverride int // 0 = use server MTU 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)
} }
// SessionManager manages a single VPN session with auto-reconnect. // SessionManager manages a single VPN session with auto-reconnect.
@@ -53,6 +60,7 @@ type SessionManager struct {
dev tun.Device dev tun.Device
routeMgr *route.Manager routeMgr *route.Manager
conn *transport.Conn conn *transport.Conn
done chan struct{}
// EWMA speed smoothing state. Only touched by reportStats (single // EWMA speed smoothing state. Only touched by reportStats (single
// goroutine), so no lock needed. ewma* fields hold the smoothed // goroutine), so no lock needed. ewma* fields hold the smoothed
@@ -99,6 +107,7 @@ func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
sm.cancel = cancel sm.cancel = cancel
sm.running = true sm.running = true
sm.done = make(chan struct{})
sm.mu.Unlock() sm.mu.Unlock()
go sm.run(ctx, cfg) go sm.run(ctx, cfg)
@@ -119,21 +128,32 @@ func (sm *SessionManager) Disconnect() {
if cancel != nil { if cancel != nil {
cancel() cancel()
} }
// Close the transport to unblock the packet pump. // Close the transport to unblock the WS->TUN goroutine.
sm.mu.Lock() sm.mu.Lock()
conn := sm.conn conn := sm.conn
dev := sm.dev
sm.mu.Unlock() sm.mu.Unlock()
if conn != nil { if conn != nil {
conn.Close() conn.Close()
} }
// Close the TUN device to unblock the TUN->WS goroutine's dev.Read().
if dev != nil {
dev.Close()
}
// Wait for the run goroutine to fully exit so that cleanup
// (route removal, TUN teardown) is complete before returning.
if sm.done != nil {
<-sm.done
}
} }
// run is the main session loop with exponential-backoff reconnection // run is the main session loop with exponential-backoff reconnection
// and CDN IP failover. // and CDN IP failover.
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
fatal := false fatal := false
defer close(sm.done)
defer func() { defer func() {
if !fatal { if !fatal && ctx.Err() == nil {
sm.setState(stats.StateDisconnected) sm.setState(stats.StateDisconnected)
} }
}() }()
@@ -147,6 +167,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
for { for {
if ctx.Err() != nil { if ctx.Err() != nil {
sm.cleanup()
return return
} }
@@ -164,6 +185,20 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
if err != nil { if err != nil {
log.L().Error("VPN connection failed", "error", err) log.L().Error("VPN connection failed", "error", err)
// A TLS certificate verification failure is not retryable:
// the cert won't change between attempts, so stop the
// loop and surface the reason to the user.
if tlsconfig.IsTLSError(err) {
log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
sm.setState(stats.StateError)
if sm.onError != nil {
sm.onError("tls_error", err.Error())
}
fatal = true
sm.cleanup()
return
}
// A fatal authentication failure (wrong password, disabled // A fatal authentication failure (wrong password, disabled
// account, expired token, rate limit) is not retryable: // account, expired token, rate limit) is not retryable:
// stop the loop and surface the reason to the user instead // stop the loop and surface the reason to the user instead
@@ -254,6 +289,28 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
serverURL = replaceHost(cfg.ServerURL, targetIP) serverURL = replaceHost(cfg.ServerURL, targetIP)
} }
// Build TLS config for wss:// connections. For ws:// there is no
// TLS layer, so tlsCfg remains nil and both the HTTP client and
// the WebSocket dialer use their default (plaintext) behaviour.
var tlsCfg *tls.Config
if strings.HasPrefix(serverURL, "wss://") {
serverName := cfg.SNIHost
if serverName == "" {
serverName = serverHostFromURL(cfg.ServerURL)
}
var err error
tlsCfg, err = tlsconfig.Build(tlsconfig.Config{
ServerName: serverName,
CACertPEM: cfg.TLSCACert,
CACertPath: cfg.TLSCAPath,
InsecureSkipVerify: cfg.TLSInsecure,
PinnedCertHash: cfg.TLSPinnedHash,
})
if err != nil {
return fmt.Errorf("tls config: %w", err)
}
}
// Determine auth strategy and obtain JWT if needed. // Determine auth strategy and obtain JWT if needed.
token := cfg.Token token := cfg.Token
if token == "" && (cfg.AuthMode == model.AuthModeJWT || cfg.AuthMode == model.AuthModeBoth) { if token == "" && (cfg.AuthMode == model.AuthModeJWT || cfg.AuthMode == model.AuthModeBoth) {
@@ -261,7 +318,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
if err != nil { if err != nil {
return fmt.Errorf("parse server URL: %w", err) return fmt.Errorf("parse server URL: %w", err)
} }
result, err := auth.Login(httpBase, cfg.Username, cfg.Password) result, err := auth.Login(httpBase, cfg.Username, cfg.Password, tlsCfg)
if err != nil { if err != nil {
if cfg.AuthMode == model.AuthModeBoth { if cfg.AuthMode == model.AuthModeBoth {
// Fall back to password auth. // Fall back to password auth.
@@ -285,6 +342,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
OnInit: func(init protocol.InitMessage) error { OnInit: func(init protocol.InitMessage) error {
return sm.setupTUN(init, cfg) return sm.setupTUN(init, cfg)
}, },
TLSConfig: tlsCfg,
} }
// Attempt JWT connection first; fall back to password on auth error. // Attempt JWT connection first; fall back to password on auth error.
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB