Compare commits
9
Commits
45b9fa24a1
..
v0.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c56fc0c21c | ||
|
|
bb4e4552d8 | ||
|
|
3e7df0f4d8 | ||
|
|
bf4744bb1d | ||
|
|
97cd9d4553 | ||
|
|
56c802c385 | ||
|
|
6026beb107 | ||
|
|
8c903860c2 | ||
|
|
7abbf9e6cc |
@@ -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
|
||||||
@@ -8,8 +8,6 @@
|
|||||||
*.app
|
*.app
|
||||||
*.dmg
|
*.dmg
|
||||||
*.exe
|
*.exe
|
||||||
*.syso
|
|
||||||
resources/icon.ico
|
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
/vendor/
|
/vendor/
|
||||||
|
|||||||
@@ -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.4
|
||||||
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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -45,6 +45,7 @@ DlgDeleteProfileMsg = 'Delete profile "{{.name}}" and its stored credentials?'
|
|||||||
DlgProfileTitle = "Profile"
|
DlgProfileTitle = "Profile"
|
||||||
DlgValidationTitle = "Validation"
|
DlgValidationTitle = "Validation"
|
||||||
DlgValidationMsg = "Name, Host, and Username are required."
|
DlgValidationMsg = "Name, Host, and Username are required."
|
||||||
|
DlgInvalidIPMsg = "Invalid IP address(es): {{.ips}}"
|
||||||
DlgDaemonError = "Daemon Error"
|
DlgDaemonError = "Daemon Error"
|
||||||
DlgCredentialError = "Credential Error"
|
DlgCredentialError = "Credential Error"
|
||||||
DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it."
|
DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it."
|
||||||
@@ -93,7 +94,7 @@ FieldMTUOverride = "MTU Override"
|
|||||||
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
||||||
PlaceholderMTU = "0 = use server MTU"
|
PlaceholderMTU = "0 = use server MTU"
|
||||||
PlaceholderPasswordUnchanged = "(unchanged)"
|
PlaceholderPasswordUnchanged = "(unchanged)"
|
||||||
PlaceholderServerIPs = "e.g. 1.2.3.4, 5.6.7.8"
|
PlaceholderServerIPs = "IPv4/IPv6 supported, e.g. 1.2.3.4, 5.6.7.8"
|
||||||
|
|
||||||
AuthModeBoth = "Both (JWT + Password)"
|
AuthModeBoth = "Both (JWT + Password)"
|
||||||
AuthModeJWT = "JWT"
|
AuthModeJWT = "JWT"
|
||||||
@@ -102,3 +103,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."
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ DlgDeleteProfileMsg = '删除配置"{{.name}}"及其存储的凭据?'
|
|||||||
DlgProfileTitle = "配置"
|
DlgProfileTitle = "配置"
|
||||||
DlgValidationTitle = "验证"
|
DlgValidationTitle = "验证"
|
||||||
DlgValidationMsg = "名称、主机名和用户名为必填项。"
|
DlgValidationMsg = "名称、主机名和用户名为必填项。"
|
||||||
|
DlgInvalidIPMsg = "以下 IP 地址格式无效:{{.ips}}"
|
||||||
DlgDaemonError = "守护进程错误"
|
DlgDaemonError = "守护进程错误"
|
||||||
DlgCredentialError = "凭据错误"
|
DlgCredentialError = "凭据错误"
|
||||||
DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。"
|
DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。"
|
||||||
@@ -93,7 +94,7 @@ FieldMTUOverride = "MTU 覆盖"
|
|||||||
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
||||||
PlaceholderMTU = "0 = 使用服务器 MTU"
|
PlaceholderMTU = "0 = 使用服务器 MTU"
|
||||||
PlaceholderPasswordUnchanged = "(未更改)"
|
PlaceholderPasswordUnchanged = "(未更改)"
|
||||||
PlaceholderServerIPs = "例: 1.2.3.4, 5.6.7.8"
|
PlaceholderServerIPs = "支持 IPv4/IPv6,例: 1.2.3.4, 5.6.7.8"
|
||||||
|
|
||||||
AuthModeBoth = "全部(JWT + 密码)"
|
AuthModeBoth = "全部(JWT + 密码)"
|
||||||
AuthModeJWT = "JWT"
|
AuthModeJWT = "JWT"
|
||||||
@@ -102,3 +103,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
@@ -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.
|
||||||
|
|||||||
+25
-9
@@ -4,6 +4,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -41,6 +42,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"`
|
||||||
}
|
}
|
||||||
@@ -81,20 +86,31 @@ func (p *ServerProfile) BuildServerURL(ip ...string) string {
|
|||||||
return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path)
|
return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetServerIPList parses ServerIPs into a string slice.
|
// ValidateServerIPs parses ServerIPs, returning valid IP addresses
|
||||||
func (p *ServerProfile) GetServerIPList() []string {
|
// and any invalid entries (for UI error reporting).
|
||||||
|
func (p *ServerProfile) ValidateServerIPs() (valid []string, invalid []string) {
|
||||||
if p.ServerIPs == "" {
|
if p.ServerIPs == "" {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
parts := strings.Split(p.ServerIPs, ",")
|
for _, part := range strings.Split(p.ServerIPs, ",") {
|
||||||
var out []string
|
|
||||||
for _, part := range parts {
|
|
||||||
s := strings.TrimSpace(part)
|
s := strings.TrimSpace(part)
|
||||||
if s != "" {
|
if s == "" {
|
||||||
out = append(out, s)
|
continue
|
||||||
|
}
|
||||||
|
if net.ParseIP(s) != nil {
|
||||||
|
valid = append(valid, s)
|
||||||
|
} else {
|
||||||
|
invalid = append(invalid, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServerIPList returns only valid IP addresses from ServerIPs,
|
||||||
|
// silently filtering out any malformed entries.
|
||||||
|
func (p *ServerProfile) GetServerIPList() []string {
|
||||||
|
valid, _ := p.ValidateServerIPs()
|
||||||
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectionStatus records the outcome of a connection attempt.
|
// ConnectionStatus records the outcome of a connection attempt.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+104
-15
@@ -3,6 +3,7 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"lmvpn/internal/i18n"
|
"lmvpn/internal/i18n"
|
||||||
"lmvpn/internal/model"
|
"lmvpn/internal/model"
|
||||||
@@ -108,6 +109,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 +169,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 +183,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 +215,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 +243,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,35 +257,51 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ips != "" {
|
||||||
|
tmp := &model.ServerProfile{ServerIPs: ips}
|
||||||
|
_, invalid := tmp.ValidateServerIPs()
|
||||||
|
if len(invalid) > 0 {
|
||||||
|
showError(i18n.T("DlgValidationTitle"),
|
||||||
|
fmt.Sprintf(i18n.T("DlgInvalidIPMsg"), strings.Join(invalid, ", ")),
|
||||||
|
a.window)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
port := parseIntDefault(portStr, 443)
|
port := parseIntDefault(portStr, 443)
|
||||||
mtu := parseIntDefault(mtuStr, 0)
|
mtu := parseIntDefault(mtuStr, 0)
|
||||||
|
|
||||||
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 +327,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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-15
@@ -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,19 +185,45 @@ 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 fatal authentication failure (wrong password, disabled
|
// A TLS certificate verification failure on the original
|
||||||
// account, expired token, rate limit) is not retryable:
|
// hostname (ipIndex == 0) is not retryable: the cert won't
|
||||||
// stop the loop and surface the reason to the user instead
|
// change between attempts, so stop the loop and surface the
|
||||||
// of hammering the server forever.
|
// reason to the user. On a CDN edge IP (ipIndex > 0) the
|
||||||
if code, msg, isFatal := fatalAuthError(err); isFatal {
|
// TLS error likely means that IP points to a different
|
||||||
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
|
// server; skip it and try the next target.
|
||||||
sm.setState(stats.StateError)
|
if tlsconfig.IsTLSError(err) {
|
||||||
if sm.onError != nil {
|
if ipIndex == 0 {
|
||||||
sm.onError(string(code), msg)
|
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
|
||||||
}
|
}
|
||||||
fatal = true
|
log.L().Warn("TLS error on CDN IP, skipping",
|
||||||
sm.cleanup()
|
"index", ipIndex, "ip", targets[ipIndex], "error", err)
|
||||||
return
|
}
|
||||||
|
|
||||||
|
// A fatal authentication failure (wrong password, disabled
|
||||||
|
// account, expired token, rate limit) on the original
|
||||||
|
// hostname is not retryable. On a CDN edge IP it likely
|
||||||
|
// 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 {
|
||||||
|
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
|
||||||
|
sm.setState(stats.StateError)
|
||||||
|
if sm.onError != nil {
|
||||||
|
sm.onError(string(code), msg)
|
||||||
|
}
|
||||||
|
fatal = true
|
||||||
|
sm.cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.L().Warn("auth error on CDN IP, skipping",
|
||||||
|
"index", ipIndex, "ip", targets[ipIndex], "code", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
sm.setState(stats.StateReconnecting)
|
sm.setState(stats.StateReconnecting)
|
||||||
@@ -254,6 +301,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 +330,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 +354,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.
|
||||||
@@ -615,7 +685,14 @@ func wsURLToHTTP(wsURL string) (string, error) {
|
|||||||
|
|
||||||
// replaceHost substitutes the host portion of a URL string.
|
// replaceHost substitutes the host portion of a URL string.
|
||||||
// e.g. wss://host:443/ws with 1.2.3.4 → wss://1.2.3.4:443/ws
|
// e.g. wss://host:443/ws with 1.2.3.4 → wss://1.2.3.4:443/ws
|
||||||
|
// Bare IPv6 addresses are automatically bracketed:
|
||||||
|
// wss://host:443/ws with 2001:db8::1 → wss://[2001:db8::1]:443/ws
|
||||||
func replaceHost(rawURL, newHost string) string {
|
func replaceHost(rawURL, newHost string) string {
|
||||||
|
// Auto-bracket bare IPv6 addresses so the colons in the address
|
||||||
|
// are not confused with the port separator.
|
||||||
|
if ip := net.ParseIP(newHost); ip != nil && ip.To4() == nil && !strings.HasPrefix(newHost, "[") {
|
||||||
|
newHost = "[" + newHost + "]"
|
||||||
|
}
|
||||||
u := rawURL
|
u := rawURL
|
||||||
for _, prefix := range []string{"wss://", "ws://"} {
|
for _, prefix := range []string{"wss://", "ws://"} {
|
||||||
if len(u) > len(prefix) && u[:len(prefix)] == prefix {
|
if len(u) > len(prefix) && u[:len(prefix)] == prefix {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Reference in New Issue
Block a user