Compare commits
25
Commits
e867c8e248
...
v0.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
@@ -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.4.2
|
||||
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,349 @@
|
||||
# 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 令牌 / 用户名密码
|
||||
- **隧道模式**:全量隧道、分流隧道(按目标绕过)、自定义隧道
|
||||
- **多服务器管理**:配置文件 + 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/ # 路由管理(全量/分流/自定义)
|
||||
│ ├── 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.
+85
-28
@@ -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,15 +536,18 @@ type controlMessage struct {
|
||||
}
|
||||
```
|
||||
|
||||
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-9`):
|
||||
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-10`):
|
||||
|
||||
```go
|
||||
type initMessage struct {
|
||||
Type string `json:"type"`
|
||||
IP string `json:"ip"`
|
||||
Prefix int `json:"prefix"`
|
||||
MTU int `json:"mtu"`
|
||||
ServerIP string `json:"server_ip"`
|
||||
Type string `json:"type"`
|
||||
IP string `json:"ip"`
|
||||
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;
|
||||
+10
-1
@@ -6,6 +6,7 @@ package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -41,7 +42,12 @@ 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).
|
||||
func Login(baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) {
|
||||
body, err := json.Marshal(loginRequest{Username: username, Password: password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -49,6 +55,9 @@ func Login(baseURL, username, password string) (*LoginResult, error) {
|
||||
|
||||
url := strings.TrimRight(baseURL, "/") + "/api/login"
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
if tlsCfg != nil {
|
||||
client.Transport = &http.Transport{TLSClientConfig: tlsCfg}
|
||||
}
|
||||
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login request: %w", err)
|
||||
|
||||
+38
-15
@@ -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,36 +111,47 @@ 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.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()
|
||||
defer d.mu.Unlock()
|
||||
if req.Config == nil {
|
||||
_ = ipc.WriteErr(conn, "missing config")
|
||||
return
|
||||
}
|
||||
if d.session != nil {
|
||||
d.stopSession()
|
||||
d.stopSessionLocked()
|
||||
}
|
||||
|
||||
cfg := vpn.SessionConfig{
|
||||
ServerURL: req.Config.ServerURL,
|
||||
SNIHost: req.Config.SNIHost,
|
||||
ServerIPs: req.Config.ServerIPs,
|
||||
Username: req.Config.Username,
|
||||
Password: req.Config.Password,
|
||||
Token: req.Config.Token,
|
||||
AuthMode: model.AuthMode(req.Config.AuthMode),
|
||||
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
|
||||
CustomCIDRs: req.Config.CustomCIDRs,
|
||||
MTUOverride: req.Config.MTUOverride,
|
||||
ServerURL: req.Config.ServerURL,
|
||||
SNIHost: req.Config.SNIHost,
|
||||
ServerIPs: req.Config.ServerIPs,
|
||||
Username: req.Config.Username,
|
||||
Password: req.Config.Password,
|
||||
Token: req.Config.Token,
|
||||
AuthMode: model.AuthMode(req.Config.AuthMode),
|
||||
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
|
||||
CustomCIDRs: req.Config.CustomCIDRs,
|
||||
MTUOverride: req.Config.MTUOverride,
|
||||
TLSCACert: req.Config.TLSCACert,
|
||||
TLSCAPath: req.Config.TLSCAPath,
|
||||
TLSInsecure: req.Config.TLSInsecure,
|
||||
TLSPinnedHash: req.Config.TLSPinnedHash,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -150,6 +164,9 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
|
||||
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})
|
||||
},
|
||||
)
|
||||
|
||||
if err := d.session.Connect(ctx, cfg); err != nil {
|
||||
@@ -160,6 +177,12 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+75
-10
@@ -45,7 +45,13 @@ 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
|
||||
}
|
||||
return s.migrateV4()
|
||||
}
|
||||
|
||||
func (s *Store) migrateV2() error {
|
||||
@@ -165,6 +171,64 @@ 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -185,15 +249,16 @@ CREATE TABLE IF NOT EXISTS server_profiles (
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connection_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at DATETIME,
|
||||
assigned_ip TEXT NOT NULL DEFAULT '',
|
||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'connected',
|
||||
error_msg TEXT NOT NULL DEFAULT '',
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL,
|
||||
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',
|
||||
error_msg TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (profile_id) REFERENCES server_profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
+19
-8
@@ -14,11 +14,13 @@ 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 (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
custom_cidrs, mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
|
||||
p.Username, p.AuthMode, p.RoutingMode,
|
||||
p.CustomCIDRs, p.MTUOverride, p.AutoConnect,
|
||||
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert profile: %w", err)
|
||||
@@ -36,11 +38,14 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
|
||||
err := s.db.QueryRow(
|
||||
`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
|
||||
custom_cidrs, mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
|
||||
created_at, last_connected_at
|
||||
FROM server_profiles WHERE id = ?`, id,
|
||||
).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.AutoConnect, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
|
||||
&p.CreatedAt, &last)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get profile %d: %w", id, err)
|
||||
}
|
||||
@@ -55,7 +60,9 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
|
||||
rows, err := s.db.Query(
|
||||
`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
|
||||
custom_cidrs, mtu_override, auto_connect,
|
||||
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
|
||||
created_at, last_connected_at
|
||||
FROM server_profiles ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list profiles: %w", err)
|
||||
@@ -68,7 +75,9 @@ 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.CustomCIDRs, &p.MTUOverride, &p.AutoConnect,
|
||||
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
|
||||
&p.CreatedAt, &last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if last.Valid {
|
||||
@@ -85,11 +94,13 @@ 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 = ?
|
||||
custom_cidrs = ?, mtu_override = ?, auto_connect = ?,
|
||||
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?
|
||||
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.CustomCIDRs, p.MTUOverride, p.AutoConnect,
|
||||
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update profile %d: %w", p.ID, err)
|
||||
}
|
||||
|
||||
+39
-4
@@ -19,13 +19,23 @@ 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"
|
||||
|
||||
DlgNoProfileTitle = "No Profile"
|
||||
DlgNoProfileEditMsg = "Select a profile to edit."
|
||||
@@ -43,11 +53,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"
|
||||
@@ -80,3 +102,16 @@ AuthModePassword = "Password"
|
||||
RoutingModeFull = "Full Tunnel"
|
||||
RoutingModeSplit = "Split Tunnel"
|
||||
RoutingModeCustom = "Custom"
|
||||
|
||||
FieldTLS = "TLS Certificate"
|
||||
FieldTLSCACert = "CA Certificate (PEM)"
|
||||
FieldTLSCAPath = "CA Certificate Path"
|
||||
HintTLSCAReplacesSystem = "Setting a custom CA replaces system root certificates. Public CA servers (e.g. Let's Encrypt) will fail verification."
|
||||
FieldTLSInsecure = "Skip Verification (Insecure)"
|
||||
FieldTLSPinnedHash = "Certificate Pin (SHA-256)"
|
||||
PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..."
|
||||
PlaceholderTLSCAPath = "/path/to/ca.crt"
|
||||
PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
|
||||
BtnBrowse = "Browse..."
|
||||
DlgTLSError = "TLS Certificate Error"
|
||||
TLSErrorVerification = "Server certificate verification failed."
|
||||
|
||||
@@ -19,13 +19,23 @@ 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"
|
||||
|
||||
DlgNoProfileTitle = "无配置"
|
||||
DlgNoProfileEditMsg = "请选择要编辑的配置。"
|
||||
@@ -43,11 +53,23 @@ DlgVPNError = "VPN 错误"
|
||||
DlgSaveError = "保存错误"
|
||||
DlgKeychainError = "钥匙串错误"
|
||||
DlgError = "错误"
|
||||
DlgAuthError = "认证失败"
|
||||
AuthErrWrongCredentials = "用户名或密码错误,请检查凭据。"
|
||||
AuthErrUserDisabled = "用户不存在或已被禁用。"
|
||||
AuthErrTokenInvalid = "认证令牌无效或已过期。"
|
||||
AuthErrRateLimited = "认证尝试过于频繁,请稍后再试。"
|
||||
AuthErrMalformed = "认证消息格式错误。"
|
||||
|
||||
BtnResetDB = "重置数据库"
|
||||
DlgResetDBTitle = "重置数据库"
|
||||
DlgResetDBMsg = "删除所有配置和连接记录?此操作无法撤销。"
|
||||
|
||||
BtnProfileList = "配置列表"
|
||||
ProfileListTitle = "配置列表"
|
||||
DlgNoListSelectTitle = "无选择"
|
||||
DlgNoListSelectEditMsg = "请在列表中选择一个配置。"
|
||||
DlgNoListSelectDeleteMsg = "请在列表中选择一个配置。"
|
||||
|
||||
TrayShowWindow = "显示窗口"
|
||||
TrayConnect = "连接"
|
||||
TrayDisconnect = "断开连接"
|
||||
@@ -80,3 +102,16 @@ AuthModePassword = "密码"
|
||||
RoutingModeFull = "全隧道"
|
||||
RoutingModeSplit = "分离隧道"
|
||||
RoutingModeCustom = "自定义"
|
||||
|
||||
FieldTLS = "TLS 证书"
|
||||
FieldTLSCACert = "CA 证书 (PEM)"
|
||||
FieldTLSCAPath = "CA 证书路径"
|
||||
HintTLSCAReplacesSystem = "设置自定义 CA 将替换系统根证书,使用公共 CA(如 Let's Encrypt)的服务器将验证失败。"
|
||||
FieldTLSInsecure = "跳过验证 (不安全)"
|
||||
FieldTLSPinnedHash = "证书固定 (SHA-256)"
|
||||
PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..."
|
||||
PlaceholderTLSCAPath = "/path/to/ca.crt"
|
||||
PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
|
||||
BtnBrowse = "浏览..."
|
||||
DlgTLSError = "TLS 证书错误"
|
||||
TLSErrorVerification = "服务器证书验证失败。"
|
||||
|
||||
+58
-18
@@ -29,6 +29,7 @@ const (
|
||||
CmdStop = "stop"
|
||||
CmdShutdown = "shutdown"
|
||||
CmdStats = "stats"
|
||||
CmdVersion = "version" // query daemon build version
|
||||
)
|
||||
|
||||
// Event types sent from daemon to GUI.
|
||||
@@ -48,16 +49,20 @@ type Request struct {
|
||||
// vpn.SessionConfig but is kept separate to avoid importing the vpn
|
||||
// package (which needs root-only TUN) into the GUI.
|
||||
type ClientConfig struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
|
||||
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
AuthMode string `json:"auth_mode"`
|
||||
RoutingMode string `json:"routing_mode"`
|
||||
CustomCIDRs []string `json:"custom_cidrs"`
|
||||
MTUOverride int `json:"mtu_override"`
|
||||
ServerURL string `json:"server_url"`
|
||||
SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN
|
||||
ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
AuthMode string `json:"auth_mode"`
|
||||
RoutingMode string `json:"routing_mode"`
|
||||
CustomCIDRs []string `json:"custom_cidrs"`
|
||||
MTUOverride int `json:"mtu_override"`
|
||||
TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only)
|
||||
TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only)
|
||||
TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only)
|
||||
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only)
|
||||
}
|
||||
|
||||
// Event is a notification from the daemon to the GUI.
|
||||
@@ -65,6 +70,7 @@ type Event struct {
|
||||
Event string `json:"event"`
|
||||
State string `json:"state,omitempty"`
|
||||
Stats *stats.Snapshot `json:"stats,omitempty"`
|
||||
Code string `json:"code,omitempty"` // stable auth-error code for EvError
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
@@ -98,15 +104,26 @@ 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()
|
||||
|
||||
// Clean up stale unix socket file (not needed for TCP).
|
||||
if netType == "unix" {
|
||||
_ = 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 +193,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)
|
||||
}
|
||||
@@ -203,8 +220,9 @@ func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// Response is a simple ack/error reply to a command.
|
||||
type Response struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
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.
|
||||
@@ -232,6 +250,28 @@ 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+28
-23
@@ -28,21 +28,25 @@ const (
|
||||
|
||||
// ServerProfile is a saved VPN server configuration.
|
||||
type ServerProfile struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"` // "wss" (default) or "ws"
|
||||
Host string `json:"host"` // hostname for SNI, e.g. vpn.example.com
|
||||
ServerIPs string `json:"server_ips"` // comma-separated CDN IPs, first used by default
|
||||
Port int `json:"port"` // default 443
|
||||
Path string `json:"path"` // default "/ws"
|
||||
Username string `json:"username"`
|
||||
AuthMode AuthMode `json:"auth_mode"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"` // "wss" (default) or "ws"
|
||||
Host string `json:"host"` // hostname for SNI, e.g. vpn.example.com
|
||||
ServerIPs string `json:"server_ips"` // comma-separated CDN IPs, first used by default
|
||||
Port int `json:"port"` // default 443
|
||||
Path string `json:"path"` // default "/ws"
|
||||
Username string `json:"username"`
|
||||
AuthMode AuthMode `json:"auth_mode"`
|
||||
RoutingMode RoutingMode `json:"routing_mode"`
|
||||
CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom
|
||||
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
|
||||
AutoConnect bool `json:"auto_connect"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastConnectedAt *time.Time `json:"last_connected_at"`
|
||||
CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom
|
||||
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)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastConnectedAt *time.Time `json:"last_connected_at"`
|
||||
}
|
||||
|
||||
// BuildServerURL constructs the WebSocket URL from the profile fields.
|
||||
@@ -108,13 +112,14 @@ const (
|
||||
|
||||
// ConnectionLog records a single VPN session.
|
||||
type ConnectionLog struct {
|
||||
ID int64 `json:"id"`
|
||||
ProfileID int64 `json:"profile_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at"`
|
||||
AssignedIP string `json:"assigned_ip"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
Status ConnectionStatus `json:"status"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
ID int64 `json:"id"`
|
||||
ProfileID int64 `json:"profile_id"`
|
||||
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"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
@@ -57,6 +57,4 @@ 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,12 @@ 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" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package paths
|
||||
|
||||
@@ -7,6 +7,12 @@ 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" }
|
||||
|
||||
func init() {
|
||||
home, _ := os.UserHomeDir()
|
||||
recomputePaths(home)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//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 }
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
@@ -21,23 +21,26 @@ const (
|
||||
|
||||
// Timeout and limit constants matching the server (tunnel.go:16-23).
|
||||
const (
|
||||
ReadTimeout = 60 * time.Second // post-ready read deadline
|
||||
WriteTimeout = 10 * time.Second // per-write deadline
|
||||
ReadyTimeout = 30 * time.Second // client must send ready within this
|
||||
PingPeriod = 30 * time.Second // server ping interval
|
||||
MaxMessageSize = 1 << 20 // 1 MiB max WebSocket message
|
||||
MaxConnsPerUser = 3 // per-user concurrent connection cap
|
||||
TokenExpiry = 24 * time.Hour // JWT validity
|
||||
ReadTimeout = 60 * time.Second // post-ready read deadline
|
||||
WriteTimeout = 10 * time.Second // per-write deadline
|
||||
ReadyTimeout = 30 * time.Second // client must send ready within this
|
||||
PingPeriod = 30 * time.Second // server ping interval
|
||||
MaxMessageSize = 1 << 20 // 1 MiB max WebSocket message
|
||||
MaxConnsPerUser = 3 // per-user concurrent connection cap
|
||||
TokenExpiry = 24 * time.Hour // JWT validity
|
||||
)
|
||||
|
||||
// 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)
|
||||
MTU int `json:"mtu"` // TUN device MTU (e.g. 1420)
|
||||
ServerIP string `json:"server_ip"` // server's tunnel IP (peer/gateway)
|
||||
Type string `json:"type"`
|
||||
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 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 ""
|
||||
}
|
||||
}
|
||||
|
||||
+139
-37
@@ -1,13 +1,16 @@
|
||||
// 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
|
||||
// - 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
|
||||
// - Split tunnel: only the VPN virtual subnet (v4 and v6) via TUN
|
||||
// - Custom: user-specified CIDRs via the TUN interface
|
||||
//
|
||||
// All routes are tracked so they can be cleanly removed on disconnect.
|
||||
// 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 (
|
||||
@@ -29,8 +32,10 @@ const (
|
||||
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
|
||||
}
|
||||
@@ -38,10 +43,15 @@ type Config struct {
|
||||
// Manager applies and removes routes. It tracks all added routes so
|
||||
// they can be cleaned up deterministically.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
addedRoutes []string // route specs added, for deletion
|
||||
serverBypass bool
|
||||
originalGateway string
|
||||
cfg Config
|
||||
addedRoutes []string // v4 route specs added, for deletion
|
||||
addedRoutes6 []string // v6 route specs added, for deletion
|
||||
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.
|
||||
@@ -72,12 +82,24 @@ func (m *Manager) Cleanup() error {
|
||||
}
|
||||
}
|
||||
m.addedRoutes = nil
|
||||
for _, r := range m.addedRoutes6 {
|
||||
if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
m.addedRoutes6 = nil
|
||||
if m.serverBypass {
|
||||
if err := m.deleteServerBypass(); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
m.serverBypass = false
|
||||
}
|
||||
if m.serverBypass6 {
|
||||
if err := m.deleteServerBypass6(); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
m.serverBypass6 = false
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("cleanup errors: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
@@ -85,26 +107,46 @@ func (m *Manager) Cleanup() error {
|
||||
}
|
||||
|
||||
func (m *Manager) applyFull() error {
|
||||
// Capture the current default gateway before modifying routes.
|
||||
// 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: it may be absent on v4-only
|
||||
// networks, in which case v6 bypass/routing is skipped.
|
||||
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
|
||||
// Bypass: server's public IPv4 via the original gateway (so the WS
|
||||
// connection doesn't loop through the tunnel).
|
||||
bypassSpec := serverIP + "/32"
|
||||
if err := addRouteVia(bypassSpec, gw); err != nil {
|
||||
return fmt.Errorf("add server bypass route: %w", err)
|
||||
if v4 != "" {
|
||||
bypassSpec := v4 + "/32"
|
||||
if err := addRouteVia(bypassSpec, gw); err != nil {
|
||||
return fmt.Errorf("add server bypass route: %w", err)
|
||||
}
|
||||
m.serverBypass = true
|
||||
}
|
||||
|
||||
// Bypass: server's public IPv6 via the original v6 gateway.
|
||||
// Non-fatal: if this fails, full-tunnel routes are still added.
|
||||
if v6 != "" && gw6 != "" {
|
||||
bypassSpec := v6 + "/128"
|
||||
if err := addRouteVia6(bypassSpec, gw6); err != nil {
|
||||
m.serverBypass6 = false
|
||||
} else {
|
||||
m.serverBypass6 = true
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -115,15 +157,35 @@ func (m *Manager) applyFull() error {
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
|
||||
// IPv6 full tunnel: ::/1 + 8000::/1 cover the entire IPv6 space,
|
||||
// more specific than ::/0. Only applied when the server assigned
|
||||
// an IPv6 address.
|
||||
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)
|
||||
subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix, false)
|
||||
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)
|
||||
|
||||
if m.cfg.VPNIP6 != "" {
|
||||
subnet6 := vpnSubnet(m.cfg.VPNIP6, m.cfg.VPNPrefix6, true)
|
||||
if err := addRoute6(subnet6, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add split route6 %s: %w", subnet6, err)
|
||||
}
|
||||
m.addedRoutes6 = append(m.addedRoutes6, subnet6)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -133,46 +195,86 @@ func (m *Manager) applyCustom() error {
|
||||
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) {
|
||||
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add custom route6 %s: %w", cidr, err)
|
||||
}
|
||||
m.addedRoutes6 = append(m.addedRoutes6, cidr)
|
||||
} else {
|
||||
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
|
||||
return fmt.Errorf("add custom route %s: %w", cidr, err)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
m.addedRoutes = append(m.addedRoutes, cidr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) deleteServerBypass() error {
|
||||
serverIP, err := resolveHost(m.cfg.ServerHost)
|
||||
if err != nil {
|
||||
return nil // best-effort
|
||||
if m.serverIP == "" {
|
||||
return nil
|
||||
}
|
||||
return deleteRouteVia(serverIP+"/32", m.originalGateway)
|
||||
return deleteRouteVia(m.serverIP+"/32", m.originalGateway)
|
||||
}
|
||||
|
||||
func (m *Manager) deleteServerBypass6() error {
|
||||
if m.serverIP6 == "" || m.originalGateway6 == "" {
|
||||
return nil
|
||||
}
|
||||
return deleteRouteVia6(m.serverIP6+"/128", m.originalGateway6)
|
||||
}
|
||||
|
||||
// vpnSubnet computes the network CIDR from an IP and prefix.
|
||||
func vpnSubnet(ipStr string, prefix int) string {
|
||||
func vpnSubnet(ipStr string, prefix int, ipv6 bool) string {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ipStr + "/" + fmt.Sprint(prefix)
|
||||
}
|
||||
mask := net.CIDRMask(prefix, 32)
|
||||
bits := 32
|
||||
if ipv6 {
|
||||
bits = 128
|
||||
}
|
||||
mask := net.CIDRMask(prefix, bits)
|
||||
network := ip.Mask(mask)
|
||||
return fmt.Sprintf("%s/%d", network.String(), prefix)
|
||||
}
|
||||
|
||||
// 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
|
||||
// resolveHosts resolves a hostname to its first IPv4 and IPv6 addresses.
|
||||
// If host is already an IP literal, it is returned directly. Either
|
||||
// result may be empty if no address of that family is available.
|
||||
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
|
||||
}
|
||||
// Strip port if present.
|
||||
if h, _, err := net.SplitHostPort(host); err == 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)
|
||||
return "", "", fmt.Errorf("lookup %s: %w", host, err)
|
||||
}
|
||||
return ips[0].String(), nil
|
||||
for _, ip := range ips {
|
||||
if v4 == "" && ip.To4() != nil {
|
||||
v4 = ip.String()
|
||||
} else if v6 == "" && ip.To4() == nil {
|
||||
v6 = 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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
)
|
||||
|
||||
// addRoute adds a route via a network interface (macOS route command).
|
||||
// route add -inet -net <cidr> -interface <iface>
|
||||
//
|
||||
// route add -inet -net <cidr> -interface <iface>
|
||||
func addRoute(cidr, iface string) error {
|
||||
return runRoute("add", "-inet", "-net", cidr, "-interface", iface)
|
||||
}
|
||||
@@ -29,13 +30,44 @@ func deleteRouteVia(cidr, gateway string) error {
|
||||
return runRoute("delete", "-inet", "-net", cidr, gateway)
|
||||
}
|
||||
|
||||
// defaultGateway returns the current default gateway IP.
|
||||
// --- IPv6 variants ---
|
||||
|
||||
func addRoute6(cidr, iface string) error {
|
||||
return runRoute("add", "-inet6", "-net", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
func deleteRoute6(cidr, iface string) error {
|
||||
return runRoute("delete", "-inet6", "-net", cidr, "-interface", iface)
|
||||
}
|
||||
|
||||
func addRouteVia6(cidr, gateway string) error {
|
||||
return runRoute("add", "-inet6", "-net", cidr, gateway)
|
||||
}
|
||||
|
||||
func deleteRouteVia6(cidr, gateway string) error {
|
||||
return runRoute("delete", "-inet6", "-net", 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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package route
|
||||
|
||||
@@ -24,12 +24,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
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//go:build windows
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
+96
-18
@@ -10,21 +10,64 @@ import (
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateDisconnected State = "disconnected"
|
||||
StateConnecting State = "connecting"
|
||||
StateConnected State = "connected"
|
||||
StateReconnecting State = "reconnecting"
|
||||
StateError State = "error"
|
||||
StateDisconnected State = "disconnected"
|
||||
StateConnecting State = "connecting"
|
||||
StateConnected State = "connected"
|
||||
StateReconnecting State = "reconnecting"
|
||||
StateError State = "error"
|
||||
)
|
||||
|
||||
// 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
|
||||
state atomic.Value // State
|
||||
assignedIP atomic.Value // string (IPv4)
|
||||
assignedIP6 atomic.Value // string (IPv6, may be empty)
|
||||
}
|
||||
|
||||
// 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 +75,7 @@ func New() *Stats {
|
||||
s := &Stats{}
|
||||
s.state.Store(StateDisconnected)
|
||||
s.assignedIP.Store("")
|
||||
s.assignedIP6.Store("")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -41,10 +85,12 @@ 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 and
|
||||
// assigned IP addresses. ip6 may be empty for an IPv4-only server.
|
||||
func (s *Stats) SetConnected(ip, ip6 string) {
|
||||
s.ConnectedAt.Store(time.Now().Unix())
|
||||
s.assignedIP.Store(ip)
|
||||
s.assignedIP6.Store(ip6)
|
||||
s.state.Store(StateConnected)
|
||||
}
|
||||
|
||||
@@ -52,29 +98,61 @@ func (s *Stats) SetConnected(ip string) {
|
||||
func (s *Stats) SetDisconnected() {
|
||||
s.ConnectedAt.Store(0)
|
||||
s.assignedIP.Store("")
|
||||
s.assignedIP6.Store("")
|
||||
s.state.Store(StateDisconnected)
|
||||
}
|
||||
|
||||
// AssignedIP returns the server-assigned tunnel IP.
|
||||
// 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) }
|
||||
|
||||
// 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
|
||||
State State
|
||||
Uptime time.Duration
|
||||
}
|
||||
|
||||
// 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(),
|
||||
AssignedIP: s.AssignedIP(),
|
||||
State: s.State(),
|
||||
RxBytesV4: rxv4,
|
||||
RxBytesV6: rxv6,
|
||||
TxBytesV4: txv4,
|
||||
TxBytesV6: txv6,
|
||||
RxBytes: rxv4 + rxv6,
|
||||
TxBytes: txv4 + txv6,
|
||||
AssignedIP: s.AssignedIP(),
|
||||
AssignedIP6: s.AssignedIP6(),
|
||||
State: s.State(),
|
||||
}
|
||||
ts := s.ConnectedAt.Load()
|
||||
if ts > 0 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -30,18 +30,19 @@ import (
|
||||
|
||||
// HandshakeConfig configures a single connection attempt.
|
||||
type HandshakeConfig struct {
|
||||
ServerURL string // e.g. wss://vpn.example.com/ws
|
||||
SNIHost string // TLS SNI hostname for CDN edge connections
|
||||
Token string // JWT; if non-empty, used via ?token= (method A)
|
||||
Username string // for password auth (method B), or fallback
|
||||
Password string // for password auth (method B), or fallback
|
||||
ServerURL string // e.g. wss://vpn.example.com/ws
|
||||
SNIHost string // TLS SNI hostname for CDN edge connections
|
||||
Token string // JWT; if non-empty, used via ?token= (method A)
|
||||
Username string // for password auth (method B), or fallback
|
||||
Password string // for password auth (method B), or fallback
|
||||
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
|
||||
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
|
||||
}
|
||||
|
||||
// Conn is an established VPN tunnel connection.
|
||||
type Conn struct {
|
||||
ws *websocket.Conn
|
||||
init protocol.InitMessage
|
||||
ws *websocket.Conn
|
||||
init protocol.InitMessage
|
||||
writeMu sync.Mutex
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
@@ -57,7 +58,9 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
|
||||
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,
|
||||
}
|
||||
@@ -158,7 +161,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 +189,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 +242,12 @@ 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 }
|
||||
|
||||
// Close terminates the connection.
|
||||
func (c *Conn) Close() error {
|
||||
c.mu.Lock()
|
||||
@@ -251,7 +261,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 +272,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.
|
||||
|
||||
@@ -26,10 +26,10 @@ func createTUN(name string) (Device, error) {
|
||||
return &darwinDevice{ifce: ifce}, nil
|
||||
}
|
||||
|
||||
func (d *darwinDevice) Name() string { return d.ifce.Name() }
|
||||
func (d *darwinDevice) Read(p []byte) (int, error) { return d.ifce.Read(p) }
|
||||
func (d *darwinDevice) Write(p []byte) (int, error) { return d.ifce.Write(p) }
|
||||
func (d *darwinDevice) Close() error { return d.ifce.Close() }
|
||||
func (d *darwinDevice) Name() string { return d.ifce.Name() }
|
||||
func (d *darwinDevice) Read(p []byte) (int, error) { return d.ifce.Read(p) }
|
||||
func (d *darwinDevice) Write(p []byte) (int, error) { return d.ifce.Write(p) }
|
||||
func (d *darwinDevice) Close() error { return d.ifce.Close() }
|
||||
|
||||
func (d *darwinDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error {
|
||||
if localIP == nil {
|
||||
@@ -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,157 @@
|
||||
//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.
+60
-51
@@ -27,13 +27,23 @@ 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
|
||||
connectBtn *widget.Button
|
||||
disconnectBtn *widget.Button
|
||||
|
||||
@@ -70,10 +80,11 @@ func Run() {
|
||||
}
|
||||
|
||||
a := &App{
|
||||
fyneApp: app.NewWithID(paths.BundleID),
|
||||
db: store,
|
||||
kc: keychain.New(),
|
||||
langSetting: cfg.Language,
|
||||
fyneApp: app.NewWithID(paths.BundleID),
|
||||
db: store,
|
||||
kc: keychain.New(),
|
||||
langSetting: cfg.Language,
|
||||
listSelectedIndex: -1,
|
||||
}
|
||||
|
||||
a.window = a.fyneApp.NewWindow(i18n.T("WindowTitle"))
|
||||
@@ -101,13 +112,26 @@ func Run() {
|
||||
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() {
|
||||
@@ -127,6 +151,16 @@ func (a *App) loadProfiles() {
|
||||
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.
|
||||
@@ -153,39 +187,6 @@ 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() {
|
||||
@@ -230,13 +231,17 @@ func (a *App) onResetDB() {
|
||||
// Reset UI state.
|
||||
a.currentProfile = nil
|
||||
a.loadProfiles()
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxLabel.SetText(i18n.T("RxZero"))
|
||||
a.txLabel.SetText(i18n.T("TxZero"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.ipLabel.SetText(i18n.T("IpNone"))
|
||||
a.ip6Label.SetText(i18n.T("Ip6None"))
|
||||
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
|
||||
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
|
||||
a.txV4Label.SetText(i18n.T("TxV4Zero"))
|
||||
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
|
||||
a.txV6Label.SetText(i18n.T("TxV6Zero"))
|
||||
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
|
||||
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
|
||||
a.setConnButtons(true, false)
|
||||
}, a.window).Show()
|
||||
}
|
||||
|
||||
@@ -277,6 +282,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 +308,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"))
|
||||
}
|
||||
|
||||
|
||||
+30
-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 {
|
||||
return c, 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,23 @@ 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,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 |
+189
-16
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"lmvpn/internal/i18n"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
@@ -18,7 +20,7 @@ var (
|
||||
authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)}
|
||||
routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)}
|
||||
|
||||
protoCodes = []string{"wss", "ws"}
|
||||
protoCodes = []string{"wss", "ws"}
|
||||
)
|
||||
|
||||
func authModeLabels() []string {
|
||||
@@ -106,6 +108,52 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
if !isNew {
|
||||
nameEntry.SetText(editing.Name)
|
||||
protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol))
|
||||
@@ -120,6 +168,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
|
||||
cidrEntry.SetText(editing.CustomCIDRs)
|
||||
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
|
||||
@@ -130,6 +182,11 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
mtuEntry.SetText("0")
|
||||
}
|
||||
|
||||
protoSelect.OnChanged = func(value string) {
|
||||
setTLSEnabled(value == "wss")
|
||||
}
|
||||
setTLSEnabled(selectedCode(protoCodes, protoSelect.SelectedIndex()) == "wss")
|
||||
|
||||
form := container.NewVBox(
|
||||
widget.NewLabel(i18n.T("FieldName")),
|
||||
nameEntry,
|
||||
@@ -157,9 +214,19 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
|
||||
widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry,
|
||||
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
|
||||
|
||||
profileWin.SetOnClosed(func() {
|
||||
@@ -175,7 +242,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
userEntry.Text, passEntry.Text,
|
||||
selectedCode(authCodes, authSelect.SelectedIndex()),
|
||||
selectedCode(routeCodes, routeSelect.SelectedIndex()),
|
||||
cidrEntry.Text, mtuEntry.Text, isNew) {
|
||||
cidrEntry.Text, mtuEntry.Text,
|
||||
tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
|
||||
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
|
||||
isNew) {
|
||||
profileWin.Close()
|
||||
}
|
||||
})
|
||||
@@ -186,14 +256,15 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
})
|
||||
|
||||
profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, container.NewVScroll(form)))
|
||||
profileWin.Resize(fyne.NewSize(460, 560))
|
||||
profileWin.Resize(fyne.NewSize(460, 760))
|
||||
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, cidrs, mtuStr,
|
||||
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool {
|
||||
if name == "" || host == "" || user == "" {
|
||||
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
|
||||
return false
|
||||
@@ -204,17 +275,21 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
|
||||
if isNew {
|
||||
p := &model.ServerProfile{
|
||||
Name: name,
|
||||
Protocol: protocol,
|
||||
Host: host,
|
||||
ServerIPs: ips,
|
||||
Port: port,
|
||||
Path: pathStr,
|
||||
Username: user,
|
||||
AuthMode: model.AuthMode(authMode),
|
||||
RoutingMode: model.RoutingMode(routeMode),
|
||||
CustomCIDRs: cidrs,
|
||||
MTUOverride: mtu,
|
||||
Name: name,
|
||||
Protocol: protocol,
|
||||
Host: host,
|
||||
ServerIPs: ips,
|
||||
Port: port,
|
||||
Path: pathStr,
|
||||
Username: user,
|
||||
AuthMode: model.AuthMode(authMode),
|
||||
RoutingMode: model.RoutingMode(routeMode),
|
||||
CustomCIDRs: cidrs,
|
||||
MTUOverride: mtu,
|
||||
TLSCACert: tlsCaPEM,
|
||||
TLSCAPath: tlsCaPath,
|
||||
TLSInsecure: tlsInsecure,
|
||||
TLSPinnedHash: tlsPinnedHash,
|
||||
}
|
||||
id, err := a.db.CreateProfile(p)
|
||||
if err != nil {
|
||||
@@ -240,6 +315,10 @@ func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
editing.RoutingMode = model.RoutingMode(routeMode)
|
||||
editing.CustomCIDRs = cidrs
|
||||
editing.MTUOverride = mtu
|
||||
editing.TLSCACert = tlsCaPEM
|
||||
editing.TLSCAPath = tlsCaPath
|
||||
editing.TLSInsecure = tlsInsecure
|
||||
editing.TLSPinnedHash = tlsPinnedHash
|
||||
if err := a.db.UpdateProfile(editing); err != nil {
|
||||
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
|
||||
return false
|
||||
@@ -267,3 +346,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()
|
||||
}
|
||||
|
||||
+21
-12
@@ -46,24 +46,33 @@ func (a *App) setupTray() {
|
||||
autoItem, enItem, zhItem,
|
||||
)
|
||||
|
||||
menu := fyne.NewMenu(i18n.T("WindowTitle"),
|
||||
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
|
||||
showDockIcon()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
|
||||
a.onConnect()
|
||||
}),
|
||||
fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
|
||||
})
|
||||
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() {
|
||||
showDockIcon()
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
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
|
||||
|
||||
+194
-54
@@ -1,7 +1,9 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/i18n"
|
||||
@@ -11,14 +13,42 @@ import (
|
||||
"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.
|
||||
@@ -30,15 +60,23 @@ func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
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"))
|
||||
|
||||
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),
|
||||
))
|
||||
|
||||
// Buttons.
|
||||
@@ -47,28 +85,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 +123,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,15 +133,19 @@ 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 {
|
||||
_ = ipc.SendStop(oldClient)
|
||||
oldClient.Close()
|
||||
}
|
||||
|
||||
// Get password from keychain.
|
||||
password, err := a.kc.GetPassword(a.currentProfile.Name)
|
||||
@@ -107,8 +155,7 @@ func (a *App) onConnect() {
|
||||
i18n.T("DlgCredentialErrorMsg"),
|
||||
a.window)
|
||||
a.stateLabel.SetText(i18n.T("StateDisconnected"))
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
a.setConnButtons(true, false)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -123,22 +170,25 @@ func (a *App) onConnect() {
|
||||
|
||||
// Build and send the start command.
|
||||
cfg := ipc.ClientConfig{
|
||||
ServerURL: serverURL,
|
||||
SNIHost: sniHost,
|
||||
ServerIPs: serverIPs,
|
||||
Username: p.Username,
|
||||
Password: password,
|
||||
AuthMode: string(p.AuthMode),
|
||||
RoutingMode: string(p.RoutingMode),
|
||||
CustomCIDRs: splitCIDRs(p.CustomCIDRs),
|
||||
MTUOverride: p.MTUOverride,
|
||||
ServerURL: serverURL,
|
||||
SNIHost: sniHost,
|
||||
ServerIPs: serverIPs,
|
||||
Username: p.Username,
|
||||
Password: password,
|
||||
AuthMode: string(p.AuthMode),
|
||||
RoutingMode: string(p.RoutingMode),
|
||||
CustomCIDRs: splitCIDRs(p.CustomCIDRs),
|
||||
MTUOverride: p.MTUOverride,
|
||||
TLSCACert: p.TLSCACert,
|
||||
TLSCAPath: p.TLSCAPath,
|
||||
TLSInsecure: p.TLSInsecure,
|
||||
TLSPinnedHash: p.TLSPinnedHash,
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -152,14 +202,24 @@ func (a *App) onConnect() {
|
||||
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"))
|
||||
}
|
||||
|
||||
// eventLoop reads IPC events from the daemon and updates the UI.
|
||||
@@ -181,11 +241,15 @@ 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.setConnButtons(true, false)
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -193,49 +257,87 @@ 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.applyState(ev.State)
|
||||
}
|
||||
})
|
||||
case ipc.EvStats:
|
||||
if ev.Stats != nil {
|
||||
s := *ev.Stats
|
||||
fyne.Do(func() {
|
||||
a.applyStats(s)
|
||||
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) {
|
||||
switch stats.State(state) {
|
||||
case stats.StateConnected:
|
||||
a.stateLabel.SetText(i18n.T("StateConnected"))
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
a.setConnButtons(false, true)
|
||||
case stats.StateConnecting:
|
||||
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,11 +346,32 @@ 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 {
|
||||
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)}))
|
||||
}
|
||||
@@ -268,6 +391,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)
|
||||
|
||||
@@ -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"
|
||||
+206
-11
@@ -9,9 +9,12 @@ package vpn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +24,7 @@ import (
|
||||
"lmvpn/internal/protocol"
|
||||
"lmvpn/internal/route"
|
||||
"lmvpn/internal/stats"
|
||||
"lmvpn/internal/tlsconfig"
|
||||
"lmvpn/internal/transport"
|
||||
"lmvpn/internal/tun"
|
||||
)
|
||||
@@ -37,6 +41,10 @@ type SessionConfig struct {
|
||||
RoutingMode route.Mode
|
||||
CustomCIDRs []string
|
||||
MTUOverride int // 0 = use server MTU
|
||||
TLSCACert string // inline CA cert PEM (wss only)
|
||||
TLSCAPath string // CA cert file path (wss only)
|
||||
TLSInsecure bool // skip cert verification (wss only)
|
||||
TLSPinnedHash string // SHA-256 cert pin (wss only)
|
||||
}
|
||||
|
||||
// SessionManager manages a single VPN session with auto-reconnect.
|
||||
@@ -44,6 +52,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 +60,32 @@ type SessionManager struct {
|
||||
dev tun.Device
|
||||
routeMgr *route.Manager
|
||||
conn *transport.Conn
|
||||
done chan struct{}
|
||||
|
||||
// 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 +107,7 @@ 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.mu.Unlock()
|
||||
|
||||
go sm.run(ctx, cfg)
|
||||
@@ -102,19 +128,35 @@ func (sm *SessionManager) Disconnect() {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
// Close the transport to unblock the packet pump.
|
||||
// Close the transport to unblock the WS->TUN goroutine.
|
||||
sm.mu.Lock()
|
||||
conn := sm.conn
|
||||
dev := sm.dev
|
||||
sm.mu.Unlock()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
// Close the TUN device to unblock the TUN->WS goroutine's dev.Read().
|
||||
if dev != nil {
|
||||
dev.Close()
|
||||
}
|
||||
// Wait for the run goroutine to fully exit so that cleanup
|
||||
// (route removal, TUN teardown) is complete before returning.
|
||||
if sm.done != nil {
|
||||
<-sm.done
|
||||
}
|
||||
}
|
||||
|
||||
// run is the main session loop with exponential-backoff reconnection
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
|
||||
backoff := time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
@@ -125,6 +167,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
sm.cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,6 +184,36 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
|
||||
|
||||
if err != nil {
|
||||
log.L().Error("VPN connection failed", "error", err)
|
||||
|
||||
// A TLS certificate verification failure is not retryable:
|
||||
// the cert won't change between attempts, so stop the
|
||||
// loop and surface the reason to the user.
|
||||
if tlsconfig.IsTLSError(err) {
|
||||
log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
|
||||
sm.setState(stats.StateError)
|
||||
if sm.onError != nil {
|
||||
sm.onError("tls_error", err.Error())
|
||||
}
|
||||
fatal = true
|
||||
sm.cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// A fatal authentication failure (wrong password, disabled
|
||||
// account, expired token, rate limit) is not retryable:
|
||||
// stop the loop and surface the reason to the user instead
|
||||
// of hammering the server forever.
|
||||
if code, msg, isFatal := fatalAuthError(err); isFatal {
|
||||
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
|
||||
}
|
||||
|
||||
sm.setState(stats.StateReconnecting)
|
||||
|
||||
// Try next CDN IP immediately.
|
||||
@@ -169,6 +242,41 @@ 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 {
|
||||
@@ -181,6 +289,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 +318,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(httpBase, cfg.Username, cfg.Password, tlsCfg)
|
||||
if err != nil {
|
||||
if cfg.AuthMode == model.AuthModeBoth {
|
||||
// Fall back to password auth.
|
||||
@@ -212,6 +342,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
OnInit: func(init protocol.InitMessage) error {
|
||||
return sm.setupTUN(init, cfg)
|
||||
},
|
||||
TLSConfig: tlsCfg,
|
||||
}
|
||||
|
||||
// Attempt JWT connection first; fall back to password on auth error.
|
||||
@@ -238,10 +369,11 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
|
||||
sm.conn = conn
|
||||
sm.mu.Unlock()
|
||||
|
||||
sm.stats.SetConnected(conn.Init().IP)
|
||||
sm.stats.SetConnected(conn.Init().IP, conn.Init().IP6)
|
||||
sm.setState(stats.StateConnected)
|
||||
log.L().Info("VPN connected",
|
||||
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
|
||||
"ip6", conn.Init().IP6, "server_ip6", conn.Init().ServerIP6,
|
||||
"mtu", conn.Init().MTU)
|
||||
|
||||
// Start stats reporter.
|
||||
@@ -280,6 +412,20 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
|
||||
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 {
|
||||
dev.Close()
|
||||
return fmt.Errorf("invalid init IPv6: %s", init.IP6)
|
||||
}
|
||||
if err := dev.ConfigureIPv6(ip6, init.Prefix6); err != nil {
|
||||
dev.Close()
|
||||
return fmt.Errorf("configure tun ipv6: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
mtu := init.MTU
|
||||
if cfg.MTUOverride > 0 {
|
||||
mtu = cfg.MTUOverride
|
||||
@@ -295,6 +441,8 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
|
||||
InterfaceName: dev.Name(),
|
||||
VPNIP: init.IP,
|
||||
VPNPrefix: init.Prefix,
|
||||
VPNIP6: init.IP6,
|
||||
VPNPrefix6: init.Prefix6,
|
||||
ServerHost: serverHostFromURL(cfg.ServerURL),
|
||||
CustomCIDRs: cfg.CustomCIDRs,
|
||||
}
|
||||
@@ -304,7 +452,8 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
|
||||
}
|
||||
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -341,7 +490,7 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
}
|
||||
return
|
||||
}
|
||||
sm.stats.TxBytes.Add(int64(n))
|
||||
sm.stats.AddTx(buf[:n])
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -368,7 +517,7 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
sm.stats.RxBytes.Add(int64(len(data)))
|
||||
sm.stats.AddRx(data)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -428,9 +577,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 +593,49 @@ 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
|
||||
sm.onStats(snap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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