24 Commits
Author SHA1 Message Date
kevin bf4744bb1d feat: 添加服务端证书合法性验证(自定义CA/跳过验证/证书固定)
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- 新增 internal/tlsconfig 包,集中构建 TLS 配置
- 修复 CDN 边缘 IP 故障转移时 HTTP 登录 TLS 验证失败的问题
- 支持自定义 CA 证书(内联 PEM + 文件路径,合并生效)
- 支持 InsecureSkipVerify 跳过证书验证
- 支持证书固定(SHA-256 指纹校验)
- TLS 验证错误设为不可恢复,避免无限重试
- Profile 编辑界面新增 TLS 设置区域,协议联动启用/禁用
- DB schema v4 迁移,新增 4 个 TLS 字段
2026-07-08 11:33:36 +08:00
kevin 97cd9d4553 fix: 提交 Windows 图标资源(icon.ico/.syso)修复 CI
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
2026-07-07 20:40:48 +08:00
kevin 56c802c385 fix: 禁用 MSYS 路径转换修复 ISCC /D 参数 2026-07-07 20:30:16 +08:00
kevin 6026beb107 fix: 修正 macOS ditto 打包选项为 --keepParent 2026-07-07 20:22:22 +08:00
kevin 8c903860c2 docs: README 添加服务端项目地址与 CI 说明 2026-07-07 20:13:50 +08:00
kevin 7abbf9e6cc feat: 添加 GitHub Actions 多平台自动发布工作流 2026-07-07 20:12:09 +08:00
kevin 45b9fa24a1 up 2026-07-07 19:57:54 +08:00
kevin 4108adf1f7 up 2026-07-07 19:57:33 +08:00
kevin 6074dd5a73 up readme 2026-07-07 19:43:48 +08:00
kevin 3c076785f6 feat: Windows 安装器 + 修复 daemon 无法连接问题
- 新增 Inno Setup 安装器脚本 (installer/lmvpn.iss),支持自定义安装路径、
  桌面快捷方式、安装前杀死旧进程,打包 wintun.dll
- Makefile 新增 installer-windows 目标,支持 ISCC 和 Wine 两种编译方式
- Windows IPC 从 AF_UNIX 改为 TCP (127.0.0.1:18923),解决提权 daemon 与
  非提权 GUI 之间的完整性级别限制导致 daemon did not become reachable
- 修复 elevation_windows.go 中 ShellExecute 参数路径含空格时被截断的问题
  (C:\Program Files\LMVPN\lmvpnd.exe → 只取到 C:\Program)
- ensureDaemon 重试次数 40→60,添加连接失败日志便于诊断
- 新增 IPCNetwork()/IPCAddress() 跨平台函数,统一 IPC 传输层抽象
2026-07-07 19:29:41 +08:00
kevin 019df7bc23 修复图标显示 2026-07-07 17:58:04 +08:00
kevin 9a1aca5d1b up 2026-07-07 17:48:48 +08:00
kevin 027d837cb2 fix: 修复 Windows 端密码无法持久化的问题
Windows 端使用内存版 MemStore(keychain_other.go, 构建标签 !darwin),
进程退出后密码丢失,导致每次启动都需重新输入密码才能连接。

新增 keychain_windows.go,基于 Windows 凭据管理器(Credential Manager)
实现 Store 接口,密码经 Windows 登录凭据加密并持久化,跨重启保留。
keychain_other.go 构建标签收窄为 !darwin && !windows,作为 Linux 回退。

macOS 版本不受影响(仍由 keychain_darwin.go 独占编译)。
2026-07-07 17:47:41 +08:00
kevin 0c5e996bfd fix: 修复 Windows 端 IPv6 无法下行的问题
根因: netsh interface ipv6 命令参数名用错
- configureIPv6Addr: name= → interface=,IPv6 地址未实际分配到适配器
- SetMTU IPv6: name= → interface=

路由修复:
- cidrToV6Prefix: 用 Mask.Size() 取前缀长度,修复 fmt.Sprintf(%d, mask) 输出字节切片的 bug
- addRoute6/deleteRoute6: netsh → route -6,用 :: 作 on-link gateway
- addRouteVia6/deleteRouteVia6: 加 -6 flag
- defaultGateway6: route -6 print :: → route -6 print(避免过滤掉 ::/0)
- applyFull: IPv6 bypass 路由失败不再阻断全隧道路由
2026-07-07 17:37:09 +08:00
kevin 65a5932eba feat: 支持 Windows x86_64 平台编译
- 新增 WinTun TUN 设备实现(tun_windows.go),无需安装驱动,
  wintun.dll 嵌入 exe 运行时自动释放
- 新增 Windows 路由管理(route_windows.go),使用 route/netsh 命令
- 新增 Windows 路径(paths_windows.go),使用 %APPDATA%/%LOCALAPPDATA%
- 拆分 daemon 启动逻辑:launch_unix.go(Setsid) / launch_windows.go
  (CREATE_NEW_PROCESS_GROUP),公共代码提取到 launch_common.go
- 拆分提权逻辑:elevation_darwin.go(osascript) / elevation_windows.go
  (ShellExecute UAC) / elevation_other.go(pkexec)
- IPCSocketPath 按平台分散,Windows 用 %TEMP%\lmvpn.sock
- daemon 二进制名用编译时常量(daemonBinaryName),按平台选定 .exe 后缀
- Makefile 新增 build-windows 目标
- 重新标记 !darwin 文件为 !darwin && !windows
2026-07-07 16:50:42 +08:00
kevin 6b9960076d up 2026-07-07 14:40:07 +08:00
kevin 56102589f6 feat: 认证失败时停止重连并提示用户
密码错误、账户禁用、令牌过期或限流等永久性认证错误不再无限
重连,改为置 StateError 并通过 EvError 弹窗告知用户具体原因
(中英文本地化)。网络错误仍正常指数退避重试。

- protocol: 新增 AuthErrorCode 类型与消息映射
- transport: AuthError/ServerError 携带 Code 字段
- vpn: run() 循环识别致命认证错误并停止,新增 onError 回调
- ipc/daemon/ui: 打通错误码到 UI 的本地化展示
2026-07-07 14:37:26 +08:00
kevin 6daa73fdb0 增加图标 2026-07-07 14:16:09 +08:00
kevin abcbb6ae68 更新项目连接 2026-07-07 13:44:37 +08:00
kevin 9c16d804d9 修复按钮逻辑bug 2026-07-07 13:34:48 +08:00
kevin e08480f609 整合配置列表 2026-07-07 13:16:07 +08:00
kevin 4420532a66 fix: ensureDaemon 自动检测并重启过期守护进程
问题: 重新构建后 IPv6 等新功能不生效,客户端仍运行旧代码。
根因: ensureDaemon() 通过 IPC socket 拨号时,若旧 daemon 进程仍在
运行(重建后未退出),GUI 会直接复用旧进程,永不启动新二进制。
日志证实旧 daemon 处理连接(init 日志无 ip6 字段),新代码从未执行。

解决方案: 新增版本握手协议,GUI 启动时查询 daemon 构建版本,
版本不匹配则自动关闭旧进程并启动新的。

- 新建 internal/version 包,GUI 与 daemon 共享同一 Version 变量,
  均通过 Makefile ldflags 注入
- IPC 新增 CmdVersion 命令,daemon 返回 version.Version;
  Response 增加 Version 字段,Client 新增 QueryVersion() 方法
- ensureDaemon() 拨号后查询版本,不匹配则发 CmdShutdown、等待
  socket 释放、重新启动 daemon;旧 daemon 不识别 CmdVersion 时
  返回错误,同样触发重启
- Makefile LDFLAGS 改为注入 lmvpn/internal/version.Version
2026-07-07 12:42:23 +08:00
kevin a332857973 up 2026-07-07 12:24:48 +08:00
kevin 8f6daf9f49 feat: 新增 IPv6 双栈支持
- 协议: InitMessage 增加 ip6/prefix6/server_ip6 字段(omitempty,向后兼容)
- TUN: Device 接口新增 ConfigureIPv6, macOS 用 ifconfig inet6, Linux 用 ip -6 addr add
- 路由: full 模式自动添加 ::/1+8000::/1, v6 服务器旁路 /128, resolveHosts 双栈解析
- 会话: setupTUN 解析 v6 字段并配置 TUN+路由, connectOnce 传 v6 给 stats/日志
- 统计: SetConnected(ip,ip6), Snapshot.AssignedIP6
- DB: connection_logs 增加 assigned_ip6 列 + migrateV3 幂等迁移
- UI: 状态卡片新增独立 IPv6 行, 始终可见(未获取显示 IPv6: —)
- 文档: 同步 client-development.md 至服务端 IPv6 版本
2026-07-07 12:24:20 +08:00
60 changed files with 3486 additions and 420 deletions
+114
View File
@@ -0,0 +1,114 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version (e.g. 0.3.9, 不带 v 前缀)'
required: true
permissions:
contents: write
jobs:
build-macos:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve version
id: ver
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
- name: Vet
run: make vet
- name: Build .app
run: make app SEMVER=${{ steps.ver.outputs.version }}
- name: Package .app as zip
run: |
VERSION="${{ steps.ver.outputs.version }}-$(git rev-parse --short HEAD)"
ditto -c -k --sequesterRsrc --keepParent LMVPN.app "LMVPN-${VERSION}-macos.zip"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: macos-build
path: LMVPN-*-macos.zip
build-windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install make and Inno Setup
shell: pwsh
run: choco install make innosetup -y --no-progress
- name: Add MinGW to PATH
shell: bash
run: echo "C:/msys64/mingw64/bin" >> "$GITHUB_PATH"
- name: Resolve version
id: ver
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
fi
- name: Vet
shell: bash
run: make vet
- name: Build installer
shell: bash
env:
MSYS_NO_PATHCONV: '1'
run: make installer-windows SEMVER=${{ steps.ver.outputs.version }} MINGW_CC=gcc SHELL=bash
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: windows-build
path: build/LMVPN-Setup-*.exe
release:
needs: [build-macos, build-windows]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
+42 -3
View File
@@ -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.1
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"
+349
View File
@@ -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.0Big 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 编译器**GCCLinux/Windows 交叉编译)或 clangmacOS),由 CGO 调用
- **网络访问**:首次构建需拉取 Go 模块依赖
版本号通过 `-ldflags "-X lmvpn/internal/version.Version=$(VERSION)"` 在链接期注入到 `internal/version` 包,GUI 与守护进程共享同一版本字符串。
### macOS
#### 依赖
- **Xcode Command Line Tools**:提供 clangCGO)、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.
+80 -23
View File
@@ -294,7 +294,7 @@ sequenceDiagram
### 5.2 IP 分配规则
服务端从 VPN 子网中分配客户端内网 IP(`internal/vpn/alloc.go:39-66``internal/vpn/service.go:47-57`):
服务端从 VPN 子网中分配客户端内网 IP(`internal/vpn/alloc.go:39-66``internal/vpn/service.go:47-58`):
| 地址 | 分配规则 | 说明 |
|------|----------|------|
@@ -308,9 +308,19 @@ sequenceDiagram
- 若该用户有预留 IP,优先使用预留;预留被占用时报错(`alloc.go:43-48`
- 地址耗尽时报错 "可用 IP 地址已耗尽"`alloc.go:65`
#### IPv6 双栈
当服务端配置了 IPv6 子网(`Subnet6`)时,客户端同时获得 IPv4 和 IPv6 地址:
- IPv4 地址始终分配(`Subnet` 必填)
- IPv6 地址仅当 `Subnet6` 非空时分配(可选)
- IPv6 预留独立于 IPv4 预留,可单独配置
- IPv6 子网前缀限制:`/64` ~ `/126`
- 对于 `/64` 等大子网,`cidr.AddressCount` 会溢出,实际扫描上限为 65536 个地址
### 5.3 init 消息
前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:126-137`):
前置检查通过后,服务端发送 `init` 文本消息(`internal/vpn/tunnel.go:132-148`):
```json
{
@@ -318,17 +328,25 @@ sequenceDiagram
"ip": "10.0.0.5",
"prefix": 24,
"mtu": 1420,
"server_ip": "10.0.0.1"
"server_ip": "10.0.0.1",
"ip6": "fd00:dead:beef::5",
"prefix6": 112,
"server_ip6": "fd00:dead:beef::1"
}
```
| 字段 | 类型 | 说明 | 源码 |
|------|------|------|------|
| `type` | string | 固定值 `"init"` | `internal/vpn/protocol.go:3-9` |
| `ip` | string | 分配给客户端的内网 IP(点分十进制) | `tunnel.go:130` |
| `prefix` | int | 子网前缀长度(如 `24` | `tunnel.go:131` |
| `mtu` | int | TUN 网卡 MTU | `tunnel.go:132` |
| `server_ip` | string | 服务器内网 IP(对端地址 | `tunnel.go:133` |
| 字段 | 类型 | 必填 | 说明 | 源码 |
|------|------|------|------|------|
| `type` | string | 是 | 固定值 `"init"` | `internal/vpn/protocol.go:3-10` |
| `ip` | string | 是 | 分配给客户端的 IPv4 地址 | `tunnel.go:136` |
| `prefix` | int | 是 | IPv4 子网前缀长度(如 `24` | `tunnel.go:137` |
| `mtu` | int | 是 | TUN 网卡 MTU | `tunnel.go:138` |
| `server_ip` | string | 是 | 服务器 IPv4 地址 | `tunnel.go:139` |
| `ip6` | string | 否 | 分配给客户端的 IPv6 地址(仅当服务端配置了 IPv6 子网时存在) | `tunnel.go:141-144` |
| `prefix6` | int | 否 | IPv6 子网前缀长度 | `tunnel.go:142` |
| `server_ip6` | string | 否 | 服务器 IPv6 地址 | `tunnel.go:143` |
> ️ `ip6`/`prefix6`/`server_ip6` 字段使用 `omitempty`,旧客户端可安全忽略。若服务端未配置 IPv6 子网,这三个字段不会出现。
### 5.4 ready 消息与超时
@@ -412,14 +430,14 @@ sequenceDiagram
### 6.4 反欺骗(Anti-Spoofing
服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-116`):
服务端**强制校验**每个来自客户端的 IP 包的源地址(`internal/vpn/switch.go:113-126`):
```
if 源IP != 该客户端被分配的IP:
丢弃该包(不转发,不写入TUN,不断开连接)
```
- **IPv4 包**:源地址必须等于客户端被分配的 IPv4 地址(`init.ip`
- **IPv6 包**:源地址必须等于客户端被分配的 IPv6 地址(`init.ip6`
> ⚠️ 客户端必须确保 TUN 网卡只发送源地址为 `init.ip` 的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃
不匹配的包将被静默丢弃(不转发、不写入 TUN、不断开连接)
> ⚠️ 客户端必须确保 TUN 网卡只发送源地址与 `init.ip` / `init.ip6` 匹配的 IP 包。若客户端配置错误导致源 IP 不匹配,所有上行包将被静默丢弃。
### 6.5 非 IP 二进制帧
@@ -488,9 +506,11 @@ conn.WriteControl(websocket.PingMessage, nil, ...)
| 约束 | 值 | 源码 |
|------|----|----|
| IP 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` |
| 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` |
| IPv4 版本 | 仅 IPv4 | `internal/handler/vpn.go:66-73` |
| IPv4 前缀长度 | ≤ /30 | `internal/handler/vpn.go:74-78` |
| IPv6 前缀长度 | /64 ~ /126 | `internal/handler/vpn.go:81-92` |
| 可用容量 | `AddressCount - 3` | `internal/vpn/alloc.go:106-112` |
| IPv6 大子网容量 | ~65533/64 等大子网 AddressCount 溢出时) | `internal/vpn/alloc.go:108-110` |
### 8.4 MTU 约束
@@ -516,7 +536,7 @@ type controlMessage struct {
}
```
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-9`):
`init` 消息结构较为特殊(`internal/vpn/protocol.go:3-10`):
```go
type initMessage struct {
@@ -525,6 +545,9 @@ type initMessage struct {
Prefix int `json:"prefix"`
MTU int `json:"mtu"`
ServerIP string `json:"server_ip"`
IP6 string `json:"ip6,omitempty"`
Prefix6 int `json:"prefix6,omitempty"`
ServerIP6 string `json:"server_ip6,omitempty"`
}
```
@@ -578,7 +601,7 @@ type initMessage struct {
|------|----------------|------|------|------|
| `auth_ok` | Text | 密码认证成功(仅方式 B | JSON | `{"type":"auth_ok"}` |
| `auth_err` | Text | 认证失败 | JSON | `{"type":"auth_err","message":"..."}` |
| `init` | Text | 认证成功且前置检查通过 | JSON | `{"type":"init","ip":"...","prefix":24,"mtu":1420,"server_ip":"..."}` |
| `init` | Text | 认证成功且前置检查通过 | JSON | `{"type":"init","ip":"...","prefix":24,"mtu":1420,"server_ip":"...","ip6":"...","prefix6":112,"server_ip6":"..."}` |
| `error` | Text | 握手阶段失败 | JSON | `{"type":"error","message":"..."}` |
| 数据帧 | Binary | `ready` 之后,下行 IP 包 | 原始 IP 包 | (二进制) |
| Ping | WebSocket Ping | 每 30 秒 | 空 | WebSocket 协议层) |
@@ -593,9 +616,11 @@ type initMessage struct {
| 配置项 | 取值来源 | 说明 |
|--------|----------|------|
| TUN 网卡地址 | `init.ip` / `init.prefix` | 如 `10.0.0.5/24` |
| TUN 网卡地址 (IPv4) | `init.ip` / `init.prefix` | 如 `10.0.0.5/24` |
| TUN 网卡地址 (IPv6) | `init.ip6` / `init.prefix6` | 如 `fd00:dead:beef::5/112`(可选,仅当 init 含 ip6 时) |
| MTU | `init.mtu` | 如 `1420` |
| 对端地址 / 默认路由网关 | `init.server_ip` | 如 `10.0.0.1` |
| 对端地址 / 默认路由网关 (IPv4) | `init.server_ip` | 如 `10.0.0.1` |
| 对端地址 (IPv6) | `init.server_ip6` | 如 `fd00:dead:beef::1`(可选) |
### 11.2 Linux
@@ -608,10 +633,17 @@ ip addr add dev <tun> <ip>/<prefix> peer <server_ip>
ip link set dev <tun> mtu <mtu>
```
IPv6(仅当 init 含 `ip6` 时):
```
ip addr add dev <tun> <ip6>/<prefix6>
```
路由:若需将所有流量经 VPN,添加默认路由:
```
ip route add 0.0.0.0/0 dev <tun>
ip route add ::/0 dev <tun> # IPv6 默认路由(可选)
```
### 11.3 macOS
@@ -623,10 +655,17 @@ ifconfig <utun> inet <ip>/<prefix> <server_ip> up
ifconfig <utun> mtu <mtu>
```
IPv6(仅当 init 含 `ip6` 时):
```
ifconfig <utun> inet6 <ip6>/<prefix6> up
```
路由:
```
route add -inet -net 0.0.0.0/0 -interface <utun>
route add -inet6 -net ::/0 -interface <utun> # IPv6 默认路由(可选)
```
> macOS 的 utun 接口由系统分配编号(如 `utun4`),客户端通常无法指定名称。
@@ -670,6 +709,12 @@ Linux 服务端须开启 IP 转发(`internal/vpn/diag_linux.go:53-60`):
sysctl -w net.ipv4.ip_forward=1
```
IPv6 双栈场景还须开启 IPv6 转发(`internal/vpn/diag_linux.go:116-122`):
```
sysctl -w net.ipv6.conf.all.forwarding=1
```
### 12.2 NAT 伪装
服务端须配置 NAT masquerade,将客户端源 IP 转换为服务器物理网卡 IP(`internal/vpn/diag_linux.go:80-113`)。
@@ -688,7 +733,19 @@ nft add rule ip nat postrouting oifname <物理网卡> masquerade
iptables -t nat -A POSTROUTING -o <物理网卡> -j MASQUERADE
```
> 服务端诊断接口 `GET /api/admin/vpn/diag``internal/handler/vpn.go:190-192`)会检测上述配置,客户端无法上网时可请管理员查看诊断结果。
IPv6 NAT66(仅双栈场景需要):
**nft**
```
nft add rule inet lmvpn_nat postrouting oifname <物理网卡> ip6 saddr <VPN_V6_SUBNET> masquerade
```
**ip6tables(回退)**
```
ip6tables -t nat -A POSTROUTING -s <VPN_V6_SUBNET> -o <物理网卡> -j MASQUERADE
```
> 服务端诊断接口 `GET /api/admin/vpn/diag``internal/handler/vpn.go:190-192`)会检测上述配置(含 IPv6),客户端无法上网时可请管理员查看诊断结果。
### 12.3 子网与 MTU
+3 -1
View File
@@ -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
+6
View File
@@ -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=
+417
View File
@@ -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=简体中文翻译由 Kira847320916@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您确定要继续吗?
+145
View File
@@ -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
View File
@@ -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)
+12 -2
View File
@@ -26,6 +26,7 @@ import (
"lmvpn/internal/model"
"lmvpn/internal/paths"
"lmvpn/internal/stats"
"lmvpn/internal/version"
"lmvpn/internal/vpn"
)
@@ -59,8 +60,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}
@@ -113,6 +114,8 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
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)
}
@@ -138,6 +141,10 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
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 +157,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 {
+5
View File
@@ -0,0 +1,5 @@
//go:build !windows
package daemon
const daemonBinaryName = "lmvpnd"
+5
View File
@@ -0,0 +1,5 @@
//go:build windows
package daemon
const daemonBinaryName = "lmvpnd.exe"
+32
View File
@@ -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.
+93
View File
@@ -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
}
+66 -1
View File
@@ -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, &notnull, &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,
@@ -190,6 +254,7 @@ CREATE TABLE IF NOT EXISTS connection_logs (
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at DATETIME,
assigned_ip TEXT NOT NULL DEFAULT '',
assigned_ip6 TEXT NOT NULL DEFAULT '',
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'connected',
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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."
+39 -4
View File
@@ -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 = "服务器证书验证失败。"
+46 -6
View File
@@ -29,6 +29,7 @@ const (
CmdStop = "stop"
CmdShutdown = "shutdown"
CmdStats = "stats"
CmdVersion = "version" // query daemon build version
)
// Event types sent from daemon to GUI.
@@ -58,6 +59,10 @@ type ClientConfig struct {
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)
}
@@ -205,6 +222,7 @@ func (c *Client) Close() error { return c.conn.Close() }
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Version string `json:"version,omitempty"` // set for CmdVersion replies
}
// WriteOK sends a success response to a connection.
@@ -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 {
+5 -2
View File
@@ -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
}
+94
View File
@@ -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)
}
+5
View File
@@ -41,6 +41,10 @@ type ServerProfile struct {
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"`
}
@@ -113,6 +117,7 @@ type ConnectionLog struct {
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
AssignedIP string `json:"assigned_ip"`
AssignedIP6 string `json:"assigned_ip6"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
Status ConnectionStatus `json:"status"`
+1 -3
View File
@@ -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" }
+6
View File
@@ -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)
+7 -1
View File
@@ -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)
+48
View File
@@ -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"),
}
}
+43 -4
View File
@@ -31,13 +31,16 @@ const (
)
// InitMessage is sent by the server after auth + pre-checks pass.
// (server: protocol.go:3-9, tunnel.go:126-137)
// (server: protocol.go:3-10, tunnel.go:134-145)
type InitMessage struct {
Type string `json:"type"`
IP string `json:"ip"` // assigned client IP (dotted-quad)
Prefix int `json:"prefix"` // subnet prefix length (e.g. 24)
IP string `json:"ip"` // assigned client IPv4 (dotted-quad)
Prefix int `json:"prefix"` // IPv4 subnet prefix length (e.g. 24)
MTU int `json:"mtu"` // TUN device MTU (e.g. 1420)
ServerIP string `json:"server_ip"` // server's tunnel IP (peer/gateway)
ServerIP string `json:"server_ip"` // server's tunnel IPv4 (peer/gateway)
IP6 string `json:"ip6,omitempty"` // assigned client IPv6 (only when server has Subnet6)
Prefix6 int `json:"prefix6,omitempty"` // IPv6 subnet prefix length
ServerIP6 string `json:"server_ip6,omitempty"` // server's tunnel IPv6
}
// ControlMessage is the generic text control message.
@@ -66,3 +69,39 @@ type AuthResponse struct {
func IsError(msgType string) bool {
return msgType == TypeAuthErr || msgType == TypeError
}
// AuthErrorCode is a stable, locale-independent identifier for a fatal
// authentication failure. It is derived from the server's auth_err
// message (or HTTP status for the JWT login path) and carried over IPC
// to the GUI, which maps it to a localized user-facing string.
type AuthErrorCode string
const (
AuthCodeWrongCredentials AuthErrorCode = "wrong_credentials" // 用户名或密码错误 / HTTP 401,403
AuthCodeUserDisabled AuthErrorCode = "user_disabled" // 用户不存在或已禁用
AuthCodeTokenInvalid AuthErrorCode = "token_invalid" // 令牌无效或已过期
AuthCodeRateLimited AuthErrorCode = "rate_limited" // 认证尝试过于频繁 / HTTP 429
AuthCodeMalformed AuthErrorCode = "malformed" // 消息格式错误
)
// AuthErrorCodeFromMessage maps a server auth_err message string to a
// stable AuthErrorCode. It returns the empty string for an unrecognized
// message, in which case the caller should treat the failure as
// non-categorical (and typically still fatal, since the server closes
// the connection after auth_err).
func AuthErrorCodeFromMessage(msg string) AuthErrorCode {
switch msg {
case "用户名或密码错误":
return AuthCodeWrongCredentials
case "用户不存在或已禁用":
return AuthCodeUserDisabled
case "令牌无效或已过期":
return AuthCodeTokenInvalid
case "认证尝试过于频繁,请稍后再试":
return AuthCodeRateLimited
case "消息格式错误":
return AuthCodeMalformed
default:
return ""
}
}
+130 -28
View File
@@ -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
}
@@ -39,9 +44,14 @@ type Config struct {
// they can be cleaned up deterministically.
type Manager struct {
cfg Config
addedRoutes []string // route specs added, for deletion
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 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
}
}
// 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 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)
}
}
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
}
+34 -2
View File
@@ -9,6 +9,7 @@ import (
)
// addRoute adds a route via a network interface (macOS route command).
//
// 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)
+32 -2
View File
@@ -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
+173
View File
@@ -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
}
+88 -10
View File
@@ -19,12 +19,55 @@ const (
// Stats holds live session statistics. Counters are atomic for
// lock-free reads from the UI/IPC layer.
//
// Counters are split by IP address family (v4/v6) at the recording
// site via AddRx/AddTx, which inspect the IP version nibble. The
// combined RxBytes/TxBytes are derived as the sum of the per-family
// counters in Snapshot, so callers that only need the total still
// work.
type Stats struct {
RxBytes atomic.Int64
TxBytes atomic.Int64
RxBytesV4 atomic.Int64
RxBytesV6 atomic.Int64
TxBytesV4 atomic.Int64
TxBytesV6 atomic.Int64
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
state atomic.Value // State
assignedIP atomic.Value // string
assignedIP atomic.Value // string (IPv4)
assignedIP6 atomic.Value // string (IPv6, may be empty)
}
// 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,28 +98,60 @@ 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(),
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()
+157
View File
@@ -0,0 +1,157 @@
// Package tlsconfig builds *tls.Config instances from user-supplied
// verification settings (custom CA, insecure mode, certificate
// pinning). It is shared by the WebSocket transport and the HTTP
// login client so both paths enforce identical TLS policy.
package tlsconfig
import (
"bytes"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
)
// Config holds TLS verification settings for a server connection.
type Config struct {
ServerName string // TLS SNI hostname (for CDN edge IP connections)
CACertPEM string // inline CA certificate PEM content
CACertPath string // path to a CA certificate file
InsecureSkipVerify bool // skip chain verification entirely
PinnedCertHash string // SHA-256 fingerprint of the leaf cert (hex, optional "sha256:" prefix)
}
// Build creates a *tls.Config from the given settings.
//
// Verification behaviour matrix:
//
// Pin set | Insecure | Behaviour
// --------+----------+-------------------------------------------
// no | no | Normal chain + hostname verification (Go default)
// no | yes | All verification skipped
// yes | no | Normal chain verification, then pin check
// yes | yes | Chain skipped, pin check only
func Build(cfg Config) (*tls.Config, error) {
tlsCfg := &tls.Config{
ServerName: cfg.ServerName,
}
if cfg.CACertPEM != "" || cfg.CACertPath != "" {
pool, err := buildCertPool(cfg.CACertPEM, cfg.CACertPath)
if err != nil {
return nil, fmt.Errorf("load CA cert: %w", err)
}
tlsCfg.RootCAs = pool
}
var pin []byte
if cfg.PinnedCertHash != "" {
var err error
pin, err = decodePin(cfg.PinnedCertHash)
if err != nil {
return nil, fmt.Errorf("invalid pinned cert hash: %w", err)
}
}
if pin != nil {
if cfg.InsecureSkipVerify {
tlsCfg.InsecureSkipVerify = true
}
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
return verifyPin(rawCerts, pin)
}
} else if cfg.InsecureSkipVerify {
tlsCfg.InsecureSkipVerify = true
}
return tlsCfg, nil
}
// PinMismatchError indicates the server leaf certificate does not
// match the pinned SHA-256 fingerprint.
type PinMismatchError struct {
Expected []byte
Got []byte
}
func (e *PinMismatchError) Error() string {
return fmt.Sprintf("certificate pin mismatch: expected %x, got %x", e.Expected, e.Got)
}
// IsTLSError reports whether err represents a TLS certificate
// verification failure that will not resolve on retry.
func IsTLSError(err error) bool {
var certInvalid *x509.CertificateInvalidError
if errors.As(err, &certInvalid) {
return true
}
var hostErr *x509.HostnameError
if errors.As(err, &hostErr) {
return true
}
var unknownAuth *x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return true
}
var verifyErr *tls.CertificateVerificationError
if errors.As(err, &verifyErr) {
return true
}
var pinErr *PinMismatchError
if errors.As(err, &pinErr) {
return true
}
return false
}
func buildCertPool(pemData, path string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
if pemData != "" {
if !pool.AppendCertsFromPEM([]byte(pemData)) {
return nil, errors.New("failed to parse inline CA certificate PEM")
}
}
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read CA cert file %s: %w", path, err)
}
if !pool.AppendCertsFromPEM(data) {
return nil, errors.New("failed to parse CA certificate file")
}
}
return pool, nil
}
func decodePin(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "sha256:") {
s = s[len("sha256:"):]
}
pin, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(pin) != sha256.Size {
return nil, fmt.Errorf("expected %d bytes, got %d", sha256.Size, len(pin))
}
return pin, nil
}
func verifyPin(rawCerts [][]byte, pin []byte) error {
if len(rawCerts) == 0 {
return errors.New("no server certificate provided")
}
h := sha256.Sum256(rawCerts[0])
if !bytes.Equal(h[:], pin) {
return &PinMismatchError{Expected: pin, Got: h[:]}
}
return nil
}
+19 -5
View File
@@ -36,6 +36,7 @@ type HandshakeConfig struct {
Username string // for password auth (method B), or fallback
Password string // for password auth (method B), or fallback
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost)
}
// Conn is an established VPN tunnel connection.
@@ -57,7 +58,9 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
WriteBufferSize: 4096, // match server (handler.go:18)
}
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 {
+4
View File
@@ -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.
+10
View File
@@ -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))
}
+9 -1
View File
@@ -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))
}
+157
View File
@@ -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.
+53 -44
View File
@@ -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
@@ -74,6 +84,7 @@ func Run() {
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() {
@@ -232,11 +233,15 @@ func (a *App) onResetDB() {
a.loadProfiles()
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.ipLabel.SetText(i18n.T("IpNone"))
a.ip6Label.SetText(i18n.T("Ip6None"))
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
a.rxLabel.SetText(i18n.T("RxZero"))
a.txLabel.SetText(i18n.T("TxZero"))
a.connectBtn.Enable()
a.disconnectBtn.Disable()
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
a.txV4Label.SetText(i18n.T("TxV4Zero"))
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
a.txV6Label.SetText(i18n.T("TxV6Zero"))
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
a.setConnButtons(true, false)
}, a.window).Show()
}
@@ -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"))
}
+29 -32
View File
@@ -3,13 +3,13 @@ package ui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"lmvpn/internal/ipc"
"lmvpn/internal/log"
"lmvpn/internal/paths"
"lmvpn/internal/version"
)
// ensureDaemon checks if the daemon is running and launches it (as
@@ -25,9 +25,27 @@ import (
// The --daemon-bin flag tells daemon-launch where to find lmvpnd. If
// not given, daemon-launch auto-detects it relative to its own path.
func ensureDaemon() (*ipc.Client, error) {
// Fast path: daemon already running.
// Fast path: a daemon is already running. Verify its build version
// matches ours; if not, it's a stale process from a previous build
// and must be restarted so the new code takes effect.
if c, err := ipc.Dial(); err == nil {
if ver, qerr := c.QueryVersion(); qerr == nil && ver == version.Version {
return c, nil
} else {
log.L().Info("stale daemon detected, restarting",
"got", ver, "expected", version.Version, "query_err", qerr)
_ = ipc.SendShutdown(c)
c.Close()
// Wait for the old daemon to exit and release the socket.
for i := 0; i < 30; i++ {
time.Sleep(100 * time.Millisecond)
if cc, err := ipc.Dial(); err == nil {
cc.Close()
} else {
break // socket gone
}
}
}
}
exe, err := os.Executable()
@@ -41,7 +59,7 @@ func ensureDaemon() (*ipc.Client, error) {
}
// Compute the daemon binary path: same directory as the GUI binary.
daemonBin := filepath.Join(filepath.Dir(exe), "lmvpnd")
daemonBin := filepath.Join(filepath.Dir(exe), daemonBinaryName)
if _, err := os.Stat(daemonBin); err != nil {
return nil, fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err)
}
@@ -50,44 +68,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
}
+44
View File
@@ -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
}
+28
View File
@@ -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
}
+44
View File
@@ -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
}
+3
View File
@@ -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

+177 -4
View File
@@ -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"
)
@@ -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
@@ -215,6 +286,10 @@ func (a *App) saveProfile(editing *model.ServerProfile,
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()
}
+16 -7
View File
@@ -46,6 +46,19 @@ func (a *App) setupTray() {
autoItem, enItem, zhItem,
)
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
a.onConnect()
})
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
a.onDisconnect()
})
if a.connectBtn != nil {
connectItem.Disabled = a.connectBtn.Disabled()
}
if a.disconnectBtn != nil {
disconnectItem.Disabled = a.disconnectBtn.Disabled()
}
menu := fyne.NewMenu(i18n.T("WindowTitle"),
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
showDockIcon()
@@ -53,17 +66,13 @@ func (a *App) setupTray() {
a.window.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
a.onConnect()
}),
fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
a.onDisconnect()
}),
connectItem,
disconnectItem,
fyne.NewMenuItemSeparator(),
langItem,
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem(i18n.T("TrayQuit"), func() {
a.fyneApp.Quit()
a.quit()
}),
)
deskApp.SetSystemTrayMenu(menu)
+5 -1
View File
@@ -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
+144 -43
View File
@@ -1,7 +1,9 @@
package ui
import (
_ "embed"
"fmt"
"net/url"
"time"
"lmvpn/internal/i18n"
@@ -11,14 +13,35 @@ 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()
}
a.setupTray()
}
// buildMainWindow creates the main application window layout.
func (a *App) buildMainWindow() fyne.CanvasObject {
// Profile selector.
@@ -30,15 +53,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 +78,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 +116,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,8 +126,7 @@ 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
}
@@ -107,8 +143,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
}
@@ -132,13 +167,16 @@ func (a *App) onConnect() {
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
}
@@ -157,8 +195,7 @@ func (a *App) onDisconnect() {
return
}
_ = ipc.SendStop(client)
a.disconnectBtn.Disable()
a.connectBtn.Enable()
a.setConnButtons(true, false)
a.stateLabel.SetText(i18n.T("StateDisconnected"))
}
@@ -181,11 +218,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
@@ -204,38 +245,60 @@ func (a *App) eventLoop() {
}
case ipc.EvError:
fyne.Do(func() {
if ev.Message != "" {
showError(i18n.T("DlgVPNError"), ev.Message, a.window)
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 +307,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 +352,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)
+15
View File
@@ -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"
+191 -10
View File
@@ -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,31 @@ type SessionManager struct {
dev tun.Device
routeMgr *route.Manager
conn *transport.Conn
// 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,
}
}
@@ -114,7 +138,12 @@ func (sm *SessionManager) Disconnect() {
// 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 func() {
if !fatal {
sm.setState(stats.StateDisconnected)
}
}()
backoff := time.Second
maxBackoff := 60 * time.Second
@@ -141,6 +170,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 +228,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 +275,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 +304,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 +328,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 +355,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 +398,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 +427,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 +438,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 +476,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 +503,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 +563,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 +579,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)
}
}
}
+2 -2
View File
@@ -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>
+51
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
LMVPN ICON "resources/icon.ico"
Binary file not shown.