Compare commits
38
Commits
e867c8e248
...
v0.6.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b81b702433 | ||
|
|
7febed50ac | ||
|
|
4caaacb68f | ||
|
|
9b1cb668b1 | ||
|
|
b52db70015 | ||
|
|
1c4906c266 | ||
|
|
ebe082500f | ||
|
|
7c49ae4d72 | ||
|
|
7b4289ae00 | ||
|
|
15af9ef72c | ||
|
|
96b1a8839c | ||
|
|
c56fc0c21c | ||
|
|
bb4e4552d8 | ||
|
|
3e7df0f4d8 | ||
|
|
bf4744bb1d | ||
|
|
97cd9d4553 | ||
|
|
56c802c385 | ||
|
|
6026beb107 | ||
|
|
8c903860c2 | ||
|
|
7abbf9e6cc | ||
|
|
45b9fa24a1 | ||
|
|
4108adf1f7 | ||
|
|
6074dd5a73 | ||
|
|
3c076785f6 | ||
|
|
019df7bc23 | ||
|
|
9a1aca5d1b | ||
|
|
027d837cb2 | ||
|
|
0c5e996bfd | ||
|
|
65a5932eba | ||
|
|
6b9960076d | ||
|
|
56102589f6 | ||
|
|
6daa73fdb0 | ||
|
|
abcbb6ae68 | ||
|
|
9c16d804d9 | ||
|
|
e08480f609 | ||
|
|
4420532a66 | ||
|
|
a332857973 | ||
|
|
8f6daf9f49 |
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 wuwenfengmi1998
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -9,11 +9,14 @@ APP_BUNDLE = $(APP_NAME).app
|
||||
|
||||
GO = go
|
||||
CGO_ENABLED = 1
|
||||
WINDRES ?= x86_64-w64-mingw32-windres
|
||||
MINGW_CC ?= x86_64-w64-mingw32-gcc
|
||||
SEMVER ?= 0.6.5
|
||||
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
VERSION = 0.1.1-$(GIT_HASH)
|
||||
LDFLAGS = -s -w -X lmvpn/internal/ui.Version=$(VERSION)
|
||||
VERSION = $(SEMVER)-$(GIT_HASH)
|
||||
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION)
|
||||
|
||||
.PHONY: all build app run daemon clean vet tidy fmt icon
|
||||
.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows
|
||||
|
||||
## all: build the .app bundle (default)
|
||||
all: app
|
||||
@@ -32,6 +35,9 @@ app: build
|
||||
cp $(BUILD_DIR)/$(GUI_BIN) $(APP_BUNDLE)/Contents/MacOS/$(GUI_BIN)
|
||||
cp $(BUILD_DIR)/$(DAEMON_BIN) $(APP_BUNDLE)/Contents/MacOS/$(DAEMON_BIN)
|
||||
cp resources/Info.plist $(APP_BUNDLE)/Contents/Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $(SEMVER)" \
|
||||
-c "Set :CFBundleVersion $(GIT_HASH)" \
|
||||
$(APP_BUNDLE)/Contents/Info.plist
|
||||
@if [ -f resources/icon.icns ]; then \
|
||||
cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \
|
||||
else echo " (no icon.icns found, skipping icon)"; fi
|
||||
@@ -80,3 +86,36 @@ fmt:
|
||||
## clean: remove build artifacts
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) $(APP_BUNDLE)
|
||||
|
||||
## icon-windows: generate icon.ico and compile Windows resource (.syso)
|
||||
icon-windows:
|
||||
go run resources/genico/main.go
|
||||
$(WINDRES) -i resources/lmvpn.rc -O coff -o cmd/lmvpn/resource_windows_amd64.syso
|
||||
$(WINDRES) -i resources/lmvpn.rc -O coff -o cmd/lmvpnd/resource_windows_amd64.syso
|
||||
@echo "Generated Windows icon resources"
|
||||
|
||||
## build-windows: cross-compile Windows x86_64 exes (requires mingw-w64)
|
||||
## The .syso icon resources are committed in cmd/*/; run `make icon-windows` to regenerate.
|
||||
build-windows:
|
||||
mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=$(MINGW_CC) \
|
||||
$(GO) build -ldflags "$(LDFLAGS) -H windowsgui" -o $(BUILD_DIR)/$(GUI_BIN).exe ./cmd/lmvpn
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=$(MINGW_CC) \
|
||||
$(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"
|
||||
|
||||
## installer-windows: build exes and compile Inno Setup installer (.exe)
|
||||
## Requires ISCC on PATH (Windows) or Wine + Inno Setup 6 (macOS/Linux).
|
||||
installer-windows: build-windows
|
||||
@echo "Compiling installer with version $(VERSION)..."
|
||||
@if command -v ISCC >/dev/null 2>&1; then \
|
||||
ISCC /DAppVersion=$(VERSION) installer/lmvpn.iss; \
|
||||
elif command -v wine >/dev/null 2>&1; then \
|
||||
wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=$(VERSION) installer/lmvpn.iss; \
|
||||
else \
|
||||
echo "ERROR: ISCC not found. Install Inno Setup 6 on Windows,"; \
|
||||
echo " or install Wine + Inno Setup 6 on macOS/Linux."; \
|
||||
echo " Download: https://jrsoftware.org/isdl.php"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Installer: $(BUILD_DIR)/LMVPN-Setup-$(VERSION).exe"
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
# LMVPN Client
|
||||
|
||||
LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的**三层(网络层)VPN 客户端**。客户端通过 WebSocket(`ws://`/`wss://`)连接服务端,完成认证后在本地 TUN 虚拟网卡与 WebSocket 连接之间双向转发原始 IP 数据包,实现透明隧道。
|
||||
|
||||
客户端使用 [Go](https://go.dev/) + [Fyne](https://fyne.io/) 开发,支持 macOS、Windows、Linux 三大桌面平台。
|
||||
|
||||
> **服务端项目**:<https://github.com/wuwenfengmi1998/lmvpn_server>
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [功能特性](#功能特性)
|
||||
- [系统要求](#系统要求)
|
||||
- [各平台编译](#各平台编译)
|
||||
- [通用前置依赖](#通用前置依赖)
|
||||
- [macOS](#macos)
|
||||
- [Windows(交叉编译)](#windows交叉编译)
|
||||
- [Linux](#linux)
|
||||
- [Make 目标速查表](#make-目标速查表)
|
||||
- [运行使用](#运行使用)
|
||||
- [配置与数据目录](#配置与数据目录)
|
||||
- [架构概览](#架构概览)
|
||||
- [开发指南](#开发指南)
|
||||
- [注意事项](#注意事项)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **双进程架构**:GUI(`lmvpn`,普通用户)+ 守护进程(`lmvpnd`,root/管理员),自动拉起与生命周期管理
|
||||
- **多种认证**:JWT 令牌 / 用户名密码
|
||||
- **隧道模式**:全隧道、代理 CIDR(指定 CIDR 走隧道)、绕过 CIDR(指定 CIDR 绕过隧道),支持 IPv4/IPv6 分开配置与 URL 动态获取 CIDR 列表
|
||||
- **URL 获取时机**:代理前(直连获取,适用于 GitHub 等外部源)或代理后(通过隧道获取,适用于 VPN 服务器可达的源)
|
||||
- **注意**:绕过 CIDR 模式下"代理后获取"可能失败——/1 覆盖路由会将 HTTP 请求导入隧道,若 VPN 服务器无法访问目标 URL 则超时。建议将 GitHub 等外部源设置为"代理前获取"
|
||||
- **CIDR 聚合**:自动合并相邻 CIDR 块以减少路由数量,配合批量脚本并行执行加速路由添加
|
||||
- **实时统计**:状态栏显示路由模式、CIDR 命中数、加载进度;支持手动刷新 CIDR 列表
|
||||
- **多服务器管理**:配置文件 + SQLite 存储多个服务器配置(Profile)
|
||||
- **国际化**:中文(简体)、英文,跟随系统语言
|
||||
- **安全存储**:macOS Keychain / Windows Credential Manager 加密保存凭据
|
||||
- **系统集成**:系统托盘图标、Dock 显隐、开机自启
|
||||
- **日志轮转**:基于 lumberjack 的文件日志自动轮转
|
||||
|
||||
---
|
||||
|
||||
## 系统要求
|
||||
|
||||
| 平台 | 最低版本 | 架构 | 备注 |
|
||||
|------|---------|------|------|
|
||||
| macOS | 11.0(Big Sur) | amd64 / arm64 | 需 Xcode Command Line Tools |
|
||||
| Windows | 10 | x86_64 | 仅支持 64 位,需 WinTun 驱动(已内置) |
|
||||
| Linux | 现代发行版 | amd64 / arm64 | 需 OpenGL/GLFW/libdbus 开发库 |
|
||||
|
||||
**所有平台均要求 `CGO_ENABLED=1`**(Fyne 依赖 OpenGL/GLFW 的 CGO 绑定,不可关闭)。
|
||||
|
||||
---
|
||||
|
||||
## 各平台编译
|
||||
|
||||
### 通用前置依赖
|
||||
|
||||
- **Go 1.26** 或更高版本(见 `go.mod`)
|
||||
- **Git**:用于在编译时注入版本号,格式为 `0.3.7-<git短哈希>`(如 `0.3.7-019df7b`)
|
||||
- **C 编译器**:GCC(Linux/Windows 交叉编译)或 clang(macOS),由 CGO 调用
|
||||
- **网络访问**:首次构建需拉取 Go 模块依赖
|
||||
|
||||
版本号通过 `-ldflags "-X lmvpn/internal/version.Version=$(VERSION)"` 在链接期注入到 `internal/version` 包,GUI 与守护进程共享同一版本字符串。
|
||||
|
||||
### macOS
|
||||
|
||||
#### 依赖
|
||||
|
||||
- **Xcode Command Line Tools**:提供 clang(CGO)、Cocoa 框架、`sips`、`iconutil`(图标生成)
|
||||
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
- 最低部署目标 macOS 11.0(见 `resources/Info.plist` 的 `LSMinimumSystemVersion`)
|
||||
|
||||
#### 编译命令
|
||||
|
||||
```bash
|
||||
# 默认目标:编译二进制并组装 LMVPN.app bundle
|
||||
make # 等价于 make app
|
||||
|
||||
# 仅编译裸二进制(build/lmvpn、build/lmvpnd),不打包 .app
|
||||
make build
|
||||
|
||||
# 重新生成 macOS 图标 resources/icon.icns(需要 icon.png 或 logo.svg)
|
||||
make icon
|
||||
|
||||
# 编译并直接运行 GUI
|
||||
make run
|
||||
```
|
||||
|
||||
`make app` 会将 `build/lmvpn`、`build/lmvpnd` 连同 `resources/Info.plist`、`resources/icon.icns` 组装成 `LMVPN.app/`。
|
||||
|
||||
#### 运行
|
||||
|
||||
守护进程创建 TUN 网卡与修改路由需要 root 权限,GUI 通过 `osascript ... with administrator privileges` 弹出系统授权对话框提权拉起守护进程。日常使用直接双击 `LMVPN.app` 即可。
|
||||
|
||||
---
|
||||
|
||||
### Windows(交叉编译)
|
||||
|
||||
Windows 版本从 macOS 或 Linux **交叉编译**生成(Fyne 无法用 `CGO_ENABLED=0` 构建,因此必须借助 mingw-w64 提供 C 交叉编译器)。仅支持 **x86_64** 架构。
|
||||
|
||||
#### 依赖
|
||||
|
||||
- **mingw-w64 工具链**:提供 `x86_64-w64-mingw32-gcc`(C 编译器)与 `x86_64-w64-mingw32-windres`(资源编译器)
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install mingw-w64
|
||||
```
|
||||
|
||||
- **Inno Setup 6**(仅打包安装程序时需要):用于生成 `.exe` 安装包
|
||||
- 原生 Windows:将 `ISCC` 加入 `PATH`
|
||||
- macOS/Linux:通过 Wine 调用,需安装 Wine 并将 Inno Setup 6 装到 Wine 的 `C:\Program Files (x86)\Inno Setup 6\`
|
||||
|
||||
#### 编译命令
|
||||
|
||||
```bash
|
||||
# 1. 生成 Windows 图标与资源(.ico + .syso),并交叉编译 exe
|
||||
make build-windows
|
||||
|
||||
# 2. 单独生成图标资源(生成 resource_windows_amd64.syso,会被 go build 自动链接)
|
||||
make icon-windows
|
||||
|
||||
# 3. 编译并打包 Inno Setup 安装程序
|
||||
make installer-windows
|
||||
```
|
||||
|
||||
`make build-windows` 实际执行:
|
||||
|
||||
```bash
|
||||
# GUI(带 -H windowsgui,无控制台窗口)
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \
|
||||
go build -ldflags "-s -w -X ... -H windowsgui" -o build/lmvpn.exe ./cmd/lmvpn
|
||||
|
||||
# 守护进程(控制台程序)
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \
|
||||
go build -ldflags "-s -w -X ..." -o build/lmvpnd.exe ./cmd/lmvpnd
|
||||
```
|
||||
|
||||
产物:`build/lmvpn.exe`、`build/lmvpnd.exe`,安装包 `build/LMVPN-Setup-<version>.exe`。
|
||||
|
||||
#### 关于 WinTun
|
||||
|
||||
Windows 版使用 [WinTun](https://www.wintun.net/) 驱动创建虚拟网卡。`wintun.dll` 在编译期通过 `//go:embed` 嵌入二进制,运行时释放到 exe 同级目录;安装包也会单独安装一份 `wintun.dll`。
|
||||
|
||||
#### 运行
|
||||
|
||||
守护进程需要管理员权限,GUI 通过 UAC(`ShellExecuteW` + `runas`)提权拉起。
|
||||
|
||||
---
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 没有独立的 Make 目标,使用平台无关的 `make build` 原生编译。
|
||||
|
||||
#### 依赖
|
||||
|
||||
- **GCC**(CGO 编译器)
|
||||
- **OpenGL 与 GLFW 开发库**(Fyne 依赖):`libgl`、`libglfw`、`libx11`、xorg 开发头
|
||||
- **libdbus-1 开发库**(系统托盘,经 `godbus/dbus`):`libdbus-1-dev` 或对应开发包
|
||||
|
||||
> 请根据所用发行版(apt / dnf / pacman 等)自行安装上述开发库。
|
||||
|
||||
#### 编译命令
|
||||
|
||||
```bash
|
||||
# 原生编译(平台无关,直接产出 build/lmvpn、build/lmvpnd)
|
||||
make build
|
||||
|
||||
# 编译并运行 GUI
|
||||
make run
|
||||
|
||||
# 以 root 运行守护进程(调试用)
|
||||
make daemon
|
||||
```
|
||||
|
||||
#### 运行
|
||||
|
||||
- TUN 网卡创建与路由配置需要 **root 或 `CAP_NET_ADMIN`** 能力
|
||||
- 提权方式:`pkexec`(`internal/ui/elevation_other.go`)
|
||||
- 系统命令依赖:`ip`、`ip route`(`internal/route/route_linux.go`、`internal/tun/tun_linux.go`)
|
||||
- **密钥链**:Linux 暂使用内存存储(不持久化),重启后凭据丢失,后续需接入 Secret Service 等后端
|
||||
|
||||
---
|
||||
|
||||
### Make 目标速查表
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| `make` / `make all` / `make app` | 编译并组装 macOS `LMVPN.app` bundle(默认) |
|
||||
| `make build` | 仅编译 `lmvpn` + `lmvpnd` 裸二进制(macOS/Linux 原生) |
|
||||
| `make run` | 编译并运行 GUI |
|
||||
| `make daemon` | 编译并以 `sudo` 运行守护进程 |
|
||||
| `make build-windows` | 交叉编译 Windows x64 exe(含图标资源生成) |
|
||||
| `make icon-windows` | 生成 Windows `.ico` 与 `.syso` 资源文件 |
|
||||
| `make installer-windows` | 编译 exe 并打包 Inno Setup 安装程序 |
|
||||
| `make icon` | 由 `icon.png`/`logo.svg` 重新生成 macOS `icon.icns` |
|
||||
| `make vet` | 运行 `go vet ./...` |
|
||||
| `make fmt` | 运行 `go fmt ./...` |
|
||||
| `make tidy` | 运行 `go mod tidy` |
|
||||
| `make clean` | 清理 `build/` 与 `LMVPN.app/` |
|
||||
|
||||
---
|
||||
|
||||
## 运行使用
|
||||
|
||||
GUI 会自动管理守护进程的生命周期(拉起、停止),无需手动启动 `lmvpnd`。
|
||||
|
||||
- **macOS**:双击 `LMVPN.app`,首次连接时系统弹出授权对话框输入密码以提权守护进程
|
||||
- **Windows**:运行安装包或直接运行 `lmvpn.exe`,首次连接时 UAC 弹窗确认提权
|
||||
- **Linux**:`make run` 或运行编译好的 `lmvpn`,首次连接时 `pkexec` 弹窗输入密码提权
|
||||
|
||||
GUI 与守护进程通过本地 IPC 通信:macOS/Linux 使用 Unix socket `/tmp/lmvpn.sock`;Windows 使用 TCP `127.0.0.1:18923`(因 Windows 上 AF_UNIX 有完整性级别校验,会阻止非提权 GUI 访问提权守护进程的 socket)。
|
||||
|
||||
---
|
||||
|
||||
## 配置与数据目录
|
||||
|
||||
各平台的数据目录布局(Bundle ID 为 `com.lmvpn.client`):
|
||||
|
||||
| 平台 | 配置/数据 | 缓存 | 日志 |
|
||||
|------|----------|------|------|
|
||||
| macOS | `~/Library/Application Support/com.lmvpn.client/` | `~/Library/Caches/com.lmvpn.client/` | `~/Library/Logs/com.lmvpn.client/` |
|
||||
| Windows | `%APPDATA%\com.lmvpn.client\` | `%LOCALAPPDATA%\com.lmvpn.client\` | 同数据目录下 `log/` |
|
||||
| Linux | `~/.local/share/com.lmvpn.client/` | `~/.cache/com.lmvpn.client/` | `~/.local/state/com.lmvpn.client/log/` |
|
||||
|
||||
- 配置文件支持 TOML 与 YAML 两种格式(`internal/config`)
|
||||
- 服务器配置(Profile)存储于 SQLite 数据库(`internal/db`,使用纯 Go 的 `modernc.org/sqlite`,无需 CGO)
|
||||
- 凭据保存于系统密钥链(macOS Keychain / Windows Credential Manager / Linux 内存)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
### 双进程设计
|
||||
|
||||
```
|
||||
┌──────────────┐ IPC (Unix socket / TCP) ┌──────────────┐
|
||||
│ lmvpn │ ◄─────────────────────────► │ lmvpnd │
|
||||
│ (GUI) │ 控制命令 / 状态 │ (守护进程) │
|
||||
│ Fyne UI │ │ WebSocket │
|
||||
│ 普通用户 │ │ TUN 网卡 │
|
||||
│ │ │ root/管理员 │
|
||||
└──────────────┘ └──────┬───────┘
|
||||
│
|
||||
WebSocket (ws/wss)
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ LMVPN 服务端 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
拆分 GUI 与守护进程的原因:避免 Fyne(及其 locale/字体初始化)加载进 root 进程,同时让提权范围最小化——仅守护进程需要 root 权限操作网卡与路由。
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
lmvpn_client/
|
||||
├── cmd/
|
||||
│ ├── lmvpn/ # GUI 入口
|
||||
│ └── lmvpnd/ # 守护进程入口
|
||||
├── internal/
|
||||
│ ├── auth/ # 认证(JWT / 用户名密码)
|
||||
│ ├── config/ # 配置文件解析(TOML / YAML)
|
||||
│ ├── daemon/ # 守护进程生命周期、IPC、提权拉起
|
||||
│ ├── db/ # SQLite 存储、Profile、日志记录
|
||||
│ ├── i18n/ # 国际化(en / zh-Hans)
|
||||
│ ├── ipc/ # GUI ↔ 守护进程 IPC 协议
|
||||
│ ├── keychain/ # 密钥链(darwin/windows/other)
|
||||
│ ├── log/ # 日志(lumberjack 轮转)
|
||||
│ ├── model/ # 数据模型
|
||||
│ ├── paths/ # 平台路径解析(darwin/windows/other)
|
||||
│ ├── protocol/ # 与服务端的 WebSocket 协议
|
||||
│ ├── route/ # 路由管理(全隧道/代理CIDR/绕过CIDR)
|
||||
│ ├── stats/ # 流量统计
|
||||
│ ├── transport/ # WebSocket 传输层
|
||||
│ ├── tun/ # TUN 虚拟网卡(darwin/linux/windows)
|
||||
│ ├── ui/ # Fyne 界面、托盘、提权、Dock
|
||||
│ ├── version/ # 版本号(链接期注入)
|
||||
│ └── vpn/ # VPN 会话管理
|
||||
├── resources/ # 图标、Info.plist、wintun.dll、资源生成器
|
||||
├── installer/ # Inno Setup 安装脚本(lmvpn.iss)
|
||||
├── docs/ # 开发文档(协议规范)
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
### 平台适配约定
|
||||
|
||||
项目使用 Go 文件后缀构建约束实现平台适配,主要分布:
|
||||
|
||||
| 模块 | macOS | Windows | Linux |
|
||||
|------|-------|---------|-------|
|
||||
| `tun` | `tun_darwin.go`(water/utun) | `tun_windows.go`(wintun + embed dll) | `tun_linux.go`(water/tun) |
|
||||
| `route` | `route_darwin.go`(`route`) | `route_windows.go`(`route`) | `route_linux.go`(`ip route`) |
|
||||
| `keychain` | `keychain_darwin.go`(Keychain) | `keychain_windows.go`(Credential Manager) | `keychain_other.go`(内存) |
|
||||
| `paths` | `paths_darwin.go` | `paths_windows.go` | `paths_other.go`(XDG) |
|
||||
| `daemon` 提权 | `launch_unix.go` + osascript | `launch_windows.go` + UAC runas | `launch_unix.go` + pkexec |
|
||||
| `ui` Dock | `dock_darwin.go`(Cocoa CGO) | `dock_other.go`(no-op) | `dock_other.go`(no-op) |
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
```bash
|
||||
# 静态检查
|
||||
make vet
|
||||
|
||||
# 格式化
|
||||
make fmt
|
||||
|
||||
# 整理依赖
|
||||
make tidy
|
||||
|
||||
# 清理构建产物
|
||||
make clean
|
||||
```
|
||||
|
||||
### 添加新平台适配
|
||||
|
||||
如需适配新的平台能力,参考现有约定新增带构建约束的文件,例如:
|
||||
|
||||
- TUN 实现 → `internal/tun/tun_<os>.go`
|
||||
- 路由命令 → `internal/route/route_<os>.go`
|
||||
- 密钥存储 → `internal/keychain/keychain_<os>.go`
|
||||
- 路径解析 → `internal/paths/paths_<os>.go`
|
||||
|
||||
### 协议规范
|
||||
|
||||
客户端与服务端的完整通信协议见 [`docs/client-development.md`](docs/client-development.md)(含认证、握手、数据面、心跳、错误码等)。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **不支持移动端**:虽 Fyne 支持 Android/iOS,本项目未配置移动端构建目标与适配代码
|
||||
- **Windows 仅 x64**:交叉编译仅目标 `GOARCH=amd64`,内置的 `wintun.dll` 为 x64 版本
|
||||
- **Linux 密钥链待完善**:当前为内存存储,凭据不持久化,生产环境建议接入 Secret Service
|
||||
- **CI/CD**:通过 GitHub Actions 自动构建 macOS / Windows 产物并发布 Release(见 [`.github/workflows/release.yml`](.github/workflows/release.yml)),打 `v*` tag 即触发
|
||||
- **CGO 必开**:任何平台都不可关闭 CGO,否则 Fyne 无法编译
|
||||
- **版本一致性**:GUI 与守护进程共享同一版本字符串,若版本不一致通常意味着旧守护进程仍在运行
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
详见仓库 LICENSE 文件。
|
||||
Binary file not shown.
Binary file not shown.
+80
-23
@@ -294,7 +294,7 @@ sequenceDiagram
|
||||
|
||||
### 5.2 IP 分配规则
|
||||
|
||||
服务端从 VPN 子网中分配客户端内网 IP(`internal/vpn/alloc.go:39-66`、`internal/vpn/service.go:47-57`):
|
||||
服务端从 VPN 子网中分配客户端内网 IP(`internal/vpn/alloc.go:39-66`、`internal/vpn/service.go:47-58`):
|
||||
|
||||
| 地址 | 分配规则 | 说明 |
|
||||
|------|----------|------|
|
||||
@@ -308,9 +308,19 @@ sequenceDiagram
|
||||
- 若该用户有预留 IP,优先使用预留;预留被占用时报错(`alloc.go:43-48`)
|
||||
- 地址耗尽时报错 "可用 IP 地址已耗尽"(`alloc.go:65`)
|
||||
|
||||
#### IPv6 双栈
|
||||
|
||||
当服务端配置了 IPv6 子网(`Subnet6`)时,客户端同时获得 IPv4 和 IPv6 地址:
|
||||
|
||||
- IPv4 地址始终分配(`Subnet` 必填)
|
||||
- IPv6 地址仅当 `Subnet6` 非空时分配(可选)
|
||||
- IPv6 预留独立于 IPv4 预留,可单独配置
|
||||
- IPv6 子网前缀限制:`/64` ~ `/126`
|
||||
- 对于 `/64` 等大子网,`cidr.AddressCount` 会溢出,实际扫描上限为 65536 个地址
|
||||
|
||||
### 5.3 init 消息
|
||||
|
||||
前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:126-137`):
|
||||
前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:132-148`):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -318,17 +328,25 @@ sequenceDiagram
|
||||
"ip": "10.0.0.5",
|
||||
"prefix": 24,
|
||||
"mtu": 1420,
|
||||
"server_ip": "10.0.0.1"
|
||||
"server_ip": "10.0.0.1",
|
||||
"ip6": "fd00:dead:beef::5",
|
||||
"prefix6": 112,
|
||||
"server_ip6": "fd00:dead:beef::1"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 | 源码 |
|
||||
|------|------|------|------|
|
||||
| `type` | string | 固定值 `"init"` | `internal/vpn/protocol.go:3-9` |
|
||||
| `ip` | string | 分配给客户端的内网 IP(点分十进制) | `tunnel.go:130` |
|
||||
| `prefix` | int | 子网前缀长度(如 `24`) | `tunnel.go:131` |
|
||||
| `mtu` | int | TUN 网卡 MTU | `tunnel.go:132` |
|
||||
| `server_ip` | string | 服务器内网 IP(对端地址) | `tunnel.go:133` |
|
||||
| 字段 | 类型 | 必填 | 说明 | 源码 |
|
||||
|------|------|------|------|------|
|
||||
| `type` | string | 是 | 固定值 `"init"` | `internal/vpn/protocol.go:3-10` |
|
||||
| `ip` | string | 是 | 分配给客户端的 IPv4 地址 | `tunnel.go:136` |
|
||||
| `prefix` | int | 是 | IPv4 子网前缀长度(如 `24`) | `tunnel.go:137` |
|
||||
| `mtu` | int | 是 | TUN 网卡 MTU | `tunnel.go:138` |
|
||||
| `server_ip` | string | 是 | 服务器 IPv4 地址 | `tunnel.go:139` |
|
||||
| `ip6` | string | 否 | 分配给客户端的 IPv6 地址(仅当服务端配置了 IPv6 子网时存在) | `tunnel.go:141-144` |
|
||||
| `prefix6` | int | 否 | IPv6 子网前缀长度 | `tunnel.go:142` |
|
||||
| `server_ip6` | string | 否 | 服务器 IPv6 地址 | `tunnel.go:143` |
|
||||
|
||||
> ℹ️ `ip6`/`prefix6`/`server_ip6` 字段使用 `omitempty`,旧客户端可安全忽略。若服务端未配置 IPv6 子网,这三个字段不会出现。
|
||||
|
||||
### 5.4 ready 消息与超时
|
||||
|
||||
@@ -412,14 +430,14 @@ sequenceDiagram
|
||||
|
||||
### 6.4 反欺骗(Anti-Spoofing)
|
||||
|
||||
服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-116`):
|
||||
服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-126`):
|
||||
|
||||
```
|
||||
if 源IP != 该客户端被分配的IP:
|
||||
丢弃该包(不转发,不写入TUN,不断开连接)
|
||||
```
|
||||
- **IPv4 包**:源地址必须等于客户端被分配的 IPv4 地址(`init.ip`)
|
||||
- **IPv6 包**:源地址必须等于客户端被分配的 IPv6 地址(`init.ip6`)
|
||||
|
||||
> ⚠️ 客户端必须确保 TUN 网卡只发送源地址为 `init.ip` 的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃。
|
||||
不匹配的包将被静默丢弃(不转发、不写入 TUN、不断开连接)。
|
||||
|
||||
> ⚠️ 客户端必须确保 TUN 网卡只发送源地址与 `init.ip` / `init.ip6` 匹配的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃。
|
||||
|
||||
### 6.5 非 IP 二进制帧
|
||||
|
||||
@@ -488,9 +506,11 @@ conn.WriteControl(websocket.PingMessage, nil, ...)
|
||||
|
||||
| 约束 | 值 | 源码 |
|
||||
|------|----|----|
|
||||
| IP 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` |
|
||||
| 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` |
|
||||
| IPv4 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` |
|
||||
| IPv4 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` |
|
||||
| IPv6 前缀长度 | /64 ~ /126 | `internal/handler/vpn.go:81-92` |
|
||||
| 可用容量 | `AddressCount - 3` | `internal/vpn/alloc.go:106-112` |
|
||||
| IPv6 大子网容量 | ~65533(/64 等大子网 AddressCount 溢出时) | `internal/vpn/alloc.go:108-110` |
|
||||
|
||||
### 8.4 MTU 约束
|
||||
|
||||
@@ -516,7 +536,7 @@ type controlMessage struct {
|
||||
}
|
||||
```
|
||||
|
||||
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-9`):
|
||||
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-10`):
|
||||
|
||||
```go
|
||||
type initMessage struct {
|
||||
@@ -525,6 +545,9 @@ type initMessage struct {
|
||||
Prefix int `json:"prefix"`
|
||||
MTU int `json:"mtu"`
|
||||
ServerIP string `json:"server_ip"`
|
||||
IP6 string `json:"ip6,omitempty"`
|
||||
Prefix6 int `json:"prefix6,omitempty"`
|
||||
ServerIP6 string `json:"server_ip6,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
@@ -578,7 +601,7 @@ type initMessage struct {
|
||||
|------|----------------|------|------|------|
|
||||
| `auth_ok` | Text | 密码认证成功(仅方式 B) | JSON | `{"type":"auth_ok"}` |
|
||||
| `auth_err` | Text | 认证失败 | JSON | `{"type":"auth_err","message":"..."}` |
|
||||
| `init` | Text | 认证成功且前置检查通过 | JSON | `{"type":"init","ip":"...","prefix":24,"mtu":1420,"server_ip":"..."}` |
|
||||
| `init` | Text | 认证成功且前置检查通过 | JSON | `{"type":"init","ip":"...","prefix":24,"mtu":1420,"server_ip":"...","ip6":"...","prefix6":112,"server_ip6":"..."}` |
|
||||
| `error` | Text | 握手阶段失败 | JSON | `{"type":"error","message":"..."}` |
|
||||
| 数据帧 | Binary | `ready` 之后,下行 IP 包 | 原始 IP 包 | (二进制) |
|
||||
| Ping | WebSocket Ping | 每 30 秒 | 空 | (WebSocket 协议层) |
|
||||
@@ -593,9 +616,11 @@ type initMessage struct {
|
||||
|
||||
| 配置项 | 取值来源 | 说明 |
|
||||
|--------|----------|------|
|
||||
| TUN 网卡地址 | `init.ip` / `init.prefix` | 如 `10.0.0.5/24` |
|
||||
| TUN 网卡地址 (IPv4) | `init.ip` / `init.prefix` | 如 `10.0.0.5/24` |
|
||||
| TUN 网卡地址 (IPv6) | `init.ip6` / `init.prefix6` | 如 `fd00:dead:beef::5/112`(可选,仅当 init 含 ip6 时) |
|
||||
| MTU | `init.mtu` | 如 `1420` |
|
||||
| 对端地址 / 默认路由网关 | `init.server_ip` | 如 `10.0.0.1` |
|
||||
| 对端地址 / 默认路由网关 (IPv4) | `init.server_ip` | 如 `10.0.0.1` |
|
||||
| 对端地址 (IPv6) | `init.server_ip6` | 如 `fd00:dead:beef::1`(可选) |
|
||||
|
||||
### 11.2 Linux
|
||||
|
||||
@@ -608,10 +633,17 @@ ip addr add dev <tun> <ip>/<prefix> peer <server_ip>
|
||||
ip link set dev <tun> mtu <mtu>
|
||||
```
|
||||
|
||||
IPv6(仅当 init 含 `ip6` 时):
|
||||
|
||||
```
|
||||
ip addr add dev <tun> <ip6>/<prefix6>
|
||||
```
|
||||
|
||||
路由:若需将所有流量经 VPN,添加默认路由:
|
||||
|
||||
```
|
||||
ip route add 0.0.0.0/0 dev <tun>
|
||||
ip route add ::/0 dev <tun> # IPv6 默认路由(可选)
|
||||
```
|
||||
|
||||
### 11.3 macOS
|
||||
@@ -623,10 +655,17 @@ ifconfig <utun> inet <ip>/<prefix> <server_ip> up
|
||||
ifconfig <utun> mtu <mtu>
|
||||
```
|
||||
|
||||
IPv6(仅当 init 含 `ip6` 时):
|
||||
|
||||
```
|
||||
ifconfig <utun> inet6 <ip6>/<prefix6> up
|
||||
```
|
||||
|
||||
路由:
|
||||
|
||||
```
|
||||
route add -inet -net 0.0.0.0/0 -interface <utun>
|
||||
route add -inet6 -net ::/0 -interface <utun> # IPv6 默认路由(可选)
|
||||
```
|
||||
|
||||
> macOS 的 utun 接口由系统分配编号(如 `utun4`),客户端通常无法指定名称。
|
||||
@@ -670,6 +709,12 @@ Linux 服务端须开启 IP 转发(`internal/vpn/diag_linux.go:53-60`):
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
```
|
||||
|
||||
IPv6 双栈场景还须开启 IPv6 转发(`internal/vpn/diag_linux.go:116-122`):
|
||||
|
||||
```
|
||||
sysctl -w net.ipv6.conf.all.forwarding=1
|
||||
```
|
||||
|
||||
### 12.2 NAT 伪装
|
||||
|
||||
服务端须配置 NAT masquerade,将客户端源 IP 转换为服务器物理网卡 IP(`internal/vpn/diag_linux.go:80-113`)。
|
||||
@@ -688,7 +733,19 @@ nft add rule ip nat postrouting oifname <物理网卡> masquerade
|
||||
iptables -t nat -A POSTROUTING -o <物理网卡> -j MASQUERADE
|
||||
```
|
||||
|
||||
> 服务端诊断接口 `GET /api/admin/vpn/diag`(`internal/handler/vpn.go:190-192`)会检测上述配置,客户端无法上网时可请管理员查看诊断结果。
|
||||
IPv6 NAT66(仅双栈场景需要):
|
||||
|
||||
**nft**:
|
||||
```
|
||||
nft add rule inet lmvpn_nat postrouting oifname <物理网卡> ip6 saddr <VPN_V6_SUBNET> masquerade
|
||||
```
|
||||
|
||||
**ip6tables(回退)**:
|
||||
```
|
||||
ip6tables -t nat -A POSTROUTING -s <VPN_V6_SUBNET> -o <物理网卡> -j MASQUERADE
|
||||
```
|
||||
|
||||
> 服务端诊断接口 `GET /api/admin/vpn/diag`(`internal/handler/vpn.go:190-192`)会检测上述配置(含 IPv6),客户端无法上网时可请管理员查看诊断结果。
|
||||
|
||||
### 12.3 子网与 MTU
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ go 1.26
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/danieljoos/wincred v1.2.3
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.34.1
|
||||
@@ -54,7 +56,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sys v0.46.0
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
|
||||
@@ -4,6 +4,8 @@ fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
|
||||
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -83,6 +85,8 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiY
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
@@ -102,6 +106,8 @@ golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.24.1 h1:vxuHLTNS3Np5zrYoPRpcheASHX/7KiGo+8Y4ZM1J2O8=
|
||||
golang.org/x/tools v0.24.1/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
|
||||
;
|
||||
; To download user-contributed translations of this file, go to:
|
||||
; https://jrsoftware.org/files/istrans/
|
||||
;
|
||||
; Note: When translating this text, do not add periods (.) to the end of
|
||||
; messages that didn't have them already, because on those messages Inno
|
||||
; Setup adds the periods automatically (appending a period would result in
|
||||
; two periods being displayed).
|
||||
;
|
||||
; Maintainer: Zhenghan Yang (Kira)
|
||||
; Email: 847320916@QQ.com
|
||||
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
; Encoding: UTF-8
|
||||
; Translation based on network resource
|
||||
;
|
||||
|
||||
[LangOptions]
|
||||
; The following three entries are very important. Be sure to read and
|
||||
; understand the '[LangOptions] section' topic in the help file.
|
||||
LanguageName=简体中文
|
||||
; About LanguageID, to reference link:
|
||||
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
|
||||
LanguageID=$0804
|
||||
; LanguageCodePage should always be set if possible, even if this file is Unicode
|
||||
; For English it's set to zero anyway because English only uses ASCII characters
|
||||
LanguageCodePage=936
|
||||
; If the language you are translating to requires special font faces or
|
||||
; sizes, uncomment any of the following entries and change them accordingly.
|
||||
;DialogFontName=
|
||||
;DialogFontSize=9
|
||||
;DialogFontBaseScaleWidth=7
|
||||
;DialogFontBaseScaleHeight=15
|
||||
;WelcomeFontName=Segoe UI
|
||||
;WelcomeFontSize=14
|
||||
|
||||
[Messages]
|
||||
|
||||
; *** Application titles
|
||||
SetupAppTitle=安装
|
||||
SetupWindowTitle=安装 - %1
|
||||
UninstallAppTitle=卸载
|
||||
UninstallAppFullTitle=%1 卸载
|
||||
|
||||
; *** Misc. common
|
||||
InformationTitle=信息
|
||||
ConfirmTitle=确认
|
||||
ErrorTitle=错误
|
||||
|
||||
; *** SetupLdr messages
|
||||
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
|
||||
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
|
||||
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
|
||||
HelpTextNote=
|
||||
|
||||
; *** Startup error messages
|
||||
LastErrorMessage=%1。%n%n错误 %2: %3
|
||||
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
|
||||
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
|
||||
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
|
||||
InvalidParameter=无效的命令行参数:%n%n%1
|
||||
SetupAlreadyRunning=安装程序已在运行。
|
||||
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
|
||||
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
|
||||
NotOnThisPlatform=此程序不能在 %1 上运行。
|
||||
OnlyOnThisPlatform=此程序只能在 %1 上运行。
|
||||
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
|
||||
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
|
||||
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
|
||||
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
|
||||
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。
|
||||
SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
|
||||
; *** Startup questions
|
||||
PrivilegesRequiredOverrideTitle=选择安装程序安装模式
|
||||
PrivilegesRequiredOverrideInstruction=选择安装模式
|
||||
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
|
||||
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
|
||||
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
|
||||
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐)
|
||||
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
|
||||
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐)
|
||||
|
||||
; *** Misc. errors
|
||||
ErrorCreatingDir=安装程序无法创建目录“%1”
|
||||
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
|
||||
|
||||
; *** Setup common messages
|
||||
ExitSetupTitle=退出安装程序
|
||||
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
|
||||
AboutSetupMenuItem=关于安装程序(&A)...
|
||||
AboutSetupTitle=关于安装程序
|
||||
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
|
||||
AboutSetupNote=
|
||||
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
|
||||
; *** Buttons
|
||||
ButtonBack=< 上一步(&B)
|
||||
ButtonNext=下一步(&N) >
|
||||
ButtonInstall=安装(&I)
|
||||
ButtonOK=确定
|
||||
ButtonCancel=取消
|
||||
ButtonYes=是(&Y)
|
||||
ButtonYesToAll=全是(&A)
|
||||
ButtonNo=否(&N)
|
||||
ButtonNoToAll=全否(&O)
|
||||
ButtonFinish=完成(&F)
|
||||
ButtonBrowse=浏览(&B)...
|
||||
ButtonWizardBrowse=浏览(&R)...
|
||||
ButtonNewFolder=新建文件夹(&M)
|
||||
|
||||
; *** "Select Language" dialog messages
|
||||
SelectLanguageTitle=选择安装语言
|
||||
SelectLanguageLabel=选择安装时使用的语言。
|
||||
|
||||
; *** Common wizard text
|
||||
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
|
||||
BeveledLabel=
|
||||
BrowseDialogTitle=浏览文件夹
|
||||
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
|
||||
NewFolderName=新建文件夹
|
||||
|
||||
; *** "Welcome" wizard page
|
||||
WelcomeLabel1=欢迎使用 [name] 安装向导
|
||||
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
|
||||
|
||||
; *** "Password" wizard page
|
||||
WizardPassword=密码
|
||||
PasswordLabel1=此安装程序需要密码验证。
|
||||
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
|
||||
PasswordEditLabel=密码(&P):
|
||||
IncorrectPassword=您输入的密码不正确,请重新输入。
|
||||
|
||||
; *** "License Agreement" wizard page
|
||||
WizardLicense=许可协议
|
||||
LicenseLabel=请在继续安装前阅读以下重要信息。
|
||||
LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。
|
||||
LicenseAccepted=我同意此协议(&A)
|
||||
LicenseNotAccepted=我不同意此协议(&D)
|
||||
|
||||
; *** "Information" wizard pages
|
||||
WizardInfoBefore=信息
|
||||
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
|
||||
WizardInfoAfter=信息
|
||||
InfoAfterLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
|
||||
|
||||
; *** "User Information" wizard page
|
||||
WizardUserInfo=用户信息
|
||||
UserInfoDesc=请输入您的信息。
|
||||
UserInfoName=用户名(&U):
|
||||
UserInfoOrg=组织(&O):
|
||||
UserInfoSerial=序列号(&S):
|
||||
UserInfoNameRequired=请输入用户名。
|
||||
|
||||
; *** "Select Destination Location" wizard page
|
||||
WizardSelectDir=选择目标位置
|
||||
SelectDirDesc=您想将 [name] 安装在哪里?
|
||||
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
|
||||
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
|
||||
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
|
||||
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
|
||||
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
|
||||
InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share
|
||||
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
|
||||
DiskSpaceWarningTitle=磁盘空间不足
|
||||
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗?
|
||||
DirNameTooLong=文件夹名称或路径太长。
|
||||
InvalidDirName=文件夹名称无效。
|
||||
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
|
||||
DirExistsTitle=文件夹已存在
|
||||
DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗?
|
||||
DirDoesntExistTitle=文件夹不存在
|
||||
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
|
||||
|
||||
; *** "Select Components" wizard page
|
||||
WizardSelectComponents=选择组件
|
||||
SelectComponentsDesc=您想安装哪些程序组件?
|
||||
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
|
||||
FullInstallation=完全安装
|
||||
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
|
||||
CompactInstallation=简洁安装
|
||||
CustomInstallation=自定义安装
|
||||
NoUninstallWarningTitle=组件已存在
|
||||
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗?
|
||||
ComponentSize1=%1 KB
|
||||
ComponentSize2=%1 MB
|
||||
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
|
||||
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
|
||||
|
||||
; *** "Select Additional Tasks" wizard page
|
||||
WizardSelectTasks=选择附加任务
|
||||
SelectTasksDesc=您想要安装程序执行哪些附加任务?
|
||||
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
|
||||
|
||||
; *** "Select Start Menu Folder" wizard page
|
||||
WizardSelectProgramGroup=选择开始菜单文件夹
|
||||
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
|
||||
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
|
||||
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
MustEnterGroupName=您必须输入一个文件夹名称。
|
||||
GroupNameTooLong=文件夹名称或路径太长。
|
||||
InvalidGroupName=文件夹名称无效。
|
||||
BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1
|
||||
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
|
||||
|
||||
; *** "Ready to Install" wizard page
|
||||
WizardReady=准备安装
|
||||
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
|
||||
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。
|
||||
ReadyLabel2b=点击“安装”继续此安装程序。
|
||||
ReadyMemoUserInfo=用户信息:
|
||||
ReadyMemoDir=目标位置:
|
||||
ReadyMemoType=安装类型:
|
||||
ReadyMemoComponents=已选择组件:
|
||||
ReadyMemoGroup=开始菜单文件夹:
|
||||
ReadyMemoTasks=附加任务:
|
||||
|
||||
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
|
||||
DownloadingLabel2=正在下载文件...
|
||||
ButtonStopDownload=停止下载(&S)
|
||||
StopDownload=您确定要停止下载吗?
|
||||
ErrorDownloadAborted=下载已中止
|
||||
ErrorDownloadFailed=下载失败:%1 %2
|
||||
ErrorDownloadSizeFailed=获取大小失败:%1 %2
|
||||
ErrorProgress=无效的进度:%1 / %2
|
||||
ErrorFileSize=文件大小错误:预期 %1,实际 %2
|
||||
|
||||
; *** TExtractionWizardPage wizard page and ExtractArchive
|
||||
ExtractingLabel=正在提取文件...
|
||||
ButtonStopExtraction=停止提取(&S)
|
||||
StopExtraction=您确定要停止提取吗?
|
||||
ErrorExtractionAborted=提取已中止
|
||||
ErrorExtractionFailed=提取失败:%1
|
||||
|
||||
; *** Archive extraction failure details
|
||||
ArchiveIncorrectPassword=密码不正确
|
||||
ArchiveIsCorrupted=压缩包已损坏
|
||||
ArchiveUnsupportedFormat=不支持的压缩包格式
|
||||
|
||||
; *** "Preparing to Install" wizard page
|
||||
WizardPreparing=正在准备安装
|
||||
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
|
||||
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
|
||||
CannotContinue=安装程序不能继续。请点击“取消”退出。
|
||||
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
|
||||
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
|
||||
CloseApplications=自动关闭应用程序(&A)
|
||||
DontCloseApplications=不要关闭应用程序(&D)
|
||||
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
|
||||
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
|
||||
|
||||
; *** "Installing" wizard page
|
||||
WizardInstalling=正在安装
|
||||
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
|
||||
|
||||
; *** "Setup Completed" wizard page
|
||||
FinishedHeadingLabel=完成 [name] 安装向导
|
||||
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
|
||||
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
|
||||
ClickFinish=点击“完成”退出安装程序。
|
||||
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
|
||||
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
|
||||
ShowReadmeCheck=是,我想查阅自述文件
|
||||
YesRadio=是,立即重启计算机(&Y)
|
||||
NoRadio=否,稍后重启计算机(&N)
|
||||
; used for example as 'Run MyProg.exe'
|
||||
RunEntryExec=运行 %1
|
||||
; used for example as 'View Readme.txt'
|
||||
RunEntryShellExec=查阅 %1
|
||||
|
||||
; *** "Setup Needs the Next Disk" stuff
|
||||
ChangeDiskTitle=安装程序需要下一张磁盘
|
||||
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
|
||||
PathLabel=路径(&P):
|
||||
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
|
||||
SelectDirectoryLabel=请指定下一张磁盘的位置。
|
||||
|
||||
; *** Installation phase messages
|
||||
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
|
||||
AbortRetryIgnoreSelectAction=选择操作
|
||||
AbortRetryIgnoreRetry=重试(&T)
|
||||
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
|
||||
AbortRetryIgnoreCancel=取消安装
|
||||
RetryCancelSelectAction=选择操作
|
||||
RetryCancelRetry=重试(&T)
|
||||
RetryCancelCancel=取消
|
||||
|
||||
; *** Installation status messages
|
||||
StatusClosingApplications=正在关闭应用程序...
|
||||
StatusCreateDirs=正在创建目录...
|
||||
StatusExtractFiles=正在提取文件...
|
||||
StatusDownloadFiles=正在下载文件...
|
||||
StatusCreateIcons=正在创建快捷方式...
|
||||
StatusCreateIniEntries=正在创建 INI 条目...
|
||||
StatusCreateRegistryEntries=正在创建注册表条目...
|
||||
StatusRegisterFiles=正在注册文件...
|
||||
StatusSavingUninstall=正在保存卸载信息...
|
||||
StatusRunProgram=正在完成安装...
|
||||
StatusRestartingApplications=正在重启应用程序...
|
||||
StatusRollback=正在撤销更改...
|
||||
|
||||
; *** Misc. errors
|
||||
ErrorInternal2=内部错误:%1
|
||||
ErrorFunctionFailedNoCode=%1 失败
|
||||
ErrorFunctionFailed=%1 失败;错误代码 %2
|
||||
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
|
||||
ErrorExecutingProgram=无法执行文件:%n%1
|
||||
|
||||
; *** Registry errors
|
||||
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
|
||||
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
|
||||
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
|
||||
|
||||
; *** INI errors
|
||||
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
|
||||
|
||||
; *** File copying errors
|
||||
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
|
||||
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
|
||||
SourceIsCorrupted=源文件已损坏
|
||||
SourceDoesntExist=源文件“%1”不存在
|
||||
SourceVerificationFailed=源文件验证失败:%1
|
||||
VerificationSignatureDoesntExist=签名文件“%1”不存在
|
||||
VerificationSignatureInvalid=签名文件“%1”无效
|
||||
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥
|
||||
VerificationFileNameIncorrect=文件名不正确
|
||||
VerificationFileTagIncorrect=文件标签不正确
|
||||
VerificationFileSizeIncorrect=文件大小不正确
|
||||
VerificationFileHashIncorrect=文件哈希值不正确
|
||||
ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。
|
||||
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
|
||||
ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K)
|
||||
ErrorReadingExistingDest=尝试读取已存在的文件时出错:
|
||||
FileExistsSelectAction=选择操作
|
||||
FileExists2=文件已经存在。
|
||||
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
|
||||
FileExistsKeepExisting=保留已存在的文件(&K)
|
||||
FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
|
||||
ExistingFileNewerSelectAction=选择操作
|
||||
ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。
|
||||
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
|
||||
ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐)
|
||||
ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
|
||||
ErrorChangingAttr=尝试更改下列已存在的文件属性时出错:
|
||||
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
|
||||
ErrorReadingSource=尝试读取下列源文件时出错:
|
||||
ErrorCopying=尝试复制下列文件时出错:
|
||||
ErrorDownloading=尝试下载文件时出错:
|
||||
ErrorExtracting=尝试提取压缩包时出错:
|
||||
ErrorReplacingExistingFile=尝试替换已存在的文件时出错:
|
||||
ErrorRestartReplace=重启并替换失败:
|
||||
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
|
||||
ErrorRegisterServer=无法注册 DLL/OCX:%1
|
||||
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
|
||||
ErrorRegisterTypeLib=无法注册类型库:%1
|
||||
|
||||
; *** Uninstall display name markings
|
||||
; used for example as 'My Program (32-bit)'
|
||||
UninstallDisplayNameMark=%1 (%2)
|
||||
; used for example as 'My Program (32-bit, All users)'
|
||||
UninstallDisplayNameMarks=%1 (%2, %3)
|
||||
UninstallDisplayNameMark32Bit=32 位
|
||||
UninstallDisplayNameMark64Bit=64 位
|
||||
UninstallDisplayNameMarkAllUsers=所有用户
|
||||
UninstallDisplayNameMarkCurrentUser=当前用户
|
||||
|
||||
; *** Post-installation errors
|
||||
ErrorOpeningReadme=尝试打开自述文件时出错。
|
||||
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
|
||||
|
||||
; *** Uninstaller messages
|
||||
UninstallNotFound=文件“%1”不存在。无法卸载。
|
||||
UninstallOpenError=文件“%1”不能被打开。无法卸载
|
||||
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
|
||||
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)
|
||||
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
|
||||
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
|
||||
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
|
||||
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
|
||||
UninstalledAll=已顺利从您的计算机中移除 %1。
|
||||
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
|
||||
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
|
||||
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
|
||||
|
||||
; *** Uninstallation phase messages
|
||||
ConfirmDeleteSharedFileTitle=删除共享文件?
|
||||
ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。
|
||||
SharedFileNameLabel=文件名:
|
||||
SharedFileLocationLabel=位置:
|
||||
WizardUninstalling=卸载状态
|
||||
StatusUninstalling=正在卸载 %1...
|
||||
|
||||
; *** Shutdown block reasons
|
||||
ShutdownBlockReasonInstallingApp=正在安装 %1。
|
||||
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
|
||||
|
||||
; The custom messages below aren't used by Setup itself, but if you make
|
||||
; use of them in your scripts, you'll want to translate them.
|
||||
|
||||
[CustomMessages]
|
||||
|
||||
NameAndVersion=%1 版本 %2
|
||||
AdditionalIcons=附加快捷方式:
|
||||
CreateDesktopIcon=创建桌面快捷方式(&D)
|
||||
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
|
||||
ProgramOnTheWeb=%1 网站
|
||||
UninstallProgram=卸载 %1
|
||||
LaunchProgram=运行 %1
|
||||
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
|
||||
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
|
||||
AutoStartProgramGroupDescription=启动:
|
||||
AutoStartProgram=自动启动 %1
|
||||
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗?
|
||||
@@ -0,0 +1,145 @@
|
||||
; LMVPN Windows Installer — Inno Setup script
|
||||
;
|
||||
; Build:
|
||||
; ISCC /DAppVersion=0.3.6-abc123 installer/lmvpn.iss
|
||||
; wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=0.3.6-abc123 installer/lmvpn.iss
|
||||
;
|
||||
; The exes (lmvpn.exe, lmvpnd.exe) must exist in ../build/ before compiling.
|
||||
; Run `make build-windows` first (or `make installer-windows` which does both).
|
||||
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "dev"
|
||||
#endif
|
||||
|
||||
#define AppName "LMVPN"
|
||||
#define AppPublisher "LMVPN"
|
||||
#define AppExeName "lmvpn.exe"
|
||||
#define DaemonExeName "lmvpnd.exe"
|
||||
#define AppURL "https://github.com/lmvpn/lmvpn_client"
|
||||
|
||||
[Setup]
|
||||
AppId={{8F7B3A2E-1D4C-4B5E-9F6A-2E3C7D8E1F0A}
|
||||
AppName={#AppName}
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName={#AppName} {#AppVersion}
|
||||
AppPublisher={#AppPublisher}
|
||||
AppPublisherURL={#AppURL}
|
||||
AppSupportURL={#AppURL}
|
||||
AppUpdatesURL={#AppURL}
|
||||
DefaultDirName={autopf}\{#AppName}
|
||||
DefaultGroupName={#AppName}
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
OutputDir=..\build
|
||||
OutputBaseFilename=LMVPN-Setup-{#AppVersion}
|
||||
SetupIconFile=..\resources\icon.ico
|
||||
UninstallDisplayIcon={app}\{#AppExeName}
|
||||
UninstallDisplayName={#AppName}
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimp"; MessagesFile: "ChineseSimplified.isl"
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式(&D)"; GroupDescription: "附加选项:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "..\build\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\build\{#DaemonExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\resources\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
|
||||
Name: "{group}\卸载 {#AppName}"; Filename: "{uninstallexe}"
|
||||
Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#AppExeName}"; Description: "立即启动 {#AppName}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallDelete]
|
||||
Type: files; Name: "{app}\wintun.dll"
|
||||
|
||||
[Code]
|
||||
|
||||
// Kill running LMVPN processes (GUI + daemon) by image name.
|
||||
// Returns True if at least one process was found and killed.
|
||||
function KillProcess(const ExeName: String): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
// taskkill /IM <name> /F /T — force-kill process tree
|
||||
if Exec(ExpandConstant('{cmd}'), '/C taskkill /IM ' + ExeName + ' /F /T',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
begin
|
||||
// exit code 0 = killed, 128 = not found — both are fine
|
||||
Result := (ResultCode = 0);
|
||||
end;
|
||||
end;
|
||||
|
||||
// PrepareToInstall runs before file extraction. We kill any running
|
||||
// LMVPN processes here so that the exes can be overwritten cleanly.
|
||||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||
begin
|
||||
KillProcess('{#DaemonExeName}');
|
||||
KillProcess('{#AppExeName}');
|
||||
// Give the OS a moment to release file handles
|
||||
Sleep(800);
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
// Kill processes before uninstall as well.
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usUninstall then
|
||||
begin
|
||||
KillProcess('{#DaemonExeName}');
|
||||
KillProcess('{#AppExeName}');
|
||||
Sleep(500);
|
||||
end;
|
||||
end;
|
||||
|
||||
// Check if a process with the given image name is currently running.
|
||||
function IsProcessRunning(const ExeName: String): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
// tasklist exits 0 if the process is found, 1 if not found
|
||||
if Exec(ExpandConstant('{cmd}'), '/C tasklist /FI "IMAGENAME eq ' + ExeName + '" /NH /FO CSV | findstr /I "' + ExeName + '"',
|
||||
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
|
||||
Result := (ResultCode = 0);
|
||||
end;
|
||||
|
||||
// Warn the user if LMVPN is still running and offer to kill it
|
||||
// before proceeding from the directory selection page.
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
if CurPageID = wpSelectDir then
|
||||
begin
|
||||
if IsProcessRunning('{#AppExeName}') or IsProcessRunning('{#DaemonExeName}') then
|
||||
begin
|
||||
if MsgBox('{#AppName} 正在运行中,需要先关闭才能继续安装。' + #13#10 + #13#10 + '是否立即关闭?',
|
||||
mbConfirmation, MB_YESNO) = IDYES then
|
||||
begin
|
||||
KillProcess('{#DaemonExeName}');
|
||||
KillProcess('{#AppExeName}');
|
||||
Sleep(800);
|
||||
// Verify they're actually dead
|
||||
if IsProcessRunning('{#AppExeName}') or IsProcessRunning('{#DaemonExeName}') then
|
||||
begin
|
||||
MsgBox('无法关闭 {#AppName} 进程,请手动结束任务后重试。', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end
|
||||
else
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
+31
-3
@@ -6,12 +6,16 @@ package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/transport"
|
||||
)
|
||||
|
||||
// LoginResult holds the response from a successful /api/login call.
|
||||
@@ -41,15 +45,39 @@ type errorResponse struct {
|
||||
// baseURL should be the HTTP(S) origin derived from the WebSocket URL
|
||||
// (e.g. "http://localhost:8080" for ws://, "https://vpn.example.com"
|
||||
// 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).
|
||||
//
|
||||
// ipPreference controls which IP address families are used when
|
||||
// resolving the server hostname ("auto", "v4", "v6").
|
||||
//
|
||||
// ctx allows cancellation of the HTTP request (e.g. when the VPN
|
||||
// session is disconnected while login is in flight).
|
||||
func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config, ipPreference string) (*LoginResult, error) {
|
||||
body, err := json.Marshal(loginRequest{Username: username, Password: password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := strings.TrimRight(baseURL, "/") + "/api/login"
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
|
||||
httpTransport := &http.Transport{
|
||||
DialContext: transport.NewRaceDialer(ipPreference),
|
||||
}
|
||||
if tlsCfg != nil {
|
||||
httpTransport.TLSClientConfig = tlsCfg
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Second, Transport: httpTransport}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create login request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login request: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Package cidrsource fetches CIDR lists from URLs. CIDR lists can be
|
||||
// fetched before routing is applied (via a direct connection) or after
|
||||
// the tunnel is established (via the tunnel itself).
|
||||
//
|
||||
// The expected format is one CIDR per line. Empty lines and lines
|
||||
// starting with '#' are treated as comments and skipped. Invalid CIDR
|
||||
// entries are silently ignored (with a debug log entry).
|
||||
package cidrsource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
"lmvpn/internal/model"
|
||||
)
|
||||
|
||||
// fetchTimeout is the total timeout for all before-proxy fetches
|
||||
// combined. It must be well under the server's 30s ReadyTimeout since
|
||||
// before-proxy fetching happens before the WS handshake begins.
|
||||
const fetchTimeout = 5 * time.Second
|
||||
|
||||
// FetchBeforeProxy fetches all CIDR URL sources with FetchTiming ==
|
||||
// "before". These are fetched via the system's direct connection
|
||||
// (before routing is applied), so no special proxy handling is needed.
|
||||
// The context allows cancellation (e.g. session teardown).
|
||||
//
|
||||
// All matching sources are fetched concurrently with a shared 5s
|
||||
// deadline so a single slow URL does not block the entire session.
|
||||
func FetchBeforeProxy(ctx context.Context, sources []model.CIDRURLSource) ([]string, error) {
|
||||
return fetchSources(ctx, sources, model.FetchBefore)
|
||||
}
|
||||
|
||||
// FetchAfterProxy fetches all CIDR URL sources with FetchTiming ==
|
||||
// "after". These are fetched via the tunnel after the data plane is up.
|
||||
// The HTTP client uses the default dialer, which respects the system
|
||||
// routing table - so when full-tunnel or bypass routes are in effect,
|
||||
// the request goes through the TUN interface.
|
||||
//
|
||||
// Sources are fetched concurrently with a 15s deadline (the tunnel is
|
||||
// already up, so there is no ReadyTimeout pressure).
|
||||
func FetchAfterProxy(ctx context.Context, sources []model.CIDRURLSource) ([]string, error) {
|
||||
return fetchSources(ctx, sources, model.FetchAfter)
|
||||
}
|
||||
|
||||
// fetchSources fetches all sources matching the given timing
|
||||
// concurrently, bounded by a shared deadline derived from fetchTimeout
|
||||
// (for "before") or 15s (for "after").
|
||||
func fetchSources(ctx context.Context, sources []model.CIDRURLSource, timing model.FetchTiming) ([]string, error) {
|
||||
// Collect matching sources.
|
||||
var matching []model.CIDRURLSource
|
||||
for _, src := range sources {
|
||||
if src.FetchTiming == timing {
|
||||
matching = append(matching, src)
|
||||
}
|
||||
}
|
||||
if len(matching) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Shared deadline for all fetches.
|
||||
deadline := fetchTimeout
|
||||
if timing == model.FetchAfter {
|
||||
deadline = 15 * time.Second
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, deadline)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
cidrs []string
|
||||
err error
|
||||
url string
|
||||
}
|
||||
results := make(chan result, len(matching))
|
||||
|
||||
for _, src := range matching {
|
||||
go func(s model.CIDRURLSource) {
|
||||
cidrs, err := fetchOne(fetchCtx, s.URL)
|
||||
results <- result{cidrs: cidrs, err: err, url: s.URL}
|
||||
}(src)
|
||||
}
|
||||
|
||||
var allCIDRs []string
|
||||
var errs []string
|
||||
for range matching {
|
||||
r := <-results
|
||||
if r.err != nil {
|
||||
label := "before-proxy"
|
||||
if timing == model.FetchAfter {
|
||||
label = "after-proxy"
|
||||
}
|
||||
log.L().Error("fetch "+label+" CIDR list failed (continuing)",
|
||||
"url", r.url, "error", r.err)
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", r.url, r.err))
|
||||
continue
|
||||
}
|
||||
label := "before-proxy"
|
||||
if timing == model.FetchAfter {
|
||||
label = "after-proxy"
|
||||
}
|
||||
log.L().Info("fetched "+label+" CIDR list",
|
||||
"url", r.url, "count", len(r.cidrs))
|
||||
allCIDRs = append(allCIDRs, r.cidrs...)
|
||||
}
|
||||
|
||||
if len(allCIDRs) == 0 && len(errs) > 0 {
|
||||
return nil, fmt.Errorf("all CIDR URL fetches failed: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return allCIDRs, nil
|
||||
}
|
||||
|
||||
// fetchOne fetches a single URL and parses the response as a CIDR list.
|
||||
func fetchOne(ctx context.Context, url string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB max
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
return ParseCIDRList(string(body)), nil
|
||||
}
|
||||
|
||||
// ParseCIDRList parses text into a list of valid CIDR strings. Each
|
||||
// line is treated as a separate CIDR entry. Empty lines and lines
|
||||
// starting with '#' are skipped. Invalid CIDR entries are silently
|
||||
// ignored.
|
||||
func ParseCIDRList(text string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
c := strings.TrimSpace(line)
|
||||
if c == "" || strings.HasPrefix(c, "#") {
|
||||
continue
|
||||
}
|
||||
_, _, err := net.ParseCIDR(c)
|
||||
if err != nil {
|
||||
log.L().Debug("ignoring invalid CIDR entry", "cidr", c)
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClassifyCIDRs splits a list of CIDR strings into IPv4 and IPv6 lists.
|
||||
func ClassifyCIDRs(cidrs []string) (v4, v6 []string) {
|
||||
for _, cidr := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.To4() != nil {
|
||||
v4 = append(v4, cidr)
|
||||
} else {
|
||||
v6 = append(v6, cidr)
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"lmvpn/internal/ipc"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"lmvpn/internal/model"
|
||||
"lmvpn/internal/paths"
|
||||
"lmvpn/internal/stats"
|
||||
"lmvpn/internal/version"
|
||||
"lmvpn/internal/vpn"
|
||||
)
|
||||
|
||||
@@ -59,8 +61,8 @@ func Run(userHome string, uid, gid int) error {
|
||||
// Chown the IPC socket so the non-root GUI process can connect.
|
||||
// This is the critical fix: without it, the socket is owned by
|
||||
// root:wheel with mode 0660, and the user cannot dial it.
|
||||
chownToUser(paths.IPCSocketPath(), uid, gid)
|
||||
log.L().Info("daemon listening", "socket", paths.IPCSocketPath())
|
||||
chownToUser(paths.IPCAddress(), uid, gid)
|
||||
log.L().Info("daemon listening", "socket", paths.IPCAddress())
|
||||
|
||||
d := &daemon{server: server}
|
||||
|
||||
@@ -91,6 +93,7 @@ func chownToUser(path string, uid, gid int) {
|
||||
|
||||
type daemon struct {
|
||||
server *ipc.Server
|
||||
mu sync.Mutex
|
||||
session *vpn.SessionManager
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
@@ -108,23 +111,40 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
|
||||
d.server.Close()
|
||||
os.Exit(0)
|
||||
case ipc.CmdStats:
|
||||
if d.session != nil {
|
||||
snap := d.session.Stats().Snapshot()
|
||||
d.mu.Lock()
|
||||
sess := d.session
|
||||
d.mu.Unlock()
|
||||
if sess != nil {
|
||||
snap := sess.Stats().Snapshot()
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
|
||||
}
|
||||
_ = ipc.WriteOK(conn)
|
||||
case ipc.CmdRefreshCIDR:
|
||||
d.mu.Lock()
|
||||
sess := d.session
|
||||
d.mu.Unlock()
|
||||
if sess != nil {
|
||||
go sess.RefreshCIDRs()
|
||||
_ = ipc.WriteOK(conn)
|
||||
} else {
|
||||
_ = ipc.WriteErr(conn, "no active session")
|
||||
}
|
||||
case ipc.CmdVersion:
|
||||
_ = ipc.WriteVersion(conn, version.Version)
|
||||
default:
|
||||
_ = ipc.WriteErr(conn, "unknown command: "+req.Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
|
||||
d.mu.Lock()
|
||||
if req.Config == nil {
|
||||
d.mu.Unlock()
|
||||
_ = ipc.WriteErr(conn, "missing config")
|
||||
return
|
||||
}
|
||||
if d.session != nil {
|
||||
d.stopSession()
|
||||
d.stopSessionLocked()
|
||||
}
|
||||
|
||||
cfg := vpn.SessionConfig{
|
||||
@@ -136,30 +156,55 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
|
||||
Token: req.Config.Token,
|
||||
AuthMode: model.AuthMode(req.Config.AuthMode),
|
||||
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
|
||||
CustomCIDRs: req.Config.CustomCIDRs,
|
||||
CIDRV4: req.Config.CIDRV4,
|
||||
CIDRV6: req.Config.CIDRV6,
|
||||
CIDRV4URLs: convertIPCSources(req.Config.CIDRV4URLs),
|
||||
CIDRV6URLs: convertIPCSources(req.Config.CIDRV6URLs),
|
||||
MTUOverride: req.Config.MTUOverride,
|
||||
TLSCACert: req.Config.TLSCACert,
|
||||
TLSCAPath: req.Config.TLSCAPath,
|
||||
TLSInsecure: req.Config.TLSInsecure,
|
||||
TLSPinnedHash: req.Config.TLSPinnedHash,
|
||||
IPPreference: req.Config.IPPreference,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
d.cancel = cancel
|
||||
d.session = vpn.New(
|
||||
func(s stats.State) {
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s)})
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s), ConnectStep: d.session.Stats().ConnectStep()})
|
||||
},
|
||||
func(snap stats.Snapshot) {
|
||||
s := snap
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &s})
|
||||
},
|
||||
func(code string, msg string) {
|
||||
d.server.Broadcast(ipc.Event{Event: ipc.EvError, Code: code, Message: msg})
|
||||
},
|
||||
)
|
||||
// Release the lock before Connect. Connect is non-blocking (it
|
||||
// starts a goroutine), but holding the lock across it would
|
||||
// serialize all IPC commands (CmdStats, CmdStop, etc.) behind the
|
||||
// session startup.
|
||||
d.mu.Unlock()
|
||||
|
||||
if err := d.session.Connect(ctx, cfg); err != nil {
|
||||
_ = ipc.WriteErr(conn, "connect: "+err.Error())
|
||||
d.mu.Lock()
|
||||
d.session = nil
|
||||
d.cancel = nil
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (d *daemon) stopSession() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.stopSessionLocked()
|
||||
}
|
||||
|
||||
func (d *daemon) stopSessionLocked() {
|
||||
if d.cancel != nil {
|
||||
d.cancel()
|
||||
d.cancel = nil
|
||||
@@ -169,3 +214,18 @@ func (d *daemon) stopSession() {
|
||||
d.session = nil
|
||||
}
|
||||
}
|
||||
|
||||
// convertIPCSources converts IPC CIDRURLSource values to model.CIDRURLSource.
|
||||
func convertIPCSources(srcs []ipc.CIDRURLSource) []model.CIDRURLSource {
|
||||
if len(srcs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]model.CIDRURLSource, len(srcs))
|
||||
for i, s := range srcs {
|
||||
out[i] = model.CIDRURLSource{
|
||||
URL: s.URL,
|
||||
FetchTiming: model.FetchTiming(s.FetchTiming),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
const daemonBinaryName = "lmvpnd.exe"
|
||||
@@ -0,0 +1,32 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// resolveDaemonBinary locates the lmvpnd binary relative to the
|
||||
// launcher's executable path. In a .app bundle, lmvpnd lives in the
|
||||
// same Contents/MacOS/ directory as lmvpn. In development, it lives
|
||||
// in the same build directory.
|
||||
func resolveDaemonBinary() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve launcher executable: %w", err)
|
||||
}
|
||||
|
||||
// Resolve any symlinks.
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve symlink: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
candidate := filepath.Join(dir, daemonBinaryName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find %s near %s", daemonBinaryName, exe)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
@@ -120,33 +122,4 @@ func Launch(userHome string, uid, gid int, daemonBin string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDaemonBinary locates the lmvpnd binary relative to the
|
||||
// launcher's executable path. In a .app bundle, lmvpnd lives in the
|
||||
// same Contents/MacOS/ directory as lmvpn. In development, it lives
|
||||
// in the same build directory.
|
||||
func resolveDaemonBinary() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve launcher executable: %w", err)
|
||||
}
|
||||
|
||||
// Resolve any symlinks.
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve symlink: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
candidates := []string{
|
||||
filepath.Join(dir, "lmvpnd"),
|
||||
filepath.Join(dir, "lmvpn-daemon"),
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find lmvpnd binary near %s (tried %v)", exe, candidates)
|
||||
}
|
||||
// resolveDaemonBinary is in launch_common.go.
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"lmvpn/internal/paths"
|
||||
)
|
||||
|
||||
// Launch starts the daemon binary (lmvpnd.exe) as a detached process
|
||||
// and returns immediately. On Windows, detachment is achieved via
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS creation flags instead
|
||||
// of the Unix Setsid call.
|
||||
//
|
||||
// The launcher itself is invoked by the GUI via ShellExecute "runas"
|
||||
// (UAC elevation). It:
|
||||
// 1. Computes the daemon log path using the user's home directory
|
||||
// 2. Opens the log file (append) and NUL
|
||||
// 3. Starts the lmvpnd.exe binary, redirecting stdio
|
||||
// 4. Returns immediately — the child continues running independently
|
||||
func Launch(userHome string, uid, gid int, daemonBin string) error {
|
||||
paths.SetUserHome(userHome)
|
||||
if err := paths.EnsureDirs(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ensure dirs: %v\n", err)
|
||||
}
|
||||
|
||||
if daemonBin == "" {
|
||||
var err error
|
||||
daemonBin, err = resolveDaemonBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
absBin, err := filepath.Abs(daemonBin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve absolute path for %s: %w", daemonBin, err)
|
||||
}
|
||||
daemonBin = absBin
|
||||
|
||||
if _, err := os.Stat(daemonBin); err != nil {
|
||||
return fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err)
|
||||
}
|
||||
|
||||
logPath := paths.DaemonLogFile()
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open daemon log %s: %w", logPath, err)
|
||||
}
|
||||
|
||||
devNull, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("open NUL: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--user-home", userHome,
|
||||
"--uid", fmt.Sprintf("%d", uid),
|
||||
"--gid", fmt.Sprintf("%d", gid),
|
||||
}
|
||||
|
||||
fmt.Fprintf(logFile, "=== launcher: starting daemon %s %v ===\n", daemonBin, args)
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP (0x200) | DETACHED_PROCESS (0x8)
|
||||
procAttr := &os.ProcAttr{
|
||||
Dir: os.TempDir(),
|
||||
Env: append(os.Environ(),
|
||||
"LMVPN_DAEMON=1",
|
||||
),
|
||||
Files: []*os.File{devNull, logFile, logFile},
|
||||
Sys: &syscall.SysProcAttr{
|
||||
CreationFlags: 0x00000200 | 0x00000008,
|
||||
},
|
||||
}
|
||||
|
||||
pid, err := os.StartProcess(daemonBin, append([]string{daemonBin}, args...), procAttr)
|
||||
logFile.Close()
|
||||
devNull.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("start daemon process: %w", err)
|
||||
}
|
||||
|
||||
_ = pid.Release()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "daemon launched (pid %d, bin %s)\n", pid.Pid, daemonBin)
|
||||
return nil
|
||||
}
|
||||
+194
-1
@@ -8,6 +8,7 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"lmvpn/internal/paths"
|
||||
@@ -45,7 +46,19 @@ func (s *Store) migrate() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateV2()
|
||||
if err := s.migrateV2(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.migrateV3(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.migrateV4(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.migrateV5(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateV6()
|
||||
}
|
||||
|
||||
func (s *Store) migrateV2() error {
|
||||
@@ -165,6 +178,180 @@ func nullStr(s sql.NullString) sql.NullString {
|
||||
return s
|
||||
}
|
||||
|
||||
// migrateV3 adds the assigned_ip6 column to connection_logs for IPv6
|
||||
// dual-stack support. Idempotent: skips if the column already exists.
|
||||
func (s *Store) migrateV3() error {
|
||||
if columnExists(s.db, "connection_logs", "assigned_ip6") {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(`ALTER TABLE connection_logs ADD COLUMN assigned_ip6 TEXT NOT NULL DEFAULT ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate v3 add assigned_ip6: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// migrateV5 replaces the single custom_cidrs column with separate
|
||||
// cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls columns. It migrates
|
||||
// existing routing modes: 'custom' -> 'proxy', 'split' -> 'full'.
|
||||
// Existing custom_cidrs are split into v4/v6 based on address family.
|
||||
// Idempotent: skips columns that already exist.
|
||||
func (s *Store) migrateV5() error {
|
||||
cols := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"cidr_v4", "ALTER TABLE server_profiles ADD COLUMN cidr_v4 TEXT NOT NULL DEFAULT ''"},
|
||||
{"cidr_v6", "ALTER TABLE server_profiles ADD COLUMN cidr_v6 TEXT NOT NULL DEFAULT ''"},
|
||||
{"cidr_v4_urls", "ALTER TABLE server_profiles ADD COLUMN cidr_v4_urls TEXT NOT NULL DEFAULT ''"},
|
||||
{"cidr_v6_urls", "ALTER TABLE server_profiles ADD COLUMN cidr_v6_urls TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
needMigration := false
|
||||
for _, c := range cols {
|
||||
if !columnExists(s.db, "server_profiles", c.name) {
|
||||
needMigration = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needMigration {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 v5 add %s: %w", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate existing custom_cidrs into cidr_v4 / cidr_v6 and update
|
||||
// routing mode codes. Only process rows that still have a non-empty
|
||||
// custom_cidrs or an old routing mode.
|
||||
rows, err := s.db.Query(`SELECT id, routing_mode, custom_cidrs FROM server_profiles`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate v5 read rows: %w", err)
|
||||
}
|
||||
type row struct {
|
||||
id int64
|
||||
routingMode string
|
||||
customCIDRs string
|
||||
}
|
||||
var toUpdate []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if err := rows.Scan(&r.id, &r.routingMode, &r.customCIDRs); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("migrate v5 scan: %w", err)
|
||||
}
|
||||
toUpdate = append(toUpdate, r)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, r := range toUpdate {
|
||||
newMode := r.routingMode
|
||||
switch newMode {
|
||||
case "custom":
|
||||
newMode = "proxy"
|
||||
case "split":
|
||||
newMode = "full"
|
||||
}
|
||||
|
||||
v4CIDRs, v6CIDRs := splitCIDRsByFamily(r.customCIDRs)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE server_profiles SET routing_mode = ?, cidr_v4 = ?, cidr_v6 = ? WHERE id = ?`,
|
||||
newMode, v4CIDRs, v6CIDRs, r.id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate v5 update row %d: %w", r.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV6 adds the ip_preference column to server_profiles for
|
||||
// controlling IPv4/IPv6 address selection when connecting by hostname.
|
||||
// Idempotent: skips if the column already exists.
|
||||
func (s *Store) migrateV6() error {
|
||||
if columnExists(s.db, "server_profiles", "ip_preference") {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(`ALTER TABLE server_profiles ADD COLUMN ip_preference TEXT NOT NULL DEFAULT 'auto'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate v6 add ip_preference: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and
|
||||
// IPv6 parts. Used for migration from the old custom_cidrs column.
|
||||
func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) {
|
||||
if customCIDRs == "" {
|
||||
return "", ""
|
||||
}
|
||||
var v4Parts, v6Parts []string
|
||||
for _, part := range strings.Split(customCIDRs, ",") {
|
||||
c := strings.TrimSpace(part)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, ipNet, err := net.ParseCIDR(c); err == nil {
|
||||
if ipNet.IP.To4() != nil {
|
||||
v4Parts = append(v4Parts, c)
|
||||
} else {
|
||||
v6Parts = append(v6Parts, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(v4Parts, ", "), strings.Join(v6Parts, ", ")
|
||||
}
|
||||
|
||||
// columnExists reports whether a column exists on a table.
|
||||
func columnExists(db *sql.DB, table, column string) bool {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false
|
||||
}
|
||||
if name == column {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const schemaV2 = `
|
||||
CREATE TABLE IF NOT EXISTS server_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -178,8 +365,13 @@ CREATE TABLE IF NOT EXISTS server_profiles (
|
||||
auth_mode TEXT NOT NULL DEFAULT 'both',
|
||||
routing_mode TEXT NOT NULL DEFAULT 'full',
|
||||
custom_cidrs TEXT NOT NULL DEFAULT '',
|
||||
cidr_v4 TEXT NOT NULL DEFAULT '',
|
||||
cidr_v6 TEXT NOT NULL DEFAULT '',
|
||||
cidr_v4_urls TEXT NOT NULL DEFAULT '',
|
||||
cidr_v6_urls TEXT NOT NULL DEFAULT '',
|
||||
mtu_override INTEGER NOT NULL DEFAULT 0,
|
||||
auto_connect INTEGER NOT NULL DEFAULT 0,
|
||||
ip_preference TEXT NOT NULL DEFAULT 'auto',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_connected_at DATETIME
|
||||
);
|
||||
@@ -190,6 +382,7 @@ CREATE TABLE IF NOT EXISTS connection_logs (
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at DATETIME,
|
||||
assigned_ip TEXT NOT NULL DEFAULT '',
|
||||
assigned_ip6 TEXT NOT NULL DEFAULT '',
|
||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'connected',
|
||||
|
||||
+5
-5
@@ -21,13 +21,13 @@ func (s *Store) StartLog(profileID int64) (int64, error) {
|
||||
}
|
||||
|
||||
// FinishLog finalises a connection log entry with final stats.
|
||||
func (s *Store) FinishLog(id int64, status model.ConnectionStatus, assignedIP string, rxBytes, txBytes int64, errMsg string) error {
|
||||
func (s *Store) FinishLog(id int64, status model.ConnectionStatus, assignedIP, assignedIP6 string, rxBytes, txBytes int64, errMsg string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE connection_logs SET
|
||||
ended_at = ?, assigned_ip = ?, rx_bytes = ?, tx_bytes = ?,
|
||||
ended_at = ?, assigned_ip = ?, assigned_ip6 = ?, rx_bytes = ?, tx_bytes = ?,
|
||||
status = ?, error_msg = ?
|
||||
WHERE id = ?`,
|
||||
time.Now(), assignedIP, rxBytes, txBytes, status, errMsg, id)
|
||||
time.Now(), assignedIP, assignedIP6, rxBytes, txBytes, status, errMsg, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finish log %d: %w", id, err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func (s *Store) FinishLog(id int64, status model.ConnectionStatus, assignedIP st
|
||||
// RecentLogs returns the most recent N connection logs for a profile.
|
||||
func (s *Store) RecentLogs(profileID int64, limit int) ([]model.ConnectionLog, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, profile_id, started_at, ended_at, assigned_ip,
|
||||
`SELECT id, profile_id, started_at, ended_at, assigned_ip, assigned_ip6,
|
||||
rx_bytes, tx_bytes, status, error_msg
|
||||
FROM connection_logs WHERE profile_id = ?
|
||||
ORDER BY started_at DESC LIMIT ?`,
|
||||
@@ -52,7 +52,7 @@ func (s *Store) RecentLogs(profileID int64, limit int) ([]model.ConnectionLog, e
|
||||
var l model.ConnectionLog
|
||||
var ended interface{}
|
||||
if err := rows.Scan(&l.ID, &l.ProfileID, &l.StartedAt, &ended,
|
||||
&l.AssignedIP, &l.RxBytes, &l.TxBytes, &l.Status, &l.ErrorMsg); err != nil {
|
||||
&l.AssignedIP, &l.AssignedIP6, &l.RxBytes, &l.TxBytes, &l.Status, &l.ErrorMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := ended.(time.Time); ok {
|
||||
|
||||
+36
-10
@@ -14,11 +14,17 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
|
||||
`INSERT INTO server_profiles
|
||||
(name, protocol, host, server_ips, port, path,
|
||||
username, auth_mode, routing_mode,
|
||||
custom_cidrs, mtu_override, auto_connect)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
|
||||
mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
|
||||
ip_preference)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
|
||||
p.Username, p.AuthMode, p.RoutingMode,
|
||||
p.CustomCIDRs, p.MTUOverride, p.AutoConnect,
|
||||
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
|
||||
p.MTUOverride, p.AutoConnect,
|
||||
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
|
||||
p.IPPreference,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert profile: %w", err)
|
||||
@@ -36,11 +42,18 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, protocol, host, server_ips, port, path,
|
||||
username, auth_mode, routing_mode,
|
||||
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
|
||||
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
|
||||
mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
|
||||
ip_preference,
|
||||
created_at, last_connected_at
|
||||
FROM server_profiles WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path,
|
||||
&p.Username, &p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride,
|
||||
&p.AutoConnect, &p.CreatedAt, &last)
|
||||
&p.Username, &p.AuthMode, &p.RoutingMode,
|
||||
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
|
||||
&p.MTUOverride, &p.AutoConnect,
|
||||
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
|
||||
&p.IPPreference, &p.CreatedAt, &last)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get profile %d: %w", id, err)
|
||||
}
|
||||
@@ -55,7 +68,11 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, protocol, host, server_ips, port, path,
|
||||
username, auth_mode, routing_mode,
|
||||
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
|
||||
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
|
||||
mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
|
||||
ip_preference,
|
||||
created_at, last_connected_at
|
||||
FROM server_profiles ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list profiles: %w", err)
|
||||
@@ -68,7 +85,10 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
|
||||
var last sql.NullTime
|
||||
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.CustomCIDRs, &p.MTUOverride, &p.AutoConnect, &p.CreatedAt, &last); err != nil {
|
||||
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
|
||||
&p.MTUOverride, &p.AutoConnect,
|
||||
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
|
||||
&p.IPPreference, &p.CreatedAt, &last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if last.Valid {
|
||||
@@ -85,11 +105,17 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error {
|
||||
`UPDATE server_profiles SET
|
||||
name = ?, protocol = ?, host = ?, server_ips = ?, port = ?, path = ?,
|
||||
username = ?, auth_mode = ?, routing_mode = ?,
|
||||
custom_cidrs = ?, mtu_override = ?, auto_connect = ?
|
||||
cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?,
|
||||
mtu_override = ?, auto_connect = ?,
|
||||
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?,
|
||||
ip_preference = ?
|
||||
WHERE id = ?`,
|
||||
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
|
||||
p.Username, p.AuthMode, p.RoutingMode,
|
||||
p.CustomCIDRs, p.MTUOverride, p.AutoConnect, p.ID)
|
||||
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
|
||||
p.MTUOverride, p.AutoConnect,
|
||||
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
|
||||
p.IPPreference, p.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update profile %d: %w", p.ID, err)
|
||||
}
|
||||
|
||||
+78
-9
@@ -19,13 +19,37 @@ BtnCancel = "Cancel"
|
||||
BtnOK = "OK"
|
||||
|
||||
IpLabel = "IP: {{.ip}}"
|
||||
Ip6Label = "IPv6: {{.ip}}"
|
||||
Ip6None = "IPv6: —"
|
||||
IpNone = "IP: —"
|
||||
UptimeLabel = "Uptime: {{.uptime}}"
|
||||
UptimeNone = "Uptime: —"
|
||||
RxLabel = "↓ {{.bytes}}"
|
||||
TxLabel = "↑ {{.bytes}}"
|
||||
RxZero = "↓ 0 B"
|
||||
TxZero = "↑ 0 B"
|
||||
RxV4Label = "IPv4 ↓ {{.bytes}} {{.speed}}"
|
||||
TxV4Label = "↑ {{.bytes}} {{.speed}}"
|
||||
RxV6Label = "IPv6 ↓ {{.bytes}} {{.speed}}"
|
||||
TxV6Label = "↑ {{.bytes}} {{.speed}}"
|
||||
RxTotalLabel = "Total ↓ {{.bytes}} {{.speed}}"
|
||||
TxTotalLabel = "↑ {{.bytes}} {{.speed}}"
|
||||
RxV4Zero = "IPv4 ↓ 0 B 0 bps"
|
||||
TxV4Zero = "↑ 0 B 0 bps"
|
||||
RxV6Zero = "IPv6 ↓ 0 B 0 bps"
|
||||
TxV6Zero = "↑ 0 B 0 bps"
|
||||
RxTotalZero = "Total ↓ 0 B 0 bps"
|
||||
TxTotalZero = "↑ 0 B 0 bps"
|
||||
|
||||
StatusRoutingMode = "Routing Mode: {{.mode}}"
|
||||
CIDRHit = "hit"
|
||||
CIDRConfigured = "configured"
|
||||
CIDRUnmatched = "unmatched"
|
||||
CIDRDestinations = "destinations"
|
||||
RouteLoading = "loading routes"
|
||||
CIDRLoading = "loading"
|
||||
StepFetchCIDRs = "fetching CIDR lists"
|
||||
StepConnecting = "connecting"
|
||||
StepLoadRoutes = "loading routes"
|
||||
|
||||
BtnRefreshCIDR = "Refresh CIDR"
|
||||
CIDRFetchError = "fetch failed"
|
||||
|
||||
DlgNoProfileTitle = "No Profile"
|
||||
DlgNoProfileEditMsg = "Select a profile to edit."
|
||||
@@ -35,6 +59,7 @@ DlgDeleteProfileMsg = 'Delete profile "{{.name}}" and its stored credentials?'
|
||||
DlgProfileTitle = "Profile"
|
||||
DlgValidationTitle = "Validation"
|
||||
DlgValidationMsg = "Name, Host, and Username are required."
|
||||
DlgInvalidIPMsg = "Invalid IP address(es): {{.ips}}"
|
||||
DlgDaemonError = "Daemon Error"
|
||||
DlgCredentialError = "Credential Error"
|
||||
DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it."
|
||||
@@ -43,11 +68,23 @@ DlgVPNError = "VPN Error"
|
||||
DlgSaveError = "Save Error"
|
||||
DlgKeychainError = "Keychain Error"
|
||||
DlgError = "Error"
|
||||
DlgAuthError = "Authentication Failed"
|
||||
AuthErrWrongCredentials = "Wrong username or password. Please check your credentials."
|
||||
AuthErrUserDisabled = "This user account does not exist or has been disabled."
|
||||
AuthErrTokenInvalid = "The authentication token is invalid or expired."
|
||||
AuthErrRateLimited = "Too many authentication attempts. Please try again later."
|
||||
AuthErrMalformed = "Authentication message format error."
|
||||
|
||||
BtnResetDB = "Reset Database"
|
||||
DlgResetDBTitle = "Reset Database"
|
||||
DlgResetDBMsg = "Delete all profiles and connection logs? This cannot be undone."
|
||||
|
||||
BtnProfileList = "Profile List"
|
||||
ProfileListTitle = "Profile List"
|
||||
DlgNoListSelectTitle = "No Selection"
|
||||
DlgNoListSelectEditMsg = "Select a profile in the list to edit."
|
||||
DlgNoListSelectDeleteMsg = "Select a profile in the list to delete."
|
||||
|
||||
TrayShowWindow = "Show Window"
|
||||
TrayConnect = "Connect"
|
||||
TrayDisconnect = "Disconnect"
|
||||
@@ -65,18 +102,50 @@ FieldUsername = "Username"
|
||||
FieldPassword = "Password"
|
||||
FieldAuthMode = "Auth Mode"
|
||||
FieldRoutingMode = "Routing Mode"
|
||||
FieldCustomCIDRs = "Custom CIDRs (comma-separated)"
|
||||
FieldCIDRV4 = "IPv4 CIDRs (comma-separated)"
|
||||
FieldCIDRV6 = "IPv6 CIDRs (comma-separated)"
|
||||
FieldCIDRV4URLs = "IPv4 CIDR URL Sources"
|
||||
FieldCIDRV6URLs = "IPv6 CIDR URL Sources"
|
||||
FieldMTUOverride = "MTU Override"
|
||||
|
||||
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
||||
PlaceholderCIDRV4 = "10.0.0.0/8, 172.16.0.0/12"
|
||||
PlaceholderCIDRV6 = "fd00::/8, 2001:db8::/32"
|
||||
PlaceholderCIDRURL = "https://example.com/cidrs.txt"
|
||||
PlaceholderMTU = "0 = use server MTU"
|
||||
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)"
|
||||
AuthModeJWT = "JWT"
|
||||
AuthModePassword = "Password"
|
||||
|
||||
RoutingModeFull = "Full Tunnel"
|
||||
RoutingModeSplit = "Split Tunnel"
|
||||
RoutingModeCustom = "Custom"
|
||||
RoutingModeProxy = "Proxy CIDR"
|
||||
RoutingModeBypass = "Bypass CIDR"
|
||||
|
||||
FetchTimingBefore = "Before Proxy"
|
||||
FetchTimingAfter = "After Proxy"
|
||||
|
||||
FieldIPPreference = "IP Preference"
|
||||
IPPrefAuto = "Auto (Race)"
|
||||
IPPrefV4 = "IPv4 Only"
|
||||
IPPrefV6 = "IPv6 Only"
|
||||
|
||||
BtnAddURL = "Add URL"
|
||||
BtnRemoveURL = "Remove"
|
||||
|
||||
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."
|
||||
|
||||
DlgBiometricCanceled = "Authentication was canceled."
|
||||
TouchIDPrompt = "authenticate to access your VPN password"
|
||||
|
||||
@@ -19,13 +19,37 @@ BtnCancel = "取消"
|
||||
BtnOK = "确定"
|
||||
|
||||
IpLabel = "IP: {{.ip}}"
|
||||
Ip6Label = "IPv6: {{.ip}}"
|
||||
Ip6None = "IPv6: —"
|
||||
IpNone = "IP: —"
|
||||
UptimeLabel = "运行时长: {{.uptime}}"
|
||||
UptimeNone = "运行时长: —"
|
||||
RxLabel = "↓ {{.bytes}}"
|
||||
TxLabel = "↑ {{.bytes}}"
|
||||
RxZero = "↓ 0 B"
|
||||
TxZero = "↑ 0 B"
|
||||
RxV4Label = "IPv4 ↓ {{.bytes}} {{.speed}}"
|
||||
TxV4Label = "↑ {{.bytes}} {{.speed}}"
|
||||
RxV6Label = "IPv6 ↓ {{.bytes}} {{.speed}}"
|
||||
TxV6Label = "↑ {{.bytes}} {{.speed}}"
|
||||
RxTotalLabel = "合计 ↓ {{.bytes}} {{.speed}}"
|
||||
TxTotalLabel = "↑ {{.bytes}} {{.speed}}"
|
||||
RxV4Zero = "IPv4 ↓ 0 B 0 bps"
|
||||
TxV4Zero = "↑ 0 B 0 bps"
|
||||
RxV6Zero = "IPv6 ↓ 0 B 0 bps"
|
||||
TxV6Zero = "↑ 0 B 0 bps"
|
||||
RxTotalZero = "合计 ↓ 0 B 0 bps"
|
||||
TxTotalZero = "↑ 0 B 0 bps"
|
||||
|
||||
StatusRoutingMode = "路由模式: {{.mode}}"
|
||||
CIDRHit = "命中"
|
||||
CIDRConfigured = "已配置"
|
||||
CIDRUnmatched = "未命中"
|
||||
CIDRDestinations = "目的地址"
|
||||
RouteLoading = "加载路由中"
|
||||
CIDRLoading = "加载中"
|
||||
StepFetchCIDRs = "获取CIDR列表"
|
||||
StepConnecting = "建立连接"
|
||||
StepLoadRoutes = "加载路由"
|
||||
|
||||
BtnRefreshCIDR = "刷新 CIDR"
|
||||
CIDRFetchError = "获取失败"
|
||||
|
||||
DlgNoProfileTitle = "无配置"
|
||||
DlgNoProfileEditMsg = "请选择要编辑的配置。"
|
||||
@@ -35,6 +59,7 @@ DlgDeleteProfileMsg = '删除配置"{{.name}}"及其存储的凭据?'
|
||||
DlgProfileTitle = "配置"
|
||||
DlgValidationTitle = "验证"
|
||||
DlgValidationMsg = "名称、主机名和用户名为必填项。"
|
||||
DlgInvalidIPMsg = "以下 IP 地址格式无效:{{.ips}}"
|
||||
DlgDaemonError = "守护进程错误"
|
||||
DlgCredentialError = "凭据错误"
|
||||
DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。"
|
||||
@@ -43,11 +68,23 @@ DlgVPNError = "VPN 错误"
|
||||
DlgSaveError = "保存错误"
|
||||
DlgKeychainError = "钥匙串错误"
|
||||
DlgError = "错误"
|
||||
DlgAuthError = "认证失败"
|
||||
AuthErrWrongCredentials = "用户名或密码错误,请检查凭据。"
|
||||
AuthErrUserDisabled = "用户不存在或已被禁用。"
|
||||
AuthErrTokenInvalid = "认证令牌无效或已过期。"
|
||||
AuthErrRateLimited = "认证尝试过于频繁,请稍后再试。"
|
||||
AuthErrMalformed = "认证消息格式错误。"
|
||||
|
||||
BtnResetDB = "重置数据库"
|
||||
DlgResetDBTitle = "重置数据库"
|
||||
DlgResetDBMsg = "删除所有配置和连接记录?此操作无法撤销。"
|
||||
|
||||
BtnProfileList = "配置列表"
|
||||
ProfileListTitle = "配置列表"
|
||||
DlgNoListSelectTitle = "无选择"
|
||||
DlgNoListSelectEditMsg = "请在列表中选择一个配置。"
|
||||
DlgNoListSelectDeleteMsg = "请在列表中选择一个配置。"
|
||||
|
||||
TrayShowWindow = "显示窗口"
|
||||
TrayConnect = "连接"
|
||||
TrayDisconnect = "断开连接"
|
||||
@@ -65,18 +102,50 @@ FieldUsername = "用户名"
|
||||
FieldPassword = "密码"
|
||||
FieldAuthMode = "认证方式"
|
||||
FieldRoutingMode = "路由模式"
|
||||
FieldCustomCIDRs = "自定义 CIDR(逗号分隔)"
|
||||
FieldCIDRV4 = "IPv4 CIDR(逗号分隔)"
|
||||
FieldCIDRV6 = "IPv6 CIDR(逗号分隔)"
|
||||
FieldCIDRV4URLs = "IPv4 CIDR URL 来源"
|
||||
FieldCIDRV6URLs = "IPv6 CIDR URL 来源"
|
||||
FieldMTUOverride = "MTU 覆盖"
|
||||
|
||||
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
|
||||
PlaceholderCIDRV4 = "10.0.0.0/8, 172.16.0.0/12"
|
||||
PlaceholderCIDRV6 = "fd00::/8, 2001:db8::/32"
|
||||
PlaceholderCIDRURL = "https://example.com/cidrs.txt"
|
||||
PlaceholderMTU = "0 = 使用服务器 MTU"
|
||||
PlaceholderPasswordUnchanged = "(未更改)"
|
||||
PlaceholderServerIPs = "例: 1.2.3.4, 5.6.7.8"
|
||||
PlaceholderServerIPs = "支持 IPv4/IPv6,例: 1.2.3.4, 5.6.7.8"
|
||||
|
||||
AuthModeBoth = "全部(JWT + 密码)"
|
||||
AuthModeJWT = "JWT"
|
||||
AuthModePassword = "密码"
|
||||
|
||||
RoutingModeFull = "全隧道"
|
||||
RoutingModeSplit = "分离隧道"
|
||||
RoutingModeCustom = "自定义"
|
||||
RoutingModeProxy = "代理 CIDR"
|
||||
RoutingModeBypass = "绕过 CIDR"
|
||||
|
||||
FetchTimingBefore = "代理前获取"
|
||||
FetchTimingAfter = "代理后获取"
|
||||
|
||||
FieldIPPreference = "IP 偏好"
|
||||
IPPrefAuto = "自动(竞速)"
|
||||
IPPrefV4 = "仅 IPv4"
|
||||
IPPrefV6 = "仅 IPv6"
|
||||
|
||||
BtnAddURL = "添加 URL"
|
||||
BtnRemoveURL = "删除"
|
||||
|
||||
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 = "服务器证书验证失败。"
|
||||
|
||||
DlgBiometricCanceled = "认证已取消。"
|
||||
TouchIDPrompt = "验证指纹以访问 VPN 密码"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package instance implements single-instance enforcement for the GUI
|
||||
// process. The first instance to start acquires the lock by listening
|
||||
// on a dedicated endpoint; subsequent instances dial the endpoint
|
||||
// (signalling "bring to front") and exit immediately.
|
||||
//
|
||||
// The lock is self-cleaning: a crashed process releases the listener
|
||||
// automatically (the kernel closes the socket). On unix a stale socket
|
||||
// file may remain; Acquire probes it with a dial before removing.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"lmvpn/internal/paths"
|
||||
)
|
||||
|
||||
// ErrAlreadyRunning is returned by Acquire when another GUI instance
|
||||
// is already running. The caller should exit silently.
|
||||
var ErrAlreadyRunning = errors.New("another instance is already running")
|
||||
|
||||
// Acquire attempts to become the sole GUI instance. On success it
|
||||
// returns a channel that receives a value every time another instance
|
||||
// signals (the caller should bring its window to the front). The
|
||||
// listener is closed when the channel is closed, which happens never
|
||||
// during normal operation - the process simply exits and the OS
|
||||
// releases the socket.
|
||||
//
|
||||
// On failure it returns ErrAlreadyRunning (the caller should exit) or
|
||||
// another error (the caller may continue without single-instance
|
||||
// protection, logging the issue).
|
||||
func Acquire() (<-chan struct{}, error) {
|
||||
netType := paths.GUILockNetwork()
|
||||
addr := paths.GUILockAddress()
|
||||
|
||||
l, err := net.Listen(netType, addr)
|
||||
if err == nil {
|
||||
ch := make(chan struct{}, 1)
|
||||
go acceptLoop(l, ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Listen failed - check whether another instance is alive.
|
||||
if c, derr := net.Dial(netType, addr); derr == nil {
|
||||
c.Close()
|
||||
return nil, ErrAlreadyRunning
|
||||
}
|
||||
|
||||
// Nobody is listening. On unix this is likely a stale socket file
|
||||
// left by a crashed process; remove it and retry once.
|
||||
if netType == "unix" {
|
||||
_ = os.Remove(addr)
|
||||
l, err = net.Listen(netType, addr)
|
||||
if err == nil {
|
||||
ch := make(chan struct{}, 1)
|
||||
go acceptLoop(l, ch)
|
||||
return ch, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("acquire instance lock: %w", err)
|
||||
}
|
||||
|
||||
// acceptLoop accepts connections from second instances. Each connection
|
||||
// is a "focus" signal; the handler closes it immediately and notifies
|
||||
// the caller via ch.
|
||||
func acceptLoop(l net.Listener, ch chan<- struct{}) {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
-12
@@ -29,6 +29,8 @@ const (
|
||||
CmdStop = "stop"
|
||||
CmdShutdown = "shutdown"
|
||||
CmdStats = "stats"
|
||||
CmdVersion = "version" // query daemon build version
|
||||
CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes
|
||||
)
|
||||
|
||||
// Event types sent from daemon to GUI.
|
||||
@@ -55,16 +57,34 @@ type ClientConfig struct {
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
AuthMode string `json:"auth_mode"`
|
||||
RoutingMode string `json:"routing_mode"`
|
||||
CustomCIDRs []string `json:"custom_cidrs"`
|
||||
RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass"
|
||||
CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs
|
||||
CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs
|
||||
CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources
|
||||
CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources
|
||||
MTUOverride int `json:"mtu_override"`
|
||||
TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only)
|
||||
TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only)
|
||||
TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only)
|
||||
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only)
|
||||
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
|
||||
}
|
||||
|
||||
// CIDRURLSource describes a URL that provides a CIDR list. It mirrors
|
||||
// model.CIDRURLSource but is kept here to avoid importing the model
|
||||
// package into the IPC wire format.
|
||||
type CIDRURLSource struct {
|
||||
URL string `json:"url"`
|
||||
FetchTiming string `json:"fetch_timing"` // "before" or "after"
|
||||
}
|
||||
|
||||
// Event is a notification from the daemon to the GUI.
|
||||
type Event struct {
|
||||
Event string `json:"event"`
|
||||
State string `json:"state,omitempty"`
|
||||
ConnectStep string `json:"connect_step,omitempty"` // current connection step (for EvState)
|
||||
Stats *stats.Snapshot `json:"stats,omitempty"`
|
||||
Code string `json:"code,omitempty"` // stable auth-error code for EvError
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
@@ -98,15 +118,34 @@ type Server struct {
|
||||
}
|
||||
|
||||
// NewServer creates (but does not start) the IPC server. It removes
|
||||
// any stale socket file first.
|
||||
// any stale socket file first (unix only).
|
||||
func NewServer() (*Server, error) {
|
||||
_ = os.Remove(paths.IPCSocketPath())
|
||||
l, err := net.Listen("unix", paths.IPCSocketPath())
|
||||
netType := paths.IPCNetwork()
|
||||
addr := paths.IPCAddress()
|
||||
|
||||
// Guard against a second daemon instance on unix: probe the
|
||||
// socket before removing it. If the dial succeeds, another
|
||||
// daemon is alive and owns the socket - refuse to start so we
|
||||
// don't silently orphan it. Only remove the file when the dial
|
||||
// fails (stale socket from a crashed process).
|
||||
if netType == "unix" {
|
||||
if c, derr := net.Dial(netType, addr); derr == nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("daemon already running on %s", addr)
|
||||
}
|
||||
_ = os.Remove(addr)
|
||||
}
|
||||
|
||||
l, err := net.Listen(netType, addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen ipc: %w", err)
|
||||
}
|
||||
// Mode 0660 so group members (admin) can connect.
|
||||
_ = os.Chmod(paths.IPCSocketPath(), 0o660)
|
||||
|
||||
// Set socket permissions so group members can connect (unix only).
|
||||
if netType == "unix" {
|
||||
_ = os.Chmod(addr, 0o660)
|
||||
}
|
||||
|
||||
return &Server{listener: l, clients: make(map[net.Conn]bool)}, nil
|
||||
}
|
||||
|
||||
@@ -176,7 +215,7 @@ type Client struct {
|
||||
|
||||
// Dial connects to the daemon.
|
||||
func Dial() (*Client, error) {
|
||||
conn, err := net.Dial("unix", paths.IPCSocketPath())
|
||||
conn, err := net.Dial(paths.IPCNetwork(), paths.IPCAddress())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial daemon: %w", err)
|
||||
}
|
||||
@@ -205,6 +244,7 @@ func (c *Client) Close() error { return c.conn.Close() }
|
||||
type Response struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Version string `json:"version,omitempty"` // set for CmdVersion replies
|
||||
}
|
||||
|
||||
// WriteOK sends a success response to a connection.
|
||||
@@ -227,20 +267,47 @@ func SendStop(c *Client) error {
|
||||
return c.Send(Request{Cmd: CmdStop})
|
||||
}
|
||||
|
||||
// SendRefreshCIDR is a convenience helper for sending a refresh CIDR command.
|
||||
func SendRefreshCIDR(c *Client) error {
|
||||
return c.Send(Request{Cmd: CmdRefreshCIDR})
|
||||
}
|
||||
|
||||
// SendShutdown is a convenience helper for sending a shutdown command.
|
||||
func SendShutdown(c *Client) error {
|
||||
return c.Send(Request{Cmd: CmdShutdown})
|
||||
}
|
||||
|
||||
// WriteVersion sends a version response to a connection.
|
||||
func WriteVersion(conn net.Conn, ver string) error {
|
||||
return writeMsg(conn, Response{OK: true, Version: ver})
|
||||
}
|
||||
|
||||
// QueryVersion sends a CmdVersion request and reads the daemon's
|
||||
// build version. It must be called before any VPN session starts (so
|
||||
// no Event messages are in flight on the connection).
|
||||
func (c *Client) QueryVersion() (string, error) {
|
||||
if err := c.Send(Request{Cmd: CmdVersion}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp Response
|
||||
if err := readMsg(c.r, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.OK {
|
||||
return "", fmt.Errorf("version query failed: %s", resp.Error)
|
||||
}
|
||||
return resp.Version, nil
|
||||
}
|
||||
|
||||
// RoutingModeFromIPC converts an IPC routing mode string to route.Mode.
|
||||
func RoutingModeFromIPC(s string) route.Mode {
|
||||
switch s {
|
||||
case "full":
|
||||
return route.ModeFull
|
||||
case "split":
|
||||
return route.ModeSplit
|
||||
case "custom":
|
||||
return route.ModeCustom
|
||||
case "proxy":
|
||||
return route.ModeProxy
|
||||
case "bypass":
|
||||
return route.ModeBypass
|
||||
default:
|
||||
return route.ModeFull
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ const ServiceName = paths.BundleID
|
||||
// ErrNotFound is returned when a secret is not present in the store.
|
||||
var ErrNotFound = fmt.Errorf("secret not found")
|
||||
|
||||
// ErrUserCanceled is returned when the user cancels a biometric
|
||||
// authentication prompt (e.g. Touch ID) or the system cannot complete
|
||||
// authentication.
|
||||
var ErrUserCanceled = fmt.Errorf("user canceled authentication")
|
||||
|
||||
// Store is the secret storage interface.
|
||||
type Store interface {
|
||||
SetPassword(profileName, password string) error
|
||||
|
||||
@@ -14,15 +14,34 @@ const (
|
||||
tokenAccountPrefix = "token:"
|
||||
)
|
||||
|
||||
// touchIDPrompt is the localized prompt shown in the Touch ID dialog.
|
||||
// It is set by the UI layer at startup via SetTouchIDPrompt.
|
||||
var touchIDPrompt = "authenticate to access your VPN password"
|
||||
|
||||
// SetTouchIDPrompt sets the localized prompt text shown in the Touch ID
|
||||
// dialog when retrieving secrets from the keychain.
|
||||
func SetTouchIDPrompt(prompt string) {
|
||||
if prompt != "" {
|
||||
touchIDPrompt = prompt
|
||||
}
|
||||
}
|
||||
|
||||
// DarwinStore implements Store using the macOS Keychain.
|
||||
type DarwinStore struct{}
|
||||
|
||||
// New returns a macOS Keychain-backed Store.
|
||||
// New returns a macOS Keychain-backed Store. On Macs with Touch ID,
|
||||
// secrets are stored with biometric (Touch ID) protection; on older
|
||||
// Macs without a biometric sensor, the standard keychain accessibility
|
||||
// is used as a fallback.
|
||||
func New() Store {
|
||||
return DarwinStore{}
|
||||
}
|
||||
|
||||
func (DarwinStore) setItem(account, secret string) error {
|
||||
// Use biometric-protected storage when Touch ID is available.
|
||||
if biometricAvailable() {
|
||||
return storeBiometricItemGo(ServiceName, account, secret)
|
||||
}
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(ServiceName)
|
||||
@@ -38,6 +57,11 @@ func (DarwinStore) setItem(account, secret string) error {
|
||||
}
|
||||
|
||||
func (DarwinStore) getItem(account string) (string, error) {
|
||||
// When Touch ID is available, use the direct CGo path which can
|
||||
// show a biometric prompt for protected items.
|
||||
if biometricAvailable() {
|
||||
return getBiometricItemGo(ServiceName, account, touchIDPrompt)
|
||||
}
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(ServiceName)
|
||||
@@ -55,6 +79,11 @@ func (DarwinStore) getItem(account string) (string, error) {
|
||||
}
|
||||
|
||||
func (DarwinStore) deleteItem(account string) error {
|
||||
// Use the direct CGo delete which works for both biometric and
|
||||
// non-biometric items (and doesn't trigger a Touch ID prompt).
|
||||
if biometricAvailable() {
|
||||
return deleteBiometricItemGo(ServiceName, account)
|
||||
}
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(ServiceName)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//go:build darwin
|
||||
|
||||
package keychain
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -framework CoreFoundation -framework Security -framework LocalAuthentication -framework Foundation
|
||||
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <Security/Security.h>
|
||||
#include <objc/objc.h>
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
|
||||
// LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1
|
||||
|
||||
// biometricAvailable returns 1 if Touch ID is available, 0 otherwise.
|
||||
// Uses the Objective-C runtime to call LAContext without including
|
||||
// Objective-C headers (which cannot be parsed by the C compiler).
|
||||
static int biometricAvailable(void) {
|
||||
Class cls = objc_getClass("LAContext");
|
||||
if (!cls) return 0;
|
||||
|
||||
id alloc = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("alloc"));
|
||||
if (!alloc) return 0;
|
||||
id context = ((id (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("init"));
|
||||
if (!context) {
|
||||
((void (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("release"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// canEvaluatePolicy:error: returns BOOL, takes (NSInteger, NSError**)
|
||||
// LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1
|
||||
BOOL result = ((BOOL (*)(id, SEL, long, void *))objc_msgSend)(
|
||||
context, sel_getUid("canEvaluatePolicy:error:"), 1, NULL);
|
||||
|
||||
((void (*)(id, SEL))objc_msgSend)(context, sel_getUid("release"));
|
||||
return result ? 1 : 0;
|
||||
}
|
||||
|
||||
// secAccessControlCreateBiometric creates a SecAccessControlRef with
|
||||
// kSecAccessControlBiometryAny. Must be released with CFRelease by caller.
|
||||
static SecAccessControlRef secAccessControlCreateBiometric(void) {
|
||||
SecAccessControlRef acl = SecAccessControlCreateWithFlags(
|
||||
kCFAllocatorDefault,
|
||||
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
||||
kSecAccessControlBiometryAny,
|
||||
NULL);
|
||||
return acl;
|
||||
}
|
||||
|
||||
// storeBiometricItem stores data in the keychain with Touch ID protection.
|
||||
// Returns 0 on success, or a non-zero OSStatus error code on failure.
|
||||
static int storeBiometricItem(CFStringRef service, CFStringRef account, CFDataRef data) {
|
||||
SecAccessControlRef acl = secAccessControlCreateBiometric();
|
||||
if (!acl) {
|
||||
return errSecAllocate;
|
||||
}
|
||||
|
||||
const void *keys[] = {
|
||||
kSecClass,
|
||||
kSecAttrService,
|
||||
kSecAttrAccount,
|
||||
kSecValueData,
|
||||
kSecAttrAccessControl,
|
||||
};
|
||||
const void *values[] = {
|
||||
kSecClassGenericPassword,
|
||||
service,
|
||||
account,
|
||||
data,
|
||||
acl,
|
||||
};
|
||||
CFDictionaryRef query = CFDictionaryCreate(
|
||||
kCFAllocatorDefault,
|
||||
keys, values, sizeof(keys) / sizeof(keys[0]),
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks);
|
||||
|
||||
// Delete any existing item first (Add fails on duplicates).
|
||||
SecItemDelete(query);
|
||||
|
||||
OSStatus status = SecItemAdd(query, NULL);
|
||||
CFRelease(query);
|
||||
CFRelease(acl);
|
||||
return (int)status;
|
||||
}
|
||||
|
||||
// getBiometricItem retrieves data from the keychain, prompting for Touch ID
|
||||
// via an LAContext with a localized reason. Returns:
|
||||
// 0 on success (dataOut filled, caller must CFRelease)
|
||||
// -1 if item not found
|
||||
// -128 (errSecUserCanceled) if user canceled
|
||||
// other positive OSStatus error codes on failure
|
||||
//
|
||||
// prompt is the localized Touch ID prompt string (CFStringRef).
|
||||
static int getBiometricItem(CFStringRef service, CFStringRef account, CFStringRef prompt, CFDataRef *dataOut) {
|
||||
// Create an LAContext and set its localizedReason for the Touch ID
|
||||
// prompt. We use the objc runtime to avoid including Objective-C
|
||||
// headers. CFStringRef is toll-free bridged to NSString*.
|
||||
id laContext = NULL;
|
||||
if (prompt) {
|
||||
Class cls = objc_getClass("LAContext");
|
||||
if (cls) {
|
||||
id alloc = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("alloc"));
|
||||
if (alloc) {
|
||||
laContext = ((id (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("init"));
|
||||
}
|
||||
}
|
||||
if (laContext) {
|
||||
((void (*)(id, SEL, id))objc_msgSend)(
|
||||
laContext, sel_getUid("setLocalizedReason:"), (id)prompt);
|
||||
}
|
||||
}
|
||||
|
||||
CFDictionaryRef query;
|
||||
if (laContext) {
|
||||
const void *keys[] = {
|
||||
kSecClass,
|
||||
kSecAttrService,
|
||||
kSecAttrAccount,
|
||||
kSecMatchLimit,
|
||||
kSecReturnData,
|
||||
kSecUseAuthenticationContext,
|
||||
};
|
||||
const void *values[] = {
|
||||
kSecClassGenericPassword,
|
||||
service,
|
||||
account,
|
||||
kSecMatchLimitOne,
|
||||
kCFBooleanTrue,
|
||||
laContext,
|
||||
};
|
||||
query = CFDictionaryCreate(
|
||||
kCFAllocatorDefault,
|
||||
keys, values, sizeof(keys) / sizeof(keys[0]),
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks);
|
||||
} else {
|
||||
// Fallback: no LAContext (e.g. LAContext class not available).
|
||||
const void *keys[] = {
|
||||
kSecClass,
|
||||
kSecAttrService,
|
||||
kSecAttrAccount,
|
||||
kSecMatchLimit,
|
||||
kSecReturnData,
|
||||
};
|
||||
const void *values[] = {
|
||||
kSecClassGenericPassword,
|
||||
service,
|
||||
account,
|
||||
kSecMatchLimitOne,
|
||||
kCFBooleanTrue,
|
||||
};
|
||||
query = CFDictionaryCreate(
|
||||
kCFAllocatorDefault,
|
||||
keys, values, sizeof(keys) / sizeof(keys[0]),
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks);
|
||||
}
|
||||
|
||||
CFTypeRef result = NULL;
|
||||
OSStatus status = SecItemCopyMatching(query, &result);
|
||||
CFRelease(query);
|
||||
|
||||
if (laContext) {
|
||||
((void (*)(id, SEL))objc_msgSend)(laContext, sel_getUid("release"));
|
||||
}
|
||||
|
||||
if (status == errSecItemNotFound) {
|
||||
return -1;
|
||||
}
|
||||
if (status != errSecSuccess) {
|
||||
return (int)status;
|
||||
}
|
||||
*dataOut = (CFDataRef)result;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// deleteBiometricItem deletes a keychain item by service+account.
|
||||
// Returns 0 on success (including "not found"), or OSStatus on error.
|
||||
static int deleteBiometricItem(CFStringRef service, CFStringRef account) {
|
||||
const void *keys[] = {
|
||||
kSecClass,
|
||||
kSecAttrService,
|
||||
kSecAttrAccount,
|
||||
};
|
||||
const void *values[] = {
|
||||
kSecClassGenericPassword,
|
||||
service,
|
||||
account,
|
||||
};
|
||||
CFDictionaryRef query = CFDictionaryCreate(
|
||||
kCFAllocatorDefault,
|
||||
keys, values, sizeof(keys) / sizeof(keys[0]),
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks);
|
||||
|
||||
OSStatus status = SecItemDelete(query);
|
||||
CFRelease(query);
|
||||
if (status == errSecItemNotFound) {
|
||||
return 0;
|
||||
}
|
||||
return (int)status;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// errSecUserCanceled is the macOS Security framework error code returned
|
||||
// when the user cancels a Touch ID / password prompt (-128).
|
||||
const errSecUserCanceled = -128
|
||||
|
||||
var (
|
||||
biometricCacheOnce sync.Once
|
||||
biometricCacheVal bool
|
||||
)
|
||||
|
||||
// biometricAvailable reports whether Touch ID is available on this Mac.
|
||||
// The result is computed once and cached.
|
||||
func biometricAvailable() bool {
|
||||
biometricCacheOnce.Do(func() {
|
||||
biometricCacheVal = C.biometricAvailable() == 1
|
||||
})
|
||||
return biometricCacheVal
|
||||
}
|
||||
|
||||
// cfRelease releases a CFTypeRef (CFString, CFData, etc.).
|
||||
func cfRelease(ref C.CFTypeRef) {
|
||||
if ref != 0 {
|
||||
C.CFRelease(ref)
|
||||
}
|
||||
}
|
||||
|
||||
// cfString creates a CFStringRef from a Go string. The caller must
|
||||
// CFRelease the result.
|
||||
func cfString(s string) (C.CFStringRef, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cs := C.CFStringCreateWithBytes(
|
||||
C.kCFAllocatorDefault,
|
||||
(*C.UInt8)(unsafe.Pointer(&[]byte(s)[0])),
|
||||
C.CFIndex(len(s)),
|
||||
C.kCFStringEncodingUTF8,
|
||||
C.false)
|
||||
if cs == 0 {
|
||||
return 0, fmt.Errorf("CFStringCreateWithBytes failed for %q", s)
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// cfData creates a CFDataRef from a byte slice. The caller must
|
||||
// CFRelease the result.
|
||||
func cfData(b []byte) (C.CFDataRef, error) {
|
||||
var p *C.UInt8
|
||||
if len(b) > 0 {
|
||||
p = (*C.UInt8)(unsafe.Pointer(&b[0]))
|
||||
}
|
||||
cd := C.CFDataCreate(C.kCFAllocatorDefault, p, C.CFIndex(len(b)))
|
||||
if cd == 0 {
|
||||
return 0, fmt.Errorf("CFDataCreate failed")
|
||||
}
|
||||
return cd, nil
|
||||
}
|
||||
|
||||
// storeBiometricItemGo stores a secret in the keychain with Touch ID
|
||||
// protection. It first deletes any existing item (which may or may not
|
||||
// have biometric protection), then adds a new biometric-protected item.
|
||||
func storeBiometricItemGo(service, account, secret string) error {
|
||||
svcRef, err := cfString(service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric store %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(svcRef))
|
||||
|
||||
accRef, err := cfString(account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric store %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(accRef))
|
||||
|
||||
secretBytes := []byte(secret)
|
||||
dataRef, err := cfData(secretBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric store %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(dataRef))
|
||||
|
||||
status := C.storeBiometricItem(svcRef, accRef, dataRef)
|
||||
if status != 0 {
|
||||
return fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBiometricItemGo retrieves a secret from the keychain, prompting
|
||||
// for Touch ID if the item is biometric-protected. prompt is the
|
||||
// localized text shown in the Touch ID dialog.
|
||||
func getBiometricItemGo(service, account, prompt string) (string, error) {
|
||||
svcRef, err := cfString(service)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(svcRef))
|
||||
|
||||
accRef, err := cfString(account)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(accRef))
|
||||
|
||||
promptRef, err := cfString(prompt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keychain biometric get %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(promptRef))
|
||||
|
||||
var dataOut C.CFDataRef
|
||||
status := C.getBiometricItem(svcRef, accRef, promptRef, &dataOut)
|
||||
if status == -1 {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if status == errSecUserCanceled {
|
||||
return "", ErrUserCanceled
|
||||
}
|
||||
if status != 0 {
|
||||
return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(dataOut))
|
||||
|
||||
bytes := C.GoBytes(unsafe.Pointer(C.CFDataGetBytePtr(dataOut)), C.int(C.CFDataGetLength(dataOut)))
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// deleteBiometricItemGo deletes a keychain item by service+account.
|
||||
// It works regardless of whether the item has biometric protection.
|
||||
func deleteBiometricItemGo(service, account string) error {
|
||||
svcRef, err := cfString(service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric delete %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(svcRef))
|
||||
|
||||
accRef, err := cfString(account)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keychain biometric delete %s: %w", account, err)
|
||||
}
|
||||
defer cfRelease(C.CFTypeRef(accRef))
|
||||
|
||||
status := C.deleteBiometricItem(svcRef, accRef)
|
||||
if status != 0 {
|
||||
return fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package keychain
|
||||
|
||||
// MemStore is an in-memory fallback used on non-darwin platforms.
|
||||
// MemStore is an in-memory fallback used on non-darwin, non-windows
|
||||
// platforms (currently Linux). Passwords do not persist across
|
||||
// restarts; a platform-appropriate backend (e.g. Secret Service)
|
||||
// should be added for production use.
|
||||
type MemStore struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//go:build windows
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/danieljoos/wincred"
|
||||
)
|
||||
|
||||
// keyPrefixes distinguish password vs token entries in the credential
|
||||
// store, mirroring the macOS implementation.
|
||||
const (
|
||||
passwordAccountPrefix = "password:"
|
||||
tokenAccountPrefix = "token:"
|
||||
)
|
||||
|
||||
// WinStore implements Store using the Windows Credential Manager.
|
||||
type WinStore struct{}
|
||||
|
||||
// New returns a Windows Credential Manager-backed Store.
|
||||
func New() Store {
|
||||
return WinStore{}
|
||||
}
|
||||
|
||||
// targetName builds the credential TargetName from the account prefix
|
||||
// and profile name: "<BundleID>/<prefix><profile>".
|
||||
func targetName(account string) string {
|
||||
return ServiceName + "/" + account
|
||||
}
|
||||
|
||||
func (WinStore) setItem(account, secret string) error {
|
||||
cred := wincred.NewGenericCredential(targetName(account))
|
||||
cred.CredentialBlob = []byte(secret)
|
||||
if err := cred.Write(); err != nil {
|
||||
return fmt.Errorf("wincred write %s: %w", account, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (WinStore) getItem(account string) (string, error) {
|
||||
cred, err := wincred.GetGenericCredential(targetName(account))
|
||||
if err != nil {
|
||||
if errors.Is(err, wincred.ErrElementNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("wincred read %s: %w", account, err)
|
||||
}
|
||||
return string(cred.CredentialBlob), nil
|
||||
}
|
||||
|
||||
func (WinStore) deleteItem(account string) error {
|
||||
cred, err := wincred.GetGenericCredential(targetName(account))
|
||||
if err != nil {
|
||||
if errors.Is(err, wincred.ErrElementNotFound) {
|
||||
return nil // already gone — treat as success
|
||||
}
|
||||
return fmt.Errorf("wincred read %s: %w", account, err)
|
||||
}
|
||||
if err := cred.Delete(); err != nil {
|
||||
return fmt.Errorf("wincred delete %s: %w", account, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s WinStore) SetPassword(profileName, password string) error {
|
||||
return s.setItem(passwordAccountPrefix+profileName, password)
|
||||
}
|
||||
|
||||
func (s WinStore) GetPassword(profileName string) (string, error) {
|
||||
return s.getItem(passwordAccountPrefix + profileName)
|
||||
}
|
||||
|
||||
func (s WinStore) DeletePassword(profileName string) error {
|
||||
return s.deleteItem(passwordAccountPrefix + profileName)
|
||||
}
|
||||
|
||||
func (s WinStore) SetToken(profileName, token string) error {
|
||||
return s.setItem(tokenAccountPrefix+profileName, token)
|
||||
}
|
||||
|
||||
func (s WinStore) GetToken(profileName string) (string, error) {
|
||||
return s.getItem(tokenAccountPrefix + profileName)
|
||||
}
|
||||
|
||||
func (s WinStore) DeleteToken(profileName string) error {
|
||||
return s.deleteItem(tokenAccountPrefix + profileName)
|
||||
}
|
||||
|
||||
func (s WinStore) DeleteAll(profileName string) error {
|
||||
_ = s.DeletePassword(profileName)
|
||||
return s.DeleteToken(profileName)
|
||||
}
|
||||
+87
-11
@@ -3,7 +3,9 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -17,15 +19,40 @@ const (
|
||||
AuthModePassword AuthMode = "password" // {type:auth} first message
|
||||
)
|
||||
|
||||
// IPPreference controls which IP address family is used when connecting
|
||||
// to a server by hostname (ServerIPs empty).
|
||||
type IPPreference string
|
||||
|
||||
const (
|
||||
IPPrefAuto IPPreference = "auto" // race all resolved addresses (v4 + v6)
|
||||
IPPrefV4 IPPreference = "v4" // only IPv4 addresses
|
||||
IPPrefV6 IPPreference = "v6" // only IPv6 addresses
|
||||
)
|
||||
|
||||
// RoutingMode selects which traffic goes through the VPN tunnel.
|
||||
type RoutingMode string
|
||||
|
||||
const (
|
||||
RoutingFull RoutingMode = "full" // 0.0.0.0/0 via tunnel
|
||||
RoutingSplit RoutingMode = "split" // only VPN subnet via tunnel
|
||||
RoutingCustom RoutingMode = "custom" // user-specified CIDRs
|
||||
RoutingFull RoutingMode = "full" // 全隧道: all traffic via tunnel
|
||||
RoutingProxy RoutingMode = "proxy" // 代理CIDR: only specified CIDRs via tunnel
|
||||
RoutingBypass RoutingMode = "bypass" // 绕过CIDR: all traffic via tunnel except specified CIDRs
|
||||
)
|
||||
|
||||
// FetchTiming specifies when a CIDR URL source is fetched.
|
||||
type FetchTiming string
|
||||
|
||||
const (
|
||||
FetchBefore FetchTiming = "before" // before proxy: fetched via direct connection before routing is applied
|
||||
FetchAfter FetchTiming = "after" // after proxy: fetched via the tunnel after the data plane is up
|
||||
)
|
||||
|
||||
// CIDRURLSource describes a URL that provides a CIDR list. The list
|
||||
// is fetched at FetchTiming and merged into the routing configuration.
|
||||
type CIDRURLSource struct {
|
||||
URL string `json:"url"`
|
||||
FetchTiming FetchTiming `json:"fetch_timing"` // "before" or "after"
|
||||
}
|
||||
|
||||
// ServerProfile is a saved VPN server configuration.
|
||||
type ServerProfile struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -38,9 +65,17 @@ type ServerProfile struct {
|
||||
Username string `json:"username"`
|
||||
AuthMode AuthMode `json:"auth_mode"`
|
||||
RoutingMode RoutingMode `json:"routing_mode"`
|
||||
CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom
|
||||
CIDRV4 string `json:"cidr_v4"` // comma-separated static IPv4 CIDRs
|
||||
CIDRV6 string `json:"cidr_v6"` // comma-separated static IPv6 CIDRs
|
||||
CIDRV4URLs string `json:"cidr_v4_urls"` // JSON array of CIDRURLSource for IPv4
|
||||
CIDRV6URLs string `json:"cidr_v6_urls"` // JSON array of CIDRURLSource for IPv6
|
||||
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
|
||||
AutoConnect bool `json:"auto_connect"`
|
||||
TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (wss only)
|
||||
TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only)
|
||||
TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only)
|
||||
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only)
|
||||
IPPreference string `json:"ip_preference"` // "auto", "v4", "v6" (hostname mode only)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastConnectedAt *time.Time `json:"last_connected_at"`
|
||||
}
|
||||
@@ -81,17 +116,57 @@ func (p *ServerProfile) BuildServerURL(ip ...string) string {
|
||||
return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path)
|
||||
}
|
||||
|
||||
// GetServerIPList parses ServerIPs into a string slice.
|
||||
func (p *ServerProfile) GetServerIPList() []string {
|
||||
// ValidateServerIPs parses ServerIPs, returning valid IP addresses
|
||||
// and any invalid entries (for UI error reporting).
|
||||
func (p *ServerProfile) ValidateServerIPs() (valid []string, invalid []string) {
|
||||
if p.ServerIPs == "" {
|
||||
return nil, nil
|
||||
}
|
||||
for _, part := range strings.Split(p.ServerIPs, ",") {
|
||||
s := strings.TrimSpace(part)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(s) != nil {
|
||||
valid = append(valid, s)
|
||||
} else {
|
||||
invalid = append(invalid, s)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ParseCIDRURLs decodes a JSON-encoded CIDRURLSource array. Returns an
|
||||
// empty slice if the string is empty or unparseable.
|
||||
func ParseCIDRURLs(jsonStr string) []CIDRURLSource {
|
||||
if jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var sources []CIDRURLSource
|
||||
if err := json.Unmarshal([]byte(jsonStr), &sources); err != nil {
|
||||
return nil
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
// SplitCIDRs splits a comma-separated CIDR string into a slice,
|
||||
// trimming whitespace from each entry and skipping empty ones.
|
||||
func SplitCIDRs(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(p.ServerIPs, ",")
|
||||
var out []string
|
||||
for _, part := range parts {
|
||||
s := strings.TrimSpace(part)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
c := strings.TrimSpace(part)
|
||||
if c != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -113,6 +188,7 @@ type ConnectionLog struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at"`
|
||||
AssignedIP string `json:"assigned_ip"`
|
||||
AssignedIP6 string `json:"assigned_ip6"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
Status ConnectionStatus `json:"status"`
|
||||
|
||||
@@ -56,7 +56,3 @@ func LogFile() string { return Paths.Log + "/lmvpn.log" }
|
||||
|
||||
// DaemonLogFile returns the path to the daemon log file.
|
||||
func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" }
|
||||
|
||||
// IPCSocketPath returns the path to the unix domain socket used for
|
||||
// GUI <-> daemon communication.
|
||||
func IPCSocketPath() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
@@ -7,6 +7,18 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
|
||||
func IPCNetwork() string { return "unix" }
|
||||
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
func IPCAddress() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
func GUILockNetwork() string { return "unix" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return Paths.Data + "/gui.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package paths
|
||||
|
||||
@@ -7,6 +7,18 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
|
||||
func IPCNetwork() string { return "unix" }
|
||||
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
func IPCAddress() string { return "/tmp/lmvpn.sock" }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
func GUILockNetwork() string { return "unix" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return Paths.Data + "/gui.sock" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build windows
|
||||
|
||||
package paths
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
|
||||
// Windows uses TCP because AF_UNIX sockets enforce mandatory
|
||||
// integrity-level checks: a socket created by the elevated daemon
|
||||
// (High Integrity) cannot be connected to by the non-elevated GUI
|
||||
// (Medium Integrity). TCP on 127.0.0.1 has no such restriction.
|
||||
func IPCNetwork() string { return "tcp" }
|
||||
|
||||
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
|
||||
// On Windows this is a TCP address on localhost.
|
||||
const ipcPort = "18923"
|
||||
|
||||
func IPCAddress() string { return "127.0.0.1:" + ipcPort }
|
||||
|
||||
// GUILockNetwork returns the transport for the GUI single-instance lock.
|
||||
// Windows uses TCP (same reason as IPC: AF_UNIX integrity-level checks).
|
||||
func GUILockNetwork() string { return "tcp" }
|
||||
|
||||
// GUILockAddress returns the address for the GUI single-instance lock.
|
||||
func GUILockAddress() string { return "127.0.0.1:18924" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
}
|
||||
|
||||
// recomputePaths sets Paths based on the given home directory.
|
||||
// On Windows the layout follows platform conventions:
|
||||
//
|
||||
// %APPDATA%\<BundleID>\ data (db, config)
|
||||
// %LOCALAPPDATA%\<BundleID>\ cache
|
||||
// %LOCALAPPDATA%\<BundleID>\Logs\ logs
|
||||
func recomputePaths(home string) {
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
appData = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
if localAppData == "" {
|
||||
localAppData = filepath.Join(home, "AppData", "Local")
|
||||
}
|
||||
Paths = Dirs{
|
||||
Data: filepath.Join(appData, BundleID),
|
||||
Cache: filepath.Join(localAppData, BundleID),
|
||||
Log: filepath.Join(localAppData, BundleID, "Logs"),
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,16 @@ const (
|
||||
)
|
||||
|
||||
// InitMessage is sent by the server after auth + pre-checks pass.
|
||||
// (server: protocol.go:3-9, tunnel.go:126-137)
|
||||
// (server: protocol.go:3-10, tunnel.go:134-145)
|
||||
type InitMessage struct {
|
||||
Type string `json:"type"`
|
||||
IP string `json:"ip"` // assigned client IP (dotted-quad)
|
||||
Prefix int `json:"prefix"` // subnet prefix length (e.g. 24)
|
||||
IP string `json:"ip"` // assigned client IPv4 (dotted-quad)
|
||||
Prefix int `json:"prefix"` // IPv4 subnet prefix length (e.g. 24)
|
||||
MTU int `json:"mtu"` // TUN device MTU (e.g. 1420)
|
||||
ServerIP string `json:"server_ip"` // server's tunnel IP (peer/gateway)
|
||||
ServerIP string `json:"server_ip"` // server's tunnel IPv4 (peer/gateway)
|
||||
IP6 string `json:"ip6,omitempty"` // assigned client IPv6 (only when server has Subnet6)
|
||||
Prefix6 int `json:"prefix6,omitempty"` // IPv6 subnet prefix length
|
||||
ServerIP6 string `json:"server_ip6,omitempty"` // server's tunnel IPv6
|
||||
}
|
||||
|
||||
// ControlMessage is the generic text control message.
|
||||
@@ -66,3 +69,39 @@ type AuthResponse struct {
|
||||
func IsError(msgType string) bool {
|
||||
return msgType == TypeAuthErr || msgType == TypeError
|
||||
}
|
||||
|
||||
// AuthErrorCode is a stable, locale-independent identifier for a fatal
|
||||
// authentication failure. It is derived from the server's auth_err
|
||||
// message (or HTTP status for the JWT login path) and carried over IPC
|
||||
// to the GUI, which maps it to a localized user-facing string.
|
||||
type AuthErrorCode string
|
||||
|
||||
const (
|
||||
AuthCodeWrongCredentials AuthErrorCode = "wrong_credentials" // 用户名或密码错误 / HTTP 401,403
|
||||
AuthCodeUserDisabled AuthErrorCode = "user_disabled" // 用户不存在或已禁用
|
||||
AuthCodeTokenInvalid AuthErrorCode = "token_invalid" // 令牌无效或已过期
|
||||
AuthCodeRateLimited AuthErrorCode = "rate_limited" // 认证尝试过于频繁 / HTTP 429
|
||||
AuthCodeMalformed AuthErrorCode = "malformed" // 消息格式错误
|
||||
)
|
||||
|
||||
// AuthErrorCodeFromMessage maps a server auth_err message string to a
|
||||
// stable AuthErrorCode. It returns the empty string for an unrecognized
|
||||
// message, in which case the caller should treat the failure as
|
||||
// non-categorical (and typically still fatal, since the server closes
|
||||
// the connection after auth_err).
|
||||
func AuthErrorCodeFromMessage(msg string) AuthErrorCode {
|
||||
switch msg {
|
||||
case "用户名或密码错误":
|
||||
return AuthCodeWrongCredentials
|
||||
case "用户不存在或已禁用":
|
||||
return AuthCodeUserDisabled
|
||||
case "令牌无效或已过期":
|
||||
return AuthCodeTokenInvalid
|
||||
case "认证尝试过于频繁,请稍后再试":
|
||||
return AuthCodeRateLimited
|
||||
case "消息格式错误":
|
||||
return AuthCodeMalformed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// cidrmerge.go merges adjacent CIDR blocks to reduce the total number
|
||||
// of routes. For example, 1.0.1.0/24 + 1.0.2.0/23 -> 1.0.0.0/22.
|
||||
// This is critical for large lists like chnroute (8786 entries) where
|
||||
// many blocks can be merged, cutting the route count by 50-70%.
|
||||
package route
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// mergeCIDRs takes a list of CIDR strings, parses them, merges
|
||||
// adjacent blocks, and returns a deduplicated, minimized list.
|
||||
// Invalid CIDR strings are silently skipped.
|
||||
func mergeCIDRs(cidrs []string) []string {
|
||||
if len(cidrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var nets []netEntry
|
||||
for _, s := range cidrs {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ones, _ := n.Mask.Size()
|
||||
isV6 := n.IP.To4() == nil
|
||||
if isV6 {
|
||||
nets = append(nets, netEntry{ip: n.IP.To16(), bits: ones})
|
||||
} else {
|
||||
nets = append(nets, netEntry{ip: n.IP.To4(), bits: ones})
|
||||
}
|
||||
}
|
||||
|
||||
// Split into v4 and v6, merge separately.
|
||||
var v4nets, v6nets []netEntry
|
||||
for _, n := range nets {
|
||||
if n.ip.To4() != nil && len(n.ip) == 4 {
|
||||
v4nets = append(v4nets, n)
|
||||
} else if n.ip.To4() == nil && len(n.ip) == 16 {
|
||||
v6nets = append(v6nets, n)
|
||||
}
|
||||
}
|
||||
|
||||
mergedV4 := mergeNets(v4nets, 32)
|
||||
mergedV6 := mergeNets(v6nets, 128)
|
||||
|
||||
var result []string
|
||||
for _, n := range mergedV4 {
|
||||
result = append(result, n.ip.String()+"/"+itoa(n.bits))
|
||||
}
|
||||
for _, n := range mergedV6 {
|
||||
result = append(result, n.ip.String()+"/"+itoa(n.bits))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type netEntry struct {
|
||||
ip net.IP
|
||||
bits int
|
||||
}
|
||||
|
||||
type sortableNet struct {
|
||||
ip []byte
|
||||
bits int
|
||||
}
|
||||
|
||||
func mergeNets(nets []netEntry, maxBits int) []netEntry {
|
||||
if len(nets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to sortable form.
|
||||
var sn []sortableNet
|
||||
for _, n := range nets {
|
||||
sn = append(sn, sortableNet{ip: []byte(n.ip), bits: n.bits})
|
||||
}
|
||||
|
||||
// Sort by IP then by prefix length (more specific first).
|
||||
sort.Slice(sn, func(i, j int) bool {
|
||||
return cmpIP(sn[i].ip, sn[j].ip) < 0 ||
|
||||
(cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits)
|
||||
})
|
||||
|
||||
// Merge: repeatedly try to combine pairs into supernets.
|
||||
// Two adjacent /n networks can merge into a /(n-1) if:
|
||||
// 1. Both have the same prefix length n
|
||||
// 2. Their network addresses differ only in bit n (i.e. one ends in 0, the other in 1 at position n)
|
||||
// 3. The merged address has bit n cleared
|
||||
changed := true
|
||||
for changed {
|
||||
changed = false
|
||||
var merged []sortableNet
|
||||
i := 0
|
||||
for i < len(sn) {
|
||||
if i+1 < len(sn) && canMerge(sn[i], sn[i+1], maxBits) {
|
||||
mergedNet := mergePair(sn[i], maxBits)
|
||||
merged = append(merged, mergedNet)
|
||||
i += 2
|
||||
changed = true
|
||||
} else {
|
||||
merged = append(merged, sn[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
sn = merged
|
||||
// Re-sort after merge (merged pairs may be out of order).
|
||||
if changed {
|
||||
sort.Slice(sn, func(i, j int) bool {
|
||||
return cmpIP(sn[i].ip, sn[j].ip) < 0 ||
|
||||
(cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convert back.
|
||||
var result []netEntry
|
||||
for _, n := range sn {
|
||||
result = append(result, netEntry{ip: net.IP(n.ip), bits: n.bits})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func canMerge(a, b sortableNet, maxBits int) bool {
|
||||
if a.bits != b.bits || a.bits == 0 {
|
||||
return false
|
||||
}
|
||||
if len(a.ip) != len(b.ip) {
|
||||
return false
|
||||
}
|
||||
// Check that they share the same prefix up to bit (a.bits - 1).
|
||||
prefixBits := a.bits - 1
|
||||
byteIdx := prefixBits / 8
|
||||
bitIdx := uint(7 - (prefixBits % 8))
|
||||
|
||||
// All bytes before byteIdx must be equal.
|
||||
for k := 0; k < byteIdx; k++ {
|
||||
if a.ip[k] != b.ip[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// In byteIdx, bits above bitIdx must be equal.
|
||||
mask := byte(0xFF << (bitIdx + 1))
|
||||
if a.ip[byteIdx]&mask != b.ip[byteIdx]&mask {
|
||||
return false
|
||||
}
|
||||
// The bit at bitIdx must differ (one is 0, one is 1).
|
||||
bitA := (a.ip[byteIdx] >> bitIdx) & 1
|
||||
bitB := (b.ip[byteIdx] >> bitIdx) & 1
|
||||
if bitA == bitB {
|
||||
return false
|
||||
}
|
||||
// The lower bits of both must be zero (network address).
|
||||
lowerMask := byte(1<<bitIdx) - 1
|
||||
if a.ip[byteIdx]&lowerMask != 0 || b.ip[byteIdx]&lowerMask != 0 {
|
||||
return false
|
||||
}
|
||||
// All bytes after byteIdx must be zero.
|
||||
for k := byteIdx + 1; k < len(a.ip); k++ {
|
||||
if a.ip[k] != 0 || b.ip[k] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergePair(a sortableNet, maxBits int) sortableNet {
|
||||
result := make([]byte, len(a.ip))
|
||||
copy(result, a.ip)
|
||||
// Clear the bit at position (a.bits - 1).
|
||||
prefixBits := a.bits - 1
|
||||
byteIdx := prefixBits / 8
|
||||
bitIdx := uint(7 - (prefixBits % 8))
|
||||
result[byteIdx] &^= 1 << bitIdx
|
||||
return sortableNet{ip: result, bits: a.bits - 1}
|
||||
}
|
||||
|
||||
func cmpIP(a, b []byte) int {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return len(a) - len(b)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
negative := n < 0
|
||||
if negative {
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if negative {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
+429
-71
@@ -1,19 +1,37 @@
|
||||
// Package route manages VPN routing on the client. It supports three
|
||||
// modes:
|
||||
//
|
||||
// - Full tunnel: all traffic (0.0.0.0/0) via the TUN interface,
|
||||
// with a bypass route for the server's public IP so
|
||||
// the WebSocket connection stays on the physical NIC
|
||||
// - Split tunnel: only the VPN virtual subnet via the TUN interface
|
||||
// - Custom: user-specified CIDRs via the TUN interface
|
||||
// - Full tunnel: all traffic (0.0.0.0/0 and ::/0) via the TUN
|
||||
// interface, with bypass routes for the server's
|
||||
// public IP (v4 and v6) so the WebSocket connection
|
||||
// stays on the physical NIC
|
||||
// - Proxy CIDR: only the specified CIDRs (v4 and v6) via TUN
|
||||
// - Bypass CIDR: all traffic via TUN except the specified CIDRs,
|
||||
// which are routed via the original gateway
|
||||
//
|
||||
// All routes are tracked so they can be cleanly removed on disconnect.
|
||||
// Routing is applied in two phases to avoid blocking the server's
|
||||
// ReadyTimeout:
|
||||
//
|
||||
// - Apply (essential): server bypass + /1 cover routes (4-6 commands,
|
||||
// <1s). Runs inside the handshake before "ready" is sent.
|
||||
// - ApplyDeferred: user CIDR routes (potentially thousands). Runs in
|
||||
// a background goroutine after the tunnel is up. Uses batch script
|
||||
// execution to minimize process creation overhead.
|
||||
//
|
||||
// IPv6 routes are applied automatically when the server assigned an
|
||||
// IPv6 address (Config.VPNIP6 != ""). All routes are tracked so they
|
||||
// can be cleanly removed on disconnect.
|
||||
package route
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
// Mode selects which traffic goes through the VPN tunnel.
|
||||
@@ -21,27 +39,39 @@ type Mode string
|
||||
|
||||
const (
|
||||
ModeFull Mode = "full"
|
||||
ModeSplit Mode = "split"
|
||||
ModeCustom Mode = "custom"
|
||||
ModeProxy Mode = "proxy"
|
||||
ModeBypass Mode = "bypass"
|
||||
)
|
||||
|
||||
// Config describes the desired routing configuration.
|
||||
type Config struct {
|
||||
Mode Mode
|
||||
InterfaceName string // e.g. "utun4"
|
||||
VPNIP string // assigned tunnel IP, e.g. "192.168.77.5"
|
||||
VPNPrefix int // subnet prefix, e.g. 24
|
||||
VPNIP string // assigned tunnel IPv4, e.g. "192.168.77.5"
|
||||
VPNPrefix int // IPv4 subnet prefix, e.g. 24
|
||||
VPNIP6 string // assigned tunnel IPv6 (empty = v4-only)
|
||||
VPNPrefix6 int // IPv6 subnet prefix
|
||||
ServerHost string // server hostname/IP (for full-tunnel bypass)
|
||||
CustomCIDRs []string // for ModeCustom
|
||||
CIDRs []string // CIDR list for ModeProxy and ModeBypass
|
||||
}
|
||||
|
||||
// Manager applies and removes routes. It tracks all added routes so
|
||||
// they can be cleaned up deterministically.
|
||||
// they can be cleaned up deterministically. All methods are safe for
|
||||
// concurrent use (Apply/ApplyDeferred may run in one goroutine while
|
||||
// Cleanup runs in another).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
addedRoutes []string // route specs added, for deletion
|
||||
addedRoutes []string // v4 route specs added via TUN, for deletion
|
||||
addedRoutes6 []string // v6 route specs added via TUN, for deletion
|
||||
bypassRoutes []string // v4 bypass route specs added via gateway
|
||||
bypassRoutes6 []string // v6 bypass route specs added via gateway
|
||||
serverBypass bool
|
||||
serverBypass6 bool
|
||||
originalGateway string
|
||||
originalGateway6 string
|
||||
serverIP string // resolved v4 (for bypass delete)
|
||||
serverIP6 string // resolved v6 (for bypass delete)
|
||||
}
|
||||
|
||||
// NewManager creates a route manager for the given configuration.
|
||||
@@ -49,130 +79,458 @@ func NewManager(cfg Config) *Manager {
|
||||
return &Manager{cfg: cfg}
|
||||
}
|
||||
|
||||
// Apply adds routes according to the configured mode.
|
||||
// Apply adds essential routes that must be in place before the VPN
|
||||
// tunnel "ready" signal is sent. This completes in <1 second (4-6
|
||||
// route commands). User CIDR routes are deferred to ApplyDeferred.
|
||||
func (m *Manager) Apply() error {
|
||||
switch m.cfg.Mode {
|
||||
case ModeFull:
|
||||
return m.applyFull()
|
||||
case ModeSplit:
|
||||
return m.applySplit()
|
||||
case ModeCustom:
|
||||
return m.applyCustom()
|
||||
case ModeProxy:
|
||||
return m.applyProxyEssential()
|
||||
case ModeBypass:
|
||||
return m.applyBypassEssential()
|
||||
default:
|
||||
return fmt.Errorf("unknown routing mode: %s", m.cfg.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup removes all routes that were added by Apply.
|
||||
func (m *Manager) Cleanup() error {
|
||||
// ApplyDeferred adds user CIDR routes that were deferred from Apply.
|
||||
// This should be called in a background goroutine after the tunnel is
|
||||
// up. For full-tunnel mode this is a no-op. For proxy/bypass modes it
|
||||
// uses batch script execution to handle potentially thousands of CIDRs
|
||||
// efficiently.
|
||||
func (m *Manager) ApplyDeferred() error {
|
||||
switch m.cfg.Mode {
|
||||
case ModeFull:
|
||||
return nil
|
||||
case ModeProxy:
|
||||
return m.applyProxyDeferred()
|
||||
case ModeBypass:
|
||||
return m.applyBypassDeferred()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AddRoutes dynamically adds routes for additional CIDRs after the
|
||||
// initial Apply/ApplyDeferred. This is used for CIDRs fetched from URLs
|
||||
// after the tunnel is established. Uses batch execution.
|
||||
func (m *Manager) AddRoutes(cidrs []string) error {
|
||||
if len(cidrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge CIDRs to reduce route count.
|
||||
merged := mergeCIDRs(cidrs)
|
||||
logRouteMerge(len(cidrs), len(merged))
|
||||
|
||||
// Split CIDRs by family.
|
||||
var v4, v6 []string
|
||||
for _, cidr := range merged {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if isIPv6CIDR(cidr) {
|
||||
v6 = append(v6, cidr)
|
||||
} else {
|
||||
v4 = append(v4, cidr)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
for _, r := range m.addedRoutes {
|
||||
|
||||
if m.cfg.Mode == ModeBypass {
|
||||
if len(v4) > 0 && m.originalGateway != "" {
|
||||
if err := addRoutesViaBatch(v4, m.originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.bypassRoutes = append(m.bypassRoutes, v4...)
|
||||
}
|
||||
}
|
||||
if len(v6) > 0 && m.originalGateway6 != "" {
|
||||
if err := addRoutesVia6Batch(v6, m.originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.bypassRoutes6 = append(m.bypassRoutes6, v6...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if len(v4) > 0 {
|
||||
if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.addedRoutes = append(m.addedRoutes, v4...)
|
||||
}
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.addedRoutes6 = append(m.addedRoutes6, v6...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("add routes errors: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasOriginalGateway reports whether the manager captured an original
|
||||
// default gateway (v4 or v6) during Apply.
|
||||
func (m *Manager) HasOriginalGateway() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.originalGateway != "" || m.originalGateway6 != ""
|
||||
}
|
||||
|
||||
// OriginalGatewayV4 returns the captured IPv4 default gateway, if any.
|
||||
func (m *Manager) OriginalGatewayV4() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.originalGateway
|
||||
}
|
||||
|
||||
// OriginalGatewayV6 returns the captured IPv6 default gateway, if any.
|
||||
func (m *Manager) OriginalGatewayV6() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.originalGateway6
|
||||
}
|
||||
|
||||
// Cleanup removes all routes that were added by Apply, ApplyDeferred,
|
||||
// or AddRoutes. Safe to call concurrently with ApplyDeferred.
|
||||
func (m *Manager) Cleanup() error {
|
||||
m.mu.Lock()
|
||||
// Snapshot all route lists under the lock, then clear them.
|
||||
addedRoutes := m.addedRoutes
|
||||
addedRoutes6 := m.addedRoutes6
|
||||
bypassRoutes := m.bypassRoutes
|
||||
bypassRoutes6 := m.bypassRoutes6
|
||||
serverBypass := m.serverBypass
|
||||
serverBypass6 := m.serverBypass6
|
||||
originalGateway := m.originalGateway
|
||||
originalGateway6 := m.originalGateway6
|
||||
serverIP := m.serverIP
|
||||
serverIP6 := m.serverIP6
|
||||
m.addedRoutes = nil
|
||||
m.addedRoutes6 = nil
|
||||
m.bypassRoutes = nil
|
||||
m.bypassRoutes6 = nil
|
||||
m.serverBypass = false
|
||||
m.serverBypass6 = false
|
||||
m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
|
||||
// Delete user CIDR routes. Use batch for large lists.
|
||||
if len(addedRoutes) > 3 {
|
||||
if err := deleteRoutesBatch(addedRoutes, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
} else {
|
||||
for _, r := range addedRoutes {
|
||||
if err := deleteRoute(r, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
m.addedRoutes = nil
|
||||
if m.serverBypass {
|
||||
if err := m.deleteServerBypass(); err != nil {
|
||||
}
|
||||
if len(addedRoutes6) > 3 {
|
||||
if err := deleteRoutes6Batch(addedRoutes6, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
m.serverBypass = false
|
||||
} else {
|
||||
for _, r := range addedRoutes6 {
|
||||
if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete bypass routes via gateway.
|
||||
if len(bypassRoutes) > 3 {
|
||||
if err := deleteRoutesViaBatch(bypassRoutes, originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
} else {
|
||||
for _, r := range bypassRoutes {
|
||||
if err := deleteRouteVia(r, originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(bypassRoutes6) > 3 {
|
||||
if err := deleteRoutesVia6Batch(bypassRoutes6, originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
} else {
|
||||
for _, r := range bypassRoutes6 {
|
||||
if err := deleteRouteVia6(r, originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete server bypass routes.
|
||||
if serverBypass && serverIP != "" {
|
||||
if err := deleteRouteVia(serverIP+"/32", originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
if serverBypass6 && serverIP6 != "" && originalGateway6 != "" {
|
||||
if err := deleteRouteVia6(serverIP6+"/128", originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("cleanup errors: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyFull() error {
|
||||
// Capture the current default gateway before modifying routes.
|
||||
// captureGatewaysAndBypass resolves the server host and adds bypass
|
||||
// routes for the server's public IPs via the original gateway. This
|
||||
// is shared by applyFull and applyBypassEssential.
|
||||
func (m *Manager) captureGatewaysAndBypass() error {
|
||||
// Capture the current default gateways before modifying routes.
|
||||
gw, err := defaultGateway()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get default gateway: %w", err)
|
||||
}
|
||||
m.originalGateway = gw
|
||||
|
||||
// Resolve server host to an IP for the bypass route.
|
||||
serverIP, err := resolveHost(m.cfg.ServerHost)
|
||||
// IPv6 default gateway is best-effort.
|
||||
gw6, _ := defaultGateway6()
|
||||
m.originalGateway6 = gw6
|
||||
|
||||
// Resolve server host to v4 + v6 IPs for bypass routes.
|
||||
v4, v6, err := resolveHosts(m.cfg.ServerHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve server host %s: %w", m.cfg.ServerHost, err)
|
||||
}
|
||||
m.serverIP = v4
|
||||
m.serverIP6 = v6
|
||||
|
||||
// Bypass: server's public IP via the original gateway (so the WS
|
||||
// connection doesn't loop through the tunnel).
|
||||
bypassSpec := serverIP + "/32"
|
||||
// Bypass: server's public IPv4 via the original gateway.
|
||||
if v4 != "" {
|
||||
bypassSpec := v4 + "/32"
|
||||
if err := addRouteVia(bypassSpec, gw); err != nil {
|
||||
return fmt.Errorf("add server bypass route: %w", err)
|
||||
}
|
||||
m.serverBypass = true
|
||||
}
|
||||
|
||||
// Two /1 routes cover the entire IPv4 space and are more specific
|
||||
// than the default route (0.0.0.0/0), so they take precedence
|
||||
// without removing the original default.
|
||||
// Bypass: server's public IPv6 via the original v6 gateway.
|
||||
if v6 != "" && gw6 != "" {
|
||||
bypassSpec := v6 + "/128"
|
||||
if err := addRouteVia6(bypassSpec, gw6); err != nil {
|
||||
m.serverBypass6 = false
|
||||
} else {
|
||||
m.serverBypass6 = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyFull() error {
|
||||
if err := m.captureGatewaysAndBypass(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Two /1 routes cover the entire IPv4 space.
|
||||
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
|
||||
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add route %s: %w", cidr, err)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
|
||||
// IPv6 full tunnel cover routes.
|
||||
if m.cfg.VPNIP6 != "" {
|
||||
for _, cidr := range []string{"::/1", "8000::/1"} {
|
||||
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add route6 %s: %w", cidr, err)
|
||||
}
|
||||
m.addedRoutes6 = append(m.addedRoutes6, cidr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applySplit() error {
|
||||
subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix)
|
||||
if err := addRoute(subnet, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add split route %s: %w", subnet, err)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, subnet)
|
||||
// applyProxyEssential does nothing - proxy mode has no essential
|
||||
// routes. All user CIDR routes are deferred.
|
||||
func (m *Manager) applyProxyEssential() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyCustom() error {
|
||||
for _, cidr := range m.cfg.CustomCIDRs {
|
||||
// applyProxyDeferred adds all user CIDR routes via the TUN interface
|
||||
// using batch execution. CIDRs are merged to minimize route count.
|
||||
func (m *Manager) applyProxyDeferred() error {
|
||||
merged := mergeCIDRs(m.cfg.CIDRs)
|
||||
logRouteMerge(len(m.cfg.CIDRs), len(merged))
|
||||
|
||||
var v4, v6 []string
|
||||
for _, cidr := range merged {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add custom route %s: %w", cidr, err)
|
||||
if isIPv6CIDR(cidr) {
|
||||
v6 = append(v6, cidr)
|
||||
} else {
|
||||
v4 = append(v4, cidr)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
if len(v4) > 0 {
|
||||
if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.addedRoutes = append(m.addedRoutes, v4...)
|
||||
}
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.addedRoutes6 = append(m.addedRoutes6, v6...)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("proxy deferred: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) deleteServerBypass() error {
|
||||
serverIP, err := resolveHost(m.cfg.ServerHost)
|
||||
if err != nil {
|
||||
return nil // best-effort
|
||||
}
|
||||
return deleteRouteVia(serverIP+"/32", m.originalGateway)
|
||||
// applyBypassEssential adds server bypass + /1 cover routes (full
|
||||
// tunnel effect). User bypass CIDRs are deferred to allow the "ready"
|
||||
// signal to be sent promptly.
|
||||
func (m *Manager) applyBypassEssential() error {
|
||||
if err := m.captureGatewaysAndBypass(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// vpnSubnet computes the network CIDR from an IP and prefix.
|
||||
func vpnSubnet(ipStr string, prefix int) string {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ipStr + "/" + fmt.Sprint(prefix)
|
||||
// Two /1 routes cover the entire IPv4 space (full tunnel).
|
||||
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
|
||||
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add route %s: %w", cidr, err)
|
||||
}
|
||||
mask := net.CIDRMask(prefix, 32)
|
||||
network := ip.Mask(mask)
|
||||
return fmt.Sprintf("%s/%d", network.String(), prefix)
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
|
||||
// resolveHost resolves a hostname to an IP address. If already an IP,
|
||||
// returns it directly.
|
||||
func resolveHost(host string) (string, error) {
|
||||
if net.ParseIP(host) != nil {
|
||||
return host, nil
|
||||
// IPv6 full tunnel cover routes.
|
||||
if m.cfg.VPNIP6 != "" {
|
||||
for _, cidr := range []string{"::/1", "8000::/1"} {
|
||||
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add route6 %s: %w", cidr, err)
|
||||
}
|
||||
// Strip port if present.
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
m.addedRoutes6 = append(m.addedRoutes6, cidr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBypassDeferred adds user bypass CIDR routes via the original
|
||||
// gateway using batch execution. CIDRs are merged to minimize route
|
||||
// count.
|
||||
func (m *Manager) applyBypassDeferred() error {
|
||||
merged := mergeCIDRs(m.cfg.CIDRs)
|
||||
logRouteMerge(len(m.cfg.CIDRs), len(merged))
|
||||
|
||||
var v4, v6 []string
|
||||
for _, cidr := range merged {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if isIPv6CIDR(cidr) {
|
||||
if m.originalGateway6 != "" {
|
||||
v6 = append(v6, cidr)
|
||||
}
|
||||
} else {
|
||||
v4 = append(v4, cidr)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var errs []string
|
||||
if len(v4) > 0 {
|
||||
if err := addRoutesViaBatch(v4, m.originalGateway); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.bypassRoutes = append(m.bypassRoutes, v4...)
|
||||
}
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
if err := addRoutesVia6Batch(v6, m.originalGateway6); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
m.bypassRoutes6 = append(m.bypassRoutes6, v6...)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("bypass deferred: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveHosts resolves a hostname to its first IPv4 and IPv6 addresses.
|
||||
// If host is already an IP literal, it is returned directly. The DNS
|
||||
// lookup is bounded to 5 seconds.
|
||||
func resolveHosts(host string) (v4, v6 string, err error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
return ip.String(), "", nil
|
||||
}
|
||||
return "", ip.String(), nil
|
||||
}
|
||||
if h, _, e := net.SplitHostPort(host); e == nil {
|
||||
host = h
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return "", fmt.Errorf("lookup %s: %w", host, err)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
return "", "", fmt.Errorf("lookup %s: %w", host, err)
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if v4 == "" && addr.IP.To4() != nil {
|
||||
v4 = addr.IP.String()
|
||||
} else if v6 == "" && addr.IP.To4() == nil {
|
||||
v6 = addr.IP.String()
|
||||
}
|
||||
}
|
||||
if v4 == "" && v6 == "" {
|
||||
return "", "", fmt.Errorf("lookup %s: no addresses", host)
|
||||
}
|
||||
return v4, v6, nil
|
||||
}
|
||||
|
||||
// isIPv6CIDR reports whether the CIDR string is IPv6.
|
||||
func isIPv6CIDR(cidr string) bool {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ipNet.IP.To4() == nil
|
||||
}
|
||||
|
||||
// logRouteMerge logs CIDR merge statistics if any reduction occurred.
|
||||
func logRouteMerge(before, after int) {
|
||||
if before > after {
|
||||
log.L().Info("CIDR merge", "before", before, "after", after, "reduced", before-after)
|
||||
}
|
||||
return ips[0].String(), nil
|
||||
}
|
||||
|
||||
@@ -4,38 +4,72 @@ package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// addRoute adds a route via a network interface (macOS route command).
|
||||
// route add -inet -net <cidr> -interface <iface>
|
||||
//
|
||||
// route add -inet <cidr> -interface <iface>
|
||||
func addRoute(cidr, iface string) error {
|
||||
return runRoute("add", "-inet", "-net", cidr, "-interface", iface)
|
||||
return runRoute("add", "-inet", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
// deleteRoute removes a route via a network interface.
|
||||
func deleteRoute(cidr, iface string) error {
|
||||
return runRoute("delete", "-inet", "-net", cidr, "-interface", iface)
|
||||
return runRoute("delete", "-inet", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
// addRouteVia adds a route via a gateway IP.
|
||||
func addRouteVia(cidr, gateway string) error {
|
||||
return runRoute("add", "-inet", "-net", cidr, gateway)
|
||||
return runRoute("add", "-inet", cidr, gateway)
|
||||
}
|
||||
|
||||
// deleteRouteVia removes a route via a gateway IP.
|
||||
func deleteRouteVia(cidr, gateway string) error {
|
||||
return runRoute("delete", "-inet", "-net", cidr, gateway)
|
||||
return runRoute("delete", "-inet", cidr, gateway)
|
||||
}
|
||||
|
||||
// defaultGateway returns the current default gateway IP.
|
||||
// --- IPv6 variants ---
|
||||
|
||||
func addRoute6(cidr, iface string) error {
|
||||
return runRoute("add", "-inet6", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
func deleteRoute6(cidr, iface string) error {
|
||||
return runRoute("delete", "-inet6", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
func addRouteVia6(cidr, gateway string) error {
|
||||
return runRoute("add", "-inet6", cidr, gateway)
|
||||
}
|
||||
|
||||
func deleteRouteVia6(cidr, gateway string) error {
|
||||
return runRoute("delete", "-inet6", cidr, gateway)
|
||||
}
|
||||
|
||||
// defaultGateway returns the current IPv4 default gateway IP.
|
||||
func defaultGateway() (string, error) {
|
||||
out, err := exec.Command("route", "-n", "get", "default").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
return parseGateway(string(out))
|
||||
}
|
||||
|
||||
// defaultGateway6 returns the current IPv6 default gateway IP.
|
||||
func defaultGateway6() (string, error) {
|
||||
out, err := exec.Command("route", "-n", "get", "-inet6", "default").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseGateway(string(out))
|
||||
}
|
||||
|
||||
func parseGateway(out string) (string, error) {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "gateway:") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
@@ -57,3 +91,136 @@ func runRoute(args ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Batch functions ---
|
||||
|
||||
// runBatchScript writes commands to temporary shell scripts and
|
||||
// executes them in parallel (up to 4 concurrent scripts). Each line
|
||||
// uses "|| true" so that individual route failures don't abort the
|
||||
// batch.
|
||||
func runBatchScript(lines []string) error {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split lines into up to 4 chunks for parallel execution.
|
||||
const maxChunks = 4
|
||||
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(lines); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
chunks = append(chunks, lines[i:end])
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
wg.Add(1)
|
||||
go func(idx int, c []string) {
|
||||
defer wg.Done()
|
||||
errs[idx] = runSingleScript(c)
|
||||
}(i, chunk)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var errStrs []string
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
errStrs = append(errStrs, e.Error())
|
||||
}
|
||||
}
|
||||
if len(errStrs) > 0 {
|
||||
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSingleScript(lines []string) error {
|
||||
f, err := os.CreateTemp("", "lmvpn-routes-*.sh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create batch script: %w", err)
|
||||
}
|
||||
tmpFile := f.Name()
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintln(f, line, "|| true"); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write batch script: %w", err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
cmd := exec.Command("sh", tmpFile)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet6 %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet6 %s -interface %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route add -inet6 %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("route delete -inet6 %s %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func addRoute(cidr, iface string) error {
|
||||
@@ -24,12 +26,42 @@ func deleteRouteVia(cidr, gateway string) error {
|
||||
return runCmd("ip", "route", "del", cidr, "via", gateway)
|
||||
}
|
||||
|
||||
// --- IPv6 variants ---
|
||||
|
||||
func addRoute6(cidr, iface string) error {
|
||||
return runCmd("ip", "-6", "route", "add", cidr, "dev", iface)
|
||||
}
|
||||
|
||||
func deleteRoute6(cidr, iface string) error {
|
||||
return runCmd("ip", "-6", "route", "del", cidr, "dev", iface)
|
||||
}
|
||||
|
||||
func addRouteVia6(cidr, gateway string) error {
|
||||
return runCmd("ip", "-6", "route", "add", cidr, "via", gateway)
|
||||
}
|
||||
|
||||
func deleteRouteVia6(cidr, gateway string) error {
|
||||
return runCmd("ip", "-6", "route", "del", cidr, "via", gateway)
|
||||
}
|
||||
|
||||
func defaultGateway() (string, error) {
|
||||
out, err := exec.Command("ip", "route", "show", "default").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(string(out)))
|
||||
return parseViaGateway(string(out))
|
||||
}
|
||||
|
||||
func defaultGateway6() (string, error) {
|
||||
out, err := exec.Command("ip", "-6", "route", "show", "default").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseViaGateway(string(out))
|
||||
}
|
||||
|
||||
func parseViaGateway(out string) (string, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(out))
|
||||
for i, f := range fields {
|
||||
if f == "via" && i+1 < len(fields) {
|
||||
return fields[i+1], nil
|
||||
@@ -45,3 +77,133 @@ func runCmd(name string, args ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Batch functions ---
|
||||
|
||||
// runBatchScript writes commands to temporary shell scripts and
|
||||
// executes them in parallel (up to 4 concurrent scripts).
|
||||
func runBatchScript(lines []string) error {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxChunks = 4
|
||||
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(lines); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
chunks = append(chunks, lines[i:end])
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
wg.Add(1)
|
||||
go func(idx int, c []string) {
|
||||
defer wg.Done()
|
||||
errs[idx] = runSingleScript(c)
|
||||
}(i, chunk)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var errStrs []string
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
errStrs = append(errStrs, e.Error())
|
||||
}
|
||||
}
|
||||
if len(errStrs) > 0 {
|
||||
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSingleScript(lines []string) error {
|
||||
f, err := os.CreateTemp("", "lmvpn-routes-*.sh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create batch script: %w", err)
|
||||
}
|
||||
tmpFile := f.Name()
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintln(f, line, "|| true"); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write batch script: %w", err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
cmd := exec.Command("sh", tmpFile)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip route add %s dev %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip route del %s dev %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip -6 route add %s dev %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip -6 route del %s dev %s", cidr, iface))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip route add %s via %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip route del %s via %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip -6 route add %s via %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
lines = append(lines, fmt.Sprintf("ip -6 route del %s via %s", cidr, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//go:build windows
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// cidrToNetworkMask splits a CIDR string into network address and
|
||||
// dotted-decimal mask, as required by the Windows `route` command.
|
||||
func cidrToNetworkMask(cidr string) (network, mask string, err error) {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return ipNet.IP.String(), net.IP(ipNet.Mask).String(), nil
|
||||
}
|
||||
|
||||
// ifaceIndex resolves a Windows interface name to its index.
|
||||
func ifaceIndex(name string) (int, error) {
|
||||
ifc, err := net.InterfaceByName(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve interface %s: %w", name, err)
|
||||
}
|
||||
return ifc.Index, nil
|
||||
}
|
||||
|
||||
// --- IPv4 ---
|
||||
|
||||
func addRoute(cidr, iface string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "add", network, "mask", mask, "0.0.0.0",
|
||||
"if", fmt.Sprintf("%d", idx), "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRoute(cidr, iface string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "delete", network, "mask", mask)
|
||||
}
|
||||
|
||||
func addRouteVia(cidr, gateway string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "add", network, "mask", mask, gateway, "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRouteVia(cidr, gateway string) error {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "delete", network, "mask", mask, gateway)
|
||||
}
|
||||
|
||||
// --- IPv6 ---
|
||||
|
||||
// cidrToV6Prefix splits an IPv6 CIDR into network and prefix length.
|
||||
func cidrToV6Prefix(cidr string) (network, prefix string, err error) {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ones, _ := ipNet.Mask.Size()
|
||||
return ipNet.IP.String(), fmt.Sprintf("%d", ones), nil
|
||||
}
|
||||
|
||||
func addRoute6(cidr, iface string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "-6", "add", network+"/"+prefix, "::",
|
||||
"if", fmt.Sprintf("%d", idx), "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRoute6(cidr, iface string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "-6", "delete", network+"/"+prefix, "::")
|
||||
}
|
||||
|
||||
func addRouteVia6(cidr, gateway string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "-6", "add", network+"/"+prefix, gateway, "metric", "1")
|
||||
}
|
||||
|
||||
func deleteRouteVia6(cidr, gateway string) error {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCmd("route", "-6", "delete", network+"/"+prefix, gateway)
|
||||
}
|
||||
|
||||
// --- Default gateway ---
|
||||
|
||||
func defaultGateway() (string, error) {
|
||||
out, err := exec.Command("route", "print", "0.0.0.0").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseRouteGateway(string(out))
|
||||
}
|
||||
|
||||
func defaultGateway6() (string, error) {
|
||||
out, err := exec.Command("route", "-6", "print").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseRoute6Gateway(string(out))
|
||||
}
|
||||
|
||||
// parseRouteGateway extracts the IPv4 default gateway from `route print`
|
||||
// output. Looks for the line with Network Destination 0.0.0.0 and
|
||||
// Netmask 0.0.0.0, and returns the Gateway column.
|
||||
func parseRouteGateway(out string) (string, error) {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[0] == "0.0.0.0" && fields[1] == "0.0.0.0" {
|
||||
return fields[2], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no default gateway found")
|
||||
}
|
||||
|
||||
// parseRoute6Gateway extracts the IPv6 default gateway from
|
||||
// `route -6 print` output. Looks for ::/0 route and returns the
|
||||
// Gateway column.
|
||||
func parseRoute6Gateway(out string) (string, error) {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f == "::/0" && i+1 < len(fields) {
|
||||
return fields[i+1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no IPv6 default gateway found")
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s %s: %w (%s)", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Batch functions ---
|
||||
|
||||
// runBatchScript writes commands to temporary .bat files and executes
|
||||
// them in parallel (up to 4 concurrent scripts).
|
||||
func runBatchScript(lines []string) error {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxChunks = 4
|
||||
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
|
||||
var chunks [][]string
|
||||
for i := 0; i < len(lines); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
chunks = append(chunks, lines[i:end])
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
wg.Add(1)
|
||||
go func(idx int, c []string) {
|
||||
defer wg.Done()
|
||||
errs[idx] = runSingleScript(c)
|
||||
}(i, chunk)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var errStrs []string
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
errStrs = append(errStrs, e.Error())
|
||||
}
|
||||
}
|
||||
if len(errStrs) > 0 {
|
||||
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSingleScript(lines []string) error {
|
||||
f, err := os.CreateTemp("", "lmvpn-routes-*.bat")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create batch script: %w", err)
|
||||
}
|
||||
tmpFile := f.Name()
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintln(f, line); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write batch script: %w", err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
cmd := exec.Command("cmd", "/c", tmpFile)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addRoutesBatch(cidrs []string, iface string) error {
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"route add %s mask %s 0.0.0.0 if %d metric 1", network, mask, idx))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesBatch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("route delete %s mask %s", network, mask))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutes6Batch(cidrs []string, iface string) error {
|
||||
idx, err := ifaceIndex(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"route -6 add %s/%s :: if %d metric 1", network, prefix, idx))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutes6Batch(cidrs []string, iface string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("route -6 delete %s/%s ::", network, prefix))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"route add %s mask %s %s metric 1", network, mask, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, mask, err := cidrToNetworkMask(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("route delete %s mask %s %s", network, mask, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func addRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"route -6 add %s/%s %s metric 1", network, prefix, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
|
||||
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
|
||||
var lines []string
|
||||
for _, cidr := range cidrs {
|
||||
network, prefix, err := cidrToV6Prefix(cidr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("route -6 delete %s/%s %s", network, prefix, gateway))
|
||||
}
|
||||
return runBatchScript(lines)
|
||||
}
|
||||
+162
-10
@@ -19,12 +19,60 @@ const (
|
||||
|
||||
// Stats holds live session statistics. Counters are atomic for
|
||||
// lock-free reads from the UI/IPC layer.
|
||||
//
|
||||
// Counters are split by IP address family (v4/v6) at the recording
|
||||
// site via AddRx/AddTx, which inspect the IP version nibble. The
|
||||
// combined RxBytes/TxBytes are derived as the sum of the per-family
|
||||
// counters in Snapshot, so callers that only need the total still
|
||||
// work.
|
||||
type Stats struct {
|
||||
RxBytes atomic.Int64
|
||||
TxBytes atomic.Int64
|
||||
RxBytesV4 atomic.Int64
|
||||
RxBytesV6 atomic.Int64
|
||||
TxBytesV4 atomic.Int64
|
||||
TxBytesV6 atomic.Int64
|
||||
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
|
||||
state atomic.Value // State
|
||||
assignedIP atomic.Value // string
|
||||
assignedIP atomic.Value // string (IPv4)
|
||||
assignedIP6 atomic.Value // string (IPv6, may be empty)
|
||||
serverHost atomic.Value // string (server hostname or IP from URL)
|
||||
connectedIP atomic.Value // string (actual remote IP of the connection)
|
||||
routeLoading atomic.Bool // true while deferred routes are being applied
|
||||
connectStep atomic.Value // string (human-readable connection step)
|
||||
cidrError atomic.Value // string (CIDR fetch error message, empty = no error)
|
||||
}
|
||||
|
||||
// AddRx records a downloaded packet (WebSocket → TUN) of length n,
|
||||
// routing the bytes to the v4 or v6 counter based on the IP version
|
||||
// nibble of the packet. Packets too short to contain a version byte
|
||||
// or with an unknown version are ignored.
|
||||
func (s *Stats) AddRx(p []byte) {
|
||||
if len(p) == 0 {
|
||||
return
|
||||
}
|
||||
n := int64(len(p))
|
||||
switch p[0] >> 4 {
|
||||
case 4:
|
||||
s.RxBytesV4.Add(n)
|
||||
case 6:
|
||||
s.RxBytesV6.Add(n)
|
||||
}
|
||||
}
|
||||
|
||||
// AddTx records an uploaded packet (TUN → WebSocket) of length n,
|
||||
// routing the bytes to the v4 or v6 counter based on the IP version
|
||||
// nibble of the packet. Packets too short to contain a version byte
|
||||
// or with an unknown version are ignored.
|
||||
func (s *Stats) AddTx(p []byte) {
|
||||
if len(p) == 0 {
|
||||
return
|
||||
}
|
||||
n := int64(len(p))
|
||||
switch p[0] >> 4 {
|
||||
case 4:
|
||||
s.TxBytesV4.Add(n)
|
||||
case 6:
|
||||
s.TxBytesV6.Add(n)
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a Stats instance initialised to the disconnected state.
|
||||
@@ -32,6 +80,11 @@ func New() *Stats {
|
||||
s := &Stats{}
|
||||
s.state.Store(StateDisconnected)
|
||||
s.assignedIP.Store("")
|
||||
s.assignedIP6.Store("")
|
||||
s.serverHost.Store("")
|
||||
s.connectedIP.Store("")
|
||||
s.connectStep.Store("")
|
||||
s.cidrError.Store("")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -41,10 +94,16 @@ func (s *Stats) SetState(st State) { s.state.Store(st) }
|
||||
// State returns the current state.
|
||||
func (s *Stats) State() State { return s.state.Load().(State) }
|
||||
|
||||
// SetConnected marks the session as connected, recording the time and IP.
|
||||
func (s *Stats) SetConnected(ip string) {
|
||||
// SetConnected marks the session as connected, recording the time,
|
||||
// assigned IP addresses, and server connection info. ip6 may be empty
|
||||
// for an IPv4-only server. serverHost is the hostname/IP from the
|
||||
// server URL; connectedIP is the actual remote IP of the connection.
|
||||
func (s *Stats) SetConnected(ip, ip6, serverHost, connectedIP string) {
|
||||
s.ConnectedAt.Store(time.Now().Unix())
|
||||
s.assignedIP.Store(ip)
|
||||
s.assignedIP6.Store(ip6)
|
||||
s.serverHost.Store(serverHost)
|
||||
s.connectedIP.Store(connectedIP)
|
||||
s.state.Store(StateConnected)
|
||||
}
|
||||
|
||||
@@ -52,28 +111,118 @@ func (s *Stats) SetConnected(ip string) {
|
||||
func (s *Stats) SetDisconnected() {
|
||||
s.ConnectedAt.Store(0)
|
||||
s.assignedIP.Store("")
|
||||
s.assignedIP6.Store("")
|
||||
s.serverHost.Store("")
|
||||
s.connectedIP.Store("")
|
||||
s.state.Store(StateDisconnected)
|
||||
s.routeLoading.Store(false)
|
||||
s.connectStep.Store("")
|
||||
s.cidrError.Store("")
|
||||
}
|
||||
|
||||
// AssignedIP returns the server-assigned tunnel IP.
|
||||
// SetRouteLoading sets whether deferred routes are currently being
|
||||
// applied (e.g. thousands of CIDR bypass routes being added in the
|
||||
// background after the tunnel is up).
|
||||
func (s *Stats) SetRouteLoading(loading bool) { s.routeLoading.Store(loading) }
|
||||
|
||||
// RouteLoading returns whether deferred routes are being applied.
|
||||
func (s *Stats) RouteLoading() bool { return s.routeLoading.Load() }
|
||||
|
||||
// SetConnectStep sets a human-readable description of the current
|
||||
// connection step (e.g. "fetching CIDR lists"). Empty clears it.
|
||||
func (s *Stats) SetConnectStep(step string) { s.connectStep.Store(step) }
|
||||
|
||||
// ConnectStep returns the current connection step description.
|
||||
func (s *Stats) ConnectStep() string {
|
||||
v := s.connectStep.Load()
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.(string)
|
||||
}
|
||||
|
||||
// SetCIDRError sets a CIDR fetch error message (empty = no error).
|
||||
func (s *Stats) SetCIDRError(msg string) { s.cidrError.Store(msg) }
|
||||
|
||||
// CIDRError returns the current CIDR fetch error message.
|
||||
func (s *Stats) CIDRError() string {
|
||||
v := s.cidrError.Load()
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.(string)
|
||||
}
|
||||
|
||||
// AssignedIP returns the server-assigned tunnel IPv4.
|
||||
func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
|
||||
|
||||
// AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty).
|
||||
func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) }
|
||||
|
||||
// ServerHost returns the server hostname/IP from the connection URL.
|
||||
func (s *Stats) ServerHost() string { return s.serverHost.Load().(string) }
|
||||
|
||||
// ConnectedIP returns the actual remote IP of the connection.
|
||||
func (s *Stats) ConnectedIP() string { return s.connectedIP.Load().(string) }
|
||||
|
||||
// Snapshot returns a point-in-time copy of all counters.
|
||||
//
|
||||
// Per-family byte counters are read directly. The combined RxBytes/
|
||||
// TxBytes are derived as v4+v6. Speed fields (RxSpeedV4 etc.) are
|
||||
// left zero here; the SessionManager fills them in by computing
|
||||
// per-tick deltas with EWMA smoothing.
|
||||
type Snapshot struct {
|
||||
RxBytes int64
|
||||
TxBytes int64
|
||||
RxBytesV4 int64
|
||||
RxBytesV6 int64
|
||||
TxBytesV4 int64
|
||||
TxBytesV6 int64
|
||||
RxBytes int64 // combined (v4+v6)
|
||||
TxBytes int64 // combined (v4+v6)
|
||||
|
||||
// Speeds in bytes/sec (EWMA-smoothed). The UI converts to bits/sec.
|
||||
RxSpeedV4 int64
|
||||
TxSpeedV4 int64
|
||||
RxSpeedV6 int64
|
||||
TxSpeedV6 int64
|
||||
RxSpeed int64 // combined
|
||||
TxSpeed int64 // combined
|
||||
|
||||
ConnectedAt time.Time
|
||||
AssignedIP string
|
||||
AssignedIP6 string
|
||||
ServerHost string `json:"server_host,omitempty"` // server hostname/IP from URL
|
||||
ConnectedIP string `json:"connected_ip,omitempty"` // actual remote IP of the connection
|
||||
State State
|
||||
Uptime time.Duration
|
||||
|
||||
// Routing info (filled by SessionManager.reportStats).
|
||||
RoutingMode string `json:"routing_mode,omitempty"` // "full", "proxy", "bypass"
|
||||
CIDRV4Total int `json:"cidr_v4_total,omitempty"`
|
||||
CIDRV4Hits int `json:"cidr_v4_hits,omitempty"`
|
||||
CIDRV6Total int `json:"cidr_v6_total,omitempty"`
|
||||
CIDRV6Hits int `json:"cidr_v6_hits,omitempty"`
|
||||
RouteLoading bool `json:"route_loading,omitempty"` // deferred routes being applied
|
||||
ConnectStep string `json:"connect_step,omitempty"` // current connection step
|
||||
CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message
|
||||
}
|
||||
|
||||
// Snapshot returns a point-in-time copy of the statistics.
|
||||
func (s *Stats) Snapshot() Snapshot {
|
||||
rxv4 := s.RxBytesV4.Load()
|
||||
rxv6 := s.RxBytesV6.Load()
|
||||
txv4 := s.TxBytesV4.Load()
|
||||
txv6 := s.TxBytesV6.Load()
|
||||
snap := Snapshot{
|
||||
RxBytes: s.RxBytes.Load(),
|
||||
TxBytes: s.TxBytes.Load(),
|
||||
RxBytesV4: rxv4,
|
||||
RxBytesV6: rxv6,
|
||||
TxBytesV4: txv4,
|
||||
TxBytesV6: txv6,
|
||||
RxBytes: rxv4 + rxv6,
|
||||
TxBytes: txv4 + txv6,
|
||||
AssignedIP: s.AssignedIP(),
|
||||
AssignedIP6: s.AssignedIP6(),
|
||||
ServerHost: s.ServerHost(),
|
||||
ConnectedIP: s.ConnectedIP(),
|
||||
State: s.State(),
|
||||
}
|
||||
ts := s.ConnectedAt.Load()
|
||||
@@ -81,5 +230,8 @@ func (s *Stats) Snapshot() Snapshot {
|
||||
snap.ConnectedAt = time.Unix(ts, 0)
|
||||
snap.Uptime = time.Since(snap.ConnectedAt)
|
||||
}
|
||||
snap.RouteLoading = s.routeLoading.Load()
|
||||
snap.ConnectStep = s.ConnectStep()
|
||||
snap.CIDRError = s.CIDRError()
|
||||
return snap
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewRaceDialer returns a dial function suitable for use as a
|
||||
// websocket.Dialer.NetDialContext or http.Transport.DialContext.
|
||||
//
|
||||
// When the host in addr is a domain name, it resolves all A/AAAA
|
||||
// records and dials them concurrently, returning the first successful
|
||||
// connection (true parallel racing, not Happy Eyeballs staggered start).
|
||||
//
|
||||
// preference controls which address families participate:
|
||||
// - "auto": race all resolved addresses (v4 + v6)
|
||||
// - "v4": only IPv4 addresses
|
||||
// - "v6": only IPv6 addresses
|
||||
//
|
||||
// When the host is an IP literal (CDN failover or direct IP mode), it
|
||||
// dials directly without racing — there is only one target.
|
||||
func NewRaceDialer(preference string) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("split host port: %w", err)
|
||||
}
|
||||
|
||||
// IP literal: dial directly, no racing.
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
d := &net.Dialer{}
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
// Domain name: resolve and race.
|
||||
ips, err := resolveAndFilter(ctx, host, preference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ips) == 1 {
|
||||
d := &net.Dialer{}
|
||||
return d.DialContext(ctx, network, net.JoinHostPort(ips[0], port))
|
||||
}
|
||||
|
||||
return raceDial(ctx, network, ips, port)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAndFilter resolves a hostname and filters by preference.
|
||||
func resolveAndFilter(ctx context.Context, host, preference string) ([]string, error) {
|
||||
resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(resolveCtx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup %s: %w", host, err)
|
||||
}
|
||||
|
||||
var ips []string
|
||||
for _, a := range addrs {
|
||||
isV4 := a.IP.To4() != nil
|
||||
switch preference {
|
||||
case "v4":
|
||||
if isV4 {
|
||||
ips = append(ips, a.IP.String())
|
||||
}
|
||||
case "v6":
|
||||
if !isV4 {
|
||||
ips = append(ips, a.IP.String())
|
||||
}
|
||||
default: // "auto" or unspecified
|
||||
ips = append(ips, a.IP.String())
|
||||
}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("lookup %s: no addresses for preference %q", host, preference)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// raceDial dials all IPs concurrently and returns the first successful
|
||||
// connection. Losing connections are closed and their errors discarded.
|
||||
func raceDial(ctx context.Context, network string, ips []string, port string) (net.Conn, error) {
|
||||
type result struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
raceCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
resultCh := make(chan result, len(ips))
|
||||
|
||||
for _, ip := range ips {
|
||||
go func(target string) {
|
||||
d := &net.Dialer{}
|
||||
c, err := d.DialContext(raceCtx, network, net.JoinHostPort(target, port))
|
||||
select {
|
||||
case resultCh <- result{conn: c, err: err}:
|
||||
case <-raceCtx.Done():
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
}(ip)
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for i := 0; i < len(ips); i++ {
|
||||
r := <-resultCh
|
||||
if r.err == nil {
|
||||
// Winner. Cancel remaining dialers; drain and close
|
||||
// any late successful connections.
|
||||
cancel()
|
||||
for j := i + 1; j < len(ips); j++ {
|
||||
if late := <-resultCh; late.conn != nil {
|
||||
late.conn.Close()
|
||||
}
|
||||
}
|
||||
return r.conn, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = r.err
|
||||
}
|
||||
}
|
||||
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("all dial attempts failed")
|
||||
}
|
||||
return nil, firstErr
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
@@ -36,6 +37,8 @@ type HandshakeConfig struct {
|
||||
Username string // for password auth (method B), or fallback
|
||||
Password string // for password auth (method B), or fallback
|
||||
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
|
||||
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
|
||||
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
|
||||
}
|
||||
|
||||
// Conn is an established VPN tunnel connection.
|
||||
@@ -52,12 +55,15 @@ type Conn struct {
|
||||
// error occurs.
|
||||
func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
dialer := websocket.Dialer{
|
||||
NetDialContext: NewRaceDialer(cfg.IPPreference),
|
||||
HandshakeTimeout: 15 * time.Second,
|
||||
ReadBufferSize: 4096, // match server (handler.go:17)
|
||||
WriteBufferSize: 4096, // match server (handler.go:18)
|
||||
}
|
||||
|
||||
if cfg.SNIHost != "" {
|
||||
if cfg.TLSConfig != nil {
|
||||
dialer.TLSClientConfig = cfg.TLSConfig
|
||||
} else if cfg.SNIHost != "" {
|
||||
dialer.TLSClientConfig = &tls.Config{
|
||||
ServerName: cfg.SNIHost,
|
||||
}
|
||||
@@ -86,12 +92,26 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
|
||||
ws.SetReadLimit(protocol.MaxMessageSize)
|
||||
|
||||
// Watch ctx and close the WS if it is cancelled. This unblocks all
|
||||
// ReadMessage/WriteMessage calls (they only use SetReadDeadline,
|
||||
// not ctx). The goroutine exits when connectDone is closed (on
|
||||
// either success or failure) to avoid leaking.
|
||||
connectDone := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ws.Close()
|
||||
case <-connectDone:
|
||||
}
|
||||
}()
|
||||
|
||||
conn := &Conn{ws: ws}
|
||||
|
||||
// Step 2: authenticate (only for password mode; JWT is validated
|
||||
// during the WS upgrade).
|
||||
if cfg.Token == "" {
|
||||
if err := conn.passwordAuth(cfg.Username, cfg.Password); err != nil {
|
||||
close(connectDone)
|
||||
ws.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,6 +120,7 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
// Step 3: receive init (or error/auth_err).
|
||||
initMsg, err := conn.readInit()
|
||||
if err != nil {
|
||||
close(connectDone)
|
||||
ws.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -108,6 +129,7 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
// Step 4: configure TUN via callback.
|
||||
if cfg.OnInit != nil {
|
||||
if err := cfg.OnInit(initMsg); err != nil {
|
||||
close(connectDone)
|
||||
ws.Close()
|
||||
return nil, fmt.Errorf("tun configure: %w", err)
|
||||
}
|
||||
@@ -115,10 +137,15 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
|
||||
// Step 5: send ready.
|
||||
if err := conn.sendReady(); err != nil {
|
||||
close(connectDone)
|
||||
ws.Close()
|
||||
return nil, fmt.Errorf("send ready: %w", err)
|
||||
}
|
||||
|
||||
// Handshake complete - stop the ctx watcher so it doesn't close
|
||||
// the connection after we return it to the caller.
|
||||
close(connectDone)
|
||||
|
||||
// Step 6: set up keepalive — reset read deadline on each server
|
||||
// ping, and auto-respond with pong (allowed via WriteControl in
|
||||
// handler — see gorilla/websocket docs).
|
||||
@@ -158,7 +185,7 @@ func (c *Conn) passwordAuth(username, password string) error {
|
||||
return fmt.Errorf("parse auth response: %w", err)
|
||||
}
|
||||
if resp.Type == protocol.TypeAuthErr {
|
||||
return &AuthError{Message: resp.Message}
|
||||
return &AuthError{Message: resp.Message, Code: protocol.AuthErrorCodeFromMessage(resp.Message)}
|
||||
}
|
||||
if resp.Type != protocol.TypeAuthOK {
|
||||
return fmt.Errorf("unexpected auth response type: %s", resp.Type)
|
||||
@@ -186,7 +213,11 @@ func (c *Conn) readInit() (protocol.InitMessage, error) {
|
||||
return protocol.InitMessage{}, fmt.Errorf("parse init/error: %w (raw: %s)", err, data)
|
||||
}
|
||||
if ctrl.Type == protocol.TypeError || ctrl.Type == protocol.TypeAuthErr {
|
||||
return protocol.InitMessage{}, &ServerError{Type: ctrl.Type, Message: ctrl.Message}
|
||||
return protocol.InitMessage{}, &ServerError{
|
||||
Type: ctrl.Type,
|
||||
Message: ctrl.Message,
|
||||
Code: protocol.AuthErrorCodeFromMessage(ctrl.Message),
|
||||
}
|
||||
}
|
||||
return protocol.InitMessage{}, fmt.Errorf("unexpected message type: %s", ctrl.Type)
|
||||
}
|
||||
@@ -235,9 +266,26 @@ func (c *Conn) WritePacket(data []byte) error {
|
||||
// Init returns the init message received during handshake.
|
||||
func (c *Conn) Init() protocol.InitMessage { return c.init }
|
||||
|
||||
// AssignedIP returns the IP assigned by the server.
|
||||
// AssignedIP returns the IPv4 assigned by the server.
|
||||
func (c *Conn) AssignedIP() string { return c.init.IP }
|
||||
|
||||
// AssignedIP6 returns the IPv6 assigned by the server (empty if none).
|
||||
func (c *Conn) AssignedIP6() string { return c.init.IP6 }
|
||||
|
||||
// RemoteIP returns the remote IP address of the underlying TCP
|
||||
// connection (the actual server/CDN IP the WebSocket is connected to).
|
||||
// Returns an empty string if the connection is not established.
|
||||
func (c *Conn) RemoteIP() string {
|
||||
if c.ws == nil {
|
||||
return ""
|
||||
}
|
||||
addr := c.ws.RemoteAddr()
|
||||
if tcpAddr, ok := addr.(*net.TCPAddr); ok {
|
||||
return tcpAddr.IP.String()
|
||||
}
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
// Close terminates the connection.
|
||||
func (c *Conn) Close() error {
|
||||
c.mu.Lock()
|
||||
@@ -251,7 +299,10 @@ func (c *Conn) Close() error {
|
||||
}
|
||||
|
||||
// AuthError indicates authentication failure (auth_err from server).
|
||||
type AuthError struct{ Message string }
|
||||
type AuthError struct {
|
||||
Message string
|
||||
Code protocol.AuthErrorCode
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string { return "auth failed: " + e.Message }
|
||||
|
||||
@@ -259,6 +310,7 @@ func (e *AuthError) Error() string { return "auth failed: " + e.Message }
|
||||
type ServerError struct {
|
||||
Type string
|
||||
Message string
|
||||
Code protocol.AuthErrorCode
|
||||
}
|
||||
|
||||
func (e *ServerError) Error() string {
|
||||
|
||||
@@ -18,6 +18,10 @@ type Device interface {
|
||||
Write(p []byte) (int, error)
|
||||
// Configure sets the interface address, prefix, and peer IP.
|
||||
Configure(localIP net.IP, prefix int, peerIP net.IP) error
|
||||
// ConfigureIPv6 sets a secondary IPv6 address and prefix on the
|
||||
// interface. Unlike Configure, there is no peer IP because macOS
|
||||
// utun IPv6 does not use the point-to-point aliasing form.
|
||||
ConfigureIPv6(localIP6 net.IP, prefix6 int) error
|
||||
// SetMTU sets the interface MTU.
|
||||
SetMTU(mtu int) error
|
||||
// Close destroys the TUN device.
|
||||
|
||||
@@ -44,6 +44,16 @@ func (d *darwinDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) erro
|
||||
return execCmd("ifconfig", d.Name(), inetType, localCidr, peerIP.String(), "up")
|
||||
}
|
||||
|
||||
func (d *darwinDevice) ConfigureIPv6(localIP6 net.IP, prefix6 int) error {
|
||||
if localIP6 == nil {
|
||||
return nil
|
||||
}
|
||||
// macOS utun IPv6 uses the plain address form (no peer aliasing):
|
||||
// ifconfig utunN inet6 <ip6>/<prefix6> up
|
||||
localCidr := fmt.Sprintf("%s/%d", localIP6.String(), prefix6)
|
||||
return execCmd("ifconfig", d.Name(), "inet6", localCidr, "up")
|
||||
}
|
||||
|
||||
func (d *darwinDevice) SetMTU(mtu int) error {
|
||||
return execCmd("ifconfig", d.Name(), "mtu", fmt.Sprintf("%d", mtu))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package tun
|
||||
|
||||
@@ -46,6 +46,14 @@ func (d *linuxDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *linuxDevice) ConfigureIPv6(localIP6 net.IP, prefix6 int) error {
|
||||
if localIP6 == nil {
|
||||
return nil
|
||||
}
|
||||
localCidr := fmt.Sprintf("%s/%d", localIP6.String(), prefix6)
|
||||
return execCmd("ip", "-6", "addr", "add", "dev", d.Name(), localCidr)
|
||||
}
|
||||
|
||||
func (d *linuxDevice) SetMTU(mtu int) error {
|
||||
return execCmd("ip", "link", "set", "dev", d.Name(), "mtu", fmt.Sprintf("%d", mtu))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//go:build windows
|
||||
|
||||
// Package tun — Windows implementation using WinTun.
|
||||
//
|
||||
// WinTun is a userspace TUN driver for Windows developed by the
|
||||
// WireGuard project. It requires only a single wintun.dll (MIT
|
||||
// licensed) placed next to the executable — no driver installation
|
||||
// is needed.
|
||||
//
|
||||
// The DLL is embedded at compile time via //go:embed and extracted
|
||||
// to the executable's directory on first use.
|
||||
package tun
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.zx2c4.com/wintun"
|
||||
)
|
||||
|
||||
//go:embed wintun.dll
|
||||
var wintunDLL []byte
|
||||
|
||||
type wintunDevice struct {
|
||||
adapter *wintun.Adapter
|
||||
session wintun.Session
|
||||
readWait windows.Handle
|
||||
name string
|
||||
}
|
||||
|
||||
func createTUN(name string) (Device, error) {
|
||||
if err := ensureWintunDLL(); err != nil {
|
||||
return nil, fmt.Errorf("extract wintun.dll: %w", err)
|
||||
}
|
||||
if name == "" {
|
||||
name = "LMVPN"
|
||||
}
|
||||
adapter, err := wintun.CreateAdapter(name, "Wintun", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create wintun adapter: %w", err)
|
||||
}
|
||||
session, err := adapter.StartSession(0x800000) // 8 MiB ring
|
||||
if err != nil {
|
||||
adapter.Close()
|
||||
return nil, fmt.Errorf("start wintun session: %w", err)
|
||||
}
|
||||
return &wintunDevice{
|
||||
adapter: adapter,
|
||||
session: session,
|
||||
readWait: session.ReadWaitEvent(),
|
||||
name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Name() string { return d.name }
|
||||
func (d *wintunDevice) Close() error { d.session.End(); return d.adapter.Close() }
|
||||
|
||||
func (d *wintunDevice) Read(p []byte) (int, error) {
|
||||
for {
|
||||
packet, err := d.session.ReceivePacket()
|
||||
if err == nil {
|
||||
n := copy(p, packet)
|
||||
d.session.ReleaseReceivePacket(packet)
|
||||
return n, nil
|
||||
}
|
||||
if err == windows.ERROR_NO_MORE_ITEMS {
|
||||
windows.WaitForSingleObject(d.readWait, windows.INFINITE)
|
||||
continue
|
||||
}
|
||||
if err == windows.ERROR_HANDLE_EOF {
|
||||
return 0, fmt.Errorf("wintun closed")
|
||||
}
|
||||
return 0, fmt.Errorf("wintun read: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Write(p []byte) (int, error) {
|
||||
buf, err := d.session.AllocateSendPacket(len(p))
|
||||
if err != nil {
|
||||
if err == windows.ERROR_HANDLE_EOF {
|
||||
return 0, fmt.Errorf("wintun closed")
|
||||
}
|
||||
return 0, fmt.Errorf("wintun allocate: %w", err)
|
||||
}
|
||||
copy(buf, p)
|
||||
d.session.SendPacket(buf)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (d *wintunDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error {
|
||||
if localIP == nil {
|
||||
return execCmd("netsh", "interface", "ip", "set", "address", "name="+d.name, "source=dhcp")
|
||||
}
|
||||
if localIP.To4() == nil {
|
||||
return d.configureIPv6Addr(localIP, prefix)
|
||||
}
|
||||
mask := net.IP(net.CIDRMask(prefix, 32))
|
||||
return execCmd("netsh", "interface", "ip", "set", "address",
|
||||
"name="+d.name, "source=static",
|
||||
"addr="+localIP.String(), "mask="+mask.String())
|
||||
}
|
||||
|
||||
func (d *wintunDevice) ConfigureIPv6(localIP6 net.IP, prefix6 int) error {
|
||||
if localIP6 == nil {
|
||||
return nil
|
||||
}
|
||||
return d.configureIPv6Addr(localIP6, prefix6)
|
||||
}
|
||||
|
||||
func (d *wintunDevice) configureIPv6Addr(ip net.IP, prefix int) error {
|
||||
addr := fmt.Sprintf("%s/%d", ip.String(), prefix)
|
||||
return execCmd("netsh", "interface", "ipv6", "add", "address",
|
||||
"interface="+d.name, "address="+addr)
|
||||
}
|
||||
|
||||
func (d *wintunDevice) SetMTU(mtu int) error {
|
||||
if err := execCmd("netsh", "interface", "ipv4", "set", "subinterface",
|
||||
"name="+d.name, fmt.Sprintf("mtu=%d", mtu)); err != nil {
|
||||
return err
|
||||
}
|
||||
return execCmd("netsh", "interface", "ipv6", "set", "subinterface",
|
||||
"interface="+d.name, fmt.Sprintf("mtu=%d", mtu))
|
||||
}
|
||||
|
||||
// ensureWintunDLL extracts the embedded wintun.dll to the executable's
|
||||
// directory if it does not already exist. The wintun Go package loads
|
||||
// the DLL via LoadLibrary which searches the exe's directory first.
|
||||
func ensureWintunDLL() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(filepath.Dir(exe), "wintun.dll")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, wintunDLL, 0o644)
|
||||
}
|
||||
|
||||
// execCmd runs a command, forwarding stdout/stderr.
|
||||
func execCmd(name string, arg ...string) error {
|
||||
cmd := exec.Command(name, arg...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s %s: %w", name, strings.Join(arg, " "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Binary file not shown.
+153
-44
@@ -1,12 +1,14 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"lmvpn/internal/config"
|
||||
"lmvpn/internal/db"
|
||||
"lmvpn/internal/i18n"
|
||||
"lmvpn/internal/instance"
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/keychain"
|
||||
"lmvpn/internal/log"
|
||||
@@ -27,13 +29,27 @@ type App struct {
|
||||
window fyne.Window
|
||||
profileWindow fyne.Window
|
||||
|
||||
// Profile list window.
|
||||
profileListWindow fyne.Window
|
||||
profileList *widget.List
|
||||
listSelectedIndex int
|
||||
|
||||
// UI widgets
|
||||
profileSelect *widget.Select
|
||||
stateLabel *widget.Label
|
||||
ipLabel *widget.Label
|
||||
ip6Label *widget.Label
|
||||
uptimeLabel *widget.Label
|
||||
rxLabel *widget.Label
|
||||
txLabel *widget.Label
|
||||
rxV4Label *widget.Label
|
||||
txV4Label *widget.Label
|
||||
rxV6Label *widget.Label
|
||||
txV6Label *widget.Label
|
||||
rxTotalLabel *widget.Label
|
||||
txTotalLabel *widget.Label
|
||||
routingModeLabel *widget.Label
|
||||
cidrV4Label *widget.Label
|
||||
cidrV6Label *widget.Label
|
||||
refreshCIDRBtn *widget.Button
|
||||
connectBtn *widget.Button
|
||||
disconnectBtn *widget.Button
|
||||
|
||||
@@ -42,7 +58,9 @@ type App struct {
|
||||
ipcClient *ipc.Client
|
||||
profiles []model.ServerProfile
|
||||
currentProfile *model.ServerProfile
|
||||
defaultProfileID int64
|
||||
langSetting string
|
||||
windowHidden bool
|
||||
}
|
||||
|
||||
// Run initialises and starts the GUI application.
|
||||
@@ -55,6 +73,17 @@ func Run() {
|
||||
// Logging.
|
||||
log.Init(log.RoleGUI, paths.LogFile())
|
||||
|
||||
// Single-instance enforcement: the first instance holds the lock;
|
||||
// a second instance signals "focus" to the first and exits.
|
||||
focusCh, err := instance.Acquire()
|
||||
if err != nil {
|
||||
if errors.Is(err, instance.ErrAlreadyRunning) {
|
||||
log.L().Info("another instance is running, exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
log.L().Warn("instance lock failed, continuing", "error", err)
|
||||
}
|
||||
|
||||
// Database.
|
||||
store, err := db.Open()
|
||||
if err != nil {
|
||||
@@ -69,11 +98,16 @@ func Run() {
|
||||
log.L().Error("init i18n", "error", err)
|
||||
}
|
||||
|
||||
// Set the localized Touch ID prompt for keychain access (macOS).
|
||||
setTouchIDPromptFromI18n()
|
||||
|
||||
a := &App{
|
||||
fyneApp: app.NewWithID(paths.BundleID),
|
||||
db: store,
|
||||
kc: keychain.New(),
|
||||
langSetting: cfg.Language,
|
||||
defaultProfileID: cfg.DefaultProfileID,
|
||||
listSelectedIndex: -1,
|
||||
}
|
||||
|
||||
a.window = a.fyneApp.NewWindow(i18n.T("WindowTitle"))
|
||||
@@ -88,6 +122,46 @@ func Run() {
|
||||
// System tray.
|
||||
a.setupTray()
|
||||
|
||||
// Listen for "focus" signals from second instances and bring the
|
||||
// window to the front.
|
||||
if focusCh != nil {
|
||||
go func() {
|
||||
for range focusCh {
|
||||
fyne.Do(func() {
|
||||
a.windowHidden = false
|
||||
activateApp()
|
||||
showDockIcon()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Register a Cocoa handler so that re-opening the .app bundle
|
||||
// (which does NOT start a new process on macOS) brings the hidden
|
||||
// window back. On other platforms this is a no-op. This must be
|
||||
// deferred until after the Fyne/GLFW event loop has started,
|
||||
// because GLFWApplicationDelegate is not created until glfw.Init()
|
||||
// runs inside runGL().
|
||||
a.fyneApp.Lifecycle().SetOnStarted(func() {
|
||||
onAppActive = func() {
|
||||
if a.windowHidden {
|
||||
a.windowHidden = false
|
||||
activateApp()
|
||||
showDockIcon()
|
||||
fyne.Do(func() {
|
||||
if a.windowHidden {
|
||||
return
|
||||
}
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
})
|
||||
}
|
||||
}
|
||||
registerReopenHandler()
|
||||
})
|
||||
|
||||
// Auto-connect if configured.
|
||||
if cfg.AutoConnect && a.currentProfile != nil {
|
||||
go func() {
|
||||
@@ -98,16 +172,30 @@ func Run() {
|
||||
|
||||
a.window.SetCloseIntercept(func() {
|
||||
if cfg.CloseToTray {
|
||||
a.windowHidden = true
|
||||
hideDockIcon()
|
||||
a.window.Hide()
|
||||
} else {
|
||||
a.fyneApp.Quit()
|
||||
a.quit()
|
||||
}
|
||||
})
|
||||
|
||||
a.window.ShowAndRun()
|
||||
}
|
||||
|
||||
// quit disconnects any active VPN session (via the daemon) and then
|
||||
// quits the application. The daemon process itself remains running.
|
||||
func (a *App) quit() {
|
||||
a.mu.Lock()
|
||||
client := a.ipcClient
|
||||
a.ipcClient = nil
|
||||
a.mu.Unlock()
|
||||
if client != nil {
|
||||
_ = ipc.SendStop(client)
|
||||
}
|
||||
a.fyneApp.Quit()
|
||||
}
|
||||
|
||||
// loadProfiles loads all profiles from the database and populates the
|
||||
// selector.
|
||||
func (a *App) loadProfiles() {
|
||||
@@ -120,13 +208,36 @@ func (a *App) loadProfiles() {
|
||||
names := a.profileNames()
|
||||
a.profileSelect.Options = names
|
||||
if len(names) > 0 {
|
||||
selected := false
|
||||
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 {
|
||||
a.currentProfile = nil
|
||||
a.profileSelect.SetSelected("")
|
||||
a.profileSelect.Refresh()
|
||||
}
|
||||
a.listSelectedIndex = -1
|
||||
a.refreshProfileList()
|
||||
}
|
||||
|
||||
// refreshProfileList refreshes the profile list widget if the profile
|
||||
// list window is currently open.
|
||||
func (a *App) refreshProfileList() {
|
||||
if a.profileList != nil {
|
||||
a.profileList.Refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// profileNames returns the names of all loaded profiles.
|
||||
@@ -148,44 +259,31 @@ 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.
|
||||
func (a *App) onAddProfile() {
|
||||
a.showProfileDialog(nil)
|
||||
}
|
||||
|
||||
// onEditProfile shows a dialog to edit the current profile.
|
||||
func (a *App) onEditProfile() {
|
||||
if a.currentProfile == nil {
|
||||
dialog.NewCustom(i18n.T("DlgNoProfileTitle"), i18n.T("BtnOK"),
|
||||
widget.NewLabel(i18n.T("DlgNoProfileEditMsg")), a.window).Show()
|
||||
return
|
||||
}
|
||||
p := *a.currentProfile
|
||||
a.showProfileDialog(&p)
|
||||
}
|
||||
|
||||
// onDeleteProfile deletes the current profile after confirmation.
|
||||
func (a *App) onDeleteProfile() {
|
||||
if a.currentProfile == nil {
|
||||
return
|
||||
}
|
||||
name := a.currentProfile.Name
|
||||
dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"),
|
||||
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
|
||||
widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": name})),
|
||||
func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
|
||||
showError(i18n.T("DlgError"), err.Error(), a.window)
|
||||
return
|
||||
}
|
||||
_ = a.kc.DeleteAll(name)
|
||||
a.loadProfiles()
|
||||
}, a.window).Show()
|
||||
}
|
||||
|
||||
// onResetDB deletes the SQLite database file after confirmation,
|
||||
// then re-creates it. All profiles, credentials, and logs are lost.
|
||||
func (a *App) onResetDB() {
|
||||
@@ -232,11 +330,15 @@ func (a *App) onResetDB() {
|
||||
a.loadProfiles()
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxLabel.SetText(i18n.T("RxZero"))
|
||||
a.txLabel.SetText(i18n.T("TxZero"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.setConnButtons(true, false)
|
||||
}, a.window).Show()
|
||||
}
|
||||
|
||||
@@ -259,6 +361,9 @@ func (a *App) changeLanguage(lang string) {
|
||||
// Switch the active localizer.
|
||||
i18n.SetLanguage(lang)
|
||||
|
||||
// Update the Touch ID prompt text for the new language.
|
||||
setTouchIDPromptFromI18n()
|
||||
|
||||
// Rebuild everything that holds cached strings.
|
||||
a.rebuildUI()
|
||||
}
|
||||
@@ -277,6 +382,12 @@ func (a *App) rebuildUI() {
|
||||
selectedName = a.currentProfile.Name
|
||||
}
|
||||
|
||||
// Close the profile list window if open; it holds cached strings
|
||||
// and will be recreated with the new language on next open.
|
||||
if a.profileListWindow != nil {
|
||||
a.profileListWindow.Close()
|
||||
}
|
||||
|
||||
// Rebuild window content (creates fresh widgets with new strings).
|
||||
a.window.SetTitle(i18n.T("WindowTitle"))
|
||||
a.window.SetContent(a.buildMainWindow())
|
||||
@@ -297,12 +408,10 @@ func (a *App) rebuildUI() {
|
||||
|
||||
// Restore connection state.
|
||||
if wasConnected {
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
} else {
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
}
|
||||
|
||||
|
||||
+28
-33
@@ -3,13 +3,13 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/log"
|
||||
"lmvpn/internal/paths"
|
||||
"lmvpn/internal/version"
|
||||
)
|
||||
|
||||
// ensureDaemon checks if the daemon is running and launches it (as
|
||||
@@ -25,9 +25,27 @@ import (
|
||||
// The --daemon-bin flag tells daemon-launch where to find lmvpnd. If
|
||||
// not given, daemon-launch auto-detects it relative to its own path.
|
||||
func ensureDaemon() (*ipc.Client, error) {
|
||||
// Fast path: daemon already running.
|
||||
// Fast path: a daemon is already running. Verify its build version
|
||||
// matches ours; if not, it's a stale process from a previous build
|
||||
// and must be restarted so the new code takes effect.
|
||||
if c, err := ipc.Dial(); err == nil {
|
||||
if ver, qerr := c.QueryVersion(); qerr == nil && ver == version.Version {
|
||||
return c, nil
|
||||
} else {
|
||||
log.L().Info("stale daemon detected, restarting",
|
||||
"got", ver, "expected", version.Version, "query_err", qerr)
|
||||
_ = ipc.SendShutdown(c)
|
||||
c.Close()
|
||||
// Wait for the old daemon to exit and release the socket.
|
||||
for i := 0; i < 30; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if cc, err := ipc.Dial(); err == nil {
|
||||
cc.Close()
|
||||
} else {
|
||||
break // socket gone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
@@ -41,7 +59,7 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
}
|
||||
|
||||
// Compute the daemon binary path: same directory as the GUI binary.
|
||||
daemonBin := filepath.Join(filepath.Dir(exe), "lmvpnd")
|
||||
daemonBin := filepath.Join(filepath.Dir(exe), daemonBinaryName)
|
||||
if _, err := os.Stat(daemonBin); err != nil {
|
||||
return nil, fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err)
|
||||
}
|
||||
@@ -50,44 +68,21 @@ func ensureDaemon() (*ipc.Client, error) {
|
||||
uid := os.Getuid()
|
||||
gid := os.Getgid()
|
||||
|
||||
// Launch the daemon via osascript (prompts for admin password).
|
||||
// The `daemon-launch` subcommand forks lmvpnd with Setsid and exits
|
||||
// immediately, so osascript returns right away.
|
||||
script := fmt.Sprintf(
|
||||
`do shell script %q with administrator privileges`,
|
||||
fmt.Sprintf("%s daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s",
|
||||
shellQuote(exe), shellQuote(home), uid, gid, shellQuote(daemonBin)),
|
||||
)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
if err := launchElevated(exe, daemonBin, home, uid, gid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.L().Info("daemon launched via osascript",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
|
||||
// Wait for the daemon to become reachable.
|
||||
logFile := paths.DaemonLogFile()
|
||||
for i := 0; i < 40; i++ {
|
||||
for i := 0; i < 60; i++ {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
if c, err := ipc.Dial(); err == nil {
|
||||
log.L().Info("daemon connected", "socket", paths.IPCSocketPath())
|
||||
log.L().Info("daemon connected", "socket", paths.IPCAddress())
|
||||
return c, nil
|
||||
} else if i == 0 || i == 10 || i == 30 || i == 59 {
|
||||
log.L().Warn("daemon not reachable yet, retrying",
|
||||
"attempt", i+1, "socket", paths.IPCAddress(), "error", err)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile)
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes for shell safety.
|
||||
// Embedded single quotes are escaped using the '\'' pattern.
|
||||
func shellQuote(s string) string {
|
||||
result := "'"
|
||||
for _, r := range s {
|
||||
if r == '\'' {
|
||||
result += "'\\''"
|
||||
} else {
|
||||
result += string(r)
|
||||
}
|
||||
}
|
||||
result += "'"
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import "C"
|
||||
|
||||
// onAppActive is called from the Cocoa main thread when the app
|
||||
// becomes active (e.g. the user re-opens the .app bundle while the
|
||||
// process is still running). It is set once in Run() before the
|
||||
// event loop starts, so no synchronisation is needed.
|
||||
var onAppActive func()
|
||||
|
||||
//export cmAppBecameActive
|
||||
func cmAppBecameActive() {
|
||||
if onAppActive != nil {
|
||||
onAppActive()
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,54 @@ package ui
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
|
||||
// Forward declaration: Go callback (defined via //export in
|
||||
// dock_callback_darwin.go). Called when the user re-opens the .app
|
||||
// bundle while the process is still running (e.g. after closing the
|
||||
// window to tray).
|
||||
extern void cmAppBecameActive(void);
|
||||
|
||||
// appShouldHandleReopen is the IMP added to GLFWApplicationDelegate
|
||||
// for the applicationShouldHandleReopen:hasVisibleWindows: selector.
|
||||
// This is the delegate method macOS calls when the user re-opens an
|
||||
// already-running .app bundle. We return YES so macOS proceeds with
|
||||
// its default activation, and invoke the Go callback to show the
|
||||
// hidden window.
|
||||
static BOOL appShouldHandleReopen(id self, SEL cmd, id sender, BOOL flag) {
|
||||
cmAppBecameActive();
|
||||
return YES;
|
||||
}
|
||||
|
||||
static void setDockIconVisible(int visible) {
|
||||
Class cls = objc_getClass("NSApplication");
|
||||
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
||||
((void (*)(id, SEL, long))objc_msgSend)(app, sel_getUid("setActivationPolicy:"), visible ? 0 : 1);
|
||||
}
|
||||
|
||||
static void cmActivateApp(void) {
|
||||
Class cls = objc_getClass("NSApplication");
|
||||
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
|
||||
((void (*)(id, SEL, BOOL))objc_msgSend)(app, sel_getUid("activateIgnoringOtherApps:"), YES);
|
||||
}
|
||||
|
||||
// cmRegisterReopenHandler adds applicationShouldHandleReopen:
|
||||
// hasVisibleWindows: to the GLFWApplicationDelegate class at runtime
|
||||
// (class_addMethod). This lets us detect when macOS re-opens the
|
||||
// .app bundle without starting a new process (which bypasses our IPC
|
||||
// single-instance mechanism). If the class already implements the
|
||||
// method, this is a no-op.
|
||||
static void cmRegisterReopenHandler(void) {
|
||||
Class cls = objc_getClass("GLFWApplicationDelegate");
|
||||
if (!cls) return;
|
||||
SEL sel = sel_getUid("applicationShouldHandleReopen:hasVisibleWindows:");
|
||||
if (class_getInstanceMethod(cls, sel)) return;
|
||||
class_addMethod(cls, sel, (IMP)appShouldHandleReopen, "B@:@B");
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
func showDockIcon() { C.setDockIconVisible(1) }
|
||||
func hideDockIcon() { C.setDockIconVisible(0) }
|
||||
|
||||
func activateApp() { C.cmActivateApp() }
|
||||
|
||||
func registerReopenHandler() { C.cmRegisterReopenHandler() }
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
|
||||
package ui
|
||||
|
||||
var onAppActive func()
|
||||
|
||||
func showDockIcon() {}
|
||||
func hideDockIcon() {}
|
||||
|
||||
func activateApp() {}
|
||||
|
||||
func registerReopenHandler() {}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with admin
|
||||
// privileges via osascript on macOS.
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
script := fmt.Sprintf(
|
||||
`do shell script %q with administrator privileges`,
|
||||
fmt.Sprintf("%s daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s",
|
||||
shellQuote(exe), shellQuote(home), uid, gid, shellQuote(daemonBin)),
|
||||
)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
}
|
||||
log.L().Info("daemon launched via osascript",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes for shell safety.
|
||||
// Embedded single quotes are escaped using the '\” pattern.
|
||||
func shellQuote(s string) string {
|
||||
result := "'"
|
||||
for _, r := range s {
|
||||
if r == '\'' {
|
||||
result += "'\\''"
|
||||
} else {
|
||||
result += string(r)
|
||||
}
|
||||
}
|
||||
result += "'"
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with admin
|
||||
// privileges. On Linux, pkexec is used (common on desktop Linux).
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
cmd := exec.Command("pkexec", exe, "daemon-launch",
|
||||
"--user-home", home,
|
||||
"--uid", fmt.Sprintf("%d", uid),
|
||||
"--gid", fmt.Sprintf("%d", gid),
|
||||
"--daemon-bin", daemonBin)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("launch daemon: %w (output: %s)", err, string(out))
|
||||
}
|
||||
log.L().Info("daemon launched via pkexec",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"lmvpn/internal/log"
|
||||
)
|
||||
|
||||
const daemonBinaryName = "lmvpnd.exe"
|
||||
|
||||
// launchElevated launches the daemon-launch subcommand with UAC
|
||||
// elevation via ShellExecute "runas" on Windows.
|
||||
func launchElevated(exe, daemonBin, home string, uid, gid int) error {
|
||||
args := fmt.Sprintf("daemon-launch --user-home \"%s\" --uid %d --gid %d --daemon-bin \"%s\"",
|
||||
home, uid, gid, daemonBin)
|
||||
|
||||
shell32 := syscall.NewLazyDLL("shell32.dll")
|
||||
proc := shell32.NewProc("ShellExecuteW")
|
||||
|
||||
verb, _ := syscall.UTF16PtrFromString("runas")
|
||||
file, _ := syscall.UTF16PtrFromString(exe)
|
||||
params, _ := syscall.UTF16PtrFromString(args)
|
||||
dir, _ := syscall.UTF16PtrFromString("")
|
||||
|
||||
ret, _, err := proc.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(verb)),
|
||||
uintptr(unsafe.Pointer(file)),
|
||||
uintptr(unsafe.Pointer(params)),
|
||||
uintptr(unsafe.Pointer(dir)),
|
||||
1, // SW_SHOWNORMAL
|
||||
)
|
||||
|
||||
if ret <= 32 {
|
||||
return fmt.Errorf("UAC elevation failed (code %d): %w", ret, err)
|
||||
}
|
||||
log.L().Info("daemon launched via UAC",
|
||||
"uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24">
|
||||
<path fill="#24292f" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
+380
-16
@@ -1,13 +1,17 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"lmvpn/internal/i18n"
|
||||
"lmvpn/internal/model"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
@@ -16,9 +20,13 @@ import (
|
||||
// mapped back to the codes stored in the database.
|
||||
var (
|
||||
authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)}
|
||||
routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)}
|
||||
routeCodes = []string{string(model.RoutingFull), string(model.RoutingProxy), string(model.RoutingBypass)}
|
||||
|
||||
protoCodes = []string{"wss", "ws"}
|
||||
|
||||
fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)}
|
||||
|
||||
ipPrefCodes = []string{string(model.IPPrefAuto), string(model.IPPrefV4), string(model.IPPrefV6)}
|
||||
)
|
||||
|
||||
func authModeLabels() []string {
|
||||
@@ -26,13 +34,21 @@ func authModeLabels() []string {
|
||||
}
|
||||
|
||||
func routeModeLabels() []string {
|
||||
return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeSplit"), i18n.T("RoutingModeCustom")}
|
||||
return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeProxy"), i18n.T("RoutingModeBypass")}
|
||||
}
|
||||
|
||||
func protoLabels() []string {
|
||||
return []string{"wss", "ws"}
|
||||
}
|
||||
|
||||
func fetchTimingLabels() []string {
|
||||
return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")}
|
||||
}
|
||||
|
||||
func ipPrefLabels() []string {
|
||||
return []string{i18n.T("IPPrefAuto"), i18n.T("IPPrefV4"), i18n.T("IPPrefV6")}
|
||||
}
|
||||
|
||||
// codeIndex returns the position of code in codes, or 0 if not found.
|
||||
func codeIndex(codes []string, code string) int {
|
||||
for i, c := range codes {
|
||||
@@ -51,6 +67,100 @@ func selectedCode(codes []string, idx int) string {
|
||||
return codes[idx]
|
||||
}
|
||||
|
||||
// urlEntryRow holds the widgets for a single CIDR URL source row.
|
||||
type urlEntryRow struct {
|
||||
urlEntry *widget.Entry
|
||||
timingSelect *widget.Select
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
// cidrURLList manages a dynamic list of CIDR URL source rows.
|
||||
type cidrURLList struct {
|
||||
rows []*urlEntryRow
|
||||
container *fyne.Container
|
||||
parent fyne.Window
|
||||
}
|
||||
|
||||
func newCIDRURLList(parent fyne.Window) *cidrURLList {
|
||||
cl := &cidrURLList{
|
||||
container: container.NewVBox(),
|
||||
parent: parent,
|
||||
}
|
||||
return cl
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) addRow(url string, timingIdx int) {
|
||||
urlEntry := widget.NewEntry()
|
||||
urlEntry.Wrapping = fyne.TextWrapOff
|
||||
urlEntry.Scroll = container.ScrollHorizontalOnly
|
||||
urlEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRURL"))
|
||||
urlEntry.SetText(url)
|
||||
|
||||
timingSelect := widget.NewSelect(fetchTimingLabels(), nil)
|
||||
if timingIdx >= 0 && timingIdx < len(fetchTimingCodes) {
|
||||
timingSelect.SetSelectedIndex(timingIdx)
|
||||
} else {
|
||||
timingSelect.SetSelectedIndex(0)
|
||||
}
|
||||
|
||||
row := &urlEntryRow{
|
||||
urlEntry: urlEntry,
|
||||
timingSelect: timingSelect,
|
||||
}
|
||||
|
||||
removeBtn := widget.NewButton(i18n.T("BtnRemoveURL"), func() {
|
||||
cl.removeRow(row)
|
||||
})
|
||||
|
||||
row.container = container.NewBorder(nil, nil, nil, removeBtn,
|
||||
container.NewVBox(urlEntry, timingSelect))
|
||||
|
||||
cl.rows = append(cl.rows, row)
|
||||
cl.container.Add(row.container)
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) removeRow(row *urlEntryRow) {
|
||||
for i, r := range cl.rows {
|
||||
if r == row {
|
||||
cl.rows = append(cl.rows[:i], cl.rows[i+1:]...)
|
||||
cl.container.Remove(row.container)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) loadFromJSON(jsonStr string) {
|
||||
for _, r := range cl.rows {
|
||||
cl.container.Remove(r.container)
|
||||
}
|
||||
cl.rows = nil
|
||||
|
||||
sources := model.ParseCIDRURLs(jsonStr)
|
||||
for _, s := range sources {
|
||||
timingIdx := codeIndex(fetchTimingCodes, string(s.FetchTiming))
|
||||
cl.addRow(s.URL, timingIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) toSources() []model.CIDRURLSource {
|
||||
var sources []model.CIDRURLSource
|
||||
for _, r := range cl.rows {
|
||||
url := strings.TrimSpace(r.urlEntry.Text)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, model.CIDRURLSource{
|
||||
URL: url,
|
||||
FetchTiming: model.FetchTiming(selectedCode(fetchTimingCodes, r.timingSelect.SelectedIndex())),
|
||||
})
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func (cl *cidrURLList) toJSON() string {
|
||||
return marshalCIDRURLs(cl.toSources())
|
||||
}
|
||||
|
||||
// showProfileDialog opens a separate window for editing a server profile.
|
||||
// If editing is nil, a new profile is created. If a profile window is
|
||||
// already open, it is brought to the front instead of opening a second one.
|
||||
@@ -94,18 +204,97 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
|
||||
authSelect := widget.NewSelect(authModeLabels(), nil)
|
||||
routeSelect := widget.NewSelect(routeModeLabels(), nil)
|
||||
ipPrefSelect := widget.NewSelect(ipPrefLabels(), nil)
|
||||
|
||||
cidrEntry := widget.NewMultiLineEntry()
|
||||
cidrEntry.Wrapping = fyne.TextWrapOff
|
||||
cidrEntry.Scroll = container.ScrollNone
|
||||
cidrEntry.SetMinRowsVisible(4)
|
||||
cidrEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRs"))
|
||||
cidrV4Entry := widget.NewMultiLineEntry()
|
||||
cidrV4Entry.Wrapping = fyne.TextWrapOff
|
||||
cidrV4Entry.Scroll = container.ScrollNone
|
||||
cidrV4Entry.SetMinRowsVisible(3)
|
||||
cidrV4Entry.SetPlaceHolder(i18n.T("PlaceholderCIDRV4"))
|
||||
|
||||
cidrV6Entry := widget.NewMultiLineEntry()
|
||||
cidrV6Entry.Wrapping = fyne.TextWrapOff
|
||||
cidrV6Entry.Scroll = container.ScrollNone
|
||||
cidrV6Entry.SetMinRowsVisible(3)
|
||||
cidrV6Entry.SetPlaceHolder(i18n.T("PlaceholderCIDRV6"))
|
||||
|
||||
mtuEntry := widget.NewEntry()
|
||||
mtuEntry.Wrapping = fyne.TextWrapOff
|
||||
mtuEntry.Scroll = container.ScrollNone
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// CIDR URL lists for IPv4 and IPv6.
|
||||
v4URLList := newCIDRURLList(profileWin)
|
||||
v6URLList := newCIDRURLList(profileWin)
|
||||
|
||||
v4AddURLBtn := widget.NewButton(i18n.T("BtnAddURL"), func() {
|
||||
v4URLList.addRow("", 0)
|
||||
})
|
||||
v6AddURLBtn := widget.NewButton(i18n.T("BtnAddURL"), func() {
|
||||
v6URLList.addRow("", 0)
|
||||
})
|
||||
|
||||
// CIDR section visibility: hide when routing mode is "full".
|
||||
cidrV4EntryBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV4")), cidrV4Entry)
|
||||
cidrV6EntryBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV6")), cidrV6Entry)
|
||||
v4URLBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV4URLs")), v4URLList.container, v4AddURLBtn)
|
||||
v6URLBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV6URLs")), v6URLList.container, v6AddURLBtn)
|
||||
cidrSection := container.NewVBox(cidrV4EntryBox, cidrV6EntryBox, v4URLBox, v6URLBox)
|
||||
|
||||
setCIDRVisible := func(mode string) {
|
||||
if mode == string(model.RoutingFull) {
|
||||
cidrSection.Hide()
|
||||
} else {
|
||||
cidrSection.Show()
|
||||
}
|
||||
}
|
||||
|
||||
if !isNew {
|
||||
nameEntry.SetText(editing.Name)
|
||||
protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol))
|
||||
@@ -118,8 +307,16 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
userEntry.SetText(editing.Username)
|
||||
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
|
||||
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
|
||||
cidrEntry.SetText(editing.CustomCIDRs)
|
||||
ipPrefSelect.SetSelectedIndex(codeIndex(ipPrefCodes, editing.IPPreference))
|
||||
cidrV4Entry.SetText(editing.CIDRV4)
|
||||
cidrV6Entry.SetText(editing.CIDRV6)
|
||||
v4URLList.loadFromJSON(editing.CIDRV4URLs)
|
||||
v6URLList.loadFromJSON(editing.CIDRV6URLs)
|
||||
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"))
|
||||
} else {
|
||||
protoSelect.SetSelectedIndex(0) // wss
|
||||
@@ -127,9 +324,20 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
pathEntry.SetText("/ws")
|
||||
authSelect.SetSelectedIndex(codeIndex(authCodes, string(model.AuthModeBoth)))
|
||||
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(model.RoutingFull)))
|
||||
ipPrefSelect.SetSelectedIndex(0) // auto
|
||||
mtuEntry.SetText("0")
|
||||
}
|
||||
|
||||
protoSelect.OnChanged = func(value string) {
|
||||
setTLSEnabled(value == "wss")
|
||||
}
|
||||
setTLSEnabled(selectedCode(protoCodes, protoSelect.SelectedIndex()) == "wss")
|
||||
|
||||
routeSelect.OnChanged = func(_ string) {
|
||||
setCIDRVisible(selectedCode(routeCodes, routeSelect.SelectedIndex()))
|
||||
}
|
||||
setCIDRVisible(selectedCode(routeCodes, routeSelect.SelectedIndex()))
|
||||
|
||||
form := container.NewVBox(
|
||||
widget.NewLabel(i18n.T("FieldName")),
|
||||
nameEntry,
|
||||
@@ -150,18 +358,34 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldPassword")), passEntry),
|
||||
),
|
||||
|
||||
container.NewGridWithColumns(2,
|
||||
container.NewGridWithColumns(3,
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldAuthMode")), authSelect),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
|
||||
container.NewVBox(widget.NewLabel(i18n.T("FieldIPPreference")), ipPrefSelect),
|
||||
),
|
||||
|
||||
widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry,
|
||||
cidrSection,
|
||||
|
||||
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
|
||||
|
||||
// Update parent references now that the window exists.
|
||||
v4URLList.parent = profileWin
|
||||
v6URLList.parent = profileWin
|
||||
|
||||
profileWin.SetOnClosed(func() {
|
||||
a.profileWindow = nil
|
||||
})
|
||||
@@ -175,7 +399,13 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
userEntry.Text, passEntry.Text,
|
||||
selectedCode(authCodes, authSelect.SelectedIndex()),
|
||||
selectedCode(routeCodes, routeSelect.SelectedIndex()),
|
||||
cidrEntry.Text, mtuEntry.Text, isNew) {
|
||||
cidrV4Entry.Text, cidrV6Entry.Text,
|
||||
v4URLList.toJSON(), v6URLList.toJSON(),
|
||||
mtuEntry.Text,
|
||||
tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
|
||||
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
|
||||
isNew,
|
||||
selectedCode(ipPrefCodes, ipPrefSelect.SelectedIndex())) {
|
||||
profileWin.Close()
|
||||
}
|
||||
})
|
||||
@@ -186,19 +416,33 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
})
|
||||
|
||||
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, 860))
|
||||
profileWin.Show()
|
||||
}
|
||||
|
||||
// saveProfile creates or updates a profile and stores credentials.
|
||||
// Returns true on success, false if validation or DB operation failed.
|
||||
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,
|
||||
cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr,
|
||||
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool,
|
||||
ipPreference string) bool {
|
||||
if name == "" || host == "" || user == "" {
|
||||
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
mtu := parseIntDefault(mtuStr, 0)
|
||||
|
||||
@@ -213,8 +457,16 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
Username: user,
|
||||
AuthMode: model.AuthMode(authMode),
|
||||
RoutingMode: model.RoutingMode(routeMode),
|
||||
CustomCIDRs: cidrs,
|
||||
CIDRV4: cidrV4,
|
||||
CIDRV6: cidrV6,
|
||||
CIDRV4URLs: cidrV4URLs,
|
||||
CIDRV6URLs: cidrV6URLs,
|
||||
MTUOverride: mtu,
|
||||
TLSCACert: tlsCaPEM,
|
||||
TLSCAPath: tlsCaPath,
|
||||
TLSInsecure: tlsInsecure,
|
||||
TLSPinnedHash: tlsPinnedHash,
|
||||
IPPreference: ipPreference,
|
||||
}
|
||||
id, err := a.db.CreateProfile(p)
|
||||
if err != nil {
|
||||
@@ -238,8 +490,16 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
editing.Username = user
|
||||
editing.AuthMode = model.AuthMode(authMode)
|
||||
editing.RoutingMode = model.RoutingMode(routeMode)
|
||||
editing.CustomCIDRs = cidrs
|
||||
editing.CIDRV4 = cidrV4
|
||||
editing.CIDRV6 = cidrV6
|
||||
editing.CIDRV4URLs = cidrV4URLs
|
||||
editing.CIDRV6URLs = cidrV6URLs
|
||||
editing.MTUOverride = mtu
|
||||
editing.TLSCACert = tlsCaPEM
|
||||
editing.TLSCAPath = tlsCaPath
|
||||
editing.TLSInsecure = tlsInsecure
|
||||
editing.TLSPinnedHash = tlsPinnedHash
|
||||
editing.IPPreference = ipPreference
|
||||
if err := a.db.UpdateProfile(editing); err != nil {
|
||||
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
|
||||
return false
|
||||
@@ -256,6 +516,16 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
return true
|
||||
}
|
||||
|
||||
// marshalCIDRURLsForDB is a helper for tests/debugging that encodes
|
||||
// CIDRURLSource slice to JSON.
|
||||
func marshalCIDRURLsForDB(sources []model.CIDRURLSource) string {
|
||||
data, err := json.Marshal(sources)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func fmtInt(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
@@ -267,3 +537,97 @@ func parseIntDefault(s string, def int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// showProfileListWindow opens a window listing all saved profiles with
|
||||
// buttons to add, edit, delete, and reset the database. Only one
|
||||
// instance is allowed; if already open it is brought to the front.
|
||||
func (a *App) showProfileListWindow() {
|
||||
if a.profileListWindow != nil {
|
||||
a.profileListWindow.RequestFocus()
|
||||
return
|
||||
}
|
||||
|
||||
a.profileList = widget.NewList(
|
||||
func() int { return len(a.profiles) },
|
||||
func() fyne.CanvasObject {
|
||||
name := widget.NewLabel("")
|
||||
name.TextStyle = fyne.TextStyle{Bold: true}
|
||||
host := widget.NewLabel("")
|
||||
return container.NewVBox(name, host)
|
||||
},
|
||||
func(id widget.ListItemID, obj fyne.CanvasObject) {
|
||||
box := obj.(*fyne.Container)
|
||||
nameLbl := box.Objects[0].(*widget.Label)
|
||||
hostLbl := box.Objects[1].(*widget.Label)
|
||||
if id >= 0 && id < len(a.profiles) {
|
||||
p := a.profiles[id]
|
||||
nameLbl.SetText(p.Name)
|
||||
hostLbl.SetText(fmt.Sprintf("%s:%d", p.Host, p.Port))
|
||||
}
|
||||
},
|
||||
)
|
||||
a.profileList.OnSelected = func(id widget.ListItemID) {
|
||||
a.listSelectedIndex = id
|
||||
}
|
||||
a.profileList.OnUnselected = func(_ widget.ListItemID) {
|
||||
a.listSelectedIndex = -1
|
||||
}
|
||||
|
||||
addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile)
|
||||
editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfileFromList)
|
||||
deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfileFromList)
|
||||
resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB)
|
||||
|
||||
buttons := container.NewGridWithColumns(4, addBtn, editBtn, deleteBtn, resetDBBtn)
|
||||
|
||||
win := a.fyneApp.NewWindow(i18n.T("ProfileListTitle"))
|
||||
a.profileListWindow = win
|
||||
win.SetOnClosed(func() {
|
||||
a.profileListWindow = nil
|
||||
a.profileList = nil
|
||||
a.listSelectedIndex = -1
|
||||
})
|
||||
|
||||
win.SetContent(container.NewBorder(nil, buttons, nil, nil, a.profileList))
|
||||
win.Resize(fyne.NewSize(460, 400))
|
||||
win.Show()
|
||||
}
|
||||
|
||||
// onEditProfileFromList opens the profile editor for the profile
|
||||
// selected in the list window.
|
||||
func (a *App) onEditProfileFromList() {
|
||||
idx := a.listSelectedIndex
|
||||
if idx < 0 || idx >= len(a.profiles) {
|
||||
dialog.NewCustom(i18n.T("DlgNoListSelectTitle"), i18n.T("BtnOK"),
|
||||
widget.NewLabel(i18n.T("DlgNoListSelectEditMsg")), a.profileListWindow).Show()
|
||||
return
|
||||
}
|
||||
p := a.profiles[idx]
|
||||
a.showProfileDialog(&p)
|
||||
}
|
||||
|
||||
// onDeleteProfileFromList deletes the profile selected in the list
|
||||
// window after confirmation.
|
||||
func (a *App) onDeleteProfileFromList() {
|
||||
idx := a.listSelectedIndex
|
||||
if idx < 0 || idx >= len(a.profiles) {
|
||||
dialog.NewCustom(i18n.T("DlgNoListSelectTitle"), i18n.T("BtnOK"),
|
||||
widget.NewLabel(i18n.T("DlgNoListSelectDeleteMsg")), a.profileListWindow).Show()
|
||||
return
|
||||
}
|
||||
p := a.profiles[idx]
|
||||
dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"),
|
||||
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
|
||||
widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": p.Name})),
|
||||
func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.db.DeleteProfile(p.ID); err != nil {
|
||||
showError(i18n.T("DlgError"), err.Error(), a.profileListWindow)
|
||||
return
|
||||
}
|
||||
_ = a.kc.DeleteAll(p.Name)
|
||||
a.loadProfiles()
|
||||
}, a.profileListWindow).Show()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build darwin
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"lmvpn/internal/i18n"
|
||||
"lmvpn/internal/keychain"
|
||||
)
|
||||
|
||||
// setTouchIDPromptFromI18n configures the localized Touch ID prompt
|
||||
// text on the keychain store for the current language.
|
||||
func setTouchIDPromptFromI18n() {
|
||||
keychain.SetTouchIDPrompt(i18n.T("TouchIDPrompt"))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !darwin
|
||||
|
||||
package ui
|
||||
|
||||
// setTouchIDPromptFromI18n is a no-op on non-darwin platforms where
|
||||
// Touch ID is not available.
|
||||
func setTouchIDPromptFromI18n() {}
|
||||
+18
-7
@@ -46,24 +46,35 @@ func (a *App) setupTray() {
|
||||
autoItem, enItem, zhItem,
|
||||
)
|
||||
|
||||
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||
a.onConnect()
|
||||
})
|
||||
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||
a.onDisconnect()
|
||||
})
|
||||
if a.connectBtn != nil {
|
||||
connectItem.Disabled = a.connectBtn.Disabled()
|
||||
}
|
||||
if a.disconnectBtn != nil {
|
||||
disconnectItem.Disabled = a.disconnectBtn.Disabled()
|
||||
}
|
||||
|
||||
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||
a.windowHidden = false
|
||||
activateApp()
|
||||
showDockIcon()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||
a.onConnect()
|
||||
}),
|
||||
fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||
a.onDisconnect()
|
||||
}),
|
||||
connectItem,
|
||||
disconnectItem,
|
||||
fyne.NewMenuItemSeparator(),
|
||||
langItem,
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem(i18n.T("TrayQuit"), func() {
|
||||
a.fyneApp.Quit()
|
||||
a.quit()
|
||||
}),
|
||||
)
|
||||
deskApp.SetSystemTrayMenu(menu)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
package ui
|
||||
|
||||
var Version = "dev"
|
||||
import "lmvpn/internal/version"
|
||||
|
||||
// Version is the application version, sourced from the shared version
|
||||
// package so the GUI and daemon report the same string.
|
||||
var Version = version.Version
|
||||
|
||||
+385
-58
@@ -1,44 +1,100 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/i18n"
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/keychain"
|
||||
"lmvpn/internal/model"
|
||||
"lmvpn/internal/stats"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
//go:embed github.svg
|
||||
var githubIconBytes []byte
|
||||
|
||||
// showError displays a titled error dialog.
|
||||
func showError(title, message string, parent fyne.Window) {
|
||||
dialog.NewCustom(title, i18n.T("BtnOK"), widget.NewLabel(message), parent).Show()
|
||||
}
|
||||
|
||||
// setConnButtons enables/disables the connect and disconnect buttons
|
||||
// and refreshes the tray menu to match.
|
||||
func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) {
|
||||
if connectEnabled {
|
||||
a.connectBtn.Enable()
|
||||
} else {
|
||||
a.connectBtn.Disable()
|
||||
}
|
||||
if disconnectEnabled {
|
||||
a.disconnectBtn.Enable()
|
||||
} else {
|
||||
a.disconnectBtn.Disable()
|
||||
}
|
||||
if a.profileSelect != nil {
|
||||
if connectEnabled {
|
||||
a.profileSelect.Enable()
|
||||
} else {
|
||||
a.profileSelect.Disable()
|
||||
}
|
||||
}
|
||||
a.setupTray()
|
||||
}
|
||||
|
||||
// buildMainWindow creates the main application window layout.
|
||||
func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
// Profile selector.
|
||||
a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) {
|
||||
a.selectProfileByName(sel)
|
||||
a.saveDefaultProfile()
|
||||
})
|
||||
|
||||
// Status display.
|
||||
a.stateLabel = widget.NewLabel(i18n.T("StateDisconnected"))
|
||||
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||
a.ipLabel = widget.NewLabel(i18n.T("IpNone"))
|
||||
a.ip6Label = widget.NewLabel(i18n.T("Ip6None"))
|
||||
a.uptimeLabel = widget.NewLabel(i18n.T("UptimeNone"))
|
||||
a.rxLabel = widget.NewLabel(i18n.T("RxZero"))
|
||||
a.txLabel = widget.NewLabel(i18n.T("TxZero"))
|
||||
a.rxV4Label = widget.NewLabel(i18n.T("RxV4Zero"))
|
||||
a.txV4Label = widget.NewLabel(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label = widget.NewLabel(i18n.T("RxV6Zero"))
|
||||
a.txV6Label = widget.NewLabel(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel = widget.NewLabel(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel = widget.NewLabel(i18n.T("TxTotalZero"))
|
||||
a.routingModeLabel = widget.NewLabel("")
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label = widget.NewLabel("")
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label = widget.NewLabel("")
|
||||
a.cidrV6Label.Hide()
|
||||
|
||||
a.refreshCIDRBtn = widget.NewButton(i18n.T("BtnRefreshCIDR"), a.onRefreshCIDR)
|
||||
a.refreshCIDRBtn.Hide()
|
||||
|
||||
statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox(
|
||||
a.stateLabel,
|
||||
a.ipLabel,
|
||||
a.ip6Label,
|
||||
a.uptimeLabel,
|
||||
container.NewHBox(a.rxLabel, a.txLabel),
|
||||
container.NewHBox(a.rxV4Label, a.txV4Label),
|
||||
container.NewHBox(a.rxV6Label, a.txV6Label),
|
||||
container.NewHBox(a.rxTotalLabel, a.txTotalLabel),
|
||||
a.routingModeLabel,
|
||||
a.cidrV4Label,
|
||||
a.cidrV6Label,
|
||||
a.refreshCIDRBtn,
|
||||
))
|
||||
|
||||
// Buttons.
|
||||
@@ -47,28 +103,35 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
a.disconnectBtn = widget.NewButton(i18n.T("BtnDisconnect"), a.onDisconnect)
|
||||
a.disconnectBtn.Disable()
|
||||
|
||||
addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile)
|
||||
editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfile)
|
||||
deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfile)
|
||||
|
||||
resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB)
|
||||
profileListBtn := widget.NewButton(i18n.T("BtnProfileList"), a.showProfileListWindow)
|
||||
|
||||
buttons := container.NewGridWithColumns(2,
|
||||
a.connectBtn, a.disconnectBtn,
|
||||
)
|
||||
profileButtons := container.NewGridWithColumns(3,
|
||||
addBtn, editBtn, deleteBtn,
|
||||
)
|
||||
|
||||
return container.NewVBox(
|
||||
githubBtn := widget.NewButtonWithIcon("", theme.NewThemedResource(fyne.NewStaticResource("github.svg", githubIconBytes)), func() {
|
||||
u, err := url.Parse("https://github.com/wuwenfengmi1998/lmvpn_client")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = a.fyneApp.OpenURL(u)
|
||||
})
|
||||
|
||||
content := container.NewVBox(
|
||||
widget.NewLabel(i18n.T("ProfileLabel")),
|
||||
a.profileSelect,
|
||||
buttons,
|
||||
profileButtons,
|
||||
resetDBBtn,
|
||||
profileListBtn,
|
||||
statusCard,
|
||||
widget.NewLabel(fmt.Sprintf("v%s", Version)),
|
||||
)
|
||||
|
||||
bottomBar := container.NewHBox(
|
||||
widget.NewLabel(fmt.Sprintf("v%s", Version)),
|
||||
layout.NewSpacer(),
|
||||
githubBtn,
|
||||
)
|
||||
|
||||
return container.NewBorder(nil, bottomBar, nil, nil, content)
|
||||
}
|
||||
|
||||
// onConnect handles the Connect button click.
|
||||
@@ -78,8 +141,7 @@ func (a *App) onConnect() {
|
||||
return
|
||||
}
|
||||
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
a.stateLabel.SetText(i18n.T("StateConnecting"))
|
||||
|
||||
go func() {
|
||||
@@ -89,26 +151,40 @@ func (a *App) onConnect() {
|
||||
fyne.Do(func() {
|
||||
showError(i18n.T("DlgDaemonError"), err.Error(), a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
oldClient := a.ipcClient
|
||||
a.ipcClient = client
|
||||
a.mu.Unlock()
|
||||
if oldClient != nil {
|
||||
// Do NOT send SendStop on the old connection. The daemon's
|
||||
// startSession already calls stopSessionLocked() which
|
||||
// tears down any existing session. Sending SendStop here
|
||||
// races with SendStart on the new connection - the daemon
|
||||
// processes each IPC connection in a separate goroutine,
|
||||
// so a late SendStop could kill the newly started session.
|
||||
oldClient.Close()
|
||||
}
|
||||
|
||||
// Get password from keychain.
|
||||
password, err := a.kc.GetPassword(a.currentProfile.Name)
|
||||
if err != nil {
|
||||
fyne.Do(func() {
|
||||
if errors.Is(err, keychain.ErrUserCanceled) {
|
||||
// User canceled the Touch ID / password prompt.
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.setConnButtons(true, false)
|
||||
} else {
|
||||
showError(i18n.T("DlgCredentialError"),
|
||||
i18n.T("DlgCredentialErrorMsg"),
|
||||
a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -130,15 +206,22 @@ func (a *App) onConnect() {
|
||||
Password: password,
|
||||
AuthMode: string(p.AuthMode),
|
||||
RoutingMode: string(p.RoutingMode),
|
||||
CustomCIDRs: splitCIDRs(p.CustomCIDRs),
|
||||
CIDRV4: model.SplitCIDRs(p.CIDRV4),
|
||||
CIDRV6: model.SplitCIDRs(p.CIDRV6),
|
||||
CIDRV4URLs: parseIPCCIDRURLs(p.CIDRV4URLs),
|
||||
CIDRV6URLs: parseIPCCIDRURLs(p.CIDRV6URLs),
|
||||
MTUOverride: p.MTUOverride,
|
||||
TLSCACert: p.TLSCACert,
|
||||
TLSCAPath: p.TLSCAPath,
|
||||
TLSInsecure: p.TLSInsecure,
|
||||
TLSPinnedHash: p.TLSPinnedHash,
|
||||
IPPreference: p.IPPreference,
|
||||
}
|
||||
if err := ipc.SendStart(client, cfg); err != nil {
|
||||
fyne.Do(func() {
|
||||
showError(i18n.T("DlgIPCError"), err.Error(), a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -148,18 +231,50 @@ func (a *App) onConnect() {
|
||||
}()
|
||||
}
|
||||
|
||||
// onDisconnect handles the Disconnect button click.
|
||||
func (a *App) onDisconnect() {
|
||||
// onRefreshCIDR handles the Refresh CIDR button click.
|
||||
func (a *App) onRefreshCIDR() {
|
||||
a.mu.Lock()
|
||||
client := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
a.refreshCIDRBtn.Disable()
|
||||
go func() {
|
||||
_ = ipc.SendRefreshCIDR(client)
|
||||
time.Sleep(2 * time.Second)
|
||||
fyne.Do(func() {
|
||||
a.refreshCIDRBtn.Enable()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// onDisconnect handles the Disconnect button click.
|
||||
func (a *App) onDisconnect() {
|
||||
a.mu.Lock()
|
||||
client := a.ipcClient
|
||||
a.ipcClient = nil
|
||||
a.mu.Unlock()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
_ = ipc.SendStop(client)
|
||||
a.disconnectBtn.Disable()
|
||||
a.connectBtn.Enable()
|
||||
client.Close()
|
||||
a.setConnButtons(true, false)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
}
|
||||
|
||||
// eventLoop reads IPC events from the daemon and updates the UI.
|
||||
@@ -181,11 +296,19 @@ func (a *App) eventLoop() {
|
||||
if current == client {
|
||||
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.rxLabel.SetText(i18n.T("RxZero"))
|
||||
a.txLabel.SetText(i18n.T("TxZero"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
a.setConnButtons(true, false)
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -193,49 +316,102 @@ func (a *App) eventLoop() {
|
||||
switch ev.Event {
|
||||
case ipc.EvState:
|
||||
fyne.Do(func() {
|
||||
a.applyState(ev.State)
|
||||
a.mu.Lock()
|
||||
current := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if current == client {
|
||||
a.applyStateWithStep(ev.State, ev.ConnectStep)
|
||||
}
|
||||
})
|
||||
case ipc.EvStats:
|
||||
if ev.Stats != nil {
|
||||
s := *ev.Stats
|
||||
fyne.Do(func() {
|
||||
a.mu.Lock()
|
||||
current := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if current == client {
|
||||
a.applyStats(s)
|
||||
}
|
||||
})
|
||||
}
|
||||
case ipc.EvError:
|
||||
fyne.Do(func() {
|
||||
if ev.Message != "" {
|
||||
showError(i18n.T("DlgVPNError"), ev.Message, a.window)
|
||||
a.mu.Lock()
|
||||
current := a.ipcClient
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// authErrorMessage maps a stable auth-error code (carried by the EvError
|
||||
// IPC event from the daemon) to a localized user-facing string. For an
|
||||
// unknown or empty code it falls back to the raw server message.
|
||||
func authErrorMessage(code, fallback string) string {
|
||||
switch code {
|
||||
case "wrong_credentials":
|
||||
return i18n.T("AuthErrWrongCredentials")
|
||||
case "user_disabled":
|
||||
return i18n.T("AuthErrUserDisabled")
|
||||
case "token_invalid":
|
||||
return i18n.T("AuthErrTokenInvalid")
|
||||
case "rate_limited":
|
||||
return i18n.T("AuthErrRateLimited")
|
||||
case "malformed":
|
||||
return i18n.T("AuthErrMalformed")
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// applyState updates UI elements for a state change.
|
||||
func (a *App) applyState(state string) {
|
||||
a.applyStateWithStep(state, "")
|
||||
}
|
||||
|
||||
// applyStateWithStep updates UI elements for a state change, optionally
|
||||
// showing a connection step description (e.g. "fetching CIDR lists").
|
||||
func (a *App) applyStateWithStep(state, step string) {
|
||||
stepLabel := connectStepLabel(step)
|
||||
switch stats.State(state) {
|
||||
case stats.StateConnected:
|
||||
if stepLabel != "" {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
|
||||
} else {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
}
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateConnecting:
|
||||
if stepLabel != "" {
|
||||
a.stateLabel.SetText(i18n.T("StateConnecting") + " (" + stepLabel + ")")
|
||||
} else {
|
||||
a.stateLabel.SetText(i18n.T("StateConnecting"))
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
}
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateReconnecting:
|
||||
a.stateLabel.SetText(i18n.T("StateReconnecting"))
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateDisconnected:
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.setConnButtons(true, false)
|
||||
case stats.StateError:
|
||||
a.stateLabel.SetText(i18n.T("StateError"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,14 +420,136 @@ func (a *App) applyStats(s stats.Snapshot) {
|
||||
if s.AssignedIP != "" {
|
||||
a.ipLabel.SetText(i18n.T("IpLabel", map[string]interface{}{"ip": s.AssignedIP}))
|
||||
}
|
||||
if s.AssignedIP6 != "" {
|
||||
a.ip6Label.SetText(i18n.T("Ip6Label", map[string]interface{}{"ip": s.AssignedIP6}))
|
||||
} else {
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
}
|
||||
if s.State == stats.StateConnected {
|
||||
stepLabel := connectStepLabel(s.ConnectStep)
|
||||
if stepLabel != "" {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
|
||||
} else if s.ServerHost != "" && s.ConnectedIP != "" {
|
||||
a.stateLabel.SetText(fmt.Sprintf("%s (%s -> %s)",
|
||||
i18n.T("StateConnected"), s.ServerHost, s.ConnectedIP))
|
||||
} else {
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
}
|
||||
a.rxLabel.SetText(i18n.T("RxLabel", map[string]interface{}{"bytes": formatBytes(s.RxBytes)}))
|
||||
a.txLabel.SetText(i18n.T("TxLabel", map[string]interface{}{"bytes": formatBytes(s.TxBytes)}))
|
||||
}
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.RxBytesV4), "speed": formatSpeed(s.RxSpeedV4),
|
||||
}))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.TxBytesV4), "speed": formatSpeed(s.TxSpeedV4),
|
||||
}))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.RxBytesV6), "speed": formatSpeed(s.RxSpeedV6),
|
||||
}))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Label", map[string]interface{}{
|
||||
"bytes": formatBytes(s.TxBytesV6), "speed": formatSpeed(s.TxSpeedV6),
|
||||
}))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalLabel", map[string]interface{}{
|
||||
"bytes": formatBytes(s.RxBytes), "speed": formatSpeed(s.RxSpeed),
|
||||
}))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalLabel", map[string]interface{}{
|
||||
"bytes": formatBytes(s.TxBytes), "speed": formatSpeed(s.TxSpeed),
|
||||
}))
|
||||
if s.Uptime > 0 {
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)}))
|
||||
}
|
||||
|
||||
// Routing mode + CIDR hit statistics.
|
||||
if s.RoutingMode != "" {
|
||||
modeLabel := routeModeLabel(s.RoutingMode)
|
||||
if s.RouteLoading {
|
||||
modeLabel += " (" + i18n.T("RouteLoading") + ")"
|
||||
}
|
||||
a.routingModeLabel.SetText(i18n.T("StatusRoutingMode", map[string]interface{}{"mode": modeLabel}))
|
||||
a.routingModeLabel.Show()
|
||||
|
||||
// Show CIDR error if present.
|
||||
if s.CIDRError != "" {
|
||||
a.cidrV4Label.SetText("IPv4 CIDR: " + i18n.T("CIDRFetchError"))
|
||||
a.cidrV6Label.SetText("IPv6 CIDR: " + i18n.T("CIDRFetchError"))
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
} else {
|
||||
switch s.RoutingMode {
|
||||
case "proxy":
|
||||
if s.RouteLoading {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading")))
|
||||
} else {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d %s",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRHit")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d %s",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRHit")))
|
||||
}
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
case "bypass":
|
||||
if s.RouteLoading {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)",
|
||||
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)",
|
||||
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading")))
|
||||
} else {
|
||||
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d %s | %s: %d %s",
|
||||
s.CIDRV4Total, i18n.T("CIDRConfigured"),
|
||||
i18n.T("CIDRUnmatched"), s.CIDRV4Hits, i18n.T("CIDRDestinations")))
|
||||
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d %s | %s: %d %s",
|
||||
s.CIDRV6Total, i18n.T("CIDRConfigured"),
|
||||
i18n.T("CIDRUnmatched"), s.CIDRV6Hits, i18n.T("CIDRDestinations")))
|
||||
}
|
||||
a.cidrV4Label.Show()
|
||||
a.cidrV6Label.Show()
|
||||
a.refreshCIDRBtn.Show()
|
||||
default:
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.routingModeLabel.Hide()
|
||||
a.cidrV4Label.Hide()
|
||||
a.cidrV6Label.Hide()
|
||||
a.refreshCIDRBtn.Hide()
|
||||
}
|
||||
}
|
||||
|
||||
// routeModeLabel returns the localised display name for a routing mode
|
||||
// code string.
|
||||
func routeModeLabel(code string) string {
|
||||
switch code {
|
||||
case "full":
|
||||
return i18n.T("RoutingModeFull")
|
||||
case "proxy":
|
||||
return i18n.T("RoutingModeProxy")
|
||||
case "bypass":
|
||||
return i18n.T("RoutingModeBypass")
|
||||
default:
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
// connectStepLabel translates a connection step code to a localised
|
||||
// display string. Returns "" for unknown/empty steps.
|
||||
func connectStepLabel(step string) string {
|
||||
switch step {
|
||||
case "fetch_cidrs":
|
||||
return i18n.T("StepFetchCIDRs")
|
||||
case "connecting":
|
||||
return i18n.T("StepConnecting")
|
||||
case "load_routes":
|
||||
return i18n.T("StepLoadRoutes")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatBytes formats a byte count human-readably.
|
||||
@@ -268,6 +566,23 @@ func formatBytes(b int64) string {
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// formatSpeed formats a bytes/sec rate as a decimal bits/sec rate
|
||||
// (kbps / Mbps / Gbps). bps is the byte rate; it is converted to bits
|
||||
// by multiplying by 8 and scaled by 1000 (decimal, network convention).
|
||||
func formatSpeed(bps int64) string {
|
||||
const unit = 1000
|
||||
bits := bps * 8
|
||||
if bits < unit {
|
||||
return fmt.Sprintf("%d bps", bits)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bits / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cbps", float64(bits)/float64(div), "kMGTPE"[exp])
|
||||
}
|
||||
|
||||
// formatDuration formats an uptime duration.
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
@@ -283,20 +598,32 @@ func formatDuration(d time.Duration) string {
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
|
||||
// splitCIDRs splits a comma-separated CIDR string into a slice.
|
||||
func splitCIDRs(s string) []string {
|
||||
if s == "" {
|
||||
// parseIPCCIDRURLs decodes a JSON-encoded model.CIDRURLSource array
|
||||
// from the profile string and converts it to the IPC wire type.
|
||||
func parseIPCCIDRURLs(jsonStr string) []ipc.CIDRURLSource {
|
||||
sources := model.ParseCIDRURLs(jsonStr)
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
start := 0
|
||||
for i := 0; i <= len(s); i++ {
|
||||
if i == len(s) || s[i] == ',' {
|
||||
if i > start {
|
||||
out = append(out, s[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
out := make([]ipc.CIDRURLSource, len(sources))
|
||||
for i, s := range sources {
|
||||
out[i] = ipc.CIDRURLSource{
|
||||
URL: s.URL,
|
||||
FetchTiming: string(s.FetchTiming),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// marshalCIDRURLs encodes a slice of CIDRURLSource to a JSON string
|
||||
// for storage in the database.
|
||||
func marshalCIDRURLs(sources []model.CIDRURLSource) string {
|
||||
if len(sources) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(sources)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Package version holds the build version string shared by the GUI
|
||||
// (lmvpn) and daemon (lmvpnd) binaries. The value is injected at link
|
||||
// time via ldflags:
|
||||
//
|
||||
// -X lmvpn/internal/version.Version=$(VERSION)
|
||||
//
|
||||
// Both binaries are built with the same LDFLAGS, so a version mismatch
|
||||
// between the running daemon and the GUI indicates a stale daemon
|
||||
// process that predates the current build.
|
||||
package version
|
||||
|
||||
// Version is the application version. Overridden at link time; "dev"
|
||||
// when running an untagged build (e.g. go run / go build without
|
||||
// ldflags).
|
||||
var Version = "dev"
|
||||
+674
-32
@@ -9,18 +9,24 @@ package vpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/auth"
|
||||
"lmvpn/internal/cidrsource"
|
||||
"lmvpn/internal/log"
|
||||
"lmvpn/internal/model"
|
||||
"lmvpn/internal/protocol"
|
||||
"lmvpn/internal/route"
|
||||
"lmvpn/internal/stats"
|
||||
"lmvpn/internal/tlsconfig"
|
||||
"lmvpn/internal/transport"
|
||||
"lmvpn/internal/tun"
|
||||
)
|
||||
@@ -35,8 +41,16 @@ type SessionConfig struct {
|
||||
AuthMode model.AuthMode
|
||||
Token string // pre-obtained JWT (empty = fetch via HTTP login)
|
||||
RoutingMode route.Mode
|
||||
CustomCIDRs []string
|
||||
CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode)
|
||||
CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode)
|
||||
CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources
|
||||
CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources
|
||||
MTUOverride int // 0 = use server MTU
|
||||
TLSCACert string // inline CA cert PEM (wss only)
|
||||
TLSCAPath string // CA cert file path (wss only)
|
||||
TLSInsecure bool // skip cert verification (wss only)
|
||||
TLSPinnedHash string // SHA-256 cert pin (wss only)
|
||||
IPPreference string // "auto" (default), "v4", "v6" - hostname mode only
|
||||
}
|
||||
|
||||
// SessionManager manages a single VPN session with auto-reconnect.
|
||||
@@ -44,6 +58,7 @@ type SessionManager struct {
|
||||
stats *stats.Stats
|
||||
onState func(stats.State)
|
||||
onStats func(stats.Snapshot)
|
||||
onError func(code string, msg string)
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
@@ -51,16 +66,38 @@ type SessionManager struct {
|
||||
dev tun.Device
|
||||
routeMgr *route.Manager
|
||||
conn *transport.Conn
|
||||
done chan struct{}
|
||||
|
||||
// CIDR hit tracking. Set during setupTUN, cleared on cleanup.
|
||||
cidrTracker *cidrTracker
|
||||
|
||||
// lastCfg stores the session config for RefreshCIDRs.
|
||||
lastCfg SessionConfig
|
||||
|
||||
// EWMA speed smoothing state. Only touched by reportStats (single
|
||||
// goroutine), so no lock needed. ewma* fields hold the smoothed
|
||||
// bytes/sec; prev* hold the last snapshot's cumulative counters and
|
||||
// tick time for delta computation.
|
||||
ewmaRxV4 float64
|
||||
ewmaTxV4 float64
|
||||
ewmaRxV6 float64
|
||||
ewmaTxV6 float64
|
||||
prevSnap stats.Snapshot
|
||||
prevTick time.Time
|
||||
speedReady bool
|
||||
}
|
||||
|
||||
// New creates a SessionManager. The onState callback (if non-nil) is
|
||||
// invoked on every state transition. The onStats callback (if non-nil)
|
||||
// is invoked periodically while connected.
|
||||
func New(onState func(stats.State), onStats func(stats.Snapshot)) *SessionManager {
|
||||
// is invoked periodically while connected. The onError callback (if
|
||||
// non-nil) is invoked once when a fatal, non-retryable error (such as
|
||||
// an authentication failure) terminates the session.
|
||||
func New(onState func(stats.State), onStats func(stats.Snapshot), onError func(string, string)) *SessionManager {
|
||||
return &SessionManager{
|
||||
stats: stats.New(),
|
||||
onState: onState,
|
||||
onStats: onStats,
|
||||
onError: onError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +119,8 @@ func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
sm.cancel = cancel
|
||||
sm.running = true
|
||||
sm.done = make(chan struct{})
|
||||
sm.lastCfg = cfg
|
||||
sm.mu.Unlock()
|
||||
|
||||
go sm.run(ctx, cfg)
|
||||
@@ -98,42 +137,106 @@ func (sm *SessionManager) Disconnect() {
|
||||
}
|
||||
sm.running = false
|
||||
cancel := sm.cancel
|
||||
// Extract resources so we can clean them up outside the lock.
|
||||
// Setting them to nil here also prevents the run goroutine's
|
||||
// cleanup from double-cleaning (it will see nil and skip).
|
||||
routeMgr := sm.routeMgr
|
||||
conn := sm.conn
|
||||
dev := sm.dev
|
||||
sm.routeMgr = nil
|
||||
sm.conn = nil
|
||||
sm.dev = nil
|
||||
sm.cidrTracker = nil
|
||||
sm.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
// Close the transport to unblock the packet pump.
|
||||
sm.mu.Lock()
|
||||
conn := sm.conn
|
||||
sm.mu.Unlock()
|
||||
|
||||
// CRITICAL: remove routes BEFORE closing the TUN device. If the TUN
|
||||
// is closed first, the /1 cover routes still point at the dead TUN
|
||||
// and all traffic is blackholed until routeMgr.Cleanup() runs -
|
||||
// this causes a brief network outage (browsers, DNS, etc.).
|
||||
if routeMgr != nil {
|
||||
if err := routeMgr.Cleanup(); err != nil {
|
||||
log.L().Error("route cleanup error during disconnect", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Now safe to close the transport and TUN device.
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
if dev != nil {
|
||||
dev.Close()
|
||||
}
|
||||
|
||||
// Wait for the run goroutine to fully exit. By now it should be
|
||||
// unblocked (ctx cancelled, conn/dev closed) and will see nil
|
||||
// routeMgr/conn/dev in cleanup, so it won't double-clean.
|
||||
if sm.done != nil {
|
||||
<-sm.done
|
||||
}
|
||||
}
|
||||
|
||||
// run is the main session loop with exponential-backoff reconnection
|
||||
// and CDN IP failover.
|
||||
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
defer sm.setState(stats.StateDisconnected)
|
||||
fatal := false
|
||||
defer close(sm.done)
|
||||
defer func() {
|
||||
if !fatal && ctx.Err() == nil {
|
||||
sm.setState(stats.StateDisconnected)
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch "before-proxy" CIDR lists ONCE before the reconnect loop.
|
||||
// These HTTP requests go through the physical NIC (routes are
|
||||
// clean). The result is reused across reconnection attempts so we
|
||||
// don't re-fetch on every retry. This runs outside the handshake
|
||||
// to avoid consuming the server's 30s ReadyTimeout budget.
|
||||
var beforeCIDRs []string
|
||||
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
|
||||
if len(allURLSources) > 0 {
|
||||
sm.stats.SetConnectStep("fetch_cidrs")
|
||||
sm.setState(stats.StateConnecting)
|
||||
log.L().Info("fetching before-proxy CIDR lists", "url_count", len(allURLSources))
|
||||
fetched, err := cidrsource.FetchBeforeProxy(ctx, allURLSources)
|
||||
if err != nil {
|
||||
log.L().Error("fetch before-proxy CIDR lists failed (continuing)", "error", err)
|
||||
}
|
||||
beforeCIDRs = fetched
|
||||
log.L().Info("before-proxy CIDR lists ready", "total_cidrs", len(beforeCIDRs))
|
||||
sm.stats.SetConnectStep("")
|
||||
}
|
||||
|
||||
backoff := time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
|
||||
// Build the full target list: original host first, then CDN IPs.
|
||||
targets := append([]string{""}, cfg.ServerIPs...) // "" = use base URL
|
||||
// Build the full target list. When ServerIPs is empty, connect via
|
||||
// hostname (IPPreference + race dialer apply). When ServerIPs is
|
||||
// non-empty, skip hostname and use IPs directly with failover.
|
||||
usingHostname := len(cfg.ServerIPs) == 0
|
||||
var targets []string
|
||||
if usingHostname {
|
||||
targets = []string{""} // "" = use base URL (hostname)
|
||||
} else {
|
||||
targets = cfg.ServerIPs // direct IP mode: sequential failover
|
||||
}
|
||||
ipIndex := 0
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
sm.cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
targetIP := ""
|
||||
if ipIndex > 0 && ipIndex < len(targets) {
|
||||
if ipIndex < len(targets) {
|
||||
targetIP = targets[ipIndex]
|
||||
}
|
||||
|
||||
err := sm.connectOnce(ctx, cfg, targetIP)
|
||||
err := sm.connectOnce(ctx, cfg, targetIP, beforeCIDRs)
|
||||
if ctx.Err() != nil {
|
||||
sm.cleanup()
|
||||
return
|
||||
@@ -141,12 +244,59 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
|
||||
if err != nil {
|
||||
log.L().Error("VPN connection failed", "error", err)
|
||||
|
||||
// Safety net: ensure no TUN/routes leak from a failed
|
||||
// attempt. connectOnce should have already cleaned up, but
|
||||
// this guards against any path that returned early.
|
||||
sm.cleanupResources()
|
||||
|
||||
// A TLS certificate verification failure on the original
|
||||
// hostname (ipIndex == 0, hostname mode) is not retryable:
|
||||
// the cert won't change between attempts, so stop the loop
|
||||
// and surface the reason to the user. On a CDN edge IP the
|
||||
// TLS error likely means that IP points to a different
|
||||
// server; skip it and try the next target.
|
||||
if tlsconfig.IsTLSError(err) {
|
||||
if usingHostname && ipIndex == 0 {
|
||||
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
|
||||
}
|
||||
log.L().Warn("TLS error on IP, skipping",
|
||||
"index", ipIndex, "ip", targets[ipIndex], "error", err)
|
||||
}
|
||||
|
||||
// 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 usingHostname && 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 IP, skipping",
|
||||
"index", ipIndex, "ip", targets[ipIndex], "code", code)
|
||||
}
|
||||
|
||||
sm.setState(stats.StateReconnecting)
|
||||
|
||||
// Try next CDN IP immediately.
|
||||
// Try next target IP immediately.
|
||||
ipIndex++
|
||||
if ipIndex < len(targets) {
|
||||
log.L().Info("trying next CDN IP", "index", ipIndex, "ip", targets[ipIndex])
|
||||
log.L().Info("trying next server IP", "index", ipIndex, "ip", targets[ipIndex])
|
||||
continue
|
||||
}
|
||||
// All targets exhausted; reset and wait with backoff.
|
||||
@@ -169,10 +319,48 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// fatalAuthError inspects err and, if it represents a permanent
|
||||
// authentication failure that should not be retried, returns the
|
||||
// stable error code, the raw server message, and true. It recognises
|
||||
// all three auth-failure shapes produced by the transport/auth layers:
|
||||
// - *transport.AuthError (WebSocket auth_err at the auth stage)
|
||||
// - *transport.ServerError (auth_err at the init stage, JWT path)
|
||||
// - *auth.LoginError (HTTP /api/login failure, JWT path)
|
||||
//
|
||||
// errors.As transparently unwraps fmt.Errorf("...: %w", err) chains,
|
||||
// so the wrapped LoginError returned by connectOnce is matched too.
|
||||
// A non-empty code is required: an auth_err with an unrecognized
|
||||
// message is treated as non-fatal so the loop falls back to retrying
|
||||
// (the server still closed the connection, but we lack a categorical
|
||||
// reason to give up).
|
||||
func fatalAuthError(err error) (protocol.AuthErrorCode, string, bool) {
|
||||
var authErr *transport.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
return authErr.Code, authErr.Message, authErr.Code != ""
|
||||
}
|
||||
var serverErr *transport.ServerError
|
||||
if errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr {
|
||||
return serverErr.Code, serverErr.Message, serverErr.Code != ""
|
||||
}
|
||||
var loginErr *auth.LoginError
|
||||
if errors.As(err, &loginErr) {
|
||||
switch loginErr.Code {
|
||||
case http.StatusTooManyRequests:
|
||||
return protocol.AuthCodeRateLimited, loginErr.Message, true
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return protocol.AuthCodeWrongCredentials, loginErr.Message, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// connectOnce performs a single connection lifecycle: authenticate,
|
||||
// handshake, configure TUN, apply routes, pump packets until failure.
|
||||
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error {
|
||||
// beforeCIDRs contains CIDRs pre-fetched from "before" timing URL
|
||||
// sources (fetched once in run(), reused across reconnection attempts).
|
||||
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string, beforeCIDRs []string) error {
|
||||
sm.setState(stats.StateConnecting)
|
||||
sm.stats.SetConnectStep("connecting")
|
||||
|
||||
// Build URL for this attempt. If targetIP is set (CDN failover),
|
||||
// build a URL with that IP. Otherwise use base ServerURL.
|
||||
@@ -181,6 +369,28 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
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.
|
||||
token := cfg.Token
|
||||
if token == "" && (cfg.AuthMode == model.AuthModeJWT || cfg.AuthMode == model.AuthModeBoth) {
|
||||
@@ -188,7 +398,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse server URL: %w", err)
|
||||
}
|
||||
result, err := auth.Login(httpBase, cfg.Username, cfg.Password)
|
||||
result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg, cfg.IPPreference)
|
||||
if err != nil {
|
||||
if cfg.AuthMode == model.AuthModeBoth {
|
||||
// Fall back to password auth.
|
||||
@@ -209,9 +419,11 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
Token: token,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
IPPreference: cfg.IPPreference,
|
||||
OnInit: func(init protocol.InitMessage) error {
|
||||
return sm.setupTUN(init, cfg)
|
||||
return sm.setupTUN(init, cfg, beforeCIDRs)
|
||||
},
|
||||
TLSConfig: tlsCfg,
|
||||
}
|
||||
|
||||
// Attempt JWT connection first; fall back to password on auth error.
|
||||
@@ -225,11 +437,19 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
if errors.As(err, &authErr) ||
|
||||
(errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr) {
|
||||
log.L().Info("JWT auth failed, falling back to password auth", "error", err)
|
||||
// Clean up any resources created by the failed attempt's
|
||||
// setupTUN before retrying.
|
||||
sm.cleanupResources()
|
||||
handshake.Token = ""
|
||||
conn, err = transport.Connect(ctx, handshake)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Clean up TUN/routes if setupTUN ran but the handshake
|
||||
// failed (e.g. server closed the connection after
|
||||
// ReadyTimeout). Without this, leaked /1 cover routes
|
||||
// blackhole all traffic and prevent reconnection.
|
||||
sm.cleanupResources()
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -238,16 +458,42 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
sm.conn = conn
|
||||
sm.mu.Unlock()
|
||||
|
||||
sm.stats.SetConnected(conn.Init().IP)
|
||||
serverHost := serverHostFromURL(cfg.ServerURL)
|
||||
connectedIP := conn.RemoteIP()
|
||||
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6, serverHost, connectedIP)
|
||||
sm.stats.SetConnectStep("load_routes")
|
||||
sm.setState(stats.StateConnected)
|
||||
log.L().Info("VPN connected",
|
||||
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
|
||||
"mtu", conn.Init().MTU)
|
||||
"ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6,
|
||||
"mtu", conn.Init().MTU,
|
||||
"server_host", serverHost, "connected_ip", connectedIP)
|
||||
|
||||
// Start stats reporter.
|
||||
statsDone := make(chan struct{})
|
||||
go sm.reportStats(statsDone, ctx)
|
||||
|
||||
// Apply deferred routes (user CIDRs) and fetch after-proxy CIDR
|
||||
// lists in a background goroutine. For proxy/bypass modes with
|
||||
// thousands of CIDRs this uses batch script execution (~3-5s).
|
||||
go func() {
|
||||
sm.mu.Lock()
|
||||
mgr := sm.routeMgr
|
||||
sm.mu.Unlock()
|
||||
if mgr != nil {
|
||||
sm.stats.SetRouteLoading(true)
|
||||
sm.stats.SetConnectStep("load_routes")
|
||||
log.L().Info("applying deferred routes")
|
||||
if err := mgr.ApplyDeferred(); err != nil {
|
||||
log.L().Error("apply deferred routes failed (continuing)", "error", err)
|
||||
}
|
||||
sm.stats.SetRouteLoading(false)
|
||||
sm.stats.SetConnectStep("")
|
||||
log.L().Info("deferred routes applied")
|
||||
}
|
||||
sm.fetchAfterProxyCIDRs(ctx, cfg)
|
||||
}()
|
||||
|
||||
// Run the packet pump (blocks until connection breaks).
|
||||
sm.pumpPackets(ctx, conn)
|
||||
|
||||
@@ -258,8 +504,12 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
|
||||
// setupTUN creates and configures the TUN device and applies routes.
|
||||
// This is called by the transport during the handshake, between init
|
||||
// and ready.
|
||||
func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) error {
|
||||
// and ready. It performs NO network calls (HTTP/DNS) so it completes
|
||||
// in milliseconds and never exceeds the server's ReadyTimeout.
|
||||
//
|
||||
// beforeCIDRs contains CIDRs fetched from "before" timing URL sources,
|
||||
// pre-fetched by connectOnce before the handshake began.
|
||||
func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig, beforeCIDRs []string) error {
|
||||
dev, err := tun.Create("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create tun: %w", err)
|
||||
@@ -271,43 +521,176 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
|
||||
localIP := net.ParseIP(init.IP)
|
||||
peerIP := net.ParseIP(init.ServerIP)
|
||||
if localIP == nil || peerIP == nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("invalid init IPs: %s / %s", init.IP, init.ServerIP)
|
||||
}
|
||||
|
||||
if err := dev.Configure(localIP, init.Prefix, peerIP); err != nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("configure tun: %w", err)
|
||||
}
|
||||
|
||||
// Configure IPv6 address when the server assigned one (dual-stack).
|
||||
hasV6 := init.IP6 != ""
|
||||
if hasV6 {
|
||||
ip6 := net.ParseIP(init.IP6)
|
||||
if ip6 == nil {
|
||||
return fmt.Errorf("invalid init IPv6: %s", init.IP6)
|
||||
}
|
||||
if err := dev.ConfigureIPv6(ip6, init.Prefix6); err != nil {
|
||||
return fmt.Errorf("configure tun ipv6: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
mtu := init.MTU
|
||||
if cfg.MTUOverride > 0 {
|
||||
mtu = cfg.MTUOverride
|
||||
}
|
||||
if err := dev.SetMTU(mtu); err != nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("set mtu: %w", err)
|
||||
}
|
||||
|
||||
// Merge static CIDRs with pre-fetched before-proxy CIDRs.
|
||||
var cidrs []string
|
||||
cidrs = append(cidrs, cfg.CIDRV4...)
|
||||
cidrs = append(cidrs, cfg.CIDRV6...)
|
||||
cidrs = append(cidrs, beforeCIDRs...)
|
||||
|
||||
// Apply routing.
|
||||
routeCfg := route.Config{
|
||||
Mode: cfg.RoutingMode,
|
||||
InterfaceName: dev.Name(),
|
||||
VPNIP: init.IP,
|
||||
VPNPrefix: init.Prefix,
|
||||
VPNIP6: init.IP6,
|
||||
VPNPrefix6: init.Prefix6,
|
||||
ServerHost: serverHostFromURL(cfg.ServerURL),
|
||||
CustomCIDRs: cfg.CustomCIDRs,
|
||||
CIDRs: cidrs,
|
||||
}
|
||||
sm.routeMgr = route.NewManager(routeCfg)
|
||||
if err := sm.routeMgr.Apply(); err != nil {
|
||||
log.L().Error("route apply failed (continuing)", "error", err)
|
||||
}
|
||||
|
||||
// Initialise the CIDR hit tracker for proxy/bypass modes.
|
||||
sm.cidrTracker = newCIDRTracker(cfg.RoutingMode, cidrs)
|
||||
|
||||
// Log CIDR breakdown by family for diagnostics.
|
||||
v4Total, _, v6Total, _ := sm.cidrTracker.Stats()
|
||||
log.L().Info("TUN configured",
|
||||
"dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix, "mtu", mtu)
|
||||
"dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix,
|
||||
"ip6", init.IP6, "prefix6", init.Prefix6, "mtu", mtu,
|
||||
"routing_mode", cfg.RoutingMode,
|
||||
"cidr_v4", v4Total, "cidr_v6", v6Total,
|
||||
"before_proxy_cidrs", len(beforeCIDRs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchAfterProxyCIDRs fetches CIDR lists from URLs with "after" timing
|
||||
// (via the tunnel) and dynamically adds their routes to the route
|
||||
// manager. This is called in a goroutine after the data plane is up.
|
||||
func (sm *SessionManager) fetchAfterProxyCIDRs(ctx context.Context, cfg SessionConfig) {
|
||||
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
|
||||
// Count only "after" sources for logging.
|
||||
afterCount := 0
|
||||
for _, s := range allURLSources {
|
||||
if s.FetchTiming == model.FetchAfter {
|
||||
afterCount++
|
||||
}
|
||||
}
|
||||
if afterCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sm.stats.SetRouteLoading(true)
|
||||
sm.stats.SetConnectStep("fetch_cidrs")
|
||||
log.L().Info("fetching after-proxy CIDR lists", "url_count", afterCount)
|
||||
fetched, err := cidrsource.FetchAfterProxy(ctx, allURLSources)
|
||||
sm.stats.SetConnectStep("load_routes")
|
||||
if err != nil {
|
||||
log.L().Error("fetch after-proxy CIDR lists completed with errors", "error", err)
|
||||
sm.stats.SetCIDRError(err.Error())
|
||||
} else {
|
||||
sm.stats.SetCIDRError("")
|
||||
}
|
||||
if len(fetched) == 0 {
|
||||
sm.stats.SetRouteLoading(false)
|
||||
sm.stats.SetConnectStep("")
|
||||
return
|
||||
}
|
||||
|
||||
merged := fetched // AddRoutes already calls mergeCIDRs internally
|
||||
added := sm.addCIDRRoutes(fetched)
|
||||
if added > 0 {
|
||||
log.L().Info("added after-proxy routes", "fetched", len(fetched), "added", added, "merged", len(merged))
|
||||
}
|
||||
|
||||
sm.stats.SetRouteLoading(false)
|
||||
sm.stats.SetConnectStep("")
|
||||
}
|
||||
|
||||
// addCIDRRoutes adds routes and updates the CIDR tracker. Returns the
|
||||
// number of CIDRs successfully added (after merge).
|
||||
func (sm *SessionManager) addCIDRRoutes(cidrs []string) int {
|
||||
sm.mu.Lock()
|
||||
mgr := sm.routeMgr
|
||||
tracker := sm.cidrTracker
|
||||
sm.mu.Unlock()
|
||||
if mgr == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if err := mgr.AddRoutes(cidrs); err != nil {
|
||||
log.L().Error("add CIDR routes failed (continuing)", "error", err)
|
||||
}
|
||||
if tracker != nil {
|
||||
tracker.AddCIDRs(cidrs)
|
||||
}
|
||||
return len(cidrs)
|
||||
}
|
||||
|
||||
// RefreshCIDRs re-fetches all CIDR URL sources (both before and after
|
||||
// timing) via the current tunnel and dynamically adds their routes.
|
||||
// This is called when the user clicks the "Refresh CIDR" button.
|
||||
func (sm *SessionManager) RefreshCIDRs() {
|
||||
sm.mu.Lock()
|
||||
ctx := sm.cancel
|
||||
cfg := sm.lastCfg
|
||||
sm.mu.Unlock()
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Use a background context with 30s timeout so the refresh works
|
||||
// even if the session context is in a weird state.
|
||||
refreshCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = refreshCtx
|
||||
|
||||
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
|
||||
if len(allURLSources) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sm.stats.SetRouteLoading(true)
|
||||
sm.stats.SetCIDRError("")
|
||||
log.L().Info("refreshing CIDR lists", "url_count", len(allURLSources))
|
||||
|
||||
fetched, err := cidrsource.FetchAfterProxy(refreshCtx, allURLSources)
|
||||
if err != nil {
|
||||
log.L().Error("refresh CIDR lists completed with errors", "error", err)
|
||||
sm.stats.SetCIDRError(err.Error())
|
||||
} else {
|
||||
sm.stats.SetCIDRError("")
|
||||
}
|
||||
if len(fetched) == 0 {
|
||||
sm.stats.SetRouteLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
added := sm.addCIDRRoutes(fetched)
|
||||
log.L().Info("refreshed CIDR routes", "fetched", len(fetched), "added", added)
|
||||
sm.stats.SetRouteLoading(false)
|
||||
}
|
||||
|
||||
// pumpPackets runs the bidirectional packet loop until the connection
|
||||
// breaks.
|
||||
func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) {
|
||||
@@ -318,6 +701,12 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 65536)
|
||||
// Cache the cidrTracker pointer once; it is stable for the
|
||||
// lifetime of pumpPackets (set in setupTUN, cleared in cleanup
|
||||
// which only runs after pumpPackets returns).
|
||||
sm.mu.Lock()
|
||||
tracker := sm.cidrTracker
|
||||
sm.mu.Unlock()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -341,7 +730,10 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
}
|
||||
return
|
||||
}
|
||||
sm.stats.TxBytes.Add(int64(n))
|
||||
sm.stats.AddTx(buf[:n])
|
||||
if tracker != nil {
|
||||
tracker.Record(buf[:n])
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -368,15 +760,19 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
sm.stats.RxBytes.Add(int64(len(data)))
|
||||
sm.stats.AddRx(data)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// cleanup tears down the TUN device and routes.
|
||||
func (sm *SessionManager) cleanup() {
|
||||
// cleanupResources tears down the TUN device, routes, and transport
|
||||
// connection WITHOUT changing the session state. This is used on
|
||||
// handshake-failure paths where the caller will set the appropriate
|
||||
// state (e.g. StateReconnecting). Returns without error if nothing
|
||||
// was set up.
|
||||
func (sm *SessionManager) cleanupResources() {
|
||||
sm.mu.Lock()
|
||||
dev := sm.dev
|
||||
routeMgr := sm.routeMgr
|
||||
@@ -384,6 +780,7 @@ func (sm *SessionManager) cleanup() {
|
||||
sm.dev = nil
|
||||
sm.routeMgr = nil
|
||||
sm.conn = nil
|
||||
sm.cidrTracker = nil
|
||||
sm.mu.Unlock()
|
||||
|
||||
if routeMgr != nil {
|
||||
@@ -397,6 +794,12 @@ func (sm *SessionManager) cleanup() {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup tears down the TUN device, routes, and transport, then marks
|
||||
// the session as disconnected. Used on normal session termination.
|
||||
func (sm *SessionManager) cleanup() {
|
||||
sm.cleanupResources()
|
||||
sm.stats.SetDisconnected()
|
||||
}
|
||||
|
||||
@@ -428,9 +831,15 @@ func (sm *SessionManager) setState(s stats.State) {
|
||||
}
|
||||
|
||||
// reportStats periodically calls the onStats callback while connected.
|
||||
// On each tick it derives per-family speeds (bytes/sec) from the
|
||||
// delta between the current and previous cumulative counters, then
|
||||
// applies EWMA smoothing (0.7 old + 0.3 new) so the displayed rates
|
||||
// don't jitter. The combined speeds are the sum of the per-family
|
||||
// smoothed values.
|
||||
func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
const ewmaAlpha = 0.3
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
@@ -438,9 +847,63 @@ func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if sm.onStats != nil {
|
||||
sm.onStats(sm.stats.Snapshot())
|
||||
if sm.onStats == nil {
|
||||
continue
|
||||
}
|
||||
snap := sm.stats.Snapshot()
|
||||
now := time.Now()
|
||||
if sm.speedReady {
|
||||
elapsed := now.Sub(sm.prevTick).Seconds()
|
||||
if elapsed <= 0 {
|
||||
elapsed = 1
|
||||
}
|
||||
// Per-second deltas (bytes/sec), clamped to >= 0 in case
|
||||
// of counter resets between reconnects within the same
|
||||
// SessionManager lifetime.
|
||||
rxV4 := max(0.0, float64(snap.RxBytesV4-sm.prevSnap.RxBytesV4)/elapsed)
|
||||
txV4 := max(0.0, float64(snap.TxBytesV4-sm.prevSnap.TxBytesV4)/elapsed)
|
||||
rxV6 := max(0.0, float64(snap.RxBytesV6-sm.prevSnap.RxBytesV6)/elapsed)
|
||||
txV6 := max(0.0, float64(snap.TxBytesV6-sm.prevSnap.TxBytesV6)/elapsed)
|
||||
if sm.ewmaRxV4 == 0 && sm.ewmaTxV4 == 0 && sm.ewmaRxV6 == 0 && sm.ewmaTxV6 == 0 {
|
||||
// First real sample: seed instead of ramping from 0.
|
||||
sm.ewmaRxV4, sm.ewmaTxV4, sm.ewmaRxV6, sm.ewmaTxV6 = rxV4, txV4, rxV6, txV6
|
||||
} else {
|
||||
sm.ewmaRxV4 = sm.ewmaRxV4*(1-ewmaAlpha) + rxV4*ewmaAlpha
|
||||
sm.ewmaTxV4 = sm.ewmaTxV4*(1-ewmaAlpha) + txV4*ewmaAlpha
|
||||
sm.ewmaRxV6 = sm.ewmaRxV6*(1-ewmaAlpha) + rxV6*ewmaAlpha
|
||||
sm.ewmaTxV6 = sm.ewmaTxV6*(1-ewmaAlpha) + txV6*ewmaAlpha
|
||||
}
|
||||
snap.RxSpeedV4 = int64(sm.ewmaRxV4)
|
||||
snap.TxSpeedV4 = int64(sm.ewmaTxV4)
|
||||
snap.RxSpeedV6 = int64(sm.ewmaRxV6)
|
||||
snap.TxSpeedV6 = int64(sm.ewmaTxV6)
|
||||
snap.RxSpeed = snap.RxSpeedV4 + snap.RxSpeedV6
|
||||
snap.TxSpeed = snap.TxSpeedV4 + snap.TxSpeedV6
|
||||
}
|
||||
sm.prevSnap = snap
|
||||
// Clear speed fields on the stored prev copy so we don't
|
||||
// accidentally carry stale speed into the next delta base
|
||||
// (only cumulative bytes matter for deltas).
|
||||
sm.prevSnap.RxSpeedV4, sm.prevSnap.TxSpeedV4 = 0, 0
|
||||
sm.prevSnap.RxSpeedV6, sm.prevSnap.TxSpeedV6 = 0, 0
|
||||
sm.prevSnap.RxSpeed, sm.prevSnap.TxSpeed = 0, 0
|
||||
sm.prevTick = now
|
||||
sm.speedReady = true
|
||||
|
||||
// Fill in CIDR hit statistics.
|
||||
sm.mu.Lock()
|
||||
tracker := sm.cidrTracker
|
||||
sm.mu.Unlock()
|
||||
if tracker != nil {
|
||||
v4Total, v4Hits, v6Total, v6Hits := tracker.Stats()
|
||||
snap.RoutingMode = string(tracker.mode)
|
||||
snap.CIDRV4Total = v4Total
|
||||
snap.CIDRV4Hits = v4Hits
|
||||
snap.CIDRV6Total = v6Total
|
||||
snap.CIDRV6Hits = v6Hits
|
||||
}
|
||||
|
||||
sm.onStats(snap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,7 +941,14 @@ func wsURLToHTTP(wsURL string) (string, error) {
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
// 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
|
||||
for _, prefix := range []string{"wss://", "ws://"} {
|
||||
if len(u) > len(prefix) && u[:len(prefix)] == prefix {
|
||||
@@ -493,3 +963,175 @@ func replaceHost(rawURL, newHost string) string {
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
// cidrTracker tracks which configured CIDRs have been "hit" by
|
||||
// outbound traffic (TUN -> WebSocket). The behaviour differs by mode:
|
||||
//
|
||||
// - Proxy mode: each outbound packet's destination IP is matched
|
||||
// against the CIDR list; the first matching CIDR is marked as hit.
|
||||
// Stats() returns the count of hit CIDRs vs total CIDRs.
|
||||
// - Bypass mode: outbound traffic through TUN is, by definition,
|
||||
// traffic that did NOT match any bypass CIDR (bypassed traffic
|
||||
// goes via the physical NIC). We count distinct destination
|
||||
// /24 (v4) or /48 (v6) prefixes seen on TUN as "unmatched
|
||||
// destinations". Stats() returns the total bypass CIDR count
|
||||
// and the unmatched destination count.
|
||||
type cidrTracker struct {
|
||||
mode route.Mode
|
||||
mu sync.RWMutex
|
||||
|
||||
// Proxy mode: pre-parsed CIDR nets + per-CIDR hit flags.
|
||||
// Protected by mu for concurrent AddCIDRs (write) vs Record/Stats
|
||||
// (read). The atomic.Bool hit flags are individually atomic, but
|
||||
// the slices themselves need the lock.
|
||||
v4CIDRs []*net.IPNet
|
||||
v6CIDRs []*net.IPNet
|
||||
v4Hits []atomic.Bool
|
||||
v6Hits []atomic.Bool
|
||||
|
||||
// Bypass mode: distinct destination prefix sets.
|
||||
// v4 key = first 3 bytes of dest IP (a /24 prefix).
|
||||
// v6 key = first 6 bytes of dest IP (a /48 prefix).
|
||||
// sync.Map is already goroutine-safe; no lock needed.
|
||||
v4Prefixes sync.Map
|
||||
v6Prefixes sync.Map
|
||||
v4Count atomic.Int64
|
||||
v6Count atomic.Int64
|
||||
}
|
||||
|
||||
// newCIDRTracker creates a tracker for the given routing mode and CIDR
|
||||
// list. For full-tunnel mode, a tracker is still created but will
|
||||
// report zero totals (no CIDRs configured).
|
||||
func newCIDRTracker(mode route.Mode, cidrs []string) *cidrTracker {
|
||||
t := &cidrTracker{mode: mode}
|
||||
if mode == route.ModeFull {
|
||||
return t
|
||||
}
|
||||
t.addCIDRs(cidrs)
|
||||
return t
|
||||
}
|
||||
|
||||
// AddCIDRs appends additional CIDRs to the tracker (used for
|
||||
// after-proxy fetched CIDRs). Existing hit flags are preserved.
|
||||
func (t *cidrTracker) AddCIDRs(cidrs []string) {
|
||||
t.addCIDRs(cidrs)
|
||||
}
|
||||
|
||||
func (t *cidrTracker) addCIDRs(cidrs []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, cidrStr := range cidrs {
|
||||
cidrStr = strings.TrimSpace(cidrStr)
|
||||
if cidrStr == "" {
|
||||
continue
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(cidrStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.To4() != nil {
|
||||
t.v4CIDRs = append(t.v4CIDRs, ipNet)
|
||||
t.v4Hits = append(t.v4Hits, atomic.Bool{})
|
||||
} else {
|
||||
t.v6CIDRs = append(t.v6CIDRs, ipNet)
|
||||
t.v6Hits = append(t.v6Hits, atomic.Bool{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record inspects an outbound IP packet (from TUN) and updates hit
|
||||
// counters. It is called from the TUN -> WebSocket goroutine for every
|
||||
// packet. Packets too short to contain an IP header or with an unknown
|
||||
// version are silently ignored.
|
||||
func (t *cidrTracker) Record(p []byte) {
|
||||
if len(p) < 1 {
|
||||
return
|
||||
}
|
||||
switch p[0] >> 4 {
|
||||
case 4:
|
||||
if len(p) < 20 {
|
||||
return
|
||||
}
|
||||
dst := net.IP(p[16:20])
|
||||
t.recordV4(dst)
|
||||
case 6:
|
||||
if len(p) < 40 {
|
||||
return
|
||||
}
|
||||
dst := net.IP(p[24:40])
|
||||
t.recordV6(dst)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *cidrTracker) recordV4(dst net.IP) {
|
||||
switch t.mode {
|
||||
case route.ModeProxy:
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
for i, cidr := range t.v4CIDRs {
|
||||
if !t.v4Hits[i].Load() && cidr.Contains(dst) {
|
||||
t.v4Hits[i].Store(true)
|
||||
}
|
||||
}
|
||||
case route.ModeBypass:
|
||||
key := string(dst.To4()[:3])
|
||||
if _, loaded := t.v4Prefixes.LoadOrStore(key, struct{}{}); !loaded {
|
||||
t.v4Count.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *cidrTracker) recordV6(dst net.IP) {
|
||||
switch t.mode {
|
||||
case route.ModeProxy:
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
for i, cidr := range t.v6CIDRs {
|
||||
if !t.v6Hits[i].Load() && cidr.Contains(dst) {
|
||||
t.v6Hits[i].Store(true)
|
||||
}
|
||||
}
|
||||
case route.ModeBypass:
|
||||
v6 := dst.To16()
|
||||
if len(v6) < 6 {
|
||||
return
|
||||
}
|
||||
key := string(v6[:6])
|
||||
if _, loaded := t.v6Prefixes.LoadOrStore(key, struct{}{}); !loaded {
|
||||
t.v6Count.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns the total and hit counts for IPv4 and IPv6 CIDRs.
|
||||
//
|
||||
// For proxy mode: "hits" = number of CIDRs that have been matched by
|
||||
// at least one outbound packet.
|
||||
// For bypass mode: "hits" = number of distinct destination prefixes
|
||||
// seen on TUN (i.e. unmatched destinations that went through the
|
||||
// tunnel because they didn't match any bypass CIDR).
|
||||
func (t *cidrTracker) Stats() (v4Total, v4Hits, v6Total, v6Hits int) {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
switch t.mode {
|
||||
case route.ModeProxy:
|
||||
v4Total = len(t.v4CIDRs)
|
||||
for i := range t.v4Hits {
|
||||
if t.v4Hits[i].Load() {
|
||||
v4Hits++
|
||||
}
|
||||
}
|
||||
v6Total = len(t.v6CIDRs)
|
||||
for i := range t.v6Hits {
|
||||
if t.v6Hits[i].Load() {
|
||||
v6Hits++
|
||||
}
|
||||
}
|
||||
case route.ModeBypass:
|
||||
v4Total = len(t.v4CIDRs)
|
||||
v4Hits = int(t.v4Count.Load())
|
||||
v6Total = len(t.v6CIDRs)
|
||||
v6Hits = int(t.v6Count.Load())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<string>0.3.7</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// genico converts resources/icon.png into a Windows .ico file using the
|
||||
// PNG-in-ICO format (the PNG bytes are wrapped in a minimal ICO container
|
||||
// without decoding/re-encoding). Works on Windows Vista and later.
|
||||
//
|
||||
// Run: go run resources/genico/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pngData, err := os.ReadFile("resources/icon.png")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "genico: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ICO header (6 bytes) + one directory entry (16 bytes) = 22 bytes overhead.
|
||||
const headerSize = 6
|
||||
const entrySize = 16
|
||||
dataOffset := uint32(headerSize + entrySize)
|
||||
|
||||
ico := make([]byte, 0, headerSize+entrySize+len(pngData))
|
||||
|
||||
// ICONDIR
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 0) // reserved
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 1) // type = icon
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 1) // count = 1 image
|
||||
|
||||
// ICONDIRENTRY
|
||||
ico = append(ico, 0) // width (0 = 256+, actual size in PNG header)
|
||||
ico = append(ico, 0) // height (0 = 256+, actual size in PNG header)
|
||||
ico = append(ico, 0) // color count (0 = ≥256 colors)
|
||||
ico = append(ico, 0) // reserved
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 1) // planes
|
||||
ico = binary.LittleEndian.AppendUint16(ico, 32) // bit count
|
||||
ico = binary.LittleEndian.AppendUint32(ico, uint32(len(pngData)))
|
||||
ico = binary.LittleEndian.AppendUint32(ico, dataOffset)
|
||||
|
||||
// PNG image data
|
||||
ico = append(ico, pngData...)
|
||||
|
||||
if err := os.WriteFile("resources/icon.ico", ico, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "genico: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Generated resources/icon.ico")
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1 @@
|
||||
LMVPN ICON "resources/icon.ico"
|
||||
Binary file not shown.
Reference in New Issue
Block a user