33 Commits
Author SHA1 Message Date
kevin 1c4906c266 chore: bump version to 0.6.1
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
2026-07-09 09:46:45 +08:00
kevin ebe082500f perf: 路由批量执行+CIDR聚合+并行脚本+连接步骤显示+CIDR刷新按钮
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
路由性能优化:
- 修复 macOS route 命令语法错误(去掉 -net 参数)
- 路由应用拆分为 Essential(ready前,4-6条命令<1s)+ Deferred(ready后,批量脚本)
- 新增 CIDR 聚合算法(cidrmerge.go),合并相邻 CIDR 块减少路由数量(10826->7500)
- 批量脚本执行:3平台各实现8个批量函数,临时脚本单次执行替代逐条进程创建
- 并行批量:拆为4个子shell并行执行,总时间降为1/4

连接步骤显示:
- stats 新增 connectStep + cidrError 原子变量
- 连接生命周期中设置步骤(fetch_cidrs/connecting/load_routes),UI 实时显示
- after-proxy CIDR 获取期间显示加载状态,获取失败显示错误提示

CIDR 刷新按钮:
- ipc 新增 CmdRefreshCIDR 命令
- daemon 处理刷新请求,session.RefreshCIDRs() 重新获取+添加路由
- UI 状态卡新增刷新按钮,proxy/bypass 模式已连接时显示

README:
- 补充 URL 获取时机注意事项(bypass模式after-proxy可能失败)
- 补充 CIDR 聚合与批量执行说明
2026-07-09 09:44:49 +08:00
kevin 7c49ae4d72 up 2026-07-09 08:22:16 +08:00
kevin 7b4289ae00 feat: 路由模式重构(full/proxy/bypass) + CIDR动态URL获取 + 命中统计 + 连接稳定性修复
路由模式与CIDR配置重构:
- 路由模式从 full/split/custom 改为 full/proxy/bypass,移除 split 和 custom
- CIDR 按 IPv4/IPv6 分开配置,支持静态 CIDR + URL 动态获取
- URL 支持"代理前获取"(直连)和"代理后获取"(走隧道)两种时机
- 数据库迁移 v5 自动转换旧数据(custom_cidrs 拆分为 cidr_v4/v6,custom->proxy,split->full)

CIDR命中统计:
- 状态栏显示当前路由模式及 IPv4/IPv6 CIDR 命中数
- 代理模式: 显示命中/总数; 绕过模式: 显示已配置数 + 未命中目的地址数
- cidrTracker 在 pumpPackets 中逐包检查出站目的IP

连接稳定性修复(多轮):
- transport.Connect 添加 ctx 监听 goroutine,取消时关闭WS中断阻塞的ReadMessage
- auth.Login 加 context.Context 参数,可被 cancel 中断
- Disconnect() 先删路由再关TUN,避免断网窗口
- startSession 在 Connect 前释放 d.mu,避免锁持有过久
- onConnect 移除 SendStop 消除竞态
- before-proxy CIDR 获取移到 run 循环前,并行获取+5s超时,重连复用
- cidrTracker 加 RWMutex 防数据竞争

UI修复:
- CIDR URL 输入框改为水平滚动+纵向布局,防止撑宽窗口
- 路由模式为full时隐藏CIDR配置区域
2026-07-09 08:21:47 +08:00
kevin 15af9ef72c fix: 移除showAndActivate避免误显示SystrayMonitor窗口
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
回退绕过GLFW遍历所有NSWindow的失败方案,恢复使用activateApp()+
showDockIcon()+window.Show()。保留真正生效的修复:正确selector
applicationShouldHandleReopen:hasVisibleWindows: + SetOnStarted延迟注册
2026-07-09 00:49:53 +08:00
kevin 96b1a8839c feat: 单实例锁与窗口恢复机制
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- GUI 单实例锁: 新增 internal/instance 包,首实例监听专用端点,
  第二实例发送 focus 信号后静默退出 (Unix: gui.sock, Win: 18924)
- 守护进程弱锁修复: NewServer() 在 os.Remove 前先 net.Dial 探测,
  有活进程时拒绝启动,避免静默抢占 socket 导致孤儿进程
- macOS .app 重开恢复: 通过 class_addMethod 给 GLFWApplicationDelegate
  注册 applicationShouldHandleReopen:hasVisibleWindows: 回调,
  绕过 GLFW 直接用 Cocoa makeKeyAndOrderFront: 显示窗口
- 三平台覆盖: macOS/Linux 用 unix socket, Windows 用 TCP 端口
2026-07-09 00:29:21 +08:00
kevin c56fc0c21c fix: CDN优选IP支持IPv6、格式校验及错误降级
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- replaceHost() 自动为裸 IPv6 地址包裹方括号,修复 IPv6 URL 畸形问题
- 新增 ValidateServerIPs() 校验 IP 格式,保存配置时拦截非法输入
- GetServerIPList() 运行时自动过滤非法 IP 条目
- CDN IP 尝试时 TLS/认证错误降级为非致命,跳过该 IP 继续故障转移
  而非终止整个连接循环(仅原始域名 ipIndex==0 时才判为致命)
- 占位符提示支持 IPv4/IPv6
2026-07-08 23:09:26 +08:00
kevin bb4e4552d8 feat: 记住上次选中的配置,下次启动自动选中
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- 启用 AppConfig.DefaultProfileID 字段(此前声明但未使用)
- 启动时 loadProfiles() 优先选中上次记录的配置,找不到则回退到第一个
- 切换配置时立即持久化到 config.yml,防止崩溃丢失
- 使用 Profile ID 而非名称,避免重命名后失效
2026-07-08 21:05:32 +08:00
kevin 3e7df0f4d8 fix: 修复连接状态下切换配置后断开重连导致异常的问题
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
问题现象:连接中切换配置 -> 断开 -> 连接,会出现连接后瞬间断开、
长时间无法重连。而先断开再切换配置则正常。

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

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

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

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

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

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

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

view.go:
- onDisconnect 置 ipcClient=nil + client.Close() 终止僵尸 eventLoop
- eventLoop 三个事件分支均加 if current == client 身份校验
- onConnect 覆盖前清理旧 IPC client
- setConnButtons 连接时禁用配置下拉框
2026-07-08 20:57:50 +08:00
kevin bf4744bb1d feat: 添加服务端证书合法性验证(自定义CA/跳过验证/证书固定)
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
- 新增 internal/tlsconfig 包,集中构建 TLS 配置
- 修复 CDN 边缘 IP 故障转移时 HTTP 登录 TLS 验证失败的问题
- 支持自定义 CA 证书(内联 PEM + 文件路径,合并生效)
- 支持 InsecureSkipVerify 跳过证书验证
- 支持证书固定(SHA-256 指纹校验)
- TLS 验证错误设为不可恢复,避免无限重试
- Profile 编辑界面新增 TLS 设置区域,协议联动启用/禁用
- DB schema v4 迁移,新增 4 个 TLS 字段
2026-07-08 11:33:36 +08:00
kevin 97cd9d4553 fix: 提交 Windows 图标资源(icon.ico/.syso)修复 CI
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
2026-07-07 20:40:48 +08:00
kevin 56c802c385 fix: 禁用 MSYS 路径转换修复 ISCC /D 参数 2026-07-07 20:30:16 +08:00
kevin 6026beb107 fix: 修正 macOS ditto 打包选项为 --keepParent 2026-07-07 20:22:22 +08:00
kevin 8c903860c2 docs: README 添加服务端项目地址与 CI 说明 2026-07-07 20:13:50 +08:00
kevin 7abbf9e6cc feat: 添加 GitHub Actions 多平台自动发布工作流 2026-07-07 20:12:09 +08:00
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
66 changed files with 6196 additions and 554 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.6.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"
+353
View File
@@ -0,0 +1,353 @@
# LMVPN Client
LMVPN 是一个基于 WebSocket 隧道与 TUN 虚拟网卡的**三层(网络层)VPN 客户端**。客户端通过 WebSocket`ws://`/`wss://`)连接服务端,完成认证后在本地 TUN 虚拟网卡与 WebSocket 连接之间双向转发原始 IP 数据包,实现透明隧道。
客户端使用 [Go](https://go.dev/) + [Fyne](https://fyne.io/) 开发,支持 macOS、Windows、Linux 三大桌面平台。
> **服务端项目**<https://github.com/wuwenfengmi1998/lmvpn_server>
---
## 目录
- [功能特性](#功能特性)
- [系统要求](#系统要求)
- [各平台编译](#各平台编译)
- [通用前置依赖](#通用前置依赖)
- [macOS](#macos)
- [Windows(交叉编译)](#windows交叉编译)
- [Linux](#linux)
- [Make 目标速查表](#make-目标速查表)
- [运行使用](#运行使用)
- [配置与数据目录](#配置与数据目录)
- [架构概览](#架构概览)
- [开发指南](#开发指南)
- [注意事项](#注意事项)
---
## 功能特性
- **双进程架构**GUI`lmvpn`,普通用户)+ 守护进程(`lmvpnd`,root/管理员),自动拉起与生命周期管理
- **多种认证**:JWT 令牌 / 用户名密码
- **隧道模式**:全隧道、代理 CIDR(指定 CIDR 走隧道)、绕过 CIDR(指定 CIDR 绕过隧道),支持 IPv4/IPv6 分开配置与 URL 动态获取 CIDR 列表
- **URL 获取时机**:代理前(直连获取,适用于 GitHub 等外部源)或代理后(通过隧道获取,适用于 VPN 服务器可达的源)
- **注意**:绕过 CIDR 模式下"代理后获取"可能失败——/1 覆盖路由会将 HTTP 请求导入隧道,若 VPN 服务器无法访问目标 URL 则超时。建议将 GitHub 等外部源设置为"代理前获取"
- **CIDR 聚合**:自动合并相邻 CIDR 块以减少路由数量,配合批量脚本并行执行加速路由添加
- **实时统计**:状态栏显示路由模式、CIDR 命中数、加载进度;支持手动刷新 CIDR 列表
- **多服务器管理**:配置文件 + SQLite 存储多个服务器配置(Profile)
- **国际化**:中文(简体)、英文,跟随系统语言
- **安全存储**macOS Keychain / Windows Credential Manager 加密保存凭据
- **系统集成**:系统托盘图标、Dock 显隐、开机自启
- **日志轮转**:基于 lumberjack 的文件日志自动轮转
---
## 系统要求
| 平台 | 最低版本 | 架构 | 备注 |
|------|---------|------|------|
| macOS | 11.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/ # 路由管理(全隧道/代理CIDR/绕过CIDR)
│ ├── stats/ # 流量统计
│ ├── transport/ # WebSocket 传输层
│ ├── tun/ # TUN 虚拟网卡(darwin/linux/windows
│ ├── ui/ # Fyne 界面、托盘、提权、Dock
│ ├── version/ # 版本号(链接期注入)
│ └── vpn/ # VPN 会话管理
├── resources/ # 图标、Info.plist、wintun.dll、资源生成器
├── installer/ # Inno Setup 安装脚本(lmvpn.iss
├── docs/ # 开发文档(协议规范)
└── Makefile
```
### 平台适配约定
项目使用 Go 文件后缀构建约束实现平台适配,主要分布:
| 模块 | macOS | Windows | Linux |
|------|-------|---------|-------|
| `tun` | `tun_darwin.go`water/utun | `tun_windows.go`wintun + embed dll | `tun_linux.go`water/tun |
| `route` | `route_darwin.go``route` | `route_windows.go``route` | `route_linux.go``ip route` |
| `keychain` | `keychain_darwin.go`Keychain | `keychain_windows.go`Credential Manager | `keychain_other.go`(内存) |
| `paths` | `paths_darwin.go` | `paths_windows.go` | `paths_other.go`XDG |
| `daemon` 提权 | `launch_unix.go` + osascript | `launch_windows.go` + UAC runas | `launch_unix.go` + pkexec |
| `ui` Dock | `dock_darwin.go`Cocoa CGO | `dock_other.go`no-op | `dock_other.go`no-op |
---
## 开发指南
```bash
# 静态检查
make vet
# 格式化
make fmt
# 整理依赖
make tidy
# 清理构建产物
make clean
```
### 添加新平台适配
如需适配新的平台能力,参考现有约定新增带构建约束的文件,例如:
- TUN 实现 → `internal/tun/tun_<os>.go`
- 路由命令 → `internal/route/route_<os>.go`
- 密钥存储 → `internal/keychain/keychain_<os>.go`
- 路径解析 → `internal/paths/paths_<os>.go`
### 协议规范
客户端与服务端的完整通信协议见 [`docs/client-development.md`](docs/client-development.md)(含认证、握手、数据面、心跳、错误码等)。
---
## 注意事项
- **不支持移动端**:虽 Fyne 支持 Android/iOS,本项目未配置移动端构建目标与适配代码
- **Windows 仅 x64**:交叉编译仅目标 `GOARCH=amd64`,内置的 `wintun.dll` 为 x64 版本
- **Linux 密钥链待完善**:当前为内存存储,凭据不持久化,生产环境建议接入 Secret Service
- **CI/CD**:通过 GitHub Actions 自动构建 macOS / Windows 产物并发布 Release(见 [`.github/workflows/release.yml`](.github/workflows/release.yml)),打 `v*` tag 即触发
- **CGO 必开**:任何平台都不可关闭 CGO,否则 Fyne 无法编译
- **版本一致性**:GUI 与守护进程共享同一版本字符串,若版本不一致通常意味着旧守护进程仍在运行
---
## 许可证
详见仓库 LICENSE 文件。
Binary file not shown.
Binary file not shown.
+80 -23
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;
+22 -2
View File
@@ -6,6 +6,8 @@ package auth
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
@@ -41,7 +43,15 @@ 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).
//
// ctx allows cancellation of the HTTP request (e.g. when the VPN
// session is disconnected while login is in flight).
func Login(ctx context.Context, baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) {
body, err := json.Marshal(loginRequest{Username: username, Password: password})
if err != nil {
return nil, err
@@ -49,7 +59,17 @@ func Login(baseURL, username, password string) (*LoginResult, error) {
url := strings.TrimRight(baseURL, "/") + "/api/login"
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
if tlsCfg != nil {
client.Transport = &http.Transport{TLSClientConfig: tlsCfg}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create login request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("login request: %w", err)
}
+173
View File
@@ -0,0 +1,173 @@
// Package cidrsource fetches CIDR lists from URLs. CIDR lists can be
// fetched before routing is applied (via a direct connection) or after
// the tunnel is established (via the tunnel itself).
//
// The expected format is one CIDR per line. Empty lines and lines
// starting with '#' are treated as comments and skipped. Invalid CIDR
// entries are silently ignored (with a debug log entry).
package cidrsource
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"lmvpn/internal/log"
"lmvpn/internal/model"
)
// fetchTimeout is the total timeout for all before-proxy fetches
// combined. It must be well under the server's 30s ReadyTimeout since
// before-proxy fetching happens before the WS handshake begins.
const fetchTimeout = 5 * time.Second
// FetchBeforeProxy fetches all CIDR URL sources with FetchTiming ==
// "before". These are fetched via the system's direct connection
// (before routing is applied), so no special proxy handling is needed.
// The context allows cancellation (e.g. session teardown).
//
// All matching sources are fetched concurrently with a shared 5s
// deadline so a single slow URL does not block the entire session.
func FetchBeforeProxy(ctx context.Context, sources []model.CIDRURLSource) ([]string, error) {
return fetchSources(ctx, sources, model.FetchBefore)
}
// FetchAfterProxy fetches all CIDR URL sources with FetchTiming ==
// "after". These are fetched via the tunnel after the data plane is up.
// The HTTP client uses the default dialer, which respects the system
// routing table - so when full-tunnel or bypass routes are in effect,
// the request goes through the TUN interface.
//
// Sources are fetched concurrently with a 15s deadline (the tunnel is
// already up, so there is no ReadyTimeout pressure).
func FetchAfterProxy(ctx context.Context, sources []model.CIDRURLSource) ([]string, error) {
return fetchSources(ctx, sources, model.FetchAfter)
}
// fetchSources fetches all sources matching the given timing
// concurrently, bounded by a shared deadline derived from fetchTimeout
// (for "before") or 15s (for "after").
func fetchSources(ctx context.Context, sources []model.CIDRURLSource, timing model.FetchTiming) ([]string, error) {
// Collect matching sources.
var matching []model.CIDRURLSource
for _, src := range sources {
if src.FetchTiming == timing {
matching = append(matching, src)
}
}
if len(matching) == 0 {
return nil, nil
}
// Shared deadline for all fetches.
deadline := fetchTimeout
if timing == model.FetchAfter {
deadline = 15 * time.Second
}
fetchCtx, cancel := context.WithTimeout(ctx, deadline)
defer cancel()
type result struct {
cidrs []string
err error
url string
}
results := make(chan result, len(matching))
for _, src := range matching {
go func(s model.CIDRURLSource) {
cidrs, err := fetchOne(fetchCtx, s.URL)
results <- result{cidrs: cidrs, err: err, url: s.URL}
}(src)
}
var allCIDRs []string
var errs []string
for range matching {
r := <-results
if r.err != nil {
label := "before-proxy"
if timing == model.FetchAfter {
label = "after-proxy"
}
log.L().Error("fetch "+label+" CIDR list failed (continuing)",
"url", r.url, "error", r.err)
errs = append(errs, fmt.Sprintf("%s: %v", r.url, r.err))
continue
}
label := "before-proxy"
if timing == model.FetchAfter {
label = "after-proxy"
}
log.L().Info("fetched "+label+" CIDR list",
"url", r.url, "count", len(r.cidrs))
allCIDRs = append(allCIDRs, r.cidrs...)
}
if len(allCIDRs) == 0 && len(errs) > 0 {
return nil, fmt.Errorf("all CIDR URL fetches failed: %s", strings.Join(errs, "; "))
}
return allCIDRs, nil
}
// fetchOne fetches a single URL and parses the response as a CIDR list.
func fetchOne(ctx context.Context, url string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB max
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return ParseCIDRList(string(body)), nil
}
// ParseCIDRList parses text into a list of valid CIDR strings. Each
// line is treated as a separate CIDR entry. Empty lines and lines
// starting with '#' are skipped. Invalid CIDR entries are silently
// ignored.
func ParseCIDRList(text string) []string {
var out []string
for _, line := range strings.Split(text, "\n") {
c := strings.TrimSpace(line)
if c == "" || strings.HasPrefix(c, "#") {
continue
}
_, _, err := net.ParseCIDR(c)
if err != nil {
log.L().Debug("ignoring invalid CIDR entry", "cidr", c)
continue
}
out = append(out, c)
}
return out
}
// ClassifyCIDRs splits a list of CIDR strings into IPv4 and IPv6 lists.
func ClassifyCIDRs(cidrs []string) (v4, v6 []string) {
for _, cidr := range cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if ipNet.IP.To4() != nil {
v4 = append(v4, cidr)
} else {
v6 = append(v6, cidr)
}
}
return v4, v6
}
+66 -7
View File
@@ -19,6 +19,7 @@ import (
"net"
"os"
"os/signal"
"sync"
"syscall"
"lmvpn/internal/ipc"
@@ -26,6 +27,7 @@ import (
"lmvpn/internal/model"
"lmvpn/internal/paths"
"lmvpn/internal/stats"
"lmvpn/internal/version"
"lmvpn/internal/vpn"
)
@@ -59,8 +61,8 @@ func Run(userHome string, uid, gid int) error {
// Chown the IPC socket so the non-root GUI process can connect.
// This is the critical fix: without it, the socket is owned by
// root:wheel with mode 0660, and the user cannot dial it.
chownToUser(paths.IPCSocketPath(), uid, gid)
log.L().Info("daemon listening", "socket", paths.IPCSocketPath())
chownToUser(paths.IPCAddress(), uid, gid)
log.L().Info("daemon listening", "socket", paths.IPCAddress())
d := &daemon{server: server}
@@ -91,6 +93,7 @@ func chownToUser(path string, uid, gid int) {
type daemon struct {
server *ipc.Server
mu sync.Mutex
session *vpn.SessionManager
cancel context.CancelFunc
}
@@ -108,23 +111,40 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) {
d.server.Close()
os.Exit(0)
case ipc.CmdStats:
if d.session != nil {
snap := d.session.Stats().Snapshot()
d.mu.Lock()
sess := d.session
d.mu.Unlock()
if sess != nil {
snap := sess.Stats().Snapshot()
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
}
_ = ipc.WriteOK(conn)
case ipc.CmdRefreshCIDR:
d.mu.Lock()
sess := d.session
d.mu.Unlock()
if sess != nil {
go sess.RefreshCIDRs()
_ = ipc.WriteOK(conn)
} else {
_ = ipc.WriteErr(conn, "no active session")
}
case ipc.CmdVersion:
_ = ipc.WriteVersion(conn, version.Version)
default:
_ = ipc.WriteErr(conn, "unknown command: "+req.Cmd)
}
}
func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
d.mu.Lock()
if req.Config == nil {
d.mu.Unlock()
_ = ipc.WriteErr(conn, "missing config")
return
}
if d.session != nil {
d.stopSession()
d.stopSessionLocked()
}
cfg := vpn.SessionConfig{
@@ -136,30 +156,54 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
Token: req.Config.Token,
AuthMode: model.AuthMode(req.Config.AuthMode),
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
CustomCIDRs: req.Config.CustomCIDRs,
CIDRV4: req.Config.CIDRV4,
CIDRV6: req.Config.CIDRV6,
CIDRV4URLs: convertIPCSources(req.Config.CIDRV4URLs),
CIDRV6URLs: convertIPCSources(req.Config.CIDRV6URLs),
MTUOverride: req.Config.MTUOverride,
TLSCACert: req.Config.TLSCACert,
TLSCAPath: req.Config.TLSCAPath,
TLSInsecure: req.Config.TLSInsecure,
TLSPinnedHash: req.Config.TLSPinnedHash,
}
ctx, cancel := context.WithCancel(context.Background())
d.cancel = cancel
d.session = vpn.New(
func(s stats.State) {
d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s)})
d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s), ConnectStep: d.session.Stats().ConnectStep()})
},
func(snap stats.Snapshot) {
s := snap
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &s})
},
func(code string, msg string) {
d.server.Broadcast(ipc.Event{Event: ipc.EvError, Code: code, Message: msg})
},
)
// Release the lock before Connect. Connect is non-blocking (it
// starts a goroutine), but holding the lock across it would
// serialize all IPC commands (CmdStats, CmdStop, etc.) behind the
// session startup.
d.mu.Unlock()
if err := d.session.Connect(ctx, cfg); err != nil {
_ = ipc.WriteErr(conn, "connect: "+err.Error())
d.mu.Lock()
d.session = nil
d.cancel = nil
d.mu.Unlock()
return
}
}
func (d *daemon) stopSession() {
d.mu.Lock()
defer d.mu.Unlock()
d.stopSessionLocked()
}
func (d *daemon) stopSessionLocked() {
if d.cancel != nil {
d.cancel()
d.cancel = nil
@@ -169,3 +213,18 @@ func (d *daemon) stopSession() {
d.session = nil
}
}
// convertIPCSources converts IPC CIDRURLSource values to model.CIDRURLSource.
func convertIPCSources(srcs []ipc.CIDRURLSource) []model.CIDRURLSource {
if len(srcs) == 0 {
return nil
}
out := make([]model.CIDRURLSource, len(srcs))
for i, s := range srcs {
out[i] = model.CIDRURLSource{
URL: s.URL,
FetchTiming: model.FetchTiming(s.FetchTiming),
}
}
return out
}
+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
}
+176 -1
View File
@@ -8,6 +8,7 @@ package db
import (
"database/sql"
"fmt"
"net"
"strings"
"lmvpn/internal/paths"
@@ -45,7 +46,16 @@ func (s *Store) migrate() error {
if err != nil {
return err
}
return s.migrateV2()
if err := s.migrateV2(); err != nil {
return err
}
if err := s.migrateV3(); err != nil {
return err
}
if err := s.migrateV4(); err != nil {
return err
}
return s.migrateV5()
}
func (s *Store) migrateV2() error {
@@ -165,6 +175,166 @@ func nullStr(s sql.NullString) sql.NullString {
return s
}
// migrateV3 adds the assigned_ip6 column to connection_logs for IPv6
// dual-stack support. Idempotent: skips if the column already exists.
func (s *Store) migrateV3() error {
if columnExists(s.db, "connection_logs", "assigned_ip6") {
return nil
}
_, err := s.db.Exec(`ALTER TABLE connection_logs ADD COLUMN assigned_ip6 TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("migrate v3 add assigned_ip6: %w", err)
}
return nil
}
// migrateV4 adds TLS certificate verification columns to
// server_profiles for custom CA, insecure mode, and cert pinning.
// Idempotent: skips columns that already exist.
func (s *Store) migrateV4() error {
cols := []struct {
name string
sql string
}{
{"tls_ca_cert", "ALTER TABLE server_profiles ADD COLUMN tls_ca_cert TEXT NOT NULL DEFAULT ''"},
{"tls_ca_path", "ALTER TABLE server_profiles ADD COLUMN tls_ca_path TEXT NOT NULL DEFAULT ''"},
{"tls_insecure", "ALTER TABLE server_profiles ADD COLUMN tls_insecure INTEGER NOT NULL DEFAULT 0"},
{"tls_pinned_hash", "ALTER TABLE server_profiles ADD COLUMN tls_pinned_hash TEXT NOT NULL DEFAULT ''"},
}
for _, c := range cols {
if !columnExists(s.db, "server_profiles", c.name) {
if _, err := s.db.Exec(c.sql); err != nil {
return fmt.Errorf("migrate v4 add %s: %w", c.name, err)
}
}
}
return nil
}
// migrateV5 replaces the single custom_cidrs column with separate
// cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls columns. It migrates
// existing routing modes: 'custom' -> 'proxy', 'split' -> 'full'.
// Existing custom_cidrs are split into v4/v6 based on address family.
// Idempotent: skips columns that already exist.
func (s *Store) migrateV5() error {
cols := []struct {
name string
sql string
}{
{"cidr_v4", "ALTER TABLE server_profiles ADD COLUMN cidr_v4 TEXT NOT NULL DEFAULT ''"},
{"cidr_v6", "ALTER TABLE server_profiles ADD COLUMN cidr_v6 TEXT NOT NULL DEFAULT ''"},
{"cidr_v4_urls", "ALTER TABLE server_profiles ADD COLUMN cidr_v4_urls TEXT NOT NULL DEFAULT ''"},
{"cidr_v6_urls", "ALTER TABLE server_profiles ADD COLUMN cidr_v6_urls TEXT NOT NULL DEFAULT ''"},
}
needMigration := false
for _, c := range cols {
if !columnExists(s.db, "server_profiles", c.name) {
needMigration = true
break
}
}
if !needMigration {
return nil
}
for _, c := range cols {
if !columnExists(s.db, "server_profiles", c.name) {
if _, err := s.db.Exec(c.sql); err != nil {
return fmt.Errorf("migrate v5 add %s: %w", c.name, err)
}
}
}
// Migrate existing custom_cidrs into cidr_v4 / cidr_v6 and update
// routing mode codes. Only process rows that still have a non-empty
// custom_cidrs or an old routing mode.
rows, err := s.db.Query(`SELECT id, routing_mode, custom_cidrs FROM server_profiles`)
if err != nil {
return fmt.Errorf("migrate v5 read rows: %w", err)
}
type row struct {
id int64
routingMode string
customCIDRs string
}
var toUpdate []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.routingMode, &r.customCIDRs); err != nil {
rows.Close()
return fmt.Errorf("migrate v5 scan: %w", err)
}
toUpdate = append(toUpdate, r)
}
rows.Close()
for _, r := range toUpdate {
newMode := r.routingMode
switch newMode {
case "custom":
newMode = "proxy"
case "split":
newMode = "full"
}
v4CIDRs, v6CIDRs := splitCIDRsByFamily(r.customCIDRs)
_, err := s.db.Exec(
`UPDATE server_profiles SET routing_mode = ?, cidr_v4 = ?, cidr_v6 = ? WHERE id = ?`,
newMode, v4CIDRs, v6CIDRs, r.id)
if err != nil {
return fmt.Errorf("migrate v5 update row %d: %w", r.id, err)
}
}
return nil
}
// splitCIDRsByFamily splits a comma-separated CIDR string into IPv4 and
// IPv6 parts. Used for migration from the old custom_cidrs column.
func splitCIDRsByFamily(customCIDRs string) (v4, v6 string) {
if customCIDRs == "" {
return "", ""
}
var v4Parts, v6Parts []string
for _, part := range strings.Split(customCIDRs, ",") {
c := strings.TrimSpace(part)
if c == "" {
continue
}
if _, ipNet, err := net.ParseCIDR(c); err == nil {
if ipNet.IP.To4() != nil {
v4Parts = append(v4Parts, c)
} else {
v6Parts = append(v6Parts, c)
}
}
}
return strings.Join(v4Parts, ", "), strings.Join(v6Parts, ", ")
}
// columnExists reports whether a column exists on a table.
func columnExists(db *sql.DB, table, column string) bool {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &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,
@@ -178,6 +348,10 @@ CREATE TABLE IF NOT EXISTS server_profiles (
auth_mode TEXT NOT NULL DEFAULT 'both',
routing_mode TEXT NOT NULL DEFAULT 'full',
custom_cidrs TEXT NOT NULL DEFAULT '',
cidr_v4 TEXT NOT NULL DEFAULT '',
cidr_v6 TEXT NOT NULL DEFAULT '',
cidr_v4_urls TEXT NOT NULL DEFAULT '',
cidr_v6_urls TEXT NOT NULL DEFAULT '',
mtu_override INTEGER NOT NULL DEFAULT 0,
auto_connect INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -190,6 +364,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 {
+30 -10
View File
@@ -14,11 +14,15 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
`INSERT INTO server_profiles
(name, protocol, host, server_ips, port, path,
username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode,
p.CustomCIDRs, p.MTUOverride, p.AutoConnect,
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash,
)
if err != nil {
return 0, fmt.Errorf("insert profile: %w", err)
@@ -36,11 +40,17 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
err := s.db.QueryRow(
`SELECT id, name, protocol, host, server_ips, port, path,
username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
created_at, last_connected_at
FROM server_profiles WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path,
&p.Username, &p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride,
&p.AutoConnect, &p.CreatedAt, &last)
&p.Username, &p.AuthMode, &p.RoutingMode,
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last)
if err != nil {
return nil, fmt.Errorf("get profile %d: %w", id, err)
}
@@ -55,7 +65,10 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
rows, err := s.db.Query(
`SELECT id, name, protocol, host, server_ips, port, path,
username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
cidr_v4, cidr_v6, cidr_v4_urls, cidr_v6_urls,
mtu_override, auto_connect,
tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash,
created_at, last_connected_at
FROM server_profiles ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
@@ -68,7 +81,10 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
var last sql.NullTime
if err := rows.Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs,
&p.Port, &p.Path, &p.Username, &p.AuthMode, &p.RoutingMode,
&p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect, &p.CreatedAt, &last); err != nil {
&p.CIDRV4, &p.CIDRV6, &p.CIDRV4URLs, &p.CIDRV6URLs,
&p.MTUOverride, &p.AutoConnect,
&p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash,
&p.CreatedAt, &last); err != nil {
return nil, err
}
if last.Valid {
@@ -85,11 +101,15 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error {
`UPDATE server_profiles SET
name = ?, protocol = ?, host = ?, server_ips = ?, port = ?, path = ?,
username = ?, auth_mode = ?, routing_mode = ?,
custom_cidrs = ?, mtu_override = ?, auto_connect = ?
cidr_v4 = ?, cidr_v6 = ?, cidr_v4_urls = ?, cidr_v6_urls = ?,
mtu_override = ?, auto_connect = ?,
tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ?
WHERE id = ?`,
p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path,
p.Username, p.AuthMode, p.RoutingMode,
p.CustomCIDRs, p.MTUOverride, p.AutoConnect, p.ID)
p.CIDRV4, p.CIDRV6, p.CIDRV4URLs, p.CIDRV6URLs,
p.MTUOverride, p.AutoConnect,
p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID)
if err != nil {
return fmt.Errorf("update profile %d: %w", p.ID, err)
}
+70 -9
View File
@@ -19,13 +19,37 @@ BtnCancel = "Cancel"
BtnOK = "OK"
IpLabel = "IP: {{.ip}}"
Ip6Label = "IPv6: {{.ip}}"
Ip6None = "IPv6: —"
IpNone = "IP: —"
UptimeLabel = "Uptime: {{.uptime}}"
UptimeNone = "Uptime: —"
RxLabel = "↓ {{.bytes}}"
TxLabel = "↑ {{.bytes}}"
RxZero = "↓ 0 B"
TxZero = "↑ 0 B"
RxV4Label = "IPv4 ↓ {{.bytes}} {{.speed}}"
TxV4Label = "↑ {{.bytes}} {{.speed}}"
RxV6Label = "IPv6 ↓ {{.bytes}} {{.speed}}"
TxV6Label = "↑ {{.bytes}} {{.speed}}"
RxTotalLabel = "Total ↓ {{.bytes}} {{.speed}}"
TxTotalLabel = "↑ {{.bytes}} {{.speed}}"
RxV4Zero = "IPv4 ↓ 0 B 0 bps"
TxV4Zero = "↑ 0 B 0 bps"
RxV6Zero = "IPv6 ↓ 0 B 0 bps"
TxV6Zero = "↑ 0 B 0 bps"
RxTotalZero = "Total ↓ 0 B 0 bps"
TxTotalZero = "↑ 0 B 0 bps"
StatusRoutingMode = "Routing Mode: {{.mode}}"
CIDRHit = "hit"
CIDRConfigured = "configured"
CIDRUnmatched = "unmatched"
CIDRDestinations = "destinations"
RouteLoading = "loading routes"
CIDRLoading = "loading"
StepFetchCIDRs = "fetching CIDR lists"
StepConnecting = "connecting"
StepLoadRoutes = "loading routes"
BtnRefreshCIDR = "Refresh CIDR"
CIDRFetchError = "fetch failed"
DlgNoProfileTitle = "No Profile"
DlgNoProfileEditMsg = "Select a profile to edit."
@@ -35,6 +59,7 @@ DlgDeleteProfileMsg = 'Delete profile "{{.name}}" and its stored credentials?'
DlgProfileTitle = "Profile"
DlgValidationTitle = "Validation"
DlgValidationMsg = "Name, Host, and Username are required."
DlgInvalidIPMsg = "Invalid IP address(es): {{.ips}}"
DlgDaemonError = "Daemon Error"
DlgCredentialError = "Credential Error"
DlgCredentialErrorMsg = "No password stored for this profile. Edit the profile to set it."
@@ -43,11 +68,23 @@ DlgVPNError = "VPN Error"
DlgSaveError = "Save Error"
DlgKeychainError = "Keychain Error"
DlgError = "Error"
DlgAuthError = "Authentication Failed"
AuthErrWrongCredentials = "Wrong username or password. Please check your credentials."
AuthErrUserDisabled = "This user account does not exist or has been disabled."
AuthErrTokenInvalid = "The authentication token is invalid or expired."
AuthErrRateLimited = "Too many authentication attempts. Please try again later."
AuthErrMalformed = "Authentication message format error."
BtnResetDB = "Reset Database"
DlgResetDBTitle = "Reset Database"
DlgResetDBMsg = "Delete all profiles and connection logs? This cannot be undone."
BtnProfileList = "Profile List"
ProfileListTitle = "Profile List"
DlgNoListSelectTitle = "No Selection"
DlgNoListSelectEditMsg = "Select a profile in the list to edit."
DlgNoListSelectDeleteMsg = "Select a profile in the list to delete."
TrayShowWindow = "Show Window"
TrayConnect = "Connect"
TrayDisconnect = "Disconnect"
@@ -65,18 +102,42 @@ FieldUsername = "Username"
FieldPassword = "Password"
FieldAuthMode = "Auth Mode"
FieldRoutingMode = "Routing Mode"
FieldCustomCIDRs = "Custom CIDRs (comma-separated)"
FieldCIDRV4 = "IPv4 CIDRs (comma-separated)"
FieldCIDRV6 = "IPv6 CIDRs (comma-separated)"
FieldCIDRV4URLs = "IPv4 CIDR URL Sources"
FieldCIDRV6URLs = "IPv6 CIDR URL Sources"
FieldMTUOverride = "MTU Override"
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
PlaceholderCIDRV4 = "10.0.0.0/8, 172.16.0.0/12"
PlaceholderCIDRV6 = "fd00::/8, 2001:db8::/32"
PlaceholderCIDRURL = "https://example.com/cidrs.txt"
PlaceholderMTU = "0 = use server MTU"
PlaceholderPasswordUnchanged = "(unchanged)"
PlaceholderServerIPs = "e.g. 1.2.3.4, 5.6.7.8"
PlaceholderServerIPs = "IPv4/IPv6 supported, e.g. 1.2.3.4, 5.6.7.8"
AuthModeBoth = "Both (JWT + Password)"
AuthModeJWT = "JWT"
AuthModePassword = "Password"
RoutingModeFull = "Full Tunnel"
RoutingModeSplit = "Split Tunnel"
RoutingModeCustom = "Custom"
RoutingModeProxy = "Proxy CIDR"
RoutingModeBypass = "Bypass CIDR"
FetchTimingBefore = "Before Proxy"
FetchTimingAfter = "After Proxy"
BtnAddURL = "Add URL"
BtnRemoveURL = "Remove"
FieldTLS = "TLS Certificate"
FieldTLSCACert = "CA Certificate (PEM)"
FieldTLSCAPath = "CA Certificate Path"
HintTLSCAReplacesSystem = "Setting a custom CA replaces system root certificates. Public CA servers (e.g. Let's Encrypt) will fail verification."
FieldTLSInsecure = "Skip Verification (Insecure)"
FieldTLSPinnedHash = "Certificate Pin (SHA-256)"
PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..."
PlaceholderTLSCAPath = "/path/to/ca.crt"
PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
BtnBrowse = "Browse..."
DlgTLSError = "TLS Certificate Error"
TLSErrorVerification = "Server certificate verification failed."
+70 -9
View File
@@ -19,13 +19,37 @@ BtnCancel = "取消"
BtnOK = "确定"
IpLabel = "IP: {{.ip}}"
Ip6Label = "IPv6: {{.ip}}"
Ip6None = "IPv6: —"
IpNone = "IP: —"
UptimeLabel = "运行时长: {{.uptime}}"
UptimeNone = "运行时长: —"
RxLabel = "↓ {{.bytes}}"
TxLabel = "↑ {{.bytes}}"
RxZero = "↓ 0 B"
TxZero = "↑ 0 B"
RxV4Label = "IPv4 ↓ {{.bytes}} {{.speed}}"
TxV4Label = "↑ {{.bytes}} {{.speed}}"
RxV6Label = "IPv6 ↓ {{.bytes}} {{.speed}}"
TxV6Label = "↑ {{.bytes}} {{.speed}}"
RxTotalLabel = "合计 ↓ {{.bytes}} {{.speed}}"
TxTotalLabel = "↑ {{.bytes}} {{.speed}}"
RxV4Zero = "IPv4 ↓ 0 B 0 bps"
TxV4Zero = "↑ 0 B 0 bps"
RxV6Zero = "IPv6 ↓ 0 B 0 bps"
TxV6Zero = "↑ 0 B 0 bps"
RxTotalZero = "合计 ↓ 0 B 0 bps"
TxTotalZero = "↑ 0 B 0 bps"
StatusRoutingMode = "路由模式: {{.mode}}"
CIDRHit = "命中"
CIDRConfigured = "已配置"
CIDRUnmatched = "未命中"
CIDRDestinations = "目的地址"
RouteLoading = "加载路由中"
CIDRLoading = "加载中"
StepFetchCIDRs = "获取CIDR列表"
StepConnecting = "建立连接"
StepLoadRoutes = "加载路由"
BtnRefreshCIDR = "刷新 CIDR"
CIDRFetchError = "获取失败"
DlgNoProfileTitle = "无配置"
DlgNoProfileEditMsg = "请选择要编辑的配置。"
@@ -35,6 +59,7 @@ DlgDeleteProfileMsg = '删除配置"{{.name}}"及其存储的凭据?'
DlgProfileTitle = "配置"
DlgValidationTitle = "验证"
DlgValidationMsg = "名称、主机名和用户名为必填项。"
DlgInvalidIPMsg = "以下 IP 地址格式无效:{{.ips}}"
DlgDaemonError = "守护进程错误"
DlgCredentialError = "凭据错误"
DlgCredentialErrorMsg = "此配置未存储密码。请编辑配置以设置密码。"
@@ -43,11 +68,23 @@ DlgVPNError = "VPN 错误"
DlgSaveError = "保存错误"
DlgKeychainError = "钥匙串错误"
DlgError = "错误"
DlgAuthError = "认证失败"
AuthErrWrongCredentials = "用户名或密码错误,请检查凭据。"
AuthErrUserDisabled = "用户不存在或已被禁用。"
AuthErrTokenInvalid = "认证令牌无效或已过期。"
AuthErrRateLimited = "认证尝试过于频繁,请稍后再试。"
AuthErrMalformed = "认证消息格式错误。"
BtnResetDB = "重置数据库"
DlgResetDBTitle = "重置数据库"
DlgResetDBMsg = "删除所有配置和连接记录?此操作无法撤销。"
BtnProfileList = "配置列表"
ProfileListTitle = "配置列表"
DlgNoListSelectTitle = "无选择"
DlgNoListSelectEditMsg = "请在列表中选择一个配置。"
DlgNoListSelectDeleteMsg = "请在列表中选择一个配置。"
TrayShowWindow = "显示窗口"
TrayConnect = "连接"
TrayDisconnect = "断开连接"
@@ -65,18 +102,42 @@ FieldUsername = "用户名"
FieldPassword = "密码"
FieldAuthMode = "认证方式"
FieldRoutingMode = "路由模式"
FieldCustomCIDRs = "自定义 CIDR(逗号分隔)"
FieldCIDRV4 = "IPv4 CIDR(逗号分隔)"
FieldCIDRV6 = "IPv6 CIDR(逗号分隔)"
FieldCIDRV4URLs = "IPv4 CIDR URL 来源"
FieldCIDRV6URLs = "IPv6 CIDR URL 来源"
FieldMTUOverride = "MTU 覆盖"
PlaceholderCIDRs = "10.0.0.0/8, 172.16.0.0/12"
PlaceholderCIDRV4 = "10.0.0.0/8, 172.16.0.0/12"
PlaceholderCIDRV6 = "fd00::/8, 2001:db8::/32"
PlaceholderCIDRURL = "https://example.com/cidrs.txt"
PlaceholderMTU = "0 = 使用服务器 MTU"
PlaceholderPasswordUnchanged = "(未更改)"
PlaceholderServerIPs = "例: 1.2.3.4, 5.6.7.8"
PlaceholderServerIPs = "支持 IPv4/IPv6例: 1.2.3.4, 5.6.7.8"
AuthModeBoth = "全部(JWT + 密码)"
AuthModeJWT = "JWT"
AuthModePassword = "密码"
RoutingModeFull = "全隧道"
RoutingModeSplit = "分离隧道"
RoutingModeCustom = "自定义"
RoutingModeProxy = "代理 CIDR"
RoutingModeBypass = "绕过 CIDR"
FetchTimingBefore = "代理前获取"
FetchTimingAfter = "代理后获取"
BtnAddURL = "添加 URL"
BtnRemoveURL = "删除"
FieldTLS = "TLS 证书"
FieldTLSCACert = "CA 证书 (PEM)"
FieldTLSCAPath = "CA 证书路径"
HintTLSCAReplacesSystem = "设置自定义 CA 将替换系统根证书,使用公共 CA(如 Let's Encrypt)的服务器将验证失败。"
FieldTLSInsecure = "跳过验证 (不安全)"
FieldTLSPinnedHash = "证书固定 (SHA-256)"
PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..."
PlaceholderTLSCAPath = "/path/to/ca.crt"
PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
BtnBrowse = "浏览..."
DlgTLSError = "TLS 证书错误"
TLSErrorVerification = "服务器证书验证失败。"
+81
View File
@@ -0,0 +1,81 @@
// Package instance implements single-instance enforcement for the GUI
// process. The first instance to start acquires the lock by listening
// on a dedicated endpoint; subsequent instances dial the endpoint
// (signalling "bring to front") and exit immediately.
//
// The lock is self-cleaning: a crashed process releases the listener
// automatically (the kernel closes the socket). On unix a stale socket
// file may remain; Acquire probes it with a dial before removing.
package instance
import (
"errors"
"fmt"
"net"
"os"
"lmvpn/internal/paths"
)
// ErrAlreadyRunning is returned by Acquire when another GUI instance
// is already running. The caller should exit silently.
var ErrAlreadyRunning = errors.New("another instance is already running")
// Acquire attempts to become the sole GUI instance. On success it
// returns a channel that receives a value every time another instance
// signals (the caller should bring its window to the front). The
// listener is closed when the channel is closed, which happens never
// during normal operation - the process simply exits and the OS
// releases the socket.
//
// On failure it returns ErrAlreadyRunning (the caller should exit) or
// another error (the caller may continue without single-instance
// protection, logging the issue).
func Acquire() (<-chan struct{}, error) {
netType := paths.GUILockNetwork()
addr := paths.GUILockAddress()
l, err := net.Listen(netType, addr)
if err == nil {
ch := make(chan struct{}, 1)
go acceptLoop(l, ch)
return ch, nil
}
// Listen failed - check whether another instance is alive.
if c, derr := net.Dial(netType, addr); derr == nil {
c.Close()
return nil, ErrAlreadyRunning
}
// Nobody is listening. On unix this is likely a stale socket file
// left by a crashed process; remove it and retry once.
if netType == "unix" {
_ = os.Remove(addr)
l, err = net.Listen(netType, addr)
if err == nil {
ch := make(chan struct{}, 1)
go acceptLoop(l, ch)
return ch, nil
}
}
return nil, fmt.Errorf("acquire instance lock: %w", err)
}
// acceptLoop accepts connections from second instances. Each connection
// is a "focus" signal; the handler closes it immediately and notifies
// the caller via ch.
func acceptLoop(l net.Listener, ch chan<- struct{}) {
for {
conn, err := l.Accept()
if err != nil {
return
}
conn.Close()
select {
case ch <- struct{}{}:
default:
}
}
}
+78 -12
View File
@@ -29,6 +29,8 @@ const (
CmdStop = "stop"
CmdShutdown = "shutdown"
CmdStats = "stats"
CmdVersion = "version" // query daemon build version
CmdRefreshCIDR = "refresh_cidr" // re-fetch CIDR URL sources and update routes
)
// Event types sent from daemon to GUI.
@@ -55,16 +57,33 @@ type ClientConfig struct {
Password string `json:"password"`
Token string `json:"token"`
AuthMode string `json:"auth_mode"`
RoutingMode string `json:"routing_mode"`
CustomCIDRs []string `json:"custom_cidrs"`
RoutingMode string `json:"routing_mode"` // "full", "proxy", "bypass"
CIDRV4 []string `json:"cidr_v4"` // static IPv4 CIDRs
CIDRV6 []string `json:"cidr_v6"` // static IPv6 CIDRs
CIDRV4URLs []CIDRURLSource `json:"cidr_v4_urls"` // IPv4 CIDR URL sources
CIDRV6URLs []CIDRURLSource `json:"cidr_v6_urls"` // IPv6 CIDR URL sources
MTUOverride int `json:"mtu_override"`
TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only)
TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only)
}
// CIDRURLSource describes a URL that provides a CIDR list. It mirrors
// model.CIDRURLSource but is kept here to avoid importing the model
// package into the IPC wire format.
type CIDRURLSource struct {
URL string `json:"url"`
FetchTiming string `json:"fetch_timing"` // "before" or "after"
}
// Event is a notification from the daemon to the GUI.
type Event struct {
Event string `json:"event"`
State string `json:"state,omitempty"`
ConnectStep string `json:"connect_step,omitempty"` // current connection step (for EvState)
Stats *stats.Snapshot `json:"stats,omitempty"`
Code string `json:"code,omitempty"` // stable auth-error code for EvError
Message string `json:"message,omitempty"`
}
@@ -98,15 +117,34 @@ type Server struct {
}
// NewServer creates (but does not start) the IPC server. It removes
// any stale socket file first.
// any stale socket file first (unix only).
func NewServer() (*Server, error) {
_ = os.Remove(paths.IPCSocketPath())
l, err := net.Listen("unix", paths.IPCSocketPath())
netType := paths.IPCNetwork()
addr := paths.IPCAddress()
// Guard against a second daemon instance on unix: probe the
// socket before removing it. If the dial succeeds, another
// daemon is alive and owns the socket - refuse to start so we
// don't silently orphan it. Only remove the file when the dial
// fails (stale socket from a crashed process).
if netType == "unix" {
if c, derr := net.Dial(netType, addr); derr == nil {
c.Close()
return nil, fmt.Errorf("daemon already running on %s", addr)
}
_ = os.Remove(addr)
}
l, err := net.Listen(netType, addr)
if err != nil {
return nil, fmt.Errorf("listen ipc: %w", err)
}
// Mode 0660 so group members (admin) can connect.
_ = os.Chmod(paths.IPCSocketPath(), 0o660)
// Set socket permissions so group members can connect (unix only).
if netType == "unix" {
_ = os.Chmod(addr, 0o660)
}
return &Server{listener: l, clients: make(map[net.Conn]bool)}, nil
}
@@ -176,7 +214,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 +243,7 @@ func (c *Client) Close() error { return c.conn.Close() }
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Version string `json:"version,omitempty"` // set for CmdVersion replies
}
// WriteOK sends a success response to a connection.
@@ -227,20 +266,47 @@ func SendStop(c *Client) error {
return c.Send(Request{Cmd: CmdStop})
}
// SendRefreshCIDR is a convenience helper for sending a refresh CIDR command.
func SendRefreshCIDR(c *Client) error {
return c.Send(Request{Cmd: CmdRefreshCIDR})
}
// SendShutdown is a convenience helper for sending a shutdown command.
func SendShutdown(c *Client) error {
return c.Send(Request{Cmd: CmdShutdown})
}
// WriteVersion sends a version response to a connection.
func WriteVersion(conn net.Conn, ver string) error {
return writeMsg(conn, Response{OK: true, Version: ver})
}
// QueryVersion sends a CmdVersion request and reads the daemon's
// build version. It must be called before any VPN session starts (so
// no Event messages are in flight on the connection).
func (c *Client) QueryVersion() (string, error) {
if err := c.Send(Request{Cmd: CmdVersion}); err != nil {
return "", err
}
var resp Response
if err := readMsg(c.r, &resp); err != nil {
return "", err
}
if !resp.OK {
return "", fmt.Errorf("version query failed: %s", resp.Error)
}
return resp.Version, nil
}
// RoutingModeFromIPC converts an IPC routing mode string to route.Mode.
func RoutingModeFromIPC(s string) route.Mode {
switch s {
case "full":
return route.ModeFull
case "split":
return route.ModeSplit
case "custom":
return route.ModeCustom
case "proxy":
return route.ModeProxy
case "bypass":
return route.ModeBypass
default:
return route.ModeFull
}
+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)
}
+76 -11
View File
@@ -3,7 +3,9 @@
package model
import (
"encoding/json"
"fmt"
"net"
"strings"
"time"
)
@@ -21,11 +23,26 @@ const (
type RoutingMode string
const (
RoutingFull RoutingMode = "full" // 0.0.0.0/0 via tunnel
RoutingSplit RoutingMode = "split" // only VPN subnet via tunnel
RoutingCustom RoutingMode = "custom" // user-specified CIDRs
RoutingFull RoutingMode = "full" // 全隧道: all traffic via tunnel
RoutingProxy RoutingMode = "proxy" // 代理CIDR: only specified CIDRs via tunnel
RoutingBypass RoutingMode = "bypass" // 绕过CIDR: all traffic via tunnel except specified CIDRs
)
// FetchTiming specifies when a CIDR URL source is fetched.
type FetchTiming string
const (
FetchBefore FetchTiming = "before" // before proxy: fetched via direct connection before routing is applied
FetchAfter FetchTiming = "after" // after proxy: fetched via the tunnel after the data plane is up
)
// CIDRURLSource describes a URL that provides a CIDR list. The list
// is fetched at FetchTiming and merged into the routing configuration.
type CIDRURLSource struct {
URL string `json:"url"`
FetchTiming FetchTiming `json:"fetch_timing"` // "before" or "after"
}
// ServerProfile is a saved VPN server configuration.
type ServerProfile struct {
ID int64 `json:"id"`
@@ -38,9 +55,16 @@ type ServerProfile struct {
Username string `json:"username"`
AuthMode AuthMode `json:"auth_mode"`
RoutingMode RoutingMode `json:"routing_mode"`
CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom
CIDRV4 string `json:"cidr_v4"` // comma-separated static IPv4 CIDRs
CIDRV6 string `json:"cidr_v6"` // comma-separated static IPv6 CIDRs
CIDRV4URLs string `json:"cidr_v4_urls"` // JSON array of CIDRURLSource for IPv4
CIDRV6URLs string `json:"cidr_v6_urls"` // JSON array of CIDRURLSource for IPv6
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
AutoConnect bool `json:"auto_connect"`
TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (wss only)
TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only)
TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only)
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only)
CreatedAt time.Time `json:"created_at"`
LastConnectedAt *time.Time `json:"last_connected_at"`
}
@@ -81,17 +105,57 @@ func (p *ServerProfile) BuildServerURL(ip ...string) string {
return fmt.Sprintf("%s://%s:%d%s", protocol, host, port, path)
}
// GetServerIPList parses ServerIPs into a string slice.
func (p *ServerProfile) GetServerIPList() []string {
// ValidateServerIPs parses ServerIPs, returning valid IP addresses
// and any invalid entries (for UI error reporting).
func (p *ServerProfile) ValidateServerIPs() (valid []string, invalid []string) {
if p.ServerIPs == "" {
return nil, nil
}
for _, part := range strings.Split(p.ServerIPs, ",") {
s := strings.TrimSpace(part)
if s == "" {
continue
}
if net.ParseIP(s) != nil {
valid = append(valid, s)
} else {
invalid = append(invalid, s)
}
}
return
}
// GetServerIPList returns only valid IP addresses from ServerIPs,
// silently filtering out any malformed entries.
func (p *ServerProfile) GetServerIPList() []string {
valid, _ := p.ValidateServerIPs()
return valid
}
// ParseCIDRURLs decodes a JSON-encoded CIDRURLSource array. Returns an
// empty slice if the string is empty or unparseable.
func ParseCIDRURLs(jsonStr string) []CIDRURLSource {
if jsonStr == "" {
return nil
}
var sources []CIDRURLSource
if err := json.Unmarshal([]byte(jsonStr), &sources); err != nil {
return nil
}
return sources
}
// SplitCIDRs splits a comma-separated CIDR string into a slice,
// trimming whitespace from each entry and skipping empty ones.
func SplitCIDRs(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(p.ServerIPs, ",")
var out []string
for _, part := range parts {
s := strings.TrimSpace(part)
if s != "" {
out = append(out, s)
for _, part := range strings.Split(s, ",") {
c := strings.TrimSpace(part)
if c != "" {
out = append(out, c)
}
}
return out
@@ -113,6 +177,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" }
+12
View File
@@ -7,6 +7,18 @@ import (
"path/filepath"
)
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
func IPCNetwork() string { return "unix" }
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
func IPCAddress() string { return "/tmp/lmvpn.sock" }
// GUILockNetwork returns the transport for the GUI single-instance lock.
func GUILockNetwork() string { return "unix" }
// GUILockAddress returns the address for the GUI single-instance lock.
func GUILockAddress() string { return Paths.Data + "/gui.sock" }
func init() {
home, _ := os.UserHomeDir()
recomputePaths(home)
+13 -1
View File
@@ -1,4 +1,4 @@
//go:build !darwin
//go:build !darwin && !windows
package paths
@@ -7,6 +7,18 @@ import (
"path/filepath"
)
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
func IPCNetwork() string { return "unix" }
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
func IPCAddress() string { return "/tmp/lmvpn.sock" }
// GUILockNetwork returns the transport for the GUI single-instance lock.
func GUILockNetwork() string { return "unix" }
// GUILockAddress returns the address for the GUI single-instance lock.
func GUILockAddress() string { return Paths.Data + "/gui.sock" }
func init() {
home, _ := os.UserHomeDir()
recomputePaths(home)
+55
View File
@@ -0,0 +1,55 @@
//go:build windows
package paths
import (
"os"
"path/filepath"
)
// IPCNetwork returns the transport network for GUI <-> daemon IPC.
// Windows uses TCP because AF_UNIX sockets enforce mandatory
// integrity-level checks: a socket created by the elevated daemon
// (High Integrity) cannot be connected to by the non-elevated GUI
// (Medium Integrity). TCP on 127.0.0.1 has no such restriction.
func IPCNetwork() string { return "tcp" }
// IPCAddress returns the listen/dial address for GUI <-> daemon IPC.
// On Windows this is a TCP address on localhost.
const ipcPort = "18923"
func IPCAddress() string { return "127.0.0.1:" + ipcPort }
// GUILockNetwork returns the transport for the GUI single-instance lock.
// Windows uses TCP (same reason as IPC: AF_UNIX integrity-level checks).
func GUILockNetwork() string { return "tcp" }
// GUILockAddress returns the address for the GUI single-instance lock.
func GUILockAddress() string { return "127.0.0.1:18924" }
func init() {
home, _ := os.UserHomeDir()
recomputePaths(home)
}
// recomputePaths sets Paths based on the given home directory.
// On Windows the layout follows platform conventions:
//
// %APPDATA%\<BundleID>\ data (db, config)
// %LOCALAPPDATA%\<BundleID>\ cache
// %LOCALAPPDATA%\<BundleID>\Logs\ logs
func recomputePaths(home string) {
appData := os.Getenv("APPDATA")
if appData == "" {
appData = filepath.Join(home, "AppData", "Roaming")
}
localAppData := os.Getenv("LOCALAPPDATA")
if localAppData == "" {
localAppData = filepath.Join(home, "AppData", "Local")
}
Paths = Dirs{
Data: filepath.Join(appData, BundleID),
Cache: filepath.Join(localAppData, BundleID),
Log: filepath.Join(localAppData, BundleID, "Logs"),
}
}
+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 ""
}
}
+210
View File
@@ -0,0 +1,210 @@
// cidrmerge.go merges adjacent CIDR blocks to reduce the total number
// of routes. For example, 1.0.1.0/24 + 1.0.2.0/23 -> 1.0.0.0/22.
// This is critical for large lists like chnroute (8786 entries) where
// many blocks can be merged, cutting the route count by 50-70%.
package route
import (
"net"
"sort"
)
// mergeCIDRs takes a list of CIDR strings, parses them, merges
// adjacent blocks, and returns a deduplicated, minimized list.
// Invalid CIDR strings are silently skipped.
func mergeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 {
return nil
}
var nets []netEntry
for _, s := range cidrs {
_, n, err := net.ParseCIDR(s)
if err != nil {
continue
}
ones, _ := n.Mask.Size()
isV6 := n.IP.To4() == nil
if isV6 {
nets = append(nets, netEntry{ip: n.IP.To16(), bits: ones})
} else {
nets = append(nets, netEntry{ip: n.IP.To4(), bits: ones})
}
}
// Split into v4 and v6, merge separately.
var v4nets, v6nets []netEntry
for _, n := range nets {
if n.ip.To4() != nil && len(n.ip) == 4 {
v4nets = append(v4nets, n)
} else if n.ip.To4() == nil && len(n.ip) == 16 {
v6nets = append(v6nets, n)
}
}
mergedV4 := mergeNets(v4nets, 32)
mergedV6 := mergeNets(v6nets, 128)
var result []string
for _, n := range mergedV4 {
result = append(result, n.ip.String()+"/"+itoa(n.bits))
}
for _, n := range mergedV6 {
result = append(result, n.ip.String()+"/"+itoa(n.bits))
}
return result
}
type netEntry struct {
ip net.IP
bits int
}
type sortableNet struct {
ip []byte
bits int
}
func mergeNets(nets []netEntry, maxBits int) []netEntry {
if len(nets) == 0 {
return nil
}
// Convert to sortable form.
var sn []sortableNet
for _, n := range nets {
sn = append(sn, sortableNet{ip: []byte(n.ip), bits: n.bits})
}
// Sort by IP then by prefix length (more specific first).
sort.Slice(sn, func(i, j int) bool {
return cmpIP(sn[i].ip, sn[j].ip) < 0 ||
(cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits)
})
// Merge: repeatedly try to combine pairs into supernets.
// Two adjacent /n networks can merge into a /(n-1) if:
// 1. Both have the same prefix length n
// 2. Their network addresses differ only in bit n (i.e. one ends in 0, the other in 1 at position n)
// 3. The merged address has bit n cleared
changed := true
for changed {
changed = false
var merged []sortableNet
i := 0
for i < len(sn) {
if i+1 < len(sn) && canMerge(sn[i], sn[i+1], maxBits) {
mergedNet := mergePair(sn[i], maxBits)
merged = append(merged, mergedNet)
i += 2
changed = true
} else {
merged = append(merged, sn[i])
i++
}
}
sn = merged
// Re-sort after merge (merged pairs may be out of order).
if changed {
sort.Slice(sn, func(i, j int) bool {
return cmpIP(sn[i].ip, sn[j].ip) < 0 ||
(cmpIP(sn[i].ip, sn[j].ip) == 0 && sn[i].bits < sn[j].bits)
})
}
}
// Convert back.
var result []netEntry
for _, n := range sn {
result = append(result, netEntry{ip: net.IP(n.ip), bits: n.bits})
}
return result
}
func canMerge(a, b sortableNet, maxBits int) bool {
if a.bits != b.bits || a.bits == 0 {
return false
}
if len(a.ip) != len(b.ip) {
return false
}
// Check that they share the same prefix up to bit (a.bits - 1).
prefixBits := a.bits - 1
byteIdx := prefixBits / 8
bitIdx := uint(7 - (prefixBits % 8))
// All bytes before byteIdx must be equal.
for k := 0; k < byteIdx; k++ {
if a.ip[k] != b.ip[k] {
return false
}
}
// In byteIdx, bits above bitIdx must be equal.
mask := byte(0xFF << (bitIdx + 1))
if a.ip[byteIdx]&mask != b.ip[byteIdx]&mask {
return false
}
// The bit at bitIdx must differ (one is 0, one is 1).
bitA := (a.ip[byteIdx] >> bitIdx) & 1
bitB := (b.ip[byteIdx] >> bitIdx) & 1
if bitA == bitB {
return false
}
// The lower bits of both must be zero (network address).
lowerMask := byte(1<<bitIdx) - 1
if a.ip[byteIdx]&lowerMask != 0 || b.ip[byteIdx]&lowerMask != 0 {
return false
}
// All bytes after byteIdx must be zero.
for k := byteIdx + 1; k < len(a.ip); k++ {
if a.ip[k] != 0 || b.ip[k] != 0 {
return false
}
}
return true
}
func mergePair(a sortableNet, maxBits int) sortableNet {
result := make([]byte, len(a.ip))
copy(result, a.ip)
// Clear the bit at position (a.bits - 1).
prefixBits := a.bits - 1
byteIdx := prefixBits / 8
bitIdx := uint(7 - (prefixBits % 8))
result[byteIdx] &^= 1 << bitIdx
return sortableNet{ip: result, bits: a.bits - 1}
}
func cmpIP(a, b []byte) int {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] < b[i] {
return -1
}
if a[i] > b[i] {
return 1
}
}
return len(a) - len(b)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
pos := len(buf)
negative := n < 0
if negative {
n = -n
}
for n > 0 {
pos--
buf[pos] = byte('0' + n%10)
n /= 10
}
if negative {
pos--
buf[pos] = '-'
}
return string(buf[pos:])
}
+429 -71
View File
@@ -1,19 +1,37 @@
// Package route manages VPN routing on the client. It supports three
// modes:
//
// - Full tunnel: all traffic (0.0.0.0/0) via the TUN interface,
// with a bypass route for the server's public IP so
// the WebSocket connection stays on the physical NIC
// - Split tunnel: only the VPN virtual subnet via the TUN interface
// - Custom: user-specified CIDRs via the TUN interface
// - Full tunnel: all traffic (0.0.0.0/0 and ::/0) via the TUN
// interface, with bypass routes for the server's
// public IP (v4 and v6) so the WebSocket connection
// stays on the physical NIC
// - Proxy CIDR: only the specified CIDRs (v4 and v6) via TUN
// - Bypass CIDR: all traffic via TUN except the specified CIDRs,
// which are routed via the original gateway
//
// All routes are tracked so they can be cleanly removed on disconnect.
// Routing is applied in two phases to avoid blocking the server's
// ReadyTimeout:
//
// - Apply (essential): server bypass + /1 cover routes (4-6 commands,
// <1s). Runs inside the handshake before "ready" is sent.
// - ApplyDeferred: user CIDR routes (potentially thousands). Runs in
// a background goroutine after the tunnel is up. Uses batch script
// execution to minimize process creation overhead.
//
// IPv6 routes are applied automatically when the server assigned an
// IPv6 address (Config.VPNIP6 != ""). All routes are tracked so they
// can be cleanly removed on disconnect.
package route
import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
"lmvpn/internal/log"
)
// Mode selects which traffic goes through the VPN tunnel.
@@ -21,27 +39,39 @@ type Mode string
const (
ModeFull Mode = "full"
ModeSplit Mode = "split"
ModeCustom Mode = "custom"
ModeProxy Mode = "proxy"
ModeBypass Mode = "bypass"
)
// Config describes the desired routing configuration.
type Config struct {
Mode Mode
InterfaceName string // e.g. "utun4"
VPNIP string // assigned tunnel IP, e.g. "192.168.77.5"
VPNPrefix int // subnet prefix, e.g. 24
VPNIP string // assigned tunnel IPv4, e.g. "192.168.77.5"
VPNPrefix int // IPv4 subnet prefix, e.g. 24
VPNIP6 string // assigned tunnel IPv6 (empty = v4-only)
VPNPrefix6 int // IPv6 subnet prefix
ServerHost string // server hostname/IP (for full-tunnel bypass)
CustomCIDRs []string // for ModeCustom
CIDRs []string // CIDR list for ModeProxy and ModeBypass
}
// Manager applies and removes routes. It tracks all added routes so
// they can be cleaned up deterministically.
// they can be cleaned up deterministically. All methods are safe for
// concurrent use (Apply/ApplyDeferred may run in one goroutine while
// Cleanup runs in another).
type Manager struct {
mu sync.Mutex
cfg Config
addedRoutes []string // route specs added, for deletion
addedRoutes []string // v4 route specs added via TUN, for deletion
addedRoutes6 []string // v6 route specs added via TUN, for deletion
bypassRoutes []string // v4 bypass route specs added via gateway
bypassRoutes6 []string // v6 bypass route specs added via gateway
serverBypass bool
serverBypass6 bool
originalGateway string
originalGateway6 string
serverIP string // resolved v4 (for bypass delete)
serverIP6 string // resolved v6 (for bypass delete)
}
// NewManager creates a route manager for the given configuration.
@@ -49,130 +79,458 @@ func NewManager(cfg Config) *Manager {
return &Manager{cfg: cfg}
}
// Apply adds routes according to the configured mode.
// Apply adds essential routes that must be in place before the VPN
// tunnel "ready" signal is sent. This completes in <1 second (4-6
// route commands). User CIDR routes are deferred to ApplyDeferred.
func (m *Manager) Apply() error {
switch m.cfg.Mode {
case ModeFull:
return m.applyFull()
case ModeSplit:
return m.applySplit()
case ModeCustom:
return m.applyCustom()
case ModeProxy:
return m.applyProxyEssential()
case ModeBypass:
return m.applyBypassEssential()
default:
return fmt.Errorf("unknown routing mode: %s", m.cfg.Mode)
}
}
// Cleanup removes all routes that were added by Apply.
func (m *Manager) Cleanup() error {
// ApplyDeferred adds user CIDR routes that were deferred from Apply.
// This should be called in a background goroutine after the tunnel is
// up. For full-tunnel mode this is a no-op. For proxy/bypass modes it
// uses batch script execution to handle potentially thousands of CIDRs
// efficiently.
func (m *Manager) ApplyDeferred() error {
switch m.cfg.Mode {
case ModeFull:
return nil
case ModeProxy:
return m.applyProxyDeferred()
case ModeBypass:
return m.applyBypassDeferred()
default:
return nil
}
}
// AddRoutes dynamically adds routes for additional CIDRs after the
// initial Apply/ApplyDeferred. This is used for CIDRs fetched from URLs
// after the tunnel is established. Uses batch execution.
func (m *Manager) AddRoutes(cidrs []string) error {
if len(cidrs) == 0 {
return nil
}
// Merge CIDRs to reduce route count.
merged := mergeCIDRs(cidrs)
logRouteMerge(len(cidrs), len(merged))
// Split CIDRs by family.
var v4, v6 []string
for _, cidr := range merged {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if isIPv6CIDR(cidr) {
v6 = append(v6, cidr)
} else {
v4 = append(v4, cidr)
}
}
m.mu.Lock()
defer m.mu.Unlock()
var errs []string
for _, r := range m.addedRoutes {
if m.cfg.Mode == ModeBypass {
if len(v4) > 0 && m.originalGateway != "" {
if err := addRoutesViaBatch(v4, m.originalGateway); err != nil {
errs = append(errs, err.Error())
} else {
m.bypassRoutes = append(m.bypassRoutes, v4...)
}
}
if len(v6) > 0 && m.originalGateway6 != "" {
if err := addRoutesVia6Batch(v6, m.originalGateway6); err != nil {
errs = append(errs, err.Error())
} else {
m.bypassRoutes6 = append(m.bypassRoutes6, v6...)
}
}
} else {
if len(v4) > 0 {
if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
} else {
m.addedRoutes = append(m.addedRoutes, v4...)
}
}
if len(v6) > 0 {
if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
} else {
m.addedRoutes6 = append(m.addedRoutes6, v6...)
}
}
}
if len(errs) > 0 {
return fmt.Errorf("add routes errors: %s", strings.Join(errs, "; "))
}
return nil
}
// HasOriginalGateway reports whether the manager captured an original
// default gateway (v4 or v6) during Apply.
func (m *Manager) HasOriginalGateway() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.originalGateway != "" || m.originalGateway6 != ""
}
// OriginalGatewayV4 returns the captured IPv4 default gateway, if any.
func (m *Manager) OriginalGatewayV4() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.originalGateway
}
// OriginalGatewayV6 returns the captured IPv6 default gateway, if any.
func (m *Manager) OriginalGatewayV6() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.originalGateway6
}
// Cleanup removes all routes that were added by Apply, ApplyDeferred,
// or AddRoutes. Safe to call concurrently with ApplyDeferred.
func (m *Manager) Cleanup() error {
m.mu.Lock()
// Snapshot all route lists under the lock, then clear them.
addedRoutes := m.addedRoutes
addedRoutes6 := m.addedRoutes6
bypassRoutes := m.bypassRoutes
bypassRoutes6 := m.bypassRoutes6
serverBypass := m.serverBypass
serverBypass6 := m.serverBypass6
originalGateway := m.originalGateway
originalGateway6 := m.originalGateway6
serverIP := m.serverIP
serverIP6 := m.serverIP6
m.addedRoutes = nil
m.addedRoutes6 = nil
m.bypassRoutes = nil
m.bypassRoutes6 = nil
m.serverBypass = false
m.serverBypass6 = false
m.mu.Unlock()
var errs []string
// Delete user CIDR routes. Use batch for large lists.
if len(addedRoutes) > 3 {
if err := deleteRoutesBatch(addedRoutes, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
}
} else {
for _, r := range addedRoutes {
if err := deleteRoute(r, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
}
}
m.addedRoutes = nil
if m.serverBypass {
if err := m.deleteServerBypass(); err != nil {
}
if len(addedRoutes6) > 3 {
if err := deleteRoutes6Batch(addedRoutes6, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
}
m.serverBypass = false
} else {
for _, r := range addedRoutes6 {
if err := deleteRoute6(r, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
}
}
}
// Delete bypass routes via gateway.
if len(bypassRoutes) > 3 {
if err := deleteRoutesViaBatch(bypassRoutes, originalGateway); err != nil {
errs = append(errs, err.Error())
}
} else {
for _, r := range bypassRoutes {
if err := deleteRouteVia(r, originalGateway); err != nil {
errs = append(errs, err.Error())
}
}
}
if len(bypassRoutes6) > 3 {
if err := deleteRoutesVia6Batch(bypassRoutes6, originalGateway6); err != nil {
errs = append(errs, err.Error())
}
} else {
for _, r := range bypassRoutes6 {
if err := deleteRouteVia6(r, originalGateway6); err != nil {
errs = append(errs, err.Error())
}
}
}
// Delete server bypass routes.
if serverBypass && serverIP != "" {
if err := deleteRouteVia(serverIP+"/32", originalGateway); err != nil {
errs = append(errs, err.Error())
}
}
if serverBypass6 && serverIP6 != "" && originalGateway6 != "" {
if err := deleteRouteVia6(serverIP6+"/128", originalGateway6); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("cleanup errors: %s", strings.Join(errs, "; "))
}
return nil
}
func (m *Manager) applyFull() error {
// Capture the current default gateway before modifying routes.
// captureGatewaysAndBypass resolves the server host and adds bypass
// routes for the server's public IPs via the original gateway. This
// is shared by applyFull and applyBypassEssential.
func (m *Manager) captureGatewaysAndBypass() error {
// Capture the current default gateways before modifying routes.
gw, err := defaultGateway()
if err != nil {
return fmt.Errorf("get default gateway: %w", err)
}
m.originalGateway = gw
// Resolve server host to an IP for the bypass route.
serverIP, err := resolveHost(m.cfg.ServerHost)
// IPv6 default gateway is best-effort.
gw6, _ := defaultGateway6()
m.originalGateway6 = gw6
// Resolve server host to v4 + v6 IPs for bypass routes.
v4, v6, err := resolveHosts(m.cfg.ServerHost)
if err != nil {
return fmt.Errorf("resolve server host %s: %w", m.cfg.ServerHost, err)
}
m.serverIP = v4
m.serverIP6 = v6
// Bypass: server's public IP via the original gateway (so the WS
// connection doesn't loop through the tunnel).
bypassSpec := serverIP + "/32"
// Bypass: server's public IPv4 via the original gateway.
if v4 != "" {
bypassSpec := v4 + "/32"
if err := addRouteVia(bypassSpec, gw); err != nil {
return fmt.Errorf("add server bypass route: %w", err)
}
m.serverBypass = true
}
// Two /1 routes cover the entire IPv4 space and are more specific
// than the default route (0.0.0.0/0), so they take precedence
// without removing the original default.
// Bypass: server's public IPv6 via the original v6 gateway.
if v6 != "" && gw6 != "" {
bypassSpec := v6 + "/128"
if err := addRouteVia6(bypassSpec, gw6); err != nil {
m.serverBypass6 = false
} else {
m.serverBypass6 = true
}
}
return nil
}
func (m *Manager) applyFull() error {
if err := m.captureGatewaysAndBypass(); err != nil {
return err
}
// Two /1 routes cover the entire IPv4 space.
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add route %s: %w", cidr, err)
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
// IPv6 full tunnel cover routes.
if m.cfg.VPNIP6 != "" {
for _, cidr := range []string{"::/1", "8000::/1"} {
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add route6 %s: %w", cidr, err)
}
m.addedRoutes6 = append(m.addedRoutes6, cidr)
}
}
return nil
}
func (m *Manager) applySplit() error {
subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix)
if err := addRoute(subnet, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add split route %s: %w", subnet, err)
}
m.addedRoutes = append(m.addedRoutes, subnet)
// applyProxyEssential does nothing - proxy mode has no essential
// routes. All user CIDR routes are deferred.
func (m *Manager) applyProxyEssential() error {
return nil
}
func (m *Manager) applyCustom() error {
for _, cidr := range m.cfg.CustomCIDRs {
// applyProxyDeferred adds all user CIDR routes via the TUN interface
// using batch execution. CIDRs are merged to minimize route count.
func (m *Manager) applyProxyDeferred() error {
merged := mergeCIDRs(m.cfg.CIDRs)
logRouteMerge(len(m.cfg.CIDRs), len(merged))
var v4, v6 []string
for _, cidr := range merged {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add custom route %s: %w", cidr, err)
if isIPv6CIDR(cidr) {
v6 = append(v6, cidr)
} else {
v4 = append(v4, cidr)
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
m.mu.Lock()
defer m.mu.Unlock()
var errs []string
if len(v4) > 0 {
if err := addRoutesBatch(v4, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
} else {
m.addedRoutes = append(m.addedRoutes, v4...)
}
}
if len(v6) > 0 {
if err := addRoutes6Batch(v6, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
} else {
m.addedRoutes6 = append(m.addedRoutes6, v6...)
}
}
if len(errs) > 0 {
return fmt.Errorf("proxy deferred: %s", strings.Join(errs, "; "))
}
return nil
}
func (m *Manager) deleteServerBypass() error {
serverIP, err := resolveHost(m.cfg.ServerHost)
if err != nil {
return nil // best-effort
}
return deleteRouteVia(serverIP+"/32", m.originalGateway)
// applyBypassEssential adds server bypass + /1 cover routes (full
// tunnel effect). User bypass CIDRs are deferred to allow the "ready"
// signal to be sent promptly.
func (m *Manager) applyBypassEssential() error {
if err := m.captureGatewaysAndBypass(); err != nil {
return err
}
// vpnSubnet computes the network CIDR from an IP and prefix.
func vpnSubnet(ipStr string, prefix int) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ipStr + "/" + fmt.Sprint(prefix)
// Two /1 routes cover the entire IPv4 space (full tunnel).
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add route %s: %w", cidr, err)
}
mask := net.CIDRMask(prefix, 32)
network := ip.Mask(mask)
return fmt.Sprintf("%s/%d", network.String(), prefix)
m.addedRoutes = append(m.addedRoutes, cidr)
}
// resolveHost resolves a hostname to an IP address. If already an IP,
// returns it directly.
func resolveHost(host string) (string, error) {
if net.ParseIP(host) != nil {
return host, nil
// IPv6 full tunnel cover routes.
if m.cfg.VPNIP6 != "" {
for _, cidr := range []string{"::/1", "8000::/1"} {
if err := addRoute6(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add route6 %s: %w", cidr, err)
}
// Strip port if present.
if h, _, err := net.SplitHostPort(host); err == nil {
m.addedRoutes6 = append(m.addedRoutes6, cidr)
}
}
return nil
}
// applyBypassDeferred adds user bypass CIDR routes via the original
// gateway using batch execution. CIDRs are merged to minimize route
// count.
func (m *Manager) applyBypassDeferred() error {
merged := mergeCIDRs(m.cfg.CIDRs)
logRouteMerge(len(m.cfg.CIDRs), len(merged))
var v4, v6 []string
for _, cidr := range merged {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if isIPv6CIDR(cidr) {
if m.originalGateway6 != "" {
v6 = append(v6, cidr)
}
} else {
v4 = append(v4, cidr)
}
}
m.mu.Lock()
defer m.mu.Unlock()
var errs []string
if len(v4) > 0 {
if err := addRoutesViaBatch(v4, m.originalGateway); err != nil {
errs = append(errs, err.Error())
} else {
m.bypassRoutes = append(m.bypassRoutes, v4...)
}
}
if len(v6) > 0 {
if err := addRoutesVia6Batch(v6, m.originalGateway6); err != nil {
errs = append(errs, err.Error())
} else {
m.bypassRoutes6 = append(m.bypassRoutes6, v6...)
}
}
if len(errs) > 0 {
return fmt.Errorf("bypass deferred: %s", strings.Join(errs, "; "))
}
return nil
}
// resolveHosts resolves a hostname to its first IPv4 and IPv6 addresses.
// If host is already an IP literal, it is returned directly. The DNS
// lookup is bounded to 5 seconds.
func resolveHosts(host string) (v4, v6 string, err error) {
if ip := net.ParseIP(host); ip != nil {
if ip.To4() != nil {
return ip.String(), "", nil
}
return "", ip.String(), nil
}
if h, _, e := net.SplitHostPort(host); e == nil {
host = h
}
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return "", fmt.Errorf("lookup %s: %w", host, err)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(addrs) == 0 {
return "", "", fmt.Errorf("lookup %s: %w", host, err)
}
for _, addr := range addrs {
if v4 == "" && addr.IP.To4() != nil {
v4 = addr.IP.String()
} else if v6 == "" && addr.IP.To4() == nil {
v6 = addr.IP.String()
}
}
if v4 == "" && v6 == "" {
return "", "", fmt.Errorf("lookup %s: no addresses", host)
}
return v4, v6, nil
}
// isIPv6CIDR reports whether the CIDR string is IPv6.
func isIPv6CIDR(cidr string) bool {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return false
}
return ipNet.IP.To4() == nil
}
// logRouteMerge logs CIDR merge statistics if any reduction occurred.
func logRouteMerge(before, after int) {
if before > after {
log.L().Info("CIDR merge", "before", before, "after", after, "reduced", before-after)
}
return ips[0].String(), nil
}
+174 -7
View File
@@ -4,38 +4,72 @@ package route
import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
)
// addRoute adds a route via a network interface (macOS route command).
// route add -inet -net <cidr> -interface <iface>
//
// route add -inet <cidr> -interface <iface>
func addRoute(cidr, iface string) error {
return runRoute("add", "-inet", "-net", cidr, "-interface", iface)
return runRoute("add", "-inet", cidr, "-interface", iface)
}
// deleteRoute removes a route via a network interface.
func deleteRoute(cidr, iface string) error {
return runRoute("delete", "-inet", "-net", cidr, "-interface", iface)
return runRoute("delete", "-inet", cidr, "-interface", iface)
}
// addRouteVia adds a route via a gateway IP.
func addRouteVia(cidr, gateway string) error {
return runRoute("add", "-inet", "-net", cidr, gateway)
return runRoute("add", "-inet", cidr, gateway)
}
// deleteRouteVia removes a route via a gateway IP.
func deleteRouteVia(cidr, gateway string) error {
return runRoute("delete", "-inet", "-net", cidr, gateway)
return runRoute("delete", "-inet", cidr, gateway)
}
// defaultGateway returns the current default gateway IP.
// --- IPv6 variants ---
func addRoute6(cidr, iface string) error {
return runRoute("add", "-inet6", cidr, "-interface", iface)
}
func deleteRoute6(cidr, iface string) error {
return runRoute("delete", "-inet6", cidr, "-interface", iface)
}
func addRouteVia6(cidr, gateway string) error {
return runRoute("add", "-inet6", cidr, gateway)
}
func deleteRouteVia6(cidr, gateway string) error {
return runRoute("delete", "-inet6", cidr, gateway)
}
// defaultGateway returns the current IPv4 default gateway IP.
func defaultGateway() (string, error) {
out, err := exec.Command("route", "-n", "get", "default").Output()
if err != nil {
return "", err
}
for _, line := range strings.Split(string(out), "\n") {
return parseGateway(string(out))
}
// defaultGateway6 returns the current IPv6 default gateway IP.
func defaultGateway6() (string, error) {
out, err := exec.Command("route", "-n", "get", "-inet6", "default").Output()
if err != nil {
return "", err
}
return parseGateway(string(out))
}
func parseGateway(out string) (string, error) {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "gateway:") {
parts := strings.SplitN(line, ":", 2)
@@ -57,3 +91,136 @@ func runRoute(args ...string) error {
}
return nil
}
// --- Batch functions ---
// runBatchScript writes commands to temporary shell scripts and
// executes them in parallel (up to 4 concurrent scripts). Each line
// uses "|| true" so that individual route failures don't abort the
// batch.
func runBatchScript(lines []string) error {
if len(lines) == 0 {
return nil
}
// Split lines into up to 4 chunks for parallel execution.
const maxChunks = 4
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
var chunks [][]string
for i := 0; i < len(lines); i += chunkSize {
end := i + chunkSize
if end > len(lines) {
end = len(lines)
}
chunks = append(chunks, lines[i:end])
}
var wg sync.WaitGroup
errs := make([]error, len(chunks))
for i, chunk := range chunks {
wg.Add(1)
go func(idx int, c []string) {
defer wg.Done()
errs[idx] = runSingleScript(c)
}(i, chunk)
}
wg.Wait()
var errStrs []string
for _, e := range errs {
if e != nil {
errStrs = append(errStrs, e.Error())
}
}
if len(errStrs) > 0 {
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
}
return nil
}
func runSingleScript(lines []string) error {
f, err := os.CreateTemp("", "lmvpn-routes-*.sh")
if err != nil {
return fmt.Errorf("create batch script: %w", err)
}
tmpFile := f.Name()
defer os.Remove(tmpFile)
for _, line := range lines {
if _, err := fmt.Fprintln(f, line, "|| true"); err != nil {
f.Close()
return fmt.Errorf("write batch script: %w", err)
}
}
f.Close()
cmd := exec.Command("sh", tmpFile)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func addRoutesBatch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("route add -inet %s -interface %s", cidr, iface))
}
return runBatchScript(lines)
}
func deleteRoutesBatch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("route delete -inet %s -interface %s", cidr, iface))
}
return runBatchScript(lines)
}
func addRoutes6Batch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("route add -inet6 %s -interface %s", cidr, iface))
}
return runBatchScript(lines)
}
func deleteRoutes6Batch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("route delete -inet6 %s -interface %s", cidr, iface))
}
return runBatchScript(lines)
}
func addRoutesViaBatch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("route add -inet %s %s", cidr, gateway))
}
return runBatchScript(lines)
}
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("route delete -inet %s %s", cidr, gateway))
}
return runBatchScript(lines)
}
func addRoutesVia6Batch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("route add -inet6 %s %s", cidr, gateway))
}
return runBatchScript(lines)
}
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("route delete -inet6 %s %s", cidr, gateway))
}
return runBatchScript(lines)
}
+164 -2
View File
@@ -1,11 +1,13 @@
//go:build !darwin
//go:build !darwin && !windows
package route
import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
)
func addRoute(cidr, iface string) error {
@@ -24,12 +26,42 @@ func deleteRouteVia(cidr, gateway string) error {
return runCmd("ip", "route", "del", cidr, "via", gateway)
}
// --- IPv6 variants ---
func addRoute6(cidr, iface string) error {
return runCmd("ip", "-6", "route", "add", cidr, "dev", iface)
}
func deleteRoute6(cidr, iface string) error {
return runCmd("ip", "-6", "route", "del", cidr, "dev", iface)
}
func addRouteVia6(cidr, gateway string) error {
return runCmd("ip", "-6", "route", "add", cidr, "via", gateway)
}
func deleteRouteVia6(cidr, gateway string) error {
return runCmd("ip", "-6", "route", "del", cidr, "via", gateway)
}
func defaultGateway() (string, error) {
out, err := exec.Command("ip", "route", "show", "default").Output()
if err != nil {
return "", err
}
fields := strings.Fields(strings.TrimSpace(string(out)))
return parseViaGateway(string(out))
}
func defaultGateway6() (string, error) {
out, err := exec.Command("ip", "-6", "route", "show", "default").Output()
if err != nil {
return "", err
}
return parseViaGateway(string(out))
}
func parseViaGateway(out string) (string, error) {
fields := strings.Fields(strings.TrimSpace(out))
for i, f := range fields {
if f == "via" && i+1 < len(fields) {
return fields[i+1], nil
@@ -45,3 +77,133 @@ func runCmd(name string, args ...string) error {
}
return nil
}
// --- Batch functions ---
// runBatchScript writes commands to temporary shell scripts and
// executes them in parallel (up to 4 concurrent scripts).
func runBatchScript(lines []string) error {
if len(lines) == 0 {
return nil
}
const maxChunks = 4
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
var chunks [][]string
for i := 0; i < len(lines); i += chunkSize {
end := i + chunkSize
if end > len(lines) {
end = len(lines)
}
chunks = append(chunks, lines[i:end])
}
var wg sync.WaitGroup
errs := make([]error, len(chunks))
for i, chunk := range chunks {
wg.Add(1)
go func(idx int, c []string) {
defer wg.Done()
errs[idx] = runSingleScript(c)
}(i, chunk)
}
wg.Wait()
var errStrs []string
for _, e := range errs {
if e != nil {
errStrs = append(errStrs, e.Error())
}
}
if len(errStrs) > 0 {
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
}
return nil
}
func runSingleScript(lines []string) error {
f, err := os.CreateTemp("", "lmvpn-routes-*.sh")
if err != nil {
return fmt.Errorf("create batch script: %w", err)
}
tmpFile := f.Name()
defer os.Remove(tmpFile)
for _, line := range lines {
if _, err := fmt.Fprintln(f, line, "|| true"); err != nil {
f.Close()
return fmt.Errorf("write batch script: %w", err)
}
}
f.Close()
cmd := exec.Command("sh", tmpFile)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func addRoutesBatch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("ip route add %s dev %s", cidr, iface))
}
return runBatchScript(lines)
}
func deleteRoutesBatch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("ip route del %s dev %s", cidr, iface))
}
return runBatchScript(lines)
}
func addRoutes6Batch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("ip -6 route add %s dev %s", cidr, iface))
}
return runBatchScript(lines)
}
func deleteRoutes6Batch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("ip -6 route del %s dev %s", cidr, iface))
}
return runBatchScript(lines)
}
func addRoutesViaBatch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("ip route add %s via %s", cidr, gateway))
}
return runBatchScript(lines)
}
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("ip route del %s via %s", cidr, gateway))
}
return runBatchScript(lines)
}
func addRoutesVia6Batch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("ip -6 route add %s via %s", cidr, gateway))
}
return runBatchScript(lines)
}
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
lines = append(lines, fmt.Sprintf("ip -6 route del %s via %s", cidr, gateway))
}
return runBatchScript(lines)
}
+349
View File
@@ -0,0 +1,349 @@
//go:build windows
package route
import (
"fmt"
"net"
"os"
"os/exec"
"strings"
"sync"
)
// cidrToNetworkMask splits a CIDR string into network address and
// dotted-decimal mask, as required by the Windows `route` command.
func cidrToNetworkMask(cidr string) (network, mask string, err error) {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return "", "", err
}
return ipNet.IP.String(), net.IP(ipNet.Mask).String(), nil
}
// ifaceIndex resolves a Windows interface name to its index.
func ifaceIndex(name string) (int, error) {
ifc, err := net.InterfaceByName(name)
if err != nil {
return 0, fmt.Errorf("resolve interface %s: %w", name, err)
}
return ifc.Index, nil
}
// --- IPv4 ---
func addRoute(cidr, iface string) error {
network, mask, err := cidrToNetworkMask(cidr)
if err != nil {
return err
}
idx, err := ifaceIndex(iface)
if err != nil {
return err
}
return runCmd("route", "add", network, "mask", mask, "0.0.0.0",
"if", fmt.Sprintf("%d", idx), "metric", "1")
}
func deleteRoute(cidr, iface string) error {
network, mask, err := cidrToNetworkMask(cidr)
if err != nil {
return err
}
return runCmd("route", "delete", network, "mask", mask)
}
func addRouteVia(cidr, gateway string) error {
network, mask, err := cidrToNetworkMask(cidr)
if err != nil {
return err
}
return runCmd("route", "add", network, "mask", mask, gateway, "metric", "1")
}
func deleteRouteVia(cidr, gateway string) error {
network, mask, err := cidrToNetworkMask(cidr)
if err != nil {
return err
}
return runCmd("route", "delete", network, "mask", mask, gateway)
}
// --- IPv6 ---
// cidrToV6Prefix splits an IPv6 CIDR into network and prefix length.
func cidrToV6Prefix(cidr string) (network, prefix string, err error) {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return "", "", err
}
ones, _ := ipNet.Mask.Size()
return ipNet.IP.String(), fmt.Sprintf("%d", ones), nil
}
func addRoute6(cidr, iface string) error {
network, prefix, err := cidrToV6Prefix(cidr)
if err != nil {
return err
}
idx, err := ifaceIndex(iface)
if err != nil {
return err
}
return runCmd("route", "-6", "add", network+"/"+prefix, "::",
"if", fmt.Sprintf("%d", idx), "metric", "1")
}
func deleteRoute6(cidr, iface string) error {
network, prefix, err := cidrToV6Prefix(cidr)
if err != nil {
return err
}
return runCmd("route", "-6", "delete", network+"/"+prefix, "::")
}
func addRouteVia6(cidr, gateway string) error {
network, prefix, err := cidrToV6Prefix(cidr)
if err != nil {
return err
}
return runCmd("route", "-6", "add", network+"/"+prefix, gateway, "metric", "1")
}
func deleteRouteVia6(cidr, gateway string) error {
network, prefix, err := cidrToV6Prefix(cidr)
if err != nil {
return err
}
return runCmd("route", "-6", "delete", network+"/"+prefix, gateway)
}
// --- Default gateway ---
func defaultGateway() (string, error) {
out, err := exec.Command("route", "print", "0.0.0.0").Output()
if err != nil {
return "", err
}
return parseRouteGateway(string(out))
}
func defaultGateway6() (string, error) {
out, err := exec.Command("route", "-6", "print").Output()
if err != nil {
return "", err
}
return parseRoute6Gateway(string(out))
}
// parseRouteGateway extracts the IPv4 default gateway from `route print`
// output. Looks for the line with Network Destination 0.0.0.0 and
// Netmask 0.0.0.0, and returns the Gateway column.
func parseRouteGateway(out string) (string, error) {
lines := strings.Split(out, "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 3 && fields[0] == "0.0.0.0" && fields[1] == "0.0.0.0" {
return fields[2], nil
}
}
return "", fmt.Errorf("no default gateway found")
}
// parseRoute6Gateway extracts the IPv6 default gateway from
// `route -6 print` output. Looks for ::/0 route and returns the
// Gateway column.
func parseRoute6Gateway(out string) (string, error) {
lines := strings.Split(out, "\n")
for _, line := range lines {
fields := strings.Fields(line)
for i, f := range fields {
if f == "::/0" && i+1 < len(fields) {
return fields[i+1], nil
}
}
}
return "", fmt.Errorf("no IPv6 default gateway found")
}
func runCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s %s: %w (%s)", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
// --- Batch functions ---
// runBatchScript writes commands to temporary .bat files and executes
// them in parallel (up to 4 concurrent scripts).
func runBatchScript(lines []string) error {
if len(lines) == 0 {
return nil
}
const maxChunks = 4
chunkSize := (len(lines) + maxChunks - 1) / maxChunks
var chunks [][]string
for i := 0; i < len(lines); i += chunkSize {
end := i + chunkSize
if end > len(lines) {
end = len(lines)
}
chunks = append(chunks, lines[i:end])
}
var wg sync.WaitGroup
errs := make([]error, len(chunks))
for i, chunk := range chunks {
wg.Add(1)
go func(idx int, c []string) {
defer wg.Done()
errs[idx] = runSingleScript(c)
}(i, chunk)
}
wg.Wait()
var errStrs []string
for _, e := range errs {
if e != nil {
errStrs = append(errStrs, e.Error())
}
}
if len(errStrs) > 0 {
return fmt.Errorf("batch script: %s", strings.Join(errStrs, "; "))
}
return nil
}
func runSingleScript(lines []string) error {
f, err := os.CreateTemp("", "lmvpn-routes-*.bat")
if err != nil {
return fmt.Errorf("create batch script: %w", err)
}
tmpFile := f.Name()
defer os.Remove(tmpFile)
for _, line := range lines {
if _, err := fmt.Fprintln(f, line); err != nil {
f.Close()
return fmt.Errorf("write batch script: %w", err)
}
}
f.Close()
cmd := exec.Command("cmd", "/c", tmpFile)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("batch: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func addRoutesBatch(cidrs []string, iface string) error {
idx, err := ifaceIndex(iface)
if err != nil {
return err
}
var lines []string
for _, cidr := range cidrs {
network, mask, err := cidrToNetworkMask(cidr)
if err != nil {
continue
}
lines = append(lines, fmt.Sprintf(
"route add %s mask %s 0.0.0.0 if %d metric 1", network, mask, idx))
}
return runBatchScript(lines)
}
func deleteRoutesBatch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
network, mask, err := cidrToNetworkMask(cidr)
if err != nil {
continue
}
lines = append(lines, fmt.Sprintf("route delete %s mask %s", network, mask))
}
return runBatchScript(lines)
}
func addRoutes6Batch(cidrs []string, iface string) error {
idx, err := ifaceIndex(iface)
if err != nil {
return err
}
var lines []string
for _, cidr := range cidrs {
network, prefix, err := cidrToV6Prefix(cidr)
if err != nil {
continue
}
lines = append(lines, fmt.Sprintf(
"route -6 add %s/%s :: if %d metric 1", network, prefix, idx))
}
return runBatchScript(lines)
}
func deleteRoutes6Batch(cidrs []string, iface string) error {
var lines []string
for _, cidr := range cidrs {
network, prefix, err := cidrToV6Prefix(cidr)
if err != nil {
continue
}
lines = append(lines, fmt.Sprintf("route -6 delete %s/%s ::", network, prefix))
}
return runBatchScript(lines)
}
func addRoutesViaBatch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
network, mask, err := cidrToNetworkMask(cidr)
if err != nil {
continue
}
lines = append(lines, fmt.Sprintf(
"route add %s mask %s %s metric 1", network, mask, gateway))
}
return runBatchScript(lines)
}
func deleteRoutesViaBatch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
network, mask, err := cidrToNetworkMask(cidr)
if err != nil {
continue
}
lines = append(lines, fmt.Sprintf("route delete %s mask %s %s", network, mask, gateway))
}
return runBatchScript(lines)
}
func addRoutesVia6Batch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
network, prefix, err := cidrToV6Prefix(cidr)
if err != nil {
continue
}
lines = append(lines, fmt.Sprintf(
"route -6 add %s/%s %s metric 1", network, prefix, gateway))
}
return runBatchScript(lines)
}
func deleteRoutesVia6Batch(cidrs []string, gateway string) error {
var lines []string
for _, cidr := range cidrs {
network, prefix, err := cidrToV6Prefix(cidr)
if err != nil {
continue
}
lines = append(lines, fmt.Sprintf("route -6 delete %s/%s %s", network, prefix, gateway))
}
return runBatchScript(lines)
}
+142 -10
View File
@@ -19,12 +19,58 @@ 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)
routeLoading atomic.Bool // true while deferred routes are being applied
connectStep atomic.Value // string (human-readable connection step)
cidrError atomic.Value // string (CIDR fetch error message, empty = no error)
}
// AddRx records a downloaded packet (WebSocket → TUN) of length n,
// routing the bytes to the v4 or v6 counter based on the IP version
// nibble of the packet. Packets too short to contain a version byte
// or with an unknown version are ignored.
func (s *Stats) AddRx(p []byte) {
if len(p) == 0 {
return
}
n := int64(len(p))
switch p[0] >> 4 {
case 4:
s.RxBytesV4.Add(n)
case 6:
s.RxBytesV6.Add(n)
}
}
// AddTx records an uploaded packet (TUN → WebSocket) of length n,
// routing the bytes to the v4 or v6 counter based on the IP version
// nibble of the packet. Packets too short to contain a version byte
// or with an unknown version are ignored.
func (s *Stats) AddTx(p []byte) {
if len(p) == 0 {
return
}
n := int64(len(p))
switch p[0] >> 4 {
case 4:
s.TxBytesV4.Add(n)
case 6:
s.TxBytesV6.Add(n)
}
}
// New creates a Stats instance initialised to the disconnected state.
@@ -32,6 +78,9 @@ func New() *Stats {
s := &Stats{}
s.state.Store(StateDisconnected)
s.assignedIP.Store("")
s.assignedIP6.Store("")
s.connectStep.Store("")
s.cidrError.Store("")
return s
}
@@ -41,10 +90,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 +103,106 @@ func (s *Stats) SetConnected(ip string) {
func (s *Stats) SetDisconnected() {
s.ConnectedAt.Store(0)
s.assignedIP.Store("")
s.assignedIP6.Store("")
s.state.Store(StateDisconnected)
s.routeLoading.Store(false)
s.connectStep.Store("")
s.cidrError.Store("")
}
// AssignedIP returns the server-assigned tunnel IP.
// SetRouteLoading sets whether deferred routes are currently being
// applied (e.g. thousands of CIDR bypass routes being added in the
// background after the tunnel is up).
func (s *Stats) SetRouteLoading(loading bool) { s.routeLoading.Store(loading) }
// RouteLoading returns whether deferred routes are being applied.
func (s *Stats) RouteLoading() bool { return s.routeLoading.Load() }
// SetConnectStep sets a human-readable description of the current
// connection step (e.g. "fetching CIDR lists"). Empty clears it.
func (s *Stats) SetConnectStep(step string) { s.connectStep.Store(step) }
// ConnectStep returns the current connection step description.
func (s *Stats) ConnectStep() string {
v := s.connectStep.Load()
if v == nil {
return ""
}
return v.(string)
}
// SetCIDRError sets a CIDR fetch error message (empty = no error).
func (s *Stats) SetCIDRError(msg string) { s.cidrError.Store(msg) }
// CIDRError returns the current CIDR fetch error message.
func (s *Stats) CIDRError() string {
v := s.cidrError.Load()
if v == nil {
return ""
}
return v.(string)
}
// AssignedIP returns the server-assigned tunnel IPv4.
func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
// AssignedIP6 returns the server-assigned tunnel IPv6 (may be empty).
func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) }
// 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
// Routing info (filled by SessionManager.reportStats).
RoutingMode string `json:"routing_mode,omitempty"` // "full", "proxy", "bypass"
CIDRV4Total int `json:"cidr_v4_total,omitempty"`
CIDRV4Hits int `json:"cidr_v4_hits,omitempty"`
CIDRV6Total int `json:"cidr_v6_total,omitempty"`
CIDRV6Hits int `json:"cidr_v6_hits,omitempty"`
RouteLoading bool `json:"route_loading,omitempty"` // deferred routes being applied
ConnectStep string `json:"connect_step,omitempty"` // current connection step
CIDRError string `json:"cidr_error,omitempty"` // CIDR fetch error message
}
// Snapshot returns a point-in-time copy of the statistics.
func (s *Stats) Snapshot() Snapshot {
rxv4 := s.RxBytesV4.Load()
rxv6 := s.RxBytesV6.Load()
txv4 := s.TxBytesV4.Load()
txv6 := s.TxBytesV6.Load()
snap := Snapshot{
RxBytes: s.RxBytes.Load(),
TxBytes: s.TxBytes.Load(),
RxBytesV4: rxv4,
RxBytesV6: rxv6,
TxBytesV4: txv4,
TxBytesV6: txv6,
RxBytes: rxv4 + rxv6,
TxBytes: txv4 + txv6,
AssignedIP: s.AssignedIP(),
AssignedIP6: s.AssignedIP6(),
State: s.State(),
}
ts := s.ConnectedAt.Load()
@@ -81,5 +210,8 @@ func (s *Stats) Snapshot() Snapshot {
snap.ConnectedAt = time.Unix(ts, 0)
snap.Uptime = time.Since(snap.ConnectedAt)
}
snap.RouteLoading = s.routeLoading.Load()
snap.ConnectStep = s.ConnectStep()
snap.CIDRError = s.CIDRError()
return snap
}
+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
}
+40 -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,
}
@@ -86,12 +89,26 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
ws.SetReadLimit(protocol.MaxMessageSize)
// Watch ctx and close the WS if it is cancelled. This unblocks all
// ReadMessage/WriteMessage calls (they only use SetReadDeadline,
// not ctx). The goroutine exits when connectDone is closed (on
// either success or failure) to avoid leaking.
connectDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
ws.Close()
case <-connectDone:
}
}()
conn := &Conn{ws: ws}
// Step 2: authenticate (only for password mode; JWT is validated
// during the WS upgrade).
if cfg.Token == "" {
if err := conn.passwordAuth(cfg.Username, cfg.Password); err != nil {
close(connectDone)
ws.Close()
return nil, err
}
@@ -100,6 +117,7 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
// Step 3: receive init (or error/auth_err).
initMsg, err := conn.readInit()
if err != nil {
close(connectDone)
ws.Close()
return nil, err
}
@@ -108,6 +126,7 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
// Step 4: configure TUN via callback.
if cfg.OnInit != nil {
if err := cfg.OnInit(initMsg); err != nil {
close(connectDone)
ws.Close()
return nil, fmt.Errorf("tun configure: %w", err)
}
@@ -115,10 +134,15 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
// Step 5: send ready.
if err := conn.sendReady(); err != nil {
close(connectDone)
ws.Close()
return nil, fmt.Errorf("send ready: %w", err)
}
// Handshake complete - stop the ctx watcher so it doesn't close
// the connection after we return it to the caller.
close(connectDone)
// Step 6: set up keepalive — reset read deadline on each server
// ping, and auto-respond with pong (allowed via WriteControl in
// handler — see gorilla/websocket docs).
@@ -158,7 +182,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 +210,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 +263,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 +282,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 +293,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.
+147 -44
View File
@@ -1,12 +1,14 @@
package ui
import (
"errors"
"os"
"sync"
"lmvpn/internal/config"
"lmvpn/internal/db"
"lmvpn/internal/i18n"
"lmvpn/internal/instance"
"lmvpn/internal/ipc"
"lmvpn/internal/keychain"
"lmvpn/internal/log"
@@ -27,13 +29,27 @@ type App struct {
window fyne.Window
profileWindow fyne.Window
// Profile list window.
profileListWindow fyne.Window
profileList *widget.List
listSelectedIndex int
// UI widgets
profileSelect *widget.Select
stateLabel *widget.Label
ipLabel *widget.Label
ip6Label *widget.Label
uptimeLabel *widget.Label
rxLabel *widget.Label
txLabel *widget.Label
rxV4Label *widget.Label
txV4Label *widget.Label
rxV6Label *widget.Label
txV6Label *widget.Label
rxTotalLabel *widget.Label
txTotalLabel *widget.Label
routingModeLabel *widget.Label
cidrV4Label *widget.Label
cidrV6Label *widget.Label
refreshCIDRBtn *widget.Button
connectBtn *widget.Button
disconnectBtn *widget.Button
@@ -42,7 +58,9 @@ type App struct {
ipcClient *ipc.Client
profiles []model.ServerProfile
currentProfile *model.ServerProfile
defaultProfileID int64
langSetting string
windowHidden bool
}
// Run initialises and starts the GUI application.
@@ -55,6 +73,17 @@ func Run() {
// Logging.
log.Init(log.RoleGUI, paths.LogFile())
// Single-instance enforcement: the first instance holds the lock;
// a second instance signals "focus" to the first and exits.
focusCh, err := instance.Acquire()
if err != nil {
if errors.Is(err, instance.ErrAlreadyRunning) {
log.L().Info("another instance is running, exiting")
os.Exit(0)
}
log.L().Warn("instance lock failed, continuing", "error", err)
}
// Database.
store, err := db.Open()
if err != nil {
@@ -74,6 +103,8 @@ func Run() {
db: store,
kc: keychain.New(),
langSetting: cfg.Language,
defaultProfileID: cfg.DefaultProfileID,
listSelectedIndex: -1,
}
a.window = a.fyneApp.NewWindow(i18n.T("WindowTitle"))
@@ -88,6 +119,46 @@ func Run() {
// System tray.
a.setupTray()
// Listen for "focus" signals from second instances and bring the
// window to the front.
if focusCh != nil {
go func() {
for range focusCh {
fyne.Do(func() {
a.windowHidden = false
activateApp()
showDockIcon()
a.window.Show()
a.window.RequestFocus()
})
}
}()
}
// Register a Cocoa handler so that re-opening the .app bundle
// (which does NOT start a new process on macOS) brings the hidden
// window back. On other platforms this is a no-op. This must be
// deferred until after the Fyne/GLFW event loop has started,
// because GLFWApplicationDelegate is not created until glfw.Init()
// runs inside runGL().
a.fyneApp.Lifecycle().SetOnStarted(func() {
onAppActive = func() {
if a.windowHidden {
a.windowHidden = false
activateApp()
showDockIcon()
fyne.Do(func() {
if a.windowHidden {
return
}
a.window.Show()
a.window.RequestFocus()
})
}
}
registerReopenHandler()
})
// Auto-connect if configured.
if cfg.AutoConnect && a.currentProfile != nil {
go func() {
@@ -98,16 +169,30 @@ func Run() {
a.window.SetCloseIntercept(func() {
if cfg.CloseToTray {
a.windowHidden = true
hideDockIcon()
a.window.Hide()
} else {
a.fyneApp.Quit()
a.quit()
}
})
a.window.ShowAndRun()
}
// quit disconnects any active VPN session (via the daemon) and then
// quits the application. The daemon process itself remains running.
func (a *App) quit() {
a.mu.Lock()
client := a.ipcClient
a.ipcClient = nil
a.mu.Unlock()
if client != nil {
_ = ipc.SendStop(client)
}
a.fyneApp.Quit()
}
// loadProfiles loads all profiles from the database and populates the
// selector.
func (a *App) loadProfiles() {
@@ -120,13 +205,36 @@ func (a *App) loadProfiles() {
names := a.profileNames()
a.profileSelect.Options = names
if len(names) > 0 {
selected := false
if a.defaultProfileID > 0 {
for i := range a.profiles {
if a.profiles[i].ID == a.defaultProfileID {
a.profileSelect.SetSelectedIndex(i)
a.selectProfileByName(a.profiles[i].Name)
selected = true
break
}
}
}
if !selected {
a.profileSelect.SetSelectedIndex(0)
a.selectProfileByName(names[0])
}
} else {
a.currentProfile = nil
a.profileSelect.SetSelected("")
a.profileSelect.Refresh()
}
a.listSelectedIndex = -1
a.refreshProfileList()
}
// refreshProfileList refreshes the profile list widget if the profile
// list window is currently open.
func (a *App) refreshProfileList() {
if a.profileList != nil {
a.profileList.Refresh()
}
}
// profileNames returns the names of all loaded profiles.
@@ -148,44 +256,31 @@ func (a *App) selectProfileByName(name string) {
}
}
// saveDefaultProfile persists the currently selected profile ID to the
// config file so it can be restored on the next launch.
func (a *App) saveDefaultProfile() {
if a.currentProfile == nil {
return
}
a.defaultProfileID = a.currentProfile.ID
cfg, err := config.Load()
if err != nil {
cfg = config.Default()
}
if cfg.DefaultProfileID == a.currentProfile.ID {
return
}
cfg.DefaultProfileID = a.currentProfile.ID
if err := config.Save(cfg); err != nil {
log.L().Error("save default profile", "error", err)
}
}
// onAddProfile shows a dialog to create a new profile.
func (a *App) onAddProfile() {
a.showProfileDialog(nil)
}
// onEditProfile shows a dialog to edit the current profile.
func (a *App) onEditProfile() {
if a.currentProfile == nil {
dialog.NewCustom(i18n.T("DlgNoProfileTitle"), i18n.T("BtnOK"),
widget.NewLabel(i18n.T("DlgNoProfileEditMsg")), a.window).Show()
return
}
p := *a.currentProfile
a.showProfileDialog(&p)
}
// onDeleteProfile deletes the current profile after confirmation.
func (a *App) onDeleteProfile() {
if a.currentProfile == nil {
return
}
name := a.currentProfile.Name
dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"),
i18n.T("BtnDelete"), i18n.T("BtnCancel"),
widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": name})),
func(ok bool) {
if !ok {
return
}
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
showError(i18n.T("DlgError"), err.Error(), a.window)
return
}
_ = a.kc.DeleteAll(name)
a.loadProfiles()
}, a.window).Show()
}
// onResetDB deletes the SQLite database file after confirmation,
// then re-creates it. All profiles, credentials, and logs are lost.
func (a *App) onResetDB() {
@@ -232,11 +327,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 +376,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 +402,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
}
+18
View File
@@ -0,0 +1,18 @@
//go:build darwin
package ui
import "C"
// onAppActive is called from the Cocoa main thread when the app
// becomes active (e.g. the user re-opens the .app bundle while the
// process is still running). It is set once in Run() before the
// event loop starts, so no synchronisation is needed.
var onAppActive func()
//export cmAppBecameActive
func cmAppBecameActive() {
if onAppActive != nil {
onAppActive()
}
}
+41
View File
@@ -8,13 +8,54 @@ package ui
#include <objc/runtime.h>
#include <objc/message.h>
// Forward declaration: Go callback (defined via //export in
// dock_callback_darwin.go). Called when the user re-opens the .app
// bundle while the process is still running (e.g. after closing the
// window to tray).
extern void cmAppBecameActive(void);
// appShouldHandleReopen is the IMP added to GLFWApplicationDelegate
// for the applicationShouldHandleReopen:hasVisibleWindows: selector.
// This is the delegate method macOS calls when the user re-opens an
// already-running .app bundle. We return YES so macOS proceeds with
// its default activation, and invoke the Go callback to show the
// hidden window.
static BOOL appShouldHandleReopen(id self, SEL cmd, id sender, BOOL flag) {
cmAppBecameActive();
return YES;
}
static void setDockIconVisible(int visible) {
Class cls = objc_getClass("NSApplication");
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
((void (*)(id, SEL, long))objc_msgSend)(app, sel_getUid("setActivationPolicy:"), visible ? 0 : 1);
}
static void cmActivateApp(void) {
Class cls = objc_getClass("NSApplication");
id app = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("sharedApplication"));
((void (*)(id, SEL, BOOL))objc_msgSend)(app, sel_getUid("activateIgnoringOtherApps:"), YES);
}
// cmRegisterReopenHandler adds applicationShouldHandleReopen:
// hasVisibleWindows: to the GLFWApplicationDelegate class at runtime
// (class_addMethod). This lets us detect when macOS re-opens the
// .app bundle without starting a new process (which bypasses our IPC
// single-instance mechanism). If the class already implements the
// method, this is a no-op.
static void cmRegisterReopenHandler(void) {
Class cls = objc_getClass("GLFWApplicationDelegate");
if (!cls) return;
SEL sel = sel_getUid("applicationShouldHandleReopen:hasVisibleWindows:");
if (class_getInstanceMethod(cls, sel)) return;
class_addMethod(cls, sel, (IMP)appShouldHandleReopen, "B@:@B");
}
*/
import "C"
func showDockIcon() { C.setDockIconVisible(1) }
func hideDockIcon() { C.setDockIconVisible(0) }
func activateApp() { C.cmActivateApp() }
func registerReopenHandler() { C.cmRegisterReopenHandler() }
+4
View File
@@ -4,3 +4,7 @@ package ui
func showDockIcon() {}
func hideDockIcon() {}
func activateApp() {}
func registerReopenHandler() {}
+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

+365 -15
View File
@@ -1,13 +1,17 @@
package ui
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"lmvpn/internal/i18n"
"lmvpn/internal/model"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
@@ -16,9 +20,11 @@ import (
// mapped back to the codes stored in the database.
var (
authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)}
routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)}
routeCodes = []string{string(model.RoutingFull), string(model.RoutingProxy), string(model.RoutingBypass)}
protoCodes = []string{"wss", "ws"}
fetchTimingCodes = []string{string(model.FetchBefore), string(model.FetchAfter)}
)
func authModeLabels() []string {
@@ -26,13 +32,17 @@ func authModeLabels() []string {
}
func routeModeLabels() []string {
return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeSplit"), i18n.T("RoutingModeCustom")}
return []string{i18n.T("RoutingModeFull"), i18n.T("RoutingModeProxy"), i18n.T("RoutingModeBypass")}
}
func protoLabels() []string {
return []string{"wss", "ws"}
}
func fetchTimingLabels() []string {
return []string{i18n.T("FetchTimingBefore"), i18n.T("FetchTimingAfter")}
}
// codeIndex returns the position of code in codes, or 0 if not found.
func codeIndex(codes []string, code string) int {
for i, c := range codes {
@@ -51,6 +61,100 @@ func selectedCode(codes []string, idx int) string {
return codes[idx]
}
// urlEntryRow holds the widgets for a single CIDR URL source row.
type urlEntryRow struct {
urlEntry *widget.Entry
timingSelect *widget.Select
container *fyne.Container
}
// cidrURLList manages a dynamic list of CIDR URL source rows.
type cidrURLList struct {
rows []*urlEntryRow
container *fyne.Container
parent fyne.Window
}
func newCIDRURLList(parent fyne.Window) *cidrURLList {
cl := &cidrURLList{
container: container.NewVBox(),
parent: parent,
}
return cl
}
func (cl *cidrURLList) addRow(url string, timingIdx int) {
urlEntry := widget.NewEntry()
urlEntry.Wrapping = fyne.TextWrapOff
urlEntry.Scroll = container.ScrollHorizontalOnly
urlEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRURL"))
urlEntry.SetText(url)
timingSelect := widget.NewSelect(fetchTimingLabels(), nil)
if timingIdx >= 0 && timingIdx < len(fetchTimingCodes) {
timingSelect.SetSelectedIndex(timingIdx)
} else {
timingSelect.SetSelectedIndex(0)
}
row := &urlEntryRow{
urlEntry: urlEntry,
timingSelect: timingSelect,
}
removeBtn := widget.NewButton(i18n.T("BtnRemoveURL"), func() {
cl.removeRow(row)
})
row.container = container.NewBorder(nil, nil, nil, removeBtn,
container.NewVBox(urlEntry, timingSelect))
cl.rows = append(cl.rows, row)
cl.container.Add(row.container)
}
func (cl *cidrURLList) removeRow(row *urlEntryRow) {
for i, r := range cl.rows {
if r == row {
cl.rows = append(cl.rows[:i], cl.rows[i+1:]...)
cl.container.Remove(row.container)
break
}
}
}
func (cl *cidrURLList) loadFromJSON(jsonStr string) {
for _, r := range cl.rows {
cl.container.Remove(r.container)
}
cl.rows = nil
sources := model.ParseCIDRURLs(jsonStr)
for _, s := range sources {
timingIdx := codeIndex(fetchTimingCodes, string(s.FetchTiming))
cl.addRow(s.URL, timingIdx)
}
}
func (cl *cidrURLList) toSources() []model.CIDRURLSource {
var sources []model.CIDRURLSource
for _, r := range cl.rows {
url := strings.TrimSpace(r.urlEntry.Text)
if url == "" {
continue
}
sources = append(sources, model.CIDRURLSource{
URL: url,
FetchTiming: model.FetchTiming(selectedCode(fetchTimingCodes, r.timingSelect.SelectedIndex())),
})
}
return sources
}
func (cl *cidrURLList) toJSON() string {
return marshalCIDRURLs(cl.toSources())
}
// showProfileDialog opens a separate window for editing a server profile.
// If editing is nil, a new profile is created. If a profile window is
// already open, it is brought to the front instead of opening a second one.
@@ -95,17 +199,95 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
authSelect := widget.NewSelect(authModeLabels(), nil)
routeSelect := widget.NewSelect(routeModeLabels(), nil)
cidrEntry := widget.NewMultiLineEntry()
cidrEntry.Wrapping = fyne.TextWrapOff
cidrEntry.Scroll = container.ScrollNone
cidrEntry.SetMinRowsVisible(4)
cidrEntry.SetPlaceHolder(i18n.T("PlaceholderCIDRs"))
cidrV4Entry := widget.NewMultiLineEntry()
cidrV4Entry.Wrapping = fyne.TextWrapOff
cidrV4Entry.Scroll = container.ScrollNone
cidrV4Entry.SetMinRowsVisible(3)
cidrV4Entry.SetPlaceHolder(i18n.T("PlaceholderCIDRV4"))
cidrV6Entry := widget.NewMultiLineEntry()
cidrV6Entry.Wrapping = fyne.TextWrapOff
cidrV6Entry.Scroll = container.ScrollNone
cidrV6Entry.SetMinRowsVisible(3)
cidrV6Entry.SetPlaceHolder(i18n.T("PlaceholderCIDRV6"))
mtuEntry := widget.NewEntry()
mtuEntry.Wrapping = fyne.TextWrapOff
mtuEntry.Scroll = container.ScrollNone
mtuEntry.SetPlaceHolder(i18n.T("PlaceholderMTU"))
tlsCaPEMEntry := widget.NewMultiLineEntry()
tlsCaPEMEntry.Wrapping = fyne.TextWrapOff
tlsCaPEMEntry.Scroll = container.ScrollNone
tlsCaPEMEntry.SetMinRowsVisible(4)
tlsCaPEMEntry.SetPlaceHolder(i18n.T("PlaceholderTLSCACert"))
tlsCaPathEntry := widget.NewEntry()
tlsCaPathEntry.Wrapping = fyne.TextWrapOff
tlsCaPathEntry.Scroll = container.ScrollNone
tlsCaPathEntry.SetPlaceHolder(i18n.T("PlaceholderTLSCAPath"))
tlsInsecureCheck := widget.NewCheck(i18n.T("FieldTLSInsecure"), nil)
tlsPinnedHashEntry := widget.NewEntry()
tlsPinnedHashEntry.Wrapping = fyne.TextWrapOff
tlsPinnedHashEntry.Scroll = container.ScrollNone
tlsPinnedHashEntry.SetPlaceHolder(i18n.T("PlaceholderTLSPinnedHash"))
var profileWin fyne.Window
browseBtn := widget.NewButton(i18n.T("BtnBrowse"), func() {
dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil || reader == nil {
return
}
defer reader.Close()
tlsCaPathEntry.SetText(reader.URI().Path())
}, profileWin).Show()
})
setTLSEnabled := func(enabled bool) {
if enabled {
tlsCaPEMEntry.Enable()
tlsCaPathEntry.Enable()
browseBtn.Enable()
tlsInsecureCheck.Enable()
tlsPinnedHashEntry.Enable()
} else {
tlsCaPEMEntry.Disable()
tlsCaPathEntry.Disable()
browseBtn.Disable()
tlsInsecureCheck.Disable()
tlsPinnedHashEntry.Disable()
}
}
// CIDR URL lists for IPv4 and IPv6.
v4URLList := newCIDRURLList(profileWin)
v6URLList := newCIDRURLList(profileWin)
v4AddURLBtn := widget.NewButton(i18n.T("BtnAddURL"), func() {
v4URLList.addRow("", 0)
})
v6AddURLBtn := widget.NewButton(i18n.T("BtnAddURL"), func() {
v6URLList.addRow("", 0)
})
// CIDR section visibility: hide when routing mode is "full".
cidrV4EntryBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV4")), cidrV4Entry)
cidrV6EntryBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV6")), cidrV6Entry)
v4URLBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV4URLs")), v4URLList.container, v4AddURLBtn)
v6URLBox := container.NewVBox(widget.NewLabel(i18n.T("FieldCIDRV6URLs")), v6URLList.container, v6AddURLBtn)
cidrSection := container.NewVBox(cidrV4EntryBox, cidrV6EntryBox, v4URLBox, v6URLBox)
setCIDRVisible := func(mode string) {
if mode == string(model.RoutingFull) {
cidrSection.Hide()
} else {
cidrSection.Show()
}
}
if !isNew {
nameEntry.SetText(editing.Name)
protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol))
@@ -118,8 +300,15 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
userEntry.SetText(editing.Username)
authSelect.SetSelectedIndex(codeIndex(authCodes, string(editing.AuthMode)))
routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode)))
cidrEntry.SetText(editing.CustomCIDRs)
cidrV4Entry.SetText(editing.CIDRV4)
cidrV6Entry.SetText(editing.CIDRV6)
v4URLList.loadFromJSON(editing.CIDRV4URLs)
v6URLList.loadFromJSON(editing.CIDRV6URLs)
mtuEntry.SetText(fmtInt(editing.MTUOverride))
tlsCaPEMEntry.SetText(editing.TLSCACert)
tlsCaPathEntry.SetText(editing.TLSCAPath)
tlsInsecureCheck.SetChecked(editing.TLSInsecure)
tlsPinnedHashEntry.SetText(editing.TLSPinnedHash)
passEntry.SetPlaceHolder(i18n.T("PlaceholderPasswordUnchanged"))
} else {
protoSelect.SetSelectedIndex(0) // wss
@@ -130,6 +319,16 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
mtuEntry.SetText("0")
}
protoSelect.OnChanged = func(value string) {
setTLSEnabled(value == "wss")
}
setTLSEnabled(selectedCode(protoCodes, protoSelect.SelectedIndex()) == "wss")
routeSelect.OnChanged = func(_ string) {
setCIDRVisible(selectedCode(routeCodes, routeSelect.SelectedIndex()))
}
setCIDRVisible(selectedCode(routeCodes, routeSelect.SelectedIndex()))
form := container.NewVBox(
widget.NewLabel(i18n.T("FieldName")),
nameEntry,
@@ -155,13 +354,28 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
container.NewVBox(widget.NewLabel(i18n.T("FieldRoutingMode")), routeSelect),
),
widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry,
cidrSection,
widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry,
widget.NewLabel(i18n.T("FieldTLS")),
widget.NewLabel(i18n.T("FieldTLSCAPath")),
container.NewBorder(nil, nil, nil, browseBtn, tlsCaPathEntry),
widget.NewLabel(i18n.T("FieldTLSCACert")),
tlsCaPEMEntry,
widget.NewLabel(i18n.T("HintTLSCAReplacesSystem")),
tlsInsecureCheck,
widget.NewLabel(i18n.T("FieldTLSPinnedHash")),
tlsPinnedHashEntry,
)
profileWin := a.fyneApp.NewWindow(i18n.T("DlgProfileTitle"))
profileWin = a.fyneApp.NewWindow(i18n.T("DlgProfileTitle"))
a.profileWindow = profileWin
// Update parent references now that the window exists.
v4URLList.parent = profileWin
v6URLList.parent = profileWin
profileWin.SetOnClosed(func() {
a.profileWindow = nil
})
@@ -175,7 +389,12 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
userEntry.Text, passEntry.Text,
selectedCode(authCodes, authSelect.SelectedIndex()),
selectedCode(routeCodes, routeSelect.SelectedIndex()),
cidrEntry.Text, mtuEntry.Text, isNew) {
cidrV4Entry.Text, cidrV6Entry.Text,
v4URLList.toJSON(), v6URLList.toJSON(),
mtuEntry.Text,
tlsCaPEMEntry.Text, tlsCaPathEntry.Text,
tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked,
isNew) {
profileWin.Close()
}
})
@@ -186,19 +405,32 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) {
})
profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, container.NewVScroll(form)))
profileWin.Resize(fyne.NewSize(460, 560))
profileWin.Resize(fyne.NewSize(460, 860))
profileWin.Show()
}
// saveProfile creates or updates a profile and stores credentials.
// Returns true on success, false if validation or DB operation failed.
func (a *App) saveProfile(editing *model.ServerProfile,
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) bool {
name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode,
cidrV4, cidrV6, cidrV4URLs, cidrV6URLs, mtuStr,
tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool {
if name == "" || host == "" || user == "" {
showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window)
return false
}
if ips != "" {
tmp := &model.ServerProfile{ServerIPs: ips}
_, invalid := tmp.ValidateServerIPs()
if len(invalid) > 0 {
showError(i18n.T("DlgValidationTitle"),
fmt.Sprintf(i18n.T("DlgInvalidIPMsg"), strings.Join(invalid, ", ")),
a.window)
return false
}
}
port := parseIntDefault(portStr, 443)
mtu := parseIntDefault(mtuStr, 0)
@@ -213,8 +445,15 @@ func (a *App) saveProfile(editing *model.ServerProfile,
Username: user,
AuthMode: model.AuthMode(authMode),
RoutingMode: model.RoutingMode(routeMode),
CustomCIDRs: cidrs,
CIDRV4: cidrV4,
CIDRV6: cidrV6,
CIDRV4URLs: cidrV4URLs,
CIDRV6URLs: cidrV6URLs,
MTUOverride: mtu,
TLSCACert: tlsCaPEM,
TLSCAPath: tlsCaPath,
TLSInsecure: tlsInsecure,
TLSPinnedHash: tlsPinnedHash,
}
id, err := a.db.CreateProfile(p)
if err != nil {
@@ -238,8 +477,15 @@ func (a *App) saveProfile(editing *model.ServerProfile,
editing.Username = user
editing.AuthMode = model.AuthMode(authMode)
editing.RoutingMode = model.RoutingMode(routeMode)
editing.CustomCIDRs = cidrs
editing.CIDRV4 = cidrV4
editing.CIDRV6 = cidrV6
editing.CIDRV4URLs = cidrV4URLs
editing.CIDRV6URLs = cidrV6URLs
editing.MTUOverride = mtu
editing.TLSCACert = tlsCaPEM
editing.TLSCAPath = tlsCaPath
editing.TLSInsecure = tlsInsecure
editing.TLSPinnedHash = tlsPinnedHash
if err := a.db.UpdateProfile(editing); err != nil {
showError(i18n.T("DlgSaveError"), err.Error(), a.window)
return false
@@ -256,6 +502,16 @@ func (a *App) saveProfile(editing *model.ServerProfile,
return true
}
// marshalCIDRURLsForDB is a helper for tests/debugging that encodes
// CIDRURLSource slice to JSON.
func marshalCIDRURLsForDB(sources []model.CIDRURLSource) string {
data, err := json.Marshal(sources)
if err != nil {
return ""
}
return string(data)
}
func fmtInt(n int) string {
return strconv.Itoa(n)
}
@@ -267,3 +523,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()
}
+18 -7
View File
@@ -46,24 +46,35 @@ func (a *App) setupTray() {
autoItem, enItem, zhItem,
)
connectItem := fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
a.onConnect()
})
disconnectItem := fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
a.onDisconnect()
})
if a.connectBtn != nil {
connectItem.Disabled = a.connectBtn.Disabled()
}
if a.disconnectBtn != nil {
disconnectItem.Disabled = a.disconnectBtn.Disabled()
}
menu := fyne.NewMenu(i18n.T("WindowTitle"),
fyne.NewMenuItem(i18n.T("TrayShowWindow"), func() {
a.windowHidden = false
activateApp()
showDockIcon()
a.window.Show()
a.window.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem(i18n.T("TrayConnect"), func() {
a.onConnect()
}),
fyne.NewMenuItem(i18n.T("TrayDisconnect"), func() {
a.onDisconnect()
}),
connectItem,
disconnectItem,
fyne.NewMenuItemSeparator(),
langItem,
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem(i18n.T("TrayQuit"), func() {
a.fyneApp.Quit()
a.quit()
}),
)
deskApp.SetSystemTrayMenu(menu)
+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
+373 -58
View File
@@ -1,44 +1,98 @@
package ui
import (
_ "embed"
"encoding/json"
"fmt"
"net/url"
"time"
"lmvpn/internal/i18n"
"lmvpn/internal/ipc"
"lmvpn/internal/model"
"lmvpn/internal/stats"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
//go:embed github.svg
var githubIconBytes []byte
// showError displays a titled error dialog.
func showError(title, message string, parent fyne.Window) {
dialog.NewCustom(title, i18n.T("BtnOK"), widget.NewLabel(message), parent).Show()
}
// setConnButtons enables/disables the connect and disconnect buttons
// and refreshes the tray menu to match.
func (a *App) setConnButtons(connectEnabled, disconnectEnabled bool) {
if connectEnabled {
a.connectBtn.Enable()
} else {
a.connectBtn.Disable()
}
if disconnectEnabled {
a.disconnectBtn.Enable()
} else {
a.disconnectBtn.Disable()
}
if a.profileSelect != nil {
if connectEnabled {
a.profileSelect.Enable()
} else {
a.profileSelect.Disable()
}
}
a.setupTray()
}
// buildMainWindow creates the main application window layout.
func (a *App) buildMainWindow() fyne.CanvasObject {
// Profile selector.
a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) {
a.selectProfileByName(sel)
a.saveDefaultProfile()
})
// Status display.
a.stateLabel = widget.NewLabel(i18n.T("StateDisconnected"))
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
a.ipLabel = widget.NewLabel(i18n.T("IpNone"))
a.ip6Label = widget.NewLabel(i18n.T("Ip6None"))
a.uptimeLabel = widget.NewLabel(i18n.T("UptimeNone"))
a.rxLabel = widget.NewLabel(i18n.T("RxZero"))
a.txLabel = widget.NewLabel(i18n.T("TxZero"))
a.rxV4Label = widget.NewLabel(i18n.T("RxV4Zero"))
a.txV4Label = widget.NewLabel(i18n.T("TxV4Zero"))
a.rxV6Label = widget.NewLabel(i18n.T("RxV6Zero"))
a.txV6Label = widget.NewLabel(i18n.T("TxV6Zero"))
a.rxTotalLabel = widget.NewLabel(i18n.T("RxTotalZero"))
a.txTotalLabel = widget.NewLabel(i18n.T("TxTotalZero"))
a.routingModeLabel = widget.NewLabel("")
a.routingModeLabel.Hide()
a.cidrV4Label = widget.NewLabel("")
a.cidrV4Label.Hide()
a.cidrV6Label = widget.NewLabel("")
a.cidrV6Label.Hide()
a.refreshCIDRBtn = widget.NewButton(i18n.T("BtnRefreshCIDR"), a.onRefreshCIDR)
a.refreshCIDRBtn.Hide()
statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox(
a.stateLabel,
a.ipLabel,
a.ip6Label,
a.uptimeLabel,
container.NewHBox(a.rxLabel, a.txLabel),
container.NewHBox(a.rxV4Label, a.txV4Label),
container.NewHBox(a.rxV6Label, a.txV6Label),
container.NewHBox(a.rxTotalLabel, a.txTotalLabel),
a.routingModeLabel,
a.cidrV4Label,
a.cidrV6Label,
a.refreshCIDRBtn,
))
// Buttons.
@@ -47,28 +101,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 +139,7 @@ func (a *App) onConnect() {
return
}
a.connectBtn.Disable()
a.disconnectBtn.Enable()
a.setConnButtons(false, true)
a.stateLabel.SetText(i18n.T("StateConnecting"))
go func() {
@@ -89,15 +149,24 @@ func (a *App) onConnect() {
fyne.Do(func() {
showError(i18n.T("DlgDaemonError"), err.Error(), a.window)
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.connectBtn.Enable()
a.disconnectBtn.Disable()
a.setConnButtons(true, false)
})
return
}
a.mu.Lock()
oldClient := a.ipcClient
a.ipcClient = client
a.mu.Unlock()
if oldClient != nil {
// Do NOT send SendStop on the old connection. The daemon's
// startSession already calls stopSessionLocked() which
// tears down any existing session. Sending SendStop here
// races with SendStart on the new connection - the daemon
// processes each IPC connection in a separate goroutine,
// so a late SendStop could kill the newly started session.
oldClient.Close()
}
// Get password from keychain.
password, err := a.kc.GetPassword(a.currentProfile.Name)
@@ -107,8 +176,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
}
@@ -130,15 +198,21 @@ func (a *App) onConnect() {
Password: password,
AuthMode: string(p.AuthMode),
RoutingMode: string(p.RoutingMode),
CustomCIDRs: splitCIDRs(p.CustomCIDRs),
CIDRV4: model.SplitCIDRs(p.CIDRV4),
CIDRV6: model.SplitCIDRs(p.CIDRV6),
CIDRV4URLs: parseIPCCIDRURLs(p.CIDRV4URLs),
CIDRV6URLs: parseIPCCIDRURLs(p.CIDRV6URLs),
MTUOverride: p.MTUOverride,
TLSCACert: p.TLSCACert,
TLSCAPath: p.TLSCAPath,
TLSInsecure: p.TLSInsecure,
TLSPinnedHash: p.TLSPinnedHash,
}
if err := ipc.SendStart(client, cfg); err != nil {
fyne.Do(func() {
showError(i18n.T("DlgIPCError"), err.Error(), a.window)
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.connectBtn.Enable()
a.disconnectBtn.Disable()
a.setConnButtons(true, false)
})
return
}
@@ -148,18 +222,50 @@ func (a *App) onConnect() {
}()
}
// onDisconnect handles the Disconnect button click.
func (a *App) onDisconnect() {
// onRefreshCIDR handles the Refresh CIDR button click.
func (a *App) onRefreshCIDR() {
a.mu.Lock()
client := a.ipcClient
a.mu.Unlock()
if client == nil {
return
}
a.refreshCIDRBtn.Disable()
go func() {
_ = ipc.SendRefreshCIDR(client)
time.Sleep(2 * time.Second)
fyne.Do(func() {
a.refreshCIDRBtn.Enable()
})
}()
}
// onDisconnect handles the Disconnect button click.
func (a *App) onDisconnect() {
a.mu.Lock()
client := a.ipcClient
a.ipcClient = nil
a.mu.Unlock()
if client == nil {
return
}
_ = ipc.SendStop(client)
a.disconnectBtn.Disable()
a.connectBtn.Enable()
client.Close()
a.setConnButtons(true, false)
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.ipLabel.SetText(i18n.T("IpNone"))
a.ip6Label.SetText(i18n.T("Ip6None"))
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
a.txV4Label.SetText(i18n.T("TxV4Zero"))
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
a.txV6Label.SetText(i18n.T("TxV6Zero"))
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
a.routingModeLabel.Hide()
a.cidrV4Label.Hide()
a.cidrV6Label.Hide()
a.refreshCIDRBtn.Hide()
}
// eventLoop reads IPC events from the daemon and updates the UI.
@@ -181,11 +287,19 @@ func (a *App) eventLoop() {
if current == client {
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.ipLabel.SetText(i18n.T("IpNone"))
a.ip6Label.SetText(i18n.T("Ip6None"))
a.uptimeLabel.SetText(i18n.T("UptimeNone"))
a.rxLabel.SetText(i18n.T("RxZero"))
a.txLabel.SetText(i18n.T("TxZero"))
a.connectBtn.Enable()
a.disconnectBtn.Disable()
a.rxV4Label.SetText(i18n.T("RxV4Zero"))
a.txV4Label.SetText(i18n.T("TxV4Zero"))
a.rxV6Label.SetText(i18n.T("RxV6Zero"))
a.txV6Label.SetText(i18n.T("TxV6Zero"))
a.rxTotalLabel.SetText(i18n.T("RxTotalZero"))
a.txTotalLabel.SetText(i18n.T("TxTotalZero"))
a.routingModeLabel.Hide()
a.cidrV4Label.Hide()
a.cidrV6Label.Hide()
a.refreshCIDRBtn.Hide()
a.setConnButtons(true, false)
}
})
return
@@ -193,49 +307,102 @@ func (a *App) eventLoop() {
switch ev.Event {
case ipc.EvState:
fyne.Do(func() {
a.applyState(ev.State)
a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current == client {
a.applyStateWithStep(ev.State, ev.ConnectStep)
}
})
case ipc.EvStats:
if ev.Stats != nil {
s := *ev.Stats
fyne.Do(func() {
a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current == client {
a.applyStats(s)
}
})
}
case ipc.EvError:
fyne.Do(func() {
if ev.Message != "" {
showError(i18n.T("DlgVPNError"), ev.Message, a.window)
a.mu.Lock()
current := a.ipcClient
a.mu.Unlock()
if current != client {
return
}
if ev.Code == "tls_error" {
showError(i18n.T("DlgTLSError"),
i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window)
} else {
msg := authErrorMessage(ev.Code, ev.Message)
if msg != "" {
showError(i18n.T("DlgAuthError"), msg, a.window)
}
}
})
}
}
}
// authErrorMessage maps a stable auth-error code (carried by the EvError
// IPC event from the daemon) to a localized user-facing string. For an
// unknown or empty code it falls back to the raw server message.
func authErrorMessage(code, fallback string) string {
switch code {
case "wrong_credentials":
return i18n.T("AuthErrWrongCredentials")
case "user_disabled":
return i18n.T("AuthErrUserDisabled")
case "token_invalid":
return i18n.T("AuthErrTokenInvalid")
case "rate_limited":
return i18n.T("AuthErrRateLimited")
case "malformed":
return i18n.T("AuthErrMalformed")
default:
return fallback
}
}
// applyState updates UI elements for a state change.
func (a *App) applyState(state string) {
a.applyStateWithStep(state, "")
}
// applyStateWithStep updates UI elements for a state change, optionally
// showing a connection step description (e.g. "fetching CIDR lists").
func (a *App) applyStateWithStep(state, step string) {
stepLabel := connectStepLabel(step)
switch stats.State(state) {
case stats.StateConnected:
if stepLabel != "" {
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
} else {
a.stateLabel.SetText(i18n.T("StateConnected"))
a.connectBtn.Disable()
a.disconnectBtn.Enable()
}
a.setConnButtons(false, true)
case stats.StateConnecting:
if stepLabel != "" {
a.stateLabel.SetText(i18n.T("StateConnecting") + " (" + stepLabel + ")")
} else {
a.stateLabel.SetText(i18n.T("StateConnecting"))
a.connectBtn.Disable()
a.disconnectBtn.Enable()
}
a.setConnButtons(false, true)
case stats.StateReconnecting:
a.stateLabel.SetText(i18n.T("StateReconnecting"))
a.connectBtn.Disable()
a.disconnectBtn.Enable()
a.setConnButtons(false, true)
case stats.StateDisconnected:
a.stateLabel.SetText(i18n.T("StateDisconnected"))
a.ipLabel.SetText(i18n.T("IpNone"))
a.connectBtn.Enable()
a.disconnectBtn.Disable()
a.ip6Label.SetText(i18n.T("Ip6None"))
a.setConnButtons(true, false)
case stats.StateError:
a.stateLabel.SetText(i18n.T("StateError"))
a.connectBtn.Enable()
a.disconnectBtn.Disable()
a.setConnButtons(true, false)
}
}
@@ -244,14 +411,133 @@ func (a *App) applyStats(s stats.Snapshot) {
if s.AssignedIP != "" {
a.ipLabel.SetText(i18n.T("IpLabel", map[string]interface{}{"ip": s.AssignedIP}))
}
if s.AssignedIP6 != "" {
a.ip6Label.SetText(i18n.T("Ip6Label", map[string]interface{}{"ip": s.AssignedIP6}))
} else {
a.ip6Label.SetText(i18n.T("Ip6None"))
}
if s.State == stats.StateConnected {
stepLabel := connectStepLabel(s.ConnectStep)
if stepLabel != "" {
a.stateLabel.SetText(i18n.T("StateConnected") + " (" + stepLabel + ")")
} else {
a.stateLabel.SetText(i18n.T("StateConnected"))
}
a.rxLabel.SetText(i18n.T("RxLabel", map[string]interface{}{"bytes": formatBytes(s.RxBytes)}))
a.txLabel.SetText(i18n.T("TxLabel", map[string]interface{}{"bytes": formatBytes(s.TxBytes)}))
}
a.rxV4Label.SetText(i18n.T("RxV4Label", map[string]interface{}{
"bytes": formatBytes(s.RxBytesV4), "speed": formatSpeed(s.RxSpeedV4),
}))
a.txV4Label.SetText(i18n.T("TxV4Label", map[string]interface{}{
"bytes": formatBytes(s.TxBytesV4), "speed": formatSpeed(s.TxSpeedV4),
}))
a.rxV6Label.SetText(i18n.T("RxV6Label", map[string]interface{}{
"bytes": formatBytes(s.RxBytesV6), "speed": formatSpeed(s.RxSpeedV6),
}))
a.txV6Label.SetText(i18n.T("TxV6Label", map[string]interface{}{
"bytes": formatBytes(s.TxBytesV6), "speed": formatSpeed(s.TxSpeedV6),
}))
a.rxTotalLabel.SetText(i18n.T("RxTotalLabel", map[string]interface{}{
"bytes": formatBytes(s.RxBytes), "speed": formatSpeed(s.RxSpeed),
}))
a.txTotalLabel.SetText(i18n.T("TxTotalLabel", map[string]interface{}{
"bytes": formatBytes(s.TxBytes), "speed": formatSpeed(s.TxSpeed),
}))
if s.Uptime > 0 {
a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)}))
}
// Routing mode + CIDR hit statistics.
if s.RoutingMode != "" {
modeLabel := routeModeLabel(s.RoutingMode)
if s.RouteLoading {
modeLabel += " (" + i18n.T("RouteLoading") + ")"
}
a.routingModeLabel.SetText(i18n.T("StatusRoutingMode", map[string]interface{}{"mode": modeLabel}))
a.routingModeLabel.Show()
// Show CIDR error if present.
if s.CIDRError != "" {
a.cidrV4Label.SetText("IPv4 CIDR: " + i18n.T("CIDRFetchError"))
a.cidrV6Label.SetText("IPv6 CIDR: " + i18n.T("CIDRFetchError"))
a.cidrV4Label.Show()
a.cidrV6Label.Show()
a.refreshCIDRBtn.Show()
} else {
switch s.RoutingMode {
case "proxy":
if s.RouteLoading {
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)",
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading")))
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)",
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading")))
} else {
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d %s",
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRHit")))
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d %s",
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRHit")))
}
a.cidrV4Label.Show()
a.cidrV6Label.Show()
a.refreshCIDRBtn.Show()
case "bypass":
if s.RouteLoading {
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d/%d (%s)",
s.CIDRV4Hits, s.CIDRV4Total, i18n.T("CIDRLoading")))
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d/%d (%s)",
s.CIDRV6Hits, s.CIDRV6Total, i18n.T("CIDRLoading")))
} else {
a.cidrV4Label.SetText(fmt.Sprintf("IPv4 CIDR: %d %s | %s: %d %s",
s.CIDRV4Total, i18n.T("CIDRConfigured"),
i18n.T("CIDRUnmatched"), s.CIDRV4Hits, i18n.T("CIDRDestinations")))
a.cidrV6Label.SetText(fmt.Sprintf("IPv6 CIDR: %d %s | %s: %d %s",
s.CIDRV6Total, i18n.T("CIDRConfigured"),
i18n.T("CIDRUnmatched"), s.CIDRV6Hits, i18n.T("CIDRDestinations")))
}
a.cidrV4Label.Show()
a.cidrV6Label.Show()
a.refreshCIDRBtn.Show()
default:
a.cidrV4Label.Hide()
a.cidrV6Label.Hide()
a.refreshCIDRBtn.Hide()
}
}
} else {
a.routingModeLabel.Hide()
a.cidrV4Label.Hide()
a.cidrV6Label.Hide()
a.refreshCIDRBtn.Hide()
}
}
// routeModeLabel returns the localised display name for a routing mode
// code string.
func routeModeLabel(code string) string {
switch code {
case "full":
return i18n.T("RoutingModeFull")
case "proxy":
return i18n.T("RoutingModeProxy")
case "bypass":
return i18n.T("RoutingModeBypass")
default:
return code
}
}
// connectStepLabel translates a connection step code to a localised
// display string. Returns "" for unknown/empty steps.
func connectStepLabel(step string) string {
switch step {
case "fetch_cidrs":
return i18n.T("StepFetchCIDRs")
case "connecting":
return i18n.T("StepConnecting")
case "load_routes":
return i18n.T("StepLoadRoutes")
default:
return ""
}
}
// formatBytes formats a byte count human-readably.
@@ -268,6 +554,23 @@ func formatBytes(b int64) string {
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
// formatSpeed formats a bytes/sec rate as a decimal bits/sec rate
// (kbps / Mbps / Gbps). bps is the byte rate; it is converted to bits
// by multiplying by 8 and scaled by 1000 (decimal, network convention).
func formatSpeed(bps int64) string {
const unit = 1000
bits := bps * 8
if bits < unit {
return fmt.Sprintf("%d bps", bits)
}
div, exp := int64(unit), 0
for n := bits / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cbps", float64(bits)/float64(div), "kMGTPE"[exp])
}
// formatDuration formats an uptime duration.
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
@@ -283,20 +586,32 @@ func formatDuration(d time.Duration) string {
return fmt.Sprintf("%ds", s)
}
// splitCIDRs splits a comma-separated CIDR string into a slice.
func splitCIDRs(s string) []string {
if s == "" {
// parseIPCCIDRURLs decodes a JSON-encoded model.CIDRURLSource array
// from the profile string and converts it to the IPC wire type.
func parseIPCCIDRURLs(jsonStr string) []ipc.CIDRURLSource {
sources := model.ParseCIDRURLs(jsonStr)
if len(sources) == 0 {
return nil
}
var out []string
start := 0
for i := 0; i <= len(s); i++ {
if i == len(s) || s[i] == ',' {
if i > start {
out = append(out, s[start:i])
}
start = i + 1
out := make([]ipc.CIDRURLSource, len(sources))
for i, s := range sources {
out[i] = ipc.CIDRURLSource{
URL: s.URL,
FetchTiming: string(s.FetchTiming),
}
}
return out
}
// marshalCIDRURLs encodes a slice of CIDRURLSource to a JSON string
// for storage in the database.
func marshalCIDRURLs(sources []model.CIDRURLSource) string {
if len(sources) == 0 {
return ""
}
data, err := json.Marshal(sources)
if err != nil {
return ""
}
return string(data)
}
+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"
+655 -26
View File
@@ -9,18 +9,24 @@ package vpn
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"lmvpn/internal/auth"
"lmvpn/internal/cidrsource"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/protocol"
"lmvpn/internal/route"
"lmvpn/internal/stats"
"lmvpn/internal/tlsconfig"
"lmvpn/internal/transport"
"lmvpn/internal/tun"
)
@@ -35,8 +41,15 @@ type SessionConfig struct {
AuthMode model.AuthMode
Token string // pre-obtained JWT (empty = fetch via HTTP login)
RoutingMode route.Mode
CustomCIDRs []string
CIDRV4 []string // static IPv4 CIDRs (proxy/bypass mode)
CIDRV6 []string // static IPv6 CIDRs (proxy/bypass mode)
CIDRV4URLs []model.CIDRURLSource // IPv4 CIDR URL sources
CIDRV6URLs []model.CIDRURLSource // IPv6 CIDR URL sources
MTUOverride int // 0 = use server MTU
TLSCACert string // inline CA cert PEM (wss only)
TLSCAPath string // CA cert file path (wss only)
TLSInsecure bool // skip cert verification (wss only)
TLSPinnedHash string // SHA-256 cert pin (wss only)
}
// SessionManager manages a single VPN session with auto-reconnect.
@@ -44,6 +57,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 +65,38 @@ type SessionManager struct {
dev tun.Device
routeMgr *route.Manager
conn *transport.Conn
done chan struct{}
// CIDR hit tracking. Set during setupTUN, cleared on cleanup.
cidrTracker *cidrTracker
// lastCfg stores the session config for RefreshCIDRs.
lastCfg SessionConfig
// EWMA speed smoothing state. Only touched by reportStats (single
// goroutine), so no lock needed. ewma* fields hold the smoothed
// bytes/sec; prev* hold the last snapshot's cumulative counters and
// tick time for delta computation.
ewmaRxV4 float64
ewmaTxV4 float64
ewmaRxV6 float64
ewmaTxV6 float64
prevSnap stats.Snapshot
prevTick time.Time
speedReady bool
}
// New creates a SessionManager. The onState callback (if non-nil) is
// invoked on every state transition. The onStats callback (if non-nil)
// is invoked periodically while connected.
func New(onState func(stats.State), onStats func(stats.Snapshot)) *SessionManager {
// is invoked periodically while connected. The onError callback (if
// non-nil) is invoked once when a fatal, non-retryable error (such as
// an authentication failure) terminates the session.
func New(onState func(stats.State), onStats func(stats.Snapshot), onError func(string, string)) *SessionManager {
return &SessionManager{
stats: stats.New(),
onState: onState,
onStats: onStats,
onError: onError,
}
}
@@ -82,6 +118,8 @@ func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error
ctx, cancel := context.WithCancel(ctx)
sm.cancel = cancel
sm.running = true
sm.done = make(chan struct{})
sm.lastCfg = cfg
sm.mu.Unlock()
go sm.run(ctx, cfg)
@@ -98,23 +136,78 @@ func (sm *SessionManager) Disconnect() {
}
sm.running = false
cancel := sm.cancel
// Extract resources so we can clean them up outside the lock.
// Setting them to nil here also prevents the run goroutine's
// cleanup from double-cleaning (it will see nil and skip).
routeMgr := sm.routeMgr
conn := sm.conn
dev := sm.dev
sm.routeMgr = nil
sm.conn = nil
sm.dev = nil
sm.cidrTracker = nil
sm.mu.Unlock()
if cancel != nil {
cancel()
}
// Close the transport to unblock the packet pump.
sm.mu.Lock()
conn := sm.conn
sm.mu.Unlock()
// CRITICAL: remove routes BEFORE closing the TUN device. If the TUN
// is closed first, the /1 cover routes still point at the dead TUN
// and all traffic is blackholed until routeMgr.Cleanup() runs -
// this causes a brief network outage (browsers, DNS, etc.).
if routeMgr != nil {
if err := routeMgr.Cleanup(); err != nil {
log.L().Error("route cleanup error during disconnect", "error", err)
}
}
// Now safe to close the transport and TUN device.
if conn != nil {
conn.Close()
}
if dev != nil {
dev.Close()
}
// Wait for the run goroutine to fully exit. By now it should be
// unblocked (ctx cancelled, conn/dev closed) and will see nil
// routeMgr/conn/dev in cleanup, so it won't double-clean.
if sm.done != nil {
<-sm.done
}
}
// run is the main session loop with exponential-backoff reconnection
// and CDN IP failover.
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
defer sm.setState(stats.StateDisconnected)
fatal := false
defer close(sm.done)
defer func() {
if !fatal && ctx.Err() == nil {
sm.setState(stats.StateDisconnected)
}
}()
// Fetch "before-proxy" CIDR lists ONCE before the reconnect loop.
// These HTTP requests go through the physical NIC (routes are
// clean). The result is reused across reconnection attempts so we
// don't re-fetch on every retry. This runs outside the handshake
// to avoid consuming the server's 30s ReadyTimeout budget.
var beforeCIDRs []string
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
if len(allURLSources) > 0 {
sm.stats.SetConnectStep("fetch_cidrs")
sm.setState(stats.StateConnecting)
log.L().Info("fetching before-proxy CIDR lists", "url_count", len(allURLSources))
fetched, err := cidrsource.FetchBeforeProxy(ctx, allURLSources)
if err != nil {
log.L().Error("fetch before-proxy CIDR lists failed (continuing)", "error", err)
}
beforeCIDRs = fetched
log.L().Info("before-proxy CIDR lists ready", "total_cidrs", len(beforeCIDRs))
sm.stats.SetConnectStep("")
}
backoff := time.Second
maxBackoff := 60 * time.Second
@@ -125,6 +218,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
for {
if ctx.Err() != nil {
sm.cleanup()
return
}
@@ -133,7 +227,7 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
targetIP = targets[ipIndex]
}
err := sm.connectOnce(ctx, cfg, targetIP)
err := sm.connectOnce(ctx, cfg, targetIP, beforeCIDRs)
if ctx.Err() != nil {
sm.cleanup()
return
@@ -141,6 +235,53 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
if err != nil {
log.L().Error("VPN connection failed", "error", err)
// Safety net: ensure no TUN/routes leak from a failed
// attempt. connectOnce should have already cleaned up, but
// this guards against any path that returned early.
sm.cleanupResources()
// A TLS certificate verification failure on the original
// hostname (ipIndex == 0) is not retryable: the cert won't
// change between attempts, so stop the loop and surface the
// reason to the user. On a CDN edge IP (ipIndex > 0) the
// TLS error likely means that IP points to a different
// server; skip it and try the next target.
if tlsconfig.IsTLSError(err) {
if ipIndex == 0 {
log.L().Warn("fatal TLS error, stopping reconnect", "error", err)
sm.setState(stats.StateError)
if sm.onError != nil {
sm.onError("tls_error", err.Error())
}
fatal = true
sm.cleanup()
return
}
log.L().Warn("TLS error on CDN IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "error", err)
}
// A fatal authentication failure (wrong password, disabled
// account, expired token, rate limit) on the original
// hostname is not retryable. On a CDN edge IP it likely
// means the IP points to a different server that returned
// 401/403, so skip it instead of stopping the loop.
if code, msg, isFatal := fatalAuthError(err); isFatal {
if ipIndex == 0 {
log.L().Warn("fatal auth error, stopping reconnect", "code", code, "message", msg)
sm.setState(stats.StateError)
if sm.onError != nil {
sm.onError(string(code), msg)
}
fatal = true
sm.cleanup()
return
}
log.L().Warn("auth error on CDN IP, skipping",
"index", ipIndex, "ip", targets[ipIndex], "code", code)
}
sm.setState(stats.StateReconnecting)
// Try next CDN IP immediately.
@@ -169,10 +310,48 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
}
}
// fatalAuthError inspects err and, if it represents a permanent
// authentication failure that should not be retried, returns the
// stable error code, the raw server message, and true. It recognises
// all three auth-failure shapes produced by the transport/auth layers:
// - *transport.AuthError (WebSocket auth_err at the auth stage)
// - *transport.ServerError (auth_err at the init stage, JWT path)
// - *auth.LoginError (HTTP /api/login failure, JWT path)
//
// errors.As transparently unwraps fmt.Errorf("...: %w", err) chains,
// so the wrapped LoginError returned by connectOnce is matched too.
// A non-empty code is required: an auth_err with an unrecognized
// message is treated as non-fatal so the loop falls back to retrying
// (the server still closed the connection, but we lack a categorical
// reason to give up).
func fatalAuthError(err error) (protocol.AuthErrorCode, string, bool) {
var authErr *transport.AuthError
if errors.As(err, &authErr) {
return authErr.Code, authErr.Message, authErr.Code != ""
}
var serverErr *transport.ServerError
if errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr {
return serverErr.Code, serverErr.Message, serverErr.Code != ""
}
var loginErr *auth.LoginError
if errors.As(err, &loginErr) {
switch loginErr.Code {
case http.StatusTooManyRequests:
return protocol.AuthCodeRateLimited, loginErr.Message, true
case http.StatusUnauthorized, http.StatusForbidden:
return protocol.AuthCodeWrongCredentials, loginErr.Message, true
}
}
return "", "", false
}
// connectOnce performs a single connection lifecycle: authenticate,
// handshake, configure TUN, apply routes, pump packets until failure.
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string) error {
// beforeCIDRs contains CIDRs pre-fetched from "before" timing URL
// sources (fetched once in run(), reused across reconnection attempts).
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, targetIP string, beforeCIDRs []string) error {
sm.setState(stats.StateConnecting)
sm.stats.SetConnectStep("connecting")
// Build URL for this attempt. If targetIP is set (CDN failover),
// build a URL with that IP. Otherwise use base ServerURL.
@@ -181,6 +360,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 +389,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
if err != nil {
return fmt.Errorf("parse server URL: %w", err)
}
result, err := auth.Login(httpBase, cfg.Username, cfg.Password)
result, err := auth.Login(ctx, httpBase, cfg.Username, cfg.Password, tlsCfg)
if err != nil {
if cfg.AuthMode == model.AuthModeBoth {
// Fall back to password auth.
@@ -210,8 +411,9 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
Username: cfg.Username,
Password: cfg.Password,
OnInit: func(init protocol.InitMessage) error {
return sm.setupTUN(init, cfg)
return sm.setupTUN(init, cfg, beforeCIDRs)
},
TLSConfig: tlsCfg,
}
// Attempt JWT connection first; fall back to password on auth error.
@@ -225,11 +427,19 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
if errors.As(err, &authErr) ||
(errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr) {
log.L().Info("JWT auth failed, falling back to password auth", "error", err)
// Clean up any resources created by the failed attempt's
// setupTUN before retrying.
sm.cleanupResources()
handshake.Token = ""
conn, err = transport.Connect(ctx, handshake)
}
}
if err != nil {
// Clean up TUN/routes if setupTUN ran but the handshake
// failed (e.g. server closed the connection after
// ReadyTimeout). Without this, leaked /1 cover routes
// blackhole all traffic and prevent reconnection.
sm.cleanupResources()
return err
}
}
@@ -238,16 +448,39 @@ 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.stats.SetConnectStep("load_routes")
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.
statsDone := make(chan struct{})
go sm.reportStats(statsDone, ctx)
// Apply deferred routes (user CIDRs) and fetch after-proxy CIDR
// lists in a background goroutine. For proxy/bypass modes with
// thousands of CIDRs this uses batch script execution (~3-5s).
go func() {
sm.mu.Lock()
mgr := sm.routeMgr
sm.mu.Unlock()
if mgr != nil {
sm.stats.SetRouteLoading(true)
sm.stats.SetConnectStep("load_routes")
log.L().Info("applying deferred routes")
if err := mgr.ApplyDeferred(); err != nil {
log.L().Error("apply deferred routes failed (continuing)", "error", err)
}
sm.stats.SetRouteLoading(false)
sm.stats.SetConnectStep("")
log.L().Info("deferred routes applied")
}
sm.fetchAfterProxyCIDRs(ctx, cfg)
}()
// Run the packet pump (blocks until connection breaks).
sm.pumpPackets(ctx, conn)
@@ -258,8 +491,12 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta
// setupTUN creates and configures the TUN device and applies routes.
// This is called by the transport during the handshake, between init
// and ready.
func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) error {
// and ready. It performs NO network calls (HTTP/DNS) so it completes
// in milliseconds and never exceeds the server's ReadyTimeout.
//
// beforeCIDRs contains CIDRs fetched from "before" timing URL sources,
// pre-fetched by connectOnce before the handshake began.
func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig, beforeCIDRs []string) error {
dev, err := tun.Create("")
if err != nil {
return fmt.Errorf("create tun: %w", err)
@@ -271,43 +508,176 @@ func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig)
localIP := net.ParseIP(init.IP)
peerIP := net.ParseIP(init.ServerIP)
if localIP == nil || peerIP == nil {
dev.Close()
return fmt.Errorf("invalid init IPs: %s / %s", init.IP, init.ServerIP)
}
if err := dev.Configure(localIP, init.Prefix, peerIP); err != nil {
dev.Close()
return fmt.Errorf("configure tun: %w", err)
}
// Configure IPv6 address when the server assigned one (dual-stack).
hasV6 := init.IP6 != ""
if hasV6 {
ip6 := net.ParseIP(init.IP6)
if ip6 == nil {
return fmt.Errorf("invalid init IPv6: %s", init.IP6)
}
if err := dev.ConfigureIPv6(ip6, init.Prefix6); err != nil {
return fmt.Errorf("configure tun ipv6: %w", err)
}
}
mtu := init.MTU
if cfg.MTUOverride > 0 {
mtu = cfg.MTUOverride
}
if err := dev.SetMTU(mtu); err != nil {
dev.Close()
return fmt.Errorf("set mtu: %w", err)
}
// Merge static CIDRs with pre-fetched before-proxy CIDRs.
var cidrs []string
cidrs = append(cidrs, cfg.CIDRV4...)
cidrs = append(cidrs, cfg.CIDRV6...)
cidrs = append(cidrs, beforeCIDRs...)
// Apply routing.
routeCfg := route.Config{
Mode: cfg.RoutingMode,
InterfaceName: dev.Name(),
VPNIP: init.IP,
VPNPrefix: init.Prefix,
VPNIP6: init.IP6,
VPNPrefix6: init.Prefix6,
ServerHost: serverHostFromURL(cfg.ServerURL),
CustomCIDRs: cfg.CustomCIDRs,
CIDRs: cidrs,
}
sm.routeMgr = route.NewManager(routeCfg)
if err := sm.routeMgr.Apply(); err != nil {
log.L().Error("route apply failed (continuing)", "error", err)
}
// Initialise the CIDR hit tracker for proxy/bypass modes.
sm.cidrTracker = newCIDRTracker(cfg.RoutingMode, cidrs)
// Log CIDR breakdown by family for diagnostics.
v4Total, _, v6Total, _ := sm.cidrTracker.Stats()
log.L().Info("TUN configured",
"dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix, "mtu", mtu)
"dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix,
"ip6", init.IP6, "prefix6", init.Prefix6, "mtu", mtu,
"routing_mode", cfg.RoutingMode,
"cidr_v4", v4Total, "cidr_v6", v6Total,
"before_proxy_cidrs", len(beforeCIDRs))
return nil
}
// fetchAfterProxyCIDRs fetches CIDR lists from URLs with "after" timing
// (via the tunnel) and dynamically adds their routes to the route
// manager. This is called in a goroutine after the data plane is up.
func (sm *SessionManager) fetchAfterProxyCIDRs(ctx context.Context, cfg SessionConfig) {
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
// Count only "after" sources for logging.
afterCount := 0
for _, s := range allURLSources {
if s.FetchTiming == model.FetchAfter {
afterCount++
}
}
if afterCount == 0 {
return
}
sm.stats.SetRouteLoading(true)
sm.stats.SetConnectStep("fetch_cidrs")
log.L().Info("fetching after-proxy CIDR lists", "url_count", afterCount)
fetched, err := cidrsource.FetchAfterProxy(ctx, allURLSources)
sm.stats.SetConnectStep("load_routes")
if err != nil {
log.L().Error("fetch after-proxy CIDR lists completed with errors", "error", err)
sm.stats.SetCIDRError(err.Error())
} else {
sm.stats.SetCIDRError("")
}
if len(fetched) == 0 {
sm.stats.SetRouteLoading(false)
sm.stats.SetConnectStep("")
return
}
merged := fetched // AddRoutes already calls mergeCIDRs internally
added := sm.addCIDRRoutes(fetched)
if added > 0 {
log.L().Info("added after-proxy routes", "fetched", len(fetched), "added", added, "merged", len(merged))
}
sm.stats.SetRouteLoading(false)
sm.stats.SetConnectStep("")
}
// addCIDRRoutes adds routes and updates the CIDR tracker. Returns the
// number of CIDRs successfully added (after merge).
func (sm *SessionManager) addCIDRRoutes(cidrs []string) int {
sm.mu.Lock()
mgr := sm.routeMgr
tracker := sm.cidrTracker
sm.mu.Unlock()
if mgr == nil {
return 0
}
if err := mgr.AddRoutes(cidrs); err != nil {
log.L().Error("add CIDR routes failed (continuing)", "error", err)
}
if tracker != nil {
tracker.AddCIDRs(cidrs)
}
return len(cidrs)
}
// RefreshCIDRs re-fetches all CIDR URL sources (both before and after
// timing) via the current tunnel and dynamically adds their routes.
// This is called when the user clicks the "Refresh CIDR" button.
func (sm *SessionManager) RefreshCIDRs() {
sm.mu.Lock()
ctx := sm.cancel
cfg := sm.lastCfg
sm.mu.Unlock()
if ctx == nil {
return
}
// Use a background context with 30s timeout so the refresh works
// even if the session context is in a weird state.
refreshCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = refreshCtx
allURLSources := append(append([]model.CIDRURLSource{}, cfg.CIDRV4URLs...), cfg.CIDRV6URLs...)
if len(allURLSources) == 0 {
return
}
sm.stats.SetRouteLoading(true)
sm.stats.SetCIDRError("")
log.L().Info("refreshing CIDR lists", "url_count", len(allURLSources))
fetched, err := cidrsource.FetchAfterProxy(refreshCtx, allURLSources)
if err != nil {
log.L().Error("refresh CIDR lists completed with errors", "error", err)
sm.stats.SetCIDRError(err.Error())
} else {
sm.stats.SetCIDRError("")
}
if len(fetched) == 0 {
sm.stats.SetRouteLoading(false)
return
}
added := sm.addCIDRRoutes(fetched)
log.L().Info("refreshed CIDR routes", "fetched", len(fetched), "added", added)
sm.stats.SetRouteLoading(false)
}
// pumpPackets runs the bidirectional packet loop until the connection
// breaks.
func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) {
@@ -318,6 +688,12 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
go func() {
defer wg.Done()
buf := make([]byte, 65536)
// Cache the cidrTracker pointer once; it is stable for the
// lifetime of pumpPackets (set in setupTUN, cleared in cleanup
// which only runs after pumpPackets returns).
sm.mu.Lock()
tracker := sm.cidrTracker
sm.mu.Unlock()
for {
select {
case <-ctx.Done():
@@ -341,7 +717,10 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
}
return
}
sm.stats.TxBytes.Add(int64(n))
sm.stats.AddTx(buf[:n])
if tracker != nil {
tracker.Record(buf[:n])
}
}
}()
@@ -368,15 +747,19 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn)
conn.Close()
return
}
sm.stats.RxBytes.Add(int64(len(data)))
sm.stats.AddRx(data)
}
}()
wg.Wait()
}
// cleanup tears down the TUN device and routes.
func (sm *SessionManager) cleanup() {
// cleanupResources tears down the TUN device, routes, and transport
// connection WITHOUT changing the session state. This is used on
// handshake-failure paths where the caller will set the appropriate
// state (e.g. StateReconnecting). Returns without error if nothing
// was set up.
func (sm *SessionManager) cleanupResources() {
sm.mu.Lock()
dev := sm.dev
routeMgr := sm.routeMgr
@@ -384,6 +767,7 @@ func (sm *SessionManager) cleanup() {
sm.dev = nil
sm.routeMgr = nil
sm.conn = nil
sm.cidrTracker = nil
sm.mu.Unlock()
if routeMgr != nil {
@@ -397,6 +781,12 @@ func (sm *SessionManager) cleanup() {
if conn != nil {
conn.Close()
}
}
// cleanup tears down the TUN device, routes, and transport, then marks
// the session as disconnected. Used on normal session termination.
func (sm *SessionManager) cleanup() {
sm.cleanupResources()
sm.stats.SetDisconnected()
}
@@ -428,9 +818,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 +834,63 @@ func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context)
case <-ctx.Done():
return
case <-ticker.C:
if sm.onStats != nil {
sm.onStats(sm.stats.Snapshot())
if sm.onStats == nil {
continue
}
snap := sm.stats.Snapshot()
now := time.Now()
if sm.speedReady {
elapsed := now.Sub(sm.prevTick).Seconds()
if elapsed <= 0 {
elapsed = 1
}
// Per-second deltas (bytes/sec), clamped to >= 0 in case
// of counter resets between reconnects within the same
// SessionManager lifetime.
rxV4 := max(0.0, float64(snap.RxBytesV4-sm.prevSnap.RxBytesV4)/elapsed)
txV4 := max(0.0, float64(snap.TxBytesV4-sm.prevSnap.TxBytesV4)/elapsed)
rxV6 := max(0.0, float64(snap.RxBytesV6-sm.prevSnap.RxBytesV6)/elapsed)
txV6 := max(0.0, float64(snap.TxBytesV6-sm.prevSnap.TxBytesV6)/elapsed)
if sm.ewmaRxV4 == 0 && sm.ewmaTxV4 == 0 && sm.ewmaRxV6 == 0 && sm.ewmaTxV6 == 0 {
// First real sample: seed instead of ramping from 0.
sm.ewmaRxV4, sm.ewmaTxV4, sm.ewmaRxV6, sm.ewmaTxV6 = rxV4, txV4, rxV6, txV6
} else {
sm.ewmaRxV4 = sm.ewmaRxV4*(1-ewmaAlpha) + rxV4*ewmaAlpha
sm.ewmaTxV4 = sm.ewmaTxV4*(1-ewmaAlpha) + txV4*ewmaAlpha
sm.ewmaRxV6 = sm.ewmaRxV6*(1-ewmaAlpha) + rxV6*ewmaAlpha
sm.ewmaTxV6 = sm.ewmaTxV6*(1-ewmaAlpha) + txV6*ewmaAlpha
}
snap.RxSpeedV4 = int64(sm.ewmaRxV4)
snap.TxSpeedV4 = int64(sm.ewmaTxV4)
snap.RxSpeedV6 = int64(sm.ewmaRxV6)
snap.TxSpeedV6 = int64(sm.ewmaTxV6)
snap.RxSpeed = snap.RxSpeedV4 + snap.RxSpeedV6
snap.TxSpeed = snap.TxSpeedV4 + snap.TxSpeedV6
}
sm.prevSnap = snap
// Clear speed fields on the stored prev copy so we don't
// accidentally carry stale speed into the next delta base
// (only cumulative bytes matter for deltas).
sm.prevSnap.RxSpeedV4, sm.prevSnap.TxSpeedV4 = 0, 0
sm.prevSnap.RxSpeedV6, sm.prevSnap.TxSpeedV6 = 0, 0
sm.prevSnap.RxSpeed, sm.prevSnap.TxSpeed = 0, 0
sm.prevTick = now
sm.speedReady = true
// Fill in CIDR hit statistics.
sm.mu.Lock()
tracker := sm.cidrTracker
sm.mu.Unlock()
if tracker != nil {
v4Total, v4Hits, v6Total, v6Hits := tracker.Stats()
snap.RoutingMode = string(tracker.mode)
snap.CIDRV4Total = v4Total
snap.CIDRV4Hits = v4Hits
snap.CIDRV6Total = v6Total
snap.CIDRV6Hits = v6Hits
}
sm.onStats(snap)
}
}
}
@@ -478,7 +928,14 @@ func wsURLToHTTP(wsURL string) (string, error) {
// replaceHost substitutes the host portion of a URL string.
// e.g. wss://host:443/ws with 1.2.3.4 → wss://1.2.3.4:443/ws
// Bare IPv6 addresses are automatically bracketed:
// wss://host:443/ws with 2001:db8::1 → wss://[2001:db8::1]:443/ws
func replaceHost(rawURL, newHost string) string {
// Auto-bracket bare IPv6 addresses so the colons in the address
// are not confused with the port separator.
if ip := net.ParseIP(newHost); ip != nil && ip.To4() == nil && !strings.HasPrefix(newHost, "[") {
newHost = "[" + newHost + "]"
}
u := rawURL
for _, prefix := range []string{"wss://", "ws://"} {
if len(u) > len(prefix) && u[:len(prefix)] == prefix {
@@ -493,3 +950,175 @@ func replaceHost(rawURL, newHost string) string {
}
return rawURL
}
// cidrTracker tracks which configured CIDRs have been "hit" by
// outbound traffic (TUN -> WebSocket). The behaviour differs by mode:
//
// - Proxy mode: each outbound packet's destination IP is matched
// against the CIDR list; the first matching CIDR is marked as hit.
// Stats() returns the count of hit CIDRs vs total CIDRs.
// - Bypass mode: outbound traffic through TUN is, by definition,
// traffic that did NOT match any bypass CIDR (bypassed traffic
// goes via the physical NIC). We count distinct destination
// /24 (v4) or /48 (v6) prefixes seen on TUN as "unmatched
// destinations". Stats() returns the total bypass CIDR count
// and the unmatched destination count.
type cidrTracker struct {
mode route.Mode
mu sync.RWMutex
// Proxy mode: pre-parsed CIDR nets + per-CIDR hit flags.
// Protected by mu for concurrent AddCIDRs (write) vs Record/Stats
// (read). The atomic.Bool hit flags are individually atomic, but
// the slices themselves need the lock.
v4CIDRs []*net.IPNet
v6CIDRs []*net.IPNet
v4Hits []atomic.Bool
v6Hits []atomic.Bool
// Bypass mode: distinct destination prefix sets.
// v4 key = first 3 bytes of dest IP (a /24 prefix).
// v6 key = first 6 bytes of dest IP (a /48 prefix).
// sync.Map is already goroutine-safe; no lock needed.
v4Prefixes sync.Map
v6Prefixes sync.Map
v4Count atomic.Int64
v6Count atomic.Int64
}
// newCIDRTracker creates a tracker for the given routing mode and CIDR
// list. For full-tunnel mode, a tracker is still created but will
// report zero totals (no CIDRs configured).
func newCIDRTracker(mode route.Mode, cidrs []string) *cidrTracker {
t := &cidrTracker{mode: mode}
if mode == route.ModeFull {
return t
}
t.addCIDRs(cidrs)
return t
}
// AddCIDRs appends additional CIDRs to the tracker (used for
// after-proxy fetched CIDRs). Existing hit flags are preserved.
func (t *cidrTracker) AddCIDRs(cidrs []string) {
t.addCIDRs(cidrs)
}
func (t *cidrTracker) addCIDRs(cidrs []string) {
t.mu.Lock()
defer t.mu.Unlock()
for _, cidrStr := range cidrs {
cidrStr = strings.TrimSpace(cidrStr)
if cidrStr == "" {
continue
}
_, ipNet, err := net.ParseCIDR(cidrStr)
if err != nil {
continue
}
if ipNet.IP.To4() != nil {
t.v4CIDRs = append(t.v4CIDRs, ipNet)
t.v4Hits = append(t.v4Hits, atomic.Bool{})
} else {
t.v6CIDRs = append(t.v6CIDRs, ipNet)
t.v6Hits = append(t.v6Hits, atomic.Bool{})
}
}
}
// Record inspects an outbound IP packet (from TUN) and updates hit
// counters. It is called from the TUN -> WebSocket goroutine for every
// packet. Packets too short to contain an IP header or with an unknown
// version are silently ignored.
func (t *cidrTracker) Record(p []byte) {
if len(p) < 1 {
return
}
switch p[0] >> 4 {
case 4:
if len(p) < 20 {
return
}
dst := net.IP(p[16:20])
t.recordV4(dst)
case 6:
if len(p) < 40 {
return
}
dst := net.IP(p[24:40])
t.recordV6(dst)
}
}
func (t *cidrTracker) recordV4(dst net.IP) {
switch t.mode {
case route.ModeProxy:
t.mu.RLock()
defer t.mu.RUnlock()
for i, cidr := range t.v4CIDRs {
if !t.v4Hits[i].Load() && cidr.Contains(dst) {
t.v4Hits[i].Store(true)
}
}
case route.ModeBypass:
key := string(dst.To4()[:3])
if _, loaded := t.v4Prefixes.LoadOrStore(key, struct{}{}); !loaded {
t.v4Count.Add(1)
}
}
}
func (t *cidrTracker) recordV6(dst net.IP) {
switch t.mode {
case route.ModeProxy:
t.mu.RLock()
defer t.mu.RUnlock()
for i, cidr := range t.v6CIDRs {
if !t.v6Hits[i].Load() && cidr.Contains(dst) {
t.v6Hits[i].Store(true)
}
}
case route.ModeBypass:
v6 := dst.To16()
if len(v6) < 6 {
return
}
key := string(v6[:6])
if _, loaded := t.v6Prefixes.LoadOrStore(key, struct{}{}); !loaded {
t.v6Count.Add(1)
}
}
}
// Stats returns the total and hit counts for IPv4 and IPv6 CIDRs.
//
// For proxy mode: "hits" = number of CIDRs that have been matched by
// at least one outbound packet.
// For bypass mode: "hits" = number of distinct destination prefixes
// seen on TUN (i.e. unmatched destinations that went through the
// tunnel because they didn't match any bypass CIDR).
func (t *cidrTracker) Stats() (v4Total, v4Hits, v6Total, v6Hits int) {
t.mu.RLock()
defer t.mu.RUnlock()
switch t.mode {
case route.ModeProxy:
v4Total = len(t.v4CIDRs)
for i := range t.v4Hits {
if t.v4Hits[i].Load() {
v4Hits++
}
}
v6Total = len(t.v6CIDRs)
for i := range t.v6Hits {
if t.v6Hits[i].Load() {
v6Hits++
}
}
case route.ModeBypass:
v4Total = len(t.v4CIDRs)
v4Hits = int(t.v4Count.Load())
v6Total = len(t.v6CIDRs)
v6Hits = int(t.v6Count.Load())
}
return
}
+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.